트랜잭션은 데이터베이스 무결성의 핵심입니다.
하지만 동시성 환경에서 예상치 못한 데이터 이상 현상들이 발생할 수 있으며,
이를 제어하는 것이 바로 트랜잭션 격리 수준입니다.
이 글에서는 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;
결론
트랜잭션 격리 수준은 데이터 정합성과 성능 사이의 트레이드오프를 결정하는 핵심 요소입니다.
핵심 가이드라인:
- READ COMMITTED: 일반적인 웹 애플리케이션의 기본 선택
- REPEATABLE READ: 복잡한 비즈니스 로직이 포함된 트랜잭션
- SERIALIZABLE: 데이터 무결성이 절대적으로 중요한 금융/의료 시스템
각 데이터베이스의 구현 차이를 이해하고,
애플리케이션의 요구사항에 맞는 적절한 격리 수준을 선택하여 안정적이고 성능이 우수한 시스템을 구축하시기 바랍니다.
참고 링크:
'DB' 카테고리의 다른 글
Redis Cluster vs Sentinel - 고가용성 아키텍처 선택 가이드 (0) | 2025.06.14 |
---|---|
트랜잭션에서 발생하는 데드락(Deadlock) 실전 예제와 해결 전략 (0) | 2025.05.18 |
데이터베이스 파티셔닝 전략 비교: MySQL vs PostgreSQL 성능 최적화 완벽 가이드 (0) | 2025.01.21 |
[PostgreSQL] PostgreSQL JSONB를 활용한 복잡한 데이터 처리 (0) | 2025.01.20 |
Oracle ORA-04036 에러 해결 및 SGA/PGA 메모리 최적화 완전 가이드 (6) | 2025.01.17 |