초당 1만 요청 처리한 아키텍처
이벤트 트래픽으로 초당 1만 요청이 밀려왔을 때 어떻게 처리했는지 공유한다.
Grafana 대시보드에서 실시간으로 밀려오는 숫자를 보는 경험
프로모션 당일 오픈 시점. 초당 12,000 요청이 들어왔다. 예상보다 20% 많았다. 근데 서버는 버텼다. API 평균 응답 시간 150ms, 에러율 0.02%.
이걸 3주 만에 준비한 이야기다.
평소에는 초당 200이었다
우리 서비스는 평소 초당 200 요청 수준이었다. EC2 2대, RDS 1대, Redis 1대의 심플한 구성. 근데 마케팅 팀에서 대규모 프로모션을 기획했다. 예상 동시 접속자 5만 명, 피크 트래픽 초당 1만 요청. 평소의 50배. D-day까지 3주.
부하 테스트부터 돌렸다
k6로 부하 테스트를 돌렸다. 초당 1,000에서 API 응답 시간이 2초를 넘기기 시작했다. 2,000에서 DB 커넥션 풀이 고갈됐다. 3,000에서 서버가 죽었다.
현재 구성으로는 초당 800이 한계였다. 단순히 서버 수를 늘리는 것만으로는 부족했다.
제일 먼저 터진 건 DB였다
RDS의 max_connections가 기본값 150이었다. 서버 2대가 각각 커넥션 풀 50개씩 쓰면 이미 100개. 여유가 없었다.
Read Replica를 추가하고 읽기 쿼리를 분산했다. 프로모션 페이지의 상품 조회 같은 읽기가 전체 트래픽의 80%였으니 효과가 컸다. 핫한 데이터는 Redis에 캐싱했다. 프로모션 상품 목록, 재고 수량을 Redis에 올리고 TTL을 10초로. DB 조회가 80% 줄었다.
(사실 DB가 터질 거라는 건 예상했다. 예상보다 빨리 터진 게 문제였지.)
재고 차감에서 race condition이 터졌다
한정 수량 상품 1,000개. 동시에 차감하려 하면 race condition이 발생한다. 낙관적 락은 실패율이 너무 높고, 비관적 락은 성능이 떨어진다.
Redis의 DECR 명령어로 재고를 원자적으로 차감하는 방식을 택했다. 반환값이 0 이상이면 성공, 음수면 실패. DB 업데이트는 비동기로. 이 구조로 재고 차감 API가 초당 5만 요청까지 처리 가능해졌다.
자잘한 병목들도 잡았다
JWT 토큰 검증에 매 요청 DB 조회가 포함되어 있었다. 블랙리스트 확인 때문이었는데, 이 조회를 Redis로 옮겼다. CDN도 추가해서 정적 자산은 물론이고 프로모션 페이지의 HTML도 짧은 TTL로 CDN에서 서빙했다.
원본 서버에 도달하는 요청 자체를 줄이는 게 가장 효과적인 최적화다.
Auto Scaling Group을 설정하고 CPU 60% 기준으로 스케일아웃하도록 했다. 최소 4대, 최대 12대. 근데 EC2 인스턴스가 부팅되는 데 2-3분이 걸려서 트래픽이 급격히 몰리면 따라잡지 못한다. 그래서 프로모션 시작 30분 전에 미리 8대를 띄워두는 scheduled scaling을 추가했다.
D-day 결과
서버 10대까지 스케일아웃. Redis 캐시 히트율 95%. 재고 1,000개는 47초 만에 소진됐다. 재고 차감 과정에서 오류는 한 건도 없었다.
특별한 기술은 없었다
이 경험에서 배운 건 세 가지다. 부하 테스트 없이 대규모 트래픽을 맞이하는 건 도박이다. 병목은 항상 가장 약한 고리에서 터진다. 캐싱과 비동기 처리가 대부분의 문제를 해결한다.
초당 1만 요청은 특별한 기술이 아니라 기본을 철저히 한 결과다.