LabHub
学习 学习路径 课程

멱등성 — 두 번 눌러도 한 번만 결제되게 · 이체 중간에 죽어도 잔액은 보존된다 · 讲解

이체 중간에 죽어도 잔액은 보존된다의 설계 원리

在 LabHub 中继续学习

한 줄 요약

차감·증가·이체 기록을 원자적으로 묶고 동시 잔액 경쟁을 재현합니다.

概念图: 한 줄 요약 · 왜 이게 필요했나 · 어떻게 동작하나 · 계약을 읽고 실패를 예측하는 워크시트

왜 이게 필요했나

송신 계좌에서는 돈을 빼고 수신 계좌에 더하기 전에 프로세스가 죽었다. 재시도는 이체 id가 없어서 다시 돈을 뺐다. 반대로 이체 기록만 먼저 커밋하면 재시도는 완료라고 판단해 입금이 영원히 누락된다. 금액 계산을 정수로 정확히 해도 트랜잭션 경계가 틀리면 결과가 잘못된다.

어떻게 동작하나

accounts와 transfers를 같은 SQLite 파일에 둔다. 유효 금액과 서로 다른 계좌를 검증하고 BEGIN IMMEDIATE 안에서 재전송 여부와 잔액을 확인한다. 차감 후 장애 주입 지점을 두고 입금과 이체 기록까지 한 번에 커밋한다. 같은 id와 같은 내용은 중복 무동작이고 다른 내용은 충돌이다. 마지막에는 잔액이 모자라는 두 이체를 동시에 실행해 하나만 성공하는지 확인한다.

BEGIN IMMEDIATE → 중복/잔액 검사 → 차감 → 장애 지점 → 입금+기록 → COMMIT

계약을 읽고 실패를 예측하는 워크시트

다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.

1. 원장 테이블을 만든다

init_db(path)는 accounts(id TEXT PRIMARY KEY,balance INTEGER NOT NULL)와 transfers(id TEXT PRIMARY KEY,source TEXT NOT NULL,target TEXT NOT NULL,amount INTEGER NOT NULL)를 멱등 생성합니다.

판단의 근거: 같은 이체 id가 두 번 커밋되지 않게 유일성을 DB에 둡니다.

리뷰할 잘못된 변경 조각:

CREATE TABLE transfers

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

2. 계좌를 한 번만 만든다

add_account(path, account_id, amount)는 bool 제외 0 이상 int만 허용하고 INSERT로 계좌를 만듭니다. 이미 있는 계좌는 sqlite3.IntegrityError로 거절하며 잔액은 보존합니다.

판단의 근거: 초기화의 UPSERT가 실제 잔액을 초기값으로 되돌리지 않게 합니다.

리뷰할 잘못된 변경 조각:

INSERT OR REPLACE INTO accounts VALUES

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

3. 없는 계좌와 0원을 구분한다

balance(path, account_id)는 계좌 잔액을 반환하고 없으면 KeyError입니다.

판단의 근거: 없음을 0으로 바꾸면 잘못된 계좌로 이체를 진행할 수 있습니다.

리뷰할 잘못된 변경 조각:

return 0

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

4. 이체 입력을 검증한다

validate_transfer(source,target,amount)는 source!=target이고 amount가 bool 제외 양의 int이면 None, 아니면 ValueError입니다.

판단의 근거: 동일 계좌의 이체와 음수 이체를 사전에 거절합니다.

리뷰할 잘못된 변경 조각:

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

5. 세 쓰기를 한 트랜잭션으로 묶는다

transfer(path,tx_id,source,target,amount,fault=lambda:None)는 같은 id/내용이면 False, id 충돌·없는 계좌·부족한 잔액은 ValueError입니다. 새 이체는 차감→fault()→입금→transfers 삽입을 원자적으로 실행하고 True입니다.

판단의 근거: 차감 뒤 강제로 예외를 내어 잔액과 이체 기록이 모두 원래대로인지 검사합니다.

리뷰할 잘못된 변경 조각:

db.commit()        fault()

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

6. 이체 이력을 결정적으로 읽는다

history(path)는 transfers를 id 오름차순의 (id,source,target,amount) 튜플 리스트로 반환합니다.

판단의 근거: 입력 순서와 조회 순서를 혼동하지 않도록 ORDER BY를 명시합니다.

리뷰할 잘못된 변경 조각:

ORDER BY id DESC"

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

7. 보존량을 확인한다

total_balance(path)는 accounts.balance의 합계입니다. 계좌가 없으면 0입니다.

판단의 근거: 개별 요청의 반환값과 전체 금액 보존을 별도로 검증합니다.

리뷰할 잘못된 변경 조각:

MAX(balance)

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

8. 동시 이체가 잔액을 넘지 않는다

compete(path,source,target,amount)는 서로 다른 id race-a/race-b로 같은 금액의 transfer를 두 스레드에서 실행합니다. ValueError만 False로 변환한 결과 리스트를 반환합니다. 잔액이 한 번분만 있으면 성공은 하나입니다.

판단의 근거: 트랜잭션 밖에서 잔액을 미리 검사하면 두 요청이 같은 잔액을 보고 모두 성공할 수 있습니다.

리뷰할 잘못된 변경 조각:

["race-a","race-a"]

이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.

현장에서 만나는 모습

실제 금융 원장의 회계·감사·법적 요구 사항을 대체하는 시스템이 아니다. 통화·소수 단위·수수료·다중 통화는 다루지 않고 금액은 최소 단위의 양의 정수다. 외부 결제 시스템 호출은 이 DB 트랜잭션에 포함되지 않으므로 별도 설계가 필요하다.

다음 실습에서 할 것

여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 원장 테이블을 만든다 → 계좌를 한 번만 만든다 → 없는 계좌와 0원을 구분한다 → 이체 입력을 검증한다 → 세 쓰기를 한 트랜잭션으로 묶는다 → 이체 이력을 결정적으로 읽는다 → 보존량을 확인한다 → 동시 이체가 잔액을 넘지 않는다.

각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.