
트랜잭션은 데이터베이스에서 처리되는 가장 작은 작업 단위 이며 모든 작업이 전부 반영되거나, 전혀 반영되지 않아야 한다는 All or Nothing 의 성질을 가지고 있습니다.
이러한 트랜잭션의 동작을 보장하기 위한 네 가지 특성을 ACID 라고 하는데요, 이건 각각 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)을 의미합니다.
이번 포스팅에서는 이 중 동시성과 밀접한 관련이 있는 ‘격리성’ 에 대해서만 집중해서 다뤄보겠습니다.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | ✅ 발생 | ✅ 발생 | ✅ 발생 |
| READ COMMITTED | ❌ 방지 | ✅ 발생 | ✅ 발생 |
| REPEATABLE READ | ❌ 방지 | ❌ 방지 | ✅ 발생 (MySQL은 드묾) |
| SERIALIZABLE | ❌ 방지 | ❌ 방지 | ❌ 방지 |
“양념 삼겹살 볶음을 만들고 있는데, 양념 넣기 전에 삼겹살을 먹어버림”
커밋되지 않은 값도 조회할 수 있습니다.
즉, 다른 트랜잭션이 아직 확정 짓지 않은 값을 읽어버릴 수 있습니다.
-- A 트랜잭션
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE net_company
SET 배출량합계 = 180000000
WHERE 기업집단명 = '한국전력';
-- COMMIT 하지 않음
위의 A 트랜잭션은 '한국전력'의 배출량합계를 업데이트 했지만.
아직 커밋하지 않았습니다. 즉, 아직 확정된 데이터가 아닌 임시 변경값입니다.
이제 동시에 실행 중인 B 트랜잭션에서 이 데이터를 조회해봅시다.
-- B 트랜잭션
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT 배출량합계
FROM net_company
WHERE 기업집단명 = '한국전력';
-- 결과: 180000000 (Dirty Read)
-- A 트랜잭션 RollBack하면 문제 발생
B 트랜잭션은 커밋되지 않은 값을 읽어 옵니다.
이게 왜 문제냐고요??????
만약 이후에 A 트랜잭션이 다음과 같이 취소됩니다.
-- A 트랜잭션
ROLLBACK;
그러면 어떻게 될까요?
B 트랜잭션은 존재하지도 않게 된 값을 읽고 있었던 셈이 됩니다.
이런 상황을 Dirty Read (더럽게 읽기) 라고 부릅니다.
“완성은 해야지, 아직 안 끝났으면 안 보여줘”
READ COMMITTED는 실무에서 가장 흔히 사용되는 격리 수준입니다.
커밋된 데이터만 조회할 수 있도록 허용하기 때문에,
이전 단계에서 발생했던 Dirty Read 문제는 완전히 방지됩니다.
그러니까, 아직 조리 중인 데이터는 “보여주지 마”라는 말이죠.
일단 확정된 요리(데이터)만 테이블에 올리겠다는 격리 철학입니다.
READ COMMITTED는 Dirty Read는 막지만,
트랜잭션 중간에 데이터가 바뀌는 건 허용합니다.
즉, 같은 SELECT 문을 트랜잭션 내에서 여러 번 실행했을 때
결과가 달라질 수 있는 것, 이것이 Non-Repeatable Read입니다.
-- A 트랜잭션
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE net_company
SET 배출량합계 = 180000000
WHERE 기업집단명 = '한국전력';
-- 아직 COMMIT 안 함 ...
A 트랜잭션은 한국전력의 배출량합계를 수정했지만,
아직 커밋하지 않은 상태입니다.
이제 동시에 B 트랜잭션이 같은 데이터를 조회합니다.
-- B 트랜잭션
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT 배출량합계
FROM net_company
WHERE 기업집단명 = '한국전력';
-- 결과: 181432888 (변경 전 값)
B는 변경되기 전의 값(181432888) 을 읽어옵니다.
A가 업데이트했지만 아직 커밋하지 않았기 때문에, B는 그걸 무시하는 거죠.
여기서 DBMS는 Undo 로그라는 곳을 참고합니다.
✅ READ COMMITTED = "아직 확정 안 된 거면, 예전 값 줘"
이는 Oracle, PostgreSQL 등의 기본 격리 수준이기도 하며,
대부분의 OLTP 시스템에서 속도와 안정성 사이의 균형으로 자주 채택됩니다.
-- A 트랜잭션
COMMIT;
이제 A의 변경 사항은 정식으로 반영됩니다.
이 시점 이후부터는 누구든 변경된 값을 읽게 됩니다.
-- B 트랜잭션
SELECT 배출량합계
FROM net_company
WHERE 기업집단명 = '한국전력';
-- 결과: 180000000 (커밋 후 바뀐 값)
즉, 트랜잭션 중간에 SELECT를 또 하면 다른 결과를 볼 수 있는 것,
이게 바로 Non-Repeatable Read입니다.
다음 단계에서는
같은 SELECT를 몇 번 해도 결과가 절대 바뀌지 않는,
데이터 스냅샷 기반의 격리 수준, REPEATABLE READ 입니다.
“처음 본 음식, 다시 봐도 그 음식”
REPEATABLE READ는 트랜잭션이 시작된 시점의 데이터를 스냅샷으로 유지합니다.
즉, 같은 쿼리를 여러 번 실행해도 결과는 항상 동일합니다.
-- B 트랜잭션
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT 배출량합계
FROM net_company
WHERE 기업집단명 = '한국전력';
-- 결과: 181432888
B 트랜잭션은 REPEATABLE READ 격리 수준으로 시작되어,
'한국전력'의 배출량합계를 181432888로 읽습니다.
이제 A 트랜잭션에서 데이터를 수정해보겠습니다.
-- A 트랜잭션
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
UPDATE net_company
SET 배출량합계 = 180000000
WHERE 기업집단명 = '한국전력';
COMMIT;
A는 값을 바꿨고 커밋도 했습니다.
그럼 B 트랜잭션에서 다시 SELECT하면?
-- B 트랜잭션
SELECT 배출량합계
FROM net_company
WHERE 기업집단명 = '한국전력';
-- 결과: 181432888 (처음 읽은 값 그대로 유지됨)
바뀐 값이 보이지 않습니다!
트랜잭션이 시작된 순간의 스냅샷이 고정되어 있기 때문입니다.
MySQL InnoDB에서는 MVCC (다중 버전 동시성 제어)를 기반으로
트랜잭션이 시작된 시점의 레코드 버전을 고정합니다.
트랜잭션 시작 → 레코드 버전 기록 → 이후 변경 무시
따라서 트랜잭션이 살아 있는 한,
"그때 그 데이터" 만 계속해서 읽게 됩니다.
REPEATABLE READ는 행 단위의 변경은 완벽히 통제하지만,
조건에 부합하는 새로운 행이 삽입되는 것까지는 막지 못합니다.
이게 바로 Phantom Read (유령 읽기)입니다.
-- 트랜잭션 B (REPEATABLE READ)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM users WHERE age >= 30;
-- 결과: 3명
-- 트랜잭션 A (다른 세션)
INSERT INTO users (name, age) VALUES ('NewUser', 35);
COMMIT;
-- 트랜잭션 B
SELECT * FROM users WHERE age >= 30;
-- 결과: 4명 ❗️→ Phantom Read 발생
같은 조건으로 SELECT 했지만,
신규 행이 조건에 들어와서 조회 결과가 바뀌었습니다.
이런 변화는 WHERE 절 범위를 잠그지 않으면 막을 수 없습니다.
MySQL의 InnoDB 스토리지 엔진은
Gap Lock / Next-Key Lock이라는 기술로 Phantom Read를 대부분 방지합니다.
다만, 이것도 명확히 테이블 전체 범위를 지정하거나
인덱스를 정확히 활용하지 않으면 완벽하지는 않습니다.
그렇다면 신규 행 삽입까지도 전부 막고 싶은 상황엔 어떻게 해야 할까요?
우리가 도달한 마지막 경지,
바로 SERIALIZABLE입니다.
"이 줄 끝나기 전까진 아무도 못 온다”
SERIALIZABLE은 트랜잭션을 모두 순차적으로 처리한 것처럼 보장합니다.
Dirty Read, Non-Repeatable Read, Phantom Read 전부 방지됩니다.
그 대신, 성능은 꽤 희생됩니다.
왜냐고요???
SELECT에도 락이 걸리기 때문입니다.
-- B 트랜잭션
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
SELECT 배출량합계
FROM net_company
WHERE 기업집단명 = '한국전력';
-- 락 설정됨
이 SELECT에도 공유 락이 걸립니다.
-- A 트랜잭션
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
UPDATE net_company
SET 배출량합계 = 180000000
WHERE 기업집단명 = '한국전력';
-- 이 시점에서 B 트랜잭션이 끝나기 전까지 대기 or 실패
즉, SELECT 먼저 한 트랜잭션이 끝나기 전까진
다른 트랜잭션에서 UPDATE조차도 못 합니다.
SERIALIZABLE은 범위 잠금(Range Lock)을 통해
WHERE 절 조건에 해당하는 범위 자체를 잠급니다.

성능과 정합성은 늘 트레이드오프입니다.
격리 수준은 “지켜야 할 게 무엇인가?” 를 기준으로 선택하면 되지 않을까요?