Theme:

한 트랜잭션이 데이터를 수정하는 동안, 다른 트랜잭션이 같은 데이터를 읽으면 어떤 값을 보게 될까요?

동시에 여러 트랜잭션이 실행되는 환경에서, 읽기와 쓰기가 서로를 차단하면 성능이 크게 떨어집니다. InnoDB의 MVCC(Multi-Version Concurrency Control) 는 데이터의 여러 버전을 유지하여 이 문제를 해결합니다. 읽기는 락 없이 처리하면서도 일관성을 보장하는 핵심 메커니즘입니다.

개념 정의

MVCC는 데이터를 변경할 때 이전 버전을 Undo Log에 보관하여, 각 트랜잭션이 자신에게 맞는 버전을 읽을 수 있게 하는 동시성 제어 방식입니다.

PLAINTEXT
트랜잭션 A: UPDATE users SET name='김영희' WHERE id=1;  (name='김철수' → '김영희')
트랜잭션 B: SELECT name FROM users WHERE id=1;  ← 어떤 값을 보는가?

MVCC 없이: B가 A의 커밋을 기다려야 함 (읽기 차단)
MVCC 있을 때: B가 이전 버전('김철수')을 Undo Log에서 읽음 (읽기 비차단)

왜 필요한가

락 기반 동시성 제어의 한계:

PLAINTEXT
락 기반:
  읽기-읽기: 동시 가능
  읽기-쓰기: 차단 (읽기가 쓰기를 기다리거나, 쓰기가 읽기를 기다림)
  쓰기-쓰기: 차단

MVCC:
  읽기-읽기: 동시 가능
  읽기-쓰기: 동시 가능 (읽기는 이전 버전 참조)
  쓰기-쓰기: 차단 (여전히 락 필요)

읽기가 쓰기를 차단하지 않고, 쓰기가 읽기를 차단하지 않습니다. 이것이 MVCC의 핵심 이점입니다.

InnoDB의 MVCC 구현

행의 숨겨진 컬럼

InnoDB의 모든 행에는 사용자가 보이지 않는 3개의 숨겨진 컬럼이 있습니다.

컬럼크기설명
DB_TRX_ID6바이트이 행을 마지막으로 수정한 트랜잭션 ID
DB_ROLL_PTR7바이트Undo Log에서 이전 버전을 가리키는 포인터
DB_ROW_ID6바이트행 ID (PK가 없을 때 자동 생성)
PLAINTEXT
실제 행 구조:
┌──────────┬──────────────┬──────────┬─────┬──────┬───────┐
│ DB_TRX_ID│ DB_ROLL_PTR  │ DB_ROW_ID│ id  │ name │ age   │
│ (trx 10) │ (→ undo log) │          │  1  │ 김영희│  28   │
└──────────┴──────────────┴──────────┴─────┴──────┴───────┘

Undo Log 기반 버전 체인

데이터가 변경되면 이전 버전이 Undo Log에 저장되고, 체인으로 연결됩니다.

SQL
-- 초기 상태 (trx 5에서 INSERT)
-- id=1, name='김철수', trx_id=5

-- trx 10: UPDATE users SET name='김영희' WHERE id=1;
-- trx 15: UPDATE users SET name='이수진' WHERE id=1;
PLAINTEXT
현재 행 (최신 버전):
  name='이수진', trx_id=15, roll_ptr → Undo Log [2]

Undo Log [2]:
  name='김영희', trx_id=10, roll_ptr → Undo Log [1]

Undo Log [1]:
  name='김철수', trx_id=5, roll_ptr → NULL (최초 버전)

버전 체인:

PLAINTEXT
이수진(trx 15) → 김영희(trx 10) → 김철수(trx 5)

Read View — 가시성 판단

Read View는 트랜잭션이 어떤 버전의 데이터를 볼 수 있는지 결정하는 스냅샷입니다.

Read View의 구성 요소

PLAINTEXT
Read View 생성 시점에 기록되는 정보:
- m_ids: 현재 활성(커밋되지 않은) 트랜잭션 ID 목록
- m_low_limit_id: 아직 할당되지 않은 가장 작은 트랜잭션 ID (이것 이상은 미래)
- m_up_limit_id: m_ids에서 가장 작은 트랜잭션 ID
- m_creator_trx_id: 이 Read View를 생성한 트랜잭션 ID

가시성 판단 알고리즘

행의 DB_TRX_IDtrx_id일 때:

PLAINTEXT
1. trx_id < m_up_limit_id
   → 이 버전은 Read View 생성 전에 커밋됨 → 보임

2. trx_id >= m_low_limit_id
   → 이 버전은 Read View 생성 후에 시작됨 → 안 보임

3. m_up_limit_id <= trx_id < m_low_limit_id
   → m_ids에 trx_id가 있으면: 아직 커밋 안 됨 → 안 보임
   → m_ids에 trx_id가 없으면: 커밋 완료됨 → 보임

4. trx_id == m_creator_trx_id
   → 자기 자신의 변경 → 보임

구체적 예시

PLAINTEXT
시간 순서:
  trx 5: INSERT INTO users VALUES (1, '김철수');  COMMIT;
  trx 10: BEGIN;
  trx 12: BEGIN;
  trx 10: UPDATE users SET name='김영희' WHERE id=1;
  trx 12: SELECT name FROM users WHERE id=1;  ← 여기서 Read View 생성

trx 12의 Read View:
  m_ids = [10]          (trx 10이 아직 활성)
  m_up_limit_id = 10
  m_low_limit_id = 13   (다음 할당될 trx id)
  m_creator_trx_id = 12

행의 현재 버전: name='김영희', trx_id=10

판단:
  trx_id(10) >= m_up_limit_id(10) → 조건 3으로
  m_ids에 10이 있는가? → 있음 → 안 보임!
  → Undo Log에서 이전 버전으로 이동

이전 버전: name='김철수', trx_id=5
  trx_id(5) < m_up_limit_id(10) → 보임!
  → 결과: '김철수'

격리 수준별 Read View 동작

READ COMMITTED

매 SELECT마다 새로운 Read View를 생성합니다.

SQL
-- Session A
BEGIN;
UPDATE users SET name = '김영희' WHERE id = 1;

-- Session B (READ COMMITTED)
BEGIN;
SELECT name FROM users WHERE id = 1;  -- Read View 생성 → '김철수' (A 미커밋)

-- Session A
COMMIT;

-- Session B
SELECT name FROM users WHERE id = 1;  -- 새 Read View 생성 → '김영희' (A 커밋됨)
-- 같은 트랜잭션 내에서 결과가 달라짐! (Non-Repeatable Read)

REPEATABLE READ (InnoDB 기본값)

트랜잭션의 첫 SELECT에서만 Read View를 생성하고, 끝까지 유지합니다.

SQL
-- Session A
BEGIN;
UPDATE users SET name = '김영희' WHERE id = 1;

-- Session B (REPEATABLE READ)
BEGIN;
SELECT name FROM users WHERE id = 1;  -- Read View 생성 → '김철수'

-- Session A
COMMIT;

-- Session B
SELECT name FROM users WHERE id = 1;  -- 기존 Read View 재사용 → '김철수'
-- 같은 트랜잭션 내에서 결과가 항상 동일! (Repeatable Read 보장)
COMMIT;

비교 정리

READ COMMITTEDREPEATABLE READ
Read View 생성매 SELECT마다첫 SELECT 시 1회
Non-Repeatable Read발생 가능발생하지 않음
Phantom Read발생 가능InnoDB에서는 MVCC로 방지
용도최신 데이터 필요 시일관된 읽기 필요 시

Consistent Read (일관된 읽기)

MVCC를 통한 읽기를 Consistent Read(일관된 읽기) 라 합니다.

SQL
-- 일반 SELECT는 Consistent Read (락 없음)
SELECT * FROM users WHERE id = 1;

-- 락을 거는 읽기 (MVCC가 아닌 현재 버전 읽기)
SELECT * FROM users WHERE id = 1 FOR SHARE;      -- 공유 락
SELECT * FROM users WHERE id = 1 FOR UPDATE;     -- 배타 락

FOR SHARE/FOR UPDATE를 사용하면 MVCC가 아닌 현재 최신 버전을 읽으며, 해당 행에 락을 겁니다.

장기 트랜잭션의 위험

MVCC에서 장기 실행 트랜잭션은 심각한 문제를 일으킬 수 있습니다.

PLAINTEXT
트랜잭션 A: BEGIN; (2시간 전에 시작, 커밋 안 함)
  → A의 Read View가 참조할 수 있는 모든 이전 버전을 유지해야 함
  → 2시간 동안의 모든 UPDATE/DELETE의 Undo Log가 purge 불가
  → Undo Log 크기 급증 → 디스크 공간 부족, 성능 저하
SQL
-- 장기 트랜잭션 확인
SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds
FROM information_schema.INNODB_TRX
ORDER BY trx_started;

-- Undo Log 크기 확인
SHOW ENGINE INNODB STATUS\G
-- History list length: 이 값이 계속 증가하면 purge가 밀리고 있는 것

예방책:

  • 트랜잭션을 가능한 짧게 유지합니다
  • wait_timeout, interactive_timeout을 적절히 설정합니다
  • 모니터링으로 장기 트랜잭션을 조기 감지합니다

UPDATE/DELETE에서의 MVCC

UPDATE는 다음 순서로 처리됩니다.

PLAINTEXT
1. 해당 행에 배타 락(X Lock) 설정
2. 현재 버전을 Undo Log에 복사
3. 행의 데이터를 새 값으로 변경
4. DB_TRX_ID를 현재 트랜잭션 ID로 업데이트
5. DB_ROLL_PTR이 Undo Log의 이전 버전을 가리키도록 설정

DELETE는 실제 삭제가 아닌 삭제 플래그(delete mark) 를 설정합니다. 실제 물리적 삭제는 purge 스레드가 나중에 처리합니다.

정리

  • MVCC는 Undo Log에 이전 버전을 보관하여, 읽기와 쓰기가 서로를 차단하지 않게 합니다
  • Read View가 트랜잭션의 가시성 범위를 결정합니다
  • READ COMMITTED는 매 SELECT마다 Read View를 생성하고, REPEATABLE READ는 첫 SELECT에서만 생성합니다
  • 일반 SELECT는 Consistent Read(락 없음)이고, FOR UPDATE/FOR SHARE는 현재 버전을 읽으며 락을 겁니다
  • 장기 트랜잭션은 Undo Log purge를 막아 성능 저하를 유발하므로, 가능한 짧게 유지해야 합니다
댓글 로딩 중...