보통 예매를 하는 애플리케이션을 보면 좌석을 선택하고 예매를 합니다. 좌석을 선택하게 되면 다른 이용자는 좌석을 선택을 하지 못하게 됩니다. 이번 프로젝트에서도 이 플로우를 따라 갑니다. 먼저, 이용자가 좌석을 선택을 하고 예매를 합니다. 근데 여기서 한 가지 주의를 해야 할 상황이 있습니다. 만약 두 명의 이용자가 같은 좌석을 예매를 하려고 한다면 어떻게 될까요? 한 좌석의 한 명만 예약이 가능한데 두 명이 한 좌석을 예매를 하는 경우가 생길 것입니다. 그래서 좌석을 선택을 할 때, 동시성을 제어를 해주어야 합니다.
이전 포스트에서 낙관적 락, 비관적 락, Redis의 원자성 명령어 등 동시성을 제어하는 방법들에 대해서 알아보았습니다. 이번 좌석을 선택하는 기능에서는 Redis의 Redisson을 작용하고자 합니다. 그 이유는 먼저, 프로젝트가 Scale Out을 선택했습니다. 그래서 서버가 분산이 되어 있는 상황입니다. 그리고 티켓팅을 하는 날짜이먄 트래픽이 많아 집니다. 분산 환경에서는 Redisson이 최적화가 되어 있습니다. 물론 Redis를 설치하고 관리를 해야 하지만 성능과 적합성을 보았을 때 Redisson을 사용하는 것이 더 좋다고 판단을 했습니다.
Redisson이 분산 락을 어떻게 구현을 하는 지 간단하게 알아보겠습니다.
Redisson의 경우 pub-sub 기반으로 Lock을 구현을 합니다. Lock을 관리하기 위한 채널을 생성을 하고 Lock을 점유하고 있는 스레드가 작업을 마치고 Lock을 반환을 하면 채널을 통해서 Lock을 점유하기 위해서 대기하고 있는 스레드에게 전달을 합니다. 전달을 받은 스레드들은 Lock을 점유하기 위해서 시도를 합니다.

이제 Redisson을 프로젝트에 적용을 해서 분산 락을 구현을 하겠습니다.
먼저, Redisson의 의존성 추가와 Config로 bean 등록을 해줍니다.
implementation 'org.redisson:redisson-spring-boot-starter:3.20.0'
의존성을 추가를 해줍니다.
@Configuration
public class RedissonConfig {
@Bean
@Profile("!test")
public RedissonClient redisson() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://my-cache-server:6379")
.setConnectionMinimumIdleSize(10)
.setConnectionPoolSize(10);
return Redisson.create(config);
}
}
Redisson을 Bean등록을 해줍니다. 이때 저는 docker를 사용하고 있기 때문에 docker의 redis이미지 이름으로 넣어주었습니다.
@Service
@RequiredArgsConstructor
public class SeatGradeService {
private final RedissonClient redissonClient;
@Transactional
public SeatEntity selectSeat(Long seatId) {
String lockKey = "seat-lock:" + seatId;
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
try {
isLocked = lock.tryLock(1, 300, TimeUnit.SECONDS);
if (!isLocked) {
throw new GlobalCommonException(SeatErrorResponsive.FILL_SEAT);
}
SeatEntity seatEntity = getSeatById(seatId);
if (seatEntity.getSeatStatus() == SeatStatus.SELECT) {
throw new GlobalCommonException(SeatErrorResponsive.FILL_SEAT);
}
seatEntity.updateSeatStatus(SeatStatus.SELECT);
return seatRepository.save(seatEntity);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Thread was interrupted while waiting for lock", e);
} finally {
if (isLocked) {
lock.unlock();
}
}
}
}
이제 Redisson을 통해서 동시성 제어를 해줍니다.
String lockKey = "seat-lock:" + seatId;
RLock lock = redissonClient.getLock(lockKey);
먼저, lockKey를 통해서 하나의 좌석에 대한 고유한 lock을 만들어 줍니다. 이 lock을 RLock에 넘겨주어서 Redis 서버와 연결을 해주는 분산 락 객체를 만들어줍니다.
isLocked = lock.tryLock(1, 300, TimeUnit.SECONDS);
lock의 tryLock을 통해서 lock을 얻는 시도를 합니다. 이 때, tryLock에는 3개의 파라미터가 들어가는데 첫 번째 파라미터는 lock을 얻기 위해서 몇초동안 시도할 지 초를 정해주는 것입니다. 두 번째는 lock을 획득을 하게 되면 300초동안 가지고 있다는 것을 의미합니다. 그리고 마지막 파라미터는 시간의 단위를 설정을 해주는 것입니다.
if (!isLocked) {
throw new GlobalCommonException(SeatErrorResponsive.FILL_SEAT);
}
만약 lock을 이미 다른 스레드가 lock을 얻어서 lock을 얻기 실패하기 때문에 좌석이 이미 선택되었다는 에러를 반환을 합니다.
SeatEntity seatEntity = getSeatById(seatId);
if (seatEntity.getSeatStatus() == SeatStatus.SELECT) {
throw new GlobalCommonException(SeatErrorResponsive.FILL_SEAT);
}
seatEntity.updateSeatStatus(SeatStatus.SELECT);
return seatRepository.save(seatEntity);
그리고 좌석을 찾아서 SeatStatus의 값을 변경을 해줘서 선택을 완료 할 수 있도록합니다.
if (isLocked) {
lock.unlock();
}
그리고 lock을 해제를 해줍니다.
이제 테스트 코드를 작성을 해서 동시성이 잘 제어가 되는 지 살펴보겠습니다.
@Test
void testConcurrentSeatSelection() throws InterruptedException {
RLock mockLock = mock(RLock.class);
BDDMockito.given(redissonClient.getLock(any(String.class))).willReturn(mockLock);
BDDMockito.given(mockLock.tryLock(any(Long.class), any(Long.class), any(TimeUnit.class)))
.willReturn(true));
BDDMockito.doNothing().when(mockLock).unlock();
EventLocationEntity eventLocationEntity = getEventLocationEntity(1L, "서울 월드컵 경기장");
SeatGradeEntity seatGradeEntity = getSeatGradeEntity(1L, eventLocationEntity);
SeatEntity seatEntity = CommonTestFixture.getSeatEntity(1L, seatGradeEntity);
BDDMockito.given(seatRepository.findById(any(Long.class)))
.willReturn(Optional.of(seatEntity));
BDDMockito.given(seatRepository.save(any(SeatEntity.class)))
.willAnswer(invocation -> invocation.getArgument(0));
int numberOfThreads = 10;
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
for (int i = 0; i < numberOfThreads; i++) {
executorService.submit(() -> {
try {
seatService.selectSeat(1L);
successCount.incrementAndGet();
} catch (GlobalCommonException e) {
assertThat(e.getErrorCode()).isEqualTo(SeatErrorResponsive.FILL_SEAT);
failureCount.incrementAndGet();
} finally {
countDownLatch.countDown();
}
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("Success Count: " + successCount.get());
System.out.println("Failure Count: " + failureCount.get());
assertThat(successCount.get()).isEqualTo(1);
assertThat(failureCount.get()).isEqualTo(9);
}
먼저, 10개의 스레드를 생성을 하고 작업을 시작을 합니다. Lock을 얻어서 성공적으로 작업을 마치면 successCount가 올가고 lock을 얻기 못해서 작업을 하지 못한다면 failureCount가 올라갑니다. 그래서 예상되는 결과는 1개만 성공을 하고 나머지는 실패를 하니 successCount 1, failureCount는 9입니다.

결과를 보면 우리가 원하던 결과가 나온 것을 볼 수 있습니다.
Redisson을 이용을 해서 분산 락을 구현을 하고 실제 프로젝트에 적용을 해보았습니다. Redisson을 사용을 하면서 분산 서버 상황에서도 동시성을 잘 제어를 할 수 있게 되었습니다. 하지만 한 가지 아쉬운 것을 분산 락을 구현을 한 부분의 가독성이 조금 떨어진다는 것입니다. 그래서 다음에는 이 부분을 가독성을 높히는 작업을 해보자 합니다.