Skip to content

[WHY] MySQL의 트랜잭션 격리 수준

Jinhyeon Kwak edited this page Jun 4, 2025 · 2 revisions

들어가며

<DB 데드락 해결: 다양한 기법 분석 및 최적화>에서 다루었듯이 저희는 주문 완료 로직에서의 동시성을 제어하기 위한 하나의 방법으로서 SERIALIZABLE의 격리 수준을 적용해보았습니다. 그리고 그 과정에서 DB 데드락이 발생했고, 이를 통해 SERIALIZABLE에 대한 오해가 있었다는 것을 알게 되었습니다.

그래서 이번 기회에 MySQL의 트랜잭션 격리 수준에 대해서 제대로 공부해보고 그 내용을 정리하고자 글을 쓰게 되었습니다. 이 글에서는 먼저 트랜잭션의 기본 개념인 ACID 속성을 간략히 살펴보고, 여러 트랜잭션이 동시에 실행될 때 발생할 수 있는 다양한 동시성 이슈들을 알아볼 것입니다. 그리고 MySQL이 제공하는 4가지 격리 수준이 이러한 문제들을 어떻게 다루는지, 그리고 어떤 상황에서 어떤 격리 수준을 선택하는 것이 좋을지에 대한 가이드라인을 제시하고자 합니다.



트랜잭션, 그리고 ACID 속성

데이터베이스에서 트랜잭션(Transaction) 은 완전히 성공적으로 실행되거나 아니면 전혀 실행되지 않은 것으로 간주되는 하나의 논리적 작업 단위입니다. 예를 들어 은행 계좌 이체는 A 계좌에서 돈을 인출하고, B 계좌에 돈을 입금하는 두 단계로 이루어집니다. 이 두 단계가 모두 성공해야만 이체가 완료되며, 하나라도 실패하면 모든 작업이 취소되어 원래 상태로 돌아가야 합니다.

그리고 트랜잭션이 신뢰성 있게 처리되기 위해 만족해야 하는 네 가지 핵심 속성을 ACID라고 부릅니다.

  • 원자성 (Atomicity): 트랜잭션 내의 모든 작업은 하나의 단위로 취급되어, 전부 성공하거나 전부 실패해야 합니다. 일부만 성공하고 일부는 실패하는 중간 상태를 허용하지 않습니다.
  • 일관성 (Consistency): 트랜잭션은 데이터베이스를 항상 하나의 유효한 상태에서 또 다른 유효한 상태로만 변경해야 합니다. 쉽게 말해 이것은 데이터베이스에 정의된 모든 규칙, 제약 조건(constraints), 트리거 등을 만족시키는 것을 의미합니다.
  • 격리성 (Isolation): 여러 트랜잭션이 동시에 실행될 때, 각 트랜잭션은 마치 다른 트랜잭션과 독립적으로 실행되는 것처럼 보여야 합니다. 즉, 한 트랜잭션의 중간 결과가 다른 트랜잭션에게 노출되거나 영향을 주어서는 안 됩니다.
  • 지속성 (Durability): 일단 트랜잭션이 성공적으로 커밋(commit)되면, 그 결과는 영구적으로 데이터베이스에 저장되어야 합니다. 이후 시스템 장애가 발생하더라도 커밋된 데이터는 손실되지 않아야 합니다.

여기서 중요한 점은 ACID 원칙들이 서로 밀접하게 연관되어 있다는 것입니다. 특히 격리성은 일관성을 유지하는 데 매우 중요한 역할을 합니다. 격리 수준이 충분히 높지 않으면, 여러 트랜잭션이 동시에 실행될 때 서로의 작업에 간섭하여 Dirty ReadNon-repeatable Read와 같은 동시성 문제가 발생할 수 있습니다.

이러한 문제들은 한 트랜잭션이 다른 트랜잭션의 '잘못된' 또는 '일시적인' 데이터를 기반으로 작업을 수행하게 만들 수 있으며, 이는 결국 데이터베이스의 제약 조건(eg. 계좌 잔액은 음수가 될 수 없음)을 위반하거나 논리적 모순(eg. 총합계가 맞지 않음)을 야기하여 데이터베이스의 일관성을 해칠 수 있습니다. 그래서 애플리케이션의 요구사항에 맞는 적절한 격리 수준을 선택하는 것은 데이터베이스의 일관성을 지키는 데 필수적입니다.



트랜잭션 간 동시성 이슈

일반적으로 여러 트랜잭션을 동시에 실행했을 때 다음과 같은 동시성 이슈가 발생할 수 있습니다.

  • Dirty Read
  • Dirty Write
  • Non-repeatable Read (Fuzzy Read)
  • Read Skew
  • Lost Update
  • Write Skew
  • Phantom Read

그러면 위 7가지 현상이 무엇을 의미하는지 다양한 사례와 함께 알아보겠습니다.


Dirty Read

Dirty Read는 한 트랜잭션(T2)이 아직 커밋되지 않은 다른 트랜잭션(T1)의 변경 사항을 읽는 현상을 말합니다. 만약 T1이 해당 변경 사항을 최종적으로 롤백한다면, T2가 읽었던 데이터는 결국 데이터베이스에 존재하지 않았던 데이터가 됩니다.

sequenceDiagram
    participant T1 as T1 (계좌 관리)
    participant DB as DB
    participant T2 as T2 (잔액 조회)
	
	T1->>DB: START TRANSACTION; 
	T1->>DB: UPDATE accounts SET balance = 500 WHERE id = 1 (아직 커밋 안 함) 
	Note over DB: 계좌 ID 1의 잔액 임시로 500 (원래 1000) 
	
	T2->>DB: START TRANSACTION; 
	T2->>DB: SELECT balance FROM accounts WHERE id = 1; 
	DB-->>T2: balance = 500 (Dirty Read 발생)
	
	T1->>DB: ROLLBACK; 
	Note over DB: 계좌 ID 1의 잔액 1000으로 복구 
	
	T2->>DB: COMMIT; 
	Note left of T2: T2는 존재하지 않았던 잔액 500을 기반으로 로직 수행
Loading

Dirty Write

Dirty Write는 한 트랜잭션(T2)이 다른 트랜잭션(T1)에 의해 아직 커밋되지 않은 데이터를 덮어쓰는 이론적인 상황을 말합니다. 만약 T1이 롤백된다면, T2가 수정한 데이터의 기반이 사라지게 되어 데이터베이스의 최종 상태를 결정하기 매우 어려워지며, 일관성이 깨질 수 있습니다.

하지만 MySQL을 포함한 대부분의 DBMS는 기본적인 잠금 메커니즘을 통해 거의 모든 격리 수준에서 Dirty Write를 실질적으로 방지합니다. 즉, 한 트랜잭션이 특정 로우(row)를 수정하기 위해 배타적 잠금(exclusive lock)을 획득하면, 해당 트랜잭션이 커밋 또는 롤백될 때까지 다른 트랜잭션은 그 로우를 수정할 수 없습니다. 따라서 아래 시나리오는 이론적인 시나리오에 해당합니다.

sequenceDiagram
    participant T1 as T1
    participant DB
    participant T2 as T2
    
    T1->>DB: START TRANSACTION;
    T1->>DB: UPDATE products SET price = 200 WHERE id = 1 (아직 커밋 안 함)
    Note over DB: 상품 ID 1 가격 임시로 200 (원래 100)

    T2->>DB: START TRANSACTION;
    T2->>DB: UPDATE products SET price = 300 WHERE id = 1

    T1->>DB: ROLLBACK
    T2->>DB: COMMIT;
    Note over DB: 데이터 일관성 문제 발생 가능 (T1 롤백 시 T2 업데이트 처리 모호해짐)
Loading

Non-repeatable Read (Fuzzy Read)

Non-repeatable Read는 한 트랜잭션 내에서 동일한 로우를 두 번 이상 읽었을 때, 그 결과가 서로 다르게 나타나는 현상입니다. 이는 첫 번째 읽기와 이후의 읽기 사이에 다른 트랜잭션이 해당 로우의 값을 수정하고 그 변경 사항을 커밋했기 때문에 발생합니다.

sequenceDiagram
    participant T1 as T1 (상품 가격 조회)
    participant DB as DB
    participant T2 as T2 (상품 가격 변경)

    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT price FROM products WHERE id = 1;
    DB-->>T1: price = 100

    T2->>DB: START TRANSACTION;
    T2->>DB: UPDATE products SET price = 150 WHERE id = 1;
    T2->>DB: COMMIT;
    Note over DB: 상품 ID 1 가격 150으로 변경됨

    T1->>DB: SELECT price FROM products WHERE id = 1;
    DB-->>T1: price = 150 (Non-repeatable Read 발생)
    T1->>DB: COMMIT;
Loading

Read Skew

Read Skew는 한 트랜잭션(T1)이 여러 데이터 항목을 순차적으로 읽는 동안, 다른 트랜잭션(T2)이 이 데이터 항목들 중 일부를 수정하고 커밋하여, T1이 일관되지 않은(시간적으로 어긋난) 데이터 상태를 보게 되는 현상입니다. 예를 들어, T1이 데이터 X를 읽고, 잠시 후 데이터 Y를 읽으려고 하는데, 그 사이에 T2가 X와 Y를 모두 변경하고 커밋했다면, T1은 X의 이전 값과 Y의 새로운 값을 읽게 되어 데이터 간의 논리적 불일치를 경험할 수 있습니다.

sequenceDiagram
    participant T1 as T1 (총액 계산)
    participant DB as DB
    participant T2 as T2 (계좌 이체)

    Note over DB: 계좌 A: 1000원, 계좌 B: 1000원 (총 2000원)
    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT balance FROM accounts WHERE id = 'A';
    DB-->>T1: balance_A = 1000

    T2->>DB: START TRANSACTION;
    T2->>DB: UPDATE accounts SET balance = balance - 500 WHERE id = 'A'
    T2->>DB: UPDATE accounts SET balance = balance + 500 WHERE id = 'B'
    T2->>DB: COMMIT;
    Note over DB: 계좌 A: 500원, 계좌 B: 1500원 (총 2000원)

    T1->>DB: SELECT balance FROM accounts WHERE id = 'B';
    DB-->>T1: balance_B = 1500
    Note right of T1: 총액: 1000(A_old) + 1500(B_new) = 2500원 (Read Skew 발생)
    T1->>DB: COMMIT;
Loading

Lost Update

Lost Update는 두 개 이상의 트랜잭션이 거의 동시에 동일한 데이터를 읽고, 각자 독립적으로 데이터를 수정한 후 데이터베이스에 다시 쓸 때(커밋할 때), 먼저 완료된 트랜잭션의 변경 사항이 나중에 완료된 트랜잭션의 변경 사항에 의해 덮어씌워져 결과적으로 유실되는 현상입니다. 이는 대표적인 쓰기-쓰기 충돌(write-write conflict)입니다.

sequenceDiagram
    participant T1 as T1 (구매자 A)
    participant DB
    participant T2 as T2 (구매자 B)

    Note over DB: 상품 ID: 1, 재고: 10개
    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT stock FROM products WHERE id = 1;
    DB-->>T1: stock = 10
    Note right of T1: 2개 구매 결정 (재고 8개로 변경 희망)

    T2->>DB: START TRANSACTION;
    T2->>DB: SELECT stock FROM products WHERE id = 1;
    DB-->>T2: stock = 10
    Note left of T2: 3개 구매 결정 (재고 7개로 변경 희망)

    T1->>DB: UPDATE products SET stock = 8 WHERE id = 1;
    T1->>DB: COMMIT;
    Note over DB: 상품 ID: 1, 재고: 8개

    T2->>DB: UPDATE products SET stock = 7 WHERE id = 1; 
    T2->>DB: COMMIT;
    Note over DB: 상품 ID: 1, 재고: 7개 (Lost Update 발생)
Loading

Write Skew

Write Skew는 두 트랜잭션(T1, T2)이 서로 다른 데이터 항목을 읽고(또는 동일한 데이터 항목의 서로 다른 부분을 읽고), 그 읽은 값을 기반으로 서로 다른 데이터 항목을 수정하거나 새로운 데이터를 삽입하지만, 두 트랜잭션이 동시에 커밋될 경우 데이터베이스 전체의 무결성 제약 조건이나 비즈니스 규칙이 깨지는 현상입니다. 각 트랜잭션은 자신이 읽은 데이터에 대해서는 일관성을 유지할 수 있지만, 두 트랜잭션의 작업이 합쳐졌을 때 예기치 않은 결과가 발생하는 것입니다. Lost Update와는 달리 직접적으로 동일한 데이터를 덮어쓰지는 않지만, 각 트랜잭션이 독립적으로 내린 결정들이 모여 전체적인 불일치를 만듭니다.

sequenceDiagram
    participant T1 as T1 (Alice)
    participant DB
    participant T2 as T2 (Bob)
    
    Note over DB: 의사 Alice, Bob 모두 당직 중 (on_call = TRUE)
    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT COUNT(*) FROM doctors_on_call WHERE on_call = TRUE;
    DB-->>T1: count = 2
    Note right of T1: 2명이니 퇴근 가능하다고 판단

    T2->>DB: START TRANSACTION;
    T2->>DB: SELECT COUNT(*) FROM doctors_on_call WHERE on_call = TRUE;
    DB-->>T2: count = 2
    Note left of T2: 2명이니 퇴근 가능하다고 판단

    T1->>DB: UPDATE doctors_on_call SET on_call = FALSE WHERE name = 'Alice';
    T1->>DB: COMMIT;
    Note over DB: Alice 퇴근 (on_call = FALSE)

    T2->>DB: UPDATE doctors_on_call SET on_call = FALSE WHERE name = 'Bob';
    T2->>DB: COMMIT;
    Note over DB: Bob 퇴근 (on_call = FALSE). 아무도 당직을 서지 않음 (Write Skew 발생)
Loading

Phantom Read

Phantom Read는 한 트랜잭션 내에서 특정 조건을 만족하는 로우들의 집합을 조회하는 쿼리를 두 번 이상 실행했을 때, 첫 번째 쿼리 결과에는 없던 '유령(phantom)' 로우가 두 번째 쿼리 결과에 나타나거나, 반대로 첫 번째 쿼리 결과에 있던 로우가 두 번째 쿼리 결과에서 사라지는 현상입니다. 이는 다른 트랜잭션이 첫 번째 쿼리와 두 번째 쿼리 실행 사이에 해당 조건 범위 내에 새로운 로우를 삽입하거나 기존 로우를 삭제하고 그 변경 사항을 커밋했기 때문에 발생합니다.

sequenceDiagram
    participant T1 as T1 (직원 조회)
    participant DB
    participant T2 as T2 (신규 직원 추가)

    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT * FROM employees WHERE department_id = 10;
    DB-->>T1: (예: 직원 X 1명 반환)

    T2->>DB: START TRANSACTION;
    T2->>DB: INSERT INTO employees (id, name, department_id) VALUES (100, 'Newbie', 10);
    T2->>DB: COMMIT;
    Note over DB: 부서 10에 신규 직원 'Newbie' 추가됨

    T1->>DB: SELECT * FROM employees WHERE department_id = 10;
    DB-->>T1: (예: 직원 X, 직원 Newbie 2명 반환 - Phantom Read 발생)
    T1->>DB: COMMIT;
Loading

유의할 점

Non-repeatable Read와 Phantom Read는 유사해 보이지만, 중요한 차이점이 있습니다. Non-repeatable Read는 특정 로우의 값이 변경되는 것에 초점을 맞추는 반면 (eg. SELECT * FROM T WHERE id = 1을 두 번 실행했는데 name 컬럼 값이 다름), Phantom Read는 특정 조건을 만족하는 로우 집합 자체가 변경되는 것(로우가 추가되거나 삭제됨)에 초점을 맞춥니다 (eg. SELECT * FROM T WHERE status = 'active'를 두 번 실행했는데 반환되는 로우의 개수가 다름).

그리고 Read Skew는 Non-repeatable Read의 한 형태로 볼 수 있으며, 여러 읽기 작업 간의 시간적 불일치로 인해 발생하는 데이터 불일치를 특히 강조합니다. Lost Update와 Write Skew 같은 경우는 모두 쓰기-쓰기 충돌의 결과로 볼 수 있지만, Lost Update는 직접적인 데이터 덮어쓰기로 인한 문제인 반면, Write Skew는 각 트랜잭션이 서로 다른 데이터를 수정하더라도 그 결과가 모여 간접적으로 데이터베이스 전체의 제약 조건을 위반하는 더 복잡한 문제입니다.



MySQL의 격리 수준

트랜잭션의 격리 수준(isolation level)은 여러 트랜잭션이 동시에 실행될 때 각 트랜잭션이 다른 트랜잭션의 변경 사항을 얼마나 '격리'해서 볼 것인지를 정의하며, 이를 통해 동시성과 데이터 일관성 사이의 균형을 조절합니다. MySQL의 경우에는 크게 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE과 같이 4가지 격리 수준을 지원하고 있습니다. 그리고 InnoDB 엔진 기준으로 기본 격리 수준은 REPEATABLE READ입니다.

그러면 이제 MySQL의 격리 수준이 어떻게 동작하는지, 어떤 동시성 이슈들을 방지하고 허용하는지, 그리고 내부적으로 어떤 잠금 메커니즘을 사용하는지 살펴보겠습니다.


READ UNCOMMITTED

READ UNCOMMITTED는 가장 낮은 격리 수준입니다. 이 수준에서 트랜잭션은 다른 트랜잭션이 아직 커밋하지 않은 변경 사항까지도 읽을 수 있습니다. SELECT 문은 잠금을 거의 사용하지 않고 수행되지만, 이로 인해 데이터의 이전 버전을 읽거나 아직 확정되지 않은 데이터를 읽을 수 있어 일관되지 않은 읽기, 즉 Dirty Read가 발생할 수 있습니다.

  • 방지하는 문제: 거의 없습니다. Dirty Write는 앞서 설명했듯이 대부분의 DBMS에서 격리 수준과 무관하게 기본적인 쓰기 잠금으로 방지됩니다.
  • 허용하는 문제: Dirty Read, Non-repeatable Read, Phantom Read를 포함한 대부분의 동시성 문제를 허용합니다. Lost Update, Read Skew, Write Skew 또한 발생 가능성이 높습니다.
  • 동작 방식: SELECT 문을 실행할 때 공유 잠금(shared lock)을 획득하지 않습니다. 이로 인해 다른 트랜잭션이 수정 중인 커밋되지 않은 데이터를 읽을 수 있습니다. 하지만 데이터를 변경하는 UPDATE, DELETE, INSERT 문은 여전히 배타적 잠금(exclusive lock)을 사용합니다.
  • 동작 예시:
sequenceDiagram
    participant T1 as T1
    participant DB
    participant T2 as T2

    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT balance FROM accounts WHERE id = 1;
    DB-->>T1: balance = 1000 (초기값)

    T2->>DB: START TRANSACTION;
    T2->>DB: UPDATE accounts SET balance = 500 WHERE id = 1 (아직 커밋 안 함)

    T1->>DB: SELECT balance FROM accounts WHERE id = 1;
    DB-->>T1: balance = 500 (T2의 커밋되지 않은 데이터를 읽음)

    T2->>DB: ROLLBACK;
    T1->>DB: COMMIT;
Loading

READ COMMITTED

READ COMMITTED 격리 수준에서는 트랜잭션이 다른 트랜잭션에 의해 이미 커밋된 변경 사항만을 읽을 수 있도록 보장합니다. 즉, Dirty Read를 방지합니다. InnoDB에서 READ COMMITTED 수준으로 동작하는 일관된 읽기(consistent read, 일반적인 SELECT 문)는 동일한 트랜잭션 내에서 여러 번 실행되더라도, 실행될 때마다 자신만의 새로운 데이터 스냅샷을 설정하고 그 스냅샷을 읽습니다. Oracle DBMS에서 기본으로 사용되는 격리 수준이며, 온라인 서비스에서 가장 많이 선택되는 격리 수준입니다.

  • 방지하는 문제: Dirty Read를 방지할 수 있습니다.
  • 허용하는 문제: Non-repeatable Read, Phantom Read, Lost Update, Read Skew, Write Skew 등이 여전히 발생할 수 있습니다.
  • 동작 방식:
    • 일관된 읽기 (Non-locking SELECT): MVCC(Multi-Version Concurrency Control)를 사용하여 구현됩니다. 각 SELECT 문이 실행될 때, 해당 시점까지 커밋된 데이터들로 구성된 새로운 스냅샷(Read View)을 생성하여 읽습니다. 따라서 한 트랜잭션 내에서도 SELECT 문이 여러 번 실행되면 매번 다른 스냅샷을 참조할 수 있어, 다른 트랜잭션의 커밋된 변경 사항이 반영될 수 있습니다 (이것이 Non-repeatable Read를 유발합니다).
    • 잠금 읽기 (SELECT... FOR UPDATE 또는 SELECT... FOR SHARE), UPDATE, DELETE: InnoDB는 이러한 SQL에 대해 인덱스 레코드에만 잠금을 설정하고, 그 인덱스 레코드 이전의 갭(gap)은 잠그지 않습니다. 이로 인해 잠긴 레코드 바로 옆에 다른 트랜잭션이 새로운 레코드를 자유롭게 삽입할 수 있게 되며, 이는 Phantom Read가 발생할 수 있는 원인이 됩니다.
  • 동작 예시1 (Dirty Read 방지):
sequenceDiagram
    participant T1 as T1
    participant DB
    participant T2 as T2

    T1->>DB: START TRANSACTION;
    T1->>DB: UPDATE products SET price = 120 WHERE id = 1 (원래 가격 100)
    Note right of T1: T1이 가격을 120으로 변경 (아직 커밋 안 함)

    T2->>DB: START TRANSACTION;
    T2->>DB: SELECT price FROM products WHERE id = 1;
    DB-->>T2: price = 100 (Dirty Read 방지)
    Note left of T2: T2는 커밋된 데이터(100)를 읽음

    T1->>DB: COMMIT;
    T2->>DB: COMMIT;
Loading

  • 동작 예시2 (Non-repeatable Read 발생):
sequenceDiagram
    participant T1 as T1
    participant DB
    participant T2 as T2

    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT price FROM products WHERE id = 1;
    DB-->>T1: price = 100

    T2->>DB: START TRANSACTION;
    T2->>DB: UPDATE products SET price = 150 WHERE id = 1;
    T2->>DB: COMMIT;

    T1->>DB: SELECT price FROM products WHERE id = 1;
    DB-->>T1: price = 150 (Non-repeatable Read 발생)
    T1->>DB: COMMIT;
Loading

REPEATABLE READ

REPEATABLE READ는 MySQL InnoDB 스토리지 엔진의 기본 트랜잭션 격리 수준입니다. 이 수준에서는 한 트랜잭션 내에서 실행되는 모든 일관된 읽기(consistent read, 즉 잠금을 사용하지 않는 일반 SELECT 문)는 해당 트랜잭션 내에서 첫 번째 읽기가 수행될 때 설정된 데이터 스냅샷을 기준으로 합니다. 즉, 일단 스냅샷이 생성되면, 동일 트랜잭션 내에서 이후에 실행되는 여러 번의 일반 SELECT 문은 항상 동일한 데이터를 보게 되어 서로 일관성을 유지합니다.

  • 방지하는 문제: Dirty Read, Non-repeatable Read를 방지할 수 있습니다.
  • 허용하는 문제: 기본적으로 Phantom Read를 허용하지만, MySQL InnoDB는 넥스트 키 락(Next Key Lock) 메커니즘을 통해 대부분 방지할 수 있습니다. 그럼에도 불구하고 특정 조건에서는(eg. 트랜잭션 내에서 잠금 읽기와 비잠금 읽기를 혼용하거나, UPDATE 문 실행 후 다시 SELECT 하는 경우) Phantom Read나 Write Skew가 발생할 수 있는 예외 케이스가 존재합니다. Lost Update는 SELECT FOR UPDATE와 같은 명시적인 잠금 없이 일반적인 읽기-수정-쓰기 패턴을 사용한다면 여전히 발생할 수 있습니다.
  • 동작 방식:
    • 일관된 읽기 (Non-locking SELECT): MVCC를 기반으로 합니다. 트랜잭션이 시작될 때가 아니라, 트랜잭션 내에서 첫 번째 읽기 작업이 수행되는 시점에 데이터베이스의 스냅샷(Read View)이 생성됩니다. 이후 해당 트랜잭션 내의 모든 일관된 읽기는 이 고정된 스냅샷을 참조합니다. 따라서 다른 트랜잭션이 데이터를 변경하고 커밋하더라도, 현재 트랜잭션의 일관된 읽기는 영향을 받지 않아 반복 가능한 읽기를 보장합니다.
    • 잠금 읽기 (SELECT... FOR UPDATE 또는 SELECT... FOR SHARE), UPDATE, DELETE:
      • 만약 쿼리가 고유 인덱스(Unique Index)를 사용하고 고유한 검색 조건(eg. WHERE primary_key = value)을 갖는다면 InnoDB는 정확히 일치하는 인덱스 레코드에만 잠금을 설정하고 그 주변의 갭은 잠그지 않습니다.  
      • 다른 모든 검색 조건(eg. 범위 조건 WHERE col > value, 또는 비고유 인덱스 사용)에 대해서는 InnoDB는 스캔한 인덱스 범위 전체에 대해 잠금을 설정합니다. 이 때 갭 잠금(Gap Lock) 또는 넥스트 키 잠금(Next-Key Lock)을 사용하여 다른 세션이 이 잠금으로 보호되는 갭 영역에 새로운 로우를 삽입하는 것을 방지합니다. 이것이 Phantom Read를 막는 핵심 메커니즘입니다.  
    • 주의사항: REPEATABLE READ 격리 수준에서 잠금문 (UPDATE, INSERT, DELETE, SELECT... FOR SHARE, SELECT... FOR UPDATE)과 잠금 없는 일반 SELECT 문을 하나의 트랜잭션 내에서 혼용하는 것은 일반적으로 권장되지 않습니다. 왜냐하면 잠금 없는 SELECT는 트랜잭션 초기의 스냅샷을 기준으로 데이터를 읽는 반면, 잠금문들은 데이터베이스의 가장 최신 상태를 기준으로 잠금을 시도하고 데이터를 처리하기 때문입니다. 이 두 가지 다른 시점의 데이터 상태가 혼재하면 트랜잭션 내에서 데이터 불일치가 발생하거나 로직을 이해하기 어렵게 만들 수 있습니다.  
  • 동작 예시1 (Non-repeatable Read 및 Phantom Read 방지):
sequenceDiagram
    participant T1 as T1
    participant DB
    participant T2 as T2

    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT COUNT(*) FROM sales WHERE product_id = 5 <br/> (첫 읽기, 스냅샷 생성)
    DB-->>T1: count = 10 
    T1->>DB: SELECT SUM(amount) FROM sales WHERE product_id = 5
    DB-->>T1: sum = 5000 (동일 스냅샷 기반)

    T2->>DB: START TRANSACTION;
    T2->>DB: UPDATE sales SET amount = amount + 100 WHERE product_id = 5 AND id = 1;
    T2->>DB: INSERT INTO sales (product_id, amount) VALUES (5, 200);
    T2->>DB: COMMIT;
    Note left of T2: T2가 product_id=5 데이터 변경 및 추가 후 커밋

    T1->>DB: SELECT COUNT(*) FROM sales WHERE product_id = 5;
    DB-->>T1: count = 10 (여전히 초기 스냅샷, Phantom Read 방지)
    T1->>DB: SELECT SUM(amount) FROM sales WHERE product_id = 5;
    DB-->>T1: sum = 5000 (여전히 초기 스냅샷, Non-repeatable Read 방지)
    T1->>DB: COMMIT;
Loading

  • 동작 예시2 (Phantom Read 발생):
sequenceDiagram
    participant T1
    participant DB
    participant T2

    Note over DB: 초기 부서 ID 20 직원: 'Carol', 'Eve'
    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT name FROM employees WHERE department_id = 20 <br/> (T1 스냅샷 생성)
    DB-->>T1: ('Carol', 'Eve')

    T2->>DB: START TRANSACTION;
    T2->>DB: INSERT INTO employees (name, department_id, salary) VALUES ('David', 20, 50000);
    T2->>DB: COMMIT;
    Note over DB: T2: 'David' 직원 (부서 ID 20) 추가 및 커밋

    T1->>DB: SELECT name FROM employees WHERE department_id = 20 FOR UPDATE <br/> (잠금 획득. 최신 데이터에 접근)
    DB-->>T1: ('Carol', 'Eve', 'David') - Phantom Read 발생
Loading

SERIALIZABLE

SERIALIZABLE은 가장 엄격한 격리 수준입니다. 이 수준에서는 트랜잭션들이 마치 한 줄로 서서 순차적으로 하나씩 실행되는 것과 동일한 결과를 보장합니다. 동시성을 극도로 제한하는 대신 데이터 일관성을 가장 엄격하게 보장합니다.

  • 방지하는 문제: Dirty Read, Non-repeatable Read, Phantom Read를 포함한 모든 표준 동시성 문제를 방지합니다. Lost Update, Write Skew, Read Skew와 같은 문제들도 효과적으로 차단합니다.
  • 허용하는 문제: 이론적으로는 없습니다. 다만 잠금을 사용하는 방식이기 때문에 데드락 발생 가능성이 있습니다.
  • 동작 방식: REPEATABLE READ와 유사하게 동작하지만, 한 가지 중요한 차이점이 있습니다. SERIALIZABLE 격리 수준에서는 모든 일반 SELECT 문을 암시적으로 SELECT... FOR SHARE로 변환합니다. 이는 일반적인 읽기 작업조차도 읽는 로우에 공유 잠금(shared lock)을 설정하여, 다른 트랜잭션이 해당 로우를 수정하는 것을 방지한다는 의미입니다. 만약 다른 트랜잭션이 이미 해당 로우에 배타적 잠금(exclusive lock)을 걸고 있다면, SELECT... FOR SHARE 문은 잠금이 해제될 때까지 대기합니다. 결과적으로 모든 트랜잭션이 순차적으로 실행되는 것과 같은 효과를 내기 위해 광범위한 잠금(공유 잠금, 배타적 잠금, 범위 잠금 등)을 사용하며, 이를 통해 동시성 관련 문제를 원천적으로 차단합니다.
  • 동작 예시1:
sequenceDiagram
    participant T1
    participant DB
    participant T2

    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT * FROM orders WHERE customer_type = 'VIP' <br/> AND order_date = '2025-06-04';
    Note over DB: SERIALIZABLE 수준에서는 이 SELECT가 <br/>넥스트 키 락을 설정하여 <br/>해당 범위에 대한 다른 트랜잭션의 변경/삽입을 막음.
    DB-->>T1: (Order101, Order102)

    T2->>DB: START TRANSACTION;
    T2->>DB: INSERT INTO orders (order_id, customer_type, order_date, item) <br/>VALUES ('Order103', 'VIP', '2025-06-04', 'NewItem');
    Note over DB: T1이 설정한 잠금(VIP 주문 and 해당 날짜)으로 인해 <br/> T2의 INSERT 문은 대기 상태가 됨.
    
    T1->>DB: COMMIT;

    Note over T2: T1의 커밋으로 인해 T2의 INSERT 문 대기 상태 해제
    DB-->>T2: INSERT 성공
    T2->>DB: COMMIT;
Loading

  • 동작 예시2 (데드락 발생):
sequenceDiagram
    participant T1
    participant DB
    participant T2

    Note over DB: 계좌 A: 100, 계좌 B: 100
    T1->>DB: START TRANSACTION;
    T1->>DB: SELECT balance FROM accounts <br/> WHERE id = 'A' (A에 공유 잠금 획득)
    DB-->>T1: balance_A = 100
    T1->>DB: SELECT balance FROM accounts <br/> WHERE id = 'B' (B에 공유 잠금 획득)
    DB-->>T1: balance_B = 100

    T2->>DB: START TRANSACTION;
    T2->>DB: SELECT balance FROM accounts <br/> WHERE id = 'A' (A에 공유 잠금 획득)
    DB-->>T2: balance_A = 100
    T2->>DB: SELECT balance FROM accounts <br/> WHERE id = 'B' (B에 공유 잠금 획득)
    DB-->>T2: balance_B = 100

    T1->>DB: UPDATE accounts SET balance = balance - 50 <br/> WHERE id = 'A' (A에 배타적 잠금 획득 시도)
    Note right of T1: T2가 A에 공유 잠금 보유 중이므로 T1은 대기 상태

    T1->>DB: UPDATE accounts SET balance = balance + 50 <br/> WHERE id = 'B' (B에 배타적 잠금 시도)
    Note left of T2: T1이 B에 공유 잠금 보유 중이므로 T2도 대기 상태 (데드락 발생)
Loading

정리

지금까지 MySQL에서 제공하는 4가지 격리 수준에 대해 알아보았습니다. 간단하게 정리하면 아래 표와 같이 요약할 수 있습니다.

격리 수준 (Isolation Level) Dirty Read 방지 Non-repeatable Read 방지 Phantom Read 방지 (InnoDB 기준) 주요 특징/동작 방식
READ UNCOMMITTED x x x 커밋되지 않은 데이터 읽기 허용. 잠금 사용 x
READ COMMITTED o x x 커밋된 데이터만 읽기.
SELECT마다 새 스냅샷. 갭 잠금 사용 x
REPEATABLE READ (기본값) o o 대부분 o 트랜잭션 내 첫 읽기 시 스냅샷 고정.
넥스트 키 락으로 Phantom Read 대부분 방지.
SERIALIZABLE o o o 모든 트랜잭션을 순차 실행하는 것과 동일한 효과.
모든 SELECT에 공유 잠금


마치며

이번 글에서는 각 격리 수준이 어떤 동시성 문제를 허용하거나 방지하는지, 그리고 내부적으로 어떤 방식으로 작동하는지를 상세히 살펴보았습니다. 주의해야 할 점은 트랜잭션 격리 수준은 단순히 설정 하나로 끝나는 기술적 옵션이 아니라, 데이터 일관성과 시스템 성능 사이의 균형을 조절하는 도구라는 것입니다(그러나 <Real MySQL 8.0>에서는 SERIALIZABLE 격리 수준이 아니라면 크게 성능의 개선이나 저하는 발생하지 않는다고 합니다).

그리고 "가장 강한 격리 수준 == 항상 좋은 선택"은 아니라는 점입니다. SERIALIZABLE은 이론적으로 가장 완벽한 일관성을 보장하지만, 실제 서비스 환경에서는 데드락이나 처리량 저하 등의 부작용을 동반할 수 있습니다. 반대로 성능을 중시해 READ UNCOMMITTED 수준을 선택하면, 예상치 못한 데이터 정합성 문제로 이어질 수 있습니다.

저희도 단순히 SERIALIZABLE로 설정하면 무조건 안전할 것이라는 막연한 생각으로 트랜잭션 격리 수준을 설정하면서 데드락이 발생하였습니다. 그리고 이것은 응답 시간 지연으로 이어지기도 하였습니다. 따라서 운영하는 서비스가 어떤 종류의 데이터 정합성을 요구하는지, 어떤 동시성 문제가 실제로 문제가 될 수 있는지, 비즈니스 우선순위가 성능에 있는지, 안정성에 있는지 등을 충분히 고려하여 격리 수준을 결정하는 것이 중요합니다.

Clone this wiki locally