[Hot Item] #12. 프로젝트 최종 회고 — v1부터 v4까지, 우리가 증명한 것들
이 글에서 다루는 내용
- v4 결과 분석 — 병목은 어디로 또 이동했나
- v1 → v2 → v3 → v4 전체 수치 비교
- 이 프로젝트가 증명한 것 — 백엔드 최적화의 본질
- 마치며 — 멈추지 않는 이유
1. v4 — DRF Serializer의 숨겨진 DB 조회
v3에서 확인한 병목은 JWT 인증 계층이었습니다. SimpleJWT가 매 요청마다 User.objects.get()을 실행해 DB 커넥션을 소진하고 있었습니다. v4에서는 커스텀 JWT 인증 클래스를 만들어 이 DB 조회를 제거했습니다.
# accounts/authentication.py
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from django.contrib.auth.models import AnonymousUser
class StatelessJWTAuthentication(JWTAuthentication):
"""
DB 조회 없이 토큰의 유효성만 검사하는 커스텀 인증 클래스.
토큰이 유효하면 DB를 거치지 않고 user_id만 추출하여 반환합니다.
"""
def get_user(self, validated_token):
from django.contrib.auth import get_user_model
User = get_user_model()
user_id = validated_token.get("user_id")
# DB 조회 없이 user_id만 가진 껍데기 유저 객체 반환
user = User()
user.id = user_id
user.pk = user_id
return user
이론상 인증 단계의 DB 조회가 사라지면 커넥션 풀 고갈도 사라져야 했습니다. 그런데 테스트 결과를 열어보니 여전히 같은 에러가 발생했습니다.
2. v4 테스트 결과 분석
📊 Statistics
항목 값
| 총 요청 수 | 1,550 |
| 실패 수 | 1,031 (66.5%) |
| 중간값 응답시간 | 2,300ms |
| 95%ile 응답시간 | 32,000ms |
| 99%ile 응답시간 | 44,000ms |
| 평균 응답시간 | 10,653ms |
| 최소 응답시간 | 5ms |
| 최대 응답시간 | 44,605ms |
📈 Charts
RPS가 초반 최대 약 155까지 올라가다가 빠르게 하락합니다. 응답 시간은 v3와 마찬가지로 우상향을 멈추지 않으며 종료 시점에 50th percentile 27,000ms, 95th percentile 37,000ms에 달합니다.
❌ Failures
건수 오류 메시지
| 549 | CatchResponseError('에러 발생: 500') |
| 482 | CatchResponseError('에러 발생: 0') |
여전히 FATAL: sorry, too many clients already입니다. 인증 DB 조회를 없앴는데도 왜 같은 에러가 날까요?
3. 마지막 범인 — DRF Serializer의 숨겨진 DB 조회
서버 로그의 스택 트레이스가 이번에도 정확히 범인을 가리킵니다.
File "/app/shop/views.py", line 127, in post
serializer.is_valid(raise_exception=True) ← 1. 유효성 검사 시작
File ".../rest_framework/relations.py", line 259, in to_internal_value
return queryset.get(pk=data) ← 2. 진짜 범인 등장
django.db.utils.OperationalError: FATAL: sorry, too many clients already
에러가 발생한 곳은 인증이 아니라 OrderSerializer.is_valid() 입니다.
v1에서 OrderSerializer를 만들 때 item_id 필드를 PrimaryKeyRelatedField로 정의했습니다.
item_id = serializers.PrimaryKeyRelatedField(
queryset=Item.objects.all(),
source='item',
write_only=True
)
DRF는 이 필드를 검증할 때 클라이언트가 보낸 item_id 값이 실제로 DB에 존재하는지 자동으로 확인합니다. 즉 is_valid()가 호출될 때마다 내부적으로 Item.objects.get(pk=1) 쿼리가 실행되고 있었던 것입니다.
병목이 이동한 전체 경로를 정리하면 다음과 같습니다.
v2: 주문 로직 → select_for_update() DB 락 경합으로 커넥션 고갈
v3: 인증 미들웨어 → User.objects.get() DB 조회로 커넥션 고갈 (v4에서 제거)
v4: Serializer → Item.objects.get() DB 조회로 커넥션 고갈
v3에서 인증 병목을 제거하자, 그동안 인증 뒤에 숨어있던 Serializer의 DB 조회가 다음 병목으로 수면 위에 드러났습니다. 트래픽은 물처럼 항상 가장 좁은 곳을 찾아냅니다.
4. v1 → v2 → v3 → v4 전체 수치 비교
테스트 환경: 동시 접속 유저 500명, Spawn rate 100/s, 재고 100개 단일 상품
지표 v1 v2 v3 v4
| 핵심 변경사항 | 순수 RDBMS | Pessimistic Lock | Redis 대기열 | 커스텀 JWT 인증 |
| 초과 판매 | ❌ 발생 | ✅ 없음 | ✅ 없음 | ✅ 없음 |
| 데이터 정합성 | ❌ 파괴 | ✅ 100% | ✅ 100% | ✅ 100% |
| 최대 RPS | ~165 | ~95 | ~180 | ~155 |
| 중간값 응답시간 | 8ms | - | 2,300ms | 2,300ms |
| 95%ile 응답시간 | 68ms | ~30,000ms | 19,000ms | 32,000ms |
| 총 처리 요청 수 | 10,032 | 1,424 | 2,187 | 1,550 |
| 실패율 | ~1% (500 에러) | 61.87% | 70.92% | 66.5% |
| 병목 위치 | 재고 UPDATE | DB 락 경합 | JWT 인증 DB 조회 | Serializer DB 조회 |
| 핵심 에러 | PositiveIntegerField 위반 | too many clients | too many clients | too many clients |
단계별 개선의 핵심
v1 → v2: Race Condition 제거, 데이터 정합성 100% 확보. 대신 Lock Contention으로 처리량이 급락했다.
v2 → v3: DB 락을 Redis 원자적 연산으로 대체. 최대 RPS가 95 → 180으로 약 90% 향상됐다. 병목이 주문 로직에서 인증 계층으로 이동했다.
v3 → v4: 인증 단계의 DB 조회 제거. 프레임워크 내부(Serializer)에 숨어있던 마지막 DB 조회가 수면 위로 드러났다.
5. 이 프로젝트가 증명한 것
수치보다 더 중요한 것은 이 과정에서 배운 것들입니다.
"나도 모르게 발생하는 DB 쿼리를 경계하라"
이 프로젝트에서 발견한 병목 세 곳 중 두 곳은 직접 작성한 코드가 아니었습니다. Django SimpleJWT의 get_user()와 DRF의 PrimaryKeyRelatedField, 모두 프레임워크가 내부에서 조용히 실행하는 DB 쿼리였습니다. 저부하 환경에서는 전혀 문제가 되지 않지만, 고부하 환경에서는 이런 숨겨진 쿼리 하나가 시스템 전체를 마비시킵니다.
"병목은 항상 다음 병목을 숨기고 있다"
1차 병목(DB 락) 제거 → 2차 병목(인증 DB 조회) 노출
2차 병목(인증 DB 조회) 제거 → 3차 병목(Serializer DB 조회) 노출
트래픽은 물처럼 가장 좁은 곳을 찾아냅니다. 하나의 병목을 해소하면 반드시 그 뒤에 숨어있던 다음 병목이 드러납니다. 이것이 백엔드 최적화가 끝이 없는 이유이자, 끝없이 깊어지는 이유입니다.
"실패한 테스트가 성공한 테스트보다 더 많은 것을 가르친다"
v4까지 에러율 0%를 달성하지는 못했습니다. 그러나 각 버전에서 서버가 뻗을 때마다 스택 트레이스를 따라가며 정확히 어디서, 왜 무너졌는지를 로그로 확인했습니다. 이것이 시스템을 깊이 이해하는 방법입니다.
6. 앞으로 — 여기서 멈추지 않는 이유
v4를 끝으로 Hot Item 프로젝트의 1차 구현은 마무리됩니다. 하지만 완전히 끝난 것이 아닙니다.
v4에서 남은 병목인 Serializer DB 조회 문제와, 그보다 근본적인 DB 커넥션 풀 한계 문제는 애플리케이션 레벨의 튜닝만으로는 해결에 한계가 있습니다. 다음 단계로 가려면 아키텍처 자체가 바뀌어야 합니다.
현재 구조 (동기 처리)
유저 요청 → 인증 → Serializer 검증 → Redis → DB 저장 → 응답
목표 구조 (비동기 처리)
유저 요청 → Redis 대기열 등록 → 즉시 응답 (0.01초)
↓ (비동기)
Celery Worker → Serializer 검증 → DB 저장
Message Queue(Celery + Redis/RabbitMQ 또는 Kafka) 를 도입하면 DB 쓰기 작업 자체를 비동기 워커에게 넘길 수 있습니다. 사용자는 요청 즉시 응답을 받고, 실제 DB 처리는 워커들이 뒤에서 안전하게 처리합니다. 이렇게 되면 DB 커넥션은 워커 수만큼만 소비되고, 500명이 동시에 몰려와도 연결 풀이 고갈되지 않습니다.
Message Queue에 대한 학습을 마친 뒤, 이 프로젝트에 v5로 적용할 계획입니다. 그때 다시 같은 조건으로 부하 테스트를 돌려 v1부터 v5까지의 수치를 완성할 예정입니다.
7. 프로젝트 최종 정리
이 프로젝트를 한 문장으로 요약하면 이렇습니다.
"단순한 CRUD를 넘어, 동시성 문제를 직접 만들고, 부수고, 한 단계씩 개선하며, 매 단계마다 왜 뻗었는지를 로그로 증명한 프로젝트"
v1에서 의도적으로 Race Condition을 만들고, v2에서 DB 락으로 정합성을 지키고, v3에서 Redis로 처리량을 높이고, v4에서 프레임워크 내부의 숨겨진 DB 쿼리를 발견하기까지 — 이 모든 과정이 코드 한 줄보다 더 많은 것을 말해줍니다.
긴 시리즈를 함께 해주셔서 감사합니다. 🚀
📌 시리즈 전체 목차
- #1. 프로젝트 소개 & Docker 환경 구성
- #2. requirements.txt, Dockerfile, docker-compose.yml 작성하기
- #3. Dev Container로 VS Code 개발 환경 컨테이너 안으로 옮기기
- #4. settings.py 환경 변수 연동, PostgreSQL 마이그레이션, Redis 연결 확인
- #5. DRF + Swagger 문서 자동화, 커스텀 User 모델, JWT 인증 설정
- 트러블슈팅 #1. AUTH_USER_MODEL 변경 후 migrate 실패
- #6. 회원가입 / 로그인 / 로그아웃 API 구현 및 Swagger UI 테스트
- #7. shop 앱 생성 — Item / Order 모델 설계 및 Django Admin 등록
- #8. 선착순 구매 API v1 — 순수 RDBMS, 그리고 의도된 실패
- #9. v1 부하 테스트 — Race Condition을 눈으로 확인하다
- #10. v2 부하 테스트 — 정합성은 지켰지만, 서버가 뻗었다
- #11. v3 부하 테스트 — Redis가 락을 없앴지만, 새로운 병목이 나타났다
- #12. 프로젝트 최종 회고 — v1부터 v4까지, 우리가 증명한 것들 ← 현재 글


