핵심 한 줄

지금까지 낙관·비관·원자적 락을 세 번이나 비교했지만, 정작 그것들이 서 있는 땅 — 격리수준 — 을 안 밟아봤다. MySQL 기본 REPEATABLE READ에 두 세션을 붙여 손으로 겪어보니, 이 다이얼은 읽기 환상은 야무지게 지키는데 쓰기 경합(lost update)은 손도 안 댄다. 두 번 출금했는데 잔액은 한 번만 줄었다. 내 락 3형제가 태어난 자리가 바로 그 빈칸이었다. 그리고 그 락을 쓰다 순서가 엇갈리면 — 데드락. 마지막으로 같은 실험을 PostgreSQL에 다시 걸어보니, 같은 “REPEATABLE READ”라는 이름도 DB마다 지키는 약속이 달랐다.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


1. 세 번 비교했는데, 땅을 안 밟았다

3편에서 초과판매를 막느라 낙관적 락·비관적 락·원자적 UPDATE를 비교했다. 멱등성에서도 락을 썼다. 락이라면 꽤 다뤘다고 생각했다.

그런데 정작 그 락들이 왜 필요한지의 근거 — 데이터베이스의 격리수준 — 를 한 번도 손으로 안 봤다. “MySQL 기본이 REPEATABLE READ”라는 문장은 외웠지만, 그게 뭘 지켜주고 뭘 안 지켜주는지는 몰랐다. 그래서 두 세션을 띄워 직접 밟기로 했다. 단정하지 말고 측정한다.

2. 격리수준 = “혼자인 척”의 가격표

트랜잭션은 자기 혼자 DB를 쓰는 척하고 싶어 한다. 실제론 옆에서 딴 트랜잭션이 동시에 읽고 쓴다. 격리수준은 그 ‘혼자인 척’ 환상을 유지하는 데 돈을 얼마나 쓸까의 다이얼이다. 높이면 정확하지만 느리고, 낮추면 빠르지만 이상현상이 샌다.

환상이 깨지는 방식이 세 가지다.

이상현상무슨 일
Dirty read남이 아직 커밋 안 한 값을 읽음 (걔가 롤백하면 유령값)
Non-repeatable read같은 을 두 번 읽었는데 값이 다름 (사이에 남이 커밋 UPDATE)
Phantom read같은 조건 조회인데 행 개수가 다름 (사이에 남이 커밋 INSERT)

레벨은 위로 갈수록 더 막는다 — READ UNCOMMITTED(아무것도) → READ COMMITTED(dirty만, PostgreSQL 기본) → REPEATABLE READ(non-repeat까지, MySQL 기본) → SERIALIZABLE(전부).

3. 읽기 환상은 야무지게 지킨다

먼저 non-repeatable read를 READ COMMITTED에서 재현했다. 탭 A가 잔액 1000을 읽고, 아직 같은 트랜잭션 안인데, 탭 B가 9999로 바꿔 커밋한다. A가 다시 읽으면 — 9999. 같은 트랜잭션인데 발밑에서 값이 바뀌었다. READ COMMITTED는 “커밋된 것만 읽는다”만 약속하지 “내 트랜잭션 동안 같은 값”은 약속하지 않는다. PostgreSQL·Oracle 기본이 이거라 거기선 실제로 난다.

이제 A만 한 칸 올려 REPEATABLE READ로 같은 짓을 했다. B가 9999로 커밋해도 — A의 2차 읽기는 여전히 1000. non-repeatable이 사라졌다. InnoDB의 MVCC가 A의 첫 SELECT 시점에 세상을 얼려(스냅샷), 남이 커밋해도 A는 옛 버전을 계속 본다. 읽기가 쓰기를 안 막고 쓰기가 읽기를 안 막는, 락 없는 일관 읽기. MySQL이 REPEATABLE READ면서도 빠른 비결이다.

여기까지만 보면 REPEATABLE READ는 완벽해 보인다. 그게 함정이었다.

4. 그런데 잔액이 사라졌다

두 탭 다 REPEATABLE READ인 채로, kim의 잔액 1000에서 둘이 동시에 100씩 출금했다. 정답은 800.

각자 1000을 읽는다. 각자 앱에서 계산한다 — “1000에서 100 빼면 900”. 각자 900을 쓴다. 커밋. 최종 잔액은?

900

800이 아니라 900. A가 쓴 900을 B가 통째로 덮었다. 두 번 출금했는데 한 번만 반영됐다 — lost update(갱신 손실). 은행이라면 100원이 공중분해된 것이다. 그리고 결정적으로, REPEATABLE READ가 멀쩡히 켜져 있었는데도 났다.

왜? 앞의 이상현상 3개 어디에도 lost update가 없다. 격리수준은 읽기 환상만 다룬다. 두 UPDATE는 각자 입장에선 그냥 정상적인 쓰기 두 개일 뿐, 서로의 존재를 모른다. 쓰기 경합은 애초에 격리수준의 관심사가 아니다. 이 빈칸을 메우는 건 애플리케이션의 몫이고, 그게 내가 세 번이나 비교했던 락들이었다.

5. 빈칸을 메우는 세 개의 손

  • 비관적 락 SELECT … FOR UPDATE — 읽을 때부터 잠가 뒤 트랜잭션을 세운다(직렬화). 그리고 스냅샷이 아니라 현재값(current read)을 준다. 실측하니 B가 A 커밋까지 대기했다가, 풀린 뒤 900을 읽고 800을 썼다. 단 주의 — 락은 올바른 값을 쥐여줄 뿐, 그 값을 실제로 써야 안전하다. 재읽은 900을 무시하고 1000으로 계산하면 lost update가 재발한다.
  • 원자적 UPDATE SET balance = balance - 100 — 읽기-계산-쓰기를 DB 한 문장으로. balance를 DB가 쓰기 시점의 현재 값으로 읽으니, 앱은 잔액을 한 번도 안 읽는다. 그래서 “재계산 잊는” 실수가 구조적으로 불가능하다. 최종 800, 깔끔.
  • 낙관적 락 version 컬럼 — 안 잠근다. 쓸 때 “내가 읽은 version 그대로냐” 확인하고, 틀리면 실패시켜 재시도한다.

큰 그림 한 줄: 비관·원자는 “못 끼게 막고(직렬화)”, 낙관은 “끼면 감지해서 다시 한다(재시도)“. 앞은 대기 비용, 뒤는 재시도 비용. 충돌이 잦으면 앞, 드물면 뒤. 3편에서 재던 그 트레이드오프가 사실 이 빈칸을 채우는 세 가지 방법이었다.

6. 유령은 두 번 막히고, 한 번 샌다

phantom도 봤다. MySQL REPEATABLE READ는 이걸 두 무기로 막는다. ① 일반 SELECT는 스냅샷이라 새 행이 커밋돼도 내 눈엔 안 보인다(PostgreSQL과 같은 방식). ② SELECT … FOR UPDATE갭 락으로, 조회 범위의 빈틈까지 잠가 남이 INSERT하는 것 자체를 대기시킨다. 없는 행을 미리 잠가 “앞으로도 여기 아무도 못 끼운다”를 보장하는 것이다. 교과서가 “REPEATABLE READ는 phantom 허용”이라 적지만, 실제 MySQL·PostgreSQL은 각자 방식으로 거의 다 막는다.

그런데 MySQL엔 이 있다. 스냅샷 읽기와 현재 읽기를 같은 범위에 섞으면 유령이 샌다. A가 id >= 3을 두 번 조회해 “없다”를 확인한 뒤(스냅샷), 그 “없는 것”을 UPDATE하면? UPDATE는 항상 현재 읽기라 1행이 매치된다. 그리고 그 UPDATE가 만든 새 버전은 A 자신의 것이라, 이후 A의 조회에 나타난다. 한 트랜잭션 안에서 같은 조건 결과가 {}{}{유령}으로 변했다.

교훈은 4번의 그것과 한 몸이다 — 쓰기가 끼는 순간 읽기 보장에 금이 간다. 그래서 읽고-쓸 거면 처음부터 FOR UPDATE로 잠금 읽기를 하거나 원자적 UPDATE로 가야 한다. 스냅샷으로 조회해놓고 그 값을 믿고 쓰면 이 틈에 빠진다.

7. 서로를 기다리다 — 데드락

락을 쓰면 새 위험이 딸려 온다. 락 순서가 엇갈리면 데드락이다. A가 1번을 잠그고 2번을 원하고, B가 2번을 잠그고 1번을 원하면 — 서로 상대가 놓기만 기다리는 순환. 둘 다 재현하니 InnoDB가 wait-for 그래프로 감지해 한쪽을 즉시 죽였다(victim, ERROR 1213). 죽은 트랜잭션은 통째로 롤백되고, 승자만 반영된다. 무한 대기를 안 만드는 것이다.

부검 도구도 봤다. SHOW ENGINE INNODB STATUSLATEST DETECTED DEADLOCK에 누가 무슨 락을 쥐고(HOLDS) 무엇을 기다렸는지(WAITING FOR), 그리고 WE ROLL BACK TRANSACTION (2)로 victim이 그대로 찍힌다. 실무에선 이 블록으로 “어느 두 쿼리가 어떤 순서로 무슨 행을 잠갔나”를 역추적한다.

예방은 우선순위가 있다. ① 락 순서 통일(모두 항상 작은 id부터 잠그면 사이클 자체가 안 생긴다 — 근본 해결). ② 재시도(1213을 잡아 트랜잭션을 통째로 다시 — 데드락은 일시적이다. 멱등성에서 CannotAcquireLockException을 지수 백오프로 재시도한 그 패턴 그대로다). ③ 안 기다리기. ④ 짧은 트랜잭션·인덱스 정비.

8. 안 기다리는 두 가지 — 그리고 DB가 큐가 된다

“기다리다 물리느니 안 기다린다”도 문법으로 있다.

  • FOR UPDATE NOWAIT — 잠긴 행을 만나면 즉시 에러. “지금 처리 중”을 빠르게 응답할 때.
  • FOR UPDATE SKIP LOCKED — 잠긴 건 건너뛰고 안 잠긴 나머지를 집는다. 워커 두 대가 같은 쿼리(LIMIT 2 FOR UPDATE SKIP LOCKED)를 쳐도, A가 1·2를 잠그면 B는 막히지 않고 3·4를 집는다. 서로 안 겹치게 자동 분배.

SKIP LOCKED가 실무의 꽃이다. status 컬럼 하나 둔 평범한 테이블을 작업 큐로 쓰면서, 워커를 N대로 늘려도 경합 없이 나눠 소비한다. 별도 큐 제품(Kafka·RabbitMQ) 없이 DB만으로 분산 작업 처리가 되는 것이다. (오해 하나 정정하자면 — “job queue”는 InnoDB 내장 기능이 아니다. 그냥 테이블을 큐처럼 쓰는 관례이고, SKIP LOCKED는 큐 기능이 아니라 아무 테이블에나 붙는 락 수식어일 뿐이다.)

9. 같은 이름, 다른 약속 — PostgreSQL은 다르게 센다

여기까지는 전부 MySQL 이야기다. 그런데 “REPEATABLE READ”라는 같은 이름을 PostgreSQL도 쓴다. 같은 약속일까? 궁금해서 postgres를 띄워 같은 실험을 다시 밟았다.

첫 줄부터 달랐다. PostgreSQL 기본 격리수준은 REPEATABLE READ가 아니라 READ COMMITTED다. MySQL이 공짜로 주던 “트랜잭션당 스냅샷”을 PG 기본은 안 준다 — 같은 앱 코드가 PG에선 non-repeatable read가 실제로 난다.

진짜 차이는 lost update에서 드러났다. 4번의 그 시나리오(둘이 1000을 읽고 각자 900을 쓴다)를 양쪽 다 REPEATABLE READ로 맞춰 재현했다.

  • MySQL: 조용히 900. 아무 불평 없이 커밋되고, 100원이 샌 걸 아무도 모른다.
  • PostgreSQL: 두 번째 UPDATE가 **ERROR: could not serialize access due to concurrent update**로 거부당한다.

같은 격리수준, 정반대 철학이다. MySQL은 “락으로 막고, 안 막았으면 마지막 쓰기가 이긴다”. PostgreSQL은 “일단 진행시키다 충돌나면 에러를 던져 재시도를 강제한다”(낙관적). 그래서 PG 앱엔 serialization failure를 잡아 재시도하는 루프가 사실상 필수다 — 재시도하면 최신 900을 읽어 800을 만든다. PG는 조용한 손상을 시끄러운 에러로 바꾼다.

그리고 REPEATABLE READ가 양쪽 다 못 막는 마지막 이상현상, write skew를 봤다. “당직 의사 최소 1명” 규칙 아래, 두 의사가 각자 “지금 2명이니 나 빠져도 되네” 읽고 각자 빠진다. 서로 다른 행을 고쳤으니 lost update도 아니고 각자는 완벽히 합법인데, 합쳐보니 당직 0명 — 개별론 옳았는데 함께 틀린 것이다. RR(스냅샷)은 행 충돌이 없어 이걸 감지 못 한다.

PostgreSQL의 SERIALIZABLE로 올리자 잡혔다. 한쪽이 **could not serialize access due to read/write dependencies**로 중단됐다 — lost update 때(concurrent update)와 메시지가 다르다. 이 문구 차이가 곧 탐지 원리의 차이다. write skew는 행 충돌이 아니라 “A가 읽은 걸 B가 바꿨다”는 읽기-쓰기 의존성 사이클이고, PG의 **SSI(Serializable Snapshot Isolation)**가 바로 그 의존성을 추적해 잡는다.

덧붙이면 — “SERIALIZABLE은 느려서 실무에서 안 쓴다”는 통설은 대개 MySQL식(모든 SELECT를 잠금 읽기로 격상하는) 구현 이야기다. PG의 SSI는 읽기에 락을 안 걸어 훨씬 가볍고, 그래서 PG에선 복잡한 불변식을 지키는 국소적 안전망으로 실제로 쓸 만하다 — 재시도 로직을 갖춘다는 조건 아래.

교훈은 분명하다. 같은 “REPEATABLE READ”라는 이름도 DB마다 지키는 약속이 다르다. 한 DB에서 검증한 동시성 코드를 다른 DB로 옮길 땐, 이름을 믿지 말고 다시 측정해야 한다.

10. 격리수준은 읽기, 쓰기 경합은 앱이 락으로

두 세션으로 열한 개를 손으로 밟고 나니, 흩어져 있던 것들이 한 줄로 꿰였다.

격리수준은 읽기 환상만 지킨다. MVCC로 non-repeatable도 phantom도 야무지게 막는다 — 단, 순수한 읽기에 한해서. 쓰기 경합(lost update)은 격리수준 밖이라, 낙관·비관·원자적 락이 그 빈칸을 메운다. 그 락을 쓰다 순서가 엇갈리면 데드락이 나고, InnoDB가 victim을 죽이면 앱이 재시도한다. 안 기다리려면 NOWAIT·SKIP LOCKED가 있다.

내가 지금까지 한 모든 동시성 실습 — 초과판매, 멱등성, 재시도 — 이 사실 이 한 문장 위에 서 있었다. 도구를 먼저 배우고 땅을 나중에 밟았지만, 땅을 밟고 나서야 그 도구들이 왜 거기 있었는지가 보였다.

카타는 계속된다. 이제 데이터베이스 아래로 한 층 더 — JVM이 힙과 GC로 무슨 일을 벌이는지, 그 다이얼을 돌려볼 차례다.

이 시리즈는 계속 이어진다.

00.카타 서문