LabHub
배우기 러닝패스 코스

データベースの概念

トランザクション — 表にない列が実務の大半だ

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

교과서의 격리 수준 표는 세 가지 이상 현상만 다루지만, 실무 동시성 버그의 대부분은 그 표에 없는 네 번째 현상인 쓰기 편향이다.

概念マップ: 원자성・일관성・격리성・지속성

왜 이게 필요했나

동시성 버그를 만나면 흔히 나오는 처방이 있다. "격리 수준을 반복 읽기로 올려라." 그리고 상당수의 경우 그 처방은 문제를 고치지 못한다. 잔고가 음수가 되고, 재고가 초과 판매되고, 중복 예약이 계속 생긴다.

이유는 두 가지다. 첫째, 표로 배운 정의와 실제 구현이 다르다. 둘째, 네 가지 수준 중 어느 것도 잡지 못하는 이상 현상이 하나 있다.

어떻게 동작하나

ACID 부터 정리하면 이렇다. 원자성은 전부 반영되거나 전부 취소되는 성질, 일관성은 제약을 지키는 성질, 격리성은 동시 실행이 순차 실행처럼 보이는 성질, 지속성은 커밋된 결과가 사라지지 않는 성질이다. 지속성은 대개 선행 기록 로그(WAL) 로 보장한다. 데이터 페이지를 고치기 전에 변경 내용을 로그에 먼저 기록하고 그 로그를 디스크에 내린다. 크래시 후에는 로그를 재생해 복구한다.

표준이 정의한 격리 수준과 이상 현상은 이렇다.

격리 수준 dirty read non-repeatable read phantom read write skew
Read Uncommitted 표준상 허용 허용 허용 허용
Read Committed 불가 허용 허용 허용
Repeatable Read 불가 불가 표준상 허용 허용
Serializable 불가 불가 불가 불가

오른쪽 끝 열이 핵심이다. Serializable 을 제외한 모든 수준이 쓰기 편향을 허용하고, 대부분의 서비스는 Read Committed 에서 돌아간다.

구현의 차이도 알아 둘 만하다. PostgreSQL 은 MVCC 로 격리를 구현하므로 Read Uncommitted 를 요청해도 Read Committed 로 동작한다. 문법은 받아 주지만 dirty read 는 구조적으로 불가능하다. "격리 수준을 낮춰 성능을 올리자"는 제안이 PostgreSQL 에서는 아무 효과가 없는 이유다. 반대로 PostgreSQL 의 Repeatable Read 는 스냅샷 격리라서 표준보다 강하고 팬텀도 보이지 않는다. 대가는 쓰기 충돌 시 트랜잭션이 오류로 죽는다는 것이다. 격리 수준을 올리면 애플리케이션이 재시도할 책임을 지게 된다. 재시도 로직 없이 수준만 올리는 것은 사용자에게 오류를 더 자주 보여 주는 변경일 뿐이다.

현장에서 만나는 모습

쓰기 편향의 고전적 예가 당직 의사 문제다. 규칙은 "당직자가 최소 한 명은 남아야 한다"이다. 두 의사가 동시에 트랜잭션을 열어 각자 count(*) = 2 를 읽고 조건을 통과한 뒤, 각각 서로 다른 행을 갱신한다. 쓰기가 겹치지 않으므로 스냅샷 격리는 아무 충돌도 감지하지 못한다. 둘 다 성공하고 당직자는 0명이 된다.

같은 구조의 버그가 반복된다. 재고 확인 후 차감, 좌석 중복 예약 검사, 유니크 제약 없는 중복 가입 검사가 모두 쓰기 편향이다. 격리 수준을 한 단계 올려도 하나도 고쳐지지 않는다.

선택지는 세 가지다. 잠글 대상 행이 명확하면 SELECT ... FOR UPDATE 같은 비관적 잠금이 가장 단순하고 예측 가능하다. 충돌이 드물고 사용자 상호작용이 중간에 끼면 버전 컬럼을 쓰는 낙관적 잠금이 낫다. 불변식이 여러 행이나 여러 표에 걸쳐 잠글 대상을 특정할 수 없으면 Serializable 이 답이다.

그리고 가능하다면 격리 수준으로 지키던 불변식을 제약으로 옮기는 것이 가장 견고하다. 유니크 인덱스나 배제 제약은 격리 수준과 무관하게 항상 성립하고, 재시도도 필요 없으며, 나중에 합류한 팀원이 실수로 우회할 수도 없다.

재시도를 어디에 둘 것인가

격리 수준을 올리기로 했다면 재시도는 선택이 아니라 짝이다. 그런데 재시도를 아무 데나 두면 오류보다 더 나쁜 결과가 나온다. 세 가지만 지키면 된다.

재시도 블록 안에는 데이터베이스 작업만 넣는다. 트랜잭션 안에서 결제 요청을 보내거나 메일을 발송했다면, 트랜잭션이 되돌려져도 그 요청은 되돌아오지 않는다. 두 번째 시도에서 같은 일이 또 일어나 고객은 두 번 결제된다. 외부 호출은 트랜잭션 밖으로 빼거나, 최소한 커밋이 끝난 뒤로 미룬다.

횟수와 간격에 상한을 둔다. 직렬화 실패는 경합이 심할수록 자주 나므로, 실패한 트랜잭션을 곧바로 다시 던지면 경합이 더 심해진다. 짧게 쉬었다가 다시 시도하고, 몇 번 실패하면 포기하고 사용자에게 알린다.

무엇 때문에 실패했는지는 SQLSTATE 로 구분한다. 직렬화 실패는 40001, 교착은 40P01 이다. 오류 메시지 문구로 분기하면 판올림 때 조용히 깨진다. 이 두 코드는 다시 시도할 값어치가 있지만, 제약 위반처럼 다시 해도 같은 결과가 나오는 오류는 재시도 대상이 아니다.

다음 실습에서 할 것

진짜 PostgreSQL 에서 두 세션을 겹쳐 돌린다. 갱신 손실을 먼저 만들어 보고 행 잠금과 반복 읽기로 각각 막은 뒤, 반복 읽기로도 막히지 않는 쓰기 편향을 당직표로 재현한다. 마지막에는 직렬화 가능 수준으로 그것을 잡고, 건너뛰기 잠금으로 작업 대기열을 나눠 처리한다.