← Posts

Database · Transaction · Isolation

격리 수준 - 같이 읽고 쓰면 무엇이 깨지는가

더티 리드·반복 불가능 읽기·팬텀 리드가 언제 생기는지, 그리고 격리 수준이라는 이름이 왜 믿을 게 못 되는지.

목차
  1. 완벽하게 격리하면 되지 않나
  2. 깨지는 방식 세 가지
    1. 더티 리드 - 없던 일이 된 값을 읽는다
    2. 반복 불가능 읽기 - 같은 걸 두 번 읽으면 값이 다르다
    3. 팬텀 리드 - 없던 행이 나타난다
  3. 표준이 정한 눈금
  4. 이름을 믿지 마라
  5. 표준 자체도 부족하다
  6. 갱신 손실은 어떻게 막나
    1. 먼저 잠근다 (비관적)
    2. 나중에 확인한다 (낙관적)
    3. 어느 쪽을 고르나
  7. 격리 수준은 어디에 거는가
  8. 그래서 뭘 쓰나

같은 트랜잭션 안에서 잔고를 두 번 조회했는데 값이 달랐다. 코드는 그대로고, 사이에 아무것도 안 했다.

버그처럼 보이지만 아니다. PostgreSQL·Oracle·SQL Server는 기본 설정에서 이걸 허용한다. 왜 그런지, 그리고 어디까지 막을 수 있는지가 이 글의 주제다. (MySQL은 기본값이 달라 이 장면이 안 나온다. 그 이유도 뒤에서 본다.)

완벽하게 격리하면 되지 않나

된다. 트랜잭션을 한 줄로 세워서 하나 끝나면 다음 걸 실행하면 이상한 일은 절대 안 생긴다. 문제는 그게 느려도 너무 느리다는 것이다.

그래서 데이터베이스는 타협한다. “이 정도 이상 현상은 눈감아줄 테니 대신 빠르게 가자” - 그 타협의 눈금이 격리 수준이다. ACID의 I가 0과 1 사이 어딘가에 있는 슬라이더인 셈이다.

무엇을 눈감아주는지 알려면 먼저 뭐가 깨질 수 있는지부터 봐야 한다.

깨지는 방식 세 가지

더티 리드 - 없던 일이 된 값을 읽는다

T2가 아직 커밋하지 않은 값을 T1이 읽는다. 그런데 T2가 롤백해버린다. T1이 읽은 값은 한 번도 존재한 적 없는 값이 된다.

diagramdiagram

이건 거의 모든 상황에서 재앙이다. 그래서 실무에서 쓰이는 격리 수준은 대부분 이것부터 막는다.

반복 불가능 읽기 - 같은 걸 두 번 읽으면 값이 다르다

맨 앞에 말한 그 상황이다. T1이 잔고를 읽고, 그 사이 T2가 값을 바꾸고 커밋하고, T1이 다시 읽는다.

diagramdiagram

더티 리드와 다른 점은 읽은 값이 둘 다 진짜라는 것이다. 1000도 사실이었고 2000도 사실이다. 다만 T1 입장에서 세상이 발밑에서 움직였을 뿐이다.

한 번만 읽고 끝내는 코드라면 아무 문제 없다. 문제는 읽은 값으로 계산하고 다시 읽어서 검증하는 코드다. 검증이 통과할 리가 없다.

팬텀 리드 - 없던 행이 나타난다

앞의 둘은 이미 있는 행의 값이 문제였다. 팬텀은 행의 개수가 문제다.

diagramdiagram

“조건에 맞는 게 3건이니까 3건만 처리하면 돼”라고 판단한 뒤에 4번째가 슬쩍 끼어드는 식이다. 값을 잠그는 것만으로는 못 막는다. 아직 존재하지도 않는 행을 미리 잠글 수는 없으니까. 범위 자체를 잠가야 한다.

표준이 정한 눈금

ANSI SQL-92는 위 세 현상을 기준으로 격리 수준 네 개를 정의했다. 아래로 갈수록 안전하고 느리다.

격리 수준더티 리드반복 불가능 읽기팬텀 리드
READ UNCOMMITTED허용허용허용
READ COMMITTED방지허용허용
REPEATABLE READ방지방지허용
SERIALIZABLE방지방지방지

표가 깔끔해서 이걸 외우면 될 것 같지만, 여기서부터가 함정이다.

이름을 믿지 마라

같은 이름의 격리 수준이 DBMS마다 다른 걸 보장한다. 이게 이 글에서 가장 중요한 문장이다.

몇 가지 예를 들면 이렇다. PostgreSQL은 READ UNCOMMITTED를 요청해도 조용히 READ COMMITTED로 동작한다 - 더티 리드를 아예 구현하지 않았기 때문이다. 그리고 PostgreSQL의 REPEATABLE READ는 표준이 허용한 팬텀 리드까지 막는다. 표준보다 더 엄격한 것이다.

MySQL InnoDB는 기본값이 REPEATABLE READ인데, 대부분의 다른 DBMS는 READ COMMITTED를 기본으로 쓴다. 같은 코드를 다른 DB로 옮기면 격리 수준이 바뀌는 셈이다.

주의

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ라고 썼다고 해서 어떤 DB에서든 같은 보장을 받는 게 아니다. 쓰고 있는 DBMS의 문서를 직접 확인해야 한다. 표준 표는 출발점일 뿐이다.

표준 자체도 부족하다

1995년에 나온 A Critique of ANSI SQL Isolation Levels라는 논문이 이 표의 문제를 지적했다. 세 가지 현상만으로 격리 수준을 정의하는 게 불충분하다는 것이다.

표에 안 잡히는 대표적인 사례가 갱신 손실(Lost Update) 이다.

diagramdiagram

둘 다 읽고, 둘 다 쓰고, 나중 게 앞의 걸 덮었다. 더티 리드도 아니고 반복 불가능 읽기도 아니다. 그런데 재고는 틀렸다.

격리 수준만 설정해두고 안심할 수 있는 문제가 아니라는 뜻이다. 그럼 어떻게 막나.

갱신 손실은 어떻게 막나

방법이 둘이다. 먼저 잠그거나, 나중에 확인하거나.

먼저 잠근다 (비관적)

읽는 시점에 “이건 내가 고칠 거니까 건드리지 마라”고 선언한다.

sql
BEGIN;
  SELECT 재고 FROM 상품 WHERE id = 1 FOR UPDATE;   -- 여기서 잠근다
  -- 다른 트랜잭션은 이 행을 읽으려다 여기서 멈춰 기다린다
  UPDATE 상품 SET 재고 = 9 WHERE id = 1;
COMMIT;                                            -- 여기서 풀린다

FOR UPDATE가 붙으면 그냥 읽기가 아니라 잠그면서 읽기가 된다. 앞 그림에서 T2는 SELECT 단계에서 대기하게 되고, T1이 끝난 뒤에야 9라는 최신 값을 받는다. 그러면 8로 제대로 계산한다.

확실하지만 대가가 있다. 기다리는 시간이 생기고, 잠근 채로 오래 있으면 뒤가 밀린다. 그리고 여러 행을 각자 다른 순서로 잠그면 서로를 기다리다 영영 안 풀리는 상황이 생기는데, 그게 다음 글의 주제다.

나중에 확인한다 (낙관적)

잠그지 않고 일단 읽는다. 대신 쓸 때 “내가 읽은 그대로인가”를 확인한다.

sql
-- 읽을 때 버전도 같이 읽어둔다 (재고=10, version=3)
SELECT 재고, version FROM 상품 WHERE id = 1;

-- 쓸 때 그 버전이 그대로일 때만 쓴다
UPDATE 상품 SET 재고 = 9, version = 4
 WHERE id = 1 AND version = 3;

핵심은 AND version = 3이다. 그사이 누가 먼저 고쳤다면 버전이 이미 4라서 이 UPDATE는 0건을 고친다. 에러가 아니라 “0건 바뀜”으로 돌아오므로, 애플리케이션이 그걸 보고 다시 읽어서 재시도한다.

바뀐 행 수를 확인하지 않으면 이 방식은 아무 일도 안 한 것과 같다. 조용히 지나간다.

어느 쪽을 고르나

먼저 잠근다나중에 확인한다
부딪힘이 잦을 때유리 (한 번에 끝남)불리 (재시도가 반복됨)
부딪힘이 드물 때불리 (안 쓸 락을 검)유리 (락 없이 지나감)
실패하면기다린다다시 한다

같은 행을 여럿이 동시에 노리면 먼저 잠그고, 어쩌다 겹치는 정도면 나중에 확인한다. 인기 상품 재고 차감은 앞쪽, 사용자가 자기 프로필을 고치는 것은 뒤쪽이다.

애초에 읽지 않고 쓸 수 있으면 둘 다 필요 없다. UPDATE 상품 SET 재고 = 재고 - 1 WHERE id = 1 AND 재고 > 0 처럼 DB 안에서 계산하면 읽고 쓰는 사이의 틈이 아예 없다. 가능하면 이게 가장 간단하다.

격리 수준은 어디에 거는가

여기까지 “어느 수준을 쓸까”를 이야기했는데, 정작 거는 방법을 안 봤다. 거는 자리가 둘이다.

sql
-- ① 이번 트랜잭션에만
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
  -- ...
COMMIT;

-- ② 지금 연결 전체에
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

①은 이 트랜잭션이 끝나면 원래대로 돌아간다. ②는 그 연결을 쓰는 동안 계속 유지된다.

실무에서는 ①을 기본으로 삼는 게 안전하다. ②가 위험한 이유가 하나 있다. 요즘 애플리케이션은 연결을 매번 새로 만들지 않고 미리 만들어둔 것을 빌려 쓰고 반납한다(커넥션 풀). 그런데 세션에 건 설정은 반납해도 안 지워진다.

plaintext
요청 A: 연결 7번을 빌린다 → SERIALIZABLE로 올린다 → 반납
요청 B: 연결 7번을 빌린다 → 아무것도 안 했는데 SERIALIZABLE이다

요청 B는 자기가 올린 적도 없는 격리 수준에서 돌고, 왜 갑자기 느려지고 충돌이 나는지 알 길이 없다. 어느 연결을 잡느냐에 따라 다르게 동작하니 재현도 안 된다.

그래서 프레임워크들도 대개 트랜잭션 단위로 지정하는 방법을 준다(Spring이라면 @Transactional(isolation = ...)). 필요한 그 트랜잭션에만 올리고, 끝나면 돌아오게 두는 것이다.

그래서 뭘 쓰나

대부분의 애플리케이션은 기본값(대개 READ COMMITTED)으로 충분하다. 올려야 할 이유가 생겼을 때 올리는 게 맞지, 처음부터 SERIALIZABLE로 깔고 시작할 일은 거의 없다. 격리 수준을 올리면 락 경합이나 재시도가 늘어나고, 그 비용은 트래픽이 늘어날수록 가파르게 커진다.

기억할 건 세 가지 정도다.

  • 읽은 값으로 판단하고 그 판단을 근거로 쓸 거라면 기본 격리 수준은 부족할 수 있다. 재고 차감, 좌석 예약, 잔고 이체가 전부 여기 해당한다.
  • 격리 수준을 올리기 전에 명시적 락이나 버전 컬럼으로 해결되는지 먼저 본다. 범위가 좁아서 부작용이 적다.
  • 무엇을 쓰든 그 DBMS가 그 이름으로 정확히 뭘 보장하는지 확인한다.
참고

격리를 실제로 어떻게 구현하는지는 두 갈래로 나뉜다. 하나는 잠가서 못 건드리게 하는 방식(락), 다른 하나는 각자에게 다른 버전을 보여주는 방식(MVCC)이다. 다음 글에서 락부터 다룬다.

관련 글