Skip to content
hello tis
Go back

설계 노트: 재고 원장과 스냅샷으로 동시성 다루기

시스템 설계 스터디 1회차(2026-07-26) 논의 중 “1. 재고 동시성 문제”를 정리했다. 1부는 구조 수준의 선택(설계안), 2부는 코드 수준의 고민(상세안)이다.

참고 자료

문제 정의

커머스 재고는 두 요구가 충돌하는 데이터다. 품절 여부를 즉시 답해야 하는 조회 요구와, 어떤 변동도 잃어버리면 안 되는 기록 요구다. 저장 구조를 정할 때 두 방식이 후보에 오른다.

  1. 원장(append-only) — 모든 변동을 불변 이벤트로 쌓고, 합산으로 현재 재고를 구한다
  2. 스냅샷 — 현재 수량 하나를 두고 변동 때마다 덮어쓴다

결론부터 말하면 둘 중 하나를 고르는 문제가 아니다. 원장을 원천 데이터로 두고, 스냅샷을 조회·차감용 파생 데이터로 추가한다. 1부에서 이 결정의 근거를, 2부에서 구현할 때 고려할 점을 정리한다.


1부 : 설계안 — 구조 수준의 선택

설계 전반의 원칙

구현이 바뀌어도 지켜야 하는 원칙 세 가지를 먼저 깔아둔다.

설계안 1 : 원장(append-only ledger) 방식 채택

구조

stock_ledger
- id            (PK, 단조 증가)
- product_id
- type          (입고 / 판매차감 / 취소복원 / 조정)
- quantity      (+N / -N)
- reference_id  (주문 ID 등 원인 식별자)
- created_at

선택 이유

주의점

설계안 2 : 스냅샷 차용

원장 단독 운영에서 드러나는 문제

구조

stock_snapshot
- product_id       (PK)
- available_qty    (현재 판매 가능 수량)
- last_ledger_id   (어느 원장 이벤트까지 반영했는지)
- updated_at

선택 이유

설계안 3 : 재고 예약 후 차감

설계안 1·2가 재고를 어디에 저장할지를 정했다면, 이 안은 언제 차감할지를 정한다. 주문 시작부터 결제 완료까지는 수십 초에서 수 분이 걸리는데, 차감을 이 구간의 어디에 두든 문제가 생긴다.

예약은 이 사이의 중간 상태다.

흐름

  1. 주문 시작 시 예약 — 판매 가능 수량에서 예약 수량을 잡아둔다.
  2. 결제 완료 시 확정 — 예약을 차감으로 전환하고, 원장에 판매차감 이벤트를 insert한다.
  3. 결제 실패·이탈 시 만료 — 유효 시간(TTL)이 지나면 예약을 자동 해제한다.

구조

선택 이유

주의점

설계안 비교

기준원장 단독스냅샷 단독원장 + 스냅샷
현재고 조회매번 합산 (느림)즉시즉시 (스냅샷)
원자적 차감불가가능가능 (스냅샷)
이력·감사 추적완전없음완전 (원장)
값이 틀어졌을 때재계산으로 복구복구 근거 없음원장에서 재계산
쓰기 경합없음핫스팟차감 순간만

원장 단독은 차감을, 스냅샷 단독은 복구를 포기해야 한다. 병행 구조는 두 테이블을 관리하는 비용을 내는 대신 어느 쪽도 포기하지 않는다. 여기에 설계안 3(예약)은 저장 구조와 별개로 차감 시점을 정하는 선택지로, 도입 조건에 해당할 때만 얹는다. 병행 구조의 비용이 구체적으로 무엇인지가 2부의 내용이다.


2부 : 상세안 — 코드 수준의 고민

설계안이 정해져도 코드 수준에서 다시 갈림길이 나온다. 두 테이블을 함께 운영하며 생기는 문제(상세안 1)와 각 테이블의 구현 규칙(상세안 2·3·4)을 정리한다.

상세안 1 : 트랜잭션 경계와 데드락

적용 지점 : 원장과 스냅샷을 함께 갱신하는 모든 쓰기 경로.

원장과 스냅샷을 함께 운영할 때 가장 조심할 지점이다. 정합성을 지키려고 두 로직을 하나의 트랜잭션으로 묶는 순간, insert와 update가 한 트랜잭션 안에서 섞이면서 데드락 조건이 만들어진다.

상황

원인

원장 insert의 S lock과 스냅샷 update의 X lock 업그레이드가 순환 대기를 만드는 과정

해결 : 변동 유형별로 반영 경로를 나눈다

동기로 묶을 대상을 고르는 기준은 하나다. 지금 안 묶으면 오버셀이 나는가. 아니라면 트랜잭션 밖으로 뺀다.

변동 유형반영 경로근거
판매 차감동기 (원장 insert + 스냅샷 update 한 트랜잭션)늦으면 오버셀 — 허용 기준상 불가
입고비동기 (스케줄러 또는 이벤트)늦으면 품절로 보일 뿐 — 감수 가능
취소 복원비동기입고와 동일

비동기 반영의 순서 보장

비동기 경로를 선택하는 순간 함께 구현해야 하는 규칙이다.

대사 배치

정리

상세안 2 : 원자적 차감 — 조건부 update 한 문장

적용 지점 : 판매 차감. 오버셀 방어의 최종 지점이다.

문제 : “조회 → 검증 → 차감”으로 나누면 조회와 차감 사이에 다른 트랜잭션이 끼어들 수 있다. 이 틈을 락으로 막으려다 상세안 1의 데드락 구조로 돌아가게 된다.

해결 : 검증과 차감을 조건부 update 한 문장에 담는다. 영향받은 행이 0이면 품절로 처리한다.

UPDATE stock_snapshot
SET available_qty = available_qty - :n
WHERE product_id = :id AND available_qty >= :n

주의점 : 조건 available_qty >= :n이 DB 수준에서 오버셀을 막는다. 이 조건을 애플리케이션 검증으로 옮기는 순간 원자성이 깨지므로, 검증 로직을 밖으로 빼고 싶어져도 이 한 문장은 유지한다.

상세안 3 : 멱등성 — 유니크 제약으로 중복 기록 차단

적용 지점 : 원장 insert 전체. 재시도, 이벤트 중복 발행, 사용자 중복 클릭 모두 원인이 된다.

문제 : 같은 변동이 두 번 기록되면 원장의 합산 자체가 틀어진다. 파생 데이터인 스냅샷은 재계산으로 복구할 수 있지만, 원천인 원장이 오염되면 복구 기준이 사라진다.

해결 : 원장의 reference_id + type에 유니크 제약을 걸어 중복 insert를 막는다. 같은 주문(reference_id)의 같은 유형(type) 변동은 한 번만 기록된다.

주의점 : 멱등성 판단을 유니크 인덱스에 맡기면 테이블이 그 제약에 묶인다. 원장을 샤딩하면 유니크가 전역으로 보장되지 않고, 파티셔닝하면 유니크 인덱스에 파티션 키가 포함돼야 한다. 확장 시점에 걸림돌이 될 수 있다는 점은 자매편(포인트-결제 설계안)의 상세안 1과 같은 한계다.

상세안 4 : 원장 이력 보관 정책

적용 지점 : 원장 운영 전체. insert만 하는 구조라 데이터는 계속 쌓이고, 방치하면 합산 조회가 느려지고 디스크·백업 비용이 커진다.

전제 : 원장은 아무 row나 지울 수 없다

해결 1 : 오래된 구간을 이월 이벤트 한 줄로 요약한다 (compaction)

해결 2 : RANGE 파티셔닝으로 정리 비용을 없앤다

해결 3 : 지울 수 없는 구간은 압축 아카이브로 이관한다

해결 4 : 파티셔닝이 안 되어 있다면, DELETE는 나눠서 지운다

기존 테이블이라 파티션 DROP을 쓸 수 없을 때의 차선책이다. 대량 DELETE는 그 자체가 장애 요인이라 지키는 순서가 있다.

정리


남은 질문

다음 스터디(스텝 1·2 심화, 아키텍처 발표)에서 확인할 것들.


Share this post:

Previous Post
JVM 네이티브 메모리 추적기: 힙 밖 1.2GB를 추적한 방법
Next Post
설계 노트: 포인트 차감과 결제 사이의 정합성 다루기