📕 전자책 전문 읽기 상태머신의 규율 · 35쪽
브라우저가 PDF를 바로 표시하지 못합니다. 새 탭에서 열기
이 글은 요약해 다시 쓴 것이고, PDF가 전문입니다.

시스템에 주문, 결제, 구독, 문서처럼 상태가 흐르는 대상이 있다면, 이 글은 당신을 위한 것입니다. 읽고 나면 얻는 것이 두 가지입니다. 상태가 왜 제멋대로 바뀌는지에 대한 답, 그리고 더 이상 제멋대로 바뀌지 않게 만드는 구체적인 설계입니다.

결론부터 말하겠습니다. 상태가 제멋대로 바뀌는 이유는 코드가 나빠서가 아닙니다. 상태머신이 이름 없이 각 if 문 안에 흩어져 있기 때문입니다. 흩어진 기계를 한 데 모아, 표로 읽히는 형태로 만드는 일. 그것이 이 글이 논증하는 전부입니다.

주문이 결제 대기에서 결제 완료로 바뀌는 순간, 시스템 안에서 무슨 일이 일어나는지 생각해 보세요. 대개의 답은 “어딘가에서 if 문이 하나 통과됐다” 정도입니다. 그 if 문이 어디에 있고 몇 개인지는 아무도 모릅니다. 모른다는 사실 자체가 문제죠. 이 글은 그 모름을 알음으로 바꾸는 과정을, 컬럼 대란에서부터 부기 전용 히스토리 테이블까지 순서대로 논증합니다.

제멋대로 바뀌는 상태는 이름 없는 상태머신이다 개념을 형상화한 이미지 글의 핵심 개념을 형상화했습니다.

상태가 제멋대로 바뀌는 이유

주문 테이블에는 status 컬럼이 있습니다. 값은 pending, paid, shipped, done, canceled. 여기까지는 괜찮고 문제가 시작되는 것은 그 다음입니다. 결제 실패를 표현하려고 payment_status 컬럼을 추가하고, 재고 확인을 위해 inventory_checked 불리언을 넣고, 환불을 위해 refund_requested를 넣습니다.

이제 status 하나로는 “지금 어디쯤인가”를 알 수 없습니다. status가 paid이고 payment_status가 failed이며 inventory_checked가 true인 주문은 도대체 무엇입니까. “결제는 완료됐는데, 결제는 실패했고 재고는 확인됐다”는 뜻입니까. 컬럼이 다섯 개가 되면, 가능한 조합의 수는 다섯 컬럼 값의 곱입니다. 대부분은 현실에서 일어나지 않는 조합이고 그 조합을 막는 방어 if 문이 여기저기에 쌓이죠.

더 나쁜 것은, 이 if 문이 서비스마다 흩어져 있다는 점이에요. 결제 서비스는 결제 컬럼만 믿고 배송 서비스는 재고 불리언만 보고 환불 서비스는 환불 컬럼만 봅니다. “이 주문은 지금 어떤 상태냐”고 물으면 한 목소리의 답이 나오지 않습니다. 각 서비스의 if 문이 서로 다른 전제를 깔고 있기 때문입니다.

이 조건에는 이름이 있어요. 암시적 상태머신이다. 흐름이 실제로 존재하기 때문에 상태머신은 있고 다만 표로 쓰여 있지 않고 한 데 모이지도, 명시화되지도 않았을 뿐입니다. 암시적인 시스템은 디버깅이 어렵고 테스트가 불완전하며, 한 기능만 고쳐도 다른 데에 영향을 줍니다. 암시적인 기계를 한 곳의 표로, 읽히는 형태로 만드는 것. 이 글의 규율은 바로 그 행위입니다.

상태, 이벤트, 전이, 가드

명시적 상태머신은 네 단어로 충분해요. 각각이 지는 책임은 다릅니다. 책임이 갈라지는 순간, 시스템이 읽히기 시작합니다.

상태는 “지금 어디쯤인가”의 답이다. pending, paid, shipped. 동시에 여러 개가 참인 적이 없다. 주문은 지금 배송 중이거나, 아니거나다. 한 시점에 상태는 하나다.

이벤트는 이미 일어난 사실이에요. 결제가 확인되고 사용자가 취소를 누르고 심사관이 승인을 누릅니다. 이벤트는 상태를 직접 쓰지 않습니다. 시스템은 이벤트를 수신하고 그 수신에 따라 전이를 할지 판단해요.

전이는 규칙이다. 이 상태에서 이 이벤트가 오면, 이 조건이면, 저 상태로 간다. 모든 조합에 도착지가 있는 것은 아니다. 완료된 주문에 결제 확인 이벤트가 와도 갈 곳이 없죠. 전이 테이블은 갈 수 있는 곳만 적는다.

가드는 전이하기 전의 조건입니다. paid에서 inTransit으로 갈 수 있다는 전이가 있다 하더라도, “재고가 충분한가”라는 가드가 거짓이면 실제로 가지 않습니다. 가드는 전이를 막을 수도, 통과시킬 수도 있는 판단입니다.

네 질문에 네 답이죠. “지금 어디쯤인가”는 상태가 답하고 “무슨 사건이 왔나”는 이벤트가 답하고 “갈 곳이 있는가”는 전이가 답하고 “조건이 충족됐는가”는 가드가 답합니다. 이 질문에 답하지 못하던 시스템이, 이제 구조 안에 답을 갖게 됩니다.

상태를 “지금 무엇을 기다리고 있는가”로 이름 짓기

상태 이름은 한 가지 질문에 답해야 합니다. 이 대상은 지금 무엇을 기다리고 있는가. 대기에 있는 주문은 결제 확인을 기다립니다. 이름은 awaitingPayment죠. 돈을 이미 받은 주문은 출고를 기다립니다. paid라는 이름은 정확히 “결제 확인을 받았고 출고 대기”라는 위치를 지칭합니다. 이름은 기다리는 것을 드러내야 하죠.

이 짓는 방식의 이득은 새 상태가 들어오려 할 때 나옵니다. “그건 무엇을 기다리는 거지?”라고 물을 수 있기 때문입니다. 답이 두 가지면, 두 상태로 나눌 수 있어요. “결제 승인 대기”와 “관리자 심사 대기”를 processing이라는 이름 하나에 넣으면, 버그로 주문이 어딘가에 멈췄을 때 어떤 대기에 멈춰 있는지 알 수 없습니다. awaitingPaymentApproval과 awaitingReview로 나누면, 각 대기의 끝을 맺는 이벤트도 달라져요. 결제 제공사의 웹훅은 전자만 움직이고 심사관의 클릭은 후자만 움직입니다. 기다림을 이름 붙이는 것은 출구를 이름 붙이는 것입니다.

상태는 몇 개가 좋은가요. 소규모 제품이라면 4에서 7개가 자연스럽고 10개 넘으면 의심의 대상이에요. [추정] 넘었다면 후보가 둘 있습니다. 사실이 상태로 끼어든 것, 혹은 서로 다른 두 상태머신이 한 컬럼에 엉킨 것입니다. 환불의 상태가 주문의 상태와 한 컬럼에 섞여 있다면, 상태를 쪼개는 것보다 기계를 둘로 나누는 편이 낫습니다. 작은 기계 둘은 큰 기계 하나보다 쉽죠.

터미널 상태는 따로 챙깅니다. 터미널은 나가는 전이가 없는 상태입니다. 완료, 취소, 환불 완료. 한번 들어가면, 그 대상은 읽기 전용이죠. 이득이 두 번 옵니다. “완료인데 왜 취소됐나?”라는 질문 자체가 사라져 디버깅이 쉬워지고, 터미널에 도착한 모든 이벤트를 “움직이지 않는다” 하나로 묶어 처리할 수 있어 코드가 쉬워집니다.

나쁜 이름의 공통점은, 어디에나 붙일 수 있다는 점이죠. processing은 어떤 대상에서나 processing이고 active는 모든 제품에 active입니다. 이름을 나란히 놓고, 이름이 특정할수록 그 상태를 만드는 전이와 가드도 특정해지는 것을 확인해 보세요.

나쁜 이름 좋은 이름 이유
processing awaitingPayment 무엇을 기다리는지 드러난다
active paid 대상과 위치가 명확하다
done completed 무엇이 끝났는지 드러난다
failed paymentFailed 어떤 실패인지 드러난다

전이 테이블은 데이터이고 실행은 한 함수

가장 중요한 결정은 단 하나, 전이 테이블을 데이터로 두는 것입니다. 나쁜 형태는 전이가 if 문으로 흩어진 모양입니다. 요구사항이 바뀔 때마다 이 if 문들을 전부 찾아야 하고 다 찾았다는 보장은 없어요. 결제 경로를 고치다 배송 경로의 if 문을 빠뜨리면, 두 서비스가 같은 이벤트를 다른 결과로 처리합니다. 표가 두 개가 되는 순간, 규율은 없어요.

좋은 형태는 표 하나와 apply 함수 하나입니다. 표는 “이 상태에서 이 이벤트가 오면 어디로 가나”를 데이터로 적습니다. apply 함수는 판단하지 않고 실행만 해요. 판단은 데이터에, 실행은 함수에. 표를 바꿀 때 코드를 건드리지 않고 코드를 고칠 때 표를 건드리지 않습니다.

apply 내부의 검사 순서는 자의적이지 않습니다. 각 단계가 각기 다른 종류의 거절을 만들기 때문입니다. 지금 상태가 터미널인가. 표에 이 상태와 이벤트의 규칙이 있는가. 가드가 참인가. 잠금에서 쓰기가 이기나. 히스토리가 같은 트랜잭션에 기록되는가. “터미널 상태”, “규칙 없음”, “가드 거짓”은 서로 다른 버그입니다. 셋을 하나의 에러로 묶으면, 디버깅할 때 무엇을 고쳐야 할지 모릅니다.

가드는 순수 함수. 컨텍스트를 읽고 참이나 거짓을 반환합니다. 데이터베이스에도, 외부 API에도 손을 대지 않아요. API를 호출해 2초 뒤에 반환하는 가드가 있으면, 전이의 타이밍이 불확정적이 됩니다. 같은 입력에 다른 결과가 나올 수 있고 테스트도 어려워집니다. “괜찮아질 때까지 기다리는” 일은 가드의 몫이 아니죠. 재고 확인에 시간이 걸린다면, 재고 확인 완료를 별도의 이벤트로 만듭니다. 가드는 이미 일어난 사실에만 묻습니다.

동시성은 여기서 결정됩니다. 결제 확인 웹훅이 두 번, 동시에 온다고 하세요. 둘 다 같은 상태를 읽고 둘 다 같은 다음 상태를 계산합니다. 낙관적 잠금이 있으면, 쓰기는 정확히 하나만 성공해요. 실패한 쪽은 충돌을 알아내고 재시도하거나 “이미 처리됨”을 반환합니다. 외부 시스템의 중복 전달은 버그가 아니라 예상 트래픽입니다. 히스토리에 event_uid 컬럼 하나를 넣으면, 같은 입력에 같은 결과가 됩니다.

한 컬럼은 상태고 한 줄은 사실

마친 상태머신을 어디에 담을까요. 원칙은 하나입니다. 현재 위치는 한 컬럼, 일어난 사실은 한 줄.

주문 테이블에는 status 컬럼이 하나 있습니다. 이 컬럼 하나로 “이 주문은 지금 어디쯤인가”를 압니다. 두 번째 컬럼을 보며 추측할 필요가 없죠. 그 옆에는 사실 컬럼들이 있습니다. paidAt은 언제 결제가 확인됐는지, shippedAt은 언제 출고됐는지, refundReason은 환불 사유를 말해 줍니다. 이 사실들은 변하지 않아요. 한번 쓰이면 그대로입니다. 상태는 변하지만 사실은 누적됩니다.

반대로, 사실에서 상태를 추론하는 코드는 경입니다. “shippedAt이 있으면 배송 중”이라고 쓰는 순간, 출고가 끝난 완료 주문을 “배송 중”으로 읽는 버그가 생깁니다. 사실은 “언제 일어났는가”에만 답해요. “지금은 어디인가”에는 답하지 못합니다. 두 질문에 두 컬럼.

상태가 바뀔 때, 그 변화 자체도 한 줄로 남깁니다. 어떤 상태에서, 어떤 이벤트가, 언제, 누구의 판단으로, 어느 상태로 옮긴 기록. 이것이 히스토리 테이블입니다. 히스토리는 부기 전용입니다. 한번 쓰면 지우지 않죠.

실수가 있으면 새 이벤트로 정정합니다. 잘못된 취소를 되돌리려면 cancelReversed라는 이벤트가 히스토리에 한 줄을 더합니다. 지우지 않고 정정하는 것이 원칙이에요. 히스토리를 갱신하거나 삭제하면, “그때 실제로 무엇이 일어났는가”의 증거가 사라집니다. 움직인 것뿐 아니라, 움직이지 않은 것도 기록해야죠. 거절된 이벤트도 “받았으나 움직이지 않았다”는 줄로 남습니다. 움직이지 않은 것까지 남기면, “왜 움직이지 않았지?”에 답할 수 있습니다.

테스트와 디버그와 확장이 쉬워지는 순간

테스트부터 보겠습니다. 암시적 상태머신의 테스트는 코드 경로를 테스트합니다. 코드가 늘수록 테스트도 늘고 “다 짰다”는 확신은 없어요. 명시적 상태머신의 테스트는 표를 테스트합니다. 표는 유한합니다. 상태 여섯 개, 이벤트 여덟 개라면 조합은 48개, 테스트도 48개죠. 하나를 만들면 빠뜨릴 것이 없습니다.

이 테스트의 아름다움은, 표가 바뀌면 테스트가 자동으로 따라온다는 점입니다. 상태를 하나 추가하면 그 상태 곱하기 모든 이벤트 줄이 추가되고, 이벤트를 하나 추가하면, 모든 상태 곱하기 그 이벤트 줄이 늘어난다는 뜻이죠. “케이스를 하나 더 해야 하나”를 묻지 않아도 됩니다. 거절의 이유도 테스트합니다. “코드가 안 죽었다”는, 성공이 아니죠. “불법 조합이 이유를 가진 거절로 돌아왔다”가 성공입니다.

불변식은 두 번째 간극을 막습니다. 전이 테스트가 표의 칸을 확인한다면, 불변식은 어떤 경로로도 참이어야 할 것을 확인합니다. completed이면 completedAt이 있고, awaitingPayment이면 paidAt은 비어 있어야 해요. 불변식은 표에서 빠진 칸을 잡아 줍니다. 그리고 프로덕션에서도, 테스트와 함께 매일 도는 것이 원칙이에요. 하루 한 번, 전체 대상을 배치로 확인하고 깨진 것만 보고합니다. “깨진 것이 없다”가 정상이에요.

디버그는 히스토리를 읽는 일입니다. 프로덕션 사고의 흔한 형태는 “존재하지 않아야 할 상태”입니다. 완료됐는데 환불이 진행 중인 주문. 암시적 상태머신에서 이를 조사하는 것은 고고학입니다. status를 쓰는 곳을 전부 찾고 로그에서 어떻게 여기까지 왔는지 재구성합니다. 오후가 통째로 사라져요. 명시적 상태머신에서는 한 쿼리로 시간 순서 타임라인이 납니다. 타임라인에 틈이 있으면, 그 틈이 버그입니다.

존재하지 않아야 할 상태가 보이면, 먼저 “표의 경로인가”부터 물어야죠. 표에 있으면 표가 틀린 것입니다. 설계 버그죠. 표에 없으면, 코드가 apply를 우회한 것입니다. 구현 버그입니다. 두 종류, 두 개의 수정 방법. “if를 하나 더 넣겠다”는 수정은 그 어느 것도 아닙니다.

확장은 새 요구사항이 왔을 때 값을 합니다. “배송 후 48시간 내에는 취소할 수 있다.” 암시적 상태머신에서는 “취소할 수 있는가”를 판단하는 곳을 전부 찾아 조건을 추가하고 다 찾기를 바랄 뿐이에요. 명시적 상태머신에서는 표에 칸 하나, 가드 하나, 상태 하나를 추가합니다. 이득은 요구사항의 비용이 보인다는 점입니다. “상태 1개, 칸 1개, 가드 1개, 테스트 N개”는 추정 가능하죠. 반대 사례도 있어요. 프로모션 코드는 상태를 바꾸지 않습니다. 주문은 그대로 대기에 있고, 할인 여부는 사실입니다. 컬럼 하나. 새 요구사항이 왔을 때 “이건 상태인가, 사실인가”를 먼저 묻는 것이, 표가 불필요하게 불어나는 것을 막습니다.

시간이 없다면, 최소 셋과 경계

경계부터 하나 짚겠습니다. 상태머신이 잘 맞는 것은, 위치 하나가 대상을 설명하고 그 위치가 소수로 셀 수 있는 대상입니다. 주문, 구독, 문서, 승인. 편집 도구의 되돌기와 다시 실행은 위치가 아니라 행위의 순서, 스택입니다. 결제와 재고가 각각 진행되면서 둘 다 끝났을 때만 완료되는 대상이라면, 둘이 각자의 기계를 갖고 조합을 명시하는 편이 낫습니다. “주문은 배송 중이고 환불은 진행 중”이라고 두 기계로 읽힌다면, 하나로 합칠 필요는 없죠.

그리고 출시를 먼저 해야 한다면, 최소 셋만 하면 됩니다. 하루 안에 끝납니다.

첫째, 표를 하나 그려 보세요. 오늘 만든 대상의 상태와 이벤트를, A4 한 장에. 그림을 그리는 것만으로 빈 칸이 보입니다. 빈 칸은 미완성이 아닙니다. “이건 일어나서는 안 된다”는 명세입니다.

둘째, status를 직접 쓰는 if를 한 군데로 모으세요. 전부 apply로 바꿀 필요는 없습니다. 흩어진 status 대입은 한 함수에 모아 두는 거죠. 전이가 “여기”에 있음을 만드는 것입니다. 표를 데이터로 바꾸는 것은 그다음 일입니다.

셋째, 히스토리 테이블을 만드세요. 부기 전용입니다. 출발 상태, 이벤트, 도착 상태, 시각. 이 셋만 있어도 “어떻게 여기까지 왔나”를 읽을 수 있습니다. 디버깅의 8할은, 이것 하나예요.

셋이 끝나면, 다음 요구사항이 “if를 하나 더 넣자”에서 “표를 하나 고치자”로 바뀝니다. 그 한 문장의 차이가, 이 글의 전부예요. 더 깊게 파고들고 싶은 분은, 이 글과 함께 준비된 전자책 “상태머신의 규율: 주문·결제·문서가 제멋대로 바뀌지 않는 기술”을 보시면 됩니다. 상태 인벤토리 잡기부터 가드와 동시성, 히스토리 운영까지, 이 글이 간략히 한 지점들을 예시와 함께 펼쳐 쓴 책입니다.

참고 자료

본문의 상태, 이벤트, 전이, 가드 네 단어와 전이 테이블, 부기 전용 히스토리, 낙관적 동시성 처리는 아래 자료와 대조해 보실 수 있습니다.

태그: backend, debugging, domain-design, order-lifecycle, payments, state-machine, testing

카테고리:

업데이트: