대규모 트래픽 경험기 - api 분리
앞 글에서 남은 질문
Redis 캐시를 적용했지만 결과는 기대 이하였다. TPS 48 → 57, 평균 응답 19.2초 → 18.2초. DB 접근은 2초에 1회까지 줄였는데도 체감 성능은 거의 그대로였다.
분석 결과 병목은 이미 DB가 아니었다. 요청 하나당 880KB의 JSON을 직렬화하고 전송하는 비용이 CPU와 네트워크를 잡아먹고 있었고, 캐시는 그 비용을 전혀 줄여주지 못했다.
여기서 질문을 바꿨다. "DB를 더 줄일 것인가?"가 아니라, "왜 이 응답이 880KB나 되는가?"
사용자가 실제로 보는 것
티켓팅 페이지에서 사용자의 행동을 다시 관찰했다.
- 좌석 화면에 처음 진입한다. 좌석 배치, 번호, 가격을 확인한다.
- 이후에는 좌석 상태만 반복 확인한다. "A-12가 아직 비어있는가?"
그런데 기존 API는 매 요청마다 이 모든 정보를 함께 내려보내고 있었다.
[
{ "seatId": 1, "seatNum": "A-1", "price": 120000, "status": "AVAILABLE" },
{ "seatId": 2, "seatNum": "A-2", "price": 120000, "status": "RESERVED" },
...
]좌석 10,000개 기준으로 seatNum과 price는 공연이 만들어질 때 한 번 정해지면 절대 바뀌지 않는다. 바뀌는 것은 오직 status 하나뿐이다. 그런데 오픈런 중에 수천 번 반복되는 조회마다 불변 데이터를 매번 직렬화하고 전송하고 있었다.
880KB 중에서 실제로 "갱신이 필요한 정보"가 차지하는 비중은 얼마나 될까? seatId(Long)와 status(enum 문자열)만 있으면 되니까, 전체 payload의 극히 일부분이다. 나머지는 전부 낭비다.
해결 전략: 정적 메타와 동적 상태의 분리
응답을 두 가지 API로 나눈다.
seat-map — 좌석 번호, 가격, 공연 정보 등 정적 메타데이터. 화면 진입 시 1회만 호출한다. 캐시 TTL을 길게 잡아도 되고, CDN에 올려도 된다.
seat-status — 각 좌석의 현재 상태(AVAILABLE/RESERVED)만 담는다. 오픈런 중 반복 조회 대상은 오직 이것이다.
// 정적 메타 — 한 번만 내려받으면 된다
public record SeatMetaRes(Long seatId, String seatNum, int price) {}
public record SeatMapRes(
String title,
LocalDateTime concertDate,
List<SeatMetaRes> seats
) {}
// 동적 상태 — 반복 조회 대상
public record SeatStatusRes(Long seatId, SeatStatus status) {}설계 원칙은 하나다. seat-status에는 절대 seatNum이나 price를 포함하지 않는다. 실수로라도 메타를 다시 섞는 순간, 이 구조 개선은 무의미해진다.
엔티티 로딩 제거 — DTO 프로젝션
API를 분리하는 것만으로는 충분하지 않다. JPA의 기본 조회 방식이 가진 비용도 함께 제거해야 한다.
기존 코드는 seatRepository.findAll()로 엔티티 10,000개를 로딩한 뒤 DTO로 변환했다. 이 과정에서 발생하는 비용이 있다.
- 엔티티 인스턴스 10,000개 생성: 각 객체마다 메모리 할당, 필드 매핑이 발생한다.
- 영속성 컨텍스트 등록: Hibernate가 10,000개의 엔티티를 1차 캐시에 올리고 변경 감지(dirty checking) 대상으로 관리한다. 순수 조회인데 불필요한 오버헤드다.
- GC 압력: 요청이 끝나면 10,000개의 엔티티 객체가 한꺼번에 GC 대상이 된다. 동시 요청이 많으면 Young GC 빈도가 증가한다.
이를 Repository 레벨에서 DTO 프로젝션으로 대체했다.
@Query("""
SELECT new ticketing.domain.concert.dto.SeatStatusRes(s.id, s.status)
FROM Seat s
WHERE s.concertSchedule.id = :scheduleId
ORDER BY s.id
""")
List<SeatStatusRes> findSeatStatusByScheduleId(long scheduleId);JPQL의 new 생성자 표현식을 사용하면 Hibernate가 엔티티를 거치지 않고 ResultSet에서 바로 DTO를 생성한다. 영속성 컨텍스트에 등록되지 않으므로 변경 감지 오버헤드도 없고, 필요한 컬럼(id, status)만 SELECT하므로 DB에서 전송되는 데이터양도 줄어든다.
대량 조회에서 엔티티 로딩 대신 DTO 프로젝션을 사용하는 것은 단순한 최적화 팁이 아니라, 10,000건 이상의 데이터를 다룰 때 반드시 고려해야 하는 기본 원칙이다.
부하 테스트 결과
동일 환경(MacBook, Spring + MySQL + Redis, JMeter)에서 500 / 1000 / 2000명 단계별로 테스트했다.
단계별 결과 (seat-status API)
주목할 점은 500명 → 1000명 구간이다. 동시 접속이 2배가 되었는데 TPS도 69 → 124로 거의 2배 늘었고, 평균 응답은 오히려 줄었다. 이는 서버의 처리 용량 안에서 요청이 증가한 것이므로, 쓰레드 풀과 커넥션 풀이 더 효율적으로 활용된 결과다.
반면 1000명 → 2000명 구간에서는 TPS가 124 → 136으로 소폭 증가에 그치고, 평균 응답이 4초 → 7.5초로 급등한다. 처리량 상한에 도달한 후 추가 요청이 대기열에 쌓이기 시작한 것이다. TPS 136에서 2000건을 처리하면 2000 / 136 ≈ 14.7초이고, 실제 Max가 14.3초로 이 계산과 정확히 일치한다.
이전 구조와 비교 (2000명 동시 접속 기준)
같은 DB, 같은 서버, 같은 캐시 구조에서 응답 형태만 바꿨을 뿐인데 처리량이 2.7배 증가했다. 2편에서 Redis 캐시가 18% 개선에 그쳤던 것과 비교하면, 병목의 위치를 정확히 짚는 것이 얼마나 중요한지 잘 보여주는 결과다.
이 실험이 보여주는 것
API 응답 설계는 성능에 직접적 영향을 준다
흔히 성능 최적화라고 하면 DB 인덱스, 캐시, 비동기 처리를 먼저 떠올린다. 하지만 이번 실험에서 가장 큰 효과를 낸 것은 "불필요한 필드를 응답에서 제거한 것" 이었다. 응답이 작아지면 직렬화 CPU가 줄고, 네트워크 전송이 빨라지고, 쓰레드 반환이 빨라지고, 뒤에 대기 중인 요청의 지연이 줄어든다. 하나의 변경이 파이프라인 전체에 연쇄 효과를 낸다.
처리량과 Tail Latency의 관계
세 번의 실험을 통해 일관된 패턴이 보인다. Tail Latency는 느린 쿼리 때문이 아니라 처리량 상한을 초과한 요청들이 대기하면서 발생한다. 처리량을 올리면 Tail이 비례해서 줄어든다. 1편의 TPS 51 → p95 36초, 이번의 TPS 136 → p95 13초. 처리량이 2.7배 늘면서 p95도 2.6배 줄었다.
아직 남은 문제
성능은 개선되었지만, 여전히 구조적 한계가 있다. 지금 방식은 클라이언트가 주기적으로 서버에 요청을 보내는 폴링 기반이다.
사용자 2,000명이 1초 간격으로 폴링하면서 90초간 체류한다면, 총 요청 수는 2,000 × 90 = 180,000건이다. 응답을 아무리 가볍게 만들어도, 폴링 자체가 만들어내는 요청량은 사용자 수에 비례해서 증가한다.
여기서 근본적인 질문이 생긴다. "왜 클라이언트가 계속 물어봐야 하지?" 좌석 상태가 바뀔 때 서버가 알려주면 되는 것 아닌가?
다음 단계
다음 편에서는 조회 모델을 Pull(폴링) → Push(SSE) 로 전환한다. 클라이언트가 묻는 것이 아니라, 서버가 변경 사항을 밀어주는 구조로 바꾸면 불필요한 요청 자체를 제거할 수 있다.
함께 읽으면 좋은 글
대규모 트래픽 경험기 - 대기열
이전 글에서 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배 개선이었다.
대규모 트래픽 경험기 - 레디스 캐싱 적용
1편에서 확인한 문제는 명확했다. 2000명이 동시에 좌석을 조회하면 TPS 51, 평균 19.2초, p95 36.2초. 서버가 처리할 수 있는 것보다 더 많은 요청이 몰리면서, 대부분의 시간이 쿼리 실행이 아니라 자기 차례를 기다리는 대기 시간으로 소비되고 있었다.