대규모 트래픽 경험기 – 문제 상황 관찰
왜 이 프로젝트를 시작했는가
백엔드 개발을 공부하면서 항상 마음에 걸리던 질문이 있었다.
"나는 실제 대규모 트래픽을 겪어본 적이 있는가?"
CRUD를 만들 수 있고, 트랜잭션을 이해하고, API를 설계할 수 있다. 하지만 현실의 수강신청, 콘서트 티케팅, 한정 수량 이벤트는 다르다. 수천~수만 명이 동시에 하나의 자원을 향해 몰려드는 순간, 평소에는 보이지 않던 문제들이 터져 나온다.
그래서 직접 만들어보고, 직접 무너뜨려보기로 했다. 도메인은 자연스럽게 콘서트 티켓팅 시스템을 선택했다.
의도적으로 단순하게 시작하다
처음부터 Redis나 캐시를 도입하지 않았다. 최적화 없이 순수하게 DB만 사용하는 구조에서 어디가 먼저 무너지는지 관찰하고 싶었기 때문이다.
- Spring Boot + JPA (Hibernate) + MySQL
- 캐시 없음, 큐 없음, 락 없음
현실에서 흔히 볼 수 있는 기본적인 동기 처리 구조다. 예약 로직도 단순하다.
@Transactional
public void reserve(Long userId, Long scheduleId, Long seatId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("유저 없음"));
Seat seat = seatRepository.findById(seatId)
.orElseThrow(() -> new IllegalArgumentException("좌석 없음"));
if (!seat.isAvailable()) {
throw new IllegalStateException("해당 자리를 예약할 수 없습니다.");
}
user.usePoint(seat.getPrice());
seat.reserve();
reservationRepository.save(new Reservation(user, seat));
}이 코드의 가장 중요한 특성은 하나다. 모든 요청이 DB까지 직접 도달한다. SELECT 2회, UPDATE 2회, INSERT 1회. 요청 하나당 최소 5번의 DB 접근이 발생한다.
테스트 환경
MacBook 단일 머신에서 Spring 서버, MySQL, JMeter를 모두 실행했다. 클라이언트와 서버가 같은 머신에 있으므로 절대적인 TPS 수치보다는 병목의 형태와 위치를 관찰하는 데 초점을 두었다.
- JMeter: 2000 Threads, Ramp-up 1초
- Synchronizing Timer로 요청을 동시에 출발시킴
1차 테스트 — 2000명 동시 예약
결과
평균 387ms. 첫인상은 "생각보다 잘 버틴다"였다. 하지만 여기서 평균만 보고 넘어가면 안 된다.
평균은 거짓말을 한다
평균 387ms라는 숫자는 "대부분의 요청이 0.4초 안에 처리되었다"는 인상을 준다. 하지만 실제 분포를 보면 이야기가 달라진다.
- 상위 5%의 요청(p95)은 1.25초 이상 걸렸다.
- 상위 1%의 요청(p99)은 1.34초 이상 걸렸다.
2000명 중 100명은 1초 이상을 기다렸다는 뜻이다. 만약 이게 20,000명이었다면 1,000명이 1초 이상 대기하는 셈이고, 사용자 경험 측면에서 이 수치는 결코 무시할 수 없다.
이 현상이 발생하는 원리는 대기열 이론 으로 설명된다. 시스템에는 동시에 처리할 수 있는 용량의 상한이 있다. Tomcat 쓰레드 풀(기본 200개)과 HikariCP 커넥션 풀(기본 10개)이 그 상한이다. 2000개의 요청이 동시에 도착하면 이런 일이 벌어진다.
요청 1~10: 즉시 DB 커넥션 획득 → 빠르게 처리 (수십 ms)
요청 11~200: 쓰레드는 할당되지만 커넥션 대기 (수백 ms)
요청 201~2000: 쓰레드 풀에서도 대기 (1초 이상)즉, p95에서 관찰되는 1.25초는 쿼리 실행 시간이 아니라 자기 차례를 기다리는 시간이다. Little's Law에 따르면 L = λ × W (대기 중인 요청 수 = 도착률 × 평균 대기 시간)이므로, 도착률이 처리량을 초과하는 순간 대기열이 급격하게 쌓이고, 꼬리에 있는 요청들의 대기 시간이 폭발적으로 증가한다. 이것이 Tail Latency 문제다.
그런데 진짜 위험한 건 POST가 아니었다
여기서 한 발 물러나서 실제 사용자의 행동 패턴을 생각해봤다.
티켓 오픈 시간에 사용자는 예약 버튼을 1번 누른다. 하지만 좌석 페이지는 계속 새로고침 한다. "내가 원하는 좌석이 아직 남아있는지" 확인하기 위해서다.
즉, 실제 트래픽의 대부분은 예약(POST)이 아니라 좌석 조회(GET) 다. POST는 사용자당 1회지만, GET은 수십 회 반복될 수 있다. 만약 POST에서 Tail Latency가 보였다면, 더 빈번하게 발생하는 GET에서는 어떤 일이 벌어질까?
2차 테스트 — 2000명 동시 좌석 조회
결과
예약 POST보다 처리량이 절반 이하로 떨어졌고, 평균 응답이 19.2초로 치솟았다. p95는 36초. 좌석 페이지를 열었는데 36초 동안 로딩만 돌아가는 것이다.
왜 GET이 POST보다 느린가
직관적으로는 SELECT 하나인 GET이 여러 쿼리를 실행하는 POST보다 빨라야 한다. 하지만 이 시스템에서는 정반대 결과가 나왔다. 원인은 응답 크기에 있었다.
좌석 조회 API는 해당 공연의 전체 좌석 상태를 반환한다. 좌석이 10,000개일 경우 하나의 응답이 거치는 경로는 다음과 같다.
DB에서 10,000건 SELECT
→ JPA 엔티티 10,000개 생성
→ DTO 10,000개 변환
→ JSON 직렬화 (약 880KB)
→ 네트워크 전송요청 하나당 880KB의 JSON을 만들어서 보내고 있었다. 이 비용이 2000번 반복되면,
- JSON 직렬화: Jackson이 10,000개의 객체를 매번 직렬화. CPU 집약적인 작업이 2000번 반복되면 CPU가 포화된다.
- 메모리: 요청당 10,000개의 엔티티 + 10,000개의 DTO + 직렬화 버퍼. 동시 요청이 많아지면 GC 압박이 커진다.
- 네트워크: 880KB × 2000 = 약 1.7GB의 데이터가 네트워크를 통과해야 한다.
POST는 요청당 수 KB의 응답만 반환하므로 이런 부하가 없었다. 병목은 DB 쿼리 자체가 아니라, 대용량 응답의 생성과 전송에 있었던 것이다.
처리량 상한과 대기열의 관계
이 결과를 대기열 이론 관점에서 다시 보자.
TPS가 51이라는 것은 서버가 초당 51건의 좌석 조회만 처리할 수 있다는 뜻이다. 2000건을 처리하는 데 필요한 시간은 2000 / 51 ≈ 39초이고, 실제 측정된 Max 응답 시간이 38초로 이 계산과 거의 일치한다.
TPS 51 → 2000건 처리에 약 39초 소요
실제 Max 응답: 38초
→ 마지막 요청은 약 38초를 "줄 서서" 기다린 셈각 요청의 실제 처리 시간은 크게 다르지 않다. 차이를 만드는 것은 "줄에서 몇 번째에 서 있느냐"다. 1번째 요청은 즉시 처리되고, 2000번째 요청은 앞의 1999건이 끝날 때까지 기다린다. 시스템이 느려진 게 아니라, 시스템이 처리할 수 있는 것보다 더 많은 요청이 몰린 것이다.
발견한 것들
1. 평균은 진실이 아니다
평균 387ms라는 숫자 뒤에 p95 1.25초가 숨어있었다. 성능을 평가할 때 평균만 보면 시스템이 충분하다는 잘못된 판단을 내릴 수 있다. Tail Latency(p95, p99)가 실제 사용자 경험을 결정한다.
2. 병목은 예상과 다른 곳에 있었다
처음에는 "쓰기(예약)가 병목일 것"이라고 예상했지만, 실제로는 읽기(좌석 조회)가 더 심각했다. 대용량 JSON 응답의 직렬화와 전송이 CPU와 네트워크를 잡아먹고 있었다. 부하 테스트 없이는 이 병목을 발견하지 못했을 것이다.
3. 처리량 상한을 넘으면 대기열이 Tail을 폭발시킨다
서버의 처리량(TPS)은 물리적 한계가 있다. 그 한계를 넘는 요청이 들어오면 내부적으로 대기열이 형성되고, 대기열의 끝에 있는 요청들이 극단적인 지연을 겪는다. 이 문제를 해결하려면 두 가지 방향이 있다. 처리량 자체를 올리거나, 유입을 처리량에 맞게 제어하거나.
다음 단계
병목의 위치가 확인되었다. 가설은 명확하다.
"DB 접근 수를 줄이면 좋아질까?"
다음 편에서는 좌석 조회 결과를 Redis 캐시로 감싸서, "요청 수 ≠ DB 접근 수" 인 구조를 만들어본다. 2000번의 요청이 들어와도 DB 쿼리는 1번만 실행되도록 만들면, 앞서 관찰한 Tail Latency가 실제로 해소되는지 검증한다.
함께 읽으면 좋은 글
대규모 트래픽 경험기 - 대기열
이전 글에서 Pessimistic Lock으로 동시성 문제를 해결했다. 이번에는 한 단계 앞으로 가서, "애초에 서버에 동시에 들어오는 요청 수를 제어하면 어떨까?" 라는 질문에서 출발한다. 콘서트 티켓 오픈 시간에 2000명이 동시에 좌석 조회를 요청한다고 가정하자.
대규모 트래픽 경험기 - 동시성 제어
현재 코드는 성능적인 문제를 어느정도 해결했지만 동시성 문제를 해결하지 못한다. 예를들어, 10명의 사람이 한 좌석에 대해 동시에 예약을 한다면 아래와 같이 10명 모두 같은 좌석을 예매할 수 있게 된다.
대규모 트래픽 경험기 - 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가 아니었다.