0장. 주문을 취소했더니 손님이 쿠폰까지 잃었습니다
손님이 주문을 취소합니다.
결제가 취소되고, 재고가 돌아오고, 적립 예정이던 포인트가 회수됩니다. 마지막으로 쓴 쿠폰을 돌려줄 차례입니다.
그런데 그 쿠폰은 어제 만료됐습니다. 주문할 때는 살아 있었습니다.
돌려줄 방법이 없습니다. 손님은 주문도 잃고 쿠폰도 잃었습니다. 에러 로그는 없습니다. 코드는 시킨 대로 했습니다.
넷은 돌아갔고 하나만 안 됐습니다. 쿠폰이 유별난 놈이었던 걸까요.
그러면 되돌린다는 건 대체 무슨 뜻이었을까요?
1장. 되돌리기는 원래 일의 반대가 아닙니다
결제를 취소해 보신 적 있으실 겁니다. 카드사에 매출이 넘어가기 전에 누르면 취소로 끝나고, 넘어간 뒤에 누르면 환불이 됩니다. 그 차이는 날짜가 아니라 매출이 넘어갔는지입니다.

같은 버튼이고 손님 눈에는 같은 결과인데, 시스템 안에서는 다른 두 가지 일이 벌어집니다. 앞쪽은 승인 건을 무효로 만들어서 정산에 아예 안 올라가게 하고, 뒤쪽은 반대 방향으로 돈을 한 번 더 보냅니다. 수수료 처리도 다릅니다.
어느 쪽도 승인 기록을 지우지는 않습니다. 취소도 카드사에 새로 보내는 거래라 취소 승인번호가 따로 나옵니다. 원래 승인 건은 취소됐다고 표시될 뿐 사라지지 않죠. 전표가 한 장 더 생기는 겁니다.
바로 이 지점에서, 이 글이 하고자 하는 이야기가 시작됩니다.
되돌리기는 원래 일을 되감는 것이 아니라, 반대 방향으로 새로 하는 일이다.
단순한 '되감기'라면 실패할 리 없습니다. 하지만 '새로 하는 일'이라면 다릅니다. 조건이 안 맞으면 실패하거나 아예 불가능할 수도 있습니다.
0장의 쿠폰이 딱 그렇습니다. 시간을 되감은 게 아니라 만료된 쿠폰을 지금 다시 발급해달라는 새로운 요청이었기 때문에 규칙에 막혀버린 겁니다.
데이터베이스가 대신 해주던 일
트랜잭션 안에서는 이런 고민을 할 필요가 없었습니다. 예외를 던지면 데이터베이스가 알아서 되돌려주니까요.
"Lack of automatic rollback - a developer must design compensating transactions that explicitly undo changes made earlier in a saga rather than relying on the automatic rollback feature of ACID transactions"
자동 롤백이 없다. 개발자가 ACID 트랜잭션의 자동 롤백 기능에 기대는 대신, 앞서 만든 변경을 명시적으로 되돌리는 보상 트랜잭션을 직접 설계해야 한다.
- microservices.io, "Saga"
직접 설계해야 한다는 말이 이 문장의 핵심입니다. 되돌리기는 공짜로 딸려 오던 기능이었는데, 서비스를 나누는 순간 직접 짜야 하는 기능으로 바뀝니다.
왜 사라지느냐면, 데이터베이스는 자기가 아직 안 끝낸 일만 되돌릴 수 있기 때문입니다. 결제 서비스가 자기 트랜잭션을 이미 커밋했으면 재고 서비스가 나중에 실패해도 결제 쪽은 이미 끝난 일입니다. 남이 커밋한 것을 취소해 달라고 말할 방법이 없죠.
그리고 직접 짜는 순간 원래 일만큼의 설계가 필요해집니다. 실패하면 어떻게 할지, 두 번 실행되면 어떻게 할지, 조건이 안 맞으면 어떻게 할지를 전부 정해야 하니까요.
되돌려도 원래대로 오지 않습니다
되돌리기가 '새로 하는 일'이라는 사실을 받아들이면, 한 가지 분명한 결론을 도출할 수 있겠습니다.
"A compensating transaction doesn't necessarily return the system data to its state at the start of the original operation. Instead, the transaction compensates for the work that the operation completes successfully before it failed."
보상 트랜잭션이 시스템 데이터를 원래 작업이 시작된 시점의 상태로 반드시 되돌리는 것은 아니다. 대신 작업이 실패하기 전에 성공적으로 마친 일을 상쇄한다.
- Microsoft Azure Architecture Center, "Compensating Transaction"
되돌린다와 원래대로 만든다는 다른 말이었습니다.
그리고 이 문장은 최근에 나온 통찰이 아닙니다. 사가라는 이름을 처음 붙인 1987년 논문에 이미 같은 말이 있습니다.
"The compensating transaction undoes, from a semantic point of view, any of the actions performed by Ti, but does not necessarily return the database to the state that existed when the execution of Ti began"
보상 트랜잭션은 Ti가 수행한 행동들을 의미의 관점에서 되돌리지만, 데이터베이스를 Ti가 실행되기 시작한 시점의 상태로 반드시 되돌리는 것은 아니다.
- Hector Garcia-Molina, Kenneth Salem, "Sagas", 1987
해석하자면, 데이터베이스를 옛날 모습으로 억지로 되감는 게 아니라, 그 행동이 만들었던 '결과'만 무효로 쳐준다는 뜻입니다.
의미의 관점에서라는 단서가 이 문장의 전부입니다. 예를 들어 좌석 하나를 예약했다가 취소했다고 해봅시다. 취소를 했으니 내 예약이라는 의미 자체는 사라지지만, 데이터베이스가 통째로 예약 전 시간으로 똑같이 돌아가지는 않습니다. 내가 예약하고 취소하는 사이에 남이 다른 좌석을 잡았을 수도 있으니까요.
같은 이유로 항공권을 취소한다고 전액을 돌려받지는 않습니다.
"You might need to apply business-specific rules. For example, canceling a flight reservation might not entitle the customer to a complete refund."
사업 고유의 규칙을 적용해야 할 수도 있다. 예를 들어 항공권 예약을 취소한다고 해서 손님이 전액 환불을 받을 자격이 생기는 것은 아니다.
- Microsoft Azure Architecture Center, "Compensating Transaction"
취소 수수료를 떼는 것은 버그가 아닙니다. 되돌리기가 원래 상태로의 복귀가 아니라 새로운 거래이기 때문에 생기는 정상 동작입니다.
그러면 화면에는 뭐라고 써야 할까요?
0장의 손님에게 그저 취소가 완료되었습니다라고만 안내하는 것은 상황을 정확히 알려주는 것이 아닙니다. 손님 입장에서는 쿠폰을 돌려받지 못했으니까요. 되돌리기를 제대로 설계한다는 것은 결국, 어디까지 복구되었고 무엇은 돌아오지 않았는지 손님에게 어떻게 설명할지 세심하게 정하는 일까지 포함합니다.
2장. 그래서 되돌리는 순서도 역순이 아닙니다
순서 이야기를 하기 전에 전제를 하나 밝혀둘 점이 있습니다. 여기서 말하는 되돌리기는 취소가 성립한 뒤의 이야기입니다. 취소가 되는 주문인지, 얼마를 돌려줄지는 그 앞에서 이미 정해져 있습니다. 그게 안 정해진 상태에서 결제부터 되돌리면, 뒤에서 이 주문은 취소가 안 된다는 답이 왔을 때 이미 나간 돈을 다시 받아올 방법이 없습니다.
되돌리기를 그림으로 그려 보라고 하면 거의 모두가 같은 그림을 그립니다. 오른쪽으로 갔던 화살표를 그대로 왼쪽으로 되돌리는 그림이죠. 4번을 되돌리고, 3번을 되돌리고, 2번, 1번 순으로요.
스택을 되감는 것처럼 보이니 자연스럽습니다. 그런데 원문은 그렇게 말하지 않습니다.
"The compensating transaction steps don't always reverse the original operation in the exact opposite order."
보상 트랜잭션의 단계들이 항상 원래 작업을 정확히 반대 순서로 되돌리는 것은 아니다.
- Microsoft Azure Architecture Center, "Compensating Transaction"
같은 문서가 그 이유까지 한 줄로 붙여 놓았습니다. 어떤 저장소가 다른 저장소보다 어긋남에 더 민감하면 그쪽을 먼저 되돌리라는 것입니다.
왜 역순이 자연스러워 보였을까요
역순이 맞는 세계가 하나 있습니다. 한 트랜잭션 안입니다.
거기서는 되돌리기가 로그를 거꾸로 재생하는 일이라 순서가 곧 정확성입니다. 나중에 쓴 것을 먼저 지우지 않으면 앞의 값이 뒤의 값에 덮여 버리니까요.
그런데 서비스를 나누고 나면 각 단계가 서로 다른 저장소에 서로 독립적으로 남아 있습니다. 재고를 먼저 돌리든 포인트를 먼저 돌리든 결과가 같습니다. 서로 다른 물건이니까요.
순서가 정확성을 좌우하지 않는다면, 순서는 다른 것을 위해 쓸 수 있습니다.
되돌리는 순서는 정확성이 아니라 노출 시간을 줄이는 데 쓴다.
무엇부터 되돌려야 할까요
배달 주문 취소를 다시 봅시다. 되돌릴 것이 넷입니다. 결제, 재고, 포인트, 그리고 매장에 보낸 알림.
역순대로라면 알림, 포인트, 재고, 결제 순입니다. 그런데 이 순서로 하면 손님 돈이 가장 오래 묶여 있습니다. 결제가 마지막이니까요.
손님 입장에서 가장 못 견디는 어긋남이 무엇일까요. 재고 숫자가 1 틀린 것은 아무도 모릅니다. 포인트가 1분 늦게 회수되는 것도 문제가 안 되죠. 돈이 안 돌아온 것은 즉시 문의가 들어옵니다.
그러니 결제를 가장 먼저 되돌립니다.
| 되돌릴 것 | 어긋나 있는 동안 무슨 일이 | 순서 |
|---|---|---|
| 결제 | 손님 돈이 묶여 있고 즉시 문의가 들어옵니다 | 1 |
| 매장 알림 | 매장이 이미 만들기 시작했을 수 있습니다 | 2 |
| 재고 | 그 시간만큼 덜 팔립니다 | 3 |
| 포인트 | 아무도 모릅니다 | 4 |
같은 네 단계인데 역순과 정반대에 가깝습니다. 역순으로 짰다면 가장 아픈 것을 가장 늦게 풀고 있었을 겁니다.
두 순서를 손님 시계로 놓고 보면 차이가 분명해집니다. 각 단계가 다른 서비스를 부르는 일이라 한 단계에 1초쯤 걸린다고 해봅시다.
| 시각 | 역순으로 짰을 때 | 아픈 것부터 짰을 때 |
|---|---|---|
| 0초 | 취소 버튼을 누릅니다 | 취소 버튼을 누릅니다 |
| 1초 | 매장 알림 취소 | 결제 취소. 손님 카드로 복귀 시작 |
| 2초 | 포인트 회수 | 매장 알림 취소 |
| 3초 | 재고 복구 | 재고 복구 |
| 4초 | 결제 취소 | 포인트 회수 |
오른쪽은 손님이 1초 만에 돈 이야기를 끝냅니다. 왼쪽은 4초입니다.
여기서 1초는 우리가 결제사에 취소를 보내는 시각입니다. 손님 카드에 실제로 돌아오는 데는 며칠이 걸리죠. 그래서 더 중요합니다. 뒤에 붙는 며칠은 우리가 줄일 수 없고, 앞의 3초는 순서만 바꾸면 줄어듭니다.
4초가 별것 아니라고 느껴지실 수 있습니다. 그런데 이 숫자는 모든 게 잘 풀렸을 때고, 중간 어디선가 재시도가 걸리면 그대로 늘어납니다. 매장 알림 서비스가 3초 동안 응답을 안 하면 왼쪽에서는 손님 돈이 그만큼 더 묶입니다. 손님은 자기와 아무 상관 없는 매장 알림 때문에 기다리고 있는 것이죠.
아픈 것부터 되돌리면, 뒤에서 무엇이 느려지든 손님이 안 느낍니다.

이 장의 핵심을 한 줄로 정리하면 이렇습니다.
되돌리는 순서는 실행의 역순이 아니라, 어긋나 있는 동안 가장 아픈 것부터 정한다.
그럼 역순으로 짜면 안 되나요
역순이 틀렸다는 말은 아닙니다. 역순이 기본값이 되면 안 된다는 말이죠.
되돌릴 것들 사이에 진짜 의존이 있으면 그때는 순서를 지켜야 합니다. 정산 항목을 지우기 전에 정산 배치에서 빼야 한다면 그건 역순이 맞습니다.
기준은 간단합니다. 두 단계 사이에 명확한 의존성이 있다면 그 의존성을 따르고, 없다면 가장 아픈 것부터 처리합니다. 실무에서 마주하는 대부분의 취소는 후자에 속합니다.
덧붙여서, 원문은 일부 단계를 동시에 처리해도 된다고 말합니다. 즉, 앞서 말한 순서 고민은 동시에 실행할 수 없는 작업들 사이에서만 따져보면 되는 문제입니다.
3장. 원래 값으로 되돌리면 남의 작업을 지웁니다
되돌리기를 짜라고 하면 대부분 이렇게 시작합니다. 바꾸기 전 값을 어딘가 적어뒀다가, 되돌릴 때 그 값을 다시 넣는 것이죠.
-- 주문할 때: 재고가 10이었고 1을 뺐다
UPDATE stock SET qty = 9 WHERE item_id = 77;
-- 되돌릴 때: 적어둔 10을 다시 넣는다
UPDATE stock SET qty = 10 WHERE item_id = 77;
깔끔해 보입니다. 그리고 손님이 한 명일 때는 정확합니다.
문제는 그 사이에 다른 손님이 있을 때입니다.
3초 사이에 벌어지는 일
첫 손님이 주문합니다. 재고가 10에서 9로 줄고, 되돌리기용으로 10을 적어둡니다.
3초 뒤 다른 손님이 같은 메뉴를 주문합니다. 재고가 9에서 8이 됩니다.
그리고 첫 손님이 취소합니다. 그런데 여기서 적어둔 10을 넣는다면...?
시간표로 놓으면 이렇습니다.
| 시각 | 벌어진 일 | 재고 | 적어둔 값 |
|---|---|---|---|
| 0초 | 첫 손님 주문 | 10 → 9 | 10 |
| 3초 | 두 번째 손님 주문 | 9 → 8 | |
| 6초 | 첫 손님 취소. 적어둔 10을 넣음 | 8 → 10 |
재고가 10이 됐습니다. 두 번째 손님의 주문이 사라졌습니다. 그 손님은 정상적으로 주문했고 음식은 만들어지고 있는데, 재고에는 그 흔적이 없습니다.

그리고 이 사고에는 로그가 없습니다. UPDATE는 성공했고 영향받은 행도 1이니까요. 며칠 뒤 재고가 안 맞는다는 이야기가 매장에서 올라올 때쯤이면 원인을 찾기가 어렵습니다.
"You might think that you can simply restore the system to its original state, but this approach can overwrite changes from other concurrent application instances."
시스템을 원래 상태로 그냥 되돌리면 된다고 생각할 수 있지만, 이 방식은 동시에 도는 다른 인스턴스가 만든 변경을 덮어쓸 수 있다.
- Microsoft Azure Architecture Center, "Compensating Transaction"
이 함정이 얼마나 오래된 것인지 보면 조금 놀랍습니다. 1987년 논문이 항공사 좌석으로 똑같은 이야기를 합니다.
"But Ci cannot simply store in the database the number of seats that existed when Ti ran because other transactions could have run between the time Ti reserved the seat and Ci canceled the reservation, and could have changed the number of reservations for this flight"
그러나 Ci는 Ti가 실행될 때 존재하던 좌석 수를 데이터베이스에 그냥 저장할 수 없다. Ti가 좌석을 예약한 시점과 Ci가 그 예약을 취소한 시점 사이에 다른 트랜잭션들이 실행되어 이 항공편의 예약 수를 바꿔 놓았을 수 있기 때문이다.
- Hector Garcia-Molina, Kenneth Salem, "Sagas", 1987
좌석이 재고로 바뀌었을 뿐 같은 문장입니다. 40년 가까이 된 논문의 예시가 오늘 우리 재고 테이블에서 그대로 재현되고 있습니다.
고치는 방법은 한 줄입니다
-- 되돌릴 때: 지금 값이 얼마든 내가 뺀 만큼만 되돌린다
UPDATE stock SET qty = qty + 1 WHERE item_id = 77;
qty = 10이 qty = qty + 1이 됐을 뿐입니다. 그런데 결과는 완전히 다른 결과죠.
앞쪽은 내가 아는 과거를 주장합니다. 그 과거는 이미 낡았을 수 있죠. 뒤쪽은 내 몫만 빼겠다고 말합니다. 그 사이에 세상이 어떻게 변했든 상관하지 않습니다.
같은 시간표를 다시 돌려 봅니다.
| 시각 | 벌어진 일 | 재고 |
|---|---|---|
| 0초 | 첫 손님 주문 | 10 → 9 |
| 3초 | 두 번째 손님 주문 | 9 → 8 |
| 6초 | 첫 손님 취소. 내 몫 1을 더함 | 8 → 9 |
9가 맞는 답입니다. 두 번째 손님의 주문 하나가 살아 있으니까요.
덮어쓰기가 안전해 보였던 이유도 짚고 가겠습니다. qty = 10은 몇 번을 실행해도 결과가 10입니다. 재시도에 강하죠. 그래서 처음 짤 때는 오히려 이쪽이 튼튼해 보입니다.
문제는 그 튼튼함이 혼자 있을 때만 성립한다는 겁니다. 옆에 다른 요청이 있으면, 몇 번을 실행해도 같은 결과라는 성질이 곧 남의 결과를 몇 번이고 지워버린다는 뜻이 됩니다.
되돌리기가 반대 방향으로 새로 하는 일이라는 1장의 명제가 코드로 내려오면 이 모양이 됩니다. 되감는 코드가 아니라 더하는 코드입니다.
그런데 여기 함정이 하나 더 있습니다
qty + 1 방식에는 치명적인 약점이 하나 있습니다. 이 코드가 두 번 실행되면 재고가 두 개 늘어난다는 점입니다.
그리고 같은 취소 요청이 두 번 도착하는 일은 아주 흔합니다. 이유는 단순합니다.
"Since any message ever sent may be delivered multiple times, we need a discipline in the application to cope with repeated messages."
한 번 보낸 메시지는 여러 번 전달될 수 있으므로, 반복된 메시지를 감당할 규율이 애플리케이션 안에 필요하다.
- Pat Helland, "Life beyond Distributed Transactions", 2007
보낸 쪽이 응답을 제때 못 받으면 다시 보냅니다. 받는 쪽은 잘 처리해 놓고 응답만 유실됐을 수도 있는데, 보낸 쪽에서는 그 둘이 똑같이 보입니다.
앞서 본 qty = 9처럼 덮어쓰는 방식은 훌륭한 장점이 있었습니다. 몇 번을 실행해도 결과가 9로 똑같아서 재시도에 아주 강하죠. 반면 qty + 1은 실행할 때마다 값이 계속 변합니다. 즉, 다른 손님의 주문을 유실시키지 않는 정확성을 얻은 대신, '취소가 두 번 적용되는 것을 막아야 하는' 새로운 숙제를 안게 된 겁니다.
해결 방법은 취소 요청마다 고유한 번호표(ID)를 붙이고, 이 번호표를 이미 처리했는지 데이터베이스에 먼저 물어보는 것입니다.
-- 되돌리기 번호표를 보관합니다. 같은 번호표는 한 번만 들어갑니다.
CREATE TABLE processed_reversals (
reversal_id VARCHAR(64) PRIMARY KEY
);
BEGIN;
-- 이 취소 요청을 처음 보는지 기록합니다.
INSERT INTO processed_reversals (reversal_id) VALUES ('rev_8842');
-- 처음이라면 내 몫만큼 재고를 돌려놓습니다.
UPDATE stock SET qty = qty + 1 WHERE item_id = 77;
COMMIT;
이렇게 짜두면, 같은 취소 요청이 두 번째로 들어왔을 때 UPDATE까지 가지도 못합니다. reversal_id가 기본키(Primary Key)로 설정되어 있기 때문에 INSERT 단계에서 에러가 나고, 묶여 있던 작업이 통째로 취소되기 때문입니다.

여기서 BEGIN과 COMMIT을 보고 의아하실 수 있습니다. "서비스를 쪼개면서 데이터베이스 트랜잭션을 쓸 수 없게 됐다고 하지 않았나?" 하고요. 맞습니다. 없어진 것은 여러 서비스(결제, 재고 등)를 가로지르는 거대한 트랜잭션입니다. 하지만 재고 서비스 자신의 데이터베이스 안에서는 여전히 트랜잭션을 쓸 수 있고, 또 써야만 합니다. 번호표만 덩그러니 남고 재고는 안 늘어나는 반쪽짜리 처리를 막아야 하니까요.
주의할 점이 하나 더 있습니다. 데이터베이스에서 중복으로 막혔다고 해서, 요청을 보낸 쪽에 "실패했다"고 에러를 돌려주면 안 됩니다. 에러를 받으면 보낸 쪽은 또다시 재시도를 할 테니까요. 같은 논문이 이 대목을 정확히 짚습니다.
"In addition to remembering that a message has been processed, if a reply is required, the same reply must be returned. After all, we don’t know if the original sender has received the reply or not."
메시지를 처리했다는 사실을 기억하는 것에 더해, 응답이 필요한 경우라면 같은 응답을 다시 돌려주어야 한다. 애초에 원래 보낸 쪽이 그 응답을 받았는지 아닌지를 우리는 모르기 때문이다.
Pat Helland, "Life beyond Distributed Transactions", 2007
기억해 두는 것만으로는 절반입니다. 기억한 뒤에 같은 대답까지 돌려줘야 보낸 쪽의 재시도가 멈춥니다.
참고로 이 취소 요청을 누가 보낼지도 정해야 합니다. 주문 서비스가 단계마다 직접 취소를 부를 수도 있고, 취소만 전담하는 서비스를 따로 둘 수도 있습니다. 어느 쪽이든 핵심은 작업이 어디까지 진행됐는지 정확히 알고 있는 쪽이 되돌리는 일도 책임져야 한다는 것입니다.
결국 되돌리기를 설계한다는 것은, 앞으로 가는 정상적인 기능을 만들 때 고민했던 동시성과 중복 방어 문제를 처음부터 다시 한 번 푸는 과정입니다.
4장. 그럼 어디에 붙이고 어떤 순서로 할 것인가
여기까지 보면 되돌리기가 생각보다 만만치는 않다는 생각이 듭니다. 그러면 자연스러운 질문이 떠오릅니다.
모든 단계에 되돌리기를 붙여야 하나요?
안 붙여도 되는 단계를 가려내는 규칙이 하나 있습니다.
"Each step of a saga that is followed by a step that can fail (for business reasons) must have a corresponding compensating transaction."
뒤에 (사업상의 이유로) 실패할 수 있는 단계가 오는 모든 단계는 그에 대응하는 보상 트랜잭션을 가져야 한다.
- microservices.io, "Developing sagas"
그러니까 뒤집어 말하면, 뒤에 실패할 것이 하나도 없는 단계는 되돌릴 일이 없다. 라는 뜻이겠죠. 마지막으로 실패할 수 있는 단계, 그 뒤로는 안전지대입니다.
다만 조건이 하나 붙습니다. 지금 말한 안전지대는 진행하다 실패해서 되감을 때의 이야기입니다. 0장처럼 다 끝난 주문을 손님이 나중에 취소하는 경우는 다릅니다. 그때는 마지막 단계까지 전부 되돌릴 대상이죠.
원전도 되돌리기가 시작되는 경로를 둘로 갈라서 적어놓았습니다.
"You can trigger compensation in two ways: When a later step in the same workload fails and you must undo previously successful steps. [...] When a subsequent client explicitly requests to cancel a completed operation."
되돌리기는 두 가지 경로로 시작된다. 같은 작업의 뒤 단계가 실패해서 앞서 성공한 단계들을 되돌려야 할 때. 그리고 완료된 작업을 손님이 나중에 취소해 달라고 요청할 때.
- Microsoft Azure Architecture Center, "Compensating Transaction"
두 경로는 같은 코드를 쓰지만 범위가 다릅니다. 앞쪽은 실패한 지점까지만 되돌리고, 뒤쪽은 전부 되돌립니다. 그래서 안전지대라는 말도 앞쪽에서만 성립합니다.
그러면 순서를 이렇게 잡습니다
규칙이 순서 설계까지 정해줍니다. 실패할 수 있는 것을 앞으로 몰수록 안전지대가 넓어지니까요.
여기에 한 가지를 더합니다.
"Identify compensable versus irreversible steps. Design the workflow so that irreversible steps occur only after all critical validations succeed."
보상 가능한 단계와 되돌릴 수 없는 단계를 구분하라. 되돌릴 수 없는 단계가 모든 중요한 검증을 통과한 뒤에만 실행되도록 흐름을 설계하라.
- Microsoft Azure Architecture Center, "Compensating Transaction"
두 문장을 합치면 이렇게 됩니다.
실패할 수 있는 일을 먼저 하고, 되돌릴 수 없는 일을 마지막에 한다.
배달 주문을 생각해 봅시다
주문 한 건을 받을 때 벌어지는 여섯 가지 단계를 '되돌릴 수 있는지' 기준으로 나누어 보겠습니다.
| 단계 | 되돌릴 수 있나 | 어떻게 되돌리나 |
|---|---|---|
| 결제 승인 | 예 | 취소 요청을 새로 보냅니다. 승인 기록이 사라지지는 않습니다 |
| 재고 차감 | 예 | 지금 값에 내 몫을 더합니다 |
| 쿠폰 사용 | 조건부 | 만료됐으면 못 돌려줍니다. 대체 쿠폰 발급이 실제 보상입니다 |
| 포인트 적립 | 예 | 회수합니다. 이미 썼으면 마이너스로 둡니다 |
| 매장 알림 | 아니오 | 이미 본 것은 안 본 것이 되지 않습니다. 취소 알림을 새로 보냅니다 |
| 라이더 배차 | 아니오 | 이미 출발했으면 못 돌립니다. 라이더에게 배달비를 치르는 것이 실제 보상입니다 |
가운데 칸이 '예/아니오/조건부' 셋으로 나뉜 것을 주목해 봅시다. 교과서나 논문에서는 보통 되돌릴 수 있는 것과 없는 것, 두 칸으로만 나눕니다. 하지만 실무에서 대형 사고가 터지는 곳은 대개 그 사이입니다.

0장에서 만료되어 버린 쿠폰이 바로 저 '조건부' 칸에 있었습니다. 개발자는 무조건 되돌릴 수 있다고 믿고 코드를 짰지만, 현실은 조건이 맞을 때만 가능했던 것이죠.
이 표가 순서를 정해줍니다
이 표가 강력한 이유는 단지 분류에서 끝나지 않고, 우리가 코드를 짜는 '순서'까지 자동으로 정해주기 때문입니다.
표를 보면 아예 되돌릴 수 없는 항목이 둘 있습니다. 매장 알림과 라이더 배차입니다. 만약 이 둘을 실행 순서의 맨 뒤로 미루면 어떻게 될까요? 앞의 네 단계(결제, 재고, 쿠폰, 포인트) 중 어디서 에러가 나든 아직 세상(매장과 라이더)에는 아무 일도 일어나지 않은 상태가 됩니다. 우리 시스템 안에서 조용히 되돌리고, 손님에게 다시 시도해 달라고 안내만 하면 깔끔하게 끝납니다.
반대로 순서를 잘못 짚어서 매장 알림을 먼저 보냈다고 상상해 보세요. 그 뒤에 결제가 실패해서 부랴부랴 취소 알림을 보낸다 해도, 매장 사장님은 이미 닭을 기름에 넣고 계실지도 모릅니다. 엎질러진 물이죠.
'절대 되돌릴 수 없는 일을 최대한 맨 뒤로 미루는 것.' 이것이 되돌리기 설계의 절반입니다.
여기까지 오면 머릿속이 꽤 맑아집니다. 무엇을 되돌릴 수 있고 없는지 명확해졌고, 그 기준에 따라 안전한 실행 순서까지 잡혔으니까요. "이 표 하나만 잘 그려두면 취소 기능 설계는 얼추 끝난 것 아닌가?" 하는 든든함마저 듭니다.
5장. 되돌리지 않는 선택
하지만 그 든든함은 '어디선가 실패하면 무조건 처음으로 되돌린다'는 전제가 맞을 때만 유효합니다. 이제 그 당연해 보이던 전제 자체를 의심해 볼 차례입니다.
앞으로 가는 편이 나을 때가 있습니다
원문이 항공권과 호텔로 예를 듭니다. 항공권 셋을 잡았는데 호텔 예약이 실패했습니다. 되돌린다면 항공권 셋을 전부 취소해야겠죠.
"Offering the customer a room at a different hotel is preferable to canceling the flights."
손님에게 다른 호텔의 방을 제안하는 편이 항공권을 취소하는 것보다 낫다.
- Microsoft Azure Architecture Center, "Compensating Transaction"
그리고 곧바로 단서를 답니다.
"However, the customer should make this decision, not the system."
다만 이 결정은 시스템이 아니라 손님이 내려야 한다.
- Microsoft Azure Architecture Center, "Compensating Transaction"
배달로 옮기면 이렇습니다. 손님이 시킨 메뉴 하나가 품절입니다. 자동으로 주문 전체를 취소하고 결제를 돌려주는 것이 맞을까요, 아니면 비슷한 메뉴를 제안하고 손님이 고르게 하는 것이 맞을까요?

되돌리기를 기본값으로 두면 시스템이 손님 대신 포기를 결정합니다. 손님은 그냥 다른 메뉴를 시켰을 수도 있는데 말이죠.
아예 되돌릴 일을 안 만드는 시각도 있습니다
여기서 한 발 더 나가 보겠습니다. 지금까지 인용한 문서들은 전부 일단 하고, 잘못되면 보상하라고 말합니다. 그런데 아마존에서 대규모 시스템을 설계한 팻 헬런드는 2007년 논문에서 다른 쪽을 봅니다. 분산 트랜잭션을 포기한 세계에서 무슨 일이 벌어지는지를 다룬 글입니다.
"In a system which cannot count on distributed transactions, the management of uncertainty must be implemented in the business logic."
분산 트랜잭션에 기댈 수 없는 시스템에서는, 불확실성의 관리가 비즈니스 로직 안에 구현되어야 한다.
Pat Helland, "Life beyond Distributed Transactions", 2007
불확실성의 관리라는 말이 낯설게 들리실 텐데, 바로 이어서 무엇을 뜻하는지 풀어놓습니다.
"The uncertainty of the outcome is held in the business semantics rather than in the record lock."
결과의 불확실성은 레코드 잠금이 아니라 비즈니스 의미 안에 담긴다.
- Pat Helland, "Life beyond Distributed Transactions", 2007
트랜잭션이 있을 때는 잠금이 불확실성을 붙들고 있었습니다. 커밋될지 롤백될지 모르는 동안 아무도 그 행을 못 봤죠. 잠금이 사라지면 그 역할을 업무 상태가 대신 맡아야 합니다.
그 방법이 확정을 미루는 것입니다.
"Essential to a tentative operation, is the right to cancel."
잠정 작업의 핵심은 취소할 권리다.
- Pat Helland, "Life beyond Distributed Transactions", 2007
"Every tentative operation eventually confirms or cancels."
모든 잠정 작업은 결국 확정되거나 취소된다.
- Pat Helland, "Life beyond Distributed Transactions", 2007
배달로 옮기면 이렇습니다. 쿠폰을 사용함으로 바로 확정하는 대신 이 주문에 잡아둠으로 두는 겁니다. 배달이 끝나면 확정하고, 취소되면 잡아둔 것을 놓아줍니다. 놓아주는 것은 재발급이 아니라서 만료 규칙에 걸리지 않습니다.
같은 논문이 왜 현실 세계가 원래 이렇게 굴러가는지도 짚습니다.
"Contracts between businesses include time commitments, cancellation clauses, reserved resources, and much more. The semantics of uncertainty is wrapped up in the behaviour of the business functionality."
기업 간의 계약에는 기한 약정, 취소 조항, 예약된 자원 같은 것들이 들어 있다. 불확실성의 의미는 그 업무 기능의 동작 안에 감싸여 있다.
Pat Helland, "Life beyond Distributed Transactions", 2007
호텔을 예약하면 언제까지 무료 취소인지가 계약에 적혀 있습니다. 그 시각이 지나면 취소는 다른 일이 됩니다. 되돌릴 수 있는 기간이 처음부터 문서에 쓰여 있는 겁니다.
물론 이 마법 같은 방법에도 시스템이 치러야 할 '값'은 있습니다.
가장 큰 비용은 복잡성입니다. 이전에는 사용함 상태 하나로 가볍게 끝났을 일이, 이제는 잡아두기(Hold), 확정(Confirm), 놓아주기(Cancel)라는 세 가지 상태로 늘어납니다. 게다가 중간에 시스템이 멈춰서 잡아둔 쿠폰이나 재고가 영영 묶여버리는 사고를 막으려면, '일정 시간이 지나면 자동으로 놓아주는' 타이머 안전장치까지 따로 만들어야 하죠.
설계가 훨씬 무거워지기 때문에 무조건 이 방식이 정답인 것은 아닙니다. 하지만 0장의 쿠폰 증발 사건처럼 '되돌리기'가 실패했을 때 그 피해를 고스란히 손님이 떠안아야 하는 치명적인 곳이라면, 이 정도의 수고로움은 기꺼이 감수할 만한 가치가 있습니다.
되돌리기도 실패합니다
그리고 이건 아마 이 글에서 가장 불편한 대목일 겁니다.
"Compensating transactions don't always work."
보상 트랜잭션이 항상 동작하는 것은 아니다.
- Microsoft Azure Architecture Center, "Compensating Transaction"
되돌리기가 실패하면 무엇을 할까요. 되돌리기를 되돌릴까요? 그건 답이 아닙니다. 그 되돌리기도 실패할 수 있으니 끝이 없죠.
원문은 사람을 부르기 전에 한 단계를 먼저 둡니다. 되돌리기 단계를 여러 번 실행해도 결과가 같게 짜서 재시도로 넘기는 것입니다. 3장에서 번호표를 붙였던 이유가 여기서 값을 합니다. 그러고도 안 되면 그때 답은 이렇습니다.
"Sometimes manual intervention is the only way to recover from a failed step."
실패한 단계를 복구하는 유일한 방법이 사람의 개입일 때가 있다.
- Microsoft Azure Architecture Center, "Compensating Transaction"
사람을 부르는 곳을 설계에 넣습니다
원전은 그 대목을 이렇게 마무리합니다.
"When decisions are high impact or hard to automate reliably, include a human in the decision-making process."
결정이 파급이 크거나 안정적으로 자동화하기 어려우면, 결정 과정에 사람을 넣어라.
- Microsoft Azure Architecture Center, "Compensating Transaction"
시스템에 '사람의 자리'를 마련한다는 것은 구체적으로 다음 세 가지를 설계도에 그려 넣는 일입니다.
- 기계가 몇 번까지 다시 시도할 것인가
- 끝내 기계가 포기했을 때, 그 건을 어느 바구니(Dead Letter Queue 등)에 담아둘 것인가
- 그 바구니에 쌓인 목록을 '누가' 확인할 것인가
다시 0장의 쿠폰 사건으로 돌아가 봅시다. 조건이 맞지 않아 끝내 돌려주지 못한 그 쿠폰은 대체 어디로 갔을까요? 허공으로 증발해 버렸습니다. 에러 로그 한 줄 남지 않았죠. 가장 큰 비극은 '되돌리기가 실패했다'는 사실 자체를 시스템조차 몰랐다는 점입니다.
기한이 끝난 쿠폰을 기계가 마음대로 되살려내게 짤 수는 없습니다. 그건 명백히 비즈니스 정책을 어기는 일이니까요. 하지만 되돌리지 못하고 실패한 건들이 모이는 곳이라는 바구니 하나만 시스템 구석에 있었어도 결말은 달랐을 겁니다. 다음 날 아침, 출근한 운영 담당자가 그 목록을 열어보고 손님에게 사과와 함께 대체 쿠폰을 수동으로 발급해 줄 수 있었을 테니까요.
기계의 실패를 사람의 대응으로 훌륭하게 덮는 것, 이것 역시 설계의 영역이었네요.
6장. 그래서 무엇을 포기하고 무엇을 얻나
지금까지 우리는 데이터베이스가 공짜로 해주던 '되돌리기'를 직접 설계할 때 마주하는 현실들을 확인했습니다. 가장 아픈 것부터 되돌리는 순서를 정하고, 남의 작업을 지우지 않기 위해 더하기 연산과 번호표를 도입했으며, 되돌릴 수 없는 작업은 최대한 뒤로 미뤘습니다. 심지어 되돌리기 자체가 실패할 경우를 대비해 사람이 개입할 경로까지 마련해야 했죠.
그렇다면 하나의 큰 트랜잭션을 쪼개어 이 복잡한 되돌리기를 직접 짜기로 하면서, 우리는 구체적으로 무엇을 내주고 무엇을 얻게 된 걸까요?
| 포기한 것 | 얻은 것 |
|---|---|
| 예외 하나로 전부 되돌아가던 편의 | 서비스마다 자기 속도로 움직일 자유 |
| 중간 상태가 아무에게도 안 보이던 성질 | 한쪽이 멈춰도 나머지가 굴러가는 구조 |
| 실패하면 없던 일이 된다는 단순함 | 실패해도 어디까지 갔는지 남는 기록 |
가운데 줄이 제일 비쌉니다. 여기서 잃은 것을 원전은 격리라고 부릅니다. 중간 단계가 밖에서 안 보이는 성질입니다.
"Lack of isolation (the "I" in ACID) - the lack of isolation means that there's risk that the concurrent execution of multiple sagas and transactions can use data anomalies."
격리가 없다. ACID의 I가 없다는 것은 여러 사가와 트랜잭션이 동시에 실행될 때 데이터 이상이 생길 위험이 있다는 뜻이다.
- microservices.io, "Saga"
인용문의 사가는 여러 서비스에 걸친 한 건의 흐름 전체를 부르는 이름입니다. 주문 하나가 결제와 재고와 포인트를 차례로 지나가는 그 흐름이 사가 하나이고, 이 글에서 다룬 되돌리기는 그 흐름의 뒤쪽 절반입니다.
트랜잭션 안에서는 중간 단계가 밖에서 안 보였습니다. 커밋 전에는 아무것도 안 보이고 커밋 뒤에는 한꺼번에 보이니, 절반만 반영된 상태를 볼 수 있는 사람이 없었죠.
되돌리기로 이어 붙인 흐름은 정반대입니다. 결제는 취소됐는데 재고는 아직 안 돌아온 상태를 누구나 볼 수 있습니다. 그 순간 재고 화면을 열면 틀린 숫자가 보입니다.
1987년 논문은 이 대목을 놀랄 만큼 건조하게 인정하고 넘어갑니다.
"When a compensating transaction Ci is run, no effort is made to notify or abort transactions that might have seen the results of Ti before they were compensated for by Ci"
보상 트랜잭션 Ci가 실행될 때, Ci가 상쇄하기 전의 Ti의 결과를 이미 보았을지 모르는 트랜잭션들에게 알리거나 그것들을 중단시키려는 노력은 이루어지지 않는다.
- Hector Garcia-Molina, Kenneth Salem, "Sagas", 1987
틀린 값을 이미 본 사람에게는 아무도 정정해 주지 않습니다. 3장에서 본 함정이 여기서 나온 것이었죠. 중간 상태가 밖에서 보인다는 것은, 그 중간에 다른 사람이 끼어들 수 있다는 뜻이니까요.
취소 버튼 하나의 값
이 글에서 다룬 것을 한 줄씩 세어 봅니다.
되돌리는 순서를 정해야 하고, 덮어쓰기 대신 더하기로 짜야 하고, 중복을 막을 번호표를 붙여야 하고, 되돌릴 수 없는 단계를 뒤로 미뤄야 하고, 실패했을 때 갈 곳을 만들어야 합니다.
다섯 가지 전부 앞으로 가는 흐름을 만들 때도 똑같이 했던 고민입니다. 순서, 동시성, 중복, 실패 처리, 그리고 사람. 되돌리는 흐름에서 처음부터 다시 합니다.

취소 버튼을 하나 만든다는 것은 그 흐름을 두 번 설계한다는 뜻이다.
기획서에서 취소는 한 줄입니다. 주문 취소 기능.
그런데 만드는 쪽에서 보면 앞으로 가는 시스템 하나와 뒤로 가는 시스템 하나입니다. 뒤쪽이 앞쪽의 거울이라고 가정하는 순간 0장이 벌어지죠. 거울이 아닙니다.
그런데 4장의 표에 문제가 하나 있습니다
4장에서는 주문의 여섯 단계를 세 칸으로 갈랐습니다. '되돌릴 수 있는 것', '조건부', 그리고 '되돌릴 수 없는 것'. 이렇게 나누고 순서를 미루면 모든 게 완벽히 통제되는 것 같았죠.
하지만 그 표에는 치명적인 착각이 숨어 있습니다. 조건부 칸에 '쿠폰' 하나만 덩그러니 놓여있었다는 점입니다. 그래서 쿠폰만 유별나게 까다로운 놈처럼 보였죠.
과연 그럴까요? 결제를 다시 봅시다. 카드사의 원거래 취소 가능 기간이 지나버리면, 그때부터 취소는 완전히 다른 복잡한 환불 절차가 됩니다. 재고는 어떤가요. 그 상품이 단종되거나 판매 중지 상태라면 되돌려 놓을 자리 자체가 사라집니다. 포인트는요. 손님이 취소하기 전에 이미 그 포인트를 다른 곳에 써버렸다면, 회수하는 순간 계정은 마이너스가 됩니다.
사실 결제, 재고, 포인트 모두 '조건부'였습니다. 0장에서 이 셋이 무사히 돌아간 것은 원래 성질이 착해서가 아니라, 그저 그날 그 시각에 아직 조건이 살아 있었기 때문입니다. 쿠폰만 우연히 그 시간이 지나 죽어 있었을 뿐이죠.
되돌릴 수 있는지 여부는 그 단계의 고정된 성질이 아니라,
'되돌리는 시점'에 따라 변하는 함수다.
안전지대를 나눴던 4장의 표는 그린 순간부터 낡기 시작합니다. 주문할 때는 '되돌릴 수 있는' 왼쪽 칸에 있던 항목이, 시간이 지나면 가운데(조건부)로 밀려나고, 더 지나면 오른쪽(되돌릴 수 없음)으로 가버립니다. 주문할 때 그린 표와 취소할 때의 표는 결코 같지 않습니다. 하지만 불행히도 우리의 코드는 대개 '주문할 때 그린 표'만 보고 짜여 있습니다.
여기서 5장의 팻 헬런드가 다시 등장합니다. 계약서에 취소 조항과 기한이 적혀 있다는 것은, 되돌릴 수 있는 유효 기간을 처음부터 명시해 두었다는 뜻입니다. 우리 코드에는 쏙 빠져있는 바로 그 '시간'의 개념을요.
기획서의 한 줄, 아키텍처의 절반
이 사실은 우리가 개발 일정을 잡을 때 아주 중요하게 쓰입니다.
기획서에 적힌 주문 취소 기능이라는 단 한 줄을 구현하려면, 단순히 뒤로 가는 코드 몇 줄을 덧붙이는 것으로 끝나지 않습니다. 앞으로 가는 흐름의 순서를 다시 배치해야 하고, 각 단계가 언제까지 되돌릴 수 있는 상태인지 수명을 알아내야 합니다.
그래서 취소 기능은 모든 기능을 다 만든 뒤에 마지막에 덧붙일 수가 없습니다. 덮어쓰기 대신 더하기로 짤지, 어떤 단계를 맨 뒤로 미룰지, 시간의 흐름에 따라 변하는 상태를 어떻게 감당할지는 '앞으로 가는 흐름'을 처음 설계할 때 이미 결정되어야 하기 때문입니다.
0장으로 돌아갑니다
그러면 되돌린다는 건 대체 무슨 뜻이었을까요?
우리는 종종 '되돌리기'를 시간을 거슬러 올라가는 '되감기'로 착각하곤 합니다. 주문할 때 했던 일을 거꾸로만 하면, 아무 일도 없었던 예전 상태로 똑같이 돌아갈 것이라고 믿는 것이죠.
하지만 되돌리기는 되감기가 아니라, 지금 시점에 반대 방향으로 실행하는 '새로운 일'이었습니다. 주문한 날과 취소한 날 사이에 쿠폰의 유효기간은 끝이 났습니다. 취소 역시 새로운 일이니 지금 시점의 규칙을 따라야 했고, 지금의 규칙은 만료된 쿠폰을 다시 발급해주지 않습니다.
시스템은 에러를 낸 게 아닙니다. 정확히 규칙대로 움직였습니다.
그리고 0장의 그날, 결제와 재고를 포함한 나머지 넷이 무사히 원래대로 돌아간 것 역시 우리의 설계가 훌륭해서가 아니었습니다. 그저 그 넷은 아직 '되돌릴 수 있는 시간' 안에 살아있었을 뿐입니다.
참고 자료
이 글이 인용한 원전
- Compensating Transaction pattern, Microsoft Azure Architecture Center
https://learn.microsoft.com/en-us/azure/architecture/patterns/compensating-transaction
이 글의 뼈대입니다. 되돌려도 원래대로 오지 않는다는 것, 순서가 역순이 아니라는 것, 덮어쓰면 남의 작업을 지운다는 것, 되돌릴 수 없는 단계를 뒤로 미루라는 것이 전부 여기 있습니다. - Life beyond Distributed Transactions: an Apostate's Opinion, Pat Helland, CIDR 2007
https://www.cidrdb.org/cidr2007/papers/cidr07p15.pdf
분산 트랜잭션을 포기한 세계를 정면으로 다룹니다. 5장의 잠정 작업과 취소권이 여기서 왔고, 3장에서 중복이 왜 오는지와 중복에 무엇으로 답해야 하는지의 근거도 이 논문입니다. 계약에 취소 기한이 적혀 있다는 대목이 6장 마지막의 재료입니다. - Sagas, Hector Garcia-Molina & Kenneth Salem, 1987
https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
사가라는 이름이 여기서 나왔습니다. 3장의 덮어쓰기 함정이 항공사 좌석 예시로 이미 적혀 있고, 6장의 격리 상실도 여기서 아주 건조하게 인정하고 넘어갑니다. 스캔본이라 아래첨자 표기가 원문과 다르게 보일 수 있습니다. - Saga, Chris Richardson
https://microservices.io/patterns/data/saga.html
자동 롤백이 사라진다는 1장의 출발점과 6장의 격리 상실을 인용했습니다. - Developing sagas, Chris Richardson
https://microservices.io/post/microservices/2019/07/09/developing-sagas-part-1.html
4장에서 어느 단계에 되돌리기를 붙여야 하는지의 규칙을 인용했습니다.
더 읽어 보면 좋은 글
- Saga pattern, Microsoft Azure Architecture Center
https://learn.microsoft.com/en-us/azure/architecture/patterns/saga
이 글은 사가의 뒤쪽 절반만 다뤘습니다. 흐름 전체를 어떻게 엮는지, 중앙에서 지휘할지 각자 이벤트를 듣게 할지는 이쪽입니다. - Microservices Patterns 4장, Chris Richardson
https://microservices.io/book
6장에서 격리를 잃었다고만 하고 끝냈는데, 그 손실을 설계로 메우는 기법들이 정리되어 있습니다. - Idempotent Receiver, Enterprise Integration Patterns
https://www.enterpriseintegrationpatterns.com/patterns/messaging/IdempotentReceiver.html
3장에서 번호표로 만든 것에 붙은 이름입니다.

A Note on Graphics
이 글에 사용된 모든 다이어그램과 일러스트 이미지는 개념의 시각화를 위해 생성형 AI 도구를 활용해 제작되었습니다.
'시스템 디자인' 카테고리의 다른 글
| [시스템 설계 - 경계] Ep.6 주문 상태 값을 하나 추가했더니 매장에 돈이 더 나갔습니다 (1) | 2026.08.05 |
|---|---|
| [시스템 설계 - 격리] Ep.5 하나가 느려지면 전부 멈춥니다 (0) | 2026.08.01 |
| [시스템 설계 - 고유 ID] Ep.4 시계가 거꾸로 갑니다: 고유 ID 만들기가 어려운 진짜 이유 (0) | 2026.07.31 |
| [시스템 설계 - DynamoDB] Ep.3 지운 물건이 되살아납니다: 다이나모 아키텍처 15년과 복잡성 보존의 법칙 (0) | 2026.07.29 |
| [시스템 설계 - 레이트 리미터] Ep.2 방패의 설계도: 트래픽 폭주 속에서 무엇을, 어디서 막을 것인가 (0) | 2026.07.28 |