PostgreSQL MVCC 심층 분석
Grace Collins
Solutions Engineer · Leapcell

PostgreSQL의 MVCC 이해
소개
데이터베이스 관리 시스템의 세계에서 여러 동시 작업을 처리하는 동안 데이터 일관성과 격리를 보장하는 것은 엄청난 과제입니다. 강력한 메커니즘 없이는 동시 트랜잭션으로 인해 누락된 업데이트, 더티 읽기, 반복 불가능한 읽기, 팬텀 읽기 등 수많은 문제가 발생할 수 있습니다. 이러한 문제는 데이터를 손상시키고, 애플리케이션 로직을 손상시키며, 성능을 심각하게 저하시킬 수 있습니다. 바로 이 지점에서 동시성 제어 메커니즘이 필수적이 됩니다. 그중에서도 다중 버전 동시성 제어(MVCC)는 특히 PostgreSQL과 같은 관계형 데이터베이스에서 매우 효과적이고 널리 채택된 접근 방식으로 두드 러집니다. MVCC를 사용하면 서로 다른 트랜잭션이 데이터베이스의 서로 다른 "스냅샷"을 볼 수 있으므로 읽기 작업 중에 전통적인 잠금 메커니즘의 필요성이 사실상 제거되어 동시성이 크게 향상됩니다. PostgreSQL에서 MVCC가 어떻게 작동하는지 이해하는 것은 데이터베이스 성능을 최적화하거나, 동시성 문제를 해결하거나, 단순히 최신 RDBMS의 엔지니어링 경이로움에 대한 더 깊은 이해를 얻고자 하는 모든 사람에게 중요합니다. 이 글에서는 PostgreSQL 내 MVPCC의 복잡성을 자세히 살펴보고 핵심 원칙을 풀어내고 효율적이고 비차단적인 작업을 가능하게 하는 방법을 보여줍니다.
MVCC의 핵심 개념
PostgreSQL의 특정 구현에 대해 자세히 알아보기 전에 MVCC의 기반이 되는 핵심 개념에 대한 기본 이해를 확립해 보겠습니다.
- 트랜잭션 ID (XID): PostgreSQL의 모든 트랜잭션에는 고유하고 단조 증가하는 32비트 트랜잭션 ID (XID)가 할당됩니다. 이 XID는 MVCC의 기초이며 트랜잭션이 시작된 시점을 표시합니다.
- 튜플 버전: 레코드를 제자리에서 업데이트하는 대신 MVCC 시스템(예: PostgreSQL)은 수정되거나 삭제될 때마다 행의 새 버전을 생성합니다. 이전 버전은 여전히 데이터베이스에 남아 다른 동시 트랜잭션이 여전히 필요로 할 수 있습니다.
- 가시성 규칙: 이러한 규칙은 특정 트랜잭션이 튜플의 어떤 버전을 "볼" 수 있는지 결정합니다. 튜플의 가시성은 현재 트랜잭션의 XID와 관련하여 생성 XID (
xmin
) 및 만료 XID (xmax
)에 따라 판단됩니다. xmin
(생성 XID): 튜플의 특정 버전을 생성한 트랜잭션의 XID입니다.xmax
(삭제/업데이트 XID): 튜플 버전을 "논리적으로" 삭제했거나 업데이트(즉, 새 버전을 생성하고 이전 버전을 해당 트랜잭션에서 "삭제됨"으로 표시)한 트랜잭션의 XID입니다.xmax
가 0이면 튜플이 삭제되거나 업데이트되지 않은 것입니다.- 트랜잭션 상태: 활성 XID 외에도 PostgreSQL은 트랜잭션의 상태(커밋됨, 중단됨, 진행 중)를 저장하는
pg_clog
(커밋 로그)도 유지 관리합니다. 이는 가시성 규칙을 올바르게 적용하는 데 필수적입니다.
실제로 MVCC: PostgreSQL 작동 방식
PostgreSQL의 MVCC 구현은 이러한 튜플 버전과 가시성 규칙을 중심으로 진행됩니다. 트랜잭션이 데이터를 읽으려면 다른 쓰기 작업을 차단하는 잠금을 획득하지 않습니다. 대신, 해당 트랜잭션에 보이는 버전을 결정하기 위해 각 튜플 버전의 xmin
및 xmax
와 트랜잭션 상태를 참조합니다.
업데이트 프로세스
products
라는 테이블에 대한 간단한 UPDATE
작업을 고려해 보세요.
UPDATE products SET price = 100.00 WHERE id = 1;
이 문장이 트랜잭션(예: 트랜잭션 XID 100) 내에서 실행될 때 PostgreSQL은 기존 행을 직접 수정하지 않습니다. 대신 다음과 같은 단계를 수행합니다.
- 이전 튜플 표시:
id = 1
에 대한 기존 튜플을 찾고xmax
를 현재 트랜잭션의 XID인 100으로 설정합니다. 이는 이전 버전을 XID 100에 의해 "삭제됨"으로 효과적으로 표시합니다. - 새 튜플 삽입:
price = 100.00
으로id = 1
에 대한 완전히 새로운 튜플을 생성합니다. 이 새 튜플의xmin
은 100으로 설정되고xmax
는 처음에 0입니다(아직 삭제되지 않았음을 의미).
중요한 점은 트랜잭션 XID 100이 COMMIT
되면 이전 튜플의 xmax
가 커밋된 트랜잭션과 효과적으로 연결되고 새 튜플의 xmin
도 커밋된 트랜잭션과 연결됩니다. 트랜잭션 XID 100이 ROLLBACK
되면 두 변경 사항이 효과적으로 실행 취소됩니다. 이전 튜플의 xmax
가 지워지고 새 튜플이 보이지 않게 되어 가비지 컬렉션 대상이 됩니다.
가시성 규칙
튜플 버전은 트랜잭션(예: XID current_xid
를 가진 트랜잭션 Y)에 대해 다음과 같은 조건이 충족될 때만 보입니다.
-
생성 XID (
xmin
) 확인:xmin
은current_xid
보다 작습니다.- 또는
xmin
은current_xid
와 같습니다(현재 트랜잭션이 생성했음을 의미). - 그리고
xmin
은 커밋된 트랜잭션입니다(단,xmin
이current_xid
인 경우는 제외).
-
삭제 XID (
xmax
) 확인:xmax
는 0입니다(아직 삭제되지 않았음을 의미).- 또는
xmax
는current_xid
보다 큽니다. - 또는
xmax
는current_xid
와 같습니다(현재 트랜잭션이 삭제했음을 의미하며, 이전 버전은 이 트랜잭션 자체가 자신의 변경 사항을 읽는 경우 여전히 볼 수 있을 수 있습니다). - 그리고
xmax
는 중단된 트랜잭션입니다(단,xmax
가current_xid
이고 현재 트랜잭션이 실제로 삭제한 경우는 제외).
이러한 규칙은 각 트랜잭션이 시작 시점의 데이터 일관된 스냅샷을 보도록 보장하여 더티 읽기 및 반복 불가능한 읽기를 방지합니다.
예를 들어 설명해 보겠습니다.
-- 초기 상태: -- products 테이블: -- (id=1, name='Laptop', price=999.99, xmin=50, xmax=0) -- XID 50에 의해 커밋됨 -- 트랜잭션 A (XID 100) 시작 BEGIN TRANSACTION; -- 다른 트랜잭션(XID 90, 95)이 동시에 읽을 수 있습니다. 이들은 XID 50의 버전을 봅니다. -- 트랜잭션 B (XID 105) 시작, 노트북 가격 업데이트 목표 BEGIN TRANSACTION; UPDATE products SET price = 1050.00 WHERE id = 1; -- PostgreSQL 상태(단순화): -- (id=1, name='Laptop', price=999.99, xmin=50, xmax=105) -- XID 105에 의해 표시된 이전 버전 -- (id=1, name='Laptop', price=1050.00, xmin=105, xmax=0) -- XID 105에 의해 생성된 새 버전 -- 트랜잭션 A (XID 100) 내부: SELECT * FROM products WHERE id = 1; -- 출력: (id=1, name='Laptop', price=999.99) -- 이유? XID 100은 XID 50에서 생성된 버전을 봅니다(`xmin=50 < 100`). XID 105에서 생성된 버전(`xmin=105 > 100`)은 XID 100에게 보이지 않습니다. -- 트랜잭션 B (XID 105) 커밋 COMMIT; -- 이제 새 트랜잭션 C (XID 110) 시작 BEGIN TRANSACTION; SELECT * FROM products WHERE id = 1; -- 출력: (id=1, name='Laptop', price=1050.00) -- 이유? XID 110: -- - (xmin=50, xmax=105)에 대해: xmin=50 < 110 (커밋됨), xmax=105 < 110 (커밋됨). 이 버전은 XID 105에 의해 삭제되었으므로 보이지 않습니다. -- - (xmin=105, xmax=0)에 대해: xmin=105 < 110 (커밋됨), xmax=0. 이 버전은 보입니다. COMMIT;
pg_clog
및 트랜잭션 상태
pg_clog
(또는 pg_xact
)는 과거 트랜잭션의 커밋 상태(커밋됨, 중단됨, 진행 중)를 저장하는 중요한 구성 요소입니다. XID를 해당 상태에 매핑하는 파일 디렉터리입니다. 트랜잭션이 xmin
또는 xmax
의 상태를 확인해야 할 때 pg_clog
를 참조합니다. 이를 통해 PostgreSQL은 트랜잭션(따라서 튜플 버전)이 "실행 중"인지 여부를 신속하게 결정할 수 있습니다.
MVCC와 인덱스 동작
PostgreSQL의 인덱스도 MVCC 원칙을 준수합니다. 인덱스 항목은 특정 튜플 버전을 가리킵니다. 이는 행이 업데이트되면 원본 인덱스 항목이 이전 튜플(이제 xmax
로 표시됨)을 계속 가리킬 수 있음을 의미합니다. 새 튜플을 가리키는 새 인덱스 항목도 생성될 수 있습니다. 이것이 VACUUM
(다음 섹션에서 설명)이 "죽은" 항목을 정리하는 데 필수적인 이유입니다.
VACUUM
의 역할
MVCC의 "새 버전 쓰기" 전략의 한 가지 중요한 결과는 "죽은 튜플"(활성 트랜잭션에 더 이상 보이지 않는 행의 이전 버전)이 축적된다는 것입니다. 이 죽은 튜플을 방치하면 데이터베이스가 부풀어 오르고, 디스크 공간을 소비하며, 쿼리 성능을 저하시킬 수 있습니다(인덱스가 이를 가리킬 수 있어 추가 확인이 필요하기 때문).
VACUUM
이 등장합니다. VACUUM
은 PostgreSQL의 가비지 컬렉터입니다. 주요 책임은 다음과 같습니다.
- 죽은 튜플 제거: 죽은 튜플이 차지하는 스토리지를 식별하고 회수합니다.
- 가시성 맵 업데이트: 인덱스 전용 스캔 속도를 높이는 데 도움이 되는 가시성 맵을 업데이트합니다.
- 오래된 트랜잭션 동결: 트랜잭션 ID 래퍼라운드를 방지하기 위해 트랜잭션 ID 카운터를 업데이트합니다. 장기 실행 데이터베이스의 치명적인 문제입니다.
VACUUM FULL
은 테이블을 더 적극적으로 다시 작성하여 공간을 회수하고 테이블 파일 크기를 줄이지만, 독점 잠금이 필요하므로 더 많은 중단이 발생합니다. AUTOVACUUM
은 백그라운드 프로세스로, VACUUM
및 ANALYZE
(통계 수집)를 자동으로 실행하여 데이터베이스를 건강하게 유지합니다.
MVCC 일관성 수준 (격리 수준)
PostgreSQL은 MVCC를 구현하여 다양한 SQL 격리 수준을 지원합니다.
- Read Committed (기본값): 트랜잭션 내의 각 문장은 데이터베이스의 새로운 스냅샷을 봅니다. 트랜잭션이
A=1
을 읽고, 다른 트랜잭션이A=2
가 되도록 변경 사항을 커밋하고, 첫 번째 트랜잭션이 다시A
를 읽으면A=2
를 보게 됩니다. 이는 더티 읽기를 방지하지만 반복 불가능한 읽기 또는 팬텀 읽기는 방지하지 못합니다. - Repeatable Read: 전체 트랜잭션에 대해 일관된 스냅샷을 제공합니다. 트랜잭션 내의 모든 문장은 해당 트랜잭션 시작 시점의 데이터베이스를 관찰합니다. 이는 더티 읽기 및 반복 불가능한 읽기를 방지합니다.
- Serializable: 가장 높은 격리 수준으로, 트랜잭션의 동시 실행이 직렬 실행된 것처럼 동일한 결과를 생성하도록 보장합니다. PostgreSQL은 "Serializable Snapshot Isolation"(SSI)이라는 기술을 사용하여 이를 달성하고, 직렬화 가능성 이상을 유발할 수 있는 트랜잭션을 감지하고 롤백합니다. 이는 팬텀 읽기를 포함한 모든 일반적인 동시성 문제를 방지합니다.
-- Read Committed 대 Repeatable Read 예시 -- 'accounts' 테이블 (id INT, balance INT) 가정 -- 초기 상태: (1, 1000) -- 세션 1 (Read Committed) BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1 시점 SELECT balance FROM accounts WHERE id = 1; -- 1000 반환 -- 세션 2 BEGIN TRANSACTION; UPDATE accounts SET balance = 1200 WHERE id = 1; COMMIT; -- T2 시점에 커밋됨 -- 세션 1로 돌아감 -- T3 시점 (T2 커밋 후) SELECT balance FROM accounts WHERE id = 1; -- 1200 반환 -- 이것은 반복 불가능한 읽기입니다. 동일한 쿼리가 동일한 트랜잭션 내에서 다른 결과를 생성했습니다. COMMIT; -- 세션 3 (Repeatable Read) BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- T4 시점 SELECT balance FROM accounts WHERE id = 1; -- 1000 반환 -- 세션 4 BEGIN TRANSACTION; UPDATE accounts SET balance = 1500 WHERE id = 1; COMMIT; -- T5 시점에 커밋됨 -- 세션 3으로 돌아감 -- T6 시점 (T5 커밋 후) SELECT balance FROM accounts WHERE id = 1; -- 1000 반환 -- 다른 트랜잭션이 변경 사항을 커밋하더라도 트랜잭션 내에서 데이터가 일관되게 유지됩니다. COMMIT;
결론
MVCC는 PostgreSQL의 동시성 모델의 핵심이며, 읽기 작업에 대한 기존 잠금에 크게 의존하지 않고 여러 트랜잭션을 처리하는 강력하고 효율적인 방법을 제공합니다. 제자리에서 업데이트하는 대신 새 튜플 버전을 생성하고 정교한 가시성 규칙을 적용함으로써 PostgreSQL은 각 트랜잭션이 데이터의 일관된 스냅샷에서 작동하도록 보장합니다. 이 설계는 동시성을 향상시킬 뿐만 아니라 강력한 격리 보장의 기반을 형성합니다. MVCC는 죽은 튜플의 오버 헤드와 VACUUM
의 필요성을 초래하지만, 성능, 안정성 및 데이터 무결성 측면에서의 이점은 현대 데이터베이스 시스템에 없어서는 안 될 기술입니다. PostgreSQL의 MVCC 구현은 엔지니어링의 우수성을 입증하며, ACID 속성을 유지하면서 매우 동시적인 작업을 가능하게 하여 다양한 응용 프로그램에 대한 데이터 일관성과 최고 성능을 보장합니다.