본문 바로가기
Project/티켓 예매 시스템(대규모 트래픽 테스트)

위기: "재고가 이상해요..." - 동시성 이슈와의 전쟁

by 창브로 2026. 2. 2.

이전 포스팅

https://changbroblog.tistory.com/239

 

시작: 대규모 트래픽을 견디는 티켓 예매 시스템 구축기

안녕하세요! 이번 프로젝트는"수만 명의 사용자가 동시에 접속하는 티켓팅 환경에서 어떻게 시스템을 안정적으로 유지할 수 있을까?" 라는 질문에서 시작되었습니다.단순히 기능을 만드는 것에

changbroblog.tistory.com

 

예매 사이트에 나와있는 공연 정보와 티켓 수량들

 

전 포스팅에서 구축한 시스템으로 야심 차게 부하 테스트를 진행했습니다.

 

JMeter를 통해 '2026 싸이 흠뻑쇼 - 서울' 공연을 300명이 동시에 예매하기를 누른다고 가정하고 테스트를 진행하겠습니다.

 

궁금하시죠?

JMeter 부하 테스트 설정

 

300번의 로그인, 300번의 예매 100% 성공

 

JMeter 상에서는 모든 요청이 성공(Success)했다고 떴습니다.

하지만 DB와 홈페이지를 확인한 순간 재밌는 일이 벌어졌습니다.

 

테스트 후 홈페이지

 

테스트 후 DB

.

"300명이 예매했는데, 재고는 왜 32개만 줄어들었지?"

 

이제 저희는 데이터 정합성 문제(동시성 이슈)를 해결하기 위해 synchronized, DB Pessimistic Lock, 그리고 Redis Distributed Lock까지 단계별로 적용해 보며 최적의 해답을 찾아가 보려고 합니다.

 


1. 문제 상황: 300건 예매 시도, 268건 누락 발생

💥 현상

JMeter로 300개의 스레드를 동시에 쏘았을 때, tickets 테이블의 재고(stock)가 정확히 300만큼 줄어들지 않는 현상을 발견했습니다.

실제 운영 중인 서비스에서 이런 상황이 발생하면 정말 큰일 날 것입니다. 재고가 없는데 결제처리를 하고 있기 때문입니다.

  • 기대 재고: 300 - 300 = 0
  • 실제 재고: 268 (268건의 예매가 재고 감소에 반영되지 않음)

🧐 원인 분석

이것은 전형적인 Race Condition(경쟁 상태) 문제입니다.

  1. User A가 재고를 조회함 (Stock = 300)
  2. User B가 재고를 조회함 (Stock = 300) - A가 아직 줄이기 전!
  3. User A가 재고를 299로 업데이트 (300- 1)
  4. User B도 재고를 299로 업데이트 (300- 1) - A의 업데이트를 덮어씀 (Lost Update)

결국 두 명이 예매했는데 재고는 1개만 줄어드는 참사가 벌어진 것입니다.


2. 1차 시도: Java synchronized 키워드

가장 먼저 떠오른 해결책은 Java의 고유 락인 synchronized를 사용하는 것입니다.

// TicketService.java
@Transactional
public synchronized void reserve(Long userId, Long ticketId) {
    // ... 예매 로직
}

 

🧪 테스트 결과

이래도 259개가 씹혔다

 

  • 결과: 재고가 259개가 남았습니다. (대실패 😱)
    • 300번의 예매 시도 중 무려 259건의 업데이트가 누락되었습니다. synchronized를 썼음에도 불구하고 사실상 동시성 제어가 거의 되지 않은 수준입니다.
  • 원인 분석:
    • Spring @Transactional 프록시의 동작 방식 때문입니다.
    • AOP 프록시는 [트랜잭션 시작 -> 메서드 실행(synchronized) -> 메서드 종료 -> 트랜잭션 커밋] 순서로 동작합니다.
    • 문제는 메서드가 종료되어 락(Lock)이 풀리는 시점과 실제로 DB에 데이터가 커밋되는 시점 사이의 간극입니다.
    • 락이 풀리자마자 대기하던 다른 스레드가 메서드에 진입하지만, 이전 스레드의 변경사항은 아직 커밋 전이라 변경 전 재고를 읽게 됩니다. 이것이 바로 업데이트가 증발하는 원인입니다.
  • 한계점:
    1. 위와 같은 구조적 문제로 인해 데이터 정합성을 완벽히 보장할 수 없습니다.
    2. 서버를 여러 대로 늘리면, 각 서버의 메모리 락은 서로 공유되지 않으므로 분산 환경에서는 무용지물이 됩니다.

3. 2차 시도: DB 비관적 락 (Pessimistic Lock)

서버가 여러 대여도 데이터베이스는 하나니까, DB 레벨에서 락을 걸어보겠습니다. SELECT ... FOR UPDATE 쿼리를 사용합니다.

 

// TicketRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select t from Ticket t where t.id = :id")
Optional<Ticket> findByIdWithLock(@Param("id") Long id);

 

동시에 실행하는 사용자의 수가 많으면 많을수록 테스트를 더 정확하고 확실하게 할 수 있기 때문에 5000명으로 늘리겠습니다!

 

10000석으로 늘어난 흠뻑쇼

 

5000명이 동시에 예매

 

약 80%가 실패하는 에러

 

987석 밖에 줄어들지 않았다.

 

🧪 테스트 결과 (5,000명 동시 접속)

  • 성공 횟수: 5,000건 중 고작 987건 (성공률 약 19%)
  • 에러율(Error Rate): 약 80% 실패
    • 현상: 데이터 정합성은 지켜졌습니다. (성공한 987건만큼만 정확히 재고가 줄어듦). 하지만 나머지 4,000여 명의 사용자는 에러 화면을 봐야 했습니다.
  • 원인 분석:
    • DB 커넥션 고갈: 비관적 락은 트랜잭션이 끝날 때까지 DB 커넥션을 점유합니다. 순차 처리를 위해 대기하는 스레드가 기하급수적으로 늘어나면서 HikariCP의 커넥션 풀이 말라버렸습니다.
    • 타임아웃 속출: 커넥션을 얻지 못하거나, 락을 얻기 위해 기다리던 요청들이 Connection Timeout 또는 Lock Wait Timeout으로 강제 종료되었습니다.

비관적 락은 데이터 정합성을 위한 강력한 도구지만, 대규모 트래픽 환경에서는 유저의 사용성을 심각하게 불쾌하게 만들 수 있다는 걸 알 수 있었습니다.


4. 3차 시도(최종): Redis 분산 락 (Distributed Lock)

DB 락의 성능 한계를 극복하기 위해, 인메모리 저장소인 Redis를 활용한 분산 락을 도입했습니다. Redisson 라이브러리를 사용하여 스핀 락(Spin Lock) 방식의 부하를 줄이고 Pub/Sub 방식으로 구현했습니다.

 

🛠️ 구현 핵심: Facade 패턴과 트랜잭션 분리

가장 중요한 점은 락의 범위와 트랜잭션의 범위를 분리하는 것입니다.
@Transactional이 적용된 메서드 안에서 락을 걸면, 락이 해제된 후에 트랜잭션이 커밋되는 동시성 틈새가 발생합니다.

이를 해결하기 위해 Facade 패턴을 도입했습니다.

  1. TicketFacade: 락(Lock) 획득 및 해제 담당.
  2. TicketService: 순수 비즈니스 로직 및 DB 트랜잭션 담당.

TicketFacade.java

public Long reserve(Long userId, Long ticketId) {
    RLock lock = redissonClient.getLock("ticketLock:" + ticketId);
    try {
        // 락 획득 시도 (10초 대기, 3초 점유)
        boolean available = lock.tryLock(10, 3, TimeUnit.SECONDS);
        if (!available) throw new IllegalStateException("진입 대기 시간 초과");

        // 락을 얻은 스레드만 서비스 호출 (트랜잭션 시작)
        return ticketService.reserve(userId, ticketId);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        lock.unlock(); // 서비스 종료(커밋 완료) 후 락 해제
    }
}

 

🏆 최종 결과 (5,000명 동시 접속)

비관적 락 때와 동일한 조건으로 테스트를 진행했습니다. 결과는 극적으로 개선되었습니다.

 

에러율 0%

 

5000개가 정확하게 남아있는 사이트의 모습

 

 

  • 데이터 정합성: 완벽함 (재고가 정확히 5,000건 감소)
  • 에러율(Error Rate): 0.00% (비관적 락 80% -> 0%로 개선!)
  • 성능(Throughput): 122.2/sec
    • 비관적 락(0.3/sec) 대비 약 400배 성능 향상을 이뤄냈습니다.
  • 응답 시간: 평균 26초.
    • 모든 요청을 에러 없이 처리하려다 보니 대기 시간(Wait Time)이 발생했지만, "단 한 명의 고객도 튕겨내지 않고 모두 처리했다"는 점에서 고가용성 목표를 달성했습니다.

5. 결론 및 다음 단계

동시성 이슈를 해결하기 위해 Java synchronized부터 DB Lock, 그리고 Redis 분산 락까지 단계별로 적용해 보았습니다.

방식 정합성 성능/가용성 특징
Synchronized ❌ 실패 🔺 보통 @Transactional 프록시 문제로 실패
DB 비관적 락 ✅ 성공 ❌ 최악 DB 커넥션 고갈로 대다수 요청 실패
Redis 분산 락 ✅ 성공 ✅ 최상 400배 성능 향상 및 에러율 0% 달성

 

하지만, 트래픽이 여기서 더 늘어난다면? Redis도 부하를 받을 것이고, 무엇보다 단일 서버(Monolith) 구조로는 한계가 있습니다.

다음 포스팅에서는 Nginx를 도입하여 서버를 여러 대(Scale-out)로 확장하고, 이때 발생하는 세션 불일치 문제를 해결해 보겠습니다.

 

 

감사합니다.