대규모 트래픽 경험기 - 동시성 제어
현재 코드는 성능적인 문제를 어느정도 해결했지만 동시성 문제를 해결하지 못한다.
예를들어, 10명의 사람이 한 좌석에 대해 동시에 예약을 한다면 아래와 같이 10명 모두 같은 좌석을 예매할 수 있게 된다.
1. 문제 상황
콘서트 티켓팅처럼 특정 시간에 트래픽이 몰리는 시스템에서는 동시성 문제가 발생하기 쉽다.
예를 들어 10명이 동시에 같은 좌석을 예약한다고 가정해보자.
이상적인 결과: 1명만 성공, 나머지 9명은 "이미 예약된 좌석" 오류
현실 (락 없을 때): 10명 모두 예약 성공 → 더블 부킹 발생
2. 왜 이런 일이 생기는가
현재 예약 로직은 다음과 같다.
@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);
}public void reserve(Long scheduleId) {
validateAvailable(); // status == AVAILABLE 인지 확인
validateSchedule(scheduleId);
status = SeatStatus.RESERVED;
}10개의 트랜잭션이 동시에 실행될 경우 아래와 같은 상황이 만들어진다.
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개의 스레드를 정확히 같은 시점에 출발시킨다.
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명이 동일 좌석을 동시에 예약하는 테스트를 실행하면 아래와 같은 결과를 확인할 수 있다.
@Test
@DisplayName("동시 10명 예약")
void 현재코드_락없음_더블부킹_발생() throws InterruptedException {
Result result = runConcurrent(
userId -> reservationService.reserve(userId, scheduleId, seatId)
);
// 예약은 1건이어야 하지만, 락이 없으면 이 assertion 이 FAIL 된다
assertThat(result.dbCount).isEqualTo(1L);
}
4. 해결 방법 — Pessimistic Lock
Pessimistic Lock(비관적 락) 은 데이터를 읽는 시점에 SELECT ... FOR UPDATE를 걸어 다른 트랜잭션의 접근을 차단한다.
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 어노테이션을 추가한다.
// SeatRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Seat s WHERE s.id = :id")
Optional<Seat> findByIdForUpdate(@Param("id") Long id);SeatService에 해당 메서드를 노출하고,
// SeatService.java
public Seat findByIdForUpdate(Long seatId) {
return seatRepository.findByIdForUpdate(seatId)
.orElseThrow(() -> new CustomException(SeatExceptionCode.SEAT_NOT_FOUND));
}ReservationService에서 기존 findById 대신 findByIdForUpdate를 호출하면 된다.
// 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. 검증
동일한 동시성 테스트를 수정된 코드로 실행하면 결과가 달라진다.
예약 성공 : 1건
예약 실패 : 9건 (CustomException: 해당 자리를 예약할 수 없습니다.)
DB 예약 : 1건 ✓ 정상
BUILD SUCCESSFUL락을 먼저 잡은 1개 트랜잭션만 AVAILABLE 상태를 보고 예약에 성공한다. 나머지 9개 트랜잭션은 락 해제 후 진입했을 때 이미 RESERVED 상태를 확인하고 정상적으로 예외를 반환한다.
정리
Pessimistic Lock은 충돌이 빈번한 티켓팅 시스템에 적합한 선택이다. 충돌이 드문 일반적인 CRUD에서는 @Version을 이용한 Optimistic Lock이 성능 면에서 유리할 수 있다.
함께 읽으면 좋은 글
대규모 트래픽 경험기 - 대기열
이전 글에서 Pessimistic Lock으로 동시성 문제를 해결했다. 이번에는 한 단계 앞으로 가서, "애초에 서버에 동시에 들어오는 요청 수를 제어하면 어떨까?" 라는 질문에서 출발한다. 콘서트 티켓 오픈 시간에 2000명이 동시에 좌석 조회를 요청한다고 가정하자.
대규모 트래픽 경험기 - SSE
3편에서 API를 seat-map(정적 메타)과 seat-status(동적 상태)로 분리하고, DTO 프로젝션으로 조회 비용을 줄였다. 2000명 동시 접속 기준 TPS 51 → 136, p95 36초 → 13초. 처리량 2.7배, Tail Latency 2.6배 개선이었다.
대규모 트래픽 경험기 - api 분리
Redis 캐시를 적용했지만 결과는 기대 이하였다. TPS 48 → 57, 평균 응답 19.2초 → 18.2초. DB 접근은 2초에 1회까지 줄였는데도 체감 성능은 거의 그대로였다. 분석 결과 병목은 이미 DB가 아니었다.
대규모 트래픽 경험기 - 레디스 캐싱 적용
1편에서 확인한 문제는 명확했다. 2000명이 동시에 좌석을 조회하면 TPS 51, 평균 19.2초, p95 36.2초. 서버가 처리할 수 있는 것보다 더 많은 요청이 몰리면서, 대부분의 시간이 쿼리 실행이 아니라 자기 차례를 기다리는 대기 시간으로 소비되고 있었다.