1️⃣ 문제 상황
동일 좌석에 대해 100명 이상이 동시에 예매 시도할 때 정상적으로는 단 1명만 예매에 성공해야 하지만
2~3명이 동시에 예매 성공하는 현상이 발생!
📌 테스트 시나리오
- 대상 좌석: A-1
- 동시 요청 수: 200건
- 결과: 예매 성공 수 2~3건 → Race Condition
2️⃣ 문제 상황
- 100건 이하의 동시 요청에서는 문제 없이 정확히 1건만 예매에 성공하고, 나머지는 실패 처리
- 그러나 동시 요청이 100건을 초과하면, 2~3건의 예매가 동시에 성공하는 현상이 발생
즉, 동일 좌석에 대한 중복 예매가 발생하는 Race Condition 상황이 발생한 것
3️⃣ 원인 분석
분산 락 자체는 정상적으로 동작
그럼에도 중복 성공이 발생한 이유는 락 획득과 트랜잭션 시작 사이의 시간 차이 때
[BookingService] -- 한 매서드에서 @Transactional + 락 획득
↓
DB insert
⚠️ 문제: 락과 트랜잭션의 타이밍 불일치
- @Transactional 메서드 내에서 락 획득이 일어나고 있다.
- Spring은 프록시 기반 트랜잭션 처리이기 때문에, 메서드 진입 전까지 트랜잭션이 실제로 활성화되지 않다.
- 따라서 락과 트랜잭션이 겹치지 않는 시간 간격이 존재한다.
- 이 틈 사이에 다른 요청이 진입 → 중복 예매 발생
기존 구조 (문제 발생한 코드 예시)
더보기
@Service
@RequiredArgsConstructor
public class BookingService {
private final UserDomainService userDomainService;
private final BookingDomainService bookingDomainService;
private final ConcertDomainService concertDomainService;
private final ConcertSeatDomainService concertSeatDomainService;
private final LettuceLockManager lettuceLockManager;
@Transactional
public BookingResponseDto createBooking(AuthUser authUser, BookingRequestDto dto) {
User user = userDomainService.findActiveUserById(authUser.getId());
List<Long> seatIds = dto.getSeatIds();
// 1. 락 키 생성
List<String> lockKeys = seatIds.stream()
.map(id -> "lock:seat:" + id)
.toList();
// 2. 락 객체 생성
LettuceMultiLock multiLock = new LettuceMultiLock(lockKeys, 5000L, lettuceLockManager);
if (!multiLock.tryLockAll()) {
throw new BookingException(BookingExceptionCode.LOCK_ACQUIRE_FAIL);
}
try {
List<ConcertSeat> concertSeats = concertSeatDomainService.findAllByIdOrThrow(seatIds);
bookingDomainService.validateBookable(concertSeats, LocalDateTime.now());
concertSeatDomainService.validateAllSameConcert(concertSeats);
concertSeatDomainService.validateNotReserved(concertSeats);
Booking booking = bookingDomainService.createBooking(user, concertSeats);
userDomainService.minusPoint(user, booking.getTotalPrice());
concertSeatDomainService.reserveAll(concertSeats);
return BookingResponseDto.from(booking);
} finally {
multiLock.unlockAll();
}
}
}
4️⃣ 해결 과정: 구조 리팩토링
문제 해결을 위해 다음과 같은 방식으로 구조를 변경했다.
🎯 리팩토링 전략
- 락 획득과 트랜잭션 책임을 명확히 분리
- 트랜잭션이 필요한 로직은 별도의 서비스 메서드로 위임
- 별도 서비스 메서드는 @Transactional이 붙은 외부 노출용 메서드여야 프록시 적용 가능
개선 구조
더보기
1. Facade에서 락 처리 → BookingService 위임
public class BookingFacade {
private final DistributedLockService lockService;
private final BookingService bookingService;
public BookingResponseDto createBooking(AuthUser authUser, BookingRequestDto requestDto) {
List<Long> seatIds = requestDto.getSeatIds();
return lockService.executeWithLock(seatIds, () ->
bookingService.createBookingLettuce(authUser, requestDto) // 트랜잭션 포함
);
}
}
2. BookingService는 트랜잭션 로직만 담당
@LettuceMultiLock(key = "#bookingRequestDto.seatIds", group = "concertSeat")
@Transactional
public BookingResponseDto createBookingByLettuce(AuthUser authUser, BookingRequestDto bookingRequestDto) {
User user = userDomainService.findActiveUserById(authUser.getId());
List<ConcertSeat> concertSeats = concertSeatDomainService.findAllByIdOrThrow(bookingRequestDto.getSeatIds());
bookingDomainService.validateBookable(concertSeats, LocalDateTime.now());
concertSeatDomainService.validateAllSameConcert(concertSeats);
concertSeatDomainService.validateNotReserved(concertSeats);
Booking booking = bookingDomainService.createBooking(user, concertSeats);
int totalPrice = booking.getTotalPrice();
userDomainService.minusPoint(user, totalPrice);
concertSeatDomainService.reserveAll(concertSeats);
return BookingResponseDto.from(booking);
}
🔁 개선된 전체 흐름
[BookingController]
↓
[BookingFacade] ← 락 획득
↓
[DistributedLockService]
↓
[runnable.run()] → [BookingService] @Transactional
↓
DB Insert (예매 성공)
트랜잭션은 이제 락 내부에서 안전하게 실행되므로, Race Condition이 사라짐
5️⃣ 테스트 재진행 결과
| 동시 요청 수 | 성공 | 실패 | 비고 |
| 100 | 1 | 99 | ✅ 정상 처리 |
| 200 | 1 | 199 | ✅ 중복 예매 완전 방지 |
6️⃣ 리팩토링 전후 비교
| 항목 | 리팩토링 전 | 리팩토링 후 |
| 트랜잭션 위치 | 락 내부, 같은 메서드 | 락 외부 메서드 분리 |
| 락 적용 시점 | 트랜잭션보다 앞 | 트랜잭션 포함 범위 내 |
| 중복 예매 발생 | 있음 | 없음 |
| 구조 명확성 | 낮음 | 높음 |
| 프록시 적용 | 불확실 | 명확하게 적용됨 |
'SPRING' 카테고리의 다른 글
| [250602 트러블 슈팅]Playwright 병렬 크롤링 중 TargetClosedError 해결기 (4) | 2025.06.03 |
|---|---|
| [250602 트러블 슈팅] 크롤링 도중에 IP밴 (3) | 2025.06.02 |
| [SPRING] Lettuce 분산 락 구현 (1) | 2025.05.27 |
| [SPRING] 동시성 제어 분산 락 (2) | 2025.05.26 |
| [SPRING] 커스텀 메트릭 수집 및 시각화 (2) | 2025.05.20 |