MVCC를 처음 배울 때 가장 헷갈리는 표현은 “잠금 없는 읽기”입니다. 이 말은 읽기 과정에서 아무 비용도 들지 않는다는 뜻이 아닙니다. InnoDB는 쓰기와 읽기를 직접 충돌시키지 않기 위해, 현재 값과 과거 버전을 함께 관리합니다.
일반 SELECT는 항상 최신 row를 그대로 읽는 것이 아니라, 자신의 읽기 시점에서 보이는 버전을 선택합니다. 최신 버전이 아직 보이면 안 되는 값이라면 undo log를 따라가 과거 버전을 읽습니다. 그래서 MVCC는 잠금을 없애는 기술이라기보다, 읽기가 매번 현재 row의 잠금을 기다리지 않아도 되게 만드는 버전 관리 방식에 가깝습니다.
이 글에서는 undo log, read view, consistent read, current read를 기준으로 InnoDB의 MVCC를 정리합니다.
InnoDB는 현재 값과 과거 버전을 함께 관리합니다
레코드가 변경되면 InnoDB는 테이블의 현재 값을 갱신하고, 이전 값은 undo log에 남깁니다. 겉으로 보기에는 테이블에 현재 row 하나만 있는 것처럼 보이지만, 내부적으로는 과거 버전으로 되돌아갈 수 있는 경로가 남습니다.
이 구조 때문에 같은 row라도 트랜잭션마다 다르게 보일 수 있습니다. 나중에 시작한 트랜잭션에게는 현재 값이 보일 수 있지만, 더 오래전에 시작한 트랜잭션에게는 undo log에 남은 이전 값이 일관된 결과일 수 있습니다.
따라서 “잠금 없는 읽기”는 단순히 테이블에서 값을 바로 읽는 동작이 아닙니다. 현재 값과 과거 버전 중 자신에게 허용된 값을 찾는 과정입니다.
read view가 읽기 결과를 결정합니다
읽기 결과를 바꾸는 핵심은 read view입니다. read view는 트랜잭션이 어떤 변경까지 볼 수 있는지 판단하는 기준입니다. 현재 row가 자신보다 나중에 시작된 트랜잭션의 변경이거나 아직 커밋되지 않은 변경이라면, InnoDB는 undo log를 따라가 이전 버전을 확인합니다.
격리 수준에 따라 read view가 만들어지는 시점도 달라집니다.
| 격리 수준 | read view 생성 기준 | 체감되는 동작 |
|---|---|---|
| READ COMMITTED | 일반적으로 문장마다 새 read view를 만듭니다. | 같은 트랜잭션 안에서도 다시 읽으면 최신 커밋 결과가 보일 수 있습니다. |
| REPEATABLE READ | 트랜잭션의 읽기 관점을 더 오래 유지합니다. | 같은 일반 SELECT를 반복하면 같은 결과가 유지되는 것처럼 보입니다. |
둘 다 MVCC를 사용합니다. 차이는 과거 버전을 어디까지 따라갈 것인지, 그리고 읽기 관점을 언제 새로 만들 것인지입니다.
정확한 동작을 볼 때는 “트랜잭션을 시작한 시점”과 “처음 consistent read를 수행한 시점”도 구분해야 합니다. REPEATABLE READ에서는 읽기 관점이 트랜잭션 동안 유지되는 것처럼 동작하지만, 실무에서는 같은 트랜잭션 안의 일반 조회가 어떤 기준의 버전을 계속 보는지가 더 직접적인 확인 지점입니다.
일반 SELECT는 consistent read에 가깝습니다
일반 SELECT는 보통 consistent read로 동작합니다. consistent read는 현재 row를 무조건 읽는 것이 아니라, read view 기준으로 자신에게 보이는 버전을 읽는 방식입니다.
다른 트랜잭션이 같은 row를 수정하고 있어도, 일반 조회가 항상 그 쓰기를 기다릴 필요는 없습니다. 자신이 볼 수 있는 이전 버전이 undo log에 남아 있다면 그 값을 읽으면 됩니다. 이것이 MVCC가 읽기와 쓰기의 충돌을 줄이는 방식입니다.
간단히 흐름을 보면 다음과 같습니다.
1. SELECT가 실행되고 read view가 결정됩니다.
2. 현재 row의 버전이 read view 기준으로 보이는지 확인합니다.
3. 보이면 현재 row를 읽습니다.
4. 보이면 안 되는 버전이면 undo log를 따라갑니다.
5. read view 기준으로 보이는 과거 버전을 찾아 결과를 만듭니다.이 구조 덕분에 일반 조회는 쓰기 트랜잭션과 항상 같은 잠금을 두고 경쟁하지 않아도 됩니다. 다만 undo log를 따라가야 하는 버전이 길어질수록 읽기 비용은 커질 수 있습니다.
하지만 모든 SELECT가 잠금 없는 읽기는 아닙니다
SELECT라는 키워드가 있다고 해서 항상 consistent read로 동작하는 것은 아닙니다. SELECT ... FOR UPDATE나 SELECT ... LOCK IN SHARE MODE처럼 현재 값을 기준으로 잠금을 잡아야 하는 읽기는 current read에 가깝습니다.
current read는 undo log의 과거 버전을 읽어 일관된 스냅샷을 만드는 것이 목적이 아닙니다. 현재 레코드를 기준으로 충돌을 제어해야 하므로, 필요한 row와 index range에 잠금을 획득할 수 있습니다.
| 구분 | 대표 쿼리 | 읽는 기준 | 충돌 방식 |
|---|---|---|---|
| Consistent read | 일반 SELECT | read view 기준으로 보이는 버전 | 쓰기 트랜잭션을 오래 기다리지 않을 수 있습니다. |
| Current read | SELECT ... FOR UPDATE, UPDATE, DELETE | 현재 레코드 | 필요한 잠금을 잡고 다른 트랜잭션과 직접 충돌할 수 있습니다. |
이 구분을 놓치면 같은 조건의 조회인데도 어떤 쿼리는 바로 끝나고, 어떤 쿼리는 lock wait에 걸리는 상황을 설명하기 어렵습니다.
같은 트랜잭션에서도 읽기 종류에 따라 결과가 달라질 수 있습니다
MVCC를 실제 쿼리와 연결할 때 자주 헷갈리는 상황이 있습니다. 같은 트랜잭션 안에서 일반 SELECT와 SELECT ... FOR UPDATE가 서로 다른 기준으로 동작하는 경우입니다.
예를 들어 REPEATABLE READ에서 일반 SELECT는 트랜잭션 초기에 만든 읽기 관점을 유지할 수 있습니다. 그런데 SELECT ... FOR UPDATE는 현재 row를 기준으로 잠금을 잡아야 하므로, read view의 과거 버전만 보고 끝나는 것이 아닙니다. 이때 다른 트랜잭션의 변경과 직접 충돌하거나 최신 커밋 상태를 기준으로 대기할 수 있습니다.
일반 조회와 잠금 조회를 같은 “SELECT”로 묶어 생각하면 MVCC 동작이 모순처럼 보입니다. 하지만 consistent read와 current read를 나누면 동작 이유가 분명해집니다.
오래 열린 트랜잭션은 undo를 지우지 못하게 합니다
MVCC의 장점은 읽기가 과거 버전을 볼 수 있다는 것입니다. 하지만 그 말은 과거 버전을 일정 시간 보존해야 한다는 뜻이기도 합니다.
오래 열린 트랜잭션이 있으면 InnoDB는 그 트랜잭션이 아직 볼 수 있는 undo 버전을 함부로 지울 수 없습니다. 최신 데이터는 계속 변경되는데 오래된 read view가 남아 있으면, purge가 밀리고 undo history가 길어질 수 있습니다.
이 문제는 쓰기 트랜잭션에서만 생기지 않습니다. 일반 조회만 하는 트랜잭션도 오래 열려 있으면 과거 버전 보존을 지연시킬 수 있습니다.
1. 긴 조회 트랜잭션이 시작됩니다.
2. 이후 다른 트랜잭션들이 같은 테이블을 계속 수정합니다.
3. 긴 조회 트랜잭션의 read view 때문에 일부 undo 버전을 지우지 못합니다.
4. purge가 밀리고 undo history가 길어집니다.
5. 과거 버전을 따라가는 비용과 저장소 사용량이 증가할 수 있습니다.특히 대용량 export, 관리 화면 조회, 배치성 리포트처럼 오래 실행되는 읽기 작업은 read-only처럼 보여도 저장소에 비용을 남길 수 있습니다. MVCC는 읽기를 가볍게 해주지만, 긴 트랜잭션을 무료로 만들어 주지는 않습니다.
긴 조회는 트랜잭션 경계를 따로 설계해야 합니다
긴 조회가 필요한 기능은 단순히 timeout을 늘리는 방식으로만 다루면 위험합니다. 요청은 성공할 수 있어도, 그동안 유지된 read view 때문에 undo purge가 늦어질 수 있습니다. 그래서 오래 걸리는 조회는 조회 방식과 트랜잭션 경계를 함께 봐야 합니다.
| 상황 | 점검할 기준 |
|---|---|
| 대용량 export | 하나의 긴 트랜잭션으로 묶지 않고 chunk 단위로 끊을 수 있는지 |
| 관리자 리포트 | 실시간 정합성이 필요한지, 별도 집계나 읽기 전용 경로로 분리할 수 있는지 |
| 배치 조회 | cursor, keyset pagination처럼 읽기 범위를 안정적으로 줄일 수 있는지 |
| 긴 API 요청 | 트랜잭션 안에 네트워크 호출이나 파일 처리가 섞여 있지 않은지 |
읽기 작업이라고 해서 모두 안전한 것은 아닙니다. 오래 유지되는 read view는 쓰기를 막지 않더라도, 과거 버전을 지우지 못하게 만들어 나중의 읽기와 purge 비용으로 돌아올 수 있습니다.
운영에서는 lock wait만 보면 부족합니다
MVCC 문제는 처음부터 명확한 장애처럼 보이지 않을 수 있습니다. 일반 조회는 계속 성공하고, 쓰기도 당장은 처리됩니다. 하지만 오래 열린 트랜잭션이 남아 있는 동안 업데이트가 계속 발생하면 undo history가 쌓이고 purge가 밀릴 수 있습니다.
운영에서 MVCC를 확인할 때는 lock wait뿐 아니라 다음 신호를 같이 봐야 합니다.
| 확인 항목 | 의미 |
|---|---|
| 오래 열린 트랜잭션 | 과거 버전 보존을 오래 요구할 수 있습니다. |
| 현재 실행 중인 쿼리 | 긴 조회, export, 리포트성 작업인지 확인합니다. |
| undo history / purge 지연 | 삭제 가능한 과거 버전이 밀리고 있는지 봅니다. |
| lock wait | current read 또는 쓰기 충돌이 실제로 대기 중인지 확인합니다. |
| 대량 변경 작업 | undo 생성량이 급격히 늘어나는 원인이 될 수 있습니다. |
이렇게 보면 MVCC는 잠금 문제와 별개의 영역이 아닙니다. consistent read는 잠금을 줄이지만, current read와 쓰기 작업은 여전히 잠금과 충돌합니다. 또한 consistent read도 오래 유지되면 undo 보존 비용으로 돌아올 수 있습니다.
격리 수준은 표보다 읽기 기준으로 이해해야 합니다
격리 수준을 외울 때는 dirty read, non-repeatable read, phantom read 같은 표로 시작하기 쉽습니다. 하지만 실제 쿼리를 분석할 때는 표보다 읽기 기준을 먼저 잡는 편이 더 도움이 됩니다.
- 이 쿼리는 consistent read인가, current read인가
- read view는 문장마다 새로 만들어지는가, 트랜잭션 동안 유지되는가
- 현재 row를 잠그는가, 과거 버전을 따라가도 되는가
- 오래 열린 read view가 undo purge를 막고 있지는 않은가
이 질문에 답하면 왜 일반 SELECT는 기다리지 않는데 SELECT ... FOR UPDATE는 기다리는지, 왜 같은 트랜잭션 안에서 다시 읽었을 때 결과가 유지되거나 바뀌는지 설명할 수 있습니다.
read view 이후에 남는 운영 신호
MVCC는 잠금을 없애는 기능이라기보다 읽기가 어떤 시점의 row를 볼지 정하는 모델입니다. InnoDB는 현재 row와 undo log의 과거 버전을 함께 관리하고, 각 트랜잭션은 자신의 read view에 맞는 버전을 고릅니다. 덕분에 일반 SELECT는 쓰기 트랜잭션을 오래 기다리지 않을 수 있지만, 그 비용은 undo 보존과 purge 지연이라는 다른 형태로 남습니다.
이 관점에서 MVCC를 볼 때 남는 체크포인트는 다음과 같습니다.
- 일반
SELECT는 consistent read로 동작해 쓰기 트랜잭션을 오래 기다리지 않을 수 있습니다. SELECT ... FOR UPDATE,UPDATE,DELETE처럼 현재 값을 기준으로 충돌을 제어해야 하는 쿼리는 current read로 동작하며 잠금을 잡을 수 있습니다.READ COMMITTED와REPEATABLE READ의 차이는 read view를 언제 새로 보느냐와 연결됩니다.- 오래 열린 트랜잭션은 undo 버전 보존을 길게 만들어 purge 지연으로 이어질 수 있습니다.
- 대용량 export, 리포트, 배치 조회는 read-only처럼 보여도 트랜잭션 경계를 따로 설계해야 합니다.
MVCC를 볼 때 마지막에 남는 질문은 “격리 수준이 무엇인가”가 아니라 “이 쿼리는 과거 버전을 읽어도 되는가, 아니면 현재 값을 잠가야 하는가”입니다. 오래 열린 트랜잭션이 있으면 undo가 오래 남고, 리포트나 export처럼 읽기만 하는 작업도 purge 지연의 원인이 될 수 있습니다. 그래서 MVCC 문제를 볼 때는 read view 생성 시점, transaction age, undo 사용량을 함께 확인해야 합니다. 필요하다면 SHOW ENGINE INNODB STATUS의 history list length나 long transaction 목록처럼, 실제로 purge가 밀리고 있는지를 보여주는 지표까지 같이 봐야 합니다.