비즈니스 요구사항
ERD 구성
reservations 테이블은 좌석 정보를 담고 있으며, 사전에 status = NONE이고 user_id = null인 좌석이 미리 등록되어 있음
사용자가 예매를 시도할 경우 해당 좌석의 status를 IN_PROGRESS로 변경하고 user_id 필드에 예매한 사용자의 ID를 저장

현재 서비스 로직의 문제점
@Transactional
public ReservationServiceResponse reserveSeats(ReservationServiceRequest request){
// 1. 요청 유효성 검증
reservationValidator.validate(request);
// 2. 예매 대상 좌석 조회
List<Reservation> reservationsToUpdate =
reservationRepository.findByScreeningIdAndScreeningSeatIdIn(
request.getScreeningId(), request.getSeatIds());
// 3. 이미 예매된 좌석이 있는 경우 예외 처리
if (reservationsToUpdate.stream().anyMatch(
reservation -> reservation.getStatus() != ReservationStatus.NONE)) {
throw new ReservationException(ALREADY_RESERVED_SEAT);
}
// 4. 좌석 상태 업데이트
reservationsToUpdate.forEach(reservation -> reservation.reserve(user));
// 5. 결제 정보 생성 및 좌석에 연결
...
}
테스트 코드로 동시성 테스트를 진행했다. 10명의 사용자가 동시에 동일 좌석에 대해 예매 요청을 보냈고, 실제로는 단 1명만 예매에 성공해야 하지만, 테스트 결과 10건 모두 성공 처리되었다.
동시성 문제를 해결하기 위한 솔루션은 다양하다. 시스템 아키텍처, 비즈니스 로직 특성, 충돌 예상 횟수에 따라 trade off를 고려하여 선택하는 것이 중요하다. 나는 분산된 DB 환경, 충돌이 빈번하다는 전제하에 Redisson 기반 분산락을 활용하여 문제를 해결했다.
DistributedMultiLock
메서드에 분산락을 적용하기 위한 커스텀 어노테이션이다. 이 어노테이션이 붙은 메서드는 AOP를 통해 자동으로 락 획득 및 해제 로직이 실행되며, 락의 waitTime(대기 시간)과 leaseTime(대여 시간)을 설정할 수 있다.
나는 사용자 경험을 고려해 waitTime=2초로 설정했으며, 임계 영역에서 약 500ms가 소요된다는 가정을 바탕으로 leaseTime은 여유 있게 2초로 지정했다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedMultiLock {
String expression();
long waitTime() default 2L;
long leaseTime() default 2L;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
DistributedMultiLockAspect
@DistributedMultiLock 어노테이션의 동작을 정의하는 AOP 클래스이다. RedissonMultiLock을 사용하여 여러 락에 대한 획득, 실패에 대한 예외처리, 해제 등을 정의한다. 락을 하나라도 얻지 못하면 전체 락을 반납한다. 락을 전부 획득하면 임계 영역에(handleWithAspectLock)에 진입한다.
@Around("@annotation(distributedMultiLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedMultiLock distributedMultiLock) throws Throwable {
List<String> keys = getLockKeys(joinPoint, distributedMultiLock.expression());
List<RLock> locks = keys.stream().map(redissonClient::getLock).toList();
RLock multiLock = new RedissonMultiLock(locks.toArray(new RLock[0]));
boolean acquired = multiLock.tryLock(
distributedMultiLock.waitTime(),
distributedMultiLock.leaseTime(),
distributedMultiLock.timeUnit()
);
if (!acquired) {
throw new IllegalStateException("좌석 락 획득 실패: " + keys);
}
log.info("스레드: {}, 락 키: {}", Thread.currentThread().getName(), keys);
try {
return aopForTransaction.proceed(joinPoint);
} finally {
try {
multiLock.unlock();
} catch (Exception e) {
log.error("락 해제 중 예외 발생", e);
e.printStackTrace();
}
}
}
handleWithAspectLock
락을 얻고 난 후 진입하는 임계 영역이다. 선택한 좌석에 대한 예약이 진행된다.
@DistributedMultiLock(expression = "#request.toLockKeys()")
public void handleWithAspectLock(ReservationServiceRequest request, User user) {
List<Reservation> reservationsToUpdate = reservationRepository
.findByScreeningIdAndScreeningSeatIdInWithLock(request.getScreeningId(), request.getSeatIds());
if (reservationsToUpdate.size() != request.getSeatIds().size()) {
throw new ReservationException(RESERVATION_DATA_INCOMPLETE);
}
if (reservationsToUpdate.stream().anyMatch(reservation -> reservation.getStatus() != ReservationStatus.NONE)) {
throw new ReservationException(ALREADY_RESERVED_SEAT);
}
reservationsToUpdate.forEach(reservation -> reservation.reserve(user));
}
reserveSeats
분산락의 범위를 좁히기 위해 임계 영역에 해당하는 부분을 별도의 메서드로 reservationLockHandler.handleWithAspectLock()로 분리했다.
@Transactional
public ReservationServiceResponse reserveSeats(ReservationServiceRequest request){
// 1. 요청 유효성 검증
reservationValidator.validate(request);
// 2. 좌석 예매
reservationLockHandler.handleWithAspectLock(request, user);
// 3. 결제 정보 생성 및 좌석에 연결
...
}
트랜잭션은 락을 획득한 이후에 시작되어야한다. 만약 트랜잭션이 먼저 시작되고 락을 나중에 획득하면, 트랜잭션 커밋 전에 락이 해제되어 다른 스레드가 임계 영역(예: 좌석 예매)에 진입할 수 있다. 이 경우 커밋되지 않은 데이터를 읽게 되어 데이터 정합성 문제가 발생한다.
따라서 reserveSeats()에서 이미 상위 트랜잭션이 존재하는 경우에는 락 획득 후 트랜잭션이 시작되도록 하기 위해 트랜잭션 전파 옵션 Propagation.REQUIRES_NEW를 사용해야 한다. 다만 REQUIRES_NEW는 매 호출마다 별도의 DB 커넥션을 사용하기 때문에 예매 API 요청이 많을 경우 DB 커넥션이 고갈될 위험이 있다.
또한 트랜잭션의 timeout을 설정해야 하며, 분산락의 leaseTime보다 짧게 지정해야 한다. 그렇지 않으면 트랜잭션이 커밋되기 전에 락이 자동으로 해제되어 다른 스레드의 트랜잭션이 임계 영역에 조기 진입하는 문제가 발생할 수 있다.
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 2)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}