예약 생성 동시성 제어 트러블슈팅 ‐ 트랜잭션과 락 획득 순서를 바꿔서 해결 - fitpassTeam/fitpass GitHub Wiki
예약 로직에서 한명이 예약을 성공하면 동시성 제어로 다른 사람은 예약을 못하게 되어야하는데 트랜잭션 끝나는 시간과 락 해제되는 시간 사이에 또 다른 예약이 시도되는 문제가 발생. DB 유니크 제약조건으로 인해서 한 예약만 가능하지만 1:1 예약 시스템이 아닌 1:다수의 예약이라면 중복예약이 발생할 수 있음.
트랜잭션과 락 획득, 해제 부분의 순서를 바꾸면 성립 가능.
트랜잭션이 끝나면 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(); } } // 문제: 여기서 트랜잭션 커밋이 발생 (락 해제 후!) } }
- 사용자A: 락 해제 (DB에는 아직 커밋 안됨)
- 사용자B: 락 획득 → 중복 체크 → "예약 없음"으로 판단
- 사용자A: 트랜잭션 커밋 (실제 DB 반영)
- 사용자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에 이미 저장되니 동시예약 확률이 줄어듦. 락이 보호해야 할 범위가 트랜잭션 전체를 포함해야 완벽한 동시성 제어 가능.
로직 방향 개선
-
락 관리 메서드 코드 (트랜잭션 없음)
@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); }
- 락 생명주기에 따라서 동시성 제어의 완전성이 결정된다
- 락의 범위가 트랜잭션을 포함해야 완벽한 동시성 제어가 가능하다
- 트랜잭션 커밋 전에 락이 해제되면 데이터 무결성 문제가 발생할 수 있다
- 분산 락과 트랜잭션의 순서가 동시성 제어의 핵심이다