예약 생성 동시성 제어 트러블슈팅 ‐ 트랜잭션과 락 획득 순서를 바꿔서 해결 - fitpassTeam/fitpass GitHub Wiki

예약 생성 동시성 제어 - 트랜잭션과 락 획득 순서를 바꿔서 해결

문제 : 동시성 제어 생명주기 관리 실패

문제 발생 배경

예약 로직에서 한명이 예약을 성공하면 동시성 제어로 다른 사람은 예약을 못하게 되어야하는데 트랜잭션 끝나는 시간과 락 해제되는 시간 사이에 또 다른 예약이 시도되는 문제가 발생. DB 유니크 제약조건으로 인해서 한 예약만 가능하지만 1:1 예약 시스템이 아닌 1:다수의 예약이라면 중복예약이 발생할 수 있음.

문제의 로직 방향

image

문제 원인 분석

트랜잭션과 락 획득, 해제 부분의 순서를 바꾸면 성립 가능.

트랜잭션이 끝나면 DB에 이미 저장되니 동시예약 확률이 줄어듦.

  • 문제 코드

    @Service
    @RequiredArgsConstructor
    public class ReservationService {
    
        @Transactional  // 문제: 트랜잭션이 락보다 먼저 시작됨
        public ReservationResponseDto createReservation(
            LocalDate reservationDate, LocalTime reservationTime,
            Long userId, Long gymId, Long trainerId) {
            
            // Redis 분산 락 키 생성
            String lockKey = String.format("reservation:lock:%d:%s:%s",
                trainerId, reservationDate, reservationTime);
            
            RLock lock = redissonClient.getLock(lockKey);
            
            try {
                // 락 획득
                if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                    throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
                }
                
                // 비즈니스 로직 실행
                // 1. 중복 예약 확인
                boolean alreadyExists = reservationRepository
                    .existsByTrainerAndReservationDateAndReservationTime(...);
                
                if (alreadyExists) {
                    throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
                }
                
                // 2. 포인트 차감
                pointService.usePoint(userId, ...);
                
                // 3. 예약 저장
                Reservation reservation = reservationRepository.save(...);
                
                // 4. 알림 발송
                notifyService.send(...);
                
                return ReservationResponseDto.from(reservation);
                
            } finally {
                // 문제: 락이 먼저 해제됨
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
            // 문제: 여기서 트랜잭션 커밋이 발생 (락 해제 후!)
        }
    }

문제의 본질 : “락 해제와 트랜잭션 커밋 사이의 시간 간격에서 발생하는 레이스 컨디션"

시나리오:

  1. 사용자A: 락 해제 (DB에는 아직 커밋 안됨)
  2. 사용자B: 락 획득 → 중복 체크 → "예약 없음"으로 판단
  3. 사용자A: 트랜잭션 커밋 (실제 DB 반영)
  4. 사용자B: 예약 저장 → 중복 예약 발생!

해결책 : 락과 트랜잭션 생명주기 분리

// Before: 락 범위가 트랜잭션보다 짧음
@Transactional
public ReservationResponseDto createReservation(...) {
    // 락 획득 → 로직 실행 → 락 해제 → 트랜잭션 커밋
}

// After: 락 범위가 트랜잭션을 포함
public ReservationResponseDto createReservation(...) {
    RLock lock = redissonClient.getLock(lockKey);
    try {
        lock.tryLock(10, 30, TimeUnit.SECONDS);
        return reservationCreate(...); // @Transactional 메서드 호출
    } finally {
        lock.unlock(); // 트랜잭션 완료 후 락 해제
    }
}

@Transactional
public ReservationResponseDto reservationCreate(...) {
    // 실제 예약 로직
}

핵심 교훈

락의 생명주기가 트랜잭션보다 길어야한다.

트랜잭션이 끝나면 DB에 이미 저장되니 동시예약 확률이 줄어듦. 락이 보호해야 할 범위가 트랜잭션 전체를 포함해야 완벽한 동시성 제어 가능.

해결방안

로직 방향 개선

image

  • 락 관리 메서드 코드 (트랜잭션 없음)

    @Service
    @RequiredArgsConstructor
    public class ReservationService {
        
        private final RedissonClient redissonClient;
        private final ReservationRepository reservationRepository;
        private final PointService pointService;
        private final NotifyService notifyService;
        
        /**
         * Redis 분산 락을 사용한 예약 생성 (트랜잭션 없음)
         * 락의 생명주기를 트랜잭션과 분리하여 동시성 문제 해결
         */
        public ReservationResponseDto createReservation(
            LocalDate reservationDate, LocalTime reservationTime,
            Long userId, Long gymId, Long trainerId) {
            
            // Redis 분산 락 키 생성 (트레이너별, 날짜별, 시간별 세분화)
            String lockKey = String.format("reservation:lock:%d:%s:%s",
                trainerId, reservationDate, reservationTime);
            
            RLock lock = redissonClient.getLock(lockKey);
            
            try {
                // 락 획득 시도 (10초 대기, 30초 후 자동 해제)
                if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                    throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
                }
                
                // 트랜잭션이 있는 비즈니스 로직 호출
                return reservationCreate(
                    reservationDate, reservationTime, ReservationStatus.PENDING,
                    userId, gymId, trainerId
                );
                
            } catch (InterruptedException e) {
                // 인터럽트 플래그 복원
                Thread.currentThread().interrupt();
                throw new BaseException(ExceptionCode.RESERVATION_INTERRUPTED);
            } finally {
                // 안전한 락 해제 (트랜잭션 완료 후)
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        }
    }
  • 비즈니스 로직 메서드 (트랜잭션 있음)

    @Transactional
    public ReservationResponseDto reservationCreate(
        LocalDate reservationDate, LocalTime reservationTime, 
        ReservationStatus status, Long userId, Long gymId, Long trainerId) {
        
        // 1. 엔티티 조회
        User user = userRepository.findByIdOrElseThrow(userId);
        Gym gym = gymRepository.findByIdOrElseThrow(gymId);
        Trainer trainer = trainerRepository.findByIdOrElseThrow(trainerId);
        
        // 2. 중복 예약 확인 (Redis 락 내에서 안전하게 체크)
        boolean alreadyExists = reservationRepository
            .existsByTrainerAndReservationDateAndReservationTime(
                trainer, reservationDate, reservationTime
            );
        
        if (alreadyExists) {
            throw new BaseException(ExceptionCode.RESERVATION_ALREADY_EXISTS);
        }
        
        // 3. 포인트 차감
        String description = "PT 예약 - " + trainer.getName();
        PointUseRefundRequestDto pointRequest = new PointUseRefundRequestDto(
            trainer.getPrice(), description);
        
        PointBalanceResponseDto pointResult = pointService.usePoint(
            userId, pointRequest.amount(), pointRequest.description());
        
        // 4. 예약 생성 및 저장
        Reservation reservation = ReservationRequestDto.from(
            reservationDate, reservationTime, status, user, gym, trainer);
        Reservation savedReservation = reservationRepository.save(reservation);
        
        // 5. 알림 발송
        String url = "/gyms/" + gymId + "/trainers/" + trainerId + 
                     "/reservations/" + savedReservation.getId();
        String content = user.getName() + "님의 예약이 완료되었습니다. " +
                        "예약 날짜는 " + reservation.getReservationDate() + 
                        " " + reservation.getReservationTime() + " 입니다.";
        
        // 사용자 및 체육관 사장에게 알림
        notifyService.send(user, NotificationType.RESERVATION, content, url);
        notifyService.send(trainer.getGym().getOwner(), 
                          NotificationType.RESERVATION, content, url);
        
        return ReservationResponseDto.from(savedReservation);
    }

배운점

  • 락 생명주기에 따라서 동시성 제어의 완전성이 결정된다
  • 락의 범위가 트랜잭션을 포함해야 완벽한 동시성 제어가 가능하다
  • 트랜잭션 커밋 전에 락이 해제되면 데이터 무결성 문제가 발생할 수 있다
  • 분산 락과 트랜잭션의 순서가 동시성 제어의 핵심이다

https://velog.io/@todok0317/FitPass-예약-시스템-트러블슈팅-예약-생성

⚠️ **GitHub.com Fallback** ⚠️