[FitPass] 예약 시스템 트러블슈팅 : 예약 생성

김현정·2025년 6월 27일
0

FitPass 예약 시스템 트러블슈팅 : 예약 생성

문제 상황

발견된 이슈

FitPass 예약 시스템에서 Redis 분산 락과 트랜잭션의 잘못된 생명주기 관리로 인한 동시성 제어 실패

1차로 작성한 로직 방향

1차 문제 증상

한명이 예약을 성공하면 동시성 제어로 처리가 되어야하는데 트랜잭션 끝나는 시간과 락 해제 시간 사이에 또다른 예약이 들어올 수 있음. 다행히 DB 유니크 제약조건으로 인해서 한 예약만 가능하지만 만약 카운팅이 되는 예약 즉 50자리를 예약하는 자리라면 중복 예약이 발생할 수 있음

  • 증상:
    동일한 시간대에 여러 사용자의 예약이 동시에 성공하는 현상 발생

  • 원인 분석:

    • 중복 방지를 DB의 유니크 제약조건에만 의존
    • 트랜잭션 종료 및 DB 반영 전, 락이 먼저 해제되는 시간 차이(race condition) 발생 가능
    • 특히 동일 시간대에 다수 정원이 가능한 예약(예: 50명 자리)의 경우, DB 제약조건으로는 중복 허용되어 초과 예약 위험
  • 문제 심각성:

    • 단일 예약은 유니크 제약조건 덕분에 한 건만 성공
    • 그러나 여러 명이 동시에 예약할 수 있는 좌석/타입의 경우, 초과 인원이 동시에 예약되면 정원 초과 및 운영 혼란 발생 가능

문제 발견

처음 개발할 때에는 단위 테스트도 통과하고, K6를 이용하여 테스트를 했을 때도 기능이 잘 동작했음.
하지만 실제로 확인을 해보니, 락 키 해제 시점과 트랜젝션 끝나는 중간 지점에 다른 사람이 예약을 채가거나 예약이 중복되는 현상이 발생함.

그래서 레디스 락 부분이 잘못된거라고 생각해서 비관적 락, 낙관적 락을 해봤더니 같은 문제가 발생함.
문제는 락 키가 아니라 생명주기가 잘못되어서 일어남.
Redis 락은 그대로 사용하고 예약 로직과 락 획득 로직을 분리하여 락 획득 시간과 트랜잭션 시작하는 시간을 바꾸기로 함.

문제 원인 분석

1. 기존 코드의 문제점

잘못된 구현

@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();
            }
        }
        // 문제: 여기서 트랜잭션 커밋이 발생 (락 해제 후!)
    }
}

2. 문제 발생 시나리오

동시성 문제 타임라인

3. 핵심 문제점 식별

락과 트랜잭션 생명주기 불일치

기존 방식의 문제:
┌─────────────────────────────────────────────────────────────┐
│ @Transactional 메서드                                         │
│ ┌─────────────────────────────────────────────────────┐     │
│ │ Redis Lock 범위                                      │     │
│ │ 1. 락 획득                                            │     │
│ │ 2. 비즈니스 로직 실행                                    │     │
│ │ 3. 락 해제                                            │     │
│ └─────────────────────────────────────────────────────┘     │
│ 4. 트랜잭션 커밋 <- 여기서 갭 발생!                                │
└─────────────────────────────────────────────────────────────┘

발생 가능한 레이스 컨디션

  1. 사용자A: 락 해제 후 트랜잭션 커밋 대기 중
  2. 사용자B: 락 획득 성공 → 아직 커밋되지 않은 데이터 조회
  3. 결과: 둘 다 "예약 없음"으로 판단하여 중복 예약 시도

하지만 DB 제약 조건으로 인해서 1명만 성공하는 것이 맞긴하지만
제대로 된 동시성 제어가 아니기에 다시 코드를 구현하기로 함.

4. 테스트 코드

@Test
    void 다중_사용자_동시_예약_테스트() throws Exception {
        // Given: 테스트 데이터 준비
        LocalDate reservationDate = LocalDate.now().plusDays(3);
        LocalTime reservationTime = LocalTime.of(15, 0);

        // 10명이 동시에 같은 시간 예약 시도
        int threadCount = 10;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);
        List<String> results = Collections.synchronizedList(new ArrayList<>());

        // 시작 시간 측정
        long startTime = System.currentTimeMillis();

        for (int i = 0; i < threadCount; i++) {
            final int userIndex = i;
            executor.submit(() -> {
                try {
                    // 각 스레드마다 새로운 사용자 생성
                    User user = createTestUser("multiUser" + userIndex + "@test.com");
                    ReservationRequestDto request = createReservationRequest(reservationDate, reservationTime);

                    // 예약 시도
                    ReservationResponseDto result = reservationService.createReservation(
                        request.reservationDate(), request.reservationTime(), user.getId(), testGym.getId(), testTrainer.getId()
                    );

                    successCount.incrementAndGet();
                    results.add("사용자" + userIndex + " 예약 성공! 예약ID: " + result.reservationId());

                } catch (Exception e) {
                    failCount.incrementAndGet();
                    results.add("사용자" + userIndex + " 예약 실패: " + e.getClass().getSimpleName() + " - " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }

        // 모든 스레드가 완료될 때까지 대기
        latch.await(30, TimeUnit.SECONDS);
        executor.shutdown();

        // 종료 시간 측정
        long endTime = System.currentTimeMillis();

        // Then: 결과 확인
        System.out.println("\n=== 다중 사용자 동시 예약 테스트 결과 ===");
        System.out.println("총 시도: " + threadCount + "명");
        System.out.println("성공: " + successCount.get() + "명");
        System.out.println("실패: " + failCount.get() + "명");
        System.out.println("실행 시간: " + (endTime - startTime) + "ms");
        System.out.println("\n상세 결과:");
        results.forEach(System.out::println);

        // 검증: 정상적이라면 1명만 성공해야 함
        assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount);

        if (successCount.get() == 1) {
            System.out.println("\n동시성 제어 완벽!");
        } else {
            System.out.println("\n동시성 문제! " + successCount.get() + "명이 동시 예약 성공");
        }
    }

예약은 1명만 성공하지만 제대로 된 예약 동시성 제어가 아니다 판단.

문제 해결 - 동시성 제어 코드 리팩토링

1. 코드 수정

2차로 작성한 로직 방향

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

@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();
            }
        }
    }
}

비즈니스 로직 메서드 (트랜잭션 있음)

/**
 * 실제 예약 생성 로직 (트랜잭션 내에서 실행)
 * Redis 락 내에서 호출되어 동시성이 보장된 상태에서 실행
 */
@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);
}

2. 유니크 제약조건도 같이 적용 (전코드와 변경없음)

@Entity
@Table(name = "reservations",
    uniqueConstraints = @UniqueConstraint(
        name = "uk_trainer_date_time",
        columnNames = {"trainer_id", "reservation_date", "reservation_time"}
    ))
public class Reservation extends BaseEntity {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, columnDefinition = "DATE")
    private LocalDate reservationDate;
    
    @Column(nullable = false, columnDefinition = "TIME")
    private LocalTime reservationTime;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "trainer_id", nullable = false)
    private Trainer trainer;
    
    // 기타 필드들...
}

3. 락과 트랜잭션 분리

개선된 방식:
┌─────────────────────────────────────────────────────────────┐
│ createReservation() - 락 관리 (트랜잭션 없음)                    │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Redis Lock 범위                                          │ │
│ │ 1. 락 획득                                                │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ @Transactional reservationCreate()                  │ │ │
│ │ │ 2. 트랜잭션 시작                                        │ │ │
│ │ │ 3. 비즈니스 로직 실행                                    │ │ │
│ │ │ 4. 트랜잭션 커밋                                        │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ 5. 락 해제                                                │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

4. 테스트 코드

달라진 점은 없음.
하지만 좀 더 안전해지고 중복예약이 발생할 수 없게 트랜잭션 관리를 구현함.

느낀점

락을 획득하는 시점이 트랜잭션 시작 전인지 후인지에 따라서 동시성 제어가 완벽한지 아닌지 알 수 있다.
지금 현재는 예약이 1개만 들어가는 1:1 시스템을 사용하기에 실패확률이 낮지만 위에 말했던 것처럼 예약 자리가 다수 즉 50, 100 등이라면 처음에 설계한 동시성제어 로직은 실패확률이 높을 것이다. 그래서 다시 코드를 구현하였고, 다음에는 1:1 예약 시스템이 아닌 1:다 예약을 해봐서 제대로 된 동시성 제어 테스트를 해보고 싶다.
또한, Redis를 사용해서 락을 거는 것만 아닌 ReentrantLock, rpoplpush, RDB활용한 비관/낙관/네임드락 이러한 부분을 여러가지로 시도해보려고 한다.

0개의 댓글