작성일 댓글 남기기

PostgreSQL MVCC와 트랜잭션 격리 수준 완벽 가이드

PostgreSQL MVCC와 트랜잭션 격리 수준

PostgreSQL의 MVCC 메커니즘은 어떻게 동작하나요?

PostgreSQL의 MVCC(다중 버전 동시성 제어)는 트랜잭션 격리를 위해 각 행 버전에 트랜잭션 ID(XID)를 부여하고 스냅샷 기반 가시성 판단을 수행합니다. 읽기 작업이 잠금을 요구하지 않으므로 높은 동시성을 달성하며, 쓰기 작업은 새 행 버전을 생성하여 기존 트랜잭션에 영향을 주지 않습니다. 이 방식은 쿼리 동시성을 극대화하되 데이터 안정성을 보장하는 핵심 메커니즘입니다.

MVCC의 기본 작동 원리

PostgreSQL은 각 행(tuple)에 다음의 메타데이터를 저장합니다:

  • xmin: 행을 생성한 트랜잭션 ID
  • xmax: 행을 삭제 또는 업데이트한 트랜잭션 ID (초기값: 0, 즉 미설정)
  • cmin/cmax: 같은 트랜잭션 내에서의 커맨드 순서

트랜잭션이 시작되면 PostgreSQL은 현재 활성 트랜잭션 목록의 스냅샷을 획득합니다. 데이터 읽기 시 각 행의 xmin과 xmax를 스냅샷과 비교하여 해당 트랜잭션에서 가시적인지 결정합니다. 행이 가시적인 조건은 다음과 같습니다:

  • xmin이 현재 트랜잭션 ID보다 작고 커밋되었음
  • xmax가 미설정이거나, 설정되었다면 현재 트랜잭션 ID보다 크거나 미커밋 상태

쓰기 작업(INSERT, UPDATE, DELETE) 시에는 새 행 버전이 생성되거나 기존 행의 xmax가 설정되므로, 다른 트랜잭션이 이미 읽고 있는 이전 버전에는 영향을 주지 않습니다.

백그라운드 정리와 VACUUM

MVCC는 행 버전 증가로 인한 저장 공간 증가를 초래합니다. PostgreSQL의 VACUUM 메커니즘은 모든 활성 트랜잭션에서 불가시적이 된 행 버전을 주기적으로 정리합니다. VACUUM FULL은 테이블을 재구성하여 테이블 파일 크기를 축소하지만, 배타 잠금을 필요로 하므로 프로덕션 환경에서는 제한적으로 사용됩니다.

autovacuum 프로세스는 다음 파라미터로 제어됩니다:

  • autovacuum_naptime: 체크 간격 (기본값: 1초)
  • autovacuum_vacuum_threshold: 정리 대상 행 개수 기준 (기본값: 50)
  • autovacuum_vacuum_scale_factor: 테이블 크기 대비 비율 (기본값: 0.2)

업데이트 빈번한 테이블에서는 autovacuum 빈도를 높여야 하며, 모니터링 도구(pg_stat_user_tables)를 통해 VACUUM 실행 횟수와 마지막 VACUUM 타임스탬프를 추적합니다.

PostgreSQL 트랜잭션 격리 수준은 무엇인가요?

PostgreSQL은 SQL 표준의 네 가지 격리 수준을 구현합니다. READ UNCOMMITTED와 READ COMMITTED는 Phantom Read 방지 없이 낮은 격리 수준이고, REPEATABLE READ와 SERIALIZABLE은 더 강한 격리를 제공합니다. 각 격리 수준은 동시 실행으로 인한 이상(anomaly) 방지 범위가 다르며, 동시성과 일관성의 트레이드오프 관계를 형성합니다.

격리 수준 더티 리드 논리적 오류 리드 Phantom Read 충돌 직렬화 특징
READ UNCOMMITTED O O O 미지원 PostgreSQL에서 READ COMMITTED와 동일 동작
READ COMMITTED X O O 미지원 커밋된 데이터만 읽음, 행 수준 잠금
REPEATABLE READ X X O 지원 트랜잭션 내 일관된 스냅샷, SSI 기반
SERIALIZABLE X X X 지원 완전한 격리, 충돌 감지 및 재시작

READ COMMITTED 격리 수준

PostgreSQL의 기본 격리 수준이며, 각 SQL 문 실행 시 새로운 스냅샷을 획득합니다. 커밋된 데이터만 읽으므로 더티 리드는 발생하지 않으나, 같은 트랜잭션 내에서 다른 트랜잭션의 커밋 결과를 중간에 볼 수 있습니다.

논리적 오류 리드 예시:

트랜잭션 A가 행 X를 읽음(값: 100) → 트랜잭션 B가 행 X를 200으로 업데이트 및 커밋 → 트랜잭션 A가 같은 행 X를 다시 읽음(값: 200). 이 경우 트랜잭션 A는 같은 행에서 서로 다른 값을 관찰합니다.

쓰기 충돌 방지를 위해 UPDATE/DELETE 문 실행 시 대상 행에 FOR UPDATE 잠금을 암묵적으로 획득합니다. 다른 트랜잭션이 이미 행을 수정했다면 현재 스냅샷 기준으로 가시적인 최신 버전을 기준으로 조건을 재평가(EvalPlanQual)합니다.

REPEATABLE READ 격리 수준

트랜잭션 시작 시 획득한 스냅샷이 유지되므로, 트랜잭션 내 모든 읽기가 일관된 데이터베이스 상태를 봅니다. 다른 트랜잭션의 커밋 결과가 현재 트랜잭션에 영향을 주지 않으므로 논리적 오류 리드가 방지됩니다.

PostgreSQL은 REPEATABLE READ 격리 수준을 Serializable Snapshot Isolation(SSI)로 구현합니다. SSI는 의존성 그래프 기반으로 직렬화 불가능한 실행 패턴을 감지하고, 충돌 트랜잭션을 강제 롤백시킵니다.

Phantom Read 발생:

트랜잭션 A가 WHERE 조건으로 행 그룹을 읽음(3건) → 트랜잭션 B가 같은 조건의 새 행을 삽입 및 커밋 → 트랜잭션 A가 같은 조건으로 재읽기(4건). REPEATABLE READ에서도 새로운 행(Phantom)이 나타날 수 있습니다. 이는 스냅샷이 기존 행의 버전만 추적하고 새 행 삽입은 추적하지 않기 때문입니다.

SERIALIZABLE 격리 수준

PostgreSQL 9.1 이상에서는 SSI 메커니즘으로 true serializable 격리를 제공합니다. 트랜잭션 간의 읽기/쓰기 의존성을 그래프로 추적하고, 사이클이 형성되면(순환 의존성) 해당 트랜잭션을 자동 롤백합니다.

의존성 추적은 다음 세 가지 충돌을 감지합니다:

  • Read-Write 충돌: 트랜잭션 A의 읽기 집합과 트랜잭션 B의 쓰기 집합이 겹침
  • Write-Read 충돌: 트랜잭션 A의 쓰기 집합과 트랜잭션 B의 읽기 집합이 겹침
  • Write-Write 충돌: 트랜잭션 A의 쓰기 집합과 트랜잭션 B의 쓰기 집합이 겹침

Phantom Read가 방지되므로 범위 조건 기반의 읽기도 보호됩니다. 다만 충돌 감지 오버헤드로 인해 동시성이 감소하고, 롤백된 트랜잭션의 재시도 로직이 필요합니다.

실제 성능 차이와 모니터링은 어떻게 되나요?

격리 수준 선택은 애플리케이션 요구사항과 워크로드 특성에 따라 결정됩니다. READ COMMITTED는 높은 동시성을 제공하지만 애플리케이션 수준의 일관성 제어가 필요하고, SERIALIZABLE은 완전한 격리를 보장하되 충돌 시 재시도 비용이 발생합니다.

격리 수준별 성능 특성

TPC-B 벤치마크(금융 거래 시뮬레이션)에서 PostgreSQL 공식 문서에 따르면:

  • READ COMMITTED: TPS(초당 트랜잭션) 약 8,000~12,000, 잠금 대기 최소
  • REPEATABLE READ: TPS 약 7,000~10,000, SSI 의존성 추적 오버헤드 ~10%
  • SERIALIZABLE: TPS 약 4,000~6,000, 충돌 감지 및 롤백으로 인한 4050% 성능 저하

충돌이 많은 워크로드(같은 행을 여러 트랜잭션이 동시 업데이트)에서는 SERIALIZABLE의 성능 저하가 더 급격합니다.

잠금 모니터링

PostgreSQL은 pg_stat_activity 뷰로 현재 트랜잭션 상태와 잠금 대기를 추적합니다:

SELECT pid, usename, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE wait_event_type IS NOT NULL;

pg_locks 뷰는 획득한 잠금과 대기 중인 잠금을 조회합니다:

SELECT * FROM pg_locks
WHERE NOT granted;

DEADLOCK 감지는 deadlock_timeout 파라미터로 제어되며(기본값: 1초), deadlock이 발생하면 한 트랜잭션이 강제 롤백되고 로그 파일에 기록됩니다.

동시성 제어 시나리오에서의 선택은 어떻게 하나요?

높은 동시성이 필수인 경우

READ COMMITTED 격리 수준을 선택하고 애플리케이션에서 낙관적 잠금(버전 컬럼) 또는 명시적 FOR UPDATE 잠금을 사용합니다. 예를 들어 다중 사용자 공유 자원(재고, 계좌 잔액)의 업데이트 시 다음 패턴을 적용합니다:

  1. 트랜잭션 시작 (READ COMMITTED)
  2. SELECT … FOR UPDATE로 행을 배타 잠금
  3. 조건 재검증 후 UPDATE/DELETE 실행
  4. COMMIT

FOR UPDATE는 READ COMMITTED 내에서도 논리적 오류 리드를 방지합니다.

복잡한 비즈니스 로직 일관성

금융, 결제, 재고 관리 같은 도메인에서는 REPEATABLE READ 이상을 권장합니다. SSI는 읽기 기반 제약(예: 주문 합계가 신용한도를 초과하지 않음)도 자동으로 보호합니다. 애플리케이션은 롤백(SQLSTATE 40001) 시 재시도 로직을 구현해야 합니다:

while True:
    try:
        with connection.cursor() as cur:
            cur.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
            # 비즈니스 로직
            connection.commit()
            break
    except psycopg2.OperationalError as e:
        if e.pgcode == '40001':  # Serialization failure
            connection.rollback()
            time.sleep(random.uniform(0.01, 0.1))  # 지수 백오프
        else:
            raise

보고서/분석 쿼리

읽기 전용 쿼리는 REPEATABLE READ로 설정하면, 트랜잭션 시작 시점의 일관된 스냅샷이 유지되므로 시간이 오래 걸리는 집계 쿼리의 결과도 점프(jump)가 없습니다. 이는 오프라인 분석과 실시간 운영의 경계에서 필수입니다.

정리하면 PostgreSQL MVCC와 격리 수준은 어떤가요?

PostgreSQL의 MVCC는 읽기와 쓰기의 완전한 분리(다중 행 버전)를 통해 잠금 없는 높은 동시성을 달성합니다. 트랜잭션 격리 수준은 READ COMMITTED의 높은 처리량부터 SERIALIZABLE의 완전한 격리까지 선택 범위를 제공하므로, 애플리케이션 요구사항에 맞춰 동시성과 일관성의 균형을 조정할 수 있습니다. 강화된 SSI 구현으로 REPEATABLE READ와 SERIALIZABLE은 사용자 정의 제약까지 자동 보호하므로, 복잡한 비즈니스 로직을 안전하게 작성할 수 있습니다.

MVCC의 저장 공간 증가는 정기적인 VACUUM 및 모니터링으로 관리되며, 데이터베이스 성능과 안정성의 핵심 요소입니다. 격리 수준 선택 시 실제 워크로드 기반 벤치마크와 pg_stat_* 뷰를 통한 지속적인 모니터링이 최적 설정의 필수 조건입니다.

자주 묻는 질문

MVCC에서 행 버전이 무한정 증가하지 않나요?

PostgreSQL의 VACUUM 메커니즘이 불가시적 행 버전을 정기적으로 정리합니다. autovacuum 프로세스는 테이블의 행 변경 비율과 설정된 threshold를 기준으로 자동 실행되며, pg_stat_user_tables 뷰에서 n_live_tup(살아있는 행 수)과 n_dead_tup(죽은 행 수)를 모니터링하여 VACUUM 필요성을 판단합니다. 적절한 VACUUM 빈도 설정이 없으면 테이블 파일 크기가 지속 증가(bloat)하고 조회 성능이 악화됩니다.

READ COMMITTED와 REPEATABLE READ 중 어떤 것을 기본으로 사용해야 하나요?

높은 동시성과 단순한 비즈니스 로직이 필요한 경우(웹 애플리케이션의 대부분) READ COMMITTED를 기본으로 하고, 복잡한 다중 단계 논리나 금융 거래 같은 강한 일관성 요구 도메인에서는 REPEATABLE READ를 사용합니다. 격리 수준은 트랜잭션 단위로 설정 가능하므로, 혼합 사용이 가능합니다(예: 보고서 조회는 REPEATABLE READ, 일반 CRUD는 READ COMMITTED).

SERIALIZABLE은 테이블 전체를 잠금하는 건가요?

아닙니다. SERIALIZABLE은 명시적 행 잠금을 사용하지 않고, 트랜잭션 간 의존성을 감지하여 충돌 시 트랜잭션을 롤백합니다. 다만 감지 오버헤드가 있고 충돌 빈도가 높으면 성능이 저하됩니다. 동시성이 매우 높은 환경에서는 SERIALIZABLE 대신 READ COMMITTED + 명시적 잠금(FOR UPDATE)이 더 효율적일 수 있습니다.

트랜잭션 격리 수준 변경 시 기존 연결은 어떻게 되나요?

SET TRANSACTION 명령은 다음 트랜잭션부터 적용되므로, 현재 진행 중인 트랜잭션의 격리 수준은 변경되지 않습니다. SET SESSION CHARACTERISTICS로 세션 전체 기본값을 변경할 수 있으며, 이 역시 다음 트랜잭션부터 적용됩니다. 데이터베이스 기본 격리 수준은 default_transaction_isolation 파라미터로 설정합니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다