본문 바로가기
inpilot.dev

주문 상태를 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 = to

주문 상태 전이도
실선이 정상 전이, 점선이 되돌림. 표에 없는 화살표는 전부 거절된다

if로 흩어져 있으면 새 상태를 추가할 때 전부 찾아 고쳐야 한다. 표로 두면 한 곳만 바뀌고, 가능한 전이 목록 자체를 테스트할 수 있다.

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, 이력은 별도 테이블.

새 글이 올라오면 받아보기

스팸 없이, 새 글이 올라올 때만 보내드려요.

댓글

댓글은 giscus 설정 후 표시됩니다. (docs/SETUP-features.md 참고)