유성

데이터베이스 격리 수준과 동시성 제어의 실체 본문

Database

데이터베이스 격리 수준과 동시성 제어의 실체

백엔드 유성 2025. 12. 15. 17:36

REPEATABLE READ는 어떻게 구현되는가?

부제: MySQL InnoDB의 MVCC와 Undo Log 해부

 

트랜잭션 격리 수준은 데이터베이스의 동시성 처리 성능과 데이터 무결성 사이의 트레이드오프를 결정하는 핵심 설정이다.

 

우리가 흔히 사용하는 RDBMS는 기본적으로 Read Committed 혹은 Repeatable Read 수준에서 동작한다.

격리 수준을 높이기 위해서는 더 강력한 Lock이 필요하지만 모든 명령어를 직렬화 하는데에는 무리가 있다.

 

대표적으로 MySQL의 대표적인 엔진 InnoDB를 살펴보면, SELECT 쿼리를 날릴 때 Lock을 전혀 사용하지 않는다.

하지만 락을 걸지 않는데 어떻게 다른 트랜잭션이 커밋한 데이터를 무시하고, 내 트랜잭션 시작 시점의 데이터를 계속 보여줄 수 있을까?

 

이 글에서는 MySQL이 MVCC(Multi-Version Concurrency Control)를 통해 어떻게 락 없는 일관된 읽기를 구현했는지, 그리고 그 구조적 한계는 무엇인지 파고들어 본다.

 

데이터베이스의 딜레마와 MVCC

데이터베이스는 두 마리 토끼를 잡아야 한다.

  1. 일관성(Consistency): 트랜잭션이 진행되는 동안 데이터가 바뀌면 안 된다.
  2. 동시성(Concurrency): 그렇다고 읽기 작업이 쓰기 작업을 막거나, 쓰기가 읽기를 막아서는 안 된다.

과거의 방식인 Lock Based 방식이라면, 누군가 데이터를 수정 중일 때 해당 데이터를 읽으려는 트랜잭션은 대기해야 했다.

이는 동시성 저하로 직결된다.

 

이 문제를 해결하기 위해 등장한 개념이 MVCC(다중 버전 동시성 제어)다.

핵심 아이디어는 간단하다.

MVCC 핵심 아이디어:
데이터를 덮어쓰지 말고, 변경 전의 데이터를 어딘가에 따로 보관하자

 

즉, 하나의 데이터에 대해 여러 버전(History)을 관리함으로써, 읽는 쪽은 '과거의 버전'을 읽고, 쓰는 쪽은 '새로운 버전'을 쓰게 하여 서로 락 없이 동작하게 만드는 것이다.

 

Undo Log와 Hidden Columns : 물리적 구현

MVCC가 "여러 버전을 관리한다"는 말은 추상적이다.

실제 InnoDB 스토리지 엔진 레벨에서는 이 버전 관리를 위해 테이블 스키마에 보이지 않는 숨겨진 컬럼(Hidden Columns)Undo Log를 사용한다.

 

InnoDB의 모든 Row에는 우리가 정의하지 않은 두 가지 핵심 필드가 숨어 있다.

  1. DB_TRX_ID (6 byte): 이 레코드를 마지막으로 수정(Insert/Update/Delete)한 트랜잭션의 ID.
  2. DB_ROLL_PTR (7 byte): 수정되기 이전의 내용을 담고 있는 Undo Log를 가리키는 포인터.

업데이트의 실제 동작 과정

쿼리로 UPDATE member SET name = 'B' WHERE id = 1을 실행했다고 가정해보자. (기존 이름은 'A')

  1. Exclusive Lock 획득: 해당 Row에 대해 쓰기 잠금을 건다.
  2. Undo Log 생성: 기존 값('A')을 Undo Log 영역으로 복사한다.
  3. 레코드 수정: 실제 데이터 페이지의 값을 'B'로 변경한다.
  4. 헤더 갱신:
    • DB_TRX_ID를 현재 트랜잭션 ID로 변경한다.
    • DB_ROLL_PTR가 방금 생성한 Undo Log('A')를 가리키게 한다.

결과적으로 데이터 페이지에는 '최신 값(B)'이 존재하고, Undo Log에는 '이전 값(A)'이 존재하며 이들이 포인터로 연결된 Linked List 형태를 띠게 된다.

Read View : 어떤 데이터를 보여줄 것인가?

데이터가 링크드 리스트로 연결되어 있다면, SELECT를 수행하는 트랜잭션은 이 중 어떤 버전을 읽어야 할까?

이 판단을 내리기 위해 InnoDB는 Read View라는 구조체를 사용한다.

정의: 트랜잭션이 시작되는 시점에 "현재 활동 중인(Active) 트랜잭션 목록"을 스냅샷 떠놓은 것.

 

Read View는 다음과 같은 핵심 정보를 가진다.

  • min_trx_id: 스냅샷 생성 시점에 가장 오래된 활성 트랜잭션 ID
  • max_trx_id: 스냅샷 생성 시점에 할당 예정인 트랜잭션 ID (가장 큰 ID + 1)
  • m_ids: 현재 커밋되지 않고 활동 중인 트랜잭션 ID들의 목록

이제 SELECT를 수행하면, 데이터의 DB_TRX_ID를 보고 가시성(Visibility)을 판단한다. 알고리즘은 다음과 유사하다.

// 가시성 판단 알고리즘 의사 코드 (Simplified)
bool is_visible(row_trx_id, read_view) {
    // 1. 내 트랜잭션이 변경한 거라면 무조건 보임
    if (row_trx_id == my_trx_id) return true;

    // 2. Read View 생성 전에 이미 커밋된 트랜잭션이라면 보임 (과거 데이터)
    if (row_trx_id < read_view.min_trx_id) return true;

    // 3. Read View 생성 이후에 시작된 트랜잭션이라면 안 보임 (미래 데이터)
    if (row_trx_id >= read_view.max_trx_id) return false;

    // 4. Read View 생성 시점에 아직 활동 중이었던(미커밋) 트랜잭션이라면 안 보임
    if (read_view.m_ids.contains(row_trx_id)) return false;

    return true; // 그 외의 경우 보임
}

 

만약 is_visible이 false라면? 데이터 페이지에 있는 최신 값을 읽지 않고, DB_ROLL_PTR를 따라 Undo Log를 역추적(Traverse)한다.

 

is_visible이 true가 되는 버전이 나올 때까지 과거로 거슬러 올라가는 것이다.

이것이 REPEATABLE READ가 동작하는 진짜 원리다.

 

다른 트랜잭션이 아무리 데이터를 수정하고 커밋해도, 내 Read View 기준에서는 "미래의 일"이거나 "아직 활동 중인 일"로 간주되어 Undo Log의 과거 데이터를 읽게 된다.

Phantom Read와 Gap Lock의 존재 이유

MVCC는 완벽해 보이지만, "유령 읽기(Phantom Read)"라는 고질적인 문제 앞에서는 취약점이 있다.

Phantom Read 시나리오:

  1. 트랜잭션 A: SELECT count(*) FROM members WHERE age > 20; (결과: 0건)
  2. 트랜잭션 B: INSERT INTO members (age) VALUES (25); 후 커밋.
  3. 트랜잭션 A: SELECT count(*) ... (결과: 0건 - MVCC 덕분에 일관성 유지됨)
  4. 문제 발생: 트랜잭션 A가 UPDATE members SET level='VIP' WHERE age > 20; 을 실행하면?

MVCC는 "읽기(Select)"에 대해서만 과거 버전을 보여준다. 하지만 UPDATE, DELETE 같은 쓰기 작업은 반드시 물리적으로 존재하는 최신 데이터를 대상으로 수행되어야 한다.

(과거의 Undo Log에 업데이트를 할 수는 없으니까.)

 

따라서 4번 과정에서 트랜잭션 A는 트랜잭션 B가 방금 넣은 레코드(age 25)를 발견하고 업데이트를 수행해버린다. 그 후 다시 SELECT를 하면, 분명 아까는 없었던 데이터가 툭 튀어나오게 된다(Phantom Read).

 

이 문제를 막기 위해 MySQL은 격리 수준이 REPEATABLE READ일 때, Gap Lock(간격 잠금)과 Next-Key Lock을 사용한다. SELECT ... FOR UPDATE 처럼 쓰기 의도가 있는 읽기를 수행하거나 실제 변경을 할 때, 단순히 해당 레코드만 잠그는 것이 아니라 레코드와 레코드 사이의 간격(Gap)까지 잠가버린다.

 

이렇게 하면 트랜잭션 B가 데이터를 INSERT 하려고 할 때, 그 "간격"이 잠겨 있어 대기하게 되고, Phantom Read가 원천적으로 차단된다.

 

JDBC나 JPA를 사용하는 경우 @Lock을 참고하자.

public enum LockModeType {
    READ,
    WRITE,
    OPTIMISTIC,
    OPTIMISTIC_FORCE_INCREMENT,
    PESSIMISTIC_READ,
    PESSIMISTIC_WRITE,
    PESSIMISTIC_FORCE_INCREMENT,
    NONE;
}

 

MVCC의 구조적 비용과 정리

세상에 공짜는 없다. MVCC와 Undo Log 구조는 훌륭한 동시성을 제공하지만 명확한 비용을 요구한다.

  1. Undo Log 비대화: 트랜잭션이 길어지면(Long Transaction), 다른 트랜잭션이 참조해야 할 수도 있는 Undo Log를 삭제(Purge)하지 못하고 계속 쌓아둬야 한다. 이는 디스크 공간 낭비뿐만 아니라, 특정 쿼리의 성능을 급격히 떨어뜨린다. (원하는 버전을 찾을 때까지 Undo Log 체인을 너무 많이 거슬러 올라가야 하기 때문)
  2. 추가적인 쓰기 오버헤드: 단순 읽기 작업은 빠르지만, 데이터 변경 시에는 Undo Log 기록, 포인터 갱신 등 추가적인 작업이 수반된다.

정리하면, REPEATABLE READ는 단순히 "트랜잭션 시작 시점의 스냅샷을 뜬다"는 추상적인 개념이 아니다.

 

Row마다 붙어 있는 트랜잭션 IDUndo Log라는 연결 리스트, 그리고 Read View라는 가시성 판단 알고리즘이 정교하게 맞물려 돌아가는 메커니즘이다.

개발자가 이 원리를 이해해야 하는 이유는 명확하다. 왜 긴 트랜잭션(Long Transaction)이 DB 성능의 주범이 되는지, 왜 락을 걸지 않았는데도 데드락(Deadlock)과 유사한 대기 현상이 발생하는지, 이 로우 레벨의 동작 방식을 알아야만 비로소 보이기 때문이다.

 

추가로, Undo Log는 JVM의 GC와 같이 개별 스레드로 정리가 된다.

다만, 너무 많은 Undo Log는 Stop-The-World와 비슷한 현상을 유발할 수 있다.