본문 바로가기
DB

트랜잭션 격리 수준 완벽 가이드: 실무에서 만나는 문제와 해결법

by devcomet 2025. 1. 21.
728x90
반응형

트랜잭션 격리 수준 완벽 가이드: 실무에서 만나는 문제와 해결법 썸네일
트랜잭션 격리 수준 완벽 가이드: 실무에서 만나는 문제와 해결법

 

트랜잭션은 데이터베이스 무결성의 핵심입니다.

하지만 동시성 환경에서 예상치 못한 데이터 이상 현상들이 발생할 수 있으며,

이를 제어하는 것이 바로 트랜잭션 격리 수준입니다.

이 글에서는 SQL 표준에서 정의한 4가지 격리 수준의 특성과 실무에서 마주하는 문제들,

그리고 각 데이터베이스별 구현 차이점까지 상세히 다룹니다.


트랜잭션 격리 수준이란?

트랜잭션 격리 수준은 여러 트랜잭션이 동시에 수행될 때 데이터의 일관성을 유지하기 위한 기준을 정의합니다.

ACID 속성 중 Isolation(격리성)의 구체적인 구현이며, 동시 실행되는 트랜잭션들 간의 상호작용을 제어합니다.

SQL 표준은 네 가지 격리 수준을 정의하며, 격리 수준이 높아질수록 데이터의 일관성은 높아지지만 성능은 저하될 수 있습니다.

격리 수준별 특성 비교

격리 수준 Dirty Read Non-Repeatable Read Phantom Read Lost Update Write Skew 성능
READ UNCOMMITTED 허용 허용 허용 허용 허용 ⭐⭐⭐⭐⭐
READ COMMITTED 방지 허용 허용 허용 허용 ⭐⭐⭐⭐
REPEATABLE READ 방지 방지 허용* 방지* 허용* ⭐⭐⭐
SERIALIZABLE 방지 방지 방지 방지 방지 ⭐⭐

 


데이터 이상 현상(Data Anomalies) 상세 분석

Dirty Read (더티 리드)

더티 리드는 한 트랜잭션이 아직 커밋되지 않은 다른 트랜잭션의 변경사항을 읽는 현상입니다.

-- 트랜잭션 A
BEGIN;
UPDATE accounts SET balance = 1000 WHERE id = 1;
-- 아직 COMMIT 하지 않음

-- 트랜잭션 B  
SELECT balance FROM accounts WHERE id = 1;  -- 1000을 읽음 (Dirty Read)

-- 트랜잭션 A가 롤백되면
ROLLBACK;  -- 실제로는 존재하지 않았던 데이터를 B가 읽은 것

실무 예시: 결제 시스템에서 임시로 차감된 잔고를 다른 거래가 읽어서 부족한 잔고로 오판하는 경우

Non-Repeatable Read (반복 불가능한 읽기)

동일한 트랜잭션 내에서 같은 데이터를 두 번 읽었을 때 서로 다른 값을 반환하는 현상입니다.

-- 트랜잭션 A
BEGIN;
SELECT price FROM products WHERE id = 100;  -- 결과: 50000원

-- 트랜잭션 B (중간에 실행)
UPDATE products SET price = 45000 WHERE id = 100;
COMMIT;

-- 트랜잭션 A (계속)
SELECT price FROM products WHERE id = 100;  -- 결과: 45000원 (값이 변경됨)
COMMIT;

실무 예시: 주문 처리 중 상품 가격이 변경되어 최종 계산 금액이 달라지는 경우

Phantom Read (팬텀 리드)

동일한 쿼리를 두 번 실행했을 때 새로운 행이 나타나거나 사라지는 현상입니다.

-- 트랜잭션 A
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 결과: 5개

-- 트랜잭션 B
INSERT INTO orders (status, amount) VALUES ('pending', 10000);
COMMIT;

-- 트랜잭션 A
SELECT COUNT(*) FROM orders WHERE status = 'pending';  -- 결과: 6개 (Phantom Read)

실무 예시: 재고 집계 중 새로운 입고 건이 추가되어 집계 결과가 일치하지 않는 경우

Lost Update (갱신 손실)

두 트랜잭션이 같은 데이터를 읽고 각각 다른 값으로 업데이트할 때, 한 쪽 업데이트가 손실되는 현상입니다.

-- 트랜잭션 A와 B가 동시에 실행
-- 둘 다 balance = 1000을 읽음

-- 트랜잭션 A
UPDATE accounts SET balance = balance + 100 WHERE id = 1;  -- 1100으로 설정

-- 트랜잭션 B  
UPDATE accounts SET balance = balance - 50 WHERE id = 1;   -- 950으로 설정

-- 최종 결과: 950 (A의 +100 업데이트가 손실됨)

실무 예시: 동시에 여러 사용자가 같은 상품의 재고를 수정할 때 일부 변경사항이 손실되는 경우

Write Skew (쓰기 스큐)

두 트랜잭션이 겹치는 데이터를 읽고, 각각 다른 부분을 수정하여 전체적으로 일관성이 깨지는 현상입니다.

-- 병원 당직 시스템: 최소 1명의 의사가 항상 당직해야 함
-- 현재 Alice와 Bob이 당직 중

-- 트랜잭션 A (Alice)
SELECT COUNT(*) FROM doctors WHERE on_call = true;  -- 2명 확인
UPDATE doctors SET on_call = false WHERE name = 'Alice';  -- Alice 당직 해제

-- 트랜잭션 B (Bob)  
SELECT COUNT(*) FROM doctors WHERE on_call = true;  -- 2명 확인
UPDATE doctors SET on_call = false WHERE name = 'Bob';    -- Bob 당직 해제

-- 최종 결과: 당직 의사 0명 (비즈니스 규칙 위반)

실무 예시: 좌석 예약 시스템에서 마지막 두 좌석을 동시에 취소하여 오버부킹이 발생하는 경우


격리 수준별 심층 분석

READ UNCOMMITTED: 최소 격리

특성: 공유 락을 사용하지 않고 배타적 락을 무시하여 가장 높은 동시성을 제공하지만 모든 이상 현상이 발생할 수 있습니다.

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

BEGIN;
-- 커밋되지 않은 데이터도 읽을 수 있음
SELECT * FROM inventory WHERE product_id = 123;

실무 적용:

  • 읽기 전용 보고서나 대시보드
  • 정확성보다 속도가 중요한 분석 쿼리
  • 데이터가 절대 변경되지 않는 정적 테이블 조회

주의사항: 금융, 의료 등 데이터 정확성이 중요한 시스템에서는 사용 금지

READ COMMITTED: 일반적 선택

특성: 커밋된 데이터만 읽을 수 있으며, 대부분의 데이터베이스에서 기본값으로 사용됩니다.

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN;
-- 각 쿼리마다 최신 커밋된 데이터를 읽음
SELECT balance FROM accounts WHERE user_id = 1;
-- 다른 트랜잭션이 이 계정을 수정하고 커밋하면
SELECT balance FROM accounts WHERE user_id = 1;  -- 다른 값을 반환할 수 있음

실무 적용:

  • 웹 애플리케이션의 일반적인 CRUD 작업
  • 단발성 쿼리가 많은 시스템
  • 실시간 데이터 조회가 필요한 경우

데이터베이스별 구현 차이:

  • PostgreSQL: 각 쿼리마다 새로운 스냅샷을 생성
  • Oracle: 일관된 읽기를 위해 MVCC 사용
  • MySQL: InnoDB 엔진에서 언두 로그 활용

REPEATABLE READ: 일관성 보장

특성: 트랜잭션 시작 시점의 데이터 스냅샷을 유지하여 동일한 쿼리의 결과가 항상 같도록 보장합니다.

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

BEGIN;
SELECT * FROM products WHERE category = 'electronics';  -- 100개 행 반환
-- 다른 트랜잭션이 같은 카테고리의 상품을 수정해도
SELECT * FROM products WHERE category = 'electronics';  -- 여전히 같은 100개 행
COMMIT;

 

MySQL의 특별한 구현:
MySQL의 REPEATABLE READ는 일부 팬텀 리드도 방지하는 독특한 구현을 가지고 있습니다.

-- MySQL에서는 다음과 같은 경우에도 팬텀 리드가 방지됨
SELECT * FROM orders WHERE amount > 1000;
-- 다른 트랜잭션이 새로운 고액 주문을 추가해도
-- 같은 결과를 반환 (갭 락 사용)

실무 적용:

  • 복잡한 비즈니스 로직이 포함된 트랜잭션
  • 다단계 계산이 필요한 금융 거래
  • 데이터 일관성이 중요한 배치 처리

SERIALIZABLE: 최고 격리

특성: 모든 트랜잭션이 순차적으로 실행된 것과 같은 결과를 보장합니다.

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

BEGIN;
-- 모든 읽기/쓰기가 직렬화됨
SELECT COUNT(*) FROM tickets WHERE event_id = 100;
UPDATE tickets SET status = 'sold' WHERE id = 1234;
COMMIT;

PostgreSQL의 SSI (Serializable Snapshot Isolation):
PostgreSQL은 진정한 SERIALIZABLE을 제공하며, 의존성 추적을 통해 충돌을 감지합니다.

-- PostgreSQL에서 Write Skew 감지 예시
-- 두 트랜잭션이 충돌하면 하나가 자동으로 롤백됨
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 충돌 발생 시 SQLSTATE '40001' 에러

실무 적용:

  • 금융 거래 시스템의 핵심 트랜잭션
  • 재고 관리의 임계 영역
  • 데이터 무결성이 절대적으로 중요한 경우

성능 최적화 팁:
가능한 경우 READ ONLY 트랜잭션으로 선언하고, 연결 풀을 사용하여 활성 연결 수를 제어하세요.


데이터베이스별 구현 차이점

MySQL (InnoDB)

-- 기본 격리 수준: REPEATABLE READ
SHOW VARIABLES LIKE 'transaction_isolation';

-- 동적 격리 수준 변경
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 갭 락으로 인한 팬텀 리드 방지
SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31' FOR UPDATE;

특징:

  • 단기 실행 쿼리는 REPEATABLE READ, 장기 실행 쿼리는 READ COMMITTED 권장
  • 갭 락(Gap Lock)으로 특정 팬텀 리드 방지
  • 넥스트 키 락(Next-Key Lock) 사용

PostgreSQL

-- 기본 격리 수준: READ COMMITTED  
SHOW default_transaction_isolation;

-- 트랜잭션별 격리 수준 설정
BEGIN ISOLATION LEVEL REPEATABLE READ;

-- 직렬화 실패 처리
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 충돌 시 재시도 로직 필요

특징:

  • REPEATABLE READ는 실제로는 Snapshot Isolation
  • 진정한 SERIALIZABLE 제공 (SSI 사용)
  • MVCC 기반으로 읽기 성능 최적화

Oracle

-- 기본 격리 수준: READ COMMITTED
-- Oracle은 READ UNCOMMITTED와 REPEATABLE READ 미지원

-- Flashback Query로 일관성 있는 읽기
SELECT * FROM orders AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '5' MINUTE);

-- FOR UPDATE를 통한 행 잠금
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

특징:

  • SERIALIZABLE은 실제로는 Snapshot Isolation
  • SCN(System Commit Number)을 이용한 MVCC
  • 읽기와 쓰기가 서로 블록하지 않음

실무 패턴과 베스트 프랙티스

격리 수준 선택 가이드

1. 읽기 중심 애플리케이션

-- 대시보드, 보고서
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 또는 READ ONLY 트랜잭션 사용
SET TRANSACTION READ ONLY;

 

2. 금융/결제 시스템

-- 계좌 이체
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
-- 복잡한 비즈니스 로직
COMMIT;

 

3. 일반 웹 애플리케이션

-- CRUD 작업
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- 기본값 사용

성능 최적화 전략

1. 동적 격리 수준 변경

-- 세션 레벨에서 필요에 따라 변경
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 중요한 트랜잭션만 높은 격리 수준 사용

 

2. 트랜잭션 범위 최소화

-- 나쁜 예: 긴 트랜잭션
BEGIN;
SELECT ... FROM large_table;  -- 시간이 오래 걸리는 쿼리
-- ... 복잡한 비즈니스 로직
UPDATE critical_table SET ...;
COMMIT;

-- 좋은 예: 트랜잭션 분할
SELECT ... FROM large_table;  -- 트랜잭션 외부에서 처리
-- 비즈니스 로직 처리
BEGIN;
UPDATE critical_table SET ...;  -- 필수 부분만 트랜잭션
COMMIT;

 

3. 읽기 전용 최적화

-- PostgreSQL
SET TRANSACTION READ ONLY;
-- MySQL
START TRANSACTION READ ONLY;

에러 처리 패턴

1. Serialization Failure 처리

import psycopg2
from psycopg2 import OperationalError

def execute_with_retry(query, max_retries=3):
    for attempt in range(max_retries):
        try:
            with connection.cursor() as cursor:
                cursor.execute("BEGIN ISOLATION LEVEL SERIALIZABLE")
                cursor.execute(query)
                cursor.execute("COMMIT")
                break
        except OperationalError as e:
            if e.pgcode == '40001':  # serialization_failure
                if attempt == max_retries - 1:
                    raise
                time.sleep(0.1 * (2 ** attempt))  # 지수 백오프
                continue
            raise

2. 데드락 처리

@Transactional(isolation = Isolation.REPEATABLE_READ)
@Retryable(value = {DeadlockLoserDataAccessException.class}, 
           maxAttempts = 3, backoff = @Backoff(delay = 100))
public void transferMoney(Long fromAccount, Long toAccount, BigDecimal amount) {
    // 계좌 이체 로직
}

고급 주제

Snapshot Isolation vs MVCC

스냅샷 격리는 트랜잭션 시작 시점의 데이터베이스 스냅샷을 생성하고, 트랜잭션 기간 동안 이를 사용합니다.

-- PostgreSQL의 Snapshot Isolation (REPEATABLE READ)
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 이 시점의 스냅샷이 트랜잭션 종료까지 유지됨
SELECT * FROM accounts WHERE id = 1;  -- 스냅샷 데이터
-- 다른 트랜잭션이 이 계정을 수정해도 영향 없음
SELECT * FROM accounts WHERE id = 1;  -- 동일한 결과
COMMIT;

분산 시스템에서의 격리

-- 분산 트랜잭션에서의 고려사항
-- 각 노드별로 다른 격리 수준 설정 가능
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 하지만 네트워크 파티션 등으로 인한 추가 복잡성 존재

Application-Level 격리

# 애플리케이션 레벨에서의 격리 구현
class OptimisticLockingService:
    def update_with_version_check(self, entity_id, new_data, version):
        query = """
        UPDATE entities 
        SET data = %s, version = version + 1 
        WHERE id = %s AND version = %s
        """
        result = cursor.execute(query, (new_data, entity_id, version))
        if result.rowcount == 0:
            raise OptimisticLockException("Entity was modified by another transaction")

성능 벤치마크와 모니터링

격리 수준별 성능 측정

-- 성능 테스트 예시 (PostgreSQL)
EXPLAIN (ANALYZE, BUFFERS) 
SELECT * FROM orders 
WHERE status = 'pending' 
AND created_at > CURRENT_DATE - INTERVAL '1 day';

-- 트랜잭션 통계 확인
SELECT 
    schemaname,
    tablename,
    n_tup_ins,
    n_tup_upd,
    n_tup_del
FROM pg_stat_user_tables 
WHERE tablename = 'orders';

락 경합 모니터링

-- MySQL에서 락 대기 상황 확인
SELECT 
    r.trx_id AS waiting_trx_id,
    r.trx_mysql_thread_id AS waiting_thread,
    b.trx_id AS blocking_trx_id,
    b.trx_mysql_thread_id AS blocking_thread
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id;

-- PostgreSQL에서 블로킹 쿼리 확인
SELECT 
    activity.pid,
    activity.usename,
    activity.query,
    blocking.pid AS blocking_pid,
    blocking.query AS blocking_query
FROM pg_stat_activity activity
JOIN pg_locks blocked_locks ON blocked_locks.pid = activity.pid
JOIN pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

문제 해결 사례

사례 1: 전자상거래 재고 관리

문제: 동시 주문으로 인한 음수 재고 발생

해결:

-- 기존 (문제 있는) 코드
BEGIN;
SELECT stock FROM products WHERE id = 123;  -- 10개 확인
-- 애플리케이션에서 재고 검증 (10 >= 5 ✓)
UPDATE products SET stock = stock - 5 WHERE id = 123;
COMMIT;

-- 개선된 코드
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE products SET stock = stock - 5 
WHERE id = 123 AND stock >= 5;
-- 영향받은 행이 0이면 재고 부족
COMMIT;

사례 2: 예약 시스템의 동시성 제어

문제: 마지막 좌석의 중복 예약

해결:

-- SELECT FOR UPDATE 사용
BEGIN;
SELECT available_seats 
FROM events 
WHERE id = 100 
FOR UPDATE;  -- 행 잠금

-- 좌석 확인 후 예약 처리
UPDATE events 
SET available_seats = available_seats - 1 
WHERE id = 100 AND available_seats > 0;
COMMIT;

사례 3: 은행 계좌 이체

문제: 잔고 부족 상태에서의 이체 승인

해결:

BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 출금 계좌 잠금 및 잔고 확인
SELECT balance FROM accounts WHERE id = :from_account FOR UPDATE;

-- 원자적 이체 처리
UPDATE accounts SET balance = balance - :amount 
WHERE id = :from_account AND balance >= :amount;

UPDATE accounts SET balance = balance + :amount 
WHERE id = :to_account;

-- 제약 조건 위반 시 자동 롤백
COMMIT;

마이그레이션 가이드

격리 수준 변경 시 고려사항

1. 단계적 적용

-- 1단계: 읽기 전용 쿼리부터 변경
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 2단계: 중요하지 않은 쓰기 작업
-- 3단계: 핵심 비즈니스 로직

 

2. 호환성 테스트

# 격리 수준 변경 전후 비교 테스트
def test_isolation_level_change():
    # 기존 격리 수준에서 테스트
    with connection.cursor() as cursor:
        cursor.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
        # 테스트 시나리오 실행

    # 새로운 격리 수준에서 테스트  
    with connection.cursor() as cursor:
        cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
        # 동일한 테스트 시나리오 실행
        # 결과 비교

 

3. 성능 영향 분석

-- 변경 전후 성능 메트릭 수집
SELECT 
    query,
    mean_time,
    stddev_time,
    calls
FROM pg_stat_statements 
WHERE query LIKE '%critical_table%'
ORDER BY mean_time DESC;

결론

트랜잭션 격리 수준은 데이터 정합성과 성능 사이의 트레이드오프를 결정하는 핵심 요소입니다.

핵심 가이드라인:

  1. READ COMMITTED: 일반적인 웹 애플리케이션의 기본 선택
  2. REPEATABLE READ: 복잡한 비즈니스 로직이 포함된 트랜잭션
  3. SERIALIZABLE: 데이터 무결성이 절대적으로 중요한 금융/의료 시스템

각 데이터베이스의 구현 차이를 이해하고,

애플리케이션의 요구사항에 맞는 적절한 격리 수준을 선택하여 안정적이고 성능이 우수한 시스템을 구축하시기 바랍니다.

 

참고 링크:

728x90
반응형