Database · Transaction · Isolation

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

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

·데이터베이스 공통 개념 2편
목차
  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이 읽은 값은 한 번도 존재한 적 없는 값이 된다.

T2
쓰기
T2 트랜잭션 안 - 아직 확정 아님 UPDATE 잔고 = 2000커밋 안 된 값 ROLLBACK
상자 안의 것은 전부 없던 일
T1
읽기
SELECT 잔고 → 2000상자 안에서 꺼내 왔다 T1이 든 2000한 번도 확정된 적 없는 값
DB에 확정된
잔고
1000 한 번도 2000이었던 적이 없다 - 선이 움직이지 않았다 1000 시간 →

2000은 T2의 상자 안에만 있던 값이다. 상자가 통째로 지워지면 T1이 든 값은 가리킬 곳이 없다 - 아래 확정선은 처음부터 끝까지 1000이었다.

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

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

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

T2
쓰기
T2 트랜잭션 안 - 아직 확정 아님 UPDATE 잔고 = 2000커밋 전이라 T1에겐 아직 안 보인다 COMMIT
여기서 2000이 진짜가 된다
T1
읽기 · 한 트랜잭션
T1 트랜잭션 하나 사이에 아무것도 안 했다 ① SELECT → 1000 ② SELECT → 2000
DB에 확정된
잔고
1000 2000 시간 →

읽은 자리는 두 번 다 확정선이다 - 둘 다 진짜다. 다만 그 사이에 T2가 커밋해서 선이 한 칸 올라섰을 뿐이다.

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

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

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

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

① T1이 센다
조건에 맞는 행을 하나씩 잠그면서
WHERE status='open'
#101 #102 #103 빈 자리 빈 자리 빈 자리
3건빈 자리엔 걸 자물쇠가 없다
② T2가 INSERT하고 커밋
잠긴 행은 하나도 안 건드렸다
WHERE status='open'
#101 #102 #103 #104새 행 빈 자리 빈 자리
4건T1이 다시 세면 하나 늘어 있다
③ 막으려면 범위째
빈 자리까지 잠가야 한다
WHERE status='open' 범위 잠금
#101 #102 #103 막힘 막힘 막힘
3건 그대로#104는 여기 못 들어온다

자물쇠가 에 달려 있으면 빈 자리는 뚫려 있다 - 아직 없는 행은 잠글 수가 없다. 그래서 범위 자체를 잠가야 개수가 고정된다.

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

표준이 정한 눈금

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

격리 수준
아래로 갈수록 엄격
더티 리드 반복 불가능 읽기 팬텀 리드 대가대기 · 경합 · 재시도 READ
UNCOMMITTED
샌다 샌다 샌다 거의 없음 READ
COMMITTED대개 이게 기본값
막힘 샌다 샌다 조금 REPEATABLE
READ
막힘 막힘 샌다 SERIALIZABLE 막힘 막힘 막힘

한 칸 내려갈 때마다 새는 것이 하나씩 꺼지고, 그만큼 대가가 길어진다. 공짜로 안전해지는 칸은 없다.

격리 수준더티 리드반복 불가능 읽기팬텀 리드
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) 이다.

T1
먼저 끝난다
COMMIT SELECT → 10 UPDATE = 9
DB에 확정된
재고 · 높이가 곧 값
10 9 T2도 9를 쓴다 - 이미 9라 아무것도 안 바뀐다 8이 됐어야 한다 - 갱신 하나가 사라졌다
T2
나중에 끝난다
COMMIT SELECT → 10 UPDATE = 9 시간 →

두 읽기가 같은 칸에 닿는다 - 둘 다 진짜 값을 읽었으니 어떤 규칙도 안 어겼다. 그런데 쓰기는 두 번인데 선은 한 칸만 내려갔다.

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

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

갱신 손실은 어떻게 막나

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

먼저 잠근다 (비관적)

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

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)이다. 다음 글에서 락부터 다룬다.