개발/DB

트랜잭션 격리수준(Isolation Levels)

냥덕_ 2025. 3. 27. 17:11

트랜잭션이란?

트랜잭션이란 데이터베이에서 하나의 논리적 작업 단위를 의미한다. 

여러 개의 SQL 연산이 하나의 트랜잭션 내에서 실행되며, 트랜잭션이 성공적으로 수행될 경우 모든 사항이 반영되지만 실패할 경우 변경 사항이 롤백되며 복구된다.

 

트랜잭션 ACID 속성

1. 원자성(Atomicity)

트랜잭션 내의 모든 작업이 성공적으로 수행되거나 전혀 수행되지 않도록 보장하는 속성

예시: 계좌 이체에서 한 계좌에서 돈을 빼고 다른 계좌에 입금하는 두 개의 연산 중 하나라도 실패하면 전체 트랜잭션이 취소

 

2. 격리성(Isolation)

동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않도록 하는 속성

예시: 두 사용자가 같은 계좌의 데이터를 수정할 경우, 격리 수준이 낮으면 데이터 충돌이 발생 가능

 

3. 일관성(Consistency)

트랜잭션이 실행된 후에도 데이터베이스가 일관된 상태를 유지

예시: 은행 계좌 이체에서 총 잔액이 변하지 않도록 보장해야 함

 

4. 지속성(Durability)

트랜잭션이 COMMIT 된 후에는 데이터베이스에 영구적으로 반영되어야 하는 속성

 

트랜잭션 격리 수준(Isolation Levels)

모든 내용은 MySQL InnoDB 기준입니다.

 

트랜잭션 격리 수준은 동시에 실행되는 트랜잭션 간의 데이터 일관성을 유지하기 위한 설정이다. 격리 수준이 낮을수록 성능은 향상되지만, 데이터 정합성 문제가 발생할 가능성이 높아진다.

 

트랜잭션이 동시에 실행될 때 발생할 수 있는 대표적인 문제들은 다음과 같다:

  • Dirty Read (더티 리드): 다른 트랜잭션에서 아직 COMMIT되지 않은 데이터를 읽음
  • Non-Repeatable Read (반복 불가능한 읽기): 같은 데이터를 여러 번 조회할 때 값이 변경됨
  • Phantom Read (팬텀 리드): 트랜잭션 도중 새로운 데이터가 삽입되어 결과 집합이 변경됨

MySQL 기준으로 지원하는 트랜잭션 격리 수준은 총 4가지이다. 각각의 격리 수준이 무엇인지 그리고 어떤 문제들이 발생할 수 있는지 알아보자.

  • READ UNCOMMITTED (커밋되지 않은 읽기)
  • READ COMMITTED (커밋된 읽기)
  • REPEATABLE READ (반복 가능한 읽기, MySQL 기본값)
  • SERIALIZABLE (직렬화)

 

READ UNCOMMITTED 

가장 낮은 격리 수준으로, 다른 트랜잭션이 아직 COMMIT 하지 않은 데이터를 읽을 수 있는 격리 수준이다

예시로 A 트랜잭션이 시작하고 id = 1인 상품의 개수를 변경하고 commit을 하지 않고 B 트랜잭션을 시작한 다음 id = 1의 상품의 개수를 조회하면 커밋되지 않았음에도 감소된 개수를 확인할 수 있다. 

 

현재 아래 이미지처럼 cnt는 100개로 데이터를 넣고 테스트를 해보자

 

A 트랜잭션에서 아래처럼 쿼리가 수행된 상태다.

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE product SET cnt = cnt - 1 WHERE id = 1;

 

B 트랜잭션에서 아래처럼 조회를 한다고 하면 

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT * FROM product WHERE id = 1;

위처럼 A에서 커밋되지 않았음에도 B 트랜잭션에서 바뀐 값을 확인할 수 있다. 

이처럼 다른 트랜잭션에서 아직 COMMIT되지 않은 데이터를 읽을 수 있는 것을 Dirty Read (더티 리드)라고 한다

 

문제는?

Dirty Read를 기반으로 처리가 이루어지면, 비정상적인 데이터가 사용될 가능성이 있다.

쉽게 위 예시로 보면 만약 A 트랜잭션에서 처리를 하다가 롤백을 하게 되면 다시 100개로 복구되는데 B 트랜잭션에서는 그대로 수행하게 될 경우 99개의 값을 가지고 처리를 하게 된다. 이로 인해 존재하지 않는 재고를 기준으로 주문이 처리되거나, 추가적인 주문이 들어올 때 잘못된 재고 정보를 바탕으로 상품이 초과 판매될 가능성이 생긴다.

추가로 해당 격리 수준은 권장하지 않는 수준이다.

READ COMMITTED 

트랜잭션이 커밋된 데이터만 읽을 수 있도록 보장하는 격리 수준이다. 즉 아직 커밋되지 않은 다른 트랜잭션의 변경 사항은 읽을 수 없다.

하지만, 다른 트랜잭션이 커밋한 데이터는 바로 읽을 수 있기 때문에, 같은 쿼리를 실행하더라도 결과가 달라질 수 있다.

어떤 현상인지 아래 예시를 살펴보자 참고로 아래 데이터 상태로 시작한다

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
select * from product where id = 1;
UPDATE product SET cnt = cnt - 1 WHERE id = 1;

 


A 트랜잭션에서 위와 같이 쿼리가 실행된 상태에서

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
select * from product where id = 1;

 

B 트랜잭션에서 위와 같이 조회를 하면 아래와 같이 1개가 나온다

이후에 A 트랜잭션이 commit을 하고 나서 B 트랜잭션에서 조회를 다시 하게 되면 아래 처럼 개수가 1개 줄어든 값으로 조회가 된다. 즉 A에서 커밋된 후 다시 조회를 했을 때 커밋된 데이터를 조회하는 것이 가능하다. 이러한 현상을 Non-Repeatable Read (반복 불가능한 읽기)라고 한다.

문제는?

여러번 조회를 할 때 동일한 데이터를 읽어서 처리를 해야하는 일에서 계속해서 결과값이 변경되어 조회가 된다면 처리에 어려움이 있을 수 있다. 예를 들어 입금 총액을 계산하는 중인데, 다른 트랜잭션에서 계속 입금이 발생하면 정확한 총액 계산이 어려울 수 있다.

 

REPEATABLE READ

트랜잭션이 시작된 시점의 데이터를 트랜잭션이 끝날 때까지 동일하게 유지하는 격리 수준이다.

이를 위해서는 보통 MVCC를 활용한다. 

MVCC란?
MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어)는 여러 트랜잭션이 동시에 데이터를 읽고 쓸 수 있도록 도와주는 기술이다.
보통 데이터를 수정할 때 기존 값을 undo 로그에 보관해두고, 트랜잭션마다 최초 조회가 일어난 시점의 스냅샷을 기준으로 데이터를 읽는다.
이 과정에서 하나의 레코드에 대해 여러 버전의 데이터가 존재할 수 있는데, 이런 방식으로 동시성을 제어하기 때문에 "다중 버전"이라고 불린다.

 

REPEATABLE READ는 MVCC를 이용해 트랜잭션 내에서 동일한 결과를 보장하지만, 새로운 레코드가 추가되는 경우에는 부정합이 생길 수 있다. 우선 동일한 결과를 보장하는 방법부터 살펴보자.

참고로 테이블에서 tx-id는 InnoDB에서 생성하는 DB_TRX_ID 컬럼을 의미합니다.

DB_TRX_ID는 행을 insert 하거나 update한 마지막 트랜잭션에 대한 트랜잭션 식별자를 의미

 

초기 B 트랜잭션(tx-id = 13)이 먼저 조회를 실행하면 이미지처럼 결과가 나오게 된다. 

 

이후에 A 트랜잭션이 시작하면서 update를 수행하게 되면 아래 이미지에서 처럼 레코드의 tx-id를 A 트랜잭션의 id로 변경하고 undo 로그에는 변경되기 이전의 데이터를 저장한다.

 

이후에 B 트랜잭션에 다시 조회를 하게 되면 tx-id를 참고해서 자신보다 이후에 실행된 값이면 undo 로그를 참고해서 데이터를 읽어온다. 따라서 아래 이미지 처럼 B 트랜잭션이 시작된 시점의 데이터 값을 읽어온다. 

만약 자신보다 이전이라면 해당 테이블의 데이터를 가져온다.

 

해당 작업이 UPDATE가 아닌 INSERT 시에도 tx-id를 참고해서 이전의 insert된 레코드의 tx-id가 현재 트랜잭션 id보다 크다면 무시하고 데이터를 가져오면서 일관된 데이터 읽기를 보장해준다.

 

문제는?

문제는 해당 격리 수준에서 데이터를 트랜잭션이 끝날 때까지 동일하게 유지하지만 다른 트랜잭션에서 새로운 데이터가 추가되면 해당 트랜잭션 내에서 추가된 레코드가 발견될 수 있다. 

 

오잉? 위에서는 INSERT 시에도 일관되게 읽어준다고 했는데요? 

해당 문제는 락을 사용하게 되면 발생한다. 이유를 쉽게 말하자면 `SELECT ... FOR UPDATE`와 같은 "잠금 쿼리"는  
MVCC 스냅샷을 사용하지 않고 실제 테이블의 최신 데이터를 기준으로 조회한다.

 

참고로 MySQL의 경우에는 넥스트 키 락을 사용하여 갭락을 걸기 때문에 유령 읽기를 방지할 수 있다.

 

SERIALIZABLE 

이 격리 수준은 이름 그대로, 모든 트랜잭션이 직렬로 실행된 것처럼 보이도록 보장한다. 마치 트랜잭션들이 하나씩 순서대로 실행된 것처럼 동작하게 만드는 격리 수준이다.

 

모든 SELECT 쿼리에도 레코드에 락이 걸리며, 범위 조회시 갭 락이 걸리게 된다. 

 

언제 SERIALIZABLE을 쓸까?

 

  • 정확성이 최우선인 시스템
    • 예: 은행 계좌 이체, 재고 계산, 회계 시스템 등
  • 동시성보다 데이터 무결성과 일관성이 더 중요한 경우

단점

  • 성능 저하
  • 락 경합 증가: 다른 트랜잭션이 대기하거나 롤백될 가능성 증가

참고

https://mangkyu.tistory.com/299

https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_virtual_generated_column