springboot

대규모 트래픽 경험기 - 동시성 제어

2026-04-11
3분 분량
JAVATraffic

현재 코드는 성능적인 문제를 어느정도 해결했지만 동시성 문제를 해결하지 못한다.

예를들어, 10명의 사람이 한 좌석에 대해 동시에 예약을 한다면 아래와 같이 10명 모두 같은 좌석을 예매할 수 있게 된다.

1. 문제 상황

콘서트 티켓팅처럼 특정 시간에 트래픽이 몰리는 시스템에서는 동시성 문제가 발생하기 쉽다.

예를 들어 10명이 동시에 같은 좌석을 예약한다고 가정해보자.

이상적인 결과: 1명만 성공, 나머지 9명은 "이미 예약된 좌석" 오류

현실 (락 없을 때): 10명 모두 예약 성공 → 더블 부킹 발생


2. 왜 이런 일이 생기는가

현재 예약 로직은 다음과 같다.

java
@Transactional
public void reserve(Long userId, Long scheduleId, Long seatId) {
    User user = userService.findById(userId);

    Seat seat = seatService.findById(seatId);  // 단순 SELECT
    seat.reserve(scheduleId);                  // 메모리에서 상태 변경
    ...
    reservationRepository.save(reservation);
}
java
public void reserve(Long scheduleId) {
    validateAvailable();   // status == AVAILABLE 인지 확인
    validateSchedule(scheduleId);
    status = SeatStatus.RESERVED;
}

10개의 트랜잭션이 동시에 실행될 경우 아래와 같은 상황이 만들어진다.

java
Thread A: SELECT seat WHERE id=1  → status = AVAILABLE ✓
Thread B: SELECT seat WHERE id=1  → status = AVAILABLE ✓
Thread C: SELECT seat WHERE id=1  → status = AVAILABLE ✓
...

Thread A: UPDATE seat SET status = RESERVED
Thread B: UPDATE seat SET status = RESERVED  ← 락이 없으므로 그냥 통과
Thread C: UPDATE seat SET status = RESERVED  ← 마찬가지

@Transactional 기본 격리 수준(READ COMMITTED)에서는 다른 트랜잭션이 커밋하기 전의 변경 사항을 볼 수 없다. 따라서 A가 커밋하기 전에 B, C가 읽으면 모두 AVAILABLE을 보게 되고, 모두 예약에 성공한다.


3. 문제 재현 — 동시성 테스트

CountDownLatch를 사용해 N개의 스레드를 정확히 같은 시점에 출발시킨다.

java
private Result runConcurrent(ReservationAction action) throws InterruptedException {
    CountDownLatch readyLatch = new CountDownLatch(THREAD_COUNT);
    CountDownLatch startLatch = new CountDownLatch(1);
    CountDownLatch doneLatch  = new CountDownLatch(THREAD_COUNT);

    AtomicInteger success = new AtomicInteger(0);
    AtomicInteger fail    = new AtomicInteger(0);

    ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT);

    for (int i = 0; i < THREAD_COUNT; i++) {
        final Long userId = userIds.get(i);
        executor.submit(() -> {
            readyLatch.countDown();   // "준비 완료" 신호
            try {
                startLatch.await();   // 모두 준비될 때까지 대기
                action.execute(userId);
                success.incrementAndGet();
            } catch (Exception e) {
                fail.incrementAndGet();
            } finally {
                doneLatch.countDown();
            }
        })
    }

    readyLatch.await();   // 모든 스레드 준비 완료 대기
    startLatch.countDown(); // ← 10개 스레드 동시 출발
    doneLatch.await(30, TimeUnit.SECONDS);
    executor.shutdown();

    return new Result(success, fail, reservationRepository.count());
}

CountDownLatch 구조

10명이 동일 좌석을 동시에 예약하는 테스트를 실행하면 아래와 같은 결과를 확인할 수 있다.

java
@Test
@DisplayName("동시 10명 예약")
void 현재코드_락없음_더블부킹_발생() throws InterruptedException {
    Result result = runConcurrent(
            userId -> reservationService.reserve(userId, scheduleId, seatId)
    );

    // 예약은 1건이어야 하지만, 락이 없으면 이 assertion 이 FAIL 된다
    assertThat(result.dbCount).isEqualTo(1L);
}
post image

4. 해결 방법 — Pessimistic Lock

Pessimistic Lock(비관적 락) 은 데이터를 읽는 시점에 SELECT ... FOR UPDATE를 걸어 다른 트랜잭션의 접근을 차단한다.

java
Thread A: SELECT seat WHERE id=1 FOR UPDATE  → 락 획득, status = AVAILABLE
Thread B: SELECT seat WHERE id=1 FOR UPDATE  → A가 커밋할 때까지 대기
Thread C: SELECT seat WHERE id=1 FOR UPDATE  → 대기

Thread A: UPDATE status = RESERVED → 커밋 → 락 해제
Thread B: 락 획득 → status = RESERVED 확인 → 예외 발생 (이미 예약됨)
Thread C: 락 획득 → status = RESERVED 확인 → 예외 발생

적용 방법은 간단하다. SeatRepository에 @Lock 어노테이션을 추가한다.

java
// SeatRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Seat s WHERE s.id = :id")
Optional<Seat> findByIdForUpdate(@Param("id") Long id);

SeatService에 해당 메서드를 노출하고,

java
// SeatService.java
public Seat findByIdForUpdate(Long seatId) {
    return seatRepository.findByIdForUpdate(seatId)
            .orElseThrow(() -> new CustomException(SeatExceptionCode.SEAT_NOT_FOUND));
}

ReservationService에서 기존 findById 대신 findByIdForUpdate를 호출하면 된다.

java
// ReservationService.java
@Transactional
public void reserve(Long userId, Long scheduleId, Long seatId) {
    User user = userService.findById(userId);

    Seat seat = seatService.findByIdForUpdate(seatId);  // FOR UPDATE
    seat.reserve(scheduleId);
    user.reserve(seat.getPrice());

    reservationRepository.save(Reservation.of(user, seat));
    ...
}

5. 검증

동일한 동시성 테스트를 수정된 코드로 실행하면 결과가 달라진다.

java
예약 성공 : 1건
예약 실패 : 9건  (CustomException: 해당 자리를 예약할 수 없습니다.)
DB 예약   : 1건  ✓ 정상

BUILD SUCCESSFUL

락을 먼저 잡은 1개 트랜잭션만 AVAILABLE 상태를 보고 예약에 성공한다. 나머지 9개 트랜잭션은 락 해제 후 진입했을 때 이미 RESERVED 상태를 확인하고 정상적으로 예외를 반환한다.


정리

Pessimistic Lock은 충돌이 빈번한 티켓팅 시스템에 적합한 선택이다. 충돌이 드문 일반적인 CRUD에서는 @Version을 이용한 Optimistic Lock이 성능 면에서 유리할 수 있다.

함께 읽으면 좋은 글