포스트

MVCC를 어떻게 이해해야 하는가

MVCC란

MVCC는 Multi-Version Concurrency Control의 약자다. 말 그대로 하나의 데이터에 대해 여러 버전을 관리해서, 읽기와 쓰기가 서로 덜 충돌하게 만드는 방식이다. 여기서 핵심은 “동시에 접근하는 여러 트랜잭션에게 같은 row를 서로 다른 모습으로 보이게 할 수 있다”는 점이다.

많이들 MVCC를 “락 없이 읽는 기술” 정도로 기억하는데, 그 표현은 반만 맞다. 더 정확히 말하면 MVCC는 읽기를 버전 기반으로 처리해서 불필요한 read lock 경쟁을 줄이는 메커니즘이다. 쓰기 충돌이 사라지는 것은 아니고, 격리 수준의 모든 문제가 자동으로 해결되는 것도 아니다.

왜 필요한가

동시성이 높은 시스템에서 읽기와 쓰기가 서로 계속 막기 시작하면 처리량이 급격히 떨어진다. 가장 단순한 방식은 “누가 쓰는 중이면 읽는 쪽도 잠깐 기다린다”인데, 이 방식은 정합성은 직관적이어도 처리량이 좋지 않다.

예를 들어 주문 테이블에서 특정 row를 업데이트하는 트랜잭션이 길게 잡혀 있다고 하자. 모든 조회가 그 row의 lock 해제를 기다려야 한다면, 조회 부하가 조금만 높아져도 애플리케이션은 금방 병목에 걸린다. MVCC는 이 상황에서 읽는 쪽이 “지금 막 수정 중인 최신 상태”가 아니라 “내가 볼 수 있는 일관된 과거 상태”를 읽게 해서 읽기와 쓰기의 충돌을 줄인다.

그래서 실무에서 자주 보는 “누군가 수정 중이어도 일반 조회는 된다”는 경험은 대부분 MVCC 덕분이다.

먼저 버전과 시점을 분리해서 봐야 한다

MVCC를 헷갈리게 만드는 이유는 보통 두 가지를 한 덩어리로 이해하기 때문이다.

  • 데이터는 여러 버전으로 관리된다.
  • 트랜잭션마다 볼 수 있는 시점이 다르다.

버전이 여러 개 있다는 사실만으로는 충분하지 않다. 중요한 것은 어떤 트랜잭션이 어떤 버전을 볼 수 있는지 결정하는 규칙이다. 결국 MVCC는 “과거 버전을 저장한다”와 “가시성 규칙으로 읽을 버전을 고른다”가 함께 있어야 성립한다.

어떻게 동작하나

구현은 DB마다 다르지만, InnoDB 기준으로는 다음 흐름으로 이해하면 이해가 쉽다.

  1. 트랜잭션이 row를 수정한다.
  2. 기존 값은 undo log 쪽에 남긴다.
  3. 현재 row에는 “누가 언제 수정했는지”를 추적할 수 있는 정보가 함께 관리된다.
  4. 다른 트랜잭션이 이 row를 읽으려 하면, 자신의 read view 기준으로 현재 버전이 보이는지 판단한다.
  5. 현재 버전이 보이지 않으면 undo log를 따라가며 자신에게 보이는 과거 버전을 찾는다.

즉, 하나의 row를 그냥 덮어쓰는 것이 아니라 “현재 버전”과 “이전 버전으로 되돌아갈 수 있는 정보”가 함께 관리된다. 읽는 시점에는 이 중에서 자신에게 허용된 버전이 선택된다.

InnoDB 관점에서 보면

InnoDB는 row에 숨겨진 트랜잭션 메타데이터를 두고, 이전 값은 undo log로 연결해둔다. 그래서 현재 row만 보면 최신 상태가 있고, 과거 상태가 필요하면 undo 체인을 따라가며 복원할 수 있다.

이걸 아주 거칠게 표현하면 아래와 같다.

1
2
3
4
5
6
7
현재 row
- name = "B"
- 이 값은 tx 105가 만듦
- 이전 버전은 undo log를 따라가면 찾을 수 있음

undo log
- tx 105 이전 값: name = "A"

트랜잭션 T1이 아직 커밋되지 않은 tx 105의 변경을 볼 수 없다면, T1은 현재 row의 "B" 대신 undo 쪽의 "A"를 읽게 된다. 이게 MVCC의 기본 감각이다.

read view를 같이 이해해야 한다

MVCC 설명에서 가장 자주 빠지는 단어가 read view다. 버전이 많아도, 어떤 버전이 현재 트랜잭션에게 보이는지 결정하는 기준이 없으면 일관된 읽기를 설명할 수 없다.

InnoDB에서 read view는 “이 트랜잭션이 어떤 트랜잭션들의 변경을 이미 확정된 것으로 볼 것인가”를 판단하는 스냅샷 정보다. 세부 필드는 DB 내부 구현에 가깝지만, 실무 감각으로는 다음 정도로 이해하면 충분하다.

  • read view가 만들어진 시점 이전에 커밋된 변경은 볼 수 있다.
  • 아직 커밋되지 않은 변경은 볼 수 없다.
  • 경우에 따라 자기 자신의 변경은 볼 수 있다.

그래서 같은 SELECT라도 “언제 read view가 만들어졌는가”에 따라 결과가 달라진다. 이 지점이 격리 수준과 바로 연결된다.

snapshot read와 current read

Snapshot Read

일반 SELECT처럼 스냅샷을 읽는다. 락 없이 과거 일관된 시점의 데이터를 보는 데 초점이 있다. 현재 누군가 수정 중이더라도, 그 수정이 아직 내 read view 기준으로 보이지 않으면 과거 버전을 읽는다.

이 읽기는 “최신 값을 읽는다”보다 “일관된 값을 읽는다”에 더 가깝다. 그래서 조회 결과가 살짝 오래된 값일 수는 있어도, 트랜잭션 관점에서는 일관성을 유지한다.

Current Read

SELECT ... FOR UPDATE, UPDATE, DELETE처럼 현재 최신 버전과 락을 기준으로 읽는다. 이 경우 관심사는 과거 스냅샷이 아니라 “지금 실제로 수정 가능한 최신 상태”다.

그래서 current read는 필요하면 대기하고, 락을 잡고, 다른 트랜잭션과 직접 충돌한다. MVCC를 이해할 때 이 current read 감각이 빠지면 “왜 어떤 SELECT는 안 막히는데 어떤 SELECT는 막히지?”라는 질문에 답을 못 하게 된다.

MVCC를 이해할 때 이 둘을 구분하지 않으면 왜 어떤 쿼리는 막히고 어떤 쿼리는 안 막히는지 설명이 안 된다.

예시로 보면 더 분명하다

아래처럼 계좌 잔액이 100인 row가 있다고 하자.

  1. 트랜잭션 A가 잔액을 100 -> 70으로 수정했지만 아직 커밋하지 않았다.
  2. 동시에 트랜잭션 B가 일반 SELECT로 잔액을 조회한다.

이때 트랜잭션 B는 보통 70이 아니라 100을 본다. A의 변경은 아직 커밋되지 않았고, B의 read view 기준으로 보이지 않기 때문이다.

반대로 트랜잭션 B가 다음처럼 읽으면 상황이 달라진다.

1
SELECT balance FROM account WHERE id = 1 FOR UPDATE;

이 쿼리는 current read다. 그래서 과거 버전을 조용히 읽고 넘어가지 않고, 최신 row와 락 상태를 기준으로 동작한다. 이미 A가 해당 row를 잡고 있다면 B는 기다리게 된다.

즉, “읽기”라는 단어 하나로 묶여 있어도 내부 동작은 전혀 다를 수 있다.

격리 수준과 MVCC

MVCC는 격리 수준과 분리된 별도 주제가 아니다. 오히려 격리 수준이 MVCC를 어떤 방식으로 활용하는지를 이해해야 실제 동작이 보인다.

READ COMMITTED

READ COMMITTED에서는 일반적으로 statement마다 새로운 read view를 만든다고 이해하면 된다. 그래서 같은 트랜잭션 안에서 같은 SELECT를 두 번 실행했을 때, 중간에 다른 트랜잭션이 커밋했다면 두 번째 조회 결과가 달라질 수 있다.

즉, dirty read는 막지만 non-repeatable read는 가능하다.

REPEATABLE READ

InnoDB의 기본 격리 수준인 REPEATABLE READ에서는 트랜잭션 내에서 처음 읽기 시점의 스냅샷을 계속 유지하는 방식으로 이해할 수 있다. 그래서 일반적인 snapshot read는 같은 트랜잭션 안에서 반복 조회해도 같은 결과를 보게 된다.

이 때문에 “반복 조회 일관성”은 좋아지지만, 그렇다고 해서 모든 종류의 팬텀 문제가 MVCC만으로 끝나는 것은 아니다. 특히 현재 읽기나 범위 갱신에서는 gap lock, next-key lock 같은 락 전략이 여전히 중요하다.

SERIALIZABLE과의 관계

SERIALIZABLE은 더 강한 격리를 제공하지만 그만큼 동시성 비용도 크다. MVCC가 있다고 해서 굳이 항상 SERIALIZABLE이 필요한 것은 아니고, 많은 시스템은 MVCC와 적절한 락 전략으로 충분한 수준의 격리를 확보한다.

MVCC가 해결하는 것과 해결하지 않는 것

MVCC가 해결하는 것:

  • 일반 조회와 쓰기 사이의 불필요한 대기를 줄인다.
  • 트랜잭션마다 일관된 읽기 시점을 제공한다.
  • read-heavy workload에서 처리량을 높이는 데 유리하다.

MVCC가 해결하지 않는 것:

  • 같은 row를 동시에 수정하려는 write-write 충돌
  • 범위 조건 갱신에서의 팬텀 방지
  • 애플리케이션 레벨의 lost update 방지 로직 전부

특히 “MVCC가 있으니 락은 중요하지 않다”는 식으로 이해하면 안 된다. MVCC는 락을 없앤 것이 아니라, 읽기 쪽에서 락 의존도를 낮춘 것이다. 쓰기 정합성은 여전히 락과 격리 수준, SQL 패턴에 크게 의존한다.

undo log와 purge도 같이 봐야 한다

MVCC는 과거 버전을 참조해야 하므로 undo log가 일정 시간 유지되어야 한다. 아직 어떤 트랜잭션이 과거 버전을 볼 가능성이 남아 있다면 관련 undo를 바로 지울 수 없다.

그래서 장시간 트랜잭션이 남아 있으면 purge가 밀리고, undo가 오래 유지되며, 내부적으로 비용이 커질 수 있다. 실무에서 “긴 트랜잭션은 웬만하면 피하라”는 말은 단지 lock 오래 잡는 문제 때문만이 아니라 MVCC 관점의 버전 정리 비용과도 연결된다.

자주 생기는 오해

MVCC면 무조건 최신 데이터를 읽는가

아니다. snapshot read는 최신 데이터보다 일관된 시점의 데이터를 읽는 데 초점이 있다.

MVCC면 락이 필요 없는가

아니다. current read와 write 충돌 제어에는 여전히 락이 필요하다.

REPEATABLE READ면 팬텀 리드가 완전히 없는가

이 질문은 어떤 종류의 읽기인지까지 같이 봐야 한다. InnoDB에서는 snapshot read와 locking read의 조합 덕분에 체감상 팬텀 문제가 덜 보일 수 있지만, 범위 변경을 물리적으로 제어하는 것은 결국 락 전략이다. MVCC만으로 모든 팬텀을 설명하면 부족하다.

정리

MVCC는 “과거 버전을 저장한다”는 개념 하나로 끝나지 않는다. 실제로는 undo log, read view, snapshot read, current read, 격리 수준이 함께 맞물려야 이해가 된다.

핵심만 다시 정리하면 다음과 같다.

  • MVCC는 읽기를 버전 기반으로 처리해서 읽기-쓰기 충돌을 줄인다.
  • 일반 SELECT는 snapshot read로 과거 일관된 버전을 읽을 수 있다.
  • FOR UPDATE, UPDATE, DELETE는 current read라서 최신 상태와 락에 직접 부딪힌다.
  • MVCC가 있어도 쓰기 충돌과 범위 락 문제는 여전히 남는다.

트랜잭션을 공부할 때 MVCC를 “락의 반대편 개념”으로 보면 자꾸 헷갈린다. “읽기 일관성을 위해 버전을 사용하는 메커니즘”으로 잡아두면, ACID, 격리 수준, undo log, gap lock이 훨씬 자연스럽게 연결된다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다