이 글에서 다루는 내용

  • Locust 설치 및 locustfile.py 작성하기
  • 테스트용 더미 유저 500명 생성하기
  • 실제 부하 테스트 실행 및 결과 분석
  • 무엇이 문제였는지, 다음에 어떻게 해결할 것인지

1. Locust란?

Locust는 Python으로 부하 테스트 시나리오를 코드로 작성할 수 있는 오픈소스 도구입니다. 수백~수천 명의 가상 사용자를 만들어 동시에 API를 호출하는 시나리오를 재현할 수 있습니다. 테스트가 실행되는 동안 실시간으로 RPS(초당 요청 수), 응답 시간, 실패율을 웹 UI로 확인할 수 있습니다.

requirements.txt에 추가하고 설치합니다.

# Load Testing
locust>=2.24.0
pip install -r requirements.txt

2. 더미 유저 500명 생성하기

부하 테스트는 500명의 가상 유저가 동시에 구매를 시도하는 시나리오입니다. 테스트 전에 Django shell로 더미 계정을 미리 만들어둡니다.

python manage.py shell
from django.contrib.auth.hashers import make_password
from django.contrib.auth import get_user_model

User = get_user_model()

# 비밀번호 해싱은 무거우므로 한 번만 미리 만들어 둡니다.
hashed_password = make_password('testpass123!')

# dummy1 ~ dummy500 까지 500명의 유저 객체 생성
users = [User(username=f'dummy{i}', password=hashed_password) for i in range(1, 501)]

# bulk_create로 한 번에 DB에 밀어 넣기
User.objects.bulk_create(users, ignore_conflicts=True)
print("500명의 더미 유저 생성 완료!")

루프를 돌며 User.objects.create()를 500번 호출하는 대신 bulk_create()를 사용했습니다. DB 쿼리를 단 한 번으로 줄여 생성 속도가 압도적으로 빠릅니다. ignore_conflicts=True는 이미 존재하는 유저가 있어도 오류 없이 건너뜁니다.

비밀번호 해싱(make_password)을 루프 밖에서 한 번만 실행한 것도 같은 이유입니다. 해싱 연산은 무거운 작업이라 500번 반복하면 체감될 만큼 느려집니다.


3. locustfile.py 작성하기

프로젝트 루트에 locustfile.py를 만듭니다.

from locust import HttpUser, task, between
import random

class HotItemShopper(HttpUser):
    wait_time = between(0.5, 1.0)
    token = ""

    def on_start(self):
        """
        가상 유저 생성 시 1~500번 중 랜덤으로 한 명의 유저를 선택하여 로그인
        """
        user_id = random.randint(1, 500)
        login_data = {
            "username": f"dummy{user_id}",
            "password": "testpass123!"
        }

        response = self.client.post("/api/accounts/login/", json=login_data)
        if response.status_code == 200:
            self.token = response.json().get("access")
        else:
            print(f"로그인 실패: dummy{user_id}")

    @task
    def buy_limited_item(self):
        """
        실제 부하 테스트: 각기 다른 유저들이 하나의 상품(ID: 1)을 동시에 구매 시도
        """
        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}")

코드 구조를 살펴봅니다.

on_start(): 각 가상 유저가 처음 생성될 때 딱 한 번 실행됩니다. 1~500 중 랜덤으로 더미 유저를 골라 로그인하고 Access Token을 저장합니다.

@task로 표시된 buy_limited_item(): 실제 반복 실행되는 부하 작업입니다. 저장해둔 토큰으로 상품 ID 1번을 반복 주문합니다.

catch_response=True와 수동 성공/실패 처리: 재고 소진으로 인한 400 응답은 서버가 정상적으로 처리한 결과이므로 response.success()로 기록합니다. 500 같은 예기치 않은 오류만 response.failure()로 기록합니다.

테스트 실행 명령어는 다음과 같습니다.

locust -f locustfile.py --host=http://localhost:8001

http://localhost:8089에 접속해 Locust 웹 UI를 열고, Number of Users 500, Spawn rate 100으로 설정해 테스트를 시작합니다.


4. 테스트 결과 분석

📊 Statistics

항목 /api/accounts/login/ /api/shop/orders/

총 요청 수 500 10,032
실패 수 372 100
중간값 응답시간 1,100ms 8ms
95%ile 응답시간 3,300ms 68ms
평균 응답시간 1,541ms 55ms

주문 API의 응답 시간 자체는 빠릅니다. 중간값 8ms, 95%ile 68ms로 단순 조회/저장 성능은 준수합니다. 하지만 실패가 100건 발생했고, 이는 재고 소진 400이 아닌 서버 내부 오류(500) 입니다.


📈 Charts

RPS는 약 165~175 수준으로 전반적으로 안정적이었습니다. 그런데 테스트 초반(6:02:40~6:02:50 구간)에 응답 시간이 크게 튀는 구간이 있습니다. 95th percentile이 최대 3,000ms까지 치솟았다가 이후 급격히 안정됩니다.

이 구간은 500명의 유저가 동시에 on_start()의 로그인 요청을 일제히 쏟아내는 시점입니다. 로그인이 완료되어 토큰을 확보한 이후에는 주문 API만 반복되면서 응답 시간이 정상으로 돌아옵니다.


❌ Failures

건수 엔드포인트 오류 메시지

353 /api/accounts/login/ ConnectionResetError(104, 'Connection reset by peer')
19 /api/accounts/login/ HTTPError('500 Server Error')
100 /api/shop/orders/ CatchResponseError('에러 발생: 500')

실패가 두 군데에서 발생했습니다.

① 로그인 실패 (372건)

500명이 테스트 시작과 동시에 로그인 요청을 일제히 전송하면서 Django 개발 서버가 커넥션을 감당하지 못해 ConnectionResetError가 발생했습니다. Django의 기본 개발 서버(runserver)는 단일 스레드 기반으로 대규모 동시 요청에 취약합니다. 19건의 500 에러도 같은 원인의 서버 과부하입니다.

이 문제는 주문 로직의 문제가 아닌 테스트 설계의 문제입니다. v2 테스트에서는 가상 유저를 시작 전에 미리 로그인시켜 토큰을 세팅해두는 방식으로 로그인 병목을 제거하고 주문 API에만 집중합니다.

② 주문 실패 (100건, 500 에러)

이것이 이 테스트에서 확인하고자 했던 핵심 문제입니다. 재고 소진으로 인한 정상적인 400이 아니라 서버 내부 오류입니다.

v1의 주문 처리 흐름은 세 단계입니다.

① SELECT: DB에서 stock 읽기
② 판단:   Application 레벨에서 stock > 0 확인
③ UPDATE: stock을 1 줄이고 저장

수백 명이 동시에 ①을 실행하면 모두 같은 stock 값을 읽습니다. 아직 아무도 ③을 실행하지 않은 시점이므로, 모두가 ②에서 "구매 가능"이라고 판단합니다.

유저 A: ① stock = 1 읽음
유저 B: ① stock = 1 읽음   ← A가 아직 저장하기 전
유저 A: ② stock > 0 → 구매 진행
유저 B: ② stock > 0 → 구매 진행  ← 둘 다 통과
유저 A: ③ stock = 0 저장
유저 B: ③ stock = -1 저장  ← PositiveIntegerField 제약 위반 → 500 에러

이후 ③에서 동시에 stock을 감소시키다가 음수로 내려가는 순간 PositiveIntegerField 제약 조건에 걸려 DB가 오류를 던집니다. 이것이 주문 API 500 에러의 원인입니다.


🗄️ DB 결과 — 초과 판매의 증거

DB를 직접 열어보면 문제가 숫자로 드러납니다.

items 테이블을 보면 초기 재고 100개였던 "나이키 한정판 조던 1"의 잔여 재고가 0입니다. 그런데 orders 테이블에는 결제 완료 상태의 주문이 100건을 훌쩍 넘겨 쌓여 있습니다.

재고 0인 상품에 100건 이상의 주문이 생성된 것, 이것이 초과 판매(Overselling)의 명확한 수치적 증거입니다. DB 제약 조건 덕분에 500 에러로 일부는 막혔지만, 그 전에 이미 재고를 초과한 주문들이 생성되어버린 상태였습니다.


5. 확인된 문제 요약

문제 원인 영향

로그인 병목 500명이 동시에 로그인 → 단일 스레드 서버 과부하 372건 로그인 실패, 초반 응답시간 급등
Race Condition DB 락 없이 읽기-확인-쓰기 분리 실행 100건 500 에러, 재고 초과 주문 발생

로그인 병목은 테스트 설계 문제로 v2에서 분리하고, Race Condition이 이 프로젝트가 해결해야 할 핵심 문제입니다.


6. 정리

오늘 확인한 것을 요약하면:

  • bulk_create()와 사전 해싱으로 500명의 더미 유저를 빠르게 생성했습니다.
  • Locust로 500명 동시 구매 시나리오를 재현했습니다.
  • 테스트 초반 로그인 병목으로 응답 시간이 급등했고, 주문 API에서 100건의 500 에러가 발생했습니다.
  • stock이 음수로 내려가려는 순간 PositiveIntegerField 제약에 걸린 것이 직접 원인이며, 근본 원인은 DB 락 없는 읽기-판단-쓰기 분리 구조입니다.
  • Django Admin에서 재고 0에 100건 이상의 완료 주문을 확인, 초과 판매를 수치로 증명했습니다.

v1은 예상대로 실패했습니다. 다음 포스트에서 DB 락으로 이 문제를 해결한 v2를 구현하고, 동일 조건에서 결과가 어떻게 달라지는지 직접 비교합니다. 🚀

 

+ Recent posts