이전 포스팅
https://changbroblog.tistory.com/239
시작: 대규모 트래픽을 견디는 티켓 예매 시스템 구축기
안녕하세요! 이번 프로젝트는"수만 명의 사용자가 동시에 접속하는 티켓팅 환경에서 어떻게 시스템을 안정적으로 유지할 수 있을까?" 라는 질문에서 시작되었습니다.단순히 기능을 만드는 것에
changbroblog.tistory.com

전 포스팅에서 구축한 시스템으로 야심 차게 부하 테스트를 진행했습니다.
JMeter를 통해 '2026 싸이 흠뻑쇼 - 서울' 공연을 300명이 동시에 예매하기를 누른다고 가정하고 테스트를 진행하겠습니다.
궁금하시죠?


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




🧪 테스트 결과 (5,000명 동시 접속)
- 성공 횟수: 5,000건 중 고작 987건 (성공률 약 19%)
- 에러율(Error Rate): 약 80% 실패
- 현상: 데이터 정합성은 지켜졌습니다. (성공한 987건만큼만 정확히 재고가 줄어듦). 하지만 나머지 4,000여 명의 사용자는 에러 화면을 봐야 했습니다.
- 원인 분석:
- DB 커넥션 고갈: 비관적 락은 트랜잭션이 끝날 때까지 DB 커넥션을 점유합니다. 순차 처리를 위해 대기하는 스레드가 기하급수적으로 늘어나면서
HikariCP의 커넥션 풀이 말라버렸습니다. - 타임아웃 속출: 커넥션을 얻지 못하거나, 락을 얻기 위해 기다리던 요청들이
Connection Timeout또는Lock Wait Timeout으로 강제 종료되었습니다.
- DB 커넥션 고갈: 비관적 락은 트랜잭션이 끝날 때까지 DB 커넥션을 점유합니다. 순차 처리를 위해 대기하는 스레드가 기하급수적으로 늘어나면서
비관적 락은 데이터 정합성을 위한 강력한 도구지만, 대규모 트래픽 환경에서는 유저의 사용성을 심각하게 불쾌하게 만들 수 있다는 걸 알 수 있었습니다.
4. 3차 시도(최종): Redis 분산 락 (Distributed Lock)
DB 락의 성능 한계를 극복하기 위해, 인메모리 저장소인 Redis를 활용한 분산 락을 도입했습니다. Redisson 라이브러리를 사용하여 스핀 락(Spin Lock) 방식의 부하를 줄이고 Pub/Sub 방식으로 구현했습니다.
🛠️ 구현 핵심: Facade 패턴과 트랜잭션 분리
가장 중요한 점은 락의 범위와 트랜잭션의 범위를 분리하는 것입니다.@Transactional이 적용된 메서드 안에서 락을 걸면, 락이 해제된 후에 트랜잭션이 커밋되는 동시성 틈새가 발생합니다.
이를 해결하기 위해 Facade 패턴을 도입했습니다.
- TicketFacade: 락(Lock) 획득 및 해제 담당.
- 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명 동시 접속)
비관적 락 때와 동일한 조건으로 테스트를 진행했습니다. 결과는 극적으로 개선되었습니다.


- 데이터 정합성: 완벽함 (재고가 정확히 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)로 확장하고, 이때 발생하는 세션 불일치 문제를 해결해 보겠습니다.

감사합니다.
'Project > 티켓 예매 시스템(대규모 트래픽 테스트)' 카테고리의 다른 글
| 확장: "로그인이 자꾸 풀려요" - Scale-out과 세션 정합성 (0) | 2026.02.05 |
|---|---|
| 시작: 대규모 트래픽을 견디는 티켓 예매 시스템 구축기 (1) | 2026.02.02 |