FitPass 예약 시스템에서 Redis 분산 락과 트랜잭션의 잘못된 생명주기 관리로 인한 동시성 제어 실패
한명이 예약을 성공하면 동시성 제어로 처리가 되어야하는데 트랜잭션 끝나는 시간과 락 해제 시간 사이에 또다른 예약이 들어올 수 있음. 다행히 DB 유니크 제약조건으로 인해서 한 예약만 가능하지만 만약 카운팅이 되는 예약 즉 50자리를 예약하는 자리라면 중복 예약이 발생할 수 있음
증상:
동일한 시간대에 여러 사용자의 예약이 동시에 성공하는 현상 발생
원인 분석:
문제 심각성:
처음 개발할 때에는 단위 테스트도 통과하고, K6를 이용하여 테스트를 했을 때도 기능이 잘 동작했음.
하지만 실제로 확인을 해보니, 락 키 해제 시점과 트랜젝션 끝나는 중간 지점에 다른 사람이 예약을 채가거나 예약이 중복되는 현상이 발생함.
그래서 레디스 락 부분이 잘못된거라고 생각해서 비관적 락, 낙관적 락을 해봤더니 같은 문제가 발생함.
문제는 락 키가 아니라 생명주기가 잘못되어서 일어남.
Redis 락은 그대로 사용하고 예약 로직과 락 획득 로직을 분리하여 락 획득 시간과 트랜잭션 시작하는 시간을 바꾸기로 함.
@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();
}
}
// 문제: 여기서 트랜잭션 커밋이 발생 (락 해제 후!)
}
}
기존 방식의 문제:
┌─────────────────────────────────────────────────────────────┐
│ @Transactional 메서드 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Redis Lock 범위 │ │
│ │ 1. 락 획득 │ │
│ │ 2. 비즈니스 로직 실행 │ │
│ │ 3. 락 해제 │ │
│ └─────────────────────────────────────────────────────┘ │
│ 4. 트랜잭션 커밋 <- 여기서 갭 발생! │
└─────────────────────────────────────────────────────────────┘
하지만 DB 제약 조건으로 인해서 1명만 성공하는 것이 맞긴하지만
제대로 된 동시성 제어가 아니기에 다시 코드를 구현하기로 함.
@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명만 성공하지만 제대로 된 예약 동시성 제어가 아니다 판단.
@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);
}
@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;
// 기타 필드들...
}
개선된 방식:
┌─────────────────────────────────────────────────────────────┐
│ createReservation() - 락 관리 (트랜잭션 없음) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Redis Lock 범위 │ │
│ │ 1. 락 획득 │ │
│ │ ┌─────────────────────────────────────────────────────┐ │ │
│ │ │ @Transactional reservationCreate() │ │ │
│ │ │ 2. 트랜잭션 시작 │ │ │
│ │ │ 3. 비즈니스 로직 실행 │ │ │
│ │ │ 4. 트랜잭션 커밋 │ │ │
│ │ └─────────────────────────────────────────────────────┘ │ │
│ │ 5. 락 해제 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
달라진 점은 없음.
하지만 좀 더 안전해지고 중복예약이 발생할 수 없게 트랜잭션 관리를 구현함.
락을 획득하는 시점이 트랜잭션 시작 전인지 후인지에 따라서 동시성 제어가 완벽한지 아닌지 알 수 있다.
지금 현재는 예약이 1개만 들어가는 1:1 시스템을 사용하기에 실패확률이 낮지만 위에 말했던 것처럼 예약 자리가 다수 즉 50, 100 등이라면 처음에 설계한 동시성제어 로직은 실패확률이 높을 것이다. 그래서 다시 코드를 구현하였고, 다음에는 1:1 예약 시스템이 아닌 1:다 예약을 해봐서 제대로 된 동시성 제어 테스트를 해보고 싶다.
또한, Redis를 사용해서 락을 거는 것만 아닌 ReentrantLock, rpoplpush, RDB활용한 비관/낙관/네임드락 이러한 부분을 여러가지로 시도해보려고 한다.