데이터 중심 애플리케이션 설계를 읽다가 챕터7 트랜잭션 내용이 좋아서 정리해보았습니다.
우선 책에 있는 동시성문제와 격리수준을 소개합니다.
동시성문제
1. 더티 읽기 (Dirty Read)
시나리오: 은행 계좌 이체 중 잔액 확인
- A 고객이 B 고객에게 100만원을 이체하는 트랜잭션 시작
- A의 계좌에서 100만원이 차감됨 (아직 커밋되지 않음)
- 이때 C 고객이 A의 계좌 잔액을 조회
- A의 이체 트랜잭션이 실패하여 롤백됨
문제점
- C 고객은 실제로는 존재하지 않는(롤백된) 잔액을 보게 됨
- 잘못된 데이터를 읽어서 사용하는 문제
2. 더티 쓰기 (Dirty Write)
시나리오: 은행 계좌 이체
- 트랜잭션 A가 상품 가격을 10000원에서 20000원으로 수정 (아직 커밋 전)
- 트랜잭션 B가 같은 상품 가격을 15000원으로 수정하고 커밋
- 트랜잭션 A가 롤백됨
- 트랜잭션 B의 변경사항이 사라지고, 원래 가격인 10000원으로 복구됨
문제점
- 금액을 이제했지만 잔액에 포함되어 있지 않음
- 데이터 자체가 손실되거나 일관성이 깨지는 문제
3. 비반복 읽기 (Non-repeatable Read)
시나리오: 상품 가격 변경 중 주문 처리
- 고객이 쇼핑몰에서 상품 상세 페이지를 봄 (가격: 10,000원)
- 관리자가 해당 상품 가격을 15,000원으로 변경하고 커밋
- 고객이 장바구니에 담고 결제 페이지로 이동
- 결제 시점에 다시 가격을 조회하니 15,000원으로 변경됨
문제점
- 고객이 동일한 트랜잭션 내에서 다른 가격을 보게 됨
4. 팬텀 읽기 (Phantom Read)
시나리오: 일일 매출 집계 중 새로운 주문 발생
- 관리자가 오늘의 총 매출을 계산하기 위해 모든 주문을 조회 (100건)
- 계산 작업을 수행하는 동안 새로운 주문이 들어옴
- 검증을 위해 다시 주문 수를 조회하니 101건으로 변경됨
문제점
- 존재하지 않는 행에 대해서는 잠금을 획득 할 수 없기 때문에 쓰기스큐를 유발하는 이유가 됨
5. 갱신 손실 (Lost Update)
시나리오: 동시 좋아요 처리
- 게시글의 현재 좋아요 수가 100개
- 사용자 A가 좋아요 버튼을 클릭 (100 → 101)
- 동시에 사용자 B도 좋아요 버튼을 클릭 (100 → 101)
- 최종 결과가 101이 됨 (102가 되어야 정상)
문제점
- 동시 업데이트로 인해 한 사용자의 작업이 무시됨
6. 쓰기 스큐 (Write Skew)
시나리오: 병원 당직 의사 스케줄 관리
- 병원 규칙: 항상 최소 1명의 의사가 당직해야 함
- A 의사가 당직을 하고 있고, B 의사도 당직 중
- A 의사가 다른 의사가 있다고 확인하고 당직을 취소
- 동시에 B 의사도 같은 이유로 당직을 취소
- 결과적으로 당직 의사가 0명이 됨
문제점
- 각각의 트랜잭션은 규칙을 지켰지만, 최종 결과는 비즈니스 규칙 위반
- 복잡한 제약 조건이 있는 경우 자주 발생할 수 있음
격리수준
읽기 커밋 (Read Committed)
- 가장 기본적인 격리 수준
- 커밋된 데이터만 읽을 수 있음
- 더티 읽기는 방지하지만, 비반복 읽기와 팬텀 읽기는 발생 가능
반복 가능한 읽기 (Repeatable Read)
- 트랜잭션 내에서 같은 쿼리는 항상 같은 결과를 반환
- 비반복읽기 팬텀읽기 방지
스냅숏 격리 (Snapshot Isolation)
- 각 트랜잭션은 시작 시점의 일관된 데이터베이스 스냅숏을 보게 됨
- MVCC(Multi-Version Concurrency Control)를 사용하여 구현
- PostgreSQL에서는 REPEATABLE READ가 실제로 Snapshot Isolation으로 구현됨
- Oracle에서는 Serializable이 Snapshot Isolation으로 구현됨
직렬화 (Serializable)
- 가장 강력한 격리 수준
- 모든 동시성 문제 방지
- 성능 비용이 가장 큼
아래는 제가 책을 읽고 추가적으로 궁금했던 내용들을 정리한 것 입니다.
더티쓰기는 어떻게 방지되는나요?
update는 기본적으로 row-level lock이 걸려서 READ UNCOMMITTED(가장 낮은 격리수준) 에서도 방지됩니다.
비반복읽기와 팬텀읽기의 차이점
비반복읽기와 팬텀읽기 모두 같은 트랜잭션에서 동일한 쿼리가 다른 결과값을 반환하기 때문에 발생하는 문제인데, 이 문제는 repeatable read 격리수준을 사용하면 같은 트랜잭션 내에서 같은 스냅샷을 보기 때문에 해결이 가능합니다.
차이점은 동일레코드에 대한 조건인지, 범위조회에 대한 조건인지에 대한 차이만 존재하게 됩니다.
하지만 쓰기 스큐가 발생한다면 더 큰 차이가 있습니다
비반복 읽기로 인한 쓰기 스큐
- 이미 존재하는 레코드에 대한 제약조건 위반
- SELECT FOR UPDATE로 해결 가능
팬텀 읽기로 인한 쓰기 스큐
- 범위 조건에 대한 제약조건 위반
- SELECT FOR UPDATE로 해결 불가능
- SERIALIZABLE 격리 수준 필요
SELECT FOR UPDATE 및 쓰기 스큐 유형별 내용은 맨 아래 적어두었습니다.
REPEATABLE READ가 쓰기 스큐를 해결하지 못하는 이유(SERIALIZABLE과 REPEATABLE READ의 차이점)
예시(도서관 좌석 예약 시스템)
- REPEATABLE READ의 경우:
- 사용자 A: "빈 좌석이 있나? 응 1번 좌석이 비어있어"
- 사용자 B: "빈 좌석이 있나? 응 1번 좌석이 비어있어"
- 둘 다 1번 좌석 예약 시도 → 중복 예약 발생 가능
- SERIALIZABLE의 경우:
- 첫 번째 트랜잭션이 "빈 좌석" 조건에 대한 잠금
- 두 번째 트랜잭션은 직렬화 오류 발생
- 중복 예약 방지
차이점
- REPEATABLE READ는:
- 개별 레코드의 읽기 일관성은 보장
- 하지만 "조건"이나 "범위"에 대한 보호는 제공하지 않음
- SERIALIZABLE의 경우:
- 범위 조건에 대한 술어 잠금(Predicate Lock) 제공
- 두 트랜잭션이 같은 범위의 데이터를 수정하려 할 때 하나만 성공
- 다른 하나는 직렬화 실패(Serialization Failure) 발생
쓰기 스큐는
- 단순한 데이터 읽기/쓰기가 아닌 "조건"에 기반한 의사결정 포함
- 이러한 "조건"에 대한 보호가 필요
- SERIALIZABLE 격리 수준에서만 완벽하게 해결 가능
따라서 REPEATABLE READ는 쓰기 스큐를 해결하지 못합니다.
PostgreSQL MVCC 구현 방식이 어떻게 되나요?
MVCC 기본 원리
- 데이터의 각 행은 여러 버전으로 존재
- 각 버전은 트랜잭션 ID(xmin, xmax)로 관리
- xmin: 해당 버전을 생성한 트랜잭션 ID
- xmax: 해당 버전을 삭제/수정한 트랜잭션 ID
동작 방식
- 트랜잭션 시작
- 트랜잭션에 고유한 ID 할당
- 현재 활성화된 트랜잭션 목록 스냅샷 생성
- 읽기 동작
- 각 행의 버전 중에서 해당 트랜잭션이 볼 수 있는 버전 선택
- 조건: xmin ≤ 현재 트랜잭션 ID < xmax
- 쓰기 동작
- 새로운 버전 생성 (기존 버전은 유지)
- 새 버전의 xmin은 현재 트랜잭션 ID
- 이전 버전의 xmax를 현재 트랜잭션 ID로 설정
행: (id=1, value='A')
초기상태: (xmin=100, xmax=null)
트랜잭션 200이 값을 'B'로 수정하면:
- 이전 버전: (xmin=100, xmax=200, value='A')
- 새 버전: (xmin=200, xmax=null, value='B')
Multi-Version이라고 부르는 이유는 하나의 데이터 행이 여러 버전으로 동시에 존재하기 때문입니다.
이를 통해 각 트랜잭션은 자신의 시점에 맞는 일관된 버전을 볼 수 있게 됩니다.
Postgrsql에서 Dead Tuple이 무엇인가요?
Dead tuple은 MVCC로 인해 생성된 과거 버전의 데이터입니다.
Dead tuple 아래와 같은 상황에서 발생합니다.
- 데이터 수정 시
원본 데이터: (xmin=100, xmax=null, value='A')
트랜잭션 200이 값을 'B'로 수정하면:
- 이전 버전: (xmin=100, xmax=200, value='A') <- Dead Tuple
- 새 버전: (xmin=200, xmax=null, value='B')
- 데이터 삭제 시
원본 데이터: (xmin=100, xmax=null, value='A')
트랜잭션 200이 삭제하면:
- Dead Tuple: (xmin=100, xmax=200, value='A')
Postgresql VACUUM
VACUUM은 dead-tuple을 제거하는 행동을 의미합니다. 일반 VACUUM 과 VACUUM FULL 이 있습니다.
일반 VACUUM
VACUUM my_table;
- 테이블 잠금 없이 동작
- 실행 중인 트랜잭션 방해하지 않음
- Dead tuple 중 어떤 트랜잭션도 볼 수 없는 것만 제거
- 디스크 공간을 OS에 반환하지 않음
VACUUM FULL
VACUUM FULL my_table;
- 테이블에 ACCESS EXCLUSIVE LOCK 필요
- 모든 Dead tuple 제거
- 테이블 전체를 재작성하여 디스크 공간 회수
- 실행 중인 트랜잭션이 있으면 대기
롱트랜잭션이랑 dead-tuple 연관성
dead-tuple을 제거하는 vacuum은 사용중인 스냅샵 이전 버전만 포함하기 때문에 롱트랜잭션의 스냅샷 이후 생성된 모든 Dead tuple이 디스크에 쌓이게 됩니다.
update가 매우 빠르게 일어나는 테이블의 경우 롱트랜잭션이 길어지게 되면 금방 DB 장애로 이어질 수 있습니다.
SELECT FOR UPDATE 로 해결할 수 있는 동시성 문제
1. 비반복 읽기 (Non-repeatable Read)
-- 해결 전
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000원
-- 다른 트랜잭션이 잔액을 2000원으로 수정
SELECT balance FROM accounts WHERE id = 1; -- 2000원 (값이 변경됨)
-- SELECT FOR UPDATE로 해결
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 1000원
-- 다른 트랜잭션의 수정 시도는 대기
SELECT balance FROM accounts WHERE id = 1; -- 1000원 (값이 보호됨)
2. 갱신 손실 (Lost Update)
-- 해결 전
BEGIN;
SELECT likes FROM posts WHERE id = 1; -- 100개
UPDATE posts SET likes = likes + 1; -- 다른 트랜잭션과 충돌 가능
-- SELECT FOR UPDATE로 해결
BEGIN;
SELECT likes FROM posts WHERE id = 1 FOR UPDATE; -- 100개
-- 다른 트랜잭션은 대기
UPDATE posts SET likes = likes + 1; -- 안전하게 업데이트
3. 쓰기 스큐 (특정 상황에서)
-- 병원 당직 예제
BEGIN;
SELECT COUNT(*) FROM doctor_shifts
WHERE shift_date = CURRENT_DATE
FOR UPDATE; -- 전체 당직 의사 수를 잠금과 함께 확인
-- 다른 트랜잭션의 당직 변경은 대기
UPDATE doctor_shifts SET is_on_duty = false
WHERE doctor_id = 1;
다만 중간에 새로운 레코드가 추가되는 경우는 잠금에 실패합니다. 존재하지 않는 레코드는 잠금을 획득할 수 없기 때문입니다.
이럴 땐 postgresql Advisory Lock 기능을 사용해봐도 좋을 것 같습니다.
# Session 레벨과 Transaction 레벨의 차이
SELECT pg_advisory_lock(1234); # 세션 종료까지 유지
SELECT pg_advisory_xact_lock(1234); # 트랜잭션 종료시 자동 해제
# 비차단 잠금 시도
SELECT pg_try_advisory_lock(1234); # 즉시 실패하고 false 반환
유형별 쓰기 스큐
비반복 읽기로 인한 쓰기 스큐
-- Transaction 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 1000원
-- 잔액이 충분하다고 판단
-- Transaction 2
UPDATE accounts SET balance = 500
WHERE id = 1;
COMMIT;
-- Transaction 1
UPDATE accounts SET balance = balance - 800;
-- 잔액 부족 상황 발생
해결 방법: SELECT FOR UPDATE
BEGIN;
SELECT balance FROM accounts
WHERE id = 1
FOR UPDATE; -- 잠금 획득
팬텀 읽기로 인한 쓰기 스큐
-- Transaction 1
BEGIN;
SELECT COUNT(*) FROM doctors
WHERE is_on_duty = true; -- 2명
-- Transaction 2
INSERT INTO doctors (name, is_on_duty)
VALUES ('Dr. Smith', true);
COMMIT;
-- Transaction 1
UPDATE doctors SET is_on_duty = false
WHERE doctor_id = 1;
-- 새로 추가된 의사까지 고려된 상태
SELECT FOR UPDATE로 해결 불가능한 이유:
- 아직 존재하지 않는 행은 잠글 수 없음
- 범위 조건에 대한 완벽한 보호 불가능
해결 방법 1: SERIALIZABLE
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 직렬화 가능성 보장
해결 방법 2: Advisory Lock
-- 비즈니스 로직에 대한 잠금
SELECT pg_advisory_xact_lock(
hashtext('doctor_duty_change')
);