데이터베이스를 다루는 개발자라면 트랜잭션 격리 수준을 한 번쯤 공부한다. REPEATABLE READ에서는 Phantom Read가 발생할 수 있고, READ COMMITTED에서는 Non-repeatable Read가 발생한다. 이 정도는 많이 알고 있지만 "왜 그런가"를 설명할 수 있는 사람은 훨씬 적다.
격리 수준은 단순한 설정 플래그가 아니다. 그 아래에는 InnoDB가 데이터를 버전 단위로 관리하는 정교한 메커니즘이 있다. 이것이 MVCC(Multi-Version Concurrency Control)다. MVCC를 이해하면 격리 수준의 동작이 "그냥 그렇게 되는 것"이 아니라 필연적인 결과임을 알게 된다. 그리고 READ COMMITTED와 REPEATABLE READ를 언제 선택해야 하는지, 왜 장기 트랜잭션이 위험한지, SELECT FOR UPDATE가 왜 필요한지까지 모두 같은 원리에서 도출된다.
락 기반 동시성 제어의 문제
MVCC를 이해하려면 그 이전 방식의 문제를 먼저 알아야 한다.
초창기 데이터베이스는 동시성 제어를 2PL(Two-Phase Locking)로 구현했다. 데이터를 읽으면 공유 락(Shared Lock)을, 수정하면 배타 락(Exclusive Lock)을 획득한다. 공유 락끼리는 호환되지만, 배타 락은 어떤 락과도 충돌한다. 트랜잭션이 끝날 때까지 획득한 락을 유지한다.
이 모델의 문제는 읽기가 쓰기를 막고, 쓰기가 읽기를 막는다는 것이다. 실제 워크로드에서 읽기는 쓰기보다 압도적으로 많다. 조회 API가 잠깐의 배치 업데이트 때문에 모두 블로킹되는 상황이 반복된다. 처리량(Throughput)이 심각하게 제한된다.
MVCC의 핵심 아이디어는 단순하다. 데이터를 덮어쓰지 않고 버전을 여러 개 유지한다.
쓰기가 발생하면 이전 버전을 별도로 보관하고 새 버전을 추가한다. 읽기는 현재 트랜잭션 시작 시점에 맞는 버전을 찾는다. 읽기는 과거의 스냅샷을 보고, 쓰기는 최신 데이터에 새 버전을 추가하므로 서로 막지 않는다. 이것이 InnoDB의 기본 철학이다.
InnoDB의 숨겨진 Row들
InnoDB의 모든 row에는 사용자가 정의한 컬럼 외에 세 개의 숨겨진 컬럼이 존재한다.
- DB_TRX_ID (6바이트): 이 row를 마지막으로 삽입하거나 수정한 트랜잭션의 ID. 트랜잭션 ID는 전역적으로 단조 증가하는 숫자다. 이 숫자가 클수록 더 최근 트랜잭션이다.
- DB_ROLL_PTR (7바이트): Roll Pointer. 이 row의 이전 버전이 저장된 undo log 레코드를 가리키는 포인터다. 이 포인터를 따라가면 과거 버전의 데이터를 복원할 수 있다.
- DB_ROW_ID (6바이트): 명시적인 기본키가 없을 때 InnoDB가 자동으로 생성하는 내부 row ID다. 기본키가 있으면 이 컬럼은 생성되지 않는다.
이 중 MVCC에서 핵심적인 역할을 하는 것은 DB_TRX_ID와 DB_ROLL_PTR이다.
Undo Log: 버전 체인
row를 수정하면 InnoDB는 다음 순서로 동작한다.
- 현재 row 데이터를 undo log에 기록한다 (이전 버전 보존)
- 현재 row의 DB_ROLL_PTR을 방금 기록한 undo log 레코드를 가리키도록 업데이트한다
- 현재 row의 데이터를 새 값으로 수정하고, DB_TRX_ID를 현재 트랜잭션 ID로 업데이트한다
이 작업이 반복되면 undo log가 체인 구조를 형성한다. 각 버전의 DB_ROLL_PTR이 이전 버전을 가리키는 연결 리스트다.

트랜잭션 150이 balance를 3000으로 바꿨고, 그 전에 트랜잭션 100이 2000으로 바꿨으며, 최초 삽입은 트랜잭션 50이 했다는 이력이 체인으로 남아 있다.
undo log는 두 가지 목적으로 사용된다. 하나는 트랜잭션 롤백 시 이전 상태 복원이고, 다른 하나가 MVCC의 버전 체인이다. 롤백이 필요 없고 그 버전을 참조하는 트랜잭션도 없어진 시점에 비로소 Purge 대상이 된다.
Read View: 어떤 버전까지 볼 수 있는가
버전 체인만으로는 충분하지 않다. 각 트랜잭션이 "어느 버전까지 볼 수 있는가"를 판단하는 기준이 필요하다. 이것이 Read View다.
Read View는 특정 시점의 트랜잭션 상태 스냅샷이다. 네 가지 정보를 담는다.
- m_creator_trx_id: 이 Read View를 생성한 트랜잭션 자신의 ID
- m_up_limit_id: Read View 생성 시점에 활성 중인 트랜잭션 ID 중 가장 작은 값
- m_low_limit_id: Read View 생성 시점에서 다음에 할당될 트랜잭션 ID (현재 최대 ID + 1)
- m_ids: Read View 생성 시점에 아직 커밋되지 않고 활성 중인 트랜잭션 ID 목록
m_creator_trx_id가 왜 필요한지 먼저 짚어보자. 내가 UPDATE를 실행하고 나서 즉시 SELECT를 하면 내 변경이 보여야 한다. 그런데 내가 수정한 row의 DB_TRX_ID는 내 trx_id이고, 나는 아직 커밋하지 않았으므로 m_ids에 포함되어 있다. 가시성 판단 로직만 따르면 내 변경이 안 보인다는 결론이 나온다. m_creator_trx_id는 이 문제를 해결한다. row의 DB_TRX_ID가 나 자신의 트랜잭션 ID이면 항상 보인다.
이제 가시성 판단 전체 로직을 보자.

m_up_limit_id보다 작은 trx_id가 "이미 커밋된" 이유를 짚어보자. trx_id는 단조 증가한다. m_up_limit_id는 Read View 생성 시점에 활성 중인 트랜잭션 중 가장 작은 ID다. 그보다 작은 ID를 가진 트랜잭션은 이미 활성 목록에 없으므로 커밋됐거나 롤백됐다. 롤백된 트랜잭션의 변경은 undo log에 의해 되돌려졌으므로 실질적으로 "커밋된 변경만 보인다"는 보장이 된다.
이 판단에서 "보이지 않는" 버전이 나오면 DB_ROLL_PTR을 따라 이전 버전으로 이동하고, 그 버전에 대해 보이는 버전을 찾거나 체인의 끝(null)에 도달할 때까지 다시 같은 판단을 반복한다.
시나리오로 이해하는 가시성 판단
계좌 테이블의 현재 상태:
id=1, balance=1000, DB_TRX_ID=30
아래 순서로 트랜잭션이 진행된다.
T=1: 트랜잭션 A 시작 (trx_id = 100)
T=2: 트랜잭션 B 시작 (trx_id = 101)
T=3: 트랜잭션 B: UPDATE balance = 2000 (아직 커밋 안 함)
T=4: 트랜잭션 A: UPDATE balance = 1500 (A 자신의 변경)
T=5: 트랜잭션 A: SELECT balance
T=6: 트랜잭션 B: COMMIT
T=7: 트랜잭션 A: SELECT balance (두 번째)
T=4 이후 버전 체인 상태
[현재 row] DB_TRX_ID=100, balance=1500 → (ROLL_PTR)
[undo 버전2] DB_TRX_ID=101, balance=2000 → (ROLL_PTR)
[undo 버전1] DB_TRX_ID=30, balance=1000 → null
T=5에서 트랜잭션 A의 Read View
- m_creator_trx_id = 100 (나 자신)
- m_up_limit_id = 100
- m_low_limit_id = 102
- m_ids = [100, 101]
현재 row의 DB_TRX_ID = 100. 이것은 m_creator_trx_id(100)와 같다. → 항상 보임. balance = 1500.
내가 변경한 것이 즉시 보인다. m_ids 판단을 거치지 않는다.
T=7에서 트랜잭션 A의 Read View (격리 수준에 따라 달라짐 - READ COMMITED vs REPEATABLE READ)
이 시점에서 트랜잭션 B는 이미 커밋됐다.
두 격리 수준의 차이는 Read View를 언제 생성하는가 단 하나다.
트랜잭션 격리 수준이 READ COMMITTED인 경우
READ COMMITTED는 매 SELECT마다 새로운 Read View를 생성한다. T=7 시점에서 B는 이미 커밋됐으므로, 새로 생성된 Read View의 m_ids에는 101이 포함되지 않는다.
Read View (T=7 시점에 새로 생성)
- m_creator_trx_id = 100
- m_up_limit_id = 100
- m_low_limit_id = 102
- m_ids = [100] ← B(101)는 이미 커밋됐으므로 제외
T=7에서 새로 생성된 Read View의 m_ids에는 B(101)가 없다(이미 커밋됐으므로). 현재 row는 DB_TRX_ID=100(A 자신), balance=1500이므로 m_creator_trx_id와 일치해 보인다. 즉 여전히 1500을 읽는다.
그런데 T=4에서 A가 UPDATE를 하지 않았다면 어떻게 됐을까. 현재 row의 DB_TRX_ID는 101이고 balance=2000이다. T=7의 새 Read View에는 101이 m_ids에 없으므로 보인다. T=5에서 1000을 읽었는데 T=7에서 2000이 보인다. 같은 트랜잭션 안에서 같은 쿼리의 결과가 달라진 것이다. 이것이 Non-repeatable Read다.

트랜잭션 격리 수준이 REPEATABLE READ인 경우
REPEATABLE READ는 트랜잭션 시작 시점에 Read View를 한 번만 생성하고 이후 재사용한다. 즉 T=5와 T=7에서 동일한 Read View를 사용한다.
Read View (T=1 시점에 생성, T=7에도 재사용)
- m_creator_trx_id = 100
- m_up_limit_id = 100
- m_low_limit_id = 102
- m_ids = [100, 101] ← B가 커밋됐어도 스냅샷엔 여전히 존재
트랜잭션 내 첫 번째 SELECT 시점에 Read View를 생성하고, 이후 동일한 Read View를 재사용한다.
T=7에서도 T=5에서 만든 Read View를 그대로 사용한다. m_ids에 여전히 101이 있으므로 B의 변경(balance=2000)은 여전히 안 보인다. 트랜잭션이 끝날 때까지 동일한 스냅샷을 보장한다.

결론
| READ COMMITTED | REPEATABLE READ | |
| Read View 생성 시점 | SELECT마다 새로 생성 | 트랜잭션 시작 시 1회 생성 |
| T=7의 m_ids | [100] | [100, 101] (스냅샷 그대로) |
| balance 결과 | 2000 | 1500 |
| 이유 | B 커밋 후 새 Read View | 기존 Read View 재사용 |
InnoDB의 기본 격리 수준이 REPEATABLE READ인 이유가 여기 있다. 트랜잭션 중간에 다른 트랜잭션의 커밋이 보이기 시작하면, 트랜잭션 내에서 일관된 판단을 내리기 어렵다. "방금 읽은 데이터가 아직 유효한가"를 항상 의심해야 한다.
MVCC가 해결하지 못하는 것들
Phantom Read
MVCC 스냅샷은 기존에 존재하던 row의 변경을 일관되게 보여준다. 하지만 새로 삽입된 row는 다른 문제를 일으킨다.
REPEATABLE READ에서 순수한 SELECT만 반복한다면 Phantom Read는 발생하지 않는다. 새 row의 DB_TRX_ID가 m_low_limit_id 이상이거나 m_ids에 있으면 안 보이기 때문이다.
문제는 쓰기 작업이 개입할 때다.
-- 트랜잭션 A (REPEATABLE READ)
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 1;
-- 결과: 3건 (Read View 생성)
-- 트랜잭션 B가 동시에 user_id=1인 row를 INSERT + COMMIT
UPDATE orders SET status = 'processed' WHERE user_id = 1;
-- InnoDB의 쓰기는 MVCC 스냅샷이 아닌 최신 데이터를 본다
-- B가 삽입한 row도 조건에 맞으면 UPDATE된다
SELECT COUNT(*) FROM orders WHERE user_id = 1;
-- 결과: 4건 (UPDATE 후 자신의 변경으로 4번째 row가 보임)
왜 쓰기는 최신 데이터를 봐야 하는가. 만약 쓰기도 스냅샷을 본다면 다른 트랜잭션이 이미 삭제한 row를 모르고 UPDATE할 수 있다. 커밋 충돌 감지가 불가능해진다. 데이터 정합성이 깨진다. 그래서 쓰기는 반드시 최신 버전을 봐야 하고, 이 때문에 MVCC 스냅샷과의 간극이 생긴다.
InnoDB는 이를 Gap Lock과 Next-Key Lock으로 보완한다.
Gap Lock: 존재하지 않는 Row를 조회할 때
WHERE user_id = 15를 조회할 때, user_id=15인 row가 없다고 가정해보자.
InnoDB는 레코드 락 대신에 15가 삽입될 수 있는 구간 (10, 20)에 Gap Lock을 건다. 이 때 다른 트랜잭션이 해당 구간에 INSERT를 시도하면 블로킹 된다.
user_id: ... 10 ... [🔒(10,20) Gap Lock] ... 20 ...
↑
이 구간에 INSERT 시도 → 블로킹
row가 없어도 구간을 잠그는 이유는 Phantom Read 방지다. MVCC 스냅샷은 읽기 일관성을 보장하지만, INSERT는 쓰기 연산이므로 스냅샷을 무시하고 실행된다. Gap Lock이 없으면 다른 트랜잭션이 15를 INSERT·커밋한 뒤, 같은 트랜잭션에서 동일 쿼리를 다시 실행했을 때 갑자기 row가 나타나는 현상이 생긴다.
Next-Key Lock: 실존하는 row가 포함된 범위 조회 시
Next-Key Lock은 Record Lock + Gap Lock의 조합이다. 실존하는 레코드를 스캔할 때 해당 레코드와 그 앞 구간을 함께 잠근다.
단, unique 인덱스로 단일 row를 조회하는 경우엔 Gap Lock 없이 Record Lock만 걸린다.

non-unique 인덱스에서 WHERE user_id = 10이라면 (−∞, 10) Gap Lock + user_id=10 Record Lock이 함께 걸린다.

WHERE user_id BETWEEN 10 AND 20이라면 스캔된 범위 전체에 Next-Key Lock이 걸린다.
레코드 락만으로는 기존 row의 수정·삭제만 막을 수 있고, 구간까지 함께 잠가야 새로운 row의 삽입도 차단되어 Phantom Read를 방지할 수 있다.
Lost Update: MVCC로 막을 수 없는 또 다른 문제
MVCC는 읽기 일관성을 보장하지만, 두 트랜잭션이 동시에 같은 row를 수정할 때 한쪽의 변경이 사라지는 Lost Update는 막지 못한다.
-- 트랜잭션 A -- 트랜잭션 B
BEGIN; BEGIN;
SELECT balance FROM account SELECT balance FROM account
WHERE id=1; -- 1000 WHERE id=1; -- 1000
-- 각자 읽은 값 기반으로 계산
UPDATE account UPDATE account
SET balance = 1000 + 500 SET balance = 1000 + 300
WHERE id=1; WHERE id=1;
COMMIT; COMMIT;
-- A의 +500 변경이 덮어써짐
-- 최종 balance: 1300 (1800이어야 함)
두 트랜잭션 모두 balance=1000을 읽고 각자 계산해서 덮어썼다. A의 +500 변경이 사라졌다. MVCC의 스냅샷은 읽기 일관성만 보장할 뿐, 이런 경쟁 조건(Race Condition)을 막지 않는다.
해결책은 SELECT FOR UPDATE다. 읽는 시점에 배타 락을 획득함으로써 다른 트랜잭션이 같은 row를 읽고 수정하는 것을 막는다.
-- 트랜잭션 A -- 트랜잭션 B
BEGIN; BEGIN;
SELECT balance FROM account
FOR UPDATE; -- 트랜잭션 A가 락을 들고 있으므로
-- balance = 1000 -- 여기서 블로킹됨
UPDATE account
SET balance = 1500
WHERE id=1;
COMMIT;
-- 이제 락 획득
SELECT balance FROM account
FOR UPDATE; -- balance = 1500
UPDATE account
SET balance = 1800
WHERE id=1;
COMMIT;
주의할 점이 있다. SELECT FOR UPDATE는 MVCC 스냅샷을 무시하고 최신 데이터를 읽는다. REPEATABLE READ라도 FOR UPDATE가 붙으면 이미 커밋된 최신 버전을 읽는다. "스냅샷 격리"를 벗어나서 락 기반 직렬화로 전환하는 것이다.
격리 수준 전체 그림
지금까지 READ COMMITTED와 REPEATABLE READ를 중심으로 설명했다. 전체 4단계를 MVCC 관점에서 정리하면:
- READ UNCOMMITTED:
MVCC를 거의 사용하지 않는다. 다른 트랜잭션이 아직 커밋하지 않은 데이터도 직접 읽는다(Dirty Read). undo log를 타지 않고 현재 row를 그대로 반환한다. - READ COMMITTED:
SELECT마다 새 Read View를 생성한다. 커밋된 최신 데이터를 항상 볼 수 있다. Non-repeatable Read 허용. Phantom Read도 발생 가능하다(Read View가 갱신되므로 새로 삽입된 row가 보일 수 있다). - REPEATABLE READ (InnoDB 기본값):
첫 SELECT 시 Read View를 생성하고 트랜잭션 내내 재사용한다. Non-repeatable Read 없음. 순수 SELECT에서는 Phantom Read도 없다. 단 쓰기 개입 시 Phantom Read 가능성이 남아있고, Gap Lock으로 보완한다. Lost Update는 SELECT FOR UPDATE로 별도 처리 필요. - SERIALIZABLE:
InnoDB는 이 격리 수준에서 모든 일반 SELECT를 자동으로 SELECT ... FOR SHARE로 변환한다. 읽기에 공유 락을 걸어, 읽는 동안 해당 row 및 갭을 다른 트랜잭션이 수정하지 못하게 한다. 완전한 직렬화를 보장하지만 동시성이 크게 떨어진다.
| Dirty Read | Non-repeatable Read | Phantom Read |
|
| READ UNCOMMITTED | O | O | O |
| READ COMMITTED | X | O | O |
| REPEATABLE READ | X | X | △ (쓰기 개입 시) |
| SERIALIZABLE | X | X | X |
MVCC와 긴 트랜잭션의 부작용
MVCC가 버전을 유지하려면 undo log를 보존해야 한다. 그 버전을 아직 볼 수 있는 트랜잭션이 하나라도 살아 있는 한 해당 버전은 삭제할 수 없다.
InnoDB의 Purge 스레드는 더 이상 필요 없는 undo log를 정리한다. 그런데 오래된 Read View를 들고 있는 트랜잭션이 있으면, 그 트랜잭션의 m_up_limit_id 이후로 생성된 모든 undo log 버전을 지울 수 없다.

이로 인해 발생하는 문제는 복합적이다.
- 디스크 공간 팽창:
undo tablespace(또는 ibdata1)가 무한정 커진다. MySQL 5.7+에서 undo tablespace가 ibdata1과 분리되어 조금 나아졌지만, 누적 자체는 막을 수 없다. - 버전 체인이 길어질수록 SELECT가 느려진다:
row를 읽을 때 현재 버전이 보이지 않으면 undo log 체인을 거슬러 올라간다. 버전이 수천 개 쌓이면 단순한SELECT *하나가 수천 번의 포인터 추적을 요구한다. 인덱스를 아무리 잘 설계해도 이 비용은 막을 수 없다. - Purge 지연 → 신규 쓰기도 느려진다:
undo 공간이 재활용되지 못하면 새 트랜잭션의 undo log를 쓸 공간이 부족해져 추가 I/O가 발생한다.
이 상태를 확인하는 방법이 있다.
-- History list length 확인: 아직 Purge되지 않은 undo log 레코드 수
SHOW ENGINE INNODB STATUS\G
-- 출력 중 "History list length XXXXX" 부분 확인
-- 수만 이상이고 계속 증가한다면 장기 트랜잭션이 문제
-- 현재 실행 중인 트랜잭션 목록
SELECT
trx_id,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_locked,
trx_rows_modified,
LEFT(trx_query, 100) AS query_preview
FROM information_schema.INNODB_TRX
ORDER BY trx_started ASC;
duration_sec이 수십 초 이상인 트랜잭션이 보인다면 의심해야 한다. 애플리케이션 코드에서 트랜잭션 범위 안에 외부 API 호출, 파일 I/O, 사용자 입력 대기 같은 작업이 포함되지는 않았는지 확인해야 한다.
정리
MVCC를 이해하면 다음 질문들이 모두 하나의 원리에서 답해진다.
"READ COMMITTED와 REPEATABLE READ의 차이는?"
Read View를 쿼리마다 새로 만드는가(RC), 트랜잭션당 한 번만 만드는가(RR)의 차이다.
"왜 트랜잭션을 짧게 유지해야 하는가?"
오래된 Read View를 들고 있으면 그 시점 이후의 모든 undo log 버전을 Purge할 수 없고, 버전 체인이 길어질수록 모든 SELECT가 느려진다.
"SELECT FOR UPDATE는 왜 필요한가?"
MVCC는 읽기 일관성을 보장하지, 경쟁 조건을 막지는 않는다. 읽은 값을 기반으로 쓰기를 해야 할 때는 읽는 시점에 락을 잡아야 Lost Update를 방지할 수 있다.
"Phantom Read는 왜 완전히 막기 어려운가?"
순수 읽기는 MVCC 스냅샷으로 막히지만, 쓰기는 최신 데이터를 보기 때문에 새로 삽입된 row가 개입할 수 있다. InnoDB는 Gap Lock으로 삽입 자체를 차단해 이를 보완한다.
격리 수준을 설정값으로 외우는 것과 MVCC를 이해하고 사용하는 것 사이에는 큰 차이가 있다. 어떤 격리 수준을 선택해야 하는지, 현재 문제의 원인이 격리 수준에 있는지, SELECT FOR UPDATE가 필요한지 — 이 판단들이 MVCC를 이해할 때 비로소 근거를 갖는다.
'데이터베이스 > MySql' 카테고리의 다른 글
| [MySQL] MySQL Primary-Replica: 내부 동작 원리부터 운영까지 (0) | 2026.03.05 |
|---|---|
| [MySQL] Container MySQL Primary-Replica 셋팅 (0) | 2026.02.25 |