1강에서 개발 환경을 세팅했다면, 2강에서는 자바라는 언어 자체에 대해 좀 더 깊이 들여다봤다. 강의 내용과 함께 스스로 떠오른 궁금증들을 정리해 두려고 한다.


1. 프로그램의 시작점, main 메서드

자바 프로그램이 실행되면 JVM은 가장 먼저 main 메서드를 찾는다. 이전 포스팅에서도 잠깐 다뤘지만, 이번 강의에서 실행 흐름이 더 명확해졌다.

public class HelloJava {
    public static void main(String[] args) {
        System.out.println("Hello, Java!");
    }
}

실행 순서를 간단히 정리하면 이렇다.

자바 실행 → main 메서드 탐색 → main 블록 내 코드를 위에서 아래로 한 줄씩 실행

참고로 psvm은 IntelliJ에서 public static void main(String[] args)를 자동완성해주는 라이브 템플릿 단축키다.

클래스와 파일명은 반드시 일치해야 한다

자바는 대소문자를 엄격히 구분한다. 그리고 한 가지 더, public class의 이름과 파일명이 반드시 같아야 한다.

HelloJava.java  →  public class HelloJava  ✅
HelloJava.java  →  public class helloJava  ❌ (컴파일 에러)

클래스의 시작과 끝은 {} 블록으로 나타내며, main 메서드도 마찬가지로 {} 안에 실행할 코드를 담는다.


2. 자바란? — 표준 스펙과 구현의 분리

자바를 이해하는 데 있어 "표준 스펙"과 "구현체"의 개념을 아는 것이 꽤 중요하다.

자바 표준 스펙

자바는 "이렇게 만들어야 한다"는 설계 문서(표준 스펙)가 먼저 존재한다. 이 스펙은 자바 커뮤니티 프로세스(JCP) 를 통해 관리되며, 어떤 회사든 자바를 구현하려면 이 스펙을 반드시 따라야 한다.

표준 스펙에서 정의하는 주요 구성 요소는 다음과 같다.

  • 자바 컴파일러 — .java 파일을 .class 파일로 변환하는 규칙
  • 자바 실행 라이브러리 — 자바 프로그램이 사용하는 표준 API
  • 자바 가상 머신(JVM) — 바이트코드를 실제로 실행하는 가상 환경

다양한 자바 구현체

스펙을 기반으로 오라클, 아마존, Azul 등 여러 회사가 실제로 작동하는 자바를 직접 개발해 배포한다. 이들이 바로 JDK(Java Development Kit) 다.

배포판 제공사 특징

OpenJDK Oracle 자바의 레퍼런스 구현체
Amazon Corretto Amazon AWS 환경에 최적화
Azul Zulu Azul Systems 장기 지원(LTS) 버전에 강점

핵심은, 어떤 배포판을 사용하든 같은 스펙을 따르기 때문에 코드가 동일하게 동작한다는 점이다. 실무에서 오라클 OpenJDK를 쓰다가 Amazon Corretto로 바꿔도 대부분 문제가 없는 이유가 바로 이것이다.

이 개념은 나중에 "어떤 JDK 버전, 어떤 배포판을 선택할 것인가"를 결정해야 하는 상황이 올 때 큰 도움이 된다.


3. 컴파일과 실행 — .java에서 실행까지

파이썬은 .py 파일을 곧바로 실행할 수 있지만, 자바는 컴파일이라는 단계가 중간에 하나 더 들어간다.

Hello.java  →  (javac 컴파일)  →  Hello.class  →  (java 실행)  →  프로그램 동작
  • javac Hello.java : 소스 코드를 JVM이 이해할 수 있는 바이트코드(.class)로 변환한다. 이 과정에서 문법 오류도 함께 검출한다.
  • java Hello : 컴파일된 .class 파일을 JVM이 읽어 실행한다. (확장자 .class는 생략)

IntelliJ에서는 이 과정을 자동으로 처리한다

실행 버튼을 누르면 IntelliJ가 javac와 java 명령을 순서대로 대신 실행해준다. 1강에서 살펴봤던 out 폴더 안에 .class 파일이 생기는 것이 바로 컴파일의 결과물이다.

한 가지 눈여겨볼 점은, 컴파일된 .class 파일에는 소스 코드에서 작성했던 주석이 사라진다. 컴파일러가 최적화 과정에서 실행에 불필요한 정보를 제거하기 때문이다.


4. 자바와 운영체제 — "한 번 작성하면 어디서든 실행된다"

자바를 공부하다 보면 "OS 독립성" 이라는 표현을 자주 만나게 된다. 파이썬도 OS를 가리지 않는데 왜 자바만 유독 이를 강조하는지 궁금했다.

파이썬 vs 자바의 방식 차이

파이썬 자바

실행 방식 인터프리터가 코드를 직접 해석 JVM이 바이트코드를 실행
OS 독립성 구현 각 OS에 맞는 파이썬 인터프리터 설치 각 OS에 맞는 JVM 설치

둘 다 OS 독립적이지만, 방식이 다르다. 자바는 JVM이라는 별도의 추상화 계층을 통해 이를 구현한다.

왜 자바만 유독 강조하는가?

자바가 처음 등장한 1995년에는 프로그램이 특정 OS에 종속되는 것이 당연한 시대였다. 그 시절에 "JVM만 있으면 어디서든 동일하게 동작한다"는 Write Once, Run Anywhere(WORA) 철학을 내세웠고, 이것이 당시에는 혁명적인 개념이었기 때문에 지금까지도 자바의 상징처럼 언급된다.


5. 문자와 문자열 — ' vs " 는 완전히 다르다

파이썬에서는 'Hello'와 "Hello"를 구분 없이 사용하지만, 자바는 이 둘을 언어 명세(JLS) 수준에서 엄격하게 구분한다.

char String

분류 기본 자료형 (Primitive Type) 참조형 (Reference Type / 클래스)
표기 'A' (단일 따옴표) "ABC" (쌍따옴표)
저장 크기 2바이트 고정 내용에 따라 가변
빈 값 불가 (공백이라도 있어야 함) 가능 ("" 허용)
JLS 근거 §4.2.1, §3.10.4 §3.10.5

왜 이게 중요한가?

따옴표를 잘못 쓰면 컴파일 오류가 발생한다. 예를 들어 아래 코드는 에러다.

char c = "A";  // ❌ Incompatible types — "A"는 String 객체로 인식됨
char c = 'A';  // ✅ 정상

"A"는 문자가 하나뿐이어도 자바 컴파일러는 이를 java.lang.String 객체로 인식하기 때문이다. 파이썬의 유연함에 익숙하다면 처음에 실수하기 쉬운 부분이니 주의가 필요하다.

한 줄로 정리하면 이렇다.

자바에서 '는 문자 하나, "는 문자열. 용도가 다르면 타입이 다르다.


2강을 정리하면서 자바가 왜 "엄격하다"는 말을 듣는지 조금씩 체감되기 시작했다. 타입 하나, 따옴표 하나도 정확히 맞춰야 컴파일이 통과된다. 번거롭게 느껴지기도 하지만, 그 덕분에 실행 전에 오류를 잡아낼 수 있다는 점은 큰 장점이다.

다음은 본격적으로 변수와 자료형을 다뤄볼 예정이다.

'Java' 카테고리의 다른 글

Java 입문 1강 — 개발 환경 세팅하기  (0) 2026.05.11

파이썬 개발자로 일하다가 자바를 새로 공부하기 시작했다. 첫 강의에서는 IntelliJ 설치와 프로젝트 기본 구조를 다뤘는데, 파이썬과는 꽤 다른 부분들이 있어서 정리해 두려고 한다.


1. IntelliJ가 만들어주는 프로젝트 구조

프로젝트를 생성하면 IntelliJ가 자동으로 아래 세 폴더를 만들어준다.

  • src (Source) — 앞으로 작성할 .java 파일들이 모두 여기에 들어간다. 실제 비즈니스 로직이 위치하는 핵심 폴더다.
  • out — .java 파일을 컴파일한 결과물인 .class 파일이 저장되는 곳이다. 빌드할 때마다 자동으로 다시 생성되므로, 실수로 지워도 전혀 문제없다.
  • .idea — IntelliJ가 이 프로젝트를 관리하기 위한 설정 파일들이 담긴 폴더다. 직접 건드릴 필요 없고, 보통 Git에도 포함시키지 않는다 (.gitignore에 추가하는 게 일반적이다).

2. Main.java와 main 메서드 해부하기

프로젝트를 만들면 샘플 코드로 아래가 생성된다.

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

자바 프로그램이 실행되면 JVM(자바 가상 머신)은 가장 먼저 public static void main(String[] args) 시그니처를 찾는다. 이게 없으면 프로그램 자체가 실행되지 않는다.

각 키워드가 무엇을 의미하는지 하나씩 살펴보면 다음과 같다.

키워드 지금 단계에서의 의미 나중에 깊게 배울 개념

public 어디서든 이 메서드에 접근할 수 있다 접근 제어자
static 객체를 생성하지 않아도 바로 실행할 수 있다 정적 멤버, 메모리 영역
void 실행 후 반환할 값이 없다 리턴 타입
main 프로그램의 시작 지점이다 (메서드 이름) 메서드 정의
String[] args 프로그램 실행 시 외부에서 넘겨줄 수 있는 데이터 묶음 배열, 파라미터

지금 단계에서는 각 키워드의 역할 정도만 이해하고 넘어가도 충분하다. static이나 접근 제어자 같은 개념은 이후 강의에서 훨씬 자세히 다루게 된다.


3. 왜 모든 코드는 class 안에 있어야 할까?

파이썬은 .py 파일을 열고 바로 코드를 작성해도 실행된다. 하지만 자바는 "모든 코드는 반드시 클래스 안에 있어야 한다" 는 규칙이 언어 차원에서 강제된다.

파이썬 개발자 입장에서는 처음엔 다소 번거롭게 느껴질 수 있지만, 이 구조 덕분에 대규모 프로젝트에서 코드를 체계적으로 관리하기 쉬워진다고 한다.

한 가지 주의할 점은, 파일명과 클래스 이름이 반드시 일치해야 한다는 것이다.

Main.java  →  public class Main  ✅
Main.java  →  public class App   ❌ (컴파일 에러 발생)

첫 강의라 내용 자체는 가볍지만, 파이썬과의 차이를 의식하면서 보니 생각보다 흥미로웠다. 다음 강의부터는 본격적으로 자바의 자료형과 연산자를 다룰 예정이다.

이 글에서 다루는 내용

  • 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까지, 우리가 증명한 것들 ← 현재 글

이 글에서 다루는 내용

  • 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를 완성합니다. 🚀

 

이 글에서 다루는 내용

  • 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를 구현합니다. 🚀

 

이 글에서 다루는 내용

  • 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를 구현하고, 동일 조건에서 결과가 어떻게 달라지는지 직접 비교합니다. 🚀

 

이 글에서 다루는 내용

  • 왜 v1부터 시작하는지 — 성능 개선을 수치로 증명하는 전략
  • ItemSerializer, OrderSerializer 작성하기
  • ItemListView, OrderCreateView 구현하기
  • shop/urls.py와 config/urls.py 연결하기
  • v1의 구조적 한계 — Race Condition이란 무엇인가

1. 왜 처음부터 Redis를 쓰지 않나요?

이 프로젝트의 목적 중 하나는 성능 개선 과정을 수치로 보여주는 것입니다.

처음부터 Redis와 Celery를 모두 적용한 완성형 코드를 작성하면 "얼마나 좋아졌는가"를 비교할 기준이 없습니다. 그래서 아래와 같은 단계를 의도적으로 나눠 진행합니다.

v1: 순수 RDBMS 조회 → 초과 판매 발생 (Race Condition 확인)
v2: DB 락(Pessimistic Locking) 적용 → 정합성 확보, 성능 측정
v3: Redis 대기열 + Lua Script → 처리량 개선, 성능 비교

지금은 v1입니다. 일부러 문제가 생기도록 만들고, 이후 단계에서 하나씩 개선합니다.


2. shop/serializers.py

from rest_framework import serializers
from .models import *

class ItemSerializer(serializers.ModelSerializer):
    class Meta:
        model = Item
        fields = ('id', 'name', 'price', 'stock')


class OrderSerializer(serializers.ModelSerializer):
    item_id = serializers.PrimaryKeyRelatedField(
        queryset=Item.objects.all(),
        source='item',
        write_only=True
    )

    class Meta:
        model = Order
        fields = ('id', 'item_id', 'status', 'created_at')
        read_only_fields = ('id', 'status', 'created_at')

ItemSerializer

상품 목록 조회에 사용합니다. id, name, price, stock 네 필드만 노출합니다.

OrderSerializer

주문 생성 요청과 응답을 처리합니다. item_id 필드 설계를 자세히 살펴봅니다.

item_id = serializers.PrimaryKeyRelatedField(
    queryset=Item.objects.all(),
    source='item',
    write_only=True
)
  • PrimaryKeyRelatedField: 클라이언트가 Item 객체 전체가 아닌 item_id(숫자 하나)만 보내도 Django가 해당 Item 인스턴스를 자동으로 조회해줍니다.
  • source='item': 필드 이름은 item_id지만 내부적으로는 모델의 item 필드에 매핑됩니다.
  • write_only=True: 요청 바디에서는 받지만 응답 JSON에는 포함하지 않습니다.

read_only_fields = ('id', 'status', 'created_at')으로 이 세 필드는 응답에만 포함되고, 클라이언트가 임의로 값을 넣어도 무시됩니다.


3. shop/views.py

from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.views import APIView
from django.db import transaction
from .models import *
from .serializers import *


class ItemListView(generics.ListAPIView):
    queryset = Item.objects.all()
    serializer_class = ItemSerializer
    permission_classes = [AllowAny]


class OrderCreateView(APIView):
    permission_classes = [IsAuthenticated]

    from drf_spectacular.utils import extend_schema
    @extend_schema(request=OrderSerializer, responses={201: OrderSerializer})
    def post(self, request, *args, **kwargs):
        serializers = OrderSerializer(data=request.data)
        serializers.is_valid(raise_exception=True)

        item = serializers.validated_data['item']

        # v1: 일반적인 RDBMS 조회
        # 예상 효과: 수천 명이 동시에 실행하면 모두가 stock > 0 이라고 판단 → 초과 판매 발생
        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
            )

ItemListView

generics.ListAPIView를 상속하면 GET 요청으로 전체 상품 목록을 반환하는 기능이 자동으로 구현됩니다. AllowAny로 비로그인 사용자도 상품 목록을 조회할 수 있습니다.

OrderCreateView

주문 생성 핵심 로직입니다. 현재 v1의 흐름은 단순합니다.

1. 요청에서 item_id를 받아 Item 조회
2. stock > 0 이면 stock을 1 줄이고 저장
3. Order 레코드 생성 후 응답

@extend_schema는 drf-spectacular가 이 View의 요청/응답 스키마를 명확하게 인식하도록 도와주는 데코레이터입니다. APIView는 ModelSerializer를 자동으로 인식하지 못하기 때문에 명시해줍니다.


4. shop/urls.py와 config/urls.py 연결

shop/urls.py

from django.urls import path
from .views import *

urlpatterns = [
    path('items/', ItemListView.as_view(), name='item-list'),
    path('orders/', OrderCreateView.as_view(), name='order-create'),
]

config/urls.py

from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
    path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
    path('api/accounts/', include('accounts.urls')),
    path('api/shop/', include('shop.urls')),      # 추가
]

최종 API 엔드포인트는 다음과 같습니다.

메서드 URL 설명 인증

GET api/shop/items/ 상품 목록 조회
POST api/shop/orders/ 주문 생성

5. v1의 구조적 한계 — Race Condition

코드를 보면 주문 처리 흐름이 세 단계로 나뉩니다.

① DB에서 stock 읽기   (SELECT)
② stock > 0 확인      (Application 레벨 판단)
③ stock 감소 후 저장  (UPDATE)

단일 사용자 환경에서는 문제가 없습니다. 하지만 수천 명이 동시에 요청을 보내면 이런 상황이 생깁니다.

사용자 A: ① stock = 1 읽음
사용자 B: ① stock = 1 읽음   ← A가 아직 저장하기 전
사용자 A: ② stock > 0 → 구매 진행
사용자 B: ② stock > 0 → 구매 진행  ← 둘 다 통과
사용자 A: ③ stock = 0 저장
사용자 B: ③ stock = -1 저장  ← 초과 판매 발생

두 요청이 거의 동시에 stock = 1을 읽고, 둘 다 stock > 0이라고 판단해 구매를 진행합니다. 결과적으로 재고가 1개인 상품이 2개 팔리는 초과 판매(Overselling) 가 발생합니다.

이것이 Race Condition(경쟁 조건) 입니다. 두 스레드가 공유 자원(재고)을 동시에 읽고 수정하려 할 때 발생하는 고전적인 동시성 문제입니다.

v1은 이 문제를 의도적으로 내포하고 있습니다. 다음 포스트에서 부하 테스트 도구로 동시 요청을 발생시켜 실제로 초과 판매가 일어나는 것을 확인하고, DB 락으로 이를 해결하는 과정을 다룹니다.


6. 정리

오늘 한 작업을 요약하면:

  • 성능 개선 과정을 수치로 비교하기 위해 순수 RDBMS 방식의 v1을 먼저 구현했습니다.
  • PrimaryKeyRelatedField로 클라이언트가 item_id만 보내도 Item 인스턴스를 자동으로 조회하도록 설계했습니다.
  • OrderCreateView는 DB 조회 → 재고 확인 → 저장의 단순한 흐름으로 구현했습니다.
  • v1은 동시 요청 시 Race Condition으로 인한 초과 판매가 발생하는 구조적 한계를 가지고 있으며, 이를 다음 단계에서 개선합니다.

다음 포스트에서는 부하 테스트로 v1의 문제를 수치로 확인하고, select_for_update()를 이용한 Pessimistic Locking으로 동시성 문제를 해결한 v2를 구현합니다. 🚀

 

이 글에서 다루는 내용

  • shop 앱 생성 및 INSTALLED_APPS 등록
  • Item, Order 모델 설계하기
  • Django Admin에 모델 등록하고 superuser로 상품 데이터 추가하기

1. shop 앱 생성하기

상품과 주문 로직을 담당할 shop 앱을 생성합니다.

python manage.py startapp shop

생성 후 settings.py의 INSTALLED_APPS에 등록합니다.

INSTALLED_APPS = [
    ...
    'rest_framework',
    'rest_framework_simplejwt.token_blacklist',
    'drf_spectacular',

    'accounts',
    'shop',       # 추가
]

2. shop/models.py — Item과 Order 모델 설계

from django.db import models
from django.conf import settings

class Item(models.Model):
    name = models.CharField(max_length=100, verbose_name="상품명")
    price = models.PositiveIntegerField(verbose_name="가격")
    # v1: RDBMS에 의존하는 재고 관리
    stock = models.PositiveIntegerField(default=0, verbose_name="잔여 재고")

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f"{self.name} (재고: {self.stock})"

    class Meta:
        db_table = 'items'


class Order(models.Model):

    class Status(models.TextChoices):
        PENDING = 'PENDING', '결제 대기'
        COMPLETED = 'COMPLETED', '결제 완료'
        FAILED = 'FAILED', '결제 실패'

    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders')
    item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='orders')
    status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)

    order_number = models.CharField(max_length=50, unique=True, null=True, blank=True)

    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"Order {self.id} - {self.user.username} - {self.item.name}"

    class Meta:
        db_table = 'orders'

Item 모델

상품 하나를 나타내는 모델입니다. 주목할 부분은 stock 필드의 주석입니다.

# v1: RDBMS에 의존하는 재고 관리
stock = models.PositiveIntegerField(default=0, verbose_name="잔여 재고")

v1이라고 명시한 이유가 있습니다. 현재는 PostgreSQL의 stock 컬럼을 직접 줄여가며 재고를 관리합니다. 이후 포스트에서 Redis를 활용한 방식으로 개선할 예정이며, 두 방식을 직접 비교해볼 수 있도록 의도적으로 단계를 나눴습니다.

auto_now_add=True는 레코드가 처음 생성될 때의 시각을 자동으로 저장하고, auto_now=True는 레코드가 저장될 때마다 현재 시각으로 갱신됩니다.

Order 모델

주문 하나를 나타내는 모델입니다. 몇 가지 설계 포인트를 살펴봅니다.

TextChoices로 상태값 관리

class Status(models.TextChoices):
    PENDING = 'PENDING', '결제 대기'
    COMPLETED = 'COMPLETED', '결제 완료'
    FAILED = 'FAILED', '결제 실패'

주문 상태를 문자열 상수로 관리합니다. DB에는 'PENDING', 'COMPLETED', 'FAILED'가 저장되고, Django Admin이나 Serializer에서는 한글 레이블('결제 대기' 등)로 표시됩니다. 상태값을 코드 여기저기에 문자열로 직접 쓰는 대신 Order.Status.PENDING처럼 참조할 수 있어 오타로 인한 버그를 방지합니다.

ForeignKey 설계

user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='orders')
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='orders')

settings.AUTH_USER_MODEL을 직접 참조해 User 모델을 가져옵니다. 'accounts.User'라고 직접 쓰는 대신 이 방식을 쓰면 나중에 User 모델이 바뀌어도 코드를 수정할 필요가 없습니다.

on_delete=models.CASCADE는 연결된 User나 Item이 삭제되면 해당 Order도 함께 삭제되도록 합니다.

related_name='orders'를 설정하면 반대 방향 참조가 가능합니다. 예를 들어 특정 유저의 모든 주문을 user.orders.all()로 조회할 수 있습니다.

order_number

order_number = models.CharField(max_length=50, unique=True, null=True, blank=True)

unique=True로 중복 주문 번호를 방지합니다. null=True, blank=True는 주문 생성 시점에는 비워두고, 이후 결제 처리 단계에서 채워 넣기 위한 설정입니다.


3. 마이그레이션 실행하기

모델을 작성했으면 마이그레이션 파일을 생성하고 DB에 적용합니다.

python manage.py makemigrations shop
python manage.py migrate
Migrations for 'shop':
  shop/migrations/0001_initial.py
    - Create model Item
    - Create model Order

Running migrations:
  Applying shop.0001_initial... OK

PostgreSQL에 items 테이블과 orders 테이블이 생성됩니다.


4. shop/admin.py — Django Admin에 모델 등록하기

from django.contrib import admin
from .models import *

@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
    list_display = ('id', 'name', 'price', 'stock', 'created_at')
    search_fields = ('name',)

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ('id', 'user', 'item', 'status', 'order_number', 'created_at')
    list_filter = ('status',)

@admin.register(Item) 데코레이터는 ItemAdmin 클래스를 Item 모델의 Admin 설정으로 등록합니다.

옵션 역할

list_display Admin 목록 페이지에 표시할 컬럼 지정
search_fields 검색창에서 검색할 필드 지정
list_filter 우측 사이드바에 필터 패널 추가

5. superuser 생성 및 상품 등록하기

superuser 생성

Django Admin에 접속하려면 superuser 계정이 필요합니다. Dev Container 터미널에서 아래 명령어를 실행합니다.

python manage.py createsuperuser
Username: admin
Email address: (생략 가능)
Password: 
Password (again): 
Superuser created successfully.

Django Admin에서 상품 등록

서버를 실행하고 http://localhost:8001/admin/에 접속합니다.

python manage.py runserver 0.0.0.0:8000

superuser 계정으로 로그인하면 Items와 Orders 메뉴가 보입니다. Items → Add Item에서 테스트용 상품을 등록합니다.

상품명: 나이키 한정판 조던 1
가격: 200000
잔여 재고: 100

저장하면 Admin 목록에 한정판 스니커즈 A (재고: 100)으로 표시됩니다. __str__ 메서드에 정의한 형식대로 표현되는 것을 확인할 수 있습니다.


6. 최종 폴더 구조

hot-item/
├── accounts/
├── shop/                       # 새로 추가된 앱
│   ├── migrations/
│   │   └── 0001_initial.py
│   ├── admin.py                # Item, Order Admin 등록
│   ├── models.py               # Item, Order 모델
│   └── ...
├── config/
└── ...

7. 정리

오늘 한 작업을 요약하면:

  • shop 앱을 생성하고 INSTALLED_APPS에 등록했습니다.
  • Item 모델에 재고(stock)를 직접 관리하는 v1 방식으로 설계했습니다. 이후 Redis 방식과 비교할 예정입니다.
  • Order 모델에 TextChoices로 상태값을 관리하고, ForeignKey로 User와 Item을 연결했습니다.
  • shop/admin.py에서 두 모델을 Django Admin에 등록하고, superuser로 테스트 상품을 추가했습니다.

다음 포스트부터는 이 상품을 대상으로 한정판 선착순 구매 API를 구현합니다. DB 락을 활용한 동시성 제어, Redis 대기열까지 본격적인 핵심 로직이 시작됩니다. 🚀

+ Recent posts