[MySQL]트랜잭션의 격리 수준 파헤치기 1

2025. 7. 24. 00:24·SW개발/Database

안녕하세요, 오랜만에 인사드립니다.

 

오늘은 트랜잭션의 격리 수준에 대해서 낱낱이 파헤쳐 보려고 합니다. MySQL의 트랜잭션 격리 수준에 따라서 어떠한 점들이 달라지게 되는지 알아보겠습니다.

 

트랜잭션의 격리 수준이란?

트랜잭션의 격리 수준은 데이터베이스에서 여러 트랜잭션이 동시에 변경 작업을 수행하고 쿼리를 수행할 때 성능과 안정성, 일관성, 그리고 결과의 재현 가능성 사이에서 균형을 미세하게 조정하는 설정입니다.

 

SQL-92 표준에 따르면 트랜잭션의 격리 수준은 Serializable, Repetable Reads, Read Comitted, Read Uncomitted 총 4가지의 레벨이 존재합니다. 각 레벨에 따라 특성들이 다르고 이로 인해 쿼리의 결과에도 영향을 미치게 됩니다.

MySQL 역시 마찬가지로 4가지의 레벨을 모두 지원합니다. (대부분의 상용 데이터베이스도 동일합니다.)

 

그러면 하나씩 알아보겠습니다.

 

Read Uncomitted

가장 낮은 격리 수준에 해당됩니다. 이 레벨에서는 SELECT 문은 락이 없는 방식으로 수행되고, 다른 트랜잭션에서 아직 커밋되지 않은 변경 사항을 읽을 수 있습니다. 그렇기에 Dirty Read 현상이 생기게 됩니다. 

 

 

Dirty Read

트랜잭션이 아직 커밋되지 않은 다른 트랙잭션에서 업데이트한 행을 읽어올 수 있습니다. 아래의 그림을 참고해 봅시다.

┌─────────────────────────────┬──────────────────────────────┐
│         트랜잭션 A            │             트랜잭션 B         │
├─────────────────────────────┼──────────────────────────────┤
│ BEGIN                       │                              │
│                             │ BEGIN                        │
│                             │                              │
│ SELECT name FROM User       │                              │
│ WHERE id = 1;               │                              │
│ → 결과: John                 │                              │
│                             │ UPDATE User                  │
│                             │ SET name = 'James'           │
│                             │ WHERE id = 1;                │
│                             │ (아직 COMMIT 하지 않음)         │
│                             │                              │
│ SELECT name FROM User       │                              │
│ WHERE id = 1;               │                              │
│ → 결과: James (Dirty Read)   │                              │
│                             │                              │
│                             │ ROLLBACK                     │
│                             │ (name은 다시 John으로 복원됨)    │
└─────────────────────────────┴──────────────────────────────┘

커밋(수정)되지 않은 데이터를 읽어오는 것은 위와 같은 상황에서 일반적으로 오류처럼 느껴질 수 있습니다. 대부분의 데이터베이스 엔진은 해당 격리 레벨을 기본으로 채택하지 않습니다.

 

가장 낮은 레벨이기에 Dirty Read 이외에도 Non-Repeatable Read, Phantom Read 현상도 같이 발생합니다. 이 부분은 아래에서 하나씩 다루겠습니다.

 

Read Comitted

Read Uncomitted 보다 한 단계 높은 격리 수준입니다. 이 레벨에서는 SELECT 문은 락이 없는 방식으로 수행되지만, 다른 트랜잭션에서 커밋된 변경 사항만 읽을 수 있습니다. 그렇기에 Dirty Read 현상은 발생하지 않습니다.

 

Non-Repetable Read

커밋된 이후의 데이터만 조회하게 되었지만 하나의 트랜잭션에서 동일 쿼리를 여러 번 수행할 때 일관된 읽기가 불가능합니다. 예시와 함께 보겠습니다.

┌─────────────────────────────┬──────────────────────────────┐
│         트랜잭션 A            │             트랜잭션 B         │
├─────────────────────────────┼──────────────────────────────┤
│ BEGIN                       │                              │
│                             │ BEGIN                        │
│                             │                              │
│ SELECT name FROM User       │                              │
│ WHERE id = 1;               │                              │
│ → 결과: John                 │                              │
│                             │                              │
│                             │ UPDATE User                  │
│                             │ SET name = 'James'           │
│                             │ WHERE id = 1;                │
│                             │ COMMIT                       │
│                             │                              │
│ SELECT name FROM User       │                              │
│ WHERE id = 1;               │                              │
│ → 결과: James,               │                              │
│ (Non-Repeatable Read)       │                              │
└─────────────────────────────┴──────────────────────────────┘

락이 없는 SELECT 문의 경우 하나의 트랜잭션 내에서 반복된 읽기에 대해서는 보장해주지 못합니다. 그렇기 때문에 같은 조건의 쿼리를 여러 번 조회한다면 데이터가 변경될 수도 있는 현상을 겪게 됩니다.

 

또한, Phantom Read 현상도 다음과 같은 이유로 인해 발생합니다.

이 레벨에서의 읽기 락(SELECT with FOR UPDATE / FOR SHARE)과 UPDATE, DELETE 문의 경우 InnoDB는 해당 레코드만 락을 걸고, 간격을 제한하는 갭 락은 걸지 않습니다. 그렇기 때문에 락 걸린 레코드 옆의 공간에 새 레코드는 자유롭게 삽입이 가능합니다.

갭 락은 외래키 제약이나 중복 키 검사에만 사용됩니다.

 

갭 락이란?
갭 락은 MySQL의 InnoDB 스토리지 엔진에서 사용되는 잠금 방식입니다.
레코드와 레코드 사이의 공간(간격)을 잠궈 해당 간격에 새로운 레코드가 삽입되는 것을 방지하는 역할을 합니다. 

 

레코드 사이의 공간에 새로운 레코드 삽입이 가능하기에 Phantom Read 현상이 발생합니다.

 

Phantom Read

트랜잭션 내에서 동일한 쿼리가 서로 다른 시점에 서로 다른 레코드 집합을 생성할 때 발생합니다. 예를 들어, SELECT 문이 두 번 실행되었을 때 첫 번째 실행 시에 반환되지 않았던 레코드를 반환하는 경우입니다. 아래의 그림을 참고해 봅시다.

┌────────────────────────────────────────┬────────────────────────────────────────┐
│              트랜잭션 A                  │               트랜잭션 B                 │
├────────────────────────────────────────┼────────────────────────────────────────┤
│ BEGIN                                  │                                        │
│                                        │ BEGIN                                  │
│ SELECT * FROM User                     │                                        │
│ WHERE age > 30 FOR UPDATE;             │                                        │
│ → 결과: 31, 50                          │                                        │
│ (갭 락 X, 레코드 락 O)                    │                                        │
│                                        │ INSERT INTO User(name, age)            │
│                                        │ VALUES ('Alice', 35);                  │
│                                        │ COMMIT                                 │
│                                        │ (갭 락이 없으므로 공간 사이에 레코드 삽입 가능   │
│                                        │                                        │
│ SELECT * FROM User                     │                                        │
│ WHERE age > 30 FOR UPDATE;             │                                        │
│ → 결과: 31, 35(Phantom), 50             │                                        │
└────────────────────────────────────────┴────────────────────────────────────────┘

트랜잭션 A에서 SELECT FOR UPDATE 쿼리의 결과로 31, 50 레코드를 읽었습니다. 이 시점에서 갭 락은 걸려있지 않은 상태입니다.

이후 트랜잭션 B에서 갭 락이 걸려 있지 않으므로 공간 사이에 새로운 레코드를 삽입합니다.

다시 트랜잭션 A에서 SELECT FOR UPDATE 쿼리로 범위 검색을 진행하면, 동일한 트랜잭션이지만 갑자기 새로운 레코드가 조회되는 현상이 발생합니다.

 

FOR UPDATE가 없는 SELECT도 갭 락이 걸리지 않기에 동일합니다.

 

Repetable Reads

Read Comitted 보다 한 단계 더 높은 격리 수준입니다. 이 레벨에서는 동일 트랜잭션 내에서 첫 번째 읽기에서 생성된 스냅샷을 계속 읽습니다. 즉, 동일 트랜잭션이라면 잠금 없는 SELECT 명령문을 계속 수행하더라도 일관된 읽기가 가능합니다.

또한, MySQL 격리 수준의 기본 설정입니다.

 

이 레벨에서는 MySQL은 대부분의 상황에서 Phantom Read가 발생하지 않습니다. 하지만 특정 상황에서는 발생할 수 있습니다. 한번 알아보도록 하겠습니다.

┌────────────────────────────────────────┬────────────────────────────────────────┐
│              트랜잭션 A                  │               트랜잭션 B                 │
├────────────────────────────────────────┼────────────────────────────────────────┤
│ BEGIN                                  │                                        │
│                                        │ BEGIN                                  │
│ SELECT * FROM User                     │                                        │
│ WHERE age > 30;                        │                                        │
│ → 결과: 31, 50                          │                                        │
│ (Undo 로그에서 조회, MVCC)                │                                        │
│                                        │                                        │
│                                        │ INSERT INTO User(name, age)            │
│                                        │ VALUES ('Alice', 35);                  │
│                                        │ COMMIT                                 │
│                                        │                                        │
│ SELECT * FROM User                     │                                        │
│ WHERE age > 30 FOR UPDATE;             │                                        │
│ → 결과: 31, 35(Phantom), 50             │                                        │
│ (데이터베이스의 최근 상태를 사용하여 조회/락)    │                                        │
└────────────────────────────────────────┴────────────────────────────────────────┘

락을 사용하지 않은 SELECT문 뒤에 락을 사용한 SELECT문의 순서로 혼합해 사용하는 경우에 Phantom Read가 발생할 수 있습니다.

 

락을 사용하지 않은 조회는 Undo 로그에서 데이터를 읽어오게 되고, 트랜잭션 B에서 데이터가 변동이 된 후에 락을 사용한 조회를 하면 데이터베이스의 가장 최근 상태에서 데이터를 조회하면서 락을 하게 됩니다. 이 상황에서 데이터의 상태가 일치하지 않기 때문에 발생하는 것입니다.

 

MySQL 공식 문서에서도 Repeatable Reads 레벨의 단일 트랜잭션에서는 락 없는 조회와 락 있는 조회의 혼합해서 사용하는 것을 권장하지 않고 있습니다. 실제로 애플리케이션 로직에서도 이렇게 작성했던 경험은 별로 없었던 것 같습니다.

 

Undo 로그란?
Undo 로그는 데이터베이스 트랜잭션에서 변경 전의 데이터를 저장하는 로그입니다.
트랜잭션이 롤백(rollback)될 때, 즉 작업을 취소하고 이전 상태로 되돌릴 때 사용됩니다. 데이터베이스 변경 사항을 기록하는 Redo 로그와 반대되는 개념입니다.
MVCC 란? (Multi-Version Concurrency Control)
MVCC(다중 버전 동시성 제어)는 데이터베이스에서 동시성을 관리하기 위한 방법 중 하나입니다. 하나의 데이터 레코드에 대해 여러 버전의 데이터를 유지하여 여러 사용자가 동시에 데이터에 접근하더라도 충돌 없이 작업을 수행할 수 있도록 합니다. 
즉, 데이터베이스 사용자들이 데이터를 읽을 때 다른 사용자의 변경 작업에 영향을 받지 않고, 일관된 데이터를 볼 수 있도록 보장합니다. 

 

위에서 설명한 케이스는 다른 데이터베이스와 MySQL의 InnoDB가 모두 Phantom Read 현상이 발생합니다.

하지만 InnoDB의 갭 락으로 인해 타 엔진들과 다른 결과를 보여주는 케이스가 있습니다. 예시와 함께 보겠습니다.

MySQL InnoDB
┌────────────────────────────────────────┬────────────────────────────────────────┐
│              트랜잭션 A                  │               트랜잭션 B                 │
├────────────────────────────────────────┼────────────────────────────────────────┤
│ BEGIN                                  │                                        │
│                                        │ BEGIN                                  │
│ SELECT * FROM User                     │                                        │
│ WHERE age > 30 FOR UPDATE;             │                                        │
│ → 결과: 31, 50                          │                                        │
│ (넥스트 키 락)                            │                                        │
│                                        │                                        │
│                                        │ INSERT INTO User(name, age)            │
│                                        │ VALUES ('Alice', 35);                  │
│                                        │ (락 획득 대기)                            │
│                                        │                                        │
│ SELECT * FROM User                     │                                        │
│ WHERE age > 30 FOR UPDATE;             │                                        │
│ → 결과: 31,50                           │                                        │
│ COMMIT                                 │                                        │
│                                        │ COMMIT                                 │
└────────────────────────────────────────┴────────────────────────────────────────┘

타 데이터베이스
┌────────────────────────────────────────┬────────────────────────────────────────┐
│              트랜잭션 A                  │               트랜잭션 B                 │
├────────────────────────────────────────┼────────────────────────────────────────┤
│ BEGIN                                  │                                        │
│                                        │ BEGIN                                  │
│ SELECT * FROM User                     │                                        │
│ WHERE age > 30 FOR UPDATE;             │                                        │
│ → 결과: 31, 50                          │                                        │
│ (갭 락이 없으므로 레코드 락만 걸림)            │                                        │
│                                        │                                        │
│                                        │ INSERT INTO User(name, age)            │
│                                        │ VALUES ('Alice', 35);                  │
│                                        │ COMMIT, 레코드 사이에 삽입 가능              │
│                                        │                                        │
│ SELECT * FROM User                     │                                        │
│ WHERE age > 30 FOR UPDATE;             │                                        │
│ → 결과: 31, 35(Phantom), 50             │                                        │
│ COMMIT                                 │                                        │
└────────────────────────────────────────┴────────────────────────────────────────┘

이 경우에는 MySQL은 넥스트 키 락(레코드 락 + 갭 락)으로 인해 유령 데이터가 생성되지 않습니다. 하지만 다른 데이터베이스의 경우에는 레코드 사이의 간격을 잠글 수 없기에 Phantom Read 현상이 발생하게 됩니다.

 

이렇게만 보면 MySQL이 다른 데이터베이스 엔진에 비해 좋은 것처럼 보일 수 있습니다. 하지만 격리 수준이 높다는 것은 데이터의 격리성은 올라가지만 반대로 삽입 지연, 동시성 저하, 데드락 같은 문제가 발생할 가능성이 높아지게 된다는 뜻과도 같습니다.

 

넥스트 키 락이란?
넥스트 키 락은 MySQL의 InnoDB 스토리지 엔진에서 사용되는 락 메커니즘 중 하나입니다.
레코드 락과 갭 락을 합쳐놓은 형태로, 특정 레코드뿐만 아니라 해당 레코드와 다음 레코드 사이의 간격까지 잠금으로써 Phantom Read 현상을 방지하는 데 사용됩니다.

 

Serializable

가장 높은 격리 수준입니다. 이 레벨에서는 모든 트랜잭션은 순차적으로 실행됩니다.  

락이 없는 SELECT 문의 경우에도 SELECT ~ FOR SHARE를 통해 읽기 락을 걸어 조회하게 됩니다. 따라서 여러 트랜잭션이 동일한 레코드에 대해 동시에 접근할 수 없어 순차성을 보장받을 수 있습니다. 위에서 언급한 여러 현상 또한 발생하지 않습니다.

 

하지만, 그만큼 동시성이 저하되고 성능 문제로 이어질 가능성이 높은 격리 수준입니다. 매우 극단적으로 안전한 상황이 아닌 이상 선택을 하지 않는 상황이 대부분입니다. 

 

(CockroachDB는 기본 격리 수준이 Serializable 입니다. 이 데이터베이스 엔진에서는 격리 수준을 지키면서도 성능 문제를 어떻게 해결했는지 알아보셔도 좋을 것 같습니다.)

 

 

최종적으로 정리하자면 다음과 같습니다.

  • Read Uncomitted : Dirty Read + Non-Repeatable Read + Phantom Read 현상
  • Read Comitted : Non-Repeatable Read + Phantom Read 현상
  • Repetable Reads : Phantom Read (일부 상황) 현상
  • Serializable : 현상 X

 

실무에서는?

MySQL, PostgreSQL을 사용했던 저의 경험으로는 기본 격리 수준을 따로 변경한 적은 없었던 것 같습니다. 실제로 동시성과 순차성에 대한 제어가 필요한 경우에는 상황에 맞게 FOR UPDATE나 UPDATE 쿼리, 낙관적 락, Redis 분산락을 이용하는 것만으로도 충분했습니다.

 

그렇지만 동시성과 관련해 다양한 문제가 발생하는 상황에서 트랜잭션 격리 수준에 대한 이해 자체는 매우 중요합니다. 특히 요즈음에는 분산 환경에서 서버, DB가 운용되는 경우가 매우 일반적이고 이러한 환경에서 발생하는 문제들은 매우 다양합니다.

 

이럴 때 트랜잭션의 격리 수준을 잘 알고 있다면 일어나는 현상들에 대해 쉽게 이해할 수 있고 디버깅이 가능해집니다. 적절한 상황에 맞게 동시성을 제어하는 방법 또한 알 수 있다고 생각합니다.

 

마치며

지금까지 트랜잭션의 격리 수준과 그에 따른 현상에 대해서 알아보았습니다. 다음 편에는 JPA 환경에서 트랜잭션의 격리 수준은 또 어떻게 적용되는지에 대해 알아보겠습니다.

 

긴 글 읽어주셔서 감사합니다.

 

 

레퍼런스

https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html#isolevel_read-committed

 

MySQL :: MySQL 8.4 Reference Manual :: 17.7.2.1 Transaction Isolation Levels

17.7.2.1 Transaction Isolation Levels Transaction isolation is one of the foundations of database processing. Isolation is the I in the acronym ACID; the isolation level is the setting that fine-tunes the balance between performance and reliability, consi

dev.mysql.com

 

728x90

'SW개발 > Database' 카테고리의 다른 글

[MySQL]트랜잭션의 격리 수준 파헤치기 2 (feat. 영속성 컨텍스트의 함정)  (4) 2025.08.05
[Database] PostgreSQL 타입 정리  (0) 2023.11.19
'SW개발/Database' 카테고리의 다른 글
  • [MySQL]트랜잭션의 격리 수준 파헤치기 2 (feat. 영속성 컨텍스트의 함정)
  • [Database] PostgreSQL 타입 정리
Leffe_pt
Leffe_pt
개발자로서 성장하면서 배워온 지식과 경험을 공유하는 공간입니다.
  • Leffe_pt
    Leffe's tistory
    Leffe_pt
  • 전체
    오늘
    어제
    • 분류 전체보기 (309)
      • SW개발 (305)
        • 코딩테스트 (172)
        • 개발이야기 (23)
        • IT 용어 (17)
        • Python (22)
        • Django (46)
        • Flask (2)
        • Database (3)
        • SQLAlchemy (0)
        • Javascript (5)
        • Linux, Unix (3)
        • JAVA (2)
        • Spring (10)
      • 회고 (4)
      • 사진 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    플레이스토어
    오픈소스
    컨트리뷰터
    django
    음식
    배공파용
    Contributor
    g
    트리 #AVL #알고리즘 #자료구조
    배달비 공유
    어플리케이션
    라이프 스타일
    배달
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Leffe_pt
[MySQL]트랜잭션의 격리 수준 파헤치기 1
상단으로

티스토리툴바