안녕하세요, 자바파커입니다.
트랜잭션 안에서 같은
SELECT를 두 번 했습니다. 첫 번째는10000, 두 번째는8000.저는 아무것도 안 바꿨습니다. 버그인가요?
버그가 아닙니다. 설정한 대로 동작한 겁니다.
결론부터 말씀드리면 — 격리 수준은 "내 트랜잭션이 도는 동안 남이 한 일을 얼마나 보게 할 것인가"를 정하는 다이얼입니다. 남을 덜 볼수록 결과가 안정적이고, 대신 느립니다. 위 현상은 그 다이얼이 낮게 맞춰져 있다는 뜻입니다.
왜 다이얼이 필요한가
트랜잭션을 하나씩 순서대로만 돌리면 이상한 일은 하나도 안 생깁니다. 그리고 아무도 그렇게 쓰지 않습니다. 너무 느리니까요.
그래서 실제 DB는 여러 트랜잭션을 겹쳐서 돌립니다. 겹치는 순간 "남이 바꾸는 중인 데이터를 내가 읽어도 되는가"라는 질문이 생기고, 그 답을 정하는 게 격리 수준입니다.
낮추면 빨라지고, 대신 아래 세 가지가 새어 들어옵니다.
이상 현상 세 가지
이름이 헷갈리기로 유명한데, "무엇이 달라졌는가" 로 구분하면 간단합니다.
| 현상 | 무엇을 봤나 | 두 번 읽었을 때 |
|---|---|---|
| Dirty Read | 아직 커밋 안 된 값 | — |
| Non-repeatable Read | 커밋된 값 | 같은 행의 값이 바뀜 |
| Phantom Read | 커밋된 값 | 행의 개수가 바뀜 |
1. Dirty Read — 커밋도 안 된 걸 읽는다
T1: UPDATE account SET balance = 8000 WHERE id = 1; -- 아직 커밋 안 함
T2: SELECT balance FROM account WHERE id = 1; -- 8000 을 읽는다
T1: ROLLBACK; -- 없던 일이 됨T2는 한 번도 존재한 적 없는 값을 읽고 그걸로 계산했습니다. 이게 가장 위험한 축입니다.
2. Non-repeatable Read — 같은 행인데 값이 바뀐다
T1: SELECT balance FROM account WHERE id = 1; -- 10000
T2: UPDATE account SET balance = 8000 WHERE id = 1; COMMIT;
T1: SELECT balance FROM account WHERE id = 1; -- 8000 ← 같은 트랜잭션인데 다름이번엔 T2가 정상적으로 커밋한 값입니다. 데이터는 멀쩡한데, T1 입장에선 발밑이 바뀐 겁니다. 도입부의 그 현상이 이것입니다.
3. Phantom Read — 없던 행이 나타난다
T1: SELECT COUNT(*) FROM orders WHERE amount > 1000; -- 3건
T2: INSERT INTO orders (amount) VALUES (5000); COMMIT;
T1: SELECT COUNT(*) FROM orders WHERE amount > 1000; -- 4건 ← 없던 행값이 아니라 범위에 걸리는 행의 수가 바뀌었습니다. 그래서 앞의 둘과 달리 특정 행을 잠그는 것만으로는 못 막습니다. 아직 존재하지 않는 행을 잠글 수는 없으니까요.
격리 수준 네 단계
위 세 가지를 어디까지 허용하는지로 단계가 나뉩니다.
| 수준 | Dirty | Non-repeatable | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 |
| READ COMMITTED | 방지 | 발생 | 발생 |
| REPEATABLE READ | 방지 | 방지 | 발생* |
| SERIALIZABLE | 방지 | 방지 | 방지 |
* 이 별표가 실무에서 제일 많이 틀리는 지점입니다. 아래에서 따로 다루겠습니다.
READ UNCOMMITTED는 사실상 쓰지 않습니다. 롤백될 값을 읽는 걸 감수할 만큼 성능이 급한 경우가 거의 없고, 애초에 Oracle은 지원조차 하지 않습니다.
기본값이 DB마다 다릅니다
여기서 사고가 납니다. 로컬에서 되던 게 운영에서 안 되거나, DB를 옮겼더니 동작이 달라지는 경우 대부분이 이것입니다.
| DB | 기본 격리 수준 |
|---|---|
| MySQL (InnoDB) | REPEATABLE READ |
| PostgreSQL | READ COMMITTED |
| Oracle | READ COMMITTED |
| SQL Server | READ COMMITTED |
MySQL만 한 단계 높습니다. 그래서 MySQL에서 잘 돌던 코드를 PostgreSQL로 옮기면 Non-repeatable Read가 새로 보이기 시작합니다. 반대 방향으로 옮기면 없던 락 대기와 데드락이 생깁니다.
확인은 이렇게 합니다.
-- MySQL
SELECT @@transaction_isolation;
-- PostgreSQL
SHOW default_transaction_isolation;별표의 정체 — RR이 팬텀을 막나
표에는 "REPEATABLE READ에서 팬텀 발생"이라고 적혀 있습니다. 그건 ANSI 표준이 그렇게 정의했다는 뜻이고, 실제 DB는 대부분 그보다 강하게 동작합니다.
MySQL(InnoDB) — 일반 SELECT는 트랜잭션 시작 시점의 스냅샷을 계속 봅니다(MVCC). 그래서 남이 INSERT를 커밋해도 안 보입니다. 잠금을 거는 읽기(SELECT ... FOR UPDATE)에서는 갭 락(gap lock) 으로 "아직 없는 행이 들어올 자리"까지 잠가 막습니다.
PostgreSQL — REPEATABLE READ가 사실상 스냅샷 격리라 팬텀이 나타나지 않습니다.
그러면 SERIALIZABLE은 왜 필요할까요. 스냅샷만으로는 못 막는 게 남기 때문입니다. 대표적인 게 write skew 입니다.
규칙: 당직자는 항상 1명 이상이어야 한다 (현재 A, B 두 명)
T1: A가 조회 → "B가 있네" → A를 당직 해제
T2: B가 조회 → "A가 있네" → B를 당직 해제
둘 다 커밋 → 당직자 0명각자의 스냅샷에서는 둘 다 규칙을 지켰습니다. 합쳐 놓으면 깨졌습니다. 이건 격리 수준을 SERIALIZABLE로 올리거나, 애플리케이션에서 명시적으로 잠가야 막힙니다.
격리 수준으로 안 잡히는 것 — 갱신 손실
표에 없는 문제가 하나 더 있습니다. 그리고 실무에서 제일 자주 터집니다.
T1: SELECT stock FROM item WHERE id = 1; -- 10
T2: SELECT stock FROM item WHERE id = 1; -- 10
T1: UPDATE item SET stock = 9 WHERE id = 1; COMMIT;
T2: UPDATE item SET stock = 9 WHERE id = 1; COMMIT;
두 번 팔았는데 재고는 1만 줄었습니다.읽고 → 계산하고 → 쓰는 흐름을 애플리케이션 코드에서 하면 격리 수준을 올려도 잘 안 잡힙니다. 해결은 셋 중 하나입니다.
| 방법 | 코드 | 언제 |
|---|---|---|
| DB에서 계산 | SET stock = stock - 1 |
가능하면 이게 제일 낫습니다 |
| 비관적 락 | SELECT ... FOR UPDATE |
충돌이 잦을 때 |
| 낙관적 락 | WHERE version = 3 후 갱신 행 수 확인 |
충돌이 드물 때 |
읽은 값을 애플리케이션에서 빼서 다시 쓰는 코드를 발견하면, 거기가 후보입니다.
실무에서는 무엇을 쓰나
기본값을 그대로 두시면 됩니다. 격리 수준을 전역으로 올리는 건 거의 답이 아닙니다 — 락 경합과 데드락이 늘고, 그 대가로 얻는 안전은 대부분의 트랜잭션에 필요 없습니다.
대신 문제가 되는 트랜잭션만 개별로 올리거나 명시적으로 잠급니다.
-- 이 트랜잭션만 올리기
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 또는 필요한 행만 잠그기
SELECT * FROM item WHERE id = 1 FOR UPDATE;기준은 단순합니다.
- 돈·재고·좌석처럼 틀리면 안 되는 것 → 명시적 잠금 또는 DB에서 직접 계산
- 통계·목록·대시보드 → 기본값으로 충분합니다. 잠깐 어긋나도 됩니다
- 긴 트랜잭션 → 격리 수준보다 트랜잭션을 짧게 만드는 게 먼저입니다
마지막 항목이 실무에서 가장 효과가 큽니다. 트랜잭션 안에 외부 API 호출이나 파일 처리가 들어가 있으면, 격리 수준을 어떻게 맞추든 락을 오래 쥐고 있어 뒤가 밀립니다.
자주 묻는 질문 (FAQ)
격리 수준을 올리면 항상 안전해지나요?
아닙니다. 갱신 손실은 격리 수준으로 안 잡힙니다. 읽고 계산해서 다시 쓰는 흐름이면 잠금이나 버전 확인이 따로 필요합니다. 그리고 SERIALIZABLE로 올리면 충돌 시 트랜잭션이 실패하므로, 재시도 코드가 없으면 오히려 에러가 늘어납니다.
스프링의 @Transactional(isolation = ...)은 어떻게 동작하나요?
JDBC 커넥션의 격리 수준을 그 트랜잭션 동안만 바꿔 줍니다. 다만 커넥션 풀에서 빌린 커넥션의 설정을 건드리는 것이라, 반납 시 원복되는지는 풀 설정에 달려 있습니다. HikariCP는 원복해 줍니다. DB가 지원하지 않는 수준을 지정하면 조용히 무시되거나 예외가 납니다 — Oracle에 REPEATABLE READ를 주는 경우가 그렇습니다.
읽기 전용 조회에도 트랜잭션이 필요한가요?
한 번의 SELECT만 한다면 필요 없습니다. 다만 여러 번 조회해서 그 값들이 서로 맞아떨어져야 한다면 하나의 트랜잭션으로 묶어야 합니다. 안 묶으면 조회 사이에 데이터가 바뀌어 합계가 안 맞는 화면이 나옵니다.
MySQL과 PostgreSQL 중 어느 쪽이 맞는 건가요?
둘 다 표준을 지킨 선택입니다. 표준은 "이 수준에서 이 현상이 발생해도 된다"고 정할 뿐, 막는 걸 금지하지 않습니다. 중요한 건 내가 쓰는 DB의 기본값을 알고 있는 것이지 어느 쪽이 옳은지가 아닙니다.
정리하며
격리 수준은 정확성과 속도를 맞바꾸는 다이얼입니다.
- 이상 현상은 셋 — 커밋 안 된 값(Dirty), 같은 행의 값이 바뀜(Non-repeatable), 행 수가 바뀜(Phantom)
- MySQL은 REPEATABLE READ, PostgreSQL은 READ COMMITTED 가 기본입니다. 옮길 때 동작이 달라집니다
- 표의 "RR에서 팬텀 발생"은 표준 정의입니다. 실제 MySQL·PostgreSQL은 그보다 강하게 막습니다
- 갱신 손실은 격리 수준으로 안 잡힙니다. DB에서 계산하거나, 잠그거나, 버전을 확인하세요
- 전역으로 올리지 마시고, 문제가 되는 트랜잭션만 다루세요
같은 조회를 두 번 했는데 값이 달랐던 경험 있으신가요? 그때 쓰던 DB의 기본 격리 수준은 무엇이었나요?