🚦DB Isolation Level(격리 수준)이란?
이전에 DB 트랜잭션에 대한 글을 정리하면서, 특징 중 격리성 에 대해서도 알아보았다.
기본적으로 트랜잭션이 독립적으로 수행되어야 하지만, 얼마나 독립적인지의 정도에서는 차이가 존재한다.
이를 격리 수준이라고 부르며, 트랜잭션이 동시에 실행될 때, 서로의 작업이 어느 정도까지 격리되어야 하는지를 설정하는 기준이 된다.
격리 수준은 4가지가 존재하며, 이번 글에서는 격리성이 낮은(= 동시성이 높은) 순으로 정리해보고자 한다.
📌 1. READ UNCOMMITTED
READ UNCOMMITTED 는 이름 그대로 커밋되지 않은 데이터까지 접근할 수 있는 격리 수준이다.

- 사용자 A가 id=51의 멤버를 INSERT 한다.
- 사용자 B가 id=51의 멤버를 SELECT 한다.
- 아직 COMMIT 되지 않았음에도, 새롭게 추가된 데이터에 접근하여 결과값을 가져오게 된다.
어떻게 가져오는가?
COMMIT이 되지 않았기에, 아직 로그와 데이터 모두 디스크에 쓰이지 않은 상황인데 사용자 B에서는 어떻게 데이터를 조회할 수 있었을까?
이는 사용자 A가 진행 중인 트랜잭션이 변경한 버퍼 풀의 내용을 그대로 읽었기에 가능하다. 그리고 사용자 B가 읽은 버퍼 풀의 데이터를
더티 페이지라고 부른다.
더티 페이지(Dirty Page)란?
버퍼 풀에 있는 데이터 중, 아직 디스크에 flush되지 않은 상태의 페이지를 말한다.
여기서
더티(Dirty)란 원본과 달라진 상태를 의미하는 용어이다.
모든 더티 페이지가 위험한 것은 아니지만, 커밋되지 않은 트랜잭션이 만든 더티 페이지는 다른 트랜잭션이 읽으면 안 되는 경우가 많기 때문에 유의해야 한다.
더티 리드(Dirty Read)란?
그리고 여기서 사용자 B가 한 행위를 더티 리드 라고 부른다.
이는 다른 트랜잭션이 아직 COMMIT하지 않은 데이터를 읽는 현상으로, 위에서 말한 더티 페이지에 접근하는 것을 허용할 때 발생하게 된다.
더티 리드가 왜 문제인가?

사용자 B가 더티 리드를 한 이후에, 사용자 A의 트랜잭션에서 문제가 발생하여 ROLLBACK을 수행했다고 해보자.
- ROLLBACK -> id=51인 멤버는 테이블에서 사라진다.
- 사용자 B의 트랜잭션은 계속 진행중
- 조회한 id=51 데이터를 기반으로 처리 로직을 계속 수행 (결제, 연관 테이블 INSERT 등)
- 데이터 정합성이 깨지게 됨
더티 리드는 존재하지 않는 데이터 를 신뢰하게 만든다는 점에서 시스템 전반의 정합성 & 무결성을 심각하게 훼손할 수 있다.
따라서 READ UNCOMMITTED는 대부분의 시스템에서 사용이 권장되지 않으며, 최소한 READ COMMITTED 이상의 격리 수준을 권장한다.
언제 쓰이는가?
그럼 이렇게 위험한 격리 수준은 언제 쓰이는지에 대한 궁금증이 생긴다.
READ UNCOMMITTED 는 정합성이 낮은 만큼 일반적인 서비스에서는 거의 사용하지 않지만, 특정 목적에 한정해 “읽기 성능”을 극단적으로 높이고 싶을 때 선택적으로 사용될 수 있다.
예를 들어 모니터링 & 통계성 쿼리 & 로깅 등이 있으며, 데이터에서 약간의 오차는 허용하는 경우에 쓰이곤 한다.
📌 2. READ COMMITTED
READ COMMITTED 는 커밋된 데이터만 접근할 수 있는 격리 수준이다.
더티 리드 방지 가능

사용자 A가 id=50인 멤버의 이름을 MinKyu로 변경하는 트랜잭션을 진행중이라고 해보자.
위 이미지 상으로는 UPDATE 문이 실행되었고, 아직 COMMIT 전이기에 버퍼 풀 내에서 저장된다.
또한 UNDO 로그에는 변경 이전의 데이터가 기록된다.

이 상태에서 사용자 B의 트랜잭션에서 id=50 멤버의 이름을 조회하는 쿼리를 실행했다고 해 보자.
아직 COMMIT 이전이기 때문에, UNDO 로그에서 변경 전의 데이터를 찾아서 반환하게 된다. 때문에 사용자 B는 MangKyu 라는 이전의 이름을 결과로 받게 된다.
즉, 더티 페이지를 직접 읽지 않고, UNDO 로그를 통해 변경 이전의 커밋된 데이터 버전을 읽기 때문에 더티 리드는 발생하지 않는다.
언제 쓰이는가?
READ COMMITTED는 대부분의 상용 데이터베이스(MS-SQL, Oracle 등)의 기본 격리 수준이며, 동시성과 데이터 정합성 사이에서 균형을 잡고 싶은 경우에 자주 사용된다.
Non-repeatable Read 발생 가능
READ COMMITTED 는 더티 리드를 방지하긴 하지만, 다른 문제가 발생할 수 있다.
- 사용자 A가 id=50인 멤버의 이름을
MinKyu로 변경하는 트랜잭션을 실행한다.
- 사용자 B가 같은 시점에 SELECT 쿼리로 이름을 조회하면, UNDO 로그를 통해 변경 이전 값인
MangKyu를 결과로 받는다. - 이후 사용자 A가 COMMIT을 수행한다.
- 사용자 B가 같은 쿼리를 다시 실행하면, 이번엔 변경 후 값인
MinKyu가 반환된다.
이렇듯, 하나의 트랜잭션 내에서 동일한 쿼리를 반복 실행했음에도 결과가 달라지는 현상을
Non-repeatable Read (반복 불가 읽기)라고 한다.
이 문제는 이후 격리 수준인 REPEATABLE READ 이상에서 해결할 수 있다.
문제가 되는 경우는?
Non-repeatable Read 가 문제가 될 만한 사례를 현실세계에서 떠올려 보자.
- A라는 사람이 편의점에
4/30 23:59에 입장
- 4월까지 1,500원으로 세일하는 과자를 집고 카운터로 이동 (트랜잭션 시작)
- 이동하던 중,
5/1 00:00이 되어 세일이 종료됨 (타 트랜잭션에 의해 가격이 2,000원으로 UPDATE 됨) - A는 1,500원으로 인지하고 구입하려 했지만, 2,000원에 구입하게 됨
이렇듯 트랜잭션 내에서 처음 확인한 정보와 최종 결과가 달라지는 현상은, 사용자의 의도와 다른 처리가 이뤄질 수 있기 때문에 비즈니스 일관성이 깨질 수 있다.
📌 3. REPEATABLE READ
일반적으로 RDBMS에서는 변경 전 레코드를 UNDO 로그에 백업해둔다.
그렇다면 변경 전/후의 데이터가 모두 존재하게 되고, 동일 레코드에 대해서 여러 버전의 데이터가 존재한다고 하여 이를 MVCC 라고 부른다.
MVCC란?
MVCC는 Multi-Version Concurrency Control의 준 말로, 다중 버전 동시성 제어라는 의미를 가진다.
이를 활용하여 트랜잭션이 롤백된 경우에 데이터를 복원할 수 있을 뿐만 아니라, 서로 다른 트랜잭션 간에 접근할 수 있는 데이터를 세밀하게 제어할 수 있게 된다.
각각의 트랜잭션은 순차 증가하는 고유한 트랜잭션 번호가 존재하며, 백업 레코드에는 어느 트랜잭션에 의해 백업되었는지 트랜잭션 번호를 함께 저장한다.
그리고 해당 데이터가 불필요해진다고 판단하는 시점에 주기적으로 백그라운드 쓰레드를 통해 삭제한다.
ps. MVCC & REDO & UNDO에 대해서는 추후 다른 글에서 자세히 다룰 예정이다.
REPEATABLE READ 수준은 MVCC를 이용하여 기본적으로 한 트랜잭션 내에서 동일한 결과를 보장하지만, 새로운 레코드가 추가(INSERT)되는 경우에는 데이터 정합성이 깨질 수 있다.
예시

B 트랜잭션이 동작하면서 id가 50이상인 레코드를 조회한다. 이 때는 1건의 결과가 정상적으로 조회된다.
현재 B트랜잭션의 id는 10이다. (T-ID=10)

A 트랜잭션에서 id가 50인 member의 name을 MinKyu로 업데이트 하는 쿼리를 날린다.
테이블 상에서는 name이 변경되었으나, UNDO 로그 내에는 업데이트 이전 name이 기록되어 있다.
현재 A트랜잭션의 id는 12이다. (T-ID=12)

A 트랜잭션이 커밋되어, name이 영구적으로 변경되었다. 아직 B 트랜잭션은 종료되지 않았기에, 다시 한 번 동일한 쿼리로 id가 50이상인 member를 조회한다.
커밋되었기 때문에, 테이블 상에서 MinKyu라는 name을 조회할 것으로 예상되지만 그렇지 않고 이전의 name인 ManKyu를 조회한다.
어떻게 이렇게 동작하는 것일까?
이는 B 트랜잭션이 A 트랜잭션보다 먼저 실행되었기 때문이다.
REPEATABLE READ 수준에서는 트랜잭션 id(번호)를 참고하여 자신보다 먼저 실행된 트랜잭션의 데이터만을 조회하게 된다.
만약 지금과 같이 자신 이후로 실행된 트랜잭션의 데이터가 존재하는 경우에는, UNDO 로그를 참고해서 데이터를 조회한다.
정리하자면 B 트랜잭션의 id가 10이고 A 트랜잭션의 id가 12이므로, 테이블의 레코드(MinKyu)를 바로 조회하는 것이 아니라 / UNDO 로그에 기록된 데이터(ManKyu)를 조회하는 것이다.

데이터 변경이 아닌, 새로운 레코드를 삽입하는 경우라면 어떻게 될까? A 트랜잭션에서 id가 51이고 name이 Martin인 레코드를 삽입하고 커밋하였다.
이때 B 트랜잭션에서 id가 50 이상인 레코드를 조회하는 쿼리를 날린다. 현재 테이블 상에는 id가 50 이상인 레코드가 2개이므로 2개의 결과가 나올 것으로 예상되지만 그렇지 않고 1건만 조회된다.
이 또한 앞에서 언급한 것처럼 트랜잭션 id를 통해서, 자신 이후에 추가된 데이터는 무시할 수 있기 때문이다.
하지만 만약에 트랜잭션 id가 존재하지 않아서, 선후 관계를 추적하지 못 하는 경우라면 어떻게 될까?
그렇다면 위처럼 상황에 따라 적절하게 UNDO 로그에서 조회하는 조치를 취할 수가 없다. 때문에 테이블 상에 있는 데이터를 그대로 읽게 되고, 결과로 2건의 레코드 조회하게 된다.
이러한 상황을 Phantom Read(팬텀 리드)라고 부른다.
Phantom Read(팬텀 리드)란?
REPEATABLE READ 수준에서는 새로운 레코드의 추가까지는 막지 않는다.
따라서 SELECT로 조회하는 경우 트랜잭션이 끝나기 전에 다른 트랜잭션에 의해 추가된 레코드가 발견될 수 있는데, 이를 Phantom Read라고 한다.
하지만
MVCC덕분에 일반적인 조회에서Phantom Read는 발생하지 않는다.
그렇다면 어떠한 경우에서 팬텀 리드가 발생할 수 있을까?
팬텀 리드가 발생할 수 있는 경우
락(잠금)이 사용되는 경우에 팬텀 리드가 발생할 수 있는데, MySQL에는 특수한 갭 락 이라는 장치가 있으므로 다른 RDBMS부터 살펴보려고 한다.

위의 예시와 동일하게 B 트랜잭션에서 id가 50이상인 멤버를 조회하려고 한다. 여기서 차이점은 SELECT가 아니라, SELECT FOR UPDATE를 사용했다는 것이다.
SELECT FOR UPDATE?
SELECT FOR UPDATE 구문은 조회하는 행(row) 에 대해 배타적 락(X-lock)을 거는 비관적 잠금 방식이다.
이 락이 걸린 레코드는 다른 트랜잭션에서 쓰기(INSERT, UPDATE, DELETE) 작업은 할 수 없지만, 읽기(SELECT) 작업은 가능하다.
이러한 락은 트랜잭션이 커밋 또는 롤백될 때, 즉 종료될 때 해제된다.
여기서 A 트랜잭션에서 id가 51인 데이터를 INSERT한다. 현재 락은 id가 50인 레코드에만 걸려 있기 때문에, 정상적으로 삽입이 된다.

이후에 B 트랜잭션에서 id가 50 이상인 member를 조회하면, 이번에는 1건이 아닌 2건의 레코드를 조회하게 된다.
모르는 사이에 레코드가 생기거나 사라지는, 팬텀 리드가 발생한 것이다.
이번에는 왜 MVCC로 팬텀 리드를 방지하지 못 했을까?
SELECT가 아닌 SELECT FOR UPDATE, 즉 잠금 있는 읽기로 조회하는 경우에는 UNDO 로그가 아닌 테이블에서 바로 조회가 수행되기 때문이다.
잠금 있는 읽기는 테이블에 변경이 일어나지 않게 하기 위해서, 테이블에 잠금을 걸고 -> 테이블에서 데이터를 조회한다.
UNDO 로그에 잠금을 걸고 -> UNDO 로그에서 조회하면 되지 않을까?
그럴 수는 없다. UNDO 로그는 Append Only 형태로, 잠금 기능이 없어 동시성 제어가 불가능하기 때문이다.
때문에 잠금 있는 읽기(SELECT FOR UPDATE or SELECT FOR SHARE)로 조회하는 경우에는, UNDO 로그가 아닌 테이블에서만 조회를 하게 되고 이로 인해 팬텀 리드를 야기할 수 있다.
MySQL Gap Lock
MySQL에는 특수한 갭 락이라는 장치가 존재하기 때문에, 위와 같은 상황에서도 팬텀 리드가 발생하지 않는다.

B 트랜잭션에서 SELECT FOR UPDATE로 조회하는 경우에, MySQL은 아래와 같이 락을 건다.
- id가 50인 레코드에 대해서는
레코드 락
- id가 50보다 큰 범위는 갭 락으로
넥스트 키 락
ps. 레코드 락, 넥스트 키 락, 갭 락 등은 추후 다른 글에서 자세히 다루려고 한다.
여기서 A 트랜잭션이 id가 51인 member를 INSERT 하려고 시도한다면, B 트랜잭션이 종료될 때 까지 기다리게 된다.
만약 대기 시간이 너무 길어진다면, 락 타임아웃이 발생할 수 있다.
그렇기 때문에 일반적으로 MySQL의 REPEATABLE READ에서는 팬텀 리드가 발생하지 않는다.
하지만 아래와 같은 경우에 유일하게 발생할 수 있다.

B 트랜잭션에서 SELECT 문으로 조회를 한다. 이 때는 락이 걸리지 않기 때문에, A 트랜잭션에서 INSERT를 수행하고 커밋까지 정상적으로 완료된다.
이후 B 트랜잭션에서 SELECT가 아닌 SELECT FOR UPDATE 문으로 조회를 시도한다. 이 때는 UNDO 로그가 아닌, 테이블 자체에서 조회를 하므로 1건이 아니라 2건이 조회되어 팬텀 리드가 발생한다.
MySQL 기준 팬텀 리드 상황 정리
지금까지 나온 내용을 정리하자면 아래와 같다.(MySQL 기준)
SELECT FOR UPDATE -> SELECT
- 갭 락으로 인해서 팬텀 리드 발생 ❌
SELECT FOR UPDATE -> SELECT FOR UPDATE
- 갭 락으로 인해서 팬텀 리드 발생 ❌
SELECT -> SELECT
- MVCC로 인해서 팬텀 리드 발생 ❌
SELECT -> SELECT FOR UPDATE
- MVCC 동작 불가, 유일하게 팬텀 리드 발생 ⭕️
📌 4. SERIALIZABLE
글 초반부에서, 격리성이 낮은 순으로 정리를 한다고 하였다.
그렇기에 이번에 정리할 SERIALIZABLE은 격리성이 가장 높고 / 동시성이 가장 낮은 수준이라고 할 수 있다.
SERIALIZABLE?
이름 그대로 트랜잭션을 순차적으로 실행시킨다는 뜻이다. 해당 수준에서는 여러 트랜잭션에서 동일한 레코드에 절대로 동시 접근할 수가 없다.
때문에 위에서 언급하였던 데이터 정합성과 관련한 문제들은 발생하지 않는다.
그렇지만 대신에 동시성이 매우 떨어지고, 이는 처리 속도 저하에 영향을 주게 된다.
SELECT 문에도 락을?
위 REPEATABLE READ 수준을 설명하면서, SELECT FOR UPDATE 또는 SELECT FOR SHARE 문에서는 락을 걸지만, 순수한 SELECT 문에서는 그렇지 않음을 언급하였다.
하지만 SERIALIZABLE 수준에서는, 순수한 SELECT 작업에서도 대상 레코드에 넥스트 키 락을 읽기 잠금(공유락, Shared Lock)으로 건다.
따라서 한 트랜잭션에서 SELECT만 하는 경우에도, 다른 트랜잭션에서는 대상 레코드에 대해 절대 추가/수정/삭제할 수 없으므로 성능이 많이 떨어지게 된다.