남기다
홈 › 트랜잭션과 잠금 › PostgreSQL과 MySQL의 REPEATABLE READ가 다른 이유

PostgreSQL과 MySQL의 REPEATABLE READ가 다른 이유

한서윤 · · 트랜잭션과 잠금

선착순 쿠폰 발급 기능을 만들던 때였다. 쿠폰마다 남은 수량을 한 행에 들고 있다가 발급 요청이 오면 그 숫자를 하나씩 줄이는 구조였다. DB는 MySQL이었고 격리 수준은 손대지 않은 기본값 REPEATABLE READ였다. 이름부터 ‘여러 번 읽어도 같은 값’이니 요청이 몰려도 숫자가 꼬일 일은 없다고 믿었다. 이벤트가 끝나고 발급 내역을 세어 보니 수량 컬럼과 맞지 않았다. 쿠폰은 103장이 나갔는데 남은 수량은 100장만 줄어 있었다.

얼마 뒤 비슷한 기능을 PostgreSQL에 새로 만들었을 때는 반대 방향으로 놀랐다. 숫자는 정확히 맞았지만 부하 테스트를 돌리자 에러 로그가 could not serialize access due to concurrent update로 가득 찼다. 두 번 다 격리 수준 이름은 똑같이 REPEATABLE READ였다.

REPEATABLE READ라는 이름은 SQL 표준에서 왔지만 실제 동작은 DB마다 구현을 따른다. 같은 시나리오를 두 DB에서 나란히 돌려 보면 차이가 또렷해진다.

실험 준비: 쿠폰 한 행, 세션 두 개

테이블은 두 DB에 똑같이 만든다. 남은 수량이 10장인 쿠폰 한 행이면 충분하다.

CREATE TABLE coupons (
  id     INT PRIMARY KEY,
  remain INT NOT NULL
);
INSERT INTO coupons VALUES (1, 10);

터미널 두 개를 열어 세션 A와 B로 쓴다. A가 트랜잭션을 열고 수량을 읽는다. 그사이 B가 3장을 발급하고 커밋한다. 마지막으로 A가 2장을 발급한다. 두 발급이 모두 반영되면 남은 수량은 5여야 한다.

A의 UPDATE는 두 가지로 나눠 본다. 하나는 remain = remain - 2처럼 DB 안에서 빼는 상대식이다. 다른 하나는 애플리케이션이 앞서 읽은 10에서 2를 빼고 remain = 8을 그대로 넣는 방식이다. 내 쿠폰 코드는 뒤쪽이었다. ORM으로 엔티티를 불러와 필드를 고치고 저장하면 SQL은 자연스럽게 이 모양이 된다.

PostgreSQL: 늦게 온 쓰기는 거절된다

-- 세션 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT remain FROM coupons WHERE id = 1;  -- 10

-- 세션 B
BEGIN;
UPDATE coupons SET remain = remain - 3 WHERE id = 1;
COMMIT;

-- 세션 A
SELECT remain FROM coupons WHERE id = 1;  -- 여전히 10
UPDATE coupons SET remain = remain - 2 WHERE id = 1;
-- ERROR:  could not serialize access due to concurrent update
ROLLBACK;

A의 두 번째 SELECT는 B가 커밋한 뒤인데도 10을 돌려준다. 여기까지는 이름 그대로다. 문제는 UPDATE다. PostgreSQL 문서에 따르면 REPEATABLE READ 트랜잭션은 자기가 시작된 뒤 다른 트랜잭션이 바꾼 행을 수정하거나 잠글 수 없다. 그래서 A는 바로 오류를 받고 롤백할 수밖에 없다. 상대식이든 remain = 8이든 결과는 같다. B가 아직 커밋하지 않았다면 A는 기다리고, B가 롤백하면 A의 UPDATE는 그대로 진행된다.

이 오류의 SQLSTATE는 40001이고, 이 수준을 쓰는 애플리케이션은 트랜잭션을 다시 시도할 준비를 해 둬야 한다고 문서에 적혀 있다. 실패한 UPDATE 한 줄만 다시 보내면 안 된다. 오류가 난 트랜잭션은 이미 중단돼서 어떤 문장도 받아 주지 않는다. 처음엔 드라이버 버그부터 의심했는데, 코드 리뷰에서 동료가 트랜잭션 함수를 통째로 감싸 40001이면 BEGIN부터 새로 실행하는 헬퍼를 제안했다. 그 헬퍼를 넣은 다음 배포부터 에러 로그 대신 재시도 횟수 지표가 쌓였다.

MySQL: 최신 값을 읽고 그 위에 쓴다

-- 세션 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT remain FROM coupons WHERE id = 1;  -- 10

-- 세션 B
START TRANSACTION;
UPDATE coupons SET remain = remain - 3 WHERE id = 1;
COMMIT;

-- 세션 A
SELECT remain FROM coupons WHERE id = 1;  -- 여전히 10
UPDATE coupons SET remain = remain - 2 WHERE id = 1;
-- Query OK, 1 row affected
SELECT remain FROM coupons WHERE id = 1;  -- 5
COMMIT;

순서는 같은데 A의 UPDATE가 아무 일 없이 성공하고, 바로 다음 SELECT에서 5가 나온다. 방금 전까지 10을 보여 주던 트랜잭션이 B의 변경을 갑자기 보게 된 것이다. MySQL 문서는 이 동작을 숨기지 않는다. 스냅샷은 트랜잭션 안의 SELECT에 적용될 뿐 DML에는 꼭 적용되지 않는다. UPDATE와 DELETE는 다른 트랜잭션이 막 커밋한 최신 행을 대상으로 움직이고, 그렇게 건드린 행은 이후 현재 트랜잭션에도 보인다.

상대식이라면 이 동작이 오히려 반갑다. 최신 값 7에서 2를 빼니 결과가 5로 맞는다. 그런데 애플리케이션이 계산한 remain = 8을 넣으면 B가 뺀 3장은 흔적 없이 사라진다. 갱신 손실이다. 내 쿠폰 수량이 어긋난 이유가 바로 이것이었다. 격리 수준 이름만 믿고 코드를 짰는데, 그 이름이 내가 기대한 걸 약속한 적은 처음부터 없었다.

Jepsen이 2023년 12월에 낸 MySQL 8.0.34 분석도 REPEATABLE READ에서 두 트랜잭션이 같은 버전을 읽고 둘 다 갱신하는 갱신 손실을 여러 번 관찰했다.

세션 A가 10을 읽고 세션 B가 3을 빼서 커밋한 뒤 A가 2를 빼려 할 때, PostgreSQL은 직렬화 오류를 내고 MySQL은 최신 값 7을 읽고 그 위에 쓰는 흐름도
같은 순서로 실행해도 A의 UPDATE에서 두 DB의 결과가 갈린다

스냅샷은 언제 찍히나

두 DB 모두 트랜잭션을 여는 순간에는 스냅샷을 찍지 않는다. PostgreSQL은 트랜잭션 제어문이 아닌 첫 문장이 시작될 때 스냅샷을 잡는다. MySQL InnoDB는 트랜잭션 안의 첫 일관된 읽기, 대개 첫 SELECT가 스냅샷을 만든다.

이 사실을 몰라서 테스트 하나를 반나절 붙잡은 적이 있다. A에서 BEGIN만 실행해 두고 B에서 값을 바꿔 커밋한 뒤 A에서 SELECT를 했더니 새 값이 그대로 보였다. REPEATABLE READ가 고장 났다고 생각하고 설정 파일부터 뒤졌다. 실제로는 A의 첫 SELECT가 B의 커밋 뒤에 실행됐으니 스냅샷도 그때 찍힌 것이다.

트랜잭션을 여는 순간을 기준으로 삼고 싶다면 PostgreSQL에서는 BEGIN 바로 뒤에 아무 SELECT나 하나 실행하면 된다. MySQL에는 전용 구문이 있다.

-- MySQL: 여는 순간 스냅샷을 잡는다
START TRANSACTION WITH CONSISTENT SNAPSHOT;

스냅샷이 묶는 범위도 다르다. PostgreSQL에서는 스냅샷이 읽기와 쓰기를 함께 붙든다. 스냅샷 이후에 바뀐 행을 쓰려 하면 오류가 나는 이유다. MySQL에서는 스냅샷이 SELECT만 붙들고 쓰기는 최신 행을 따라간다. 한 트랜잭션 안에서 과거와 현재가 섞여 보일 수 있다는 뜻이다.

두 DB에서 안전하게 차감하는 법

A의 쓰기 방식PostgreSQLMySQL
상대식 remain = remain - 2직렬화 오류, 재시도 필요최신 값 기준으로 반영
읽은 값으로 계산한 remain = 8직렬화 오류, 재시도 필요갱신 손실
조건부 WHERE id = 1 AND remain = 10직렬화 오류0건 갱신, 애플리케이션이 감지
먼저 SELECT ... FOR UPDATE스냅샷 이후 바뀐 행이면 직렬화 오류최신 값을 잠그고 읽음

조건을 거는 낙관적 갱신은 MySQL에서 특히 쓸모가 있다. 읽은 값을 WHERE에 다시 적어 두면 B가 먼저 바꾼 경우 0건이 돌아오고, 애플리케이션이 그걸 보고 다시 시도할 수 있다. 버전 컬럼을 둬도 된다. SELECT ... FOR UPDATE로 먼저 잠그는 방법도 있지만 MySQL에서 없는 행에 걸면 갭 잠금 때문에 교착이 생기기 쉽다. 이 이야기는 없는 행에 SELECT FOR UPDATE를 걸면 MySQL이 교착되는 이유에 따로 적었다.

지금 내가 쓰는 방식

요즘은 새 프로젝트에서 DB를 정하면 이 글의 두 세션 실험을 먼저 돌려 본다. 5분이면 끝나는데, 이름만 믿고 짠 코드를 나중에 고치는 데는 며칠이 걸렸다.

수량을 줄이는 코드에는 애플리케이션에서 계산한 값을 넣지 않는다. remain = remain - 1에 WHERE remain > 0을 붙이고 영향받은 행 수를 확인한다. MySQL에서는 이 한 줄로 최신 값에서 안전하게 빠지고, 수량이 바닥나면 0건이 돌아온다. PostgreSQL에서 단순 차감만 할 때는 기본값인 READ COMMITTED에 둔다. 이 수준에서는 같은 UPDATE가 앞선 커밋을 기다렸다가 갱신된 행에 조건을 다시 적용하므로 오류 없이 지나간다. 여러 번 읽은 값이 서로 맞아야 해서 REPEATABLE READ를 고를 때는 앞서 말한 40001 헬퍼를 꼭 함께 붙인다.

어느 DB가 옳다고 말하기는 어렵다. PostgreSQL은 어긋날 기미가 보이면 멈추고, MySQL은 일단 진행하고 판단을 코드에 맡긴다. 쿠폰 3장이 사라진 그 이벤트 이후로, 트랜잭션 코드를 리뷰할 때는 ‘이 값은 언제 어디서 읽은 값인가’부터 묻는다.

참고한 자료