본문 바로가기
카테고리 없음

읽기가 쓰기를 안 기다리는 비결, MVCC

by 테크구루스 2026. 7. 13.

읽기가 쓰기를 안 기다리는 비결, MVCC를 스냅샷과 버전 관리 관점에서 정리한 글입니다.

왜 읽기는 쓰기를 기다리지 않아도 되는가

대시보드 조회 API가 배치 작업이 도는 시간대에만 느려진다는 제보를 받았던 게 몇 년 전 일입니다. 처음 떠올린 그림은 단순했습니다. 배치가 큰 테이블을 통째로 갱신하니, 그 행을 읽으려는 조회 쿼리들이 갱신이 끝날 때까지 줄을 서서 기다린다는 시나리오였죠. 읽기와 쓰기가 같은 데이터를 두고 서로를 막는다, 저는 오랫동안 이게 데이터베이스의 자연스러운 동작이라고 믿고 있었습니다.

그런데 PostgreSQL과 MySQL(InnoDB) 같은 요즘 데이터베이스는 그렇게 동작하지 않습니다. 핵심은 MVCC, 즉 다중 버전 동시성 제어(Multi-Version Concurrency Control)입니다. 이름 그대로, 하나의 행(row)을 물리적으로 여러 버전(version)으로 유지하는 방식입니다. 어떤 트랜잭션이 특정 행을 UPDATE해도 기존 행을 제자리에서 덮어쓰지 않습니다. 대신 새 버전을 하나 더 만들어 두고, 옛 버전은 그대로 남겨 둡니다.

그러면 같은 행에 대해 값이 두 개 존재하게 되는데, 여기서 snapshot이 등장합니다. 각 트랜잭션은 시작 시점(혹은 문장 시점)에 "내가 볼 수 있는 세상"의 스냅샷을 하나 갖습니다. 읽기 쿼리는 락을 잡고 최신 값을 기다리는 게 아니라, 자기 스냅샷에 맞는 버전을 골라서 읽습니다. 그래서 배치가 새 버전을 한창 만들고 있어도, 먼저 시작한 조회 트랜잭션은 자기에게 보이는 옛 버전을 조용히 읽고 지나갈 수 있죠.

정리하면 이렇습니다.

  • 쓰기는 기존 버전을 지우지 않고 새 버전을 추가합니다.
  • 읽기는 최신 버전이 아니라 자기 스냅샷에 부합하는 버전을 봅니다.
  • 따라서 읽기는 쓰기를 기다리지 않고, 쓰기도 읽기 때문에 막히지 않습니다.

읽기가 쓰기를 안 기다리는 비결, MVCC
읽기가 쓰기를 안 기다리는 비결, MVCC

이 구조 덕분에 "읽기는 쓰기를 차단하지 않고, 쓰기는 읽기를 차단하지 않는다(readers don't block writers, writers don't block readers)"는 성질이 성립합니다. 제가 처음에 그렸던 '줄 서서 기다리는' 그림은, 애초에 전제부터 틀렸던 셈입니다.

스냅샷과 버전의 수명

그럼 트랜잭션은 어떤 버전이 자기에게 보이는지를 어떻게 판정할까요. PostgreSQL을 예로 들면, 모든 tuple(행의 한 버전)에는 숨은 시스템 컬럼 두 개가 붙어 있습니다. xmin은 이 버전을 만든(INSERT/UPDATE) 트랜잭션의 ID, xmax는 이 버전을 지우거나 갱신해 무효화한 트랜잭션의 ID입니다. 직접 눈으로 확인할 수도 있습니다.

-- 숨은 시스템 컬럼으로 버전 메타데이터 조회
SELECT xmin, xmax, * FROM orders WHERE id = 42;

가시성(visibility) 판정은 대략 이런 질문으로 이뤄집니다. "이 tuple의 xmin은 내 스냅샷 기준으로 이미 커밋된 트랜잭션인가, 그리고 xmax는 아직 커밋되지 않았거나 비어 있는가." 두 조건이 맞으면 그 버전은 나에게 보입니다. 여기서 기준이 되는 건 벽시계 시간이 아니라 커밋(commit) 시점입니다. 내 스냅샷을 뜬 순간 이미 커밋돼 있던 변경은 보이고, 그 이후에 커밋된 변경은 xmin이 아무리 최신이어도 나에게는 존재하지 않는 것처럼 취급됩니다.

tuple xmin xmax 내 스냅샷에서 보이는가
옛 버전 100 (커밋됨) 205 (진행 중) 보임
새 버전 205 (진행 중) 없음 안 보임

문제는 여기서 시작됩니다. UPDATEDELETE가 남긴 옛 버전들은 물리적으로 계속 파일에 남아 있습니다. 어떤 트랜잭션에게도 더는 보이지 않게 된 버전을 dead tuple(죽은 버전)이라고 부르는데, 이게 자동으로 사라지지는 않습니다. 갱신이 잦은 테이블일수록 dead tuple이 쌓여 테이블이 실제 데이터량보다 훨씬 부풀어 오르죠. 이 부풀음을 table bloat라고 합니다. 한 가지 오해하기 쉬운 지점은, UPDATE 한 번이 반드시 새 버전 하나만 남기는 게 아니라는 사실입니다. 그 행을 가리키던 인덱스 엔트리도 새 tuple을 향해 갱신되면서 인덱스 쪽에도 죽은 흔적이 남으니, 조회가 인덱스를 타더라도 bloat의 영향에서 완전히 자유롭지는 않습니다.

그래서 필요한 게 VACUUM입니다. VACUUM은 더 이상 어느 스냅샷에도 보이지 않는 dead tuple의 공간을 회수해 재사용 가능하게 만드는 청소 작업입니다. autovacuum이 백그라운드에서 알아서 돌지만, 한 가지 함정이 있습니다. VACUUM은 "지금 살아 있는 가장 오래된 트랜잭션조차 볼 수 없는" 버전만 지울 수 있습니다. 즉 아주 오래 열려 있는 long transaction이 하나라도 있으면, 그 트랜잭션의 스냅샷이 옛 버전을 여전히 참조할 수 있기 때문에 VACUUM은 그 뒤로 쌓인 dead tuple을 손대지 못합니다.

"읽기도 락을 잡는다"는 오해

앞의 대시보드 사건으로 돌아가겠습니다. 당시 저는 조회가 느린 원인을 '읽기 잠금 경합'으로 못박고, 엉뚱한 방향으로 며칠을 태웠습니다. 조회 트랜잭션의 격리 수준을 낮춰 보고, 애플리케이션 레벨에 읽기 캐시를 급하게 끼우고, 심지어 리포트성 쿼리에 READ UNCOMMITTED 흉내를 내려는 시도까지 했었죠. 개인적으로는 그때가 가장 부끄러운 삽질 구간이었습니다. 읽기는 쓰기를 기다리지 않는다는 MVCC의 대전제를 몰랐으니, 존재하지도 않는 락을 상대로 싸운 셈이었으니까요.

방향을 바꾼 건 pg_stat_activity를 열어 본 순간이었습니다. 느려지는 시간대에 stateidle in transaction인 세션 하나가 40분 넘게 살아 있었습니다. 리포트 배치가 트랜잭션을 열고 중간 계산을 애플리케이션에서 처리하는 동안, 커밋도 롤백도 하지 않은 채 연결을 붙잡고 있던 것이었습니다. 이 long transaction이 옛 스냅샷을 붙들고 있으니 autovacuum이 dead tuple을 회수하지 못했고, 갱신이 잦던 테이블의 dead tuple 비율이 순식간에 치솟았습니다.

  • 조회가 느렸던 진짜 원인: 락 경합이 아니라 dead tuple이 잔뜩 낀 테이블을 스캔하느라 읽을 페이지 수가 몇 배로 늘어난 것
  • 근본 방아쇠: 커밋 없이 열려 있던 idle in transaction 세션이 VACUUM을 가로막은 것

확인은 간단했습니다.

-- 죽은 버전 비율과 마지막 청소 시각 점검
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

n_dead_tupn_live_tup을 웃도는 테이블이 범인이었습니다. 특히 갱신 폭주 구간에는 dead tuple 비율이 60%를 넘었는데, 이는 실제로 필요한 데이터의 절반 이상이 죽은 껍데기였다는 뜻이죠. 조치는 튜닝이 아니라 규율에 가까웠습니다. 배치의 트랜잭션 경계를 잘게 쪼개 계산 로직을 트랜잭션 밖으로 빼냈고, idle_in_transaction_session_timeout을 걸어 방치된 세션을 강제로 정리했습니다. 문제의 테이블은 한 번 VACUUM으로 공간을 회수해 준 뒤 autovacuum 임계값을 낮췄죠. 그 뒤로 같은 시간대 p95 조회 지연은 약 900ms에서 120ms 안팎으로 내려앉았습니다.

 

가시성 판정 흐름
가시성 판정 흐름

 

이 경험 이후 저는 데이터베이스 성능 문제를 볼 때 "읽기가 어디서 막히나"를 먼저 묻지 않게 됐습니다. 대신 "누가 스냅샷을 오래 붙들고 있나", "dead tuple이 어디에 쌓이나"를 먼저 봅니다. MVCC는 읽기와 쓰기를 서로 풀어 주는 대신, 그 대가로 버전이라는 쓰레기를 남기고 그 청소를 우리에게 맡기는 구조니까요. 원리를 알고 나면 튜닝 지점이 락에서 버전 수명 관리로 자연스럽게 옮겨 갑니다.

 

읽기와 쓰기 비차단
읽기와 쓰기 비차단

읽기가 쓰기를 기다리지 않는다는 한 줄을 제대로 이해하는 데 저는 꽤 먼 길을 돌아왔습니다. 혹시 조회 성능 앞에서 락부터 의심하고 계시다면, 먼저 스냅샷과 dead tuple을 들여다보시길 권하고 싶네요.