← Posts

Database · Transaction · MVCC

MVCC - 잠그는 대신 버전을 쌓는다

읽기가 쓰기를 막지 않게 만든 대가로 무엇을 떠안게 되는가. 버전 체인, 스냅샷, 그리고 청소 문제.

목차
  1. 덮어쓰지 않는다
  2. 어느 버전을 볼 것인가
  3. 버전은 어디에 쌓이나
  4. 격리 수준을 다시 보면
  5. 대가 하나: 쌓인 버전을 치워야 한다
  6. 대가 둘: 쓰기끼리는 여전히 부딪힌다
  7. 대가 셋: 각자 다른 세상을 보는 부작용
  8. 정리하면

월요일 아침마다 돌던 정산 리포트가 3분쯤 걸린다. 그동안 주문 테이블에 쓰려던 트랜잭션이 전부 멈춘다. 리포트는 읽기만 하는데도 그렇다.

앞 글에서 본 락 호환표 때문이다. 읽기가 공유락을 걸면, 그 행에는 배타락을 못 건다. 읽는 사람과 쓰는 사람이 서로를 막는다.

참고

지금 MySQL이나 PostgreSQL을 쓰고 있다면 이 장면이 낯설 것이다. 맞다, 거기서는 이런 일이 안 일어난다. 이 글에서 볼 MVCC를 이미 쓰고 있기 때문이다. 읽기가 락을 거는 세계가 어땠는지를 알아야 MVCC가 무엇을 바꿨는지 보이니, 먼저 그 세계에서 출발한다.

읽기가 압도적으로 많은 서비스에서 이건 견디기 어렵다. 그래서 다른 길이 나왔다.

덮어쓰지 않는다

발상은 단순하다. UPDATE가 값을 덮어쓰지 않고 새 버전을 하나 더 만든다.

옛 버전은 그대로 남는다. 그러면 지금 읽고 있는 사람은 옛 버전을 계속 보면 되고, 쓰는 사람은 새 버전을 만들면 된다. 서로 볼 게 다르니 막을 이유가 없다.

diagramdiagram

한 행에 여러 버전이 사슬처럼 달린다. 이걸 버전 체인이라고 부른다. 각 버전에는 “누가 만들었는지”와 “누가 지웠는지”가 트랜잭션 번호로 붙어 있다.

이 방식이 MVCC(Multi-Version Concurrency Control) 다. 이름 그대로 여러 버전으로 동시성을 다룬다.

어느 버전을 볼 것인가

버전이 여러 개면 문제가 하나 생긴다. 지금 이 트랜잭션은 어느 걸 봐야 하나?

답은 스냅샷이다. 트랜잭션이 첫 쿼리를 던지는 순간 “지금 이 순간 커밋이 끝난 것들”의 목록을 찍어둔다(정확히 언제 찍는지는 격리 수준이 정한다 - 뒤에서 본다). 그리고 그 이후로는 그 목록에 있는 버전만 본다. 나중에 다른 트랜잭션이 무슨 짓을 하든 신경 쓰지 않는다.

diagramdiagram

T2는 T1을 기다리지 않았다. T1도 T2 때문에 멈추지 않았다. 읽기와 쓰기가 서로를 막지 않는다. 이게 MVCC의 전부라고 해도 된다.

버전은 어디에 쌓이나

“옛 버전을 남겨둔다”고 했는데, 그 옛 버전이 어디에 있느냐가 제품마다 갈린다. 그리고 이 선택 하나가 뒤에 볼 대가들을 전부 결정한다.

방식이 둘이다.

diagramdiagram

① 테이블에 쌓는 방식은 갱신할 때 새 행을 하나 더 쓴다. 옛 행은 지우지 않고 “언제까지 유효했다”는 표시만 남긴다. 테이블 안에 죽은 행과 산 행이 섞여 있게 된다.

② 따로 쌓는 방식은 행 자리에 최신 값을 덮어쓰되, 덮기 전 값을 다른 곳에 옮겨둔다. 옛 버전을 봐야 하는 트랜잭션은 그 보관소를 되짚어 올라가서 자기가 볼 값을 찾아낸다.

행에는 어느 쪽이든 **“이 버전을 누가 만들었나”**를 적어둔다. 트랜잭션마다 번호가 있고, 그 번호를 행에 같이 저장하는 것이다. 스냅샷이 하는 일은 결국 이 번호를 보고 **“이건 내 시점 기준으로 이미 커밋된 것인가”**를 판정하는 것이다.

① 테이블에 쌓기② 따로 쌓기
최신 값 읽기죽은 행을 지나쳐 가야 함바로 읽음
옛 값 읽기그 자리에 있음보관소를 되짚어 감
청소테이블을 훑어서 치움보관소를 비움
부풀면테이블이 커짐보관소가 참

어느 쪽이 낫다기보다 아픈 자리가 다르다. ①은 테이블이 부풀어 조회가 느려지고, ②는 보관소가 꽉 차면 오래된 스냅샷이 무효가 되면서 조회가 아예 실패한다.

지금부터 볼 세 가지 대가가 전부 이 구조에서 나온다.

격리 수준을 다시 보면

여기까지 오면 격리 수준 글의 표가 다르게 읽힌다. MVCC를 쓰는 DB에서 격리 수준의 차이는 대체로 스냅샷을 언제 찍느냐의 차이다.

스냅샷을 찍는 시점결과
READ COMMITTED문장마다 새로매번 최신을 보니 두 번 읽으면 값이 다를 수 있다
REPEATABLE READ트랜잭션 시작 때 한 번트랜잭션 내내 같은 세상을 본다

반복 불가능 읽기가 READ COMMITTED에서 생기고 REPEATABLE READ에서 안 생기는 이유가 이거다. 락으로 설명하면 “읽은 행의 락을 언제까지 붙드느냐”인데, MVCC로 설명하면 “스냅샷을 언제 찍느냐”다. 같은 현상을 두 방식이 다르게 해결하는 것이다.

대가 하나: 쌓인 버전을 치워야 한다

공짜일 리가 없다. 옛 버전이 계속 쌓인다.

아무도 그 버전을 안 보게 되면 지워야 한다. 그런데 “아무도 안 본다”를 어떻게 아나? 가장 오래된 스냅샷보다 앞선 버전이면 안전하다. 그보다 오래된 걸 볼 트랜잭션이 없으니까.

문제는 여기서 생긴다.

diagramdiagram

조회를 한 번이라도 한 트랜잭션을 열어둔 채 방치하면 그동안의 모든 옛 버전을 못 치운다. 그 뒤로 아무 일도 안 하고 있어도 그렇다. 스냅샷을 쥔 이상 볼 가능성이 있고, 그것만으로 청소기가 손을 못 댄다.

이러면 디스크를 먹고, 조회가 느려진다. 옛 버전을 테이블에 그대로 쌓아두는 PostgreSQL 같은 방식에서는 특히 직접적이다. 실제 데이터는 100만 건인데 죽은 버전 900만 건을 같이 훑는 식이 된다.

주의

“긴 트랜잭션을 피하라”는 조언이 락 방식에서는 다른 사람을 기다리게 하니까였다. MVCC에서는 이유가 하나 더 붙는다. 청소를 막기 때문이다. 락을 하나도 안 쥔 읽기 전용 트랜잭션이라도, 조회 한 번 하고 열어두면 청소를 막는다. 모니터링에서 찾아야 할 게 이것이라 이름도 붙어 있다 - idle in transaction.

앞에서 본 두 방식이 여기서 갈린다. 테이블에 쌓는 쪽은 훑어서 죽은 행을 치워야 하고, 따로 쌓는 쪽은 보관소가 꽉 차면 오래된 스냅샷이 무효가 되면서 “너무 오래된 스냅샷” 류의 에러를 낸다. 어느 쪽이든 청소는 공짜가 아니고, 긴 트랜잭션이 그 청소를 막는다는 점은 같다.

대가 둘: 쓰기끼리는 여전히 부딪힌다

MVCC가 해결한 건 읽기와 쓰기 사이다. 쓰기와 쓰기 사이는 그대로다.

같은 행을 두 트랜잭션이 동시에 고치려 하면, 버전을 나눠줄 수가 없다. 최종 값은 하나여야 하니까. 그래서 이때는 결국 락이 등장한다. 한쪽이 기다린다.

앞 글의 데드락은 MVCC를 써도 사라지지 않는다. MVCC는 락을 없앤 게 아니라 읽기에서 락을 걷어낸 것이다.

그리고 격리 수준 글에서 봤던 갱신 손실이 여기서 미묘해진다. DB가 알아서 막아주는 경우와 그렇지 않은 경우가 갈린다.

UPDATE 재고 SET 수량 = 수량 - 1처럼 DB 안에서 계산하는 갱신은, 스냅샷을 보고 있더라도 쓰는 순간에는 최신 버전을 다시 확인한다. 그래서 REPEATABLE READ에서는 나중 트랜잭션이 기다렸다가 갱신된 값 위에 다시 계산하거나(MySQL InnoDB), 아예 에러로 실패한다(PostgreSQL). 조용히 덮이지 않는다.

문제는 앱이 값을 읽어와 계산한 뒤 통째로 되쓰는 경우다.

sql
SELECT 수량 FROM 재고 WHERE id = 1;   -- 앱이 10을 받아서
UPDATE 재고 SET 수량 = 9 WHERE id = 1; -- 9라고 계산해 되쓴다

이건 DB 입장에서 “9를 넣어라”는 지시일 뿐이라 막을 근거가 없다. 게다가 READ COMMITTED에서는 앞의 계산형 갱신마저 스냅샷이 문장마다 새로 잡혀 손실이 날 수 있다. 그래서 이 자리에서는 여전히 먼저 잠그거나 나중에 확인하거나 둘 중 하나를 해야 한다. MVCC가 그 선택을 없애주지는 않는다.

대가 셋: 각자 다른 세상을 보는 부작용

이게 가장 미묘하다. 스냅샷 격리에는 락 방식엔 없던 고유한 함정이 있다.

당직 시스템을 생각해보자. 규칙은 하나다. 최소 한 명은 당직이어야 한다. 지금 두 명이 당직이다.

diagramdiagram

둘 다 자기 검사는 통과했다. 서로 다른 행을 고쳤으니 충돌도 없다. 그런데 규칙이 깨졌다.

이걸 쓰기 편중(Write Skew) 이라고 부른다. 격리 수준 글에서 “ANSI 표의 세 가지 현상만으로는 부족하다”고 했던 그 이야기가 여기로 이어진다. 더티 리드도 아니고 반복 불가능 읽기도 아니고 팬텀도 아니다. 표에 없는 방식으로 깨진다.

원인은 각자 자기 스냅샷 안에서만 검사했기 때문이다. A의 세상에서는 B가 아직 당직이고, B의 세상에서는 A가 아직 당직이다. 둘 다 맞았고, 합쳐놓으니 틀렸다.

막으려면 스냅샷 격리보다 위로 올라가거나, 검사하는 행을 명시적으로 잠그거나, 제약 조건을 DB에 걸어야 한다.

주의

“위로 올라간다”에서 격리 수준의 이름만 믿으면 안 되는 게 여기서도 걸린다. SERIALIZABLE이라고 적혀 있어도 실제로는 스냅샷 격리 그대로라 이 현상을 못 막는 제품이 있고(Oracle), 읽기-쓰기 충돌을 추적해 진짜로 막는 제품이 있고(PostgreSQL), 모든 조회를 잠금 읽기로 바꿔 막는 제품이 있다(MySQL InnoDB). 이름이 같아도 셋이 다르다.

정리하면

MVCC는 읽기와 쓰기의 충돌이라는 문제 하나를 아주 잘 푼다. 대신 세 가지를 떠안는다.

  • 청소 - 옛 버전이 쌓이고, 긴 트랜잭션이 그 청소를 막는다.
  • 쓰기 충돌은 그대로 - 데드락도 갱신 손실도 사라지지 않는다.
  • 쓰기 편중 - 각자 다른 스냅샷에서 검사하면 합쳐서 틀릴 수 있다.

이 셋을 알고 나면 실무 조언 하나가 다르게 들린다. 트랜잭션은 짧게 열고 빨리 닫아라. 락 방식에서는 남을 안 기다리게 하려고, MVCC에서는 청소를 안 막으려고. 이유는 다른데 결론은 같다.

참고

여기까지가 동시성이다. 다음 글부터는 방향을 바꿔서 조회 성능으로 간다. 그런데 그 전에 바닥을 하나 깔아야 한다. DB가 디스크를 무슨 단위로 읽고 쓰는지. 그걸 모르면 인덱스가 왜 그렇게 생겼는지 설명이 안 된다.

관련 글