본문으로 건너뛰기

1. MVCC(Multi-Version Concurrency Control)

  • MVCC는 MySQL의 InnoDB 스토리지 엔진에서 사용하는 동시성 제어 메커니즘입니다.
  • 여러 트랜잭션이 동시에 데이터베이스에 접근할 때, 각 트랜잭션이 데이터의 특정 버전(스냅샷)을 보게 함으로써 동시성과 격리성을 모두 제공합니다.
  • MVCC의 핵심 아이디어는 데이터를 읽는 트랜잭션이 데이터를 수정하는 트랜잭션을 차단하지 않고, 반대로 수정하는 트랜잭션도 읽는 트랜잭션을 차단하지 않는 것입니다.
  • 여시서 MVCC의 멀티 버전이라 함은 하나의 레코드에 대해 여러 개의 버전이 동시에 관리된다는 의미입니다.
  • MVCC의 가장 큰 목적은 Lock(잠금)을 사용하지 않고 일관된 읽기를 제공하는 것입니다.

1.1 MVCC의 주요 특징

  • 데이터의 여러 버전을 동시에 유지합니다.
  • 각 트랜잭션은 트랜잭션 시작 시점의 일관된 데이터베이스 스냅샷을 볼 수 있습니다.
  • 락(lock)을 최소화하여 동시성을 향상시킵니다.
  • 읽기 작업(SELECT)이 쓰기 작업(INSERT, UPDATE, DELETE)을 차단하지 않습니다.
  • InnoDB에서는 Undo 로그를 활용하여 MVCC를 구현합니다.

1.2 잠금 없는 일관된 읽기

  • InnoDB 스토리지 엔진은 MVCC 기술을 활용하여 읽기 작업 시 잠금을 사용하지 않습니다.
  • SERIALIZABLE 격리 수준을 제외한 모든 격리 수준(READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ)에서 일반 SELECT 쿼리는 다른 트랜잭션이 레코드에 걸어둔 잠금과 관계없이 즉시 읽기가 가능합니다.
  • 다른 트랜잭션이 레코드를 변경하고 아직 커밋하지 않아 잠금 상태인 레코드도, InnoDB는 언두 로그를 활용하여 변경 이전의 데이터 버전을 읽을 수 있습니다.
  • 그러나 이로 인해 장기 실행 트랜잭션이 존재할 경우, 해당 트랜잭션의 일관된 읽기를 위해 언두 로그를 삭제하지 못하고 계속 유지해야 하므로 서버 성능이 저하될 수 있습니다.
  • 따라서 성능 최적화를 위해 트랜잭션은 가능한 빨리 커밋 또는 롤백하여 종료하는 것이 좋습니다.

2. MVCC의 동작 원리

  • 지금부터는 MVCC의 동작 원리를 살펴보겠습니다.

2.1 트랜잭션 ID 활용

  • InnoDB는 각 트랜잭션에 고유한 트랜잭션 ID(TRX_ID)를 부여합니다.
  • 트랜잭션 ID는 트랜잭션이 시작될 때마다 단조 증가하는 값으로 할당됩니다.
  • 데이터 행(row)이 변경될 때마다 해당 행에 현재 트랜잭션의 ID가 기록됩니다.

2.2 행 버전 관리

  • InnoDB는 테이블의 각 행에 대해 시스템 컬럼을 내부적으로 관리합니다
    • 이 컬럼들은 InnoDB 내부에서만 사용되며, 사용자가 직접 조회할 수는 없습니다.
  • DB_TRX_ID: 해당 행을 마지막으로 수정한 트랜잭션의 ID를 저장합니다.
  • DB_ROLL_PTR
    • Undo 로그 레코드를 가리키는 포인터입니다.
    • InnoDB는 DB_ROLL_PTR 포인터를 따라 Undo 로그 체인으로 이동합니다.
    • 이 포인터는 해당 레코드의 이전 버전을 가리킵니다.

2.3 가시성 결정 메커니즘

  • 트랜잭션이 데이터를 읽을 때, InnoDB는 각 행의 DB_TRX_ID를 확인하여 해당 행의 버전이 해당 트랜잭션에 '가시적'인지 결정합니다.
  • 읽기 트랜잭션이 시작할 때, 시스템은 '읽기 뷰(Read View)'라는 구조를 생성합니다.
  • 읽기 뷰는 특정 시점의 데이터베이스 상태를 나타내는 스냅샷으로, 가시성(visibility) 판단에 쓰입니다.
  • 자세한 내용은 아래에서 설명합니다.

2.3.1 읽기 뷰(Read View)

  • 읽기 뷰는 MVCC (Multi-Version Concurrency Control) 를 사용하는 MySQL(InnoDB)에서 트랜잭션 격리 수준을 구현하기 위해 사용됩니다.
  • 읽기 뷰는 특정 시점의 데이터베이스 상태를 나타내는 스냅샷으로, 가시성(visibility) 판단에 쓰입니다.
  • 수정 불가(immutable) 한 데이터 구조입니다.
읽기 뷰의 구조
  • 읽기 뷰 구조체는 다음 정보를 포함합니다.
  • creator_trx_id
    • 읽기 뷰를 생성한 트랜잭션의 ID입니다.
  • trx_ids
    • 읽기 뷰 생성 시점에 아직 커밋되지 않고 활성 상태였던 모든 트랜잭션 ID의 목록입니다.
  • up_limit_id
    • 가장 오래된 활성 트랜잭션 ID입니다.
    • 활성 트랜잭션 목록(trx_ids)에서 가장 작은 트랜잭션 ID입니다.
    • 이 값보다 작은 트랜잭션 ID를 가진 레코드는 모두 이미 커밋되었다고 간주하여 가시적입니다.
  • low_limit_id
    • 읽기 뷰 생성 시점까지 시스템에서 할당한 마지막 트랜잭션 ID에 1을 더한 값입니다.
    • 즉, 이 값은 다음에 할당될 트랜잭션 ID입니다.
    • 이 값 이상의 ID를 가진 트랜잭션은 읽기 뷰 생성 이후에 시작된 것으로 간주하여 비가시적입니다.
격리 수준별 읽기 뷰 생성 시점
  • Read Committed 격리 수준에서는 매 쿼리가 실행될 때 마다 ReadView 를 생성하지만, Repeatable Read 수준에서는 트랜잭션이 실행할 때 한번만 ReadView 를 생성합니다.
예시
  • 현재 상황
    • 트랜잭션 ID 100, 101, 102, 103이 모두 커밋됨
    • 트랜잭션 ID 104, 105는 현재 활성 상태 (커밋되지 않음)
    • 트랜잭션 ID 106이 방금 시작되어 읽기 뷰를 생성합니다.
  • 읽기 뷰 구성
    • creator_trx_id: 106 (읽기 뷰를 생성한 트랜잭션 ID)
    • trx_ids: [104, 105] (현재 활성 상태인 트랜잭션 목록)
    • up_limit_id = 104(활성 트랜잭션 목록에서 가장 작은 트랜잭션 ID)
    • low_limit_id = 107 (다음에 할당될 트랜잭션 ID)

2.3.2 행 가시성 결정 규칙

  • InnoDB가 특정 레코드가 현재 트랜잭션에 "보여야 하는지" 결정할 때는 다음과 같은 알고리즘을 사용합니다.
  • 먼저 레코드의 DB_TRX_ID와 읽기 뷰를 사용하여 레코드의 가시성을 결정합니다.
  • DB_TRX_ID < up_limit_id 인 경우 -> 가시적
    • 이 레코드를 마지막으로 수정한 트랜잭션의 ID가 up_limit_id보다 작습니다.
    • up_limit_id은 읽기 뷰 생성 시점에 만들어진 활성 트랜잭션 목록에서 가장 작은 트랜잭션 ID입니다.
    • 해당 트랜잭션이 현재 읽기 뷰가 생성되기 전에 이미 커밋되었음을 의미합니다.
    • 따라서 이 레코드는 현재 트랜잭션에 "가시적"이며, 현재 버전을 읽을 수 있습니다.
  • DB_TRX_ID >= low_limit_id 인 경우 -> 비가시적
    • low_limit_id는 읽기 뷰가 생성된 시점에서 시스템에서 할당된 마지막 트랜잭션 ID에 1을 더한 값입니다.
    • 이 조건의 의미는 해당 레코드가 읽기 뷰가 생성된 이후에 시작된 트랜잭션에 의해 수정되었음을 의미합니다.
    • 따라서 해당 레코드는 현재 트랜잭션에 "비가시적"이며, Undo 로그에서 이전 버전을 찾아야 합니다.
    • InnoDB는 DB_ROLL_PTR 포인터를 따라 Undo 로그 체인으로 이동합니다. 이 포인터는 해당 레코드의 이전 버전을 가리킵니다.
    • 이후 과정은 가시성 판단을 위해 반복됩니다.
  • up_limit_id <= DB_TRX_ID < low_limit_id 인 경우 -> 조건부 가시성
    • 이는 더 복잡한 케이스로, 해당 트랜잭션이 현재 읽기 뷰 생성 시점에 활성화되어 있었을 수도, 이미 커밋되었을 수도 있습니다.
    • DB_TRX_ID가 trx_ids 목록에 있는 경우
      • 해당 트랜잭션은 읽기 뷰 생성 시점에 활성 상태였고 아직 커밋되지 않았을 수 있습니다.
      • 따라서 이 레코드는 "비가시적"이며, Undo 로그에서 이전 버전을 찾아야 합니다.
    • DB_TRX_ID가 trx_ids 목록에 없는 경우
      • 해당 트랜잭션은 읽기 뷰 생성 시점 전에 이미 커밋되었습니다.
      • 따라서 이 레코드는 "가시적"이며, 현재 버전을 읽을 수 있습니다.

2.3.3 MVCC 가시성 판단 및 레코드 탐색 예시

  • 초기 상황:
    • 트랜잭션 ID 50: 이미 커밋됨
    • 트랜잭션 ID 51: 활성 상태 (아직 커밋되지 않음)
    • 트랜잭션 ID 52: 방금 시작되어 읽기 뷰 생성
    • 다음에 할당될 트랜잭션 ID: 53
  • 트랜잭션 52의 읽기 뷰 구성:
    • creator_trx_id: 52
    • trx_ids: [51]
    • up_limit_id: 51
    • low_limit_id: 53
  • 데이터 상황:
    • 사용자 테이블에 "user_id=1"인 행이 있고, 이름 필드가 여러 트랜잭션에 의해 수정되었다고 가정합니다.
현재 DB의 행 데이터:
- DB_TRX_ID: 54 (가장 최근에 수정한 트랜잭션 ID)
- 이름: "Charlie"
- DB_ROLL_PTR: → Undo 로그 #1

Undo 로그 체인:

Undo 로그 #1:
- DB_TRX_ID: 51
- 이름: "Bob"
- DB_ROLL_PTR: → Undo 로그 #2

Undo 로그 #2:
- DB_TRX_ID: 50
- 이름: "Alice"
- DB_ROLL_PTR: → Undo 로그 #3

Undo 로그 #3:
- DB_TRX_ID: 45
- 이름: "John"
- DB_ROLL_PTR: null (더 이상 이전 버전 없음)

이제 트랜잭션 52가 "SELECT 이름 FROM 사용자 WHERE user_id=1"을 실행할 때:

  1. 현재 행 데이터 확인:
    • DB_TRX_ID = 54
    • 가시성 판단: 54 >= 53 (low_limit_id)이므로 비가시적
    • 결론: 읽기 뷰 생성 후 변경됨, DB_ROLL_PTR을 따라 이전 버전으로 이동
  2. Undo 로그 #1로 이동:
    • DB_TRX_ID = 51
    • 가시성 판단: 51 >= 51 && 51 < 53이고, 51은 trx_ids에 있음
    • 결론: 아직 커밋되지 않은 트랜잭션의 변경, 비가시적, 다시 DB_ROLL_PTR 따라감
  3. Undo 로그 #2로 이동:
    • DB_TRX_ID = 50
    • 가시성 판단: 50 < 51 (up_limit_id)
    • 결론: 가시적! 이미 커밋된 트랜잭션의 변경
  4. 최종 결과:
    • 트랜잭션 52는 "Alice"라는 이름을 읽음
    • 더 이상 Undo 로그 체인을 탐색할 필요 없음

3. Undo 로그와 MVCC

3.1 Undo 로그의 역할

  • Undo 로그는 데이터 변경 이전의 상태를 저장하는 로그입니다.
  • MVCC에서 두 가지 핵심 기능을 제공합니다:
    • 트랜잭션 롤백: 트랜잭션이 실패하거나 명시적 롤백 시 데이터를 이전 상태로 복원합니다.
      • 일관된 읽기: 다른 트랜잭션에서 변경한 데이터에 대해 이전 버전을 제공합니다.

3.2 Undo 로그 레코드의 연결 구조

  • 하나의 행이 여러 번 수정될 때, Undo 로그 레코드는 링크드 리스트 형태로 연결됩니다.
  • 각 Undo 로그 레코드는 이전 버전의 Undo 로그 레코드를 가리키는 포인터를 포함합니다.
  • 이 연결 구조를 통해 InnoDB는 필요한 경우 데이터의 모든 이전 버전에 접근할 수 있습니다.

4. 트랜잭션 격리 수준과 MVCC

4.1 READ UNCOMMITTED

  • 가장 낮은 격리 수준으로, MVCC를 사용하지 않습니다.
  • 트랜잭션은 다른 트랜잭션이 커밋하지 않은 변경사항(더티 리드)도 볼 수 있습니다.
  • 성능은 가장 좋지만, 데이터 일관성은 보장되지 않습니다.

4.2 READ COMMITTED

  • 각 쿼리 실행 시마다 새로운 읽기 뷰(Read View)를 생성합니다.
  • 커밋된 데이터만 볼 수 있어 더티 리드는 방지됩니다.
  • 하나의 트랜잭션 내에서도 다른 트랜잭션이 커밋한 변경사항이 보일 수 있어 반복 읽기 불일치(non-repeatable read) 문제가 발생할 수 있습니다.

4.3 REPEATABLE READ

  • MySQL InnoDB의 기본 격리 수준입니다.
  • 트랜잭션 시작 시 읽기 뷰를 생성하고, 트랜잭션 종료까지 이 읽기 뷰를 유지합니다.
  • 트랜잭션 내에서 일관된 데이터 스냅샷을 제공하여 반복 읽기 불일치를 방지합니다.
  • 팬텀 리드(phantom read)는 특정 상황에서 발생할 수 있지만, InnoDB는 갭 락(gap lock)과 넥스트 키 락(next-key lock)을 사용하여 이를 대부분 방지합니다.

4.4 SERIALIZABLE

  • 가장 높은 격리 수준으로, 완전한 데이터 일관성을 제공합니다.
  • SELECT 문에 자동으로 FOR SHARE 옵션을 적용하여 모든 읽기에 공유 락을 설정합니다.
  • 팬텀 리드를 완전히 방지하지만, 동시성이 크게 저하됩니다.
  • SERIALIZABLE의 핵심은 모든 SELECT 쿼리가 자동으로 공유 락을 획득한다는 점입니다.
  • 이로 인해 읽기 작업도 쓰기 작업을 차단할 수 있고(반대도 마찬가지), 결과적으로 완전한 직렬화 가능성(serializability)을 보장합니다.
  • 그러나 이는 동시성을 크게 저하시키는 원인이 됩니다.

5. Undo 로그 관리와 퍼지(Purge)

5.1 Undo 로그 보존 기간

  • 트랜잭션이 커밋된 후에도 Undo 로그는 즉시 삭제되지 않습니다.
  • 다른 활성 트랜잭션이 여전히 해당 Undo 로그의 이전 버전 데이터를 필요로 할 수 있기 때문입니다.
  • MySQL은 'History List Length'라는 지표로 아직 정리되지 않은 Undo 로그 페이지 수를 추적합니다.

5.2 퍼지 작업(Purge Operation)

  • 퍼지 스레드는 더 이상 필요 없는 Undo 로그 레코드를 정리하는 백그라운드 프로세스입니다.
  • 특정 Undo 로그 레코드가 다음 조건을 모두 충족할 때 정리 대상이 됩니다:
    • 해당 트랜잭션이 커밋되었을 것
    • 어떤 활성 트랜잭션도 해당 레코드의 이전 버전을 필요로 하지 않을 것
  • 퍼지 스레드의 동작은 innodb_purge_threads 파라미터로 조정 가능합니다.

6. MVCC와 성능 영향

6.1 장점

  • 읽기 작업과 쓰기 작업 간의 차단을 최소화하여 동시성을 향상시킵니다.
  • 락 경합을 줄여 전반적인 시스템 처리량을 증가시킵니다.
  • 트랜잭션 격리성을 유지하면서도 높은 동시성을 제공합니다.

6.2 단점과 주의사항

  • Undo 로그가 계속 증가하면 디스크 공간을 많이 차지할 수 있습니다.
  • 오래 실행되는 트랜잭션은 Undo 로그 정리를 지연시켜 시스템 성능에 영향을 줄 수 있습니다.
  • 복잡한 조인이나 서브쿼리를 포함하는 대규모 트랜잭션은 메모리 사용량을 증가시킬 수 있습니다.

6.3 성능 최적화 팁

  • 트랜잭션을 가능한 한 짧게 유지하여 Undo 로그 부담을 줄입니다.
  • 불필요하게 높은 격리 수준을 사용하지 않습니다. 대부분의 경우 REPEATABLE READ가 적절한 균형을 제공합니다.
  • SHOW ENGINE INNODB STATUS로 Undo 로그 상태를 주기적으로 모니터링합니다.
  • History List Length가 지속적으로 증가하면 오래 실행되는 트랜잭션이 있는지 확인합니다.
  • information_schema.INNODB_TRX 테이블을 쿼리하여 오래 실행 중인 트랜잭션을 식별합니다.

7. 실제 사례로 보는 MVCC와 Undo 로그

7.1 기본 예제: 동시 트랜잭션에서 MVCC 동작

다음은 두 개의 트랜잭션이 동시에 실행될 때 MVCC가 어떻게 동작하는지 보여주는 간단한 예제입니다:

초기 데이터

CREATE TABLE accounts
(
id INT PRIMARY KEY,
name VARCHAR(100),
balance DECIMAL(10, 2)
);

INSERT INTO accounts
VALUES (1, 'Alice', 1000.00);

트랜잭션 A (시작 시간: T1)

START TRANSACTION;
-- balance 조회
SELECT balance
FROM accounts
WHERE id = 1;
-- 결과: 1000.00
-- 다른 작업 수행 중...

트랜잭션 B (시작 시간: T2)

START TRANSACTION;
-- Alice의 계좌에서 200.00 출금
UPDATE accounts
SET balance = balance - 200.00
WHERE id = 1;
COMMIT;

트랜잭션 A 계속 (시간: T3)

-- balance 다시 조회
SELECT balance
FROM accounts
WHERE id = 1; -- 결과: 여전히 1000.00
COMMIT;
  • 트랜잭션 A는 시작할 때 읽기 뷰를 생성합니다.
  • 트랜잭션 B가 Alice의 잔액을 업데이트하고 커밋해도, 트랜잭션 A의 읽기 뷰는 변경되지 않습니다.
  • 따라서 트랜잭션 A는 여전히 초기 잔액인 1000.00을 조회합니다.
  • 이것이 REPEATABLE READ 격리 수준에서 MVCC가 동작하는 방식입니다.

7.2 Undo 로그 동작 예시

위 예제에서 UPDATE 문이 실행될 때 다음과 같은 Undo 로그 작업이 발생합니다:

  1. 트랜잭션 B가 UPDATE를 실행합니다.
  2. InnoDB는 현재 데이터를 Undo 로그에 저장합니다:
    • 이전 값: balance = 1000.00
    • 트랜잭션 ID: T1보다 작은 값(이전 트랜잭션)
    • 롤백 포인터: 이전 변경 지점(없을 경우 NULL)
  3. 테이블의 실제 행을 업데이트합니다:
    • 새 값: balance = 800.00
    • 트랜잭션 ID: T2
    • 롤백 포인터: 방금 생성한 Undo 로그 레코드를 가리킴
  4. 트랜잭션 A가 동일한 행을 읽으려고 할 때, MVCC는 다음을 확인합니다:
    • 현재 행의 트랜잭션 ID(T2)가 트랜잭션 A의 읽기 뷰에 가시적인지 확인
    • T2는 트랜잭션 A가 시작된 후에 생성되었으므로 가시적이지 않음
    • 롤백 포인터를 따라 Undo 로그에서 이전 버전(balance = 1000.00)을 찾아 반환

8. 결론

  • MVCC는 MySQL InnoDB의 강력한 기능으로, 동시성과 데이터 일관성 사이의 균형을 제공합니다.
  • Undo 로그는 MVCC의 핵심 구성 요소로, 데이터의 이전 버전을 저장하고 트랜잭션 롤백을 지원합니다.
  • 트랜잭션 격리 수준에 따라 MVCC의 동작이 달라지며, 적절한 격리 수준 선택이 중요합니다.
  • 효과적인 Undo 로그 관리는 MySQL 성능 최적화의 중요한 부분입니다.
  • 트랜잭션을 짧게 유지하고, Undo 로그 상태를 모니터링하면 MVCC 관련 성능 이슈를 최소화할 수 있습니다.