이번 포스팅에서는 Redisson을 사용하여 분산락을 구현해보는 시간을 가지도록 하겠습니다.
동일한 자원에 대해 여러 스레드가 동시에 접근하면서 발생하는 동시성 문제를 해결하는 방법 중 하나가 바로 분산락입니다.
즉, 경쟁 상황에서 하나의 공유자원에 접근할 때, 데이터의 결함이 발생하지 않도록 원자성(atomic
)을 보장하는 기법입니다.
분산락은 특정 자원에 대해 락을 요청하고, 락을 성공적으로 획득한 프로세스만 자원에 접근하도록 합니다. 자원에 대한 작업이 완료되면 해당 락을 해제하여 다른 프로세스가 자원에 접근할 수 있도록 합니다.
Redis
에서 분산락을 구현하기 위해 다양한 구현체를 제공하는데(Ex Jedis
, Lettuce
, Redisson
등) 그 중 하나가 바로 Redisson
입니다.
Redisson
은 Java
에서 사용되는 Redis
클라이언트입니다. Redisson
은 비교적 합리적인 방식으로 Lock
획득 재시도 기능이 구현되어있습니다.
Lettuce
는 스핀락이라고 불리는 일종의 폴링 기법을 활용해서 Lock
획득을 재시도하는 한편 Redisson
은 Redis PUB/SUB
기능을 사용해서 Lock
획득을 재시도합니다.
즉, Lock
획득에 실패하면, Redisson
은 특정 채널을 구독하고, Lock
이 다시 획득할 수 있는 상태가 됐다는 이벤트를 받았을 때, 다시 Lock
획득을 시도합니다. 결과적으로 Lock
이 획득될 때까지 계속 Lock
획득을 요청하는 Lettuce
보다 효율적이고, Redis
서버에도 부하를 덜 주는 방법이라고 볼 수 있습니다.
이번 EverTrip
프로젝트에서 게시글의 조회수 증가 로직에 분산락을 적용해보도록 하겠습니다.
dependencies {
...
// Redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.16.0'
}
Redisson
스타터 라이브러리를 의존성 받아옵니다.
@Configuration
public class RedissonConfig {
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient(@Value("${spring.data.redis.host}") String host,
@Value("${spring.data.redis.port}") String port,
@Value("${spring.data.redis.password}") String password) {
Config config = new Config();
config.useSingleServer()
.setAddress(REDISSON_HOST_PREFIX + host + ":" + port)
.setPassword(password);
return Redisson.create(config);
}
}
Redis
서버의 호스트 주소, 포트 번호, 비밀번호를 설정한 RedissonClient
를 스프링 빈으로 등록해줍니다.
public PostResponseDto getPostDetailV2(Long postId, Long memberId) {
// 레디스에 해당 post가 존재할 시 레디스 정보를 넘겨주고 없을 시 실제 DB 조회 후 레디스에 저장
PostResponseDto postDetail = postCacheService.getPostDetailUsingCacheable(postId);
// 조회수(제일 최신)는 Redis에서 조회해서 postDetail의 조회수에 넣어줍니다.
Long views = postCacheService.getViews(postId).longValue();
// 방문자 리스트에 해당 사용자가 존재하지 않을 시 방문자 리스트에 추가해주고 조회수 1 증가 시켜주기
if (!redisForCacheService.isMember(ConstantPool.CacheName.VIEWERS + ":" + postId, memberId.toString())) {
// Redis에 방문자 명단 추가
redisForCacheService.addToset(ConstantPool.CacheName.VIEWERS + ":" + postId, memberId.toString());
// Redisson 락킹 적용
String lockKey = "lock:viewCount:"+postId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 락을 얻기 위한 재시도(Lock 획득을 시도하는 최대 시간, 락을 획득한 후 점유하는 최대 시간, 시간 단위)
if (lock.tryLock(5, 2, TimeUnit.SECONDS)) {
try {
// 수동으로 cacheManager를 통해 redis에 조회수 +1 증가 시켜주기
String viewsCacheKey = postId.toString();
Cache viewsCache = cacheManager.getCache(ConstantPool.CacheName.VIEWS);
Cache.ValueWrapper valueWrapper = viewsCache.get(postId.toString());
Long currentViews = ((Integer) valueWrapper.get()).longValue();
viewsCache.put(viewsCacheKey, currentViews + 1L);
views = currentViews+1L;
} finally {
lock.unlock();
}
} else {
// 락을 얻지 못한 경우 처리
log.warn("Could not acquire lock for key: {}", lockKey);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Could not acquire lock for key: {}",lockKey);
throw new ApplicationException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
postDetail.setView(views);
return postDetail;
}
방문자 리스트에 조회를 하는 사용자가 존재하지 않을 시 방문자 리스트에 추가하고 조회수를 1 증가시키는 부분에 Redisson
락킹을 적용한 코드입니다.
게시글의 id를 사용하여 lockKey
를 생성하고 RedissonClient
의 getLock
메서드를 통해 RedissonLock
객체를 얻어옵니다. 해당 Lock
객체의 tryLock
, unlock
메서드를 사용하여 락을 획득, 해제할 수 있습니다.
tryLock
메서드의 파라미터는 Lock
획득을 시도하는 최대 시간, Lock
을 획득한 후 점유하는 최대 시간, 시간 단위를 넣어줍니다.
락이 걸려있는 동안 다른 사용자는 해당 락이 해제될 때까지 대기하며 락 획득 재시도를 하게 되므로 동시성 문제를 제어할 수 있게 됩니다.
분산락이 제대로 적용됐는지 테스트를 해보도록 하겠습니다.
@SpringBootTest
@Transactional
public class PostServiceTest {
@Autowired
private PostService postService;
@Autowired
private RedisForCacheService redisForCacheService;
@Autowired
private PostCacheService postCacheService;
@Autowired
private CacheManager cacheManager;
private Long postId;
@BeforeEach
public void setUp() {
postId = 1L;
// 초기 조회수 설정
Cache viewsCache = cacheManager.getCache(ConstantPool.CacheName.VIEWS);
if (viewsCache != null) {
viewsCache.put(postId.toString(), 0L);
}
// 초기 방문자 리스트 비우기
redisForCacheService.deleteSet(ConstantPool.CacheName.VIEWERS + ":" + postId);
}
// @AfterEach
// public void tearDown() {
// postId = 1L;
//
// // 조회수 삭제
// Cache viewsCache = cacheManager.getCache(ConstantPool.CacheName.VIEWS);
// viewsCache.evict(postId.toString());
//
//
// // 방문자 리스트 비우기
// redisForCacheService.deleteSet(ConstantPool.CacheName.VIEWERS + ":" + postId);
// }
@Test
public void testConcurrentViewCountIncrement() throws InterruptedException, ExecutionException, TimeoutException {
int numberOfThreads = 100; // 동시 요청 스레드 수
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
// 여러 스레드에서 동시에 요청 실행
Future<Void>[] futures = new Future[numberOfThreads];
for (int i = 0; i < numberOfThreads; i++) {
int memberId = i + 1;
Callable<Void> task = () -> {
postService.getPostDetailV2(postId, (long) memberId);
return null;
};
futures[i] = executorService.submit(task);
}
// 결과 대기 및 확인
for (Future<Void> future : futures) {
future.get(5, TimeUnit.SECONDS);
}
executorService.shutdown();
// 최종 조회수 확인
Long finalViews = postCacheService.getViews(postId).longValue();
assertEquals(numberOfThreads, finalViews); // 모든 요청이 들어와서 조회수가 정확히 증가했는지 확인
}
}
100개의 스레드에서 동시에 postService.getPostDetailV2(postId, (long) memberId)
메서드를 호출하여 테스트를 진행하였습니다.
테스트 결과 정상적으로 동작하는 것을 확인할 수 있었습니다. Redis
에 있는 방문자 리스트와 조회수도 정상인지 확인해보겠습니다.
조회수는 100, 방문자 리스트는 1부터 100까지 정상적으로 들어와있는 것을 확인할 수 있었습니다.
이번 포스팅에서는 Redisson
을 활용한 분산락을 적용해보았습니다. 개인적으로 동시성 문제를 해결하는 것은 매우 중요하다고 생각하기에 이번 포스팅을 작성하며 매우 유익한 시간을 가졌던 것 같습니다.
참고 자료
Jan92님 블로그의 Redisson 분산락을 사용하는 이유와 기본적인 사용 방법 편
Kai님 블로그의 [Spring] Redis(Redisson) 분산락을 활용하여 동시성 문제 해결하기 편