본문 바로가기

기록

MVCC(Multi-Version Concurrency Control) 알아보기

채용 공고에서 마주친 낯선 질문

최근 이직을 준비하며 여러 회사의 채용 공고를 훑어보던 중이었다. 우대사항이나 필요 역량에 종종 등장하는 단어 하나가 유독 눈에 밟혔다.

바로 MVCC.

서버 개발자로 꽤 오랜 시간을 보냈지만, 솔직히 고백하자면 이 용어를 보자마자 명쾌하게 설명할 자신이 없었다. 그동안은 "DB 동시성 제어는 트랜잭션 걸면 알아서 되는 거 아닌가?"라며 ORM이라는 거대한 방패 뒤에 숨어 있었기 때문일지도 모르겠다.

이제는 도구의 사용법을 넘어 원리를 이해하고, 트레이드오프를 고려하며 개발하고 싶다는 생각이 들었다. 그래서 채용 공고 덕분에 다시 마주하게 된 이 MVCC라는 개념을 정리해 보고자 한다.

왜 내 SELECT 쿼리는 멈추지 않을까?

본격적으로 알아보기에 앞서 질문을 하나 던져보자.

데이터베이스를 공부하다 보면 가장 자주 마주치는 난관 중 하나가 ‘동시성 제어’인 것 같다. 예전의 데이터베이스나 일부 파일 시스템은 데이터의 무결성을 지키기 위해 단순하고 강력한 방법을 사용했다. 바로 Lock이다. 누군가 데이터를 수정하고 있다면, 다른 누군가는 해당 작업이 끝날 때까지 기다려야 했다.

하지만 현대의 RDBMS에서는 다르다. 대량의 UPDATE가 발생하고 있는 테이블에 SELECT를 사용해도, 쿼리는 성능 저하가 있을지언정 대기 없이 결과를 반환한다.

이것이 가능한 이유가 뭘까? 바로 MVCC 기술 덕분이다.

MVCC란 무엇인가?

MVCC(Multi-Version Concurrency Control)는 이름 그대로 ‘하나의 데이터에 대해 여러 버전을 관리하여 동시성을 제어하는 기술’이다. 쉽게 비유하자면 개발자들이 흔히 사용하는 Git과 비슷하다. 소스 코드를 수정한다고 해서 이전 코드가 즉시 사라지는 것이 아니다. 커밋 로그를 통해 이전 버전을 볼 수도 있고, 현재 작업 중인 버전을 볼 수도 있다.

데이터베이스도 마찬가지다. 데이터에 변경이 일어날 때, 기존 데이터를 덮어쓰는 대신, 특정 시점의 스냅샷 정보를 바탕으로 여러 버전을 유지한다. 이를 통해 다음과 같은 MVCC의 핵심 철학이 완성된다.

“Writers don’t block Readers, and Readers don’t block Writers.”
쓰기 작업은 읽기를 막지 않고, 읽기 작업은 쓰기를 막지 않는다.

MVCC의 동작 원리

그렇다면 MVCC는 어떻게 동작할까? 가장 널리 쓰이는 MySQL(InnoDB)을 기준으로 알아보자.

데이터 뒤에 숨겨진 비밀

InnoDB가 버전을 관리하는 마법을 이해하려면 먼저 ‘숨겨진 컬럼’을 알아야 한다. 우리가 테이블을 설계할 때 정의한 컬럼 외에도, InnoDB는 내부적으로 각 행마다 몰래 관리하는 컬럼들이 있다.

  • DB_TRX_ID: 이 행을 마지막으로 생성하거나 수정한 트랜잭션 ID
  • DB_ROLL_PTR: Undo Log에 저장된 이전 버전을 가리키는 포인터

즉, DB는 이 ID와 포인터를 이용해 “언제 누가 수정했는지”, “과거 데이터는 어디 있는지”를 추적한다.

실제 동작 과정

  1. 데이터 수정(Update) 발생: 트랜잭션이 시작되고 특정 행(Row)을 업데이트 하면, DB는 기존의 값을 Undo Log라는 별도의 공간에 복사한다. 그리고 실제 데이터 영역(Clustered Index)에는 새로운 값을 기록하고 DB_ROLL_PTR을 이용해 Undo Log의 옛날 값을 가리키게 한다.
  2. 데이터 조회(Read) 요청: 다른 트랜잭션이 해당 데이터를 조회하려고 접근한다.
  3. 버전 확인: DB는 조회하려는 데이터의 DB_TRX_ID를 확인한다. 만약 아직 커밋되지 않은(또는 자신보다 나중에 시작된) 트랜잭션이 건드린 데이터라면, DB_ROLL_PTR을 타고 Undo Log에 있는 이전 버전의 데이터를 가져와서 보여준다.

이 과정을 통해 SELECT 쿼리를 날린 사용자는 잠금 대기 없이, 자신의 트랜잭션 시점에 맞는 일관된 과거 데이터를 읽을 수 있게 된다. 이를 Consistent Non-locking Read라고 부른다.

트랜잭션 격리 수준(Isolation Level)과의 관계

MVCC는 트랜잭션 격리 수준을 구현하는 기반 기술이 된다. 우리가 흔히 사용하는 격리 수준에 따라 MVCC가 참조하는 ‘버전’의 기준이 달라진다.

  • READ COMMITTED
    • 쿼리를 실행할 때마다 새로운 스냅샷(Read View)를 생성한다.
    • 즉, 다른 트랜잭션이 데이터를 수정하고 커밋(Commit)하면, 내 트랜잭션 안에서도 바로 최신 변경 사항이 조회된다.
  • REPEATABLE READ(MySQL 기본값)
    • 트랜잭션이 처음 시작되는 시점에 스냅샷(Read View)를 생성한다.
    • 트랜잭션이 아무리 길어져도, 처음 생성된 스냅샷을 계속 바라보기 때문에 다른 트랜잭션의 변경 사항(심지어 커밋된 내용이라도)이 보이지 않는다. 이를 통해 한 트랜잭션 내에서 동일한 조회 결과를 보장한다.

MySQL vs PostgreSQL 구현 차이

같은 MVCC라도 DBMS 엔진마다 구현 방식에는 차이가 있다.

구분 MySQL(InnoDB) PostgreSQL
데이터 저장 방식 최신 데이터는 데이터 페이지에, 이전 데이터는 Undo Log에 저장 최신/이전 데이터 모두 데이터 페이지(Heap)에 함께 저장
버전 관리 Undo Log 포인터를 통해 과거 버전 추적 Tuple 헤더의 xmin(생성 ID), xmax(삭제 ID)로 버전 관리
구버전 정리 Purge Thread가 주기적으로 Undo Log 삭제 Vacuum 프로세스가 주기적으로 Dead Tuple 정리
장단점 최신 데이터 조회가 빠르지만, 트랜잭션이 길어지면 Undo Log가 급증하여 부하 발생 가능 UPDATE가 사실상 INSERT + DELETE라 쓰기 비용이 높고, Vacuum 튜닝이 필수

블랙박스를 열어본 뒤에

이번에 MVCC 자료를 찾아보고 정리하면서, 그동안 내가 작성했던 수많은 쿼리들이 머릿속을 스쳐 지나갔다.

이전에는 데이터베이스가 마법처럼 동시성을 처리해주는 ‘블랙박스’로만 보였다. 하지만 그 뚜껑을 열어보니, 수많은 트랜잭션의 충돌을 막기 위해 Undo Log를 쌓고, 버전을 관리하고, 부지런히 스냅샷을 찍어내는 과정이 숨어 있었다.

“아는 만큼 보인다”는 말이 딱이다. 이제는 트랜잭션 코드를 짤 때 단순히 “기능이 동작한다”에 그치지 않고, “이 시점에 DB 내부에서는 스냅샷이 생성되겠구나”, “이 긴 처리는 Undo Log를 비대하게 만들겠구나”라는 관점을 하나 더 가지게 될 것 같다.

나처럼 채용 공고를 보다가, 혹은 개발하다가 문득 ‘당연하게 동작하는 것’들에 의문을 품기 시작한 분들에게 이 정리가 작은 도움이 되었으면 좋겠다. 혹시 내가 잘못 이해한 부분이 있다면 언제든 댓글로 알려주길 바란다. 나 또한 배우는 과정에 있으니까.