[Hot Item] #11. v3 부하 테스트 — Redis가 락을 없앴지만, 새로운 병목이 나타났다
이 글에서 다루는 내용
- v3 테스트 결과 분석 — 락은 사라졌는데 왜 또 뻗었나
- 서버 로그로 진짜 범인 찾기 — JWT 인증 계층의 배신
- v1 → v2 → v3 수치 비교 — 이 프로젝트가 증명한 것
- v4 계획 — 마지막 병목을 없애기 위한 전략
1. v3 테스트 결과
📊 Statistics

항목 값
| 총 요청 수 | 2,187 |
| 실패 수 | 1,551 (70.92%) |
| 중간값 응답시간 | 2,300ms |
| 95%ile 응답시간 | 19,000ms |
| 99%ile 응답시간 | 21,000ms |
| 평균 응답시간 | 6,426ms |
| 최소 응답시간 | 6ms |
| 최대 응답시간 | 31,447ms |
수치만 보면 v2보다 나빠 보입니다. 실패율이 61.87%에서 70.92%로 올랐습니다. 그런데 이 숫자를 그대로 받아들이면 안 됩니다. 이유는 뒤에서 설명합니다.
📈 Charts
그래프의 모양이 v2와 미묘하게 다릅니다.
v2에서는 테스트 내내 응답 시간이 꾸준히 우상향했습니다. DB 락을 잡으려는 트랜잭션들이 쌓이면서 대기 시간이 계속 길어지는 패턴이었습니다.
v3에서는 초반에 RPS가 최대 약 180까지 치솟습니다. v2의 최대 RPS(약 95)의 거의 두 배입니다. Redis가 DB 락을 대신 처리하면서 요청 처리 속도가 크게 빨라진 것입니다. 그러나 이 빠른 속도가 오히려 새로운 문제를 드러냈습니다. 응답 시간이 10:13:45 이후 빠르게 올라가면서 결국 v2와 비슷한 양상으로 이어집니다.
❌ Failures
건수 오류 메시지
| 497 | CatchResponseError('에러 발생: 500') |
| 384 | CatchResponseError('에러 발생: 0') |
v2와 에러 메시지가 똑같습니다. too many clients already와 Broken pipe. 그런데 에러가 발생한 위치가 다릅니다.
2. 서버 로그로 진짜 범인 찾기
에러 로그의 스택 트레이스를 따라가면 범인이 드러납니다.
File ".../rest_framework/views.py", line 420, in initial
self.perform_authentication(request) ← 1. 인증 단계
...
File ".../rest_framework_simplejwt/authentication.py", line 132, in get_user
user = self.user_model.objects.get(...) ← 2. 범인 등장
...
psycopg.OperationalError: FATAL: sorry, too many clients already
에러가 발생한 곳은 shop/views.py의 주문 로직이 아닙니다. 심지어 Redis도 아닙니다. JWT 토큰을 검증하는 인증 미들웨어 단계입니다.
JWT의 숨겨진 DB 조회
JWT는 "Stateless(무상태)"라서 DB를 타지 않는다고 알려져 있습니다. 토큰 자체에 사용자 정보가 담겨 있으니까요. 그런데 Django SimpleJWT는 기본 설정에서 요청이 들어올 때마다 토큰 안의 user_id를 꺼내 실제로 DB에 User.objects.get(id=user_id)를 실행합니다. "이 유저가 DB에 실제로 존재하는지" 매번 확인하는 것입니다.
[v2 병목 위치]
요청 → 인증(DB) → 주문 로직 → select_for_update(DB 락) ← 여기서 막힘
[v3 병목 위치]
요청 → 인증(DB) ← 여기서 막힘 → Redis 처리 → DB 최종 저장
v2에서는 주문 로직의 DB 락이 워낙 오래 걸리니 인증 단계의 DB 조회는 상대적으로 묻혀 있었습니다. v3에서 주문 로직의 락이 사라지자 요청 처리 속도가 급격히 빨라졌고, 그 빠른 속도로 쏟아지는 인증 DB 조회가 커넥션 풀을 고갈시키기 시작한 것입니다.
1차 병목(주문 DB 락)을 제거하자, 그 뒤에 숨어있던 2차 병목(인증 DB 조회)이 수면 위로 드러난 것입니다.
3. 그러면 v3는 개악인가? — 에러율 수치의 진짜 의미
실패율 수치만 보면 v3(70.92%)가 v2(61.87%)보다 나빠 보입니다. 그런데 다음 표를 보면 해석이 달라집니다.
항목 v2 v3
| 총 요청 수 | 1,424 | 2,187 |
| 실패 수 | 881 | 1,551 |
| 성공 수 | 543 | 636 |
| RPS (최대) | ~95 | ~180 |
같은 시간 동안 v3는 v2보다 약 753건 더 많은 요청을 받아냈습니다. 성공 건수도 543건에서 636건으로 늘었습니다. 실패율 퍼센트가 높아 보이는 것은 서버가 더 많은 요청을 처리하게 되면서 연결 풀 한계에 더 빨리 부딪혔기 때문입니다.
"실패율이 더 높아진 것이 아니라, 처리 속도가 빨라져서 더 많은 요청이 몰린 것이다."
에러의 성격도 다릅니다.
- v2의 에러: DB 락이 걸린 상태에서 수십 초간 전체 DB를 점유하는 악성 장기 에러
- v3의 에러: 연결 한도(100개)를 초과해서 즉시 튕겨내는 단순 초과 에러
DB가 수십 초 동안 락에 묶여 있는 것과, 연결 초과로 즉시 거절하는 것은 시스템 전체에 미치는 영향이 완전히 다릅니다.
4. v1 → v2 → v3 수치 비교 — 이 프로젝트가 증명한 것
테스트 환경: 500명 동시 접속, Spawn rate 100/s, 재고 100개 단일 상품
지표 v1 (순수 RDBMS) v2 (Pessimistic Lock) v3 (Redis 대기열)
| 초과 판매 | 발생 (140% 초과) | 없음 | 없음 |
| 데이터 정합성 | ❌ 파괴 | ✅ 100% 보장 | ✅ 100% 보장 |
| 최대 응답시간 | ~3,837ms | ~31,447ms | ~31,447ms |
| 중간값 응답시간 | 8ms | - | 2,300ms |
| 95%ile 응답시간 | 68ms | ~30,000ms | 19,000ms |
| 최대 RPS | ~165 | ~95 | ~180 |
| 총 처리 요청 수 | 10,032 | 1,424 | 2,187 |
| 실패 원인 | PositiveIntegerField 제약 위반 | Lock Contention (DB 락 경합) | Auth DB 커넥션 고갈 |
| 병목 위치 | 재고 UPDATE | select_for_update 대기 | JWT 인증 User.objects.get |
개선 흐름을 한 문장으로
- v1 → v2: 데이터 무결성을 확보했지만, DB 락 경합으로 성능이 급락했다.
- v2 → v3: DB 락을 Redis로 대체해 처리량(RPS)을 약 90% 향상시켰고, 병목이 주문 로직에서 인증 계층으로 이동했다.
- 발견한 인사이트: 트래픽 병목은 단계적으로 드러난다. 가장 큰 병목을 제거하면 그 뒤에 숨어있던 다음 병목이 나타난다.
5. v4 계획 — 마지막 병목을 없애기 위해
v3에서 드러난 마지막 병목의 원인은 명확합니다.
JWT 인증 시 매 요청마다 DB에서 User를 조회
→ 500명 동시 요청 = 500번의 DB 쿼리
→ PostgreSQL 커넥션 풀(100개) 고갈
→ FATAL: too many clients already
JWT의 원래 장점은 토큰 안에 user_id가 이미 담겨 있다는 점입니다. 서명(Signature)이 유효하고 만료되지 않았다면, 굳이 DB에서 유저의 존재를 다시 확인하지 않아도 됩니다.
v4에서는 커스텀 JWT 인증 클래스를 만들어 이 불필요한 DB 조회를 제거합니다.
[현재 v3 인증 흐름]
요청 → 토큰 유효성 검사 → DB User 조회 ← 병목
[v4 목표 인증 흐름]
요청 → 토큰 유효성 검사 → 토큰에서 user_id 직접 추출 → DB 조회 없이 통과
이렇게 되면 500명이 동시에 접근해도 인증 단계에서 DB 커넥션을 소비하지 않습니다. DB 커넥션은 오직 실제 결제가 확정된 100명만 사용하게 됩니다. 이론상 에러율이 0%에 수렴해야 합니다.
6. 정리
오늘 확인한 것을 요약하면:
- v3에서 Redis 대기열이 DB 락을 완전히 제거해 처리량(RPS)이 v2 대비 약 90% 향상됐습니다.
- 초과 판매는 v2와 마찬가지로 0건으로 데이터 정합성은 완벽히 지켰습니다.
- 병목이 주문 로직에서 JWT 인증 계층으로 이동했습니다. Django SimpleJWT가 매 요청마다 DB에서 User를 조회하는 것이 원인이었습니다.
- v3의 실패율(70.92%)이 v2(61.87%)보다 높아 보이는 것은 서버가 더 빨리 처리하면서 더 많은 요청이 몰렸기 때문입니다.
v1부터 v3까지, 이 프로젝트는 동시성 문제를 해결하는 과정에서 단계마다 새로운 병목을 발견하고 개선해왔습니다. 다음 포스트에서는 커스텀 JWT 인증 클래스를 구현해 인증 DB 조회를 제거하고, 500명 동시 요청에서 에러율 0%를 목표로 하는 v4를 완성합니다. 🚀
