주문 상태를 boolean 세 개로 관리하다 망한 이야기 — 상태 머신
2025-12-28 · 3 min read
$ UPDATE orders SET status='paid'
UPDATE 0 — 이미 누가 바꿈
플래그가 늘어나면 조합이 폭발한다
is_paid BOOLEAN,
is_cancelled BOOLEAN,
is_shipped BOOLEAN세 개면 조합이 8가지다. 그런데 실제로 유효한 건 몇 개 안 된다.
is_cancelled = true AND is_shipped = true는 뭘까? 배송된 걸 취소했다는 건가,
취소했는데 배송이 나갔다는 건가. 코드 어딘가에서 두 필드를 각자 켜다 보면
아무도 정의한 적 없는 상태가 DB에 쌓인다.
조회 조건도 같이 썩는다.
WHERE is_paid AND NOT is_cancelled AND NOT is_shipped -- 이게 '결제완료'?이 조건이 코드 곳곳에 흩어지고, 그중 하나만 빠뜨리면 버그다.
상태를 하나로 합친다
status TEXT NOT NULL CHECK (status IN (
'created', 'paid', 'shipped', 'delivered',
'cancelled', 'refunded', 'failed'
))불가능한 조합이 표현 자체가 안 된다. 이게 핵심 이득이다. 검증 코드를 아무리 잘 써도, 애초에 못 쓰게 만드는 것보다 약하다.
전이를 데이터로 선언한다
상태를 합쳐도 "결제완료 → 생성됨"으로 되돌아가는 건 막아야 한다.
if문 대신 표로 둔다.
TRANSITIONS = {
"created": {"paid", "cancelled", "failed"},
"paid": {"shipped", "refunded"},
"shipped": {"delivered"},
"delivered": {"refunded"},
"cancelled": set(), # 종료 상태
"refunded": set(),
"failed": {"created"}, # 재시도 허용
}
def transition(order, to: str):
if to not in TRANSITIONS[order.status]:
raise InvalidTransition(f"{order.status} → {to}")
order.status = toif로 흩어져 있으면 새 상태를 추가할 때 전부 찾아 고쳐야 한다.
표로 두면 한 곳만 바뀌고, 가능한 전이 목록 자체를 테스트할 수 있다.
def test_no_orphan_state():
"""어떤 상태든 시작점에서 도달 가능해야 한다"""
reachable, stack = {"created"}, ["created"]
while stack:
for nxt in TRANSITIONS[stack.pop()]:
if nxt not in reachable:
reachable.add(nxt); stack.append(nxt)
assert reachable == set(TRANSITIONS)도달 불가능한 상태가 있으면 죽은 코드거나 전이표에 구멍이 있는 것이다.
동시성: 전이도 경합한다
읽고 검사하고 쓰는 구조라 이 글의 문제가 그대로 재현된다. 결제 웹훅과 사용자 취소가 동시에 들어오면 둘 다 통과할 수 있다.
전이 조건을 UPDATE에 넣는다.
UPDATE orders SET status = 'paid'
WHERE id = $1 AND status = 'created';행 수가 0이면 이미 누가 상태를 바꾼 것이다. 락도 재시도도 필요 없다.
SELECT로 읽고 파이썬에서 검사한 뒤 UPDATE하는 코드는
부하가 걸리면 반드시 뚫린다.
상태 변경 이력을 남긴다
status 컬럼은 현재 값만 안다. "언제 결제됐나", "왜 실패했나"는 못 답한다.
그런데 이건 CS 문의의 대부분이다.
CREATE TABLE order_events (
id BIGSERIAL PRIMARY KEY,
order_id UUID NOT NULL,
from_state TEXT,
to_state TEXT NOT NULL,
reason TEXT,
actor TEXT, -- 'user' | 'webhook:pg' | 'batch'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);전이 함수 안에서 항상 같이 INSERT한다. 같은 트랜잭션이라 어긋날 일이 없다.
actor가 특히 유용하다. "누가 이 주문을 취소했나"를 로그를 뒤지지 않고 답할 수 있다.
부분 상태는 상태로 만들지 않는다
부분 배송, 부분 취소를 상태로 넣고 싶어진다.
partially_shipped, partially_refunded... 이러면 다시 조합 폭발이다.
수량이 있는 건 상태가 아니라 숫자로 둔다.
orders.status = 'shipped'
order_items.shipped_qty / order_items.qty
"전량인지 일부인지"는 계산해서 알아낸다. 상태 개수를 늘리는 대신 집계 쿼리 하나를 쓰는 게 훨씬 싸다.
안 한 것
상태 머신 라이브러리는 안 썼다. dict 하나와 함수 하나로 충분하다. 라이브러리가 필요해지는 시점은 가드 조건, 진입/퇴장 훅, 병렬 상태가 동시에 필요할 때인데, 주문 도메인에서는 대체로 안 온다.
한 줄 요약
플래그 대신
status한 컬럼, 전이는 dict로 선언, 변경은 조건부 UPDATE, 이력은 별도 테이블.