MVCC와 InnoDB 잠금 정리: 일관된 읽기, 갭 락, 넥스트 키 락, 팬텀 리드
InnoDB는 읽기는 MVCC(스냅숏)로, 쓰기는 잠금으로 동시성을 처리한다. 평범한 SELECT는 언두 로그로 과거 버전을 만들어 읽기 때문에 잠금을 기다리지 않는다. 반대로UPDATE·DELETE·SELECT ... FOR UPDATE는 최신 버전을 읽고(잠금 읽기), REPEATABLE READ에서는 인덱스 레코드뿐 아니라 레코드 사이의 틈(갭)까지 잠가 팬텀을 막는다. 이 글은 트랜잭션·격리 수준 글의 다음 편이다.
1. 두 종류의 읽기
| 구분 | 일관된 읽기 (Consistent Read) | 잠금 읽기 (Locking Read) |
| 문장 | 일반 SELECT | SELECT ... FOR UPDATE / FOR SHARE, UPDATE, DELETE, INSERT의 중복 검사 |
| 읽는 버전 | 스냅숏(Read View) 시점의 버전 | 항상 최신 커밋 버전 (current read) |
| 잠금 | 안 걸고, 안 기다림 | 레코드·갭에 잠금을 걸고, 충돌하면 기다림 |
| 구현 | 언두 로그로 과거 버전 재구성 | 인덱스 레코드 잠금 |
같은 트랜잭션 안에서도 둘이 다른 결과를 낼 수 있다. REPEATABLE READ에서 다른 트랜잭션이 행을 추가하고 커밋하면, 일반 SELECT로는 안 보이는데 SELECT ... FOR UPDATE로는 보인다.
2. MVCC의 구조
모든 행은 숨은 컬럼을 갖는다.
| 숨은 컬럼 | 의미 |
| DB_TRX_ID | 이 버전을 마지막으로 바꾼 트랜잭션 ID |
| DB_ROLL_PTR | 이전 버전이 있는 언두 로그 위치 (버전 체인) |
UPDATE는 행을 제자리에서 고치되 이전 값을 언두 로그에 남기고, 롤 포인터로 이어 둔다. 그래서 한 행은 '최신 버전 → 이전 버전 → 더 이전 버전'의 체인이 된다.
Read View는 '내가 볼 수 있는 커밋이 어디까지인가'를 정하는 스냅숏이다.
| 값 | 의미 |
| m_ids | Read View를 만들 때 아직 진행 중이던 트랜잭션 목록 |
| up_limit_id | 진행 중이던 것 중 가장 작은 ID (이보다 작으면 이미 커밋 → 보임) |
| low_limit_id | 다음에 발급될 ID (이 이상이면 내 뒤에 시작 → 안 보임) |
| creator_trx_id | 나 자신 (내가 바꾼 건 보임) |
행의 DB_TRX_ID가 '보이지 않는' 트랜잭션이면, 롤 포인터를 따라가며 보이는 버전이 나올 때까지 거슬러 올라간다.
| 격리 수준 | Read View를 만드는 시점 | 결과 |
| READ COMMITTED | SELECT 문장마다 새로 | 문장마다 최신 커밋을 봄 → Non-repeatable read 가능 |
| REPEATABLE READ | 트랜잭션의 첫 SELECT 때 한 번 | 끝날 때까지 같은 스냅숏 → 일반 SELECT는 다시 읽어도 같음 |
언두 로그는 그 버전을 볼 수 있는 Read View가 남아 있는 동안 지울 수 없다. 오래 열려 있는 트랜잭션이 있으면 언두가 계속 쌓이고(purge 지연), 버전 체인이 길어져 읽기도 느려진다. 긴 트랜잭션을 피해야 하는 이유 중 하나다.
3. 잠금의 종류
| 잠금 | 잠그는 범위 | 목적 |
| 레코드 락 (Record Lock) | 인덱스 레코드 하나 | 그 행의 수정 막기 |
| 갭 락 (Gap Lock) | 인덱스 레코드 사이의 틈 (레코드 자체는 아님) | 그 틈에 새 행 INSERT 막기 |
| 넥스트 키 락 (Next-Key Lock) | 레코드 + 그 앞의 갭 | REPEATABLE READ의 기본 잠금, 팬텀 방지 |
| 삽입 의도 락 (Insert Intention) | INSERT 직전 갭에 거는 특수 갭 락 | 같은 갭의 다른 위치 INSERT끼리는 서로 안 막음 |
| 모드 | S (공유) | X (배타) |
| S (공유) | 같이 가능 | 충돌 |
| X (배타) | 충돌 | 충돌 |
갭 락끼리는 S든 X든 서로 충돌하지 않는다. 갭 락의 유일한 목적은 '그 틈에 INSERT 못 하게'이기 때문이다.
중요: InnoDB의 잠금은 행이 아니라 인덱스에 걸린다. 조건에 쓸 인덱스가 없으면 풀 스캔하면서 지나간 모든 레코드와 갭을 잠가서, 사실상 테이블 전체가 잠긴다.
4. 넥스트 키 락이 팬텀을 막는 방식
age 인덱스에 10, 20, 30이 있을 때 넥스트 키 범위는 (-∞, 10], (10, 20], (20, 30], (30, +∞)이다.
A가 같은 조건으로 다시 잠금 읽기를 해도 새 행이 끼어들 수 없으니 팬텀이 생기지 않는다. 표준 SQL에서 REPEATABLE READ는 팬텀을 허용하지만, InnoDB는 이 방식으로 대부분 막는다.
검색 조건에 따라 잠금이 줄어든다.
| 상황 | 걸리는 잠금 |
| 유니크 인덱스로 '존재하는' 한 행을 동등 조건 검색 | 레코드 락만 (갭 없음) |
| 유니크 인덱스로 '없는' 값을 검색 | 그 값이 들어갈 갭에 갭 락 |
| 일반(비유니크) 인덱스 검색 | 넥스트 키 락 + 마지막 레코드 다음 갭 |
| 인덱스 없는 조건 | 스캔한 모든 레코드와 갭 |
| READ COMMITTED | 갭 락을 거의 쓰지 않음 (외래 키·중복 검사 제외) → 팬텀 가능, 대신 동시성 높음 |
5. MVCC와 잠금 읽기를 섞을 때의 함정: Lost Update
일반 SELECT는 잠금을 걸지 않으니 둘 다 같은 값을 읽는다. 해결은 셋 중 하나다.
- 원자적 갱신:
UPDATE item SET stock = stock - 1 WHERE id = 1 AND stock > 0(UPDATE는 최신 값을 읽고 잠금) - 비관적 잠금: 처음부터
SELECT ... FOR UPDATE로 읽기 - 낙관적 잠금:
version컬럼을 두고UPDATE ... WHERE id = 1 AND version = 7, 바뀐 행이 0개면 재시도
6. 데드락
갭 락끼리는 충돌하지 않지만, 그 뒤의 INSERT는 상대의 갭 락에 막힌다. 그래서 '없으면 넣기'를 잠금 읽기로 하면 데드락이 자주 난다.
- InnoDB는 대기 그래프에서 순환을 찾아 한쪽(되돌리기 싼 쪽)을 롤백한다. 애플리케이션은 재시도할 수 있어야 한다.
SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK에서 두 트랜잭션이 잡고 기다린 잠금을 볼 수 있다.- 줄이는 방법: 같은 순서로 자원 잠그기, 트랜잭션 짧게, 조건에 맞는 인덱스 두기, '없으면 넣기'는
INSERT ... ON DUPLICATE KEY UPDATE나 유니크 제약 + 중복 에러 처리로.
7. 정리
| 질문 | 답 |
| 일반 SELECT는 왜 잠금을 안 기다리나 | 언두 로그로 만든 스냅숏 버전을 읽어서 (MVCC) |
| RC와 RR의 차이를 MVCC로 말하면 | Read View를 문장마다 만드나, 트랜잭션에 한 번 만드나 |
| RR에서 팬텀은 어떻게 막나 | 일반 읽기는 스냅숏으로, 잠금 읽기는 넥스트 키 락으로 갭까지 잠가서 |
| 갭 락끼리 충돌하나 | 안 함. INSERT(삽입 의도 락)만 막음 |
| 인덱스가 없으면 | 스캔한 모든 레코드·갭이 잠겨 테이블 잠금처럼 됨 |
| 긴 트랜잭션이 왜 나쁜가 | 잠금을 오래 잡고, 언두 purge를 막아 버전 체인이 길어짐 |