동시성 문제(Concurrency Issue): 1편. 문제 도출! 주문 재고 동시성 문제는 어떻게 발견했는가?

2026. 7. 24. 14:38·💭Retrospective

1. 문제의 발단: "재고가 10개인데 주문은 99개가 성공했다"

단순히 재고를 확인하고 차감하는 일반적인 주문 API를 구현했습니다. Postman이나 Swagger로 테스트했을 때는 아무 문제 없이 동작했고, 기능 테스트도 모두 통과했죠. 하지만 "200명의 사용자가 동시에 주문하면 어떻게 될까?"라는 의문이 생겨 동시성 테스트를 진행했고, 충격적인 결과를 확인했습니다.

  • 초기 재고: 10개
  • 동시 요청: 200명 (1인당 1개 주문)
  • 주문 성공: 100개
  • 재고 부족 실패: 100개
  • 최종 재고: 0개

최종 재고가 0이라 얼핏 정상처럼 보이지만, 실제로는 존재하지도 않는 90개의 재고가 팔려나간 셈입니다.

2. 기존 주문 생성 로직의 허점

당시 작성했던 로직은 다음과 같이 지극히 평범했습니다.

  • [주문 요청] ──> [상품/재고 조회] ──> [주문 가능 검증] ──> [재고 차감] ──> [주문 저장]
// 기존 주문 처리 코드 예시
List<Inventory> inventories = inventoryRepository.findAllByProductIdIn(productIds);

inventory.validateEnoughStock(quantity); // 재고 검증
inventory.decrease(quantity);           // 재고 차감

purchaseOrderRepository.save(order);      // 주문 저장

단일 요청에서는 아주 잘 작동합니다. 하지만 이 코드에는 한 가지 치명적인 가정이 숨어 있습니다.

"항상 한 번에 한 명의 사용자만 주문을 요청한다."

3. 왜 문제가 발생했을까? (Lost Update 현상)

이 문제는 데이터베이스의 Lost Update (수정 손실) 현상 때문에 발생합니다. 두 개 이상의 트랜잭션이 거의 동시에 동일한 데이터를 읽고(Read) 수정(Write)할 때, 먼저 수행된 변경 사항이 나중의 변경 사항에 의해 덮어씌워지는 현상입니다.

🔍 동시 요청 시 일어나는 일

sequenceDiagram
    participant A as Thread A
    participant DB as Inventory(DB)
    participant B as Thread B

    Note over A,B: 동시 요청 시작

    A->>DB: 재고 조회
    DB-->>A: 재고 = 10

    B->>DB: 재고 조회
    DB-->>B: 재고 = 10

    A->>DB: 재고 1개 차감 후 저장 (10 → 9)
    DB-->>A: 저장 완료 (재고 = 9)

    B->>DB: 재고 1개 차감 후 저장 (10 → 9)
    Note right of B: 이전에 조회한 값(10)을 기준으로 계산
    DB-->>B: 저장 완료 (재고 = 9)

    Note over A,B: 주문은 2건 모두 성공
    Note over DB: 최종 재고 = 9 (Lost Update 발생)

 

Clearly, 주문은 2건이 처리되었지만 DB의 재고는 1개만 줄어들었습니다.
Thread A가 차감한 기록이 Thread B에 의해 사라진 것(Lost)입니다.

❓ "`@Transactional`을 걸었는데 왜 안 막힐까요?"
Spring의 `@Transactional`은 단일 트랜잭션 내 작업의 원자성(All or Nothing)을 보장할 뿐, 
서로 다른 트랜잭션이 같은 데이터에 동시에 접근하는 것까지 막아주지는 않기 때문입니다.

4. 문제를 재현하기 위한 동시성 테스트 설계

이 현상을 이론이 아닌 테스트 코드로 완벽히 재현해 보기로 했습니다.

1. 핵심 불변식 (Invariant) 정의

가장 먼저 동시성 정합성을 판가름할 기준을 세웠습니다.

  • 초기 재고 = 성공한 주문 수량 + 최종 재고
  • 정상 케이스: 10 = 10 + 0 (정합성 만족 TRUE)
  • 오류 케이스: 10 = 99 + 0 (정합성 붕괴 FALSE)

2. 테스트 성공 조건

  1. 성공한 주문 수량 = 최대 10개
  2. 재고 부족으로 실패한 수량 = 190개
  3. 최종 잔여 재고 = 0개
  4. 재고 불변식 만족 여부 = True

3. 동시성 환경 조성을 위한 고민

단순히 반복문으로 200개의 스레드를 생성한다고 해서 실제 동시 요청이 만들어지지는 않습니다. 스레드를 많이 생성한다고 해서 반드시 동시에 실행되는 것은 아니기 때문입니다. OS 스케줄링 과정에서 스레드가 하나씩 순차적으로 처리될 가능성이 크기 때문이었습니다. 따라서 모든 스레드가 준비를 마칠 때까지 기다렸다가, 한 번에 '땅!' 하고 출발시킬 수 있는 동시성 제어 도구(예: `CountDownLatch`)가 필요했습니다.

5. 동시성 테스트 구현: ExecutorService & CountDownLatch

문제의 가설을 세웠으니, 이제 코드로 직접 200명의 동시 요청을 만들어낼 차례입니다.

❌ 실패했던 첫 번째 시도: 단순 스레드 생성

// 단순히 스레드만 200개 만드는 잘못된 접근
for (int i = 0; i < 200; i++) {
    executorService.submit(() -> {
        createOrder(...);
    });
}

이 방식은 매 실행마다 결과가 천차만별이었습니다. 스레드만 200개 만든다고 끝이 아니었기 때문입니다. 운영체제의 CPU 스케줄링에 의해 스레드들이 순차적으로 실행되면 동시성이 옅어져 Lost Update가 제대로 발생하지 않습니다. 따라서 스레드를 많이 생성한다고, 모두 동시에 실행된다는 것은 절대 아니라는 점을 파악할 수 있었습니다.

⭕ 해결책: 동시 출발선(CountDownLatch) 구축

모든 스레드가 생성된 후 출발선 앞에서 대기하다가, 신호가 떨어지는 순간 동시에 달리도록 구조를 설계했습니다.

  • [200개 스레드 생성] ──> [출발선 대기] ──> [startLatch.countDown()] ──> [200개 동시에 출발!]
int threadCount = 200;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);

CountDownLatch startLatch = new CountDownLatch(1);          // 출발 신호용 Latch
CountDownLatch endLatch = new CountDownLatch(threadCount); // 작업 종료 대기용 Latch

for (int i = 0; i < threadCount; i++) {
    executorService.submit(() -> {
        try {
            startLatch.await(); // 메인 스레드가 신호를 줄 때까지 대기
            createOrder(...);
        } catch (Exception e) {
            // 예외 수집
        } finally {
            endLatch.countDown();
        }
    });
}

startLatch.countDown(); // 💥 200개 스레드 동시 출발!
endLatch.await();      // 모든 스레드의 작업 완료 대기
💡 `CyclicBarrier` 대신 `CountDownLatch`를 선택한 이유
둘 다 동시 실행을 조율하는 도구이지만, 이번 테스트는 "단 한 번, 요청을 동시에 쏘아 보내는 작업"이었기 때문에 1회성 제어에 적합한 `CountDownLatch`가 훨씬 가볍고 직관적이었습니다.

6. 동시성 테스트보다 더 오래 걸렸던 트러블슈팅

동시성 코드를 짜자마자 Lost Update를 볼 수 있을 줄 알았지만, 진짜 난관은 테스트 환경 제어였습니다.

1) Fixture가 대량 동시 요청을 견디지 못함

  • 문제: 200명의 회원을 생성할 때 `Duplicate entry 'member@test.com'` 예외 발생
  • 원인: 기존 `MemberFixture`가 고정된 이메일만 리턴하도록 설계되어 있었음
  • 해결: Fixture가 매번 고유한 값(`member1@test.com`, `member2@test.com`)을 생성하도록 개편

2) 테스트 재실행 시 데이터 충돌 (Rollback의 한계)

  • 문제: 첫 번째 실행은 성공하지만, 두 번째 실행부터 DB 충돌로 실패
  • 원인: 동시성 테스트는 각 스레드가 별도의 DB 트랜잭션/커넥션을 사용하기 때문에,
    테스트 클래스의 `@Transactional` 자동 롤백이 적용되지 않고 DB에 데이터가 남아있음
  • 해결: `AtomicLong` 기반의 시퀀스를 도입하여 실행할 때마다 항상 유일한 고유값으로 생성
private static final AtomicLong sequence = new AtomicLong();

public static Member createMember() {
    long id = sequence.incrementAndGet();
    return new Member("member" + id + "@test.com", "010-0000-" + String.format("%04d", id));
}

3) 동시성 테스트의 비결정성 (Flaky Test)

테스트를 돌릴 때마다 성공건수가 87개, 95개, 102개처럼 매번 다르게 나왔습니다.

처음엔 실패인가 싶었지만, 동시성 테스트는 본질적으로 비결정적(Non-deterministic)입니다. 중요한 것은 "정확히 몇 개가 성공했는가"라는 숫자가 아니라, "재고 불변식(`10 = 성공수 + 최종재고`)이 깨졌는가"라는 정합성 붕괴 자체를 증명하는 것이었습니다.

7. 최종 테스트 결과 및 정리

모든 환경을 정비한 뒤 동시성 테스트를 최종 실행했습니다.

📊 테스트 실행 결과

❌ 재고 불변식 검증

  • `초기 재고 (10) != 성공한 주문 (99) + 최종 재고 (0)`

재고 10개짜리 상품에 99개의 성공 주문이 발생하는 정합성 붕괴를 완벽하게 재현했습니다.
여기에서 반드시 파악해야만 하는 것은 바로 이 두 가지였습니다.

  • `@Transactional`은 만능이 아니다: 단일 트랜잭션의 원자성만 보장할 뿐, 동시 접근을 제어해 주지는 않습니다.
  • 재현 가능한 버그는 해결할 수 있다: "재수가 없으면 터지는 버그"로 치부되던 동시성 문제를 테스트 코드로 완벽히 재현했습니다.

'💭Retrospective' 카테고리의 다른 글

동시성 문제(Concurrency Issue): 3편. 주문만 안전하면 끝일까? 시스템 전체를 관통하는 동시성 설계와 한계 회고  (0) 2026.07.24
동시성 문제(Concurrency Issue): 2편. 비관적 락(Pessimistic Lock)으로 재고 정합성 해결하기  (0) 2026.07.24
상품 상세 조회 API 성능 개선기: DB 부하 줄이고 데이터 정합성 지키기 (Redis, k6, Cache)  (0) 2026.07.23
클린 아키텍처 with 파이썬: 높은 자유도를 보장하는 파이썬에서 클린 아키텍처는 어떻게 구현할까?  (0) 2026.05.24
GitHub Copilot Dev Days Seoul: Microsoft korea에서 AI 코딩 어시스턴트와 함께하는 실전 개발 워크샵 후기  (0) 2026.04.26
'💭Retrospective' 카테고리의 다른 글
  • 동시성 문제(Concurrency Issue): 3편. 주문만 안전하면 끝일까? 시스템 전체를 관통하는 동시성 설계와 한계 회고
  • 동시성 문제(Concurrency Issue): 2편. 비관적 락(Pessimistic Lock)으로 재고 정합성 해결하기
  • 상품 상세 조회 API 성능 개선기: DB 부하 줄이고 데이터 정합성 지키기 (Redis, k6, Cache)
  • 클린 아키텍처 with 파이썬: 높은 자유도를 보장하는 파이썬에서 클린 아키텍처는 어떻게 구현할까?
limdaeil
limdaeil
limdaeil 님의 블로그 입니다.
  • limdaeil
    limdaeil
    limdaeil
  • 전체
    오늘
    어제
    • 분류 전체보기 (82) N
      • 💭Retrospective (21)
      • 🥕FrontEnd (0)
      • 🐬MySQL (1)
      • 🐍Python (5)
      • 🍃SpringBoot (33) N
      • ☕Java (1)
      • ♾️Devops (1)
      • 🌎Network (2)
      • 📚Read & 👨‍🏫Course (10)
      • Programmers (1)
      • 🧪Test (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    DI
    Python
    MySQL
    나는리뷰어다
    한빛미디어
    한빛아카데미
    optimistic rock
    junit
    서평단
    Mockito
    spring boot
    gradle
    Concurrency
    IoC
    redis
    회고
    책
    Spring
    jwt
    distributed lock
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
limdaeil
동시성 문제(Concurrency Issue): 1편. 문제 도출! 주문 재고 동시성 문제는 어떻게 발견했는가?
상단으로

티스토리툴바