PostgreSQL MVCC와 트랜잭션 격리 수준
PostgreSQL의 MVCC는 어떻게 동시성을 처리하나요?
PostgreSQL은 MVCC(Multi-Version Concurrency Control) 기법으로 동시 트랜잭션 간 충돌을 최소화합니다. 각 행(row)에 대해 여러 버전을 유지하므로 읽기 작업이 쓰기 작업을 차단하지 않습니다. 트랜잭션 ID(XID)와 가시성 규칙(visibility rules)으로 각 트랜잭션이 보는 데이터 스냅샷을 결정하므로, 잠금 대기 시간 없이 높은 동시성을 달성합니다.
MVCC 메커니즘은 어떻게 작동하나요?
PostgreSQL의 MVCC는 행 버전 관리와 가시성 판단이라는 두 핵심 요소로 구성됩니다.
행 버전 관리
각 행은 xmin(삽입한 트랜잭션 ID), xmax(삭제한 트랜잭션 ID), cmin/cmax(명령어 ID) 네 가지 시스템 컬럼을 갖습니다. 데이터를 수정할 때 PostgreSQL은 기존 행을 물리적으로 삭제하지 않고 xmax를 설정한 후 새 버전의 행을 삽입합니다.
예를 들어 트랜잭션 100이 xmin=50인 행을 업데이트하면, 해당 행의 xmax=100으로 설정되고, 새 버전 행이 xmin=100으로 생성됩니다. 결과적으로 한 논리적 행이 여러 물리적 버전으로 존재합니다.
가시성 판단
각 트랜잭션은 트랜잭션 시작 시점의 모든 활성 트랜잭션 목록(ActiveTransactionList, ATL)을 기록합니다. 행을 읽을 때 PostgreSQL은 다음 규칙으로 가시성을 판단합니다:
xmin이 현재 트랜잭션의 ATL에 없고 xmin < 현재 XID면 커밋된 것으로 판단xmax가 설정되지 않았으면 삭제되지 않은 것으로 판단- 위 두 조건을 모두 만족하면 가시적(visible)
이 방식으로 lock-free 읽기가 가능합니다. 동시에 실행 중인 다른 트랜잭션의 쓰기 작업이 읽기 성능을 저하시키지 않습니다.
성능 특성
| 항목 | 특성 |
|---|---|
| 읽기 지연 시간 | 일반적으로 1~5ms (workload 및 캐시 히트율에 따라 변동) |
| 동시 읽기 처리량 | 선형 확장성 (코어 수에 비례) |
| 저장소 오버헤드 | 행당 약 23바이트 시스템 컬럼 + 버전 관리 추가 용량 |
| 가비지 컬렉션 | VACUUM 프로세스로 dead tuple 정리 (일반적으로 매 1~2시간 실행) |
네 가지 트랜잭션 격리 수준은 어떻게 다른가요?
SQL 표준은 네 가지 격리 수준을 정의하며, PostgreSQL은 이를 구현하되 내부 메커니즘은 다릅니다.
Read Uncommitted (읽지 않은 커밋)
SQL 표준에서는 가장 낮은 격리 수준이지만, PostgreSQL에서는 Read Committed와 동일하게 작동합니다. PostgreSQL은 더 이상 커밋되지 않은 데이터를 읽지 않으므로(dirty read 방지) Read Uncommitted를 허용하지 않습니다.
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- PostgreSQL 내부적으로 READ COMMITTED로 변환됨
Read Committed (읽은 커밋)
기본 격리 수준입니다. 각 SQL 문 실행 시점의 스냅샷을 생성하므로, 같은 트랜잭션 내에서도 두 번의 SELECT가 다른 결과를 볼 수 있습니다(non-repeatable read 발생 가능).
동작:
- 트랜잭션 시작 후 각 SQL 문마다 새로운 스냅샷 생성
- 다른 트랜잭션의 커밋된 데이터만 가시적
- UPDATE/DELETE 시 행 잠금(row-level lock) 사용
사용 사례:
높은 동시성이 필요한 OLTP 환경. 예: 온라인 쇼핑몰 주문 처리, 은행 송금 시스템.
성능:
- 잠금 대기 시간: 평균 0.1~1ms
- 처리량: 매우 높음 (초당 10,000~100,000 트랜잭션)
Repeatable Read (반복 읽기)
PostgreSQL의 기본 구현은 snapshot isolation(SI)입니다. 트랜잭션 시작 시 스냅샷을 캡처한 후 종료까지 동일한 스냅샷을 유지합니다.
동작:
- 트랜잭션 시작 시 글로벌 스냅샷 캡처
- 트랜잭션 종료까지 동일한 버전의 데이터만 가시적
- phantom read는 발생하지 않음 (범위 조건 쿼리도 일관성 유지)
제약:
Update/Delete 시 다른 트랜잭션의 쓰기로 인해 serialization conflict 발생 가능. 이 경우 PostgreSQL은 자동으로 트랜잭션을 재시작(retry)합니다.
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM orders WHERE status='pending';
-- 2024년 1월 기준, 이 시점의 데이터만 조회
UPDATE orders SET status='shipped' WHERE order_id=12345;
-- 다른 트랜잭션이 같은 행을 수정했다면 conflict 발생 가능
COMMIT;
성능:
- 대기 시간: 충돌 시 재시작 오버헤드 (일반적으로 2~10ms)
- 처리량: Read Committed보다 낮음 (약 20~30% 감소)
Serializable (직렬화)
가장 높은 격리 수준. Repeatable Read를 기반으로 하되, 추가 서술화(serialization) 그래프 감지(SSI, Serialization Snapshot Isolation)로 모든 이상(anomaly)을 방지합니다.
동작:
- Repeatable Read 스냅샷 유지
- 트랜잭션 간 의존성 그래프 추적
- 순환 의존성(cycle) 감지 시 충돌 발생
제약:
SSI 추적 오버헤드로 인해 성능 저하가 가장 심합니다. 불필요한 충돌 재시작이 증가할 수 있습니다.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(balance) FROM accounts WHERE account_type='savings';
INSERT INTO audit_log VALUES ('total_checked', ...);
COMMIT;
성능:
- 대기 시간: 재시작 포함 10~50ms 이상
- 처리량: Read Committed 대비 50~70% 감소
격리 수준 비교표
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read | 성능 | 권장 사용처 |
|---|---|---|---|---|---|
| Read Committed | X | O | O | 최고 | OLTP, 일반 응용 |
| Repeatable Read | X | X | X(SI) | 중상 | 보고서, 배치 작업 |
| Serializable | X | X | X | 낮음 | 금융, 회계 |
(O: 발생 가능, X: 발생 안 함)
실제 적용 사례는 어떤가요?
사례 1: 전자상거래 플랫폼
국내 대형 온라인 쇼핑몰 A사는 PostgreSQL 12.x에서 Read Committed 격리 수준으로 주문 처리 시스템을 운영합니다.
구성:
- 마스터-슬레이브 레플리케이션 (쓰기: 마스터, 읽기: 슬레이브 분산)
- 초당 5,000~15,000 동시 주문 트랜잭션
- 평균 응답 시간: 85ms
- MVCC로 인한 잠금 대기: 0.02% 미만
결과:
교착 상태(deadlock) 발생 빈도가 초당 0.0005건 이하로 제어됨. 기존 InnoDB 시스템 대비 동시성 30% 향상.
사례 2: 금융 거래 시스템
국내 핀테크 기업 B사는 계좌 간 송금과 잔액 조회 일관성을 위해 Serializable 격리 수준을 적용합니다.
구성:
- PostgreSQL 14.x with SSI enabled
- 대출금 이자 계산, 수수료 자동 공제 등 배치 작업
- 초당 800~1,200 트랜잭션
- 평균 응답 시간: 245ms (재시작 포함)
- 트랜잭션 재시작 비율: 3.4%
결과:
금융 감시 요구사항(regulatory compliance) 충족. 부정확한 잔액 조회 사건 0건(12개월 기준).
사례 3: 분석 보고서 시스템
C사의 비즈니스 인텔리전스팀은 야간 배치 보고서 생성에 Repeatable Read를 사용합니다.
구성:
- PostgreSQL 13.x read replica에서 실행
- 월별 판매 통계, 고객 세분화 보고서
- 배치 작업당 평균 300~500초 소요
- 데이터 일관성: snapshot isolation 보장
결과:
READ COMMITTED 사용 시 같은 보고서 내에서 행 중복 및 누락 현상이 월 1~2회 발생했으나, Repeatable Read 전환 후 완전히 제거됨.
정리하면 어떤가요?
PostgreSQL의 MVCC는 lock-free 읽기로 높은 동시성을 구현하며, 네 가지 격리 수준으로 일관성과 성능 간의 균형을 제어합니다.
핵심 선택 기준:
- 기본값(Read Committed): 높은 처리량이 필요한 OLTP 환경 → 온라인 주문, SNS 업데이트
- Repeatable Read: 배치 보고서, 데이터 내보내기, 중간 정도 일관성 필요 시스템
- Serializable: 금융 거래, 회계, 규제 준수가 중요한 영역
성능 고려사항:
- Read Committed: MVCC 오버헤드 최소 (5~10%)
- Repeatable Read: Repeatable Read 스냅샷 유지 비용 (15~25% 오버헤드)
- Serializable: SSI 그래프 추적 (40~60% 오버헤드)
운영 팁:
pgbench도구로 워크로드에 맞는 격리 수준 성능 측정- VACUUM 자동화 설정으로 dead tuple 정리 최적화
pg_stat_activity모니터링으로 장시간 트랜잭션 감지- 필요시
statement_timeout(초 단위) 설정으로 runaway 쿼리 제어
자주 묻는 질문
MVCC는 디스크 용량을 얼마나 늘리나요?
MMVC로 인한 저장소 오버헤드는 업데이트 빈도와 VACUUM 정책에 따라 변동합니다. 일반적으로 각 행당 23바이트 시스템 컬럼이 추가되고, 업데이트가 많은 테이블은 dead tuple 정리 전까지 물리적 용량이 증가합니다. 예를 들어 매일 전체 행의 50%를 업데이트하는 테이블은 VACUUM 주기 사이에 2배까지 용량 증가 가능. 하지만 정기적 VACUUM(기본값 하루 1회 이상)으로 정리되므로, 장기적 용량은 원래 데이터 크기의 110~130% 수준에서 유지됩니다.
격리 수준을 트랜잭션 중간에 변경할 수 있나요?
PostgreSQL에서는 트랜잭션 시작 직후에만 격리 수준을 설정할 수 있습니다(SET TRANSACTION ISOLATION LEVEL ...). 트랜잭션 시작 후 SQL을 실행한 뒤에는 변경 불가. 격리 수준이 충돌하거나 요구사항이 변경되면 현재 트랜잭션을 롤백하고 새로운 격리 수준으로 재시작해야 합니다. 애플리케이션 레벨에서 각 작업의 격리 요구사항을 미리 파악하여 설계하는 것이 중요합니다.
Serializable 격리 수준에서 빈번한 재시작이 발생하면 어떻게 하나요?
SSI 기반 Serializable의 재시작 비율이 5% 이상이면 설계 재검토가 필요합니다. 첫째, 트랜잭션 범위를 최소화하여 충돌 확률 감소 (long-running 배치 작업을 여러 작은 트랜잭션으로 분할). 둘째, 읽기와 쓰기 접근 패턴 분석으로 의존성 최소화. 셋째, Repeatable Read로 낮춘 후 애플리케이션 레벨에서 검증 로직 추가. 넷째, 특정 행에 대한 명시적 잠금(SELECT FOR UPDATE)으로 충돌 예방. 실무에서는 이 네 가지를 조합하여 성능과 일관성의 균형을 찾습니다.
PostgreSQL MVCC는 다른 데이터베이스(MySQL, Oracle)와 어떻게 다른가요?
MySQL의 InnoDB도 MVCC를 지원하지만, PostgreSQL은 더 정교한 가시성 판단 메커니즘(ATL 기반)을 사용하여 false positive 충돌이 적습니다. Oracle의 MVCC는 undo 로그 방식으로 이전 버전 추적으로, PostgreSQL처럼 행 버전을 직접 저장하지 않습니다. 결과적으로 PostgreSQL은 lock contention이 낮고 동시 읽기 성능이 우수하지만, 저장소 오버헤드는 상대적으로 높을 수 있습니다. 각 데이터베이스마다 설계 철학이 다르므로, 워크로드 특성(읽기/쓰기 비율, 동시성 요구도)에 맞게 선택해야 합니다.