[Hot Item] #10. v2 부하 테스트 — 정합성은 지켰지만, 서버가 뻗었다
이 글에서 다루는 내용
- v2 코드: select_for_update()로 Race Condition 차단하기
- 테스트 전 데이터 초기화 및 로그인 병목 해결 방법 개선
- v2 부하 테스트 결과 분석
- 데이터 정합성은 지켰지만 드러난 새로운 문제 — Lock Contention
- v3로 넘어가야 하는 이유
1. v2 코드 — select_for_update() 적용
v1의 문제는 DB 락 없이 읽기-판단-쓰기가 분리되어 Race Condition이 발생한 것이었습니다. v2에서는 select_for_update()와 @transaction.atomic을 적용해 이를 해결합니다.
from django.db import transaction
class OrderCreateView(APIView):
permission_classes = [IsAuthenticated]
@transaction.atomic
def post(self, request, *args, **kwargs):
serializer = OrderSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# [V2 개선: select_for_update 적용]
# 해당 Row에 락을 걸어 다른 트랜잭션이 접근하지 못하게 차단합니다.
item = Item.objects.select_for_update().get(id=serializer.validated_data['item'].id)
if item.stock > 0:
item.stock -= 1
item.save()
order = Order.objects.create(
user=request.user,
item=item,
status=Order.Status.COMPLETED
)
return Response(
{"message": "주문 완료", "order_id": order.id, "remain_stock": item.stock},
status=status.HTTP_201_CREATED
)
else:
return Response({"error": "재고 부족"}, status=status.HTTP_400_BAD_REQUEST)
v1과의 차이는 단 두 가지입니다.
@transaction.atomic: 이 데코레이터가 붙은 함수는 내부의 모든 DB 작업이 하나의 트랜잭션으로 묶입니다. 중간에 오류가 생기면 전체가 롤백되어 데이터가 절반만 반영되는 상황을 막아줍니다.
select_for_update(): 일반 get()이 단순 SELECT라면, select_for_update()는 SELECT ... FOR UPDATE를 실행합니다. 해당 row에 배타적 락(Exclusive Lock) 을 걸어, 이 트랜잭션이 끝날 때까지 다른 트랜잭션이 같은 row를 읽거나 수정하지 못하게 차단합니다. 화장실 문을 잠그는 것처럼, 내가 나올 때까지 다른 사람은 밖에서 기다립니다.
v1: 읽기(락 없음) → 판단 → 쓰기 ← 여러 트랜잭션이 동시에 같은 값을 읽음
v2: 읽기(락 걸기) → 판단 → 쓰기 ← 한 트랜잭션씩 순서대로 처리
2. 테스트 전 준비 — 데이터 초기화 & 로그인 병목 제거
데이터 초기화
v1 테스트로 쌓인 주문 데이터를 정리하고 재고를 다시 100개로 충전합니다.
python manage.py shell
from shop.models import Item, Order
Order.objects.all().delete()
Item.objects.filter(id=1).update(stock=100)
print("데이터 초기화 완료: 재고 100, 주문 0")
여기서 한 가지 주목할 점이 있습니다. delete()는 테이블의 row(데이터)만 삭제합니다. PostgreSQL 내부에는 ID를 자동으로 증가시키는 시퀀스(Sequence) 라는 별도의 카운터가 있는데, 이 카운터는 데이터를 지워도 초기화되지 않습니다.
그래서 v1 테스트 후 delete()를 실행하고 v2 테스트를 돌리면, 새로운 주문의 ID가 1번이 아니라 이전 테스트의 마지막 ID 다음 번호부터 시작합니다. 데이터 정합성에 영향을 주진 않지만, 테스트 결과를 분석할 때 혼란스러울 수 있습니다.
💡 v3 테스트 전에는 TRUNCATE를 사용합니다.
TRUNCATE는 row 삭제와 동시에 시퀀스(ID 카운터)까지 1로 초기화합니다. v3 테스트를 깔끔하게 시작하기 위해 이 시점에는 TRUNCATE를 사용할 예정입니다.
로그인 병목 제거 — 토큰 사전 발급
v1 테스트에서 발생한 가장 큰 노이즈는 500명이 동시에 로그인하면서 생긴 병목이었습니다. 주문 로직이 아닌 로그인 서버 과부하가 결과를 오염시켰습니다.
v2에서는 테스트 시작 전에 Django shell에서 미리 500개의 Access Token을 발급해 파일에 저장합니다.
from django.contrib.auth import get_user_model
from rest_framework_simplejwt.tokens import RefreshToken
User = get_user_model()
with open('access_tokens.txt', 'w') as f:
for i in range(1, 501):
user = User.objects.get(username=f'dummy{i}')
access_token = RefreshToken.for_user(user).access_token
f.write(f"{str(access_token)}\n")
print("500개의 액세스 토큰이 access_tokens.txt에 저장되었습니다.")
locustfile.py도 수정합니다. on_start()에서 로그인 요청을 보내는 대신, 미리 저장된 파일에서 토큰을 읽어 사용합니다.
from locust import HttpUser, task, between
import random
# 테스트 시작 전 파일에서 토큰 목록 로드
with open("access_tokens.txt", "r") as f:
TOKENS = [line.strip() for line in f.readlines()]
class HotItemShopper(HttpUser):
wait_time = between(0.1, 0.5)
def on_start(self):
# 로그인 요청 없이 미리 발급된 토큰 중 하나를 선택
self.token = random.choice(TOKENS)
@task
def buy_limited_item(self):
if self.token:
headers = {"Authorization": f"Bearer {self.token}"}
payload = {"item_id": 1}
with self.client.post(
"/api/shop/orders/",
json=payload,
headers=headers,
catch_response=True
) as response:
if response.status_code == 201:
response.success()
elif response.status_code == 400:
response.success()
else:
response.failure(f"에러 발생: {response.status_code}")
이제 테스트 트래픽이 100% 주문 API에만 집중됩니다.
3. v2 테스트 결과 분석
📈 Charts
그래프가 v1과 완전히 다른 모양입니다.
v1에서는 초반 로그인 병목 구간을 제외하면 응답 시간이 빠르게 안정됐습니다. v2에서는 반대입니다. 테스트가 진행될수록 응답 시간이 우상향을 멈추지 않고 계속 치솟습니다.
95th percentile 응답 시간이 테스트 막판에는 25,000~30,000ms(25~30초) 에 육박했습니다. 50th percentile(중간값)조차 테스트 종료 시점에 약 18,000ms(18초) 에 달했습니다. 사용자의 절반이 결제 버튼을 누르고 18초를 기다렸다는 뜻입니다.
RPS(초당 요청 수)도 최대 약 95까지 올라갔다가 이후 점점 감소합니다. 서버가 요청을 처리하는 속도보다 대기열에 쌓이는 속도가 더 빨라진 상태입니다.
❌ Failures
건수 엔드포인트 오류 메시지
| 497 | /api/shop/orders/ | CatchResponseError('에러 발생: 500') |
| 384 | /api/shop/orders/ | CatchResponseError('에러 발생: 0') |
v1의 실패와 성격이 다릅니다.
v1의 500 에러는 PositiveIntegerField에 음수를 넣으려다 발생한 데이터 제약 위반이었습니다.
v2의 500 에러와 0 에러는 서버 로그에서 원인이 드러납니다.
FATAL: sorry, too many clients already
PostgreSQL의 기본 최대 동시 연결 수는 100개입니다. 그런데 지금 무슨 일이 벌어지고 있는지 순서대로 따라가봅니다.
① 500명이 동시에 주문 요청을 보냄
② Django: 요청마다 PostgreSQL 연결(Connection) 하나씩 확보
③ 첫 번째 트랜잭션: select_for_update()로 'item' row에 락 획득
④ 나머지 499개 트랜잭션: 같은 row에 락을 걸려다 대기 상태로 진입
⑤ 대기 중인 트랜잭션들이 PostgreSQL 연결을 쥔 채 놓아주지 않음
⑥ PostgreSQL 연결 100개 순식간에 소진
⑦ 101번째 요청부터: "연결선이 없어!" → FATAL: too many clients already
⑧ Django → 500 Internal Server Error 반환
⑨ 일부는 너무 오래 기다리다 클라이언트 측에서 연결 강제 종료 → 에러 코드 0 (Broken pipe)
이것이 Lock Contention(락 경합)입니다. 락 자체가 문제가 아니라, 락을 기다리는 트랜잭션들이 DB 연결을 점유한 채 대기하면서 연결 풀을 고갈시키는 것이 문제입니다.
4. 그래도 v2가 v1보다 나은 이유 — 데이터 정합성
성능 수치만 보면 v2가 v1보다 훨씬 나빠 보입니다. 그런데 가장 중요한 결과를 확인해야 합니다.
v2 테스트 후 Django Admin에서 items 테이블을 확인하면 잔여 재고가 정확히 0입니다. orders 테이블의 결제 완료 주문은 정확히 100건입니다.
v1에서는 재고 0인데 주문이 100건을 넘었습니다. v2에서는 재고 100개에 주문 정확히 100건, 단 1건의 초과 판매도 발생하지 않았습니다.
select_for_update()는 성능을 희생하는 대신 데이터 정합성을 완벽하게 지켰습니다.
항목 v1 v2
| 초과 판매 여부 | 발생 (100건 초과) | 없음 (정확히 100건) |
| 500 에러 원인 | PositiveIntegerField 제약 위반 | DB 연결 풀 고갈 |
| 응답 시간 (95%ile) | ~68ms (주문 API) | ~30,000ms |
| 실패율 | 낮음 | 61.87% |
5. v2의 결론 — RDBMS 락의 한계
v2가 보여준 것은 명확합니다.
"RDBMS의 비관적 락은 정합성을 보장하지만, 대규모 동시 트래픽에서는 DB 연결 풀을 고갈시켜 서버 전체를 마비시킨다."
근본 원인은 DB 커넥션이 유한한 자원이라는 점입니다. 락을 기다리는 트랜잭션들이 각자 커넥션을 하나씩 붙잡고 대기하는 구조에서는, 동시 사용자가 커넥션 한도를 넘는 순간 시스템이 무너집니다.
이 문제를 해결하려면 DB에 도달하기 전에 트래픽을 걸러내는 단계가 필요합니다. 수천 명이 동시에 DB 문을 두드리게 하는 대신, 앞단에서 순서를 정리하고 한 명씩 입장시키는 구조가 필요합니다.
이것이 v3에서 Redis 대기열을 도입하는 이유입니다.
6. v3 예고 — Redis 대기열로 병목 해소
v3에서 적용할 전략은 다음과 같습니다.
[v2] 500명 → 동시에 DB 도달 → 락 경합 → 연결 풀 고갈
[v3] 500명 → Redis 대기열(메모리) → 순서 정렬 → DB는 한 번에 하나씩
Redis는 인메모리(In-Memory) 기반으로 DB보다 수십~수백 배 빠릅니다. 또한 Sorted Set의 원자적 연산을 활용해 대기열 진입과 순번 확인을 네트워크 요청 한 번으로 처리할 수 있습니다. DB에 집중되던 락 부하를 Redis가 흡수하고, DB는 실제 결제 처리만 담당하도록 역할을 분리합니다.
7. 정리
오늘 확인한 것을 요약하면:
- select_for_update() + @transaction.atomic으로 Race Condition을 완전히 차단해 정확히 100건의 주문만 처리됐습니다.
- delete()는 row만 삭제하고 시퀀스(ID 카운터)는 초기화하지 않습니다. v3 테스트 전에는 TRUNCATE를 사용합니다.
- 로그인 병목 문제는 토큰을 사전 발급해 파일에 저장하는 방식으로 해결하고, 테스트 트래픽을 주문 API에만 집중시켰습니다.
- Lock Contention으로 인해 PostgreSQL 연결 풀이 고갈되어 61.87%의 실패율과 최대 30초의 응답 지연이 발생했습니다.
- 데이터 정합성은 v2가 완벽하지만, 성능 병목은 RDBMS 락만으로는 해결할 수 없음을 수치로 확인했습니다.
v2는 "정합성은 지킬 수 있다"는 것을 증명했고, 동시에 "RDBMS 락만으로는 대규모 트래픽을 감당할 수 없다"는 것도 증명했습니다. 다음 포스트에서는 Redis Sorted Set 대기열로 이 문제를 해결한 v3를 구현합니다. 🚀

