동시에 실행된 두 트랜잭션이 서로 다른 결과를 내는 이유
계좌 이체 코드는 왜 가끔 돈을 잃어버리는가
다음 코드를 처음 봤을 때 대부분의 개발자는 문제가 없다고 생각합니다.
-- 세션 A: 잔고를 100만원 늘린다
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 500000
UPDATE accounts SET balance = 500000 + 1000000 WHERE id = 1;
COMMIT;단독으로 실행하면 완벽하게 동작합니다. 문제는 이 코드가 절대 단독으로 실행되지 않는다는
점입니다. 같은 계좌에 동시에 이체가 들어오면, 두 트랜잭션이 똑같이 SELECT로 500000을
읽고, 각자 자기 계산 결과로 덮어씁니다. 나중에 커밋한 쪽이 이기고, 먼저 커밋한 쪽의 증가분은
그냥 사라집니다. 로그에는 두 트랜잭션 모두 "성공"으로 남습니다. 장애 티켓이 열리는 건
보통 몇 주 뒤, 월말 정산에서 숫자가 안 맞을 때입니다.
이게 이 책 전체를 관통하는 문제입니다. 트랜잭션은 개별적으로는 옳지만, 동시에 실행되면 서로의 존재를 모른 채 서로를 덮어씁니다. 그리고 이 문제를 막는 방법이 바로 "격리수준"이라 불리는 것인데, 실무에서는 이 이름 때문에 오해가 생깁니다 — 격리수준을 올리면 이런 문제가 전부 사라진다고 생각하지만, 사실은 어떤 문제를 얼마의 비용으로 막을지 고르는 다이얼에 가깝습니다.
재현해 보기
실제로 겪어보지 않은 문제는 기억에 남지 않습니다. psql 두 개를 열어서 아래를 순서대로
실행해 보세요.
-- 세션 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 500000 확인
-- 세션 B (A가 아직 커밋 안 한 상태에서)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 역시 500000
UPDATE accounts SET balance = balance + 1000000 WHERE id = 1;
COMMIT;
-- 세션 A로 돌아와서
UPDATE accounts SET balance = 500000 + 1000000 WHERE id = 1;
COMMIT;
SELECT balance FROM accounts WHERE id = 1; -- 1500000. B가 넣은 100만원은 사라졌다기대했던 최종 잔고는 250만원(원래 50만 + A의 100만 + B의 100만)이지만 실제로는 150만원입니다. 이걸 Lost Update(갱신 손실)라고 부릅니다. 이 책에서 다룰 네 가지 주요 이상현상 중 하나이고, 나머지는 3장에서 다룹니다.
왜 이 문제가 "가끔" 일어나는가
로컬에서 혼자 개발할 때는 이 버그를 절대 볼 수 없습니다. 요청이 순차적으로 들어오기 때문입니다. 이 버그는 트래픽이 늘어 두 요청이 수 밀리초 안에 겹칠 때만 나타납니다. 그래서 QA 단계를 통과하고, 초기 운영도 무사히 넘긴 뒤, 사용자가 몇 배로 늘어난 시점에 갑자기 터집니다. 저는 이걸 "성공이 만든 버그"라고 부릅니다 — 서비스가 잘 안 됐으면 애초에 드러나지 않았을 문제니까요.
이 책은 이런 문제를 세 개의 각도에서 다룹니다. 먼저 어떤 이상현상들이 존재하는지 (3장), SQL 표준과 각 DB가 그것을 어떤 격리수준으로 막는지(4장), 그리고 그 격리수준이 내부적으로 MVCC와 락으로 어떻게 구현되는지(5~6장)입니다. 마지막에는 데드락(7장)과 실무에서의 선택 기준(8장)으로 마무리합니다.
한 가지 미리 말씀드리자면, 이 책에서 "격리수준을 SERIALIZABLE로 올리면 다 해결된다"는 식의 결론은 나오지 않습니다. 그렇게 간단했다면 대부분의 프로덕션 DB가 기본값으로 그렇게 설정되어 있었을 겁니다. 실제로는 정반대입니다 — 대부분의 RDBMS는 기본 격리수준으로 가장 느슨한 축에 속하는 Read Committed를 씁니다. 그 이유를 이해하는 것이 이 책의 핵심입니다.