infra
Platform

모듈 맵

[Database] 트랜잭션 격리 수준(Isolation Level)과 이상 현상 제어

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 32 / 37

[Database] 트랜잭션 격리 수준(Isolation Level)과 이상 현상 제어

READ COMMITTED vs REPEATABLE READ — 동시 요청이 많아질 때 발생하는 데이터 불일치의 원인과 격리 수준 선택 기준

🚨INCIDENT ALERT
HIGH

동시에 실행되는 요청이 같은 데이터를 읽고 쓰면 눈에 보이지 않는 경쟁 조건이 생깁니다. 트랜잭션 격리 수준을 모르면 테스트에서는 통과한 로직이 운영에서 중복 처리나 잔액 오류를 냅니다. 격리 수준별 현상을 이해하면 필요한 만큼만 강하게 잠글 수 있습니다.

이번 챕터에서 배울 것

격리 수준을 외우는 게 목표가 아닙니다. "언제 어떤 버그가 생기고, 어떻게 방지하는가"를 이해하면 됩니다.

  • 14가지 격리 수준 — READ UNCOMMITTED부터 SERIALIZABLE까지
  • 2더티 리드·비반복 읽기·팬텀 리드 — 각 문제가 발생하는 조건
  • 3MySQL InnoDB vs PostgreSQL 기본값 차이와 실무 영향
  • 4SELECT FOR UPDATE — 읽기에 잠금 거는 실전 패턴
  • 5격리 수준 선택 기준 — 서비스 유형별 권고

트랜잭션 격리 수준 — 더티 리드, 팬텀 리드, 동시성 제어

동시 요청이 10개에서 1,000개로 늘어나는 순간, 지금까지 없던 버그가 나타납니다. 잔액이 음수가 되거나, 방금 INSERT한 데이터가 조회되지 않거나, 같은 쿼리가 두 번 다른 결과를 돌려줍니다. 트랜잭션 격리 수준은 이 문제들이 언제, 왜 발생하는지 정의하는 규칙입니다.


실습 환경 준비

💡개념

4가지 격리 수준과 발생 가능한 문제

재고 차감 트랜잭션이 두 개 동시에 실행됩니다. 둘 다 현재 재고를 읽고, 둘 다 1씩 차감합니다. 결과적으로 재고가 2 줄어야 하는데 1만 줄어들었습니다. 두 트랜잭션이 서로의 변경을 보지 못한 채 동시에 같은 값을 읽었기 때문입니다. 격리 수준은 이런 동시성 문제를 얼마나 방지할지를 결정합니다. 높을수록 안전하지만 성능이 떨어지고, 낮을수록 빠르지만 이상 현상이 생깁니다.

4가지 격리 수준과 발생 가능한 문제

격리 수준은 "동시에 실행 중인 트랜잭션이 서로의 변경을 얼마나 볼 수 있는가"를 결정합니다. 높을수록 안전하지만 성능이 떨어집니다. 아래 표에서 어느 수준에서 어떤 문제가 방지되는지 한눈에 볼 수 있습니다.

격리 수준          더티 리드   비반복 읽기   팬텀 리드
─────────────────────────────────────────────────────
READ UNCOMMITTED    발생        발생         발생
READ COMMITTED      방지        발생         발생     ← PostgreSQL 기본
REPEATABLE READ     방지        방지         발생     ← MySQL InnoDB 기본
SERIALIZABLE        방지        방지         방지

더티 리드 (Dirty Read): 아직 커밋되지 않은 다른 트랜잭션의 변경을 읽음 비반복 읽기 (Non-Repeatable Read): 같은 행을 두 번 SELECT했을 때 다른 트랜잭션의 COMMIT으로 결과가 달라짐 팬텀 리드 (Phantom Read): 같은 WHERE 조건으로 두 번 SELECT했을 때 결과 행 수가 달라짐

SQL
-- 현재 격리 수준 확인
-- PostgreSQL
SHOW transaction_isolation;

-- MySQL
SELECT @@transaction_isolation;

-- 세션 단위로 격리 수준 변경
-- PostgreSQL
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- MySQL
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • 동시 세션 결과두 트랜잭션에서 같은 데이터를 읽고 쓸 때 결과가 어떻게 달라지는지 확인합니다.
  • 격리 수준READ COMMITTED와 REPEATABLE READ 차이가 실습 결과에 나타나는지 봅니다.
  • 락 대기강한 격리 수준에서 대기나 데드락 가능성이 생기는지 점검합니다.

💡개념

READ COMMITTED vs REPEATABLE READ — 실제로 뭐가 다른가

같은 트랜잭션 내에서 SELECT balance FROM accounts WHERE id = 1을 두 번 실행할 때:

READ COMMITTED vs REPEATABLE READ — 실제로 뭐가 다른가

SQL
-- [터미널 A] 트랜잭션 시작
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- → 10000 반환

-- [터미널 B] 이 시점에 다른 트랜잭션이 업데이트하고 커밋
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;

-- [터미널 A] 같은 쿼리를 다시 실행
SELECT balance FROM accounts WHERE id = 1;
-- READ COMMITTED → 5000  (다른 트랜잭션의 COMMIT이 즉시 반영)
-- REPEATABLE READ → 10000  (트랜잭션 시작 시점 스냅샷 유지)

실무 영향: 서비스 성격에 따라 적절한 격리 수준이 다릅니다.

상황권장 격리 수준
일반 웹 서비스 CRUDREAD COMMITTED
재고·잔액 차감REPEATABLE READ + SELECT FOR UPDATE
정산·회계 배치REPEATABLE READ
순서 보장이 절대적인 금융SERIALIZABLE (드물게)
SQL
-- 재고 차감 안전 패턴 (SELECT FOR UPDATE)
BEGIN;

-- 행 잠금: 다른 트랜잭션이 이 행을 수정하면 여기서 대기
SELECT stock FROM products WHERE id = 42 FOR UPDATE;

-- 재고 충분할 때만 차감
UPDATE products SET stock = stock - 1 WHERE id = 42 AND stock > 0;

COMMIT;

💡개념

MVCC — 잠금 없이 동시성 확보하는 원리

읽기 트랜잭션이 많은 테이블에서 쓰기가 발생하면 잠금 경합이 생깁니다. 읽기가 쓰기를 막거나, 쓰기가 읽기를 막으면 동시 요청이 많아질수록 성능이 떨어집니다. 그런데 실제 PostgreSQL은 읽기와 쓰기가 서로를 차단하지 않습니다. MVCC 덕분에 읽기는 항상 일관된 스냅샷을 보고, 쓰기는 동시에 진행됩니다. 이 원리를 알면 잠금 없이도 일관성을 확보하는 이유를 이해할 수 있습니다.

MVCC — 잠금 없이 동시성 확보하는 원리

PostgreSQL과 MySQL InnoDB는 MVCC(Multi-Version Concurrency Control)로 읽기-쓰기 충돌을 줄입니다.

트랜잭션 A (읽기)                트랜잭션 B (쓰기)
─────────────────────────────────────────────────
BEGIN;                           BEGIN;
SELECT balance → 10000           UPDATE balance = 5000;
(스냅샷에서 읽음,                 (새 버전 생성,
 B의 변경과 충돌 없음)             A의 읽기를 차단 안 함)
                                 COMMIT;
SELECT balance → 10000           (구 버전은 VACUUM이
(여전히 스냅샷 값)                 나중에 정리)
COMMIT;

MVCC 덕분에 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않습니다. 배치 집계 쿼리가 실행 중이어도 OLTP 트랜잭션이 차단되지 않는 이유가 이것입니다.


실습: 비반복 읽기 직접 확인

SQL
-- 터미널 A: READ COMMITTED에서 비반복 읽기 발생시키기
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 10000

-- 터미널 B에서 실행 후 COMMIT:
-- UPDATE accounts SET balance = 5000 WHERE id = 1; COMMIT;

SELECT balance FROM accounts WHERE id = 1;  -- 5000 (달라짐)
COMMIT;

-- 터미널 A: REPEATABLE READ에서는 방지
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 10000

-- 터미널 B에서 동일하게 UPDATE 후 COMMIT

SELECT balance FROM accounts WHERE id = 1;  -- 10000 (스냅샷 유지)
COMMIT;

SERIALIZABLE에서 직렬화 실패

로컬 터미널
# 증상: SERIALIZABLE 격리 수준에서 UPDATE 시 에러
ERROR:  could not serialize access due to concurrent update
DETAIL:  Process 1234 updated row in relation "accounts" while process 5678 was reading it.

# 원인: 두 트랜잭션이 직렬 실행 불가능한 순서로 같은 데이터에 접근

# 해결: 애플리케이션 레벨에서 재시도(Retry) 로직 구현
# SERIALIZABLE은 실패 트랜잭션을 자동으로 롤백하므로
# BEGIN ... COMMIT 블록을 재시도해야 함

# 일반 서비스에서는 SERIALIZABLE 대신 REPEATABLE READ + FOR UPDATE가 실용적

동시 요청으로 인한 데이터 불일치

SQL
-- 증상: 재고 1개인데 동시 요청 2개가 모두 차감에 성공

-- 원인: 잠금 없이 읽고 쓰는 패턴
SELECT stock FROM products WHERE id = 42;  -- 1 읽음
-- (동시 요청도 1을 읽고 차감 성공)
UPDATE products SET stock = stock - 1 WHERE id = 42;  -- 0으로

-- 해결: SELECT FOR UPDATE로 읽기 시점에 잠금
BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
-- 두 번째 요청은 이 지점에서 대기
UPDATE products SET stock = stock - 1 WHERE id = 42 AND stock > 0;
COMMIT;
-- 두 번째 요청이 실행되면 stock=0이므로 UPDATE가 0행 영향

💼
실무 맥락
현업 패턴

실무에서 격리 수준을 다루는 순간

서비스별 권고 격리 수준: 서비스 유형별로 기본 격리 수준을 설정하는 예시입니다.

SQL
-- 대부분의 웹 서비스 (READ COMMITTED, PostgreSQL 기본)
-- 소셜 피드, 게시판, 상품 조회 → 잠금 없이 최신 데이터 빠르게 읽기

-- 결제/재고 서비스 (REPEATABLE READ + FOR UPDATE)
-- 티켓 예약, 재고 차감, 포인트 사용

-- 정산 배치 (REPEATABLE READ, MySQL InnoDB 기본)
-- 월말 정산, 통계 집계 → 집계 중 데이터 변경으로 인한 불일치 방지

-- MySQL에서 격리 수준 확인
SELECT @@global.transaction_isolation;   -- 전역 기본값
SELECT @@session.transaction_isolation;  -- 현재 세션

-- PostgreSQL에서 특정 트랜잭션만 격리 수준 변경
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... 중요한 집계 쿼리
COMMIT;

ORM 사용 시 주의점: ORM은 격리 수준을 추상화하지만 내부적으로는 동일한 DB 트랜잭션이 실행됩니다.

Python
# Django: 트랜잭션 격리 수준 설정
DATABASES = {
    'default': {
        'OPTIONS': {
            'isolation_level': 'read committed',  # 명시적 설정
        }
    }
}

# Spring: @Transactional에 격리 수준 지정
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processPayment(Long accountId, int amount) {
    Account account = accountRepository.findByIdForUpdate(accountId);
    account.deduct(amount);
}

MySQL과 PostgreSQL의 기본 격리 수준이 다르므로, 두 DB를 모두 지원하는 서비스는 명시적으로 격리 수준을 설정해야 합니다.

💡개념

데드락 — 진단과 회피 패턴

두 트랜잭션이 서로 상대방의 잠금이 풀리기를 기다리다가 영원히 진행되지 못하는 상태가 데드락입니다. 주문 서비스에서 A 트랜잭션이 orders → order_items 순으로 잠금을 잡고, B 트랜잭션이 order_items → orders 순으로 잠금을 잡으면 서로를 기다리다가 DB가 한쪽을 강제 롤백합니다. 애플리케이션 로그에 갑작스러운 트랜잭션 실패가 보이면 데드락을 의심해야 합니다. 진단 방법과 회피 패턴을 알면 데드락은 예방 가능한 문제입니다.

데드락 발생 원리

트랜잭션 A                          트랜잭션 B
─────────────────────────────────────────────────
BEGIN;                               BEGIN;
UPDATE orders SET ... WHERE id=1;    UPDATE order_items SET ... WHERE id=10;
-- orders 행 잠금 획득                -- order_items 행 잠금 획득

UPDATE order_items SET ... WHERE id=10;  UPDATE orders SET ... WHERE id=1;
-- order_items 잠금 대기 (B가 보유)      -- orders 잠금 대기 (A가 보유)

-- 서로를 기다리다 DB가 데드락 감지 → 한쪽을 롤백

PostgreSQL 데드락 진단

SQL
-- 현재 대기 중인 잠금 확인
SELECT
    blocked.pid          AS blocked_pid,
    blocked.query        AS blocked_query,
    blocking.pid         AS blocking_pid,
    blocking.query       AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

-- 데드락 발생 시 로그 확인 (postgresql.conf)
-- log_lock_waits = on
-- deadlock_timeout = 1s  (1초 이상 잠금 대기 시 로그)

MySQL 데드락 진단

위험 명령어서버 재시작 없이 즉시 전체 MySQL 인스턴스에 적용됩니다

MySQL 전역 설정 변경

안전한 실행 조건: my.cnf에도 동일하게 추가해야 재시작 후에도 유지됩니다

실행 전 반드시 확인

  • 운영 DB가 아닌 테스트 환경에서 먼저 검증했는가
  • 변경 내용을 팀과 공유했는가
  • 로그 파일 디스크 용량이 충분한가
SET GLOBAL innodb_print_all_deadlocks = ON

위 항목을 모두 확인한 후 복사할 수 있습니다

SQL
-- 가장 최근 데드락 상세 정보 확인
SHOW ENGINE INNODB STATUS\G
-- "LATEST DETECTED DEADLOCK" 섹션에서
-- 어떤 트랜잭션이 어떤 행의 잠금을 가지고 있었는지 확인

-- 데드락 로그 자동 기록 활성화
SET GLOBAL innodb_print_all_deadlocks = ON;
-- /var/log/mysql/error.log 에 기록됨

데드락 회피 3원칙

① 잠금 획득 순서를 일관되게 유지 모든 트랜잭션이 항상 같은 순서로 테이블/행을 잠그도록 코드 규칙을 만듭니다.

Python
# 나쁜 예: 트랜잭션마다 잠금 순서가 다름
# 트랜잭션 A: account_a 잠금 → account_b 잠금
# 트랜잭션 B: account_b 잠금 → account_a 잠금

# 좋은 예: 항상 id 오름차순으로 잠금
def transfer(from_id, to_id, amount):
    first_id, second_id = sorted([from_id, to_id])
    # first_id 행 잠금 → second_id 행 잠금 (일관된 순서)
    with transaction():
        first = Account.objects.select_for_update().get(id=first_id)
        second = Account.objects.select_for_update().get(id=second_id)

② 트랜잭션을 짧게 유지 잠금을 오래 보유할수록 데드락 가능성이 높아집니다. 트랜잭션 안에서 외부 API 호출, 파일 I/O, 긴 연산을 하지 않습니다.

③ 데드락 재시도 로직 구현 데드락은 완전히 제거하기 어렵습니다. 애플리케이션에서 데드락 에러(ERROR 1213 MySQL, ERROR 40P01 PostgreSQL)를 감지해 자동 재시도하는 것이 현실적입니다.

Python
from sqlalchemy.exc import OperationalError
import time

def execute_with_retry(session, func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func(session)
        except OperationalError as e:
            if 'deadlock' in str(e).lower() and attempt < max_retries - 1:
                session.rollback()
                time.sleep(0.1 * (attempt + 1))  # 점진적 대기
                continue
            raise

다음 모듈에서는 낙관적 락(Optimistic Lock)과 비관적 락의 차이, 그리고 동시성 충돌 제어 전략을 다룹니다.

지식 확인

퀴즈 — 5문제

Q1

PostgreSQL의 기본 격리 수준은 READ COMMITTED인데, 같은 트랜잭션 내에서 SELECT를 두 번 실행했을 때 결과가 다를 수 있다. 이 현상은?

Q2

결제 서비스에서 '잔액 조회 → 잔액 차감' 두 쿼리 사이에 다른 요청이 같은 잔액을 동시에 차감해 잔액이 음수가 됐다. 이를 방지하는 가장 적절한 방법은?

Q3

MySQL(InnoDB) 기본 격리 수준이 REPEATABLE READ인데 PostgreSQL은 READ COMMITTED다. 같은 코드가 두 DB에서 다르게 동작할 수 있는 시나리오는?

Q4

대용량 배치 작업(100만 행 집계)이 긴 시간 동안 실행 중일 때 다른 OLTP 트랜잭션을 막지 않으려면 어떤 전략이 맞는가?

Q5

트랜잭션 격리 수준을 높이면 발생하는 trade-off는?

0 / 5 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Ubuntu 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

40📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

database고급 · 75
[Database] 쿼리 실행 계획(Execution Plan) 읽는 법과 인덱스 최적화
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점