LabHub
배우기 러닝패스 코스

은행 현장의 언어 · 동시 이체와 한도, 그리고 트랜잭션 · 이론

잔액은 충분했다 — 두 창구가 같은 잔액을 봤을 뿐이다

LabHub 에서 이어서 보기

한 줄 요약

잔액 확인과 잔액 차감이 두 문장으로 갈라져 있으면 그 사이에 다른 요청이 끼어들어 같은 잔액을 근거로 두 번 승인된다. 고치는 방법은 확인을 갱신 문장 안으로 밀어 넣고, 승인 여부를 "내가 읽은 값" 이 아니라 "실제로 바뀐 행 수" 로 판정하는 것이다.

왜 이게 필요했나

장애 보고서에 가장 자주 적히는 문장 중 하나가 "잔액이 마이너스가 됐습니다" 다. 코드를 열어 보면 대개 이렇게 생겼다. 먼저 SELECT balance 로 잔액을 읽고, 파이썬이나 자바 쪽에서 if balance >= amount 로 판단하고, 그 다음 UPDATE account SET balance = balance - amount 를 친다. 한 번에 한 요청만 들어오는 시험 환경에서는 영원히 정상이다.

문제는 같은 계좌에 요청이 겹치는 순간이다. 월급날 자동이체와 고객의 앱 출금이 같은 100밀리초 안에 도착하면 둘 다 SELECT 를 먼저 하고, 둘 다 잔액이 충분하다고 판단한다. 그 뒤에 두 UPDATE 가 차례로 나간다. 둘 다 승인됐고, 둘 다 원장에 적혔고, 잔액은 음수가 된다. 이때 흥미로운 점은 원장과 잔액이 서로 어긋나지 않는다는 것이다. 차감은 두 번 다 제대로 반영됐기 때문이다. 그래서 "원장 합계 == 잔액" 만 확인하는 마감 점검은 이 사고를 조용히 통과시킨다.

이 종류의 결함은 재현이 어렵다는 이유로 오래 남는다. 부하를 걸어 보라는 말은 자주 나오지만, 부하는 확률이다. 운이 나쁘면 한 시간을 돌려도 안 나고, 운이 좋아 한 번 났다고 해도 다음 실행에서 또 난다는 보장이 없다. 재현이 확률이면 고쳤는지 아닌지도 확률로만 말하게 된다.

어떻게 동작하나

먼저 재현을 결정적으로 만드는 법이다. 부하를 키우는 대신 겹치는 지점을 맞춘다. 요청 두 개가 "둘 다 읽은 뒤에야 둘 다 쓴다" 는 순서로 진행되도록 배리어를 하나 두면, 라운드마다 반드시 같은 잔액을 읽는다. 요청 마흔 건이면 1초도 안 걸리고, 돌릴 때마다 같은 결과가 나온다. 재현이 결정적이어야 고쳤다는 말에 근거가 생긴다.

고치는 쪽은 한 문장이다.

UPDATE account SET balance = balance - :amt WHERE acct_id = :acct AND balance >= :amt;

이 문장이 바꾼 행이 1이면 승인, 0이면 거절이다. 바꾼 행 수는 SQLite 에서 changes() 로 읽고, 파이썬 [sqlite3 모듈](https://docs.python.org/3/library/sqlite3.html)에서는 커서의 rowcount 로 읽는다. 여기서 중요한 것은 우리가 앞에서 읽어 둔 잔액을 판정에 쓰지 않는다는 점이다. 읽은 값은 기록과 설명에만 쓰고, 승인 여부는 데이터베이스가 정한다.

일일 누적 한도도 같은 모양으로 붙는다. 누적액을 따로 칸에 들고 있으면 그 칸과 원장이 어긋나는 순간부터 한도가 거짓말을 하므로, 원장에서 그날 출금을 세는 하위 질의를 조건에 넣는다.

UPDATE account SET balance = balance - :amt WHERE acct_id = :acct AND balance >= :amt   AND (SELECT COALESCE(-SUM(amount),0) FROM ledger         WHERE acct_id = :acct AND biz_date = :d AND amount < 0) + :amt <= daily_limit;

그 다음이 트랜잭션과 잠금이다. [SQLite 의 BEGIN 문서](https://www.sqlite.org/lang_transaction.html)는 읽기 트랜잭션은 여럿이 동시에 열 수 있지만 쓰기 트랜잭션은 한 번에 하나뿐이라고 못박는다. 그리고 한 가지 함정을 명시한다. 기본값인 BEGIN DEFERRED 로 시작해 SELECT 를 먼저 하면 읽기 트랜잭션이 열리는데, 그 뒤의 쓰기 문장은 읽기를 쓰기로 승격하려 시도하고, 다른 연결이 이미 데이터베이스를 고치고 있으면 승격이 불가능해 SQLITE_BUSY 로 실패한다. BEGIN IMMEDIATE 는 처음부터 쓰기 트랜잭션을 열어 이 승격 자체를 없앤다. 그래서 "읽고 나서 쓰는" 트랜잭션은 처음부터 IMMEDIATE 로 여는 것이 안전하다.

기다림은 [PRAGMA busy_timeout](https://www.sqlite.org/pragma.html) 이 맡는다. 잠금이 걸려 있을 때 곧바로 실패하는 대신 지정한 밀리초만큼 잠들었다 다시 시도한다. 기본값은 0이라 아무것도 기다리지 않는다. 잠금이 어떤 단계로 오르내리는지는 [파일 잠금과 동시성 문서](https://www.sqlite.org/lockingv3.html)에 단계별로 적혀 있고, 어떤 오류 코드가 어떤 상황에서 나오는지는 [결과 코드 문서](https://www.sqlite.org/rescode.html)가 정리한다.

마지막이 재시도다. 재시도는 다시 걸어도 되는 실패에만 건다. 잠금 경합은 시간이 지나면 풀리므로 다시 걸어도 되고, 잔액 부족이나 한도 초과는 몇 번을 다시 걸어도 같은 답이 나오므로 걸면 안 된다. 상한 없는 재시도는 장애를 키운다. 상한과 대기 간격, 그리고 어떤 실패에 거는지를 정책으로 적어 두고 시도 횟수를 기록에 남긴다.

현장에서 만나는 모습

첫째, "트랜잭션으로 감쌌으니 괜찮다" 는 오해다. 트랜잭션은 원자성을 주지 다른 요청이 그 사이에 같은 행을 바꾸지 못하게 막아 주지 않는다. 격리 수준과 잠금이 그 일을 하고, 조건을 갱신 문장 안에 넣는 것이 가장 싸게 같은 효과를 내는 방법이다.

둘째, 재시도가 사고를 키우는 경우다. 한 팀이 모든 데이터베이스 오류에 무한 재시도를 걸어 두었더니, 잠금 하나가 길어진 순간 대기 중인 요청이 쌓이면서 연결 수가 한도를 쳤다. 재시도는 실패를 늦출 뿐 줄이지 않는다. 상한과 간격이 정책에 없으면 그 재시도는 폭탄이다.

셋째, 점검 항목을 하나만 두는 경우다. 원장 합계와 잔액이 맞는지만 보는 마감 점검은 이번 사고를 통과시킨다. 음수 잔액과 한도 초과를 따로 세워 두어야 잡힌다. 점검은 여러 개를 나란히 두고, 무엇을 봤는지와 관측값을 함께 남긴다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

계좌 여섯 개짜리 스냅샷을 직접 만들고, 읽고-고치고-쓰기 경로로 마이너스 잔액을 재현 가능한 모양으로 만든다. 그 다음 조건부 갱신으로 같은 겹침을 막고, 쓰기 잠금과 busy_timeout 을 직접 겪어 본다. 일일 누적 한도를 같은 문장 안에 넣고, 상한 있는 재시도를 붙인 뒤, 불변식 세 가지로 계좌 여섯 개를 점검해 증거 보고서로 닫는다.