PROJECT DETAIL / 개인 프로젝트

WMS-Lite

재고 할당 API를 동시성·성능·이벤트·장애 복구로 발전시킨 홈랩

개인 프로젝트 · 진행 중단계별 실험 · 개선 진행 중

  • Java
  • Spring Boot
  • PostgreSQL
  • k6
  • JPA · Flyway
  • Redis · Lua
  • Kafka · Outbox
OVERVIEW

프로젝트 개요

테이블과 기본 인덱스, 재고 할당 API를 만든 V0에서 출발한 홈랩 프로젝트입니다. 동시성 제어 전략을 비교해 조건부 UPDATE를 선택한 뒤 DB 최적화, Redis 품절 처리, Kafka·Outbox와 장애 복구를 단계적으로 적용했습니다.

SCOPE

담당 업무 및 개발 내용

상품·창고·재고·주문·할당을 모듈형 단일 애플리케이션으로 구성했습니다. 최초 API의 초과 할당을 재현하고 실험 브랜치별로 동시성 전략을 비교했습니다. 선택한 방식 위에서 DB 접근을 줄이고 Redis와 이벤트 발행을 추가했으며, 마지막에는 중복 요청과 인프라 장애의 복구 경로를 실험했습니다.

V0 오류 재현
재고 100 → 할당 840
V2 DB 최적화
RPS +39.7%
V3 품절 응답
p95 −83.7%
GitHub 저장소 ↗

01 / EXPERIMENT JOURNEY

하나의 재고 할당 API를 단계적으로 개선했습니다.

새 기술을 추가하기 전에 이전 단계의 문제를 재현하고, 변경 후에는 재고 정합성과 응답 결과를 함께 비교했습니다. V0는 기본 구현으로 보존하고 이후 개선은 실험 브랜치로 나눠 진행했습니다.

  1. V0

    기본 API·오류 재현

    기본 테이블과 인덱스, 조회 후 수량을 갱신하는 API를 만들고 Lost Update를 재현했습니다.

    재고 100개 → 840건 할당master단계 자세히 보기 ↗
  2. V1

    동시성 전략 비교

    낙관적·비관적 락과 조건부 UPDATE의 처리량, 응답 시간과 실패 유형을 비교했습니다.

    Atomic 선택 · 초과 할당 0experiment/v1-atomic단계 자세히 보기 ↗
  3. V2

    DB 접근 최적화

    기존 인덱스와 실행 계획을 분석하고 엔티티 조회와 추가 상태 UPDATE를 줄였습니다.

    RPS 2,420.09 → 3,380.82experiment/v2-db-optimized단계 자세히 보기 ↗
  4. V3

    Redis 품절 처리

    Lua로 품절 요청을 DB에 도달하기 전에 종료하고 DB 실패 시 수량을 반환했습니다.

    p95 262.18ms → 42.79msexperiment/v3-redis단계 자세히 보기 ↗
  5. V4

    Kafka·Outbox

    할당과 이벤트 기록을 함께 저장하고 발행·수신을 분리해 후속 연계를 준비했습니다.

    Kafka 중단 중에도 이벤트 보존experiment/v4-kafka-outbox단계 자세히 보기 ↗
  6. V5

    멱등성·장애 복구

    중복 요청·이벤트, 발행 재시도·격리·재발행과 Redis/DB 불일치 탐지를 추가했습니다.

    중복 요청 총 500건 · 오류 0experiment/v5-resilience-idempotency단계 자세히 보기 ↗

단계별 수치의 부하 조건과 반복 횟수는 각 구현 탭에 표시했습니다.

02 / ARCHITECTURE

시스템 구성과 처리 흐름

V3~V5에서 확장한 재고 할당·이벤트 흐름

  1. 할당 API요청 키 · 중복 검사
  2. Redis Lua품절 처리 · 선예약
  3. DB 트랜잭션재고 · 주문 · Outbox
  4. 이벤트 처리Kafka · 중복 수신 방지
V0~V2의 DB 예약을 유지한 상태에서 V3에 Redis 품절 처리를, V4에 Outbox·Kafka 발행을 추가했습니다. V5에는 API·Consumer 멱등성과 재발행, Redis/DB 불일치 탐지를 더했습니다. Consumer의 업무 후속 처리는 이후 확장할 계획입니다.

03 / IMPLEMENTATION

단계별 구현과 실험

V0의 기본 API에서 V5의 장애 복구까지, 각 단계의 문제와 변경 사항, 측정 결과를 정리했습니다.

V0 구현·실험 완료

기본 API·오류 재현

테이블과 기본 인덱스를 구성하고, 재고를 조회한 뒤 애플리케이션에서 수량을 갱신하는 API로 시작했습니다.

실험 브랜치master

기본 API·오류 재현 처리 흐름

  1. 테이블 구성기본 인덱스 · 제약
  2. 재고 조회가용 수량 계산
  3. 주문 · 할당JPA 저장 · 수량 갱신
  4. 동시 요청Lost Update 재현
PROBLEM

문제

단일 요청에서는 정상 동작했지만 여러 요청이 같은 재고 값을 읽고 갱신하면서 재고 예약 수량과 실제 할당 합계가 달라졌습니다.

SOLUTION

해결

재고 100개에 1,000건의 요청을 보내 예약 수량과 할당 합계를 대조했습니다. 최초 구현을 기준으로 보존하고 다음 단계에서 동시성 제어 방식을 비교했습니다.

설계 이유와 트레이드오프

응답 성공과 재고 정합성 함께 보기

HTTP 성공 건수만으로는 초과 할당을 발견할 수 없었습니다. 보유 재고, 예약 수량과 할당 합계를 함께 점검하는 것을 이후 실험의 기준으로 삼았습니다.

VALIDATION

구현 결과와 테스트

1,000요청 중 840건이 할당됐고 DB 예약 수량은 100으로 남았습니다. 보유 재고 100개보다 740건이 더 할당되는 Lost Update를 재현했습니다.

V1 구현·실험 완료

동시성 전략 비교

같은 부하에서 낙관적 락, 재시도, 비관적 락과 조건부 UPDATE의 성능·실패 유형을 비교했습니다.

실험 브랜치experiment/v1-optimisticexperiment/v1-pessimisticexperiment/v1-atomicexperiment/v1-optimistic-backoff-jitter

동시성 전략 비교 처리 흐름

  1. 전략별 구현낙관 · 비관 · Atomic
  2. 동일 부하100 VUs · 1,000요청
  3. 결과 비교정합성 · 예외 · 성능
  4. 방식 선택Early Atomic UPDATE
PROBLEM

문제

초과 할당을 막는 것과 요청에 정확한 품절 결과를 반환하는 것은 함께 해결해야 했습니다. 충돌 재시도와 DB 잠금 대기는 각각 다른 비용을 만들었습니다.

SOLUTION

해결

낙관적 락에는 새 트랜잭션에서 최대 3회 시도하는 재시도를 추가하고, 비관적 락과 조건부 UPDATE도 별도 브랜치로 구현했습니다. 초과 할당 없이 성공·품절 결과를 반환하고 처리량도 높은 조건부 UPDATE를 선택했습니다.

설계 이유와 트레이드오프

응답 시간 하나로 선택하지 않기

낙관적 락+재시도는 p95가 낮았지만 재시도 소진으로 끝나는 요청이 남았습니다. Atomic은 p95가 더 높아도 성공 100건·품절 900건을 반환하면서 높은 처리량을 보였고, 이후 개선의 기준으로 선택했습니다.

예약 판단을 트랜잭션 앞에서 실행

가용 수량 검사와 갱신을 하나의 UPDATE로 묶고 먼저 실행해 품절 요청의 불필요한 조회·저장을 줄였습니다. DB 내부 행 잠금은 여전히 존재하므로 같은 재고의 경합은 다음 단계에서 줄여야 했습니다.

낙관적 재시도에 backoff·jitter 추가 실험

후속 실험에서는 재시도 사이에 지수 backoff와 full jitter를 추가했습니다. 충돌 집중을 완화하려 했지만 5회 실험에서 재시도 소진이 184~222건 남아 품절과 구분되는 실패 유형을 함께 관찰했습니다.

V1 동시성 제어 전략 비교
전략측정RPSp95응답 결과
낙관적 락 · 재시도 없음별도 1회708.74864.96ms성공 100 / 충돌 749 / 품절 151
낙관적 락 + 재시도5회 중앙값2,237.88113.33ms성공 100 / 재시도 소진 202~226 / 나머지 품절
비관적 락5회 중앙값1,166.09200.80ms성공 100 / 품절 900
조건부 UPDATE · 선택5회 중앙값2,420.09225.47ms성공 100 / 품절 900

100 VUs · 1,000요청 · 단일 상품 · 재고 100개 · 요청당 1개. 반복 실행 사이 DB를 초기화했습니다. p95는 전체 요청 응답 시간이며 충돌·품절 응답도 포함합니다. VUs는 가상 사용자 수, RPS는 초당 요청 수, p95는 응답 시간의 95백분위 값입니다.

낙관적 backoff·jitter 추가 실험
측정RPSp95응답 결과
5회 중앙값2,109.78116.98ms성공 100 / 재시도 소진 184~222 / 나머지 품절

100 VUs · 1,000요청 · 재고 100개. V1 기본 비교 이후 진행한 별도 실험입니다.

VALIDATION

구현 결과와 테스트

선택한 Atomic 방식은 5회 실험 모두 성공 100건·품절 900건·초과 할당 0건을 유지했습니다. DB 예약 이후 주문·할당도 같은 트랜잭션으로 저장합니다.

V2 구현·실험 완료

DB 접근 최적화

조건부 UPDATE를 유지하면서 실행 계획과 성공 경로의 DB 작업량을 분석했습니다.

실험 브랜치experiment/v2-db-optimized

DB 접근 최적화 처리 흐름

  1. SQL 관찰실행 계획 · 기존 인덱스
  2. 조회 축소ID · getReference
  3. 상태 반영저장 전 ALLOCATED
  4. 부하 비교RPS · p95 · 정합성
PROBLEM

문제

예약을 성공한 뒤 상품·창고·재고 전체 조회와 주문 상태 UPDATE가 추가로 발생해 트랜잭션 작업량을 늘렸습니다.

SOLUTION

해결

상품·창고는 getReferenceById로 연결하고 재고는 ID만 조회했습니다. 주문 상태를 최초 저장 전에 변경해 추가 UPDATE를 제거했습니다. 기존 복합 UNIQUE 인덱스와 실행 계획도 확인했습니다.

설계 이유와 트레이드오프

인덱스를 늘리기 전에 실제 병목 확인

실험의 작은 재고 테이블에서는 기존 복합 인덱스가 있어도 Seq Scan 비용이 더 낮았습니다. 같은 인덱스를 추가하는 대신 동일 재고 행의 경합과 예약 이후의 DB 작업을 줄이는 데 집중했습니다.

참조 연결에 필요한 정보만 조회

전체 엔티티의 값이 필요하지 않은 관계 연결에는 ID와 지연 참조를 사용했습니다. 불필요한 SELECT를 줄이는 대신 참조의 유효성과 지연 조회 발생 시점을 고려해야 합니다.

V1 Atomic → V2 DB 최적화
단계RPSp95응답 결과
V1 Atomic2,420.09225.47ms성공 100 / 품절 900
V2 DB 최적화3,380.82149.28ms성공 100 / 품절 900

100 VUs · 1,000요청 · 재고 100개, 5회 실행의 지표별 중앙값.

VALIDATION

구현 결과와 테스트

100 VUs·1,000요청의 5회 중앙값에서 V1 Atomic 대비 RPS가 39.7% 증가하고 p95가 33.8% 감소했습니다. 성공 100건·품절 900건을 유지했습니다.

V3 구현·실험 완료

Redis 품절 처리

품절된 요청이 DB의 같은 재고 행에 도달하지 않도록 Redis Lua를 추가했습니다.

실험 브랜치experiment/v3-redis

Redis 품절 처리 처리 흐름

  1. Lua 수량 검사품절이면 즉시 종료
  2. Redis 선예약가용 수량 원자 차감
  3. DB 트랜잭션Atomic · 주문 저장
  4. 할당 응답성공 결과 반환
Lua가 수량 부족을 반환하면 DB 접근 없이 품절로 응답합니다. 예약 후보만 DB 트랜잭션을 진행하며, DB 처리 실패 시 Redis 수량을 반환합니다.
PROBLEM

문제

DB 접근을 줄인 뒤에도 품절 요청이 모두 동일 재고 행에 도달해 경합과 커넥션 대기를 만들었습니다.

SOLUTION

해결

Lua로 가용 수량 검사와 차감을 원자적으로 처리하고 품절이면 즉시 응답했습니다. 예약 후보는 기존 DB 트랜잭션으로 처리하고, DB 실패 시 Redis 감소분을 반환하도록 구성했습니다.

설계 이유와 트레이드오프

Redis는 빠른 품절 판단, DB는 최종 재고 장부

DB의 조건부 UPDATE를 유지해 Redis가 최종 예약 판단을 대체하지 않도록 했습니다. 두 저장소의 원자적 커밋은 없으므로 프로세스 중단과 보상 실패는 V5의 불일치 탐지·복구 과제로 이어졌습니다.

V2 → V3 품절 요청 처리 개선
단계RPSp95응답 결과
V2 DB 최적화5,155.94262.18ms성공 100 / 품절 2,900
V3 Redis Lua7,454.8742.79ms성공 100 / 품절 2,900

300 VUs · 3,000요청 · 재고 100개, 5회 중앙값. 품절 요청 비중이 높은 단일 상품 부하입니다.

VALIDATION

구현 결과와 테스트

300 VUs·3,000요청의 5회 실험에서 성공 100건·품절 2,900건·기타 오류 0건을 유지했습니다. 같은 조건의 V2 대비 RPS는 44.6% 증가하고 p95는 83.7% 줄었습니다.

V4 구현·실험 완료

Kafka·Outbox 확장

성능 개선 이후에는 할당 결과를 후속 작업으로 전달할 이벤트 발행 경로를 추가했습니다.

실험 브랜치experiment/v4-kafka-outbox

Kafka·Outbox 확장 처리 흐름

  1. 할당 트랜잭션재고 · 주문 · Outbox
  2. Publisher미발행 이벤트 조회
  3. Kafka 발행발행 상태 갱신
  4. Consumer이벤트 수신
PROBLEM

문제

할당 저장과 Kafka 전송을 따로 처리하면 DB 커밋 이후 발행 실패로 이벤트가 누락될 수 있습니다.

SOLUTION

해결

할당 결과와 Outbox 이벤트를 같은 트랜잭션에 저장하고 Publisher가 커밋된 이벤트를 Kafka로 발행하도록 구현했습니다. Consumer를 연결해 향후 알림·배송 연동·분석 같은 후속 작업을 확장할 기반을 마련했습니다.

설계 이유와 트레이드오프

할당 요청과 후속 처리의 책임 분리

재고 할당은 동기 DB 트랜잭션으로 유지하고 후속 이벤트 발행을 분리했습니다. 모듈형 단일 애플리케이션에서도 연계 작업을 확장할 수 있지만, Outbox 저장 비용과 이벤트 지연을 관리해야 합니다.

at-least-once 전달을 전제로 설계

Kafka 전송 성공과 발행 상태 저장 사이에 중단되면 같은 이벤트가 다시 전달될 수 있습니다. 이 문제를 V5의 Consumer 멱등성과 재발행 실험으로 이어갔습니다.

V3 → V4 이벤트 기록 추가 비용
단계RPSp95예약 결과
V3 Redis Lua7,454.8742.79ms성공 100 / 품절 2,900
V4 Redis + Outbox7,273.1341.97ms성공 100 / 품절 2,900

300 VUs · 3,000요청 · 재고 100개, 5회 중앙값. 전체 API 응답 성능을 측정했으며 Kafka 전체 처리량·발행 지연 수치는 아닙니다.

VALIDATION

구현 결과와 테스트

Kafka 중단 중에도 할당 API가 성공하고 미발행 이벤트가 DB에 남았습니다. Kafka 복구 후 발행 상태 전환과 수신을 점검했고, 부하 실험 후 생성된 이벤트 100건의 발행 완료를 확인했습니다.

V5 구현·실험 완료

멱등성·장애 복구

중복 요청·이벤트와 인프라 장애에서 데이터가 보존되고 재처리되는 흐름을 추가했습니다.

실험 브랜치experiment/v5-resilience-idempotency

멱등성·장애 복구 처리 흐름

  1. 중복 요청요청 키 · 기존 결과
  2. 중복 이벤트이벤트 ID · 소비 이력
  3. 발행 실패backoff · DEAD
  4. 복구 작업replay · 불일치 탐지
중복 요청 처리, 중복 수신 방지, 발행 실패 복구와 수량 불일치 탐지는 각각의 처리 경로입니다. 발행 실패는 backoff → DEAD 격리 → 수동 replay로 이어집니다.
PROBLEM

문제

API 재시도는 중복 주문을 만들 수 있고, 이벤트 재발행은 같은 후속 작업을 반복할 수 있습니다. DB 실패나 프로세스 중단은 Redis와 DB의 수량을 어긋나게 할 수 있습니다.

SOLUTION

해결

동일 요청 키에는 기존 주문을 반환하고 DB UNIQUE 제약으로 동시 중복 요청을 처리했습니다. Consumer는 이벤트 ID와 소비자 기준으로 최초 처리만 통과시킵니다. Publisher에는 지수 backoff·DEAD 격리·수동 replay를 추가하고 Redis/DB 가용 수량을 주기적으로 대조했습니다.

설계 이유와 트레이드오프

중복 검사와 실제 처리의 경계

DB 제약과 처리 이력으로 중복 요청·이벤트를 제어했습니다. Consumer의 실제 외부 연계 작업은 향후 추가할 계획이며, 외부 부작용에도 같은 이벤트 식별자를 전달해야 합니다.

실패 이벤트를 보존하고 명시적으로 재발행

반복 실패는 DB의 DEAD 상태에 격리하고 수동 replay로 다시 발행합니다. Kafka가 중단된 상황에서도 복구 대상을 남길 수 있지만, 운영 적용에는 관리 API 권한과 재발행 감사 이력이 필요합니다.

Redis와 DB 불일치 탐지를 먼저 구현

가용 수량을 주기적으로 대조하고 불일치·키 누락을 경고합니다. 진행 중인 요청의 선예약을 덮어쓰지 않도록 자동 보정은 하지 않고, 실험에서는 DB 기준으로 수동 복구한 뒤 일치 여부를 확인했습니다.

V5 실패·복구 시나리오
시나리오결과
같은 요청 키 반복기존 주문 반환 · 중복 주문/할당/이벤트 생성 방지
같은 이벤트 재발행소비 이력 1건 · 중복 수신 건너뛰기
Kafka 반복 발행 실패1·2·4·8초 backoff, 5번째 실패에 DEAD
DEAD 이벤트 replay동일 이벤트 ID로 PENDING → PUBLISHED
Redis 수량 불일치DB 100 / Redis 97 탐지 → 수동 복구 후 일치

API 중복 실험은 기능·정합성 점검이며 V3·V4 처리량 비교와 별도 조건입니다.

VALIDATION

구현 결과와 테스트

10 VUs·동일 요청 100건을 5회 반복해 총 500건 모두 성공했고 응답 주문 ID 불일치·기타 오류는 0건이었습니다. 각 실행 후 주문·할당·Outbox가 1건씩 생성되고 DB 예약 1개·Redis 가용 99개인 상태를 점검했습니다.

04 / RESULTS & TESTING

개발 결과와 테스트

WMS-Lite 개발 결과
항목결과
V0 · 문제 재현재고 100개에 840건 할당, 초과 할당 740건
V1 · 동시성 선택조건부 UPDATE로 성공 100 / 품절 900 / 초과 할당 0
V2 · DB 개선100 VUs 조건: RPS +39.7% · p95 −33.8%
V3 · 품절 응답300 VUs 조건: V2 대비 RPS +44.6% · p95 −83.7%
V4 · 후속 연계 기반할당·Outbox 함께 저장, Kafka 중단→복구 후 발행
V5 · 중복·복구동일 요청 총 500건 오류 0, 중복 소비 방지·DEAD replay·수량 불일치 탐지

05 / REFLECTION

개발 회고와 개선 방향

회고

같은 재고 할당 API를 반복해서 개선하면서 문제에 따라 선택 기준이 달라졌습니다. V1에서는 초과 할당과 충돌 실패를, V2·V3에서는 트랜잭션 작업량과 품절 응답 비용을 줄였습니다. V4·V5에서는 성능만큼 이벤트 보존과 중복 처리, 복구 절차가 중요해졌습니다. 기술을 먼저 정하기보다 이전 단계의 병목과 실패 유형을 다음 실험의 출발점으로 삼았습니다.

NEXT / 향후 계획

향후 개선 방향

Consumer에 실제 후속 업무를 연결하고 외부 작업의 멱등성, 소비 실패 재시도와 DLQ를 확장할 계획입니다. 관리 API의 권한·감사 이력과 Redis/DB 수량의 안전한 재동기화 절차도 보완하려 합니다.

다른 프로젝트도 살펴보기

업무 프로젝트모바일·기간계 연계 미들웨어업무 프로젝트오토스캐너 연계 API업무 프로젝트배송지 주소 조회 서비스업무 프로젝트AMS개인 프로젝트PeakLog
전체 프로젝트로 돌아가기 ←

처리 흐름

100%확대 후 가로·세로로 스크롤할 수 있습니다.
선택한 확대 도식
↑