SPRING

[SPRING] Lettuce 분산 락 트러블슈팅

도원좀비 2025. 5. 28. 19:10

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️⃣ 리팩토링 전후 비교

항목 리팩토링 전 리팩토링 후
트랜잭션 위치 락 내부, 같은 메서드 락 외부 메서드 분리
락 적용 시점 트랜잭션보다 앞 트랜잭션 포함 범위 내
중복 예매 발생 있음 없음
구조 명확성 낮음 높음
프록시 적용 불확실 명확하게 적용됨