대규모 트래픽 경험기 - 레디스 캐싱 적용
가설: DB 접근을 줄이면 해결되지 않을까?
1편에서 확인한 문제는 명확했다. 2000명이 동시에 좌석을 조회하면 TPS 51, 평균 19.2초, p95 36.2초. 서버가 처리할 수 있는 것보다 더 많은 요청이 몰리면서, 대부분의 시간이 쿼리 실행이 아니라 자기 차례를 기다리는 대기 시간으로 소비되고 있었다.
가장 직관적인 가설은 이것이었다.
"모든 요청이 DB까지 도달하기 때문 아닐까? DB 접근 수를 줄이면 처리량이 올라가고, 대기열이 짧아지고, Tail Latency가 해소되지 않을까?"
그래서 좌석 조회 API에 Redis 캐시를 붙였다.
캐시 설계
목표는 단순하다. 요청 수 ≠ DB 접근 수인 구조를 만드는 것이다. 2000건의 요청이 들어와도 DB 쿼리는 1~2번만 실행되도록 한다.
public List<SeatStatusRes> getSeatStatus(long scheduleId) {
String key = "seat-status:" + scheduleId;
List<SeatStatusRes> cached = redis.get(key);
if (cached != null) {
return cached; // DB 접근 없이 반환
}
List<SeatStatusRes> result =
seatRepository.findSeatStatusByScheduleId(scheduleId);
redis.set(key, result, Duration.ofSeconds(2));
return result;
}TTL을 2초로 설정한 이유가 있다. 오픈런 상황에서는 2초 안에 수천 건의 요청이 몰린다. 첫 번째 요청만 DB를 치고 캐시에 저장하면, 나머지 수천 건은 Redis에서 즉시 반환된다. TTL이 너무 길면 예약 후 좌석 상태 변경이 반영되지 않고, 너무 짧으면 캐시 효과가 줄어든다. 2초는 "실시간성"과 "DB 보호" 사이의 균형점이다.
결과 — 기대와 현실의 괴리
동일한 조건(2000명, 1초 Ramp-up)으로 다시 테스트했다.
솔직히 기대했던 그림은 TPS 100 이상, 평균 5초 이하였다. 하지만 현실은 TPS 18% 증가, 평균은 거의 동일. DB를 줄였는데 체감 성능이 거의 그대로였다.
이 결과를 보고 두 가지 가능성을 떠올렸다. 캐시가 제대로 동작하지 않았거나, 아니면 애초에 병목이 DB가 아니었거나.
왜 캐시가 효과를 못 냈는가
1. 캐시는 데이터를 빨리 가져올 뿐, 작게 만들지 않는다
1편에서 측정한 좌석 조회 응답 크기는 약 880KB였다. 10,000개의 좌석 정보를 JSON으로 직렬화한 결과다.
Redis 캐시를 도입하면 이 880KB를 DB 대신 Redis에서 가져온다. DB 쿼리 시간은 절약되지만, 그 이후의 과정은 동일하다.
[캐시 적용 전]
DB에서 10,000건 SELECT → 엔티티 생성 → DTO 변환 → JSON 직렬화(880KB) → 네트워크 전송
[캐시 적용 후]
Redis에서 캐시 조회 → JSON 역직렬화 → JSON 재직렬화(880KB) → 네트워크 전송880KB의 JSON을 만들어서 보내는 비용은 그대로다. 요청 하나당 이 과정을 거치고, 그게 2000번 반복된다. 캐시가 절약해 준 것은 DB 쿼리 수십 ms뿐이고, 전체 응답 시간에서 그 비중은 미미했던 것이다.
2. 병목이 이동했다
1편에서는 DB 커넥션 풀 대기가 병목이라고 추정했다. 캐시를 붙이면서 DB 접근은 2초에 1번으로 줄었지만, 새로운 병목이 드러났다.
- JSON 직렬화: Jackson이 10,000개 객체를 직렬화하는 것은 CPU 집약적인 작업이다. 동시 요청이 많아지면 CPU 코어를 두고 경쟁한다.
- 네트워크 전송: 880KB × 2000 = 약 1.7GB. 로컬 테스트라 네트워크 대역폭 문제는 없지만, 실제 환경에서는 이것만으로도 병목이 된다.
- 쓰레드 점유: 880KB를 직렬화하고 전송하는 동안 Tomcat 쓰레드 하나가 점유된다. 응답이 크면 클수록 쓰레드 반환이 늦어지고, 뒤에 대기 중인 요청들의 지연이 커진다.
정리하면, 병목은 DB에서 CPU/직렬화/네트워크로 이동했을 뿐이었다. 캐시는 DB라는 병목 하나를 제거했지만, 그 뒤에 숨어있던 더 큰 병목이 그대로 남아있었다.
3. TTL 기반 캐시의 구조적 한계
TTL 2초라는 것은 2초마다 캐시가 만료된다는 뜻이다. 만료 시점에 수천 건의 요청이 동시에 도착하면, 여러 쓰레드가 동시에 캐시 미스를 감지하고 각각 DB 쿼리를 실행할 수 있다. 이를 Cache Stampede(캐시 쇄도) 라고 한다.
이번 테스트에서 큰 영향은 없었지만, 유저 수가 더 늘어나면 TTL 만료 시점마다 DB에 스파이크성 부하가 발생할 수 있다. 이를 방지하려면 뮤텍스 패턴(캐시 미스 시 하나의 쓰레드만 DB 조회를 수행하고 나머지는 대기)이나, TTL에 랜덤 지터를 추가하는 방법이 있다.
4. 처리량 기반 대기열은 여전히 존재한다
TPS 57.7에서 2000건을 처리하면 2000 / 57.7 ≈ 34.6초가 소요된다. 실제 Max가 31초로 이 계산과 거의 일치한다. 캐시가 TPS를 48에서 57로 올려줬지만, 여전히 2000건을 처리하려면 30초 이상 줄을 서야 하는 구조다.
사고의 전환
여기서 질문을 바꿨다.
처음 질문은 "DB를 더 줄이면 되지 않을까?"였다. 하지만 결과가 보여주는 건 다른 방향이었다. DB 접근은 이미 2초에 1번까지 줄였고, 그래도 성능이 안 나온다. 문제는 DB가 아니라 응답 구조 자체에 있었다.
사용자가 좌석 조회에서 실제로 필요한 정보가 무엇인지 다시 생각해봤다. 좌석 번호, 행, 열, 가격 같은 메타데이터는 공연이 만들어질 때 한 번 정해지면 바뀌지 않는다. 오픈런 중에 사용자가 반복적으로 확인하는 것은 오직 각 좌석이 AVAILABLE인지 RESERVED인지, 상태 하나뿐이다.
그런데 지금 API는 매 요청마다 메타데이터와 상태를 합쳐서 10,000개 좌석의 전체 정보를 880KB짜리 JSON으로 내려보내고 있었다. 바뀌지 않는 데이터를 매번 직렬화하고 전송하는 데 CPU와 네트워크를 낭비하고 있었던 것이다.
정리
Redis 캐시는 분명 올바른 방향이었다. DB 부하를 줄이는 데는 성공했다. 하지만 이 실험이 보여준 더 중요한 메시지는 이것이다.
캐시를 도입하기 전에 "진짜 병목이 DB인지"를 먼저 확인해야 한다.
병목이 DB라면 캐시가 답이다. 하지만 병목이 응답 크기, 직렬화, 네트워크에 있다면 캐시만으로는 해결되지 않는다. 도구를 선택하기 전에 병목의 위치를 정확히 진단하는 것이 성능 최적화의 첫 번째 원칙이다.
다음 단계
병목이 "880KB짜리 응답을 매번 만들어 보내는 것"이라면, 응답 자체를 줄여야 한다. 다음 편에서는 좌석 데이터를 두 가지로 분리한다.
- seat-map (메타데이터): 좌석 번호, 행, 열, 가격. 한 번 내려받으면 바뀌지 않는다.
- seat-status (상태): AVAILABLE / RESERVED. 이것만 반복 조회한다.
엔티티 전체를 로딩하는 대신 DTO 프로젝션으로 필요한 필드만 조회하면, 응답 크기가 어디까지 줄어들 수 있는지 확인한다.
함께 읽으면 좋은 글
대규모 트래픽 경험기 - 대기열
이전 글에서 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가 아니었다.