[Spring] Transaction - 격리 수준(Isolation)
트랜잭션 격리수준(Isolation level)은 동시성과 일관성의 균형을 잡는 핵심 레버입니다. Spring의 @Transactional(isolation = …) 한 줄은 결국 JDBC Connection의 isolation을 조정해 DB 엔진(InnoDB, PostgreSQL 등)에 동작을 위임할 수 있습니다.
1. 이상현상
DB 트랜잭션에서 자주 말하는 세 가지 이상현상이 있습니다. 이상현상을 알아야 Isolation을 알 수 있기 때문에 참고하시길 바랍니다.
Isolation | Dirty Read | Non-Repeatable Read | Phantom Read |
READ_UNCOMMITTED | ✅ (이상현상 발생) | ✅ (이상현상 발생) | ✅ (이상현상 발생) |
READ_COMMITTED | ❌ | ✅ (이상현상 발생) | ✅ (이상현상 발생) |
REPEATABLE_READ | ❌ | ❌ | (DB별 상이) |
SERIALIZABLE | ❌ | ❌ | ❌ |
1-1. Dirty Read (더티 리드)
1) 정의
- 다른 트랜잭션이 커밋하지 않은 변경 내용을 읽는 현상.
- JPA 관점에서 얘기하면 flush 상태를 말할 수 있습니다.
2) Dirty Read의 이상 현상
- 상대 트랜잭션이 롤백하면, 내가 읽은 값은 존재하지 않았던 값이 됨.
예시 (T1=작성, T2=조회)
- T1: UPDATE account SET balance = balance - 100 WHERE id=1; (아직 커밋 안 함)
- T2: SELECT balance FROM account WHERE id=1; → 감소된 값을 봄
- T1: ROLLBACK
- 결과: T2가 본 값은 “유령 데이터”
3) 격리수준에 따른 이상 현상 발생 여부
- 이상현상 발생 X : READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE
- 이상현상 발생 O : READ_UNCOMMITTED
1-2. Non-Repeatable Read (비반복 가능 읽기)
1) 정의
- 같은 트랜잭션 안에서 같은 행을 두 번 읽었는데, 그 사이에 다른 트랜잭션이 커밋해 값이 달라지는 현상.
2) Non-Repeatable Read의 이상 현상
예시 (T1=두 번 읽기, T2=수정 후 커밋)
- T1: SELECT balance FROM account WHERE id=1; → 1000
- T2: UPDATE account SET balance=900 WHERE id=1; COMMIT;
- T1: (같은 트랜잭션에서) SELECT balance ... → 900 (값이 달라짐)
3) 격리수준에 따른 이상 현상 발생 여부
- 이상현상 발생 X : REPEATABLE_READ, SERIALIZABLE
- 이상현상 발생 O : READ_COMMITTED, READ_UNCOMMITTED
❗️ JPA 사용시 : 같은 영속성 컨텍스트(1차 캐시)에서 같은 엔티티를 다시 조회하면 캐시가 반환되어 값이 안 바뀐 것처럼 보일 수 있습니다. EntityManager.clear()/refresh()를 해야 비반복 가능 읽기가 드러납니다. (격리수준과 별개 이슈)
1-3. Phantom Read (팬텀 리드)
1) 정의
- 같은 트랜잭션 안에서 동일한 조건의 범위 조회를 두 번 했는데, 그 사이에 다른 트랜잭션의 INSERT/DELETE 커밋으로 행의 개수(혹은 매칭 집합)가 달라지는 현상.
2) Phantom Read (팬텀 리드)의 이상 현상
예시 (T1=범위조회, T2=신규 행 삽입 커밋)
- T1: SELECT * FROM orders WHERE price > 100; → 10행
- T2: INSERT INTO orders(price) VALUES (150); COMMIT;
- T1: (같은 트랜잭션) SELECT * FROM orders WHERE price > 100; → 11행 (새 행이 “팬텀”처럼 나타남)
3) 격리수준에 따른 이상 현상 발생 여부
- 이상현상 발생 X : SERIALIZABLE
- 이상현상 발생 O : DB 별 상이
2. 격리수준
DB 별 기본 값은 아래와 같습니다.
- MySQL/InnoDB: 기본 REPEATABLE_READ
- PostgreSQL: 기본 READ_COMMITTED
- Oracle: 기본 READ_COMMITTED
2-1. READ_UNCOMMITTED
- 특징: 커밋 전 데이터까지 읽을 수 있음
- 장점: 이론상 가장 빠름
- 단점: 일관성 붕괴 위험이 큼 → 실무에서 거의 금지
- 발생 가능한 이상현상
- Dirty Read
- Non-Repeatable Read
- Phantom Read
- 사용: 사실상 사용 지양
2-2. READ_COMMITTED
- 특징: 커밋된 데이터만 읽음(더티 리드 금지). 두 번 읽으면 값이 달라질 수 있음
- 장점: 대부분의 OLTP 기본값(PG/Oracle/SQL Server 기본). 잠금 경합 비교적 낮음
- 단점: Non-Repeatable/Phantom 가능
- 발생 가능한 이상현상
- Non-Repeatable Read
- Phantom Read
- 사용: 일반 조회/업무 로직의 기본값으로 적합
2-3. REPEATABLE_READ
- 특징: 같은 로우를 동일 트랜잭션에서 반복 조회 시 값이 변하지 않도록 보장
- 장점: 비반복 읽기 방지 → 조회 일관성 ↑
- 단점: 범위잠금/갭락으로 동시성 저하/데드락 증가 가능
- 발생 가능한 이상현상
- DB 별 상이
- 사용: 참조 일관성이 중요한 조회 + 업데이트 혼재 상황. MySQL 기본
1) REPEATABLE_READ와 READ_COMMITTED 차이
- T1 시작 → SELECT id=1 ⇒ 이름 “Alice” 조회
→ 이때 스냅샷 고정 - T2 시작 → id=1을 “Bob”으로 UPDATE 후 커밋
- T1에서 다시 SELECT id=1
- REPEATABLE_READ: 여전히 “Alice” (스냅샷 유지)
- READ_COMMITTED: 이번엔 “Bob” (문장마다 최신 커밋본)
2-4. SERIALIZABLE
- 특징: 직렬 실행과 같은 효과(가장 강함)
- 장점: Dirty/Non-Repeatable/Phantom 모두 차단
- 단점: 처리량 급감, 충돌 시 재시도 증가, 동시성이 크게 떨어지고 락 경합/대기 시간이 증가
- 발생 가능한 이상현상 없음
- 사용: 금융 등 강 일관성이 최우선인 극히 일부 케이스에 짧은 구간만
3. Default 격리수준 변경 케이스
일반적으로 격리수준은 Default로 사용하는 것이 좋습니다. 하지만 실 서비스를 개발하다 보면 Default 격리수준을 사용하다보면 여러 이슈가 발생하게 되는데 이때 격리수준을 변경하여 해당 이슈들을 해결해야 합니다. 단, 격리수준을 변경하기 전에 변경하지 않고도 해결할 수 있다면 변경하지 않고 해결하는 것이 좋습니다.
- 증상 파악
- 데드락 증가(1213/40001), 대기/타임아웃, purge 지연, “신선도” 민원 등
- 국소적 적용
- 전역 기본값은 유지하고, 문제 메서드/흐름만 바꾼다.
- @Transactional(isolation = …) 한 트랜잭션 범위에만 적용
- 보조 전략 병행 - 우선 적용
- 유니크 인덱스/정확한 인덱싱/쿼리 범위 축소
- FOR UPDATE SKIP LOCKED(MySQL 8+)로 작업 픽업
- 읽기 API는 readOnly = true로 JPA 오버헤드↓ → QueryDSL 성능↑
- 재시도 정책(낙관적 락 충돌/데드락) 표준화
아래 케이스들은 MySQL 기반으로 작성했습니다. 타 DB에서 동일한 케이스로 적용가능한지 여부는 확인해보지 않았으니 참고 부탁드리며 다양한 환경들이 존재하기 때문에 아래 정리한 케이스들이 '무조건 맞다'라고 할 수 없습니다. 따라서 사용 시 본인의 판단하에 적절한 방법을 선택하시길 바랍니다.
3-1. READ_COMMITTED 선택 케이스
1) Gap Lock / Next Key Lock으로 인한 데드락∙대기가 잦을 때
- 징후: 범위 UPDATE/DELETE나 SELECT ... FOR UPDATE 중 교차 범위에서 데드락이 자주 발생.
- 이유: REPEATABLE_READ는 팬텀 방지를 위해 갭락/넥스트키락을 많이 잡습니다.
- 대응: 해당 트랜잭션만 READ_COMMITTED로 낮추면 InnoDB가 레코드락 위주로 잠가 충돌을 줄일 수 있습니다
2) 대용량 읽기/리포팅/배치에서 스냅샷 유지 비용을 줄이고 싶을 때
- 징후: 긴 트랜잭션이 undo 기록을 오래 붙잡아 purge 지연/히스토리 폭증.
- 이유: REPEATABLE_READ는 트랜잭션 동안 동일 스냅샷을 유지합니다.
- 대응: 읽기 전용 흐름에 한해 READ_COMMITTED(문장 단위 스냅샷)로 전환하고, 트랜잭션을 짧게 유지.
💡 긴 트랜잭션이 undo 기록을 오래 붙잡아 purge 지연 /히스토리 폭증 설명
트랜잭션이 오래 열려 있으면 MySQL(InnoDB)이 예전에 찍어둔 “과거 스냅샷”을 보여주기 위해 과거 버전(undo 로그)을 계속 보관해야 하고, 그래서 청소(purge)를 못 해서 쓰레기(히스토리)가 쌓입니다.
→ 디스크/메모리/성능에 악영향.
REPEATABLE_READ는 트랜잭션 전체에 동일 스냅샷을 유지하기 때문에 오래 열려 있으면 purge가 지연됩니다.
3) CQRS/리드 레플리카에서 읽기 신선도가 더 중요한 경우
- 징후: “방금 쓴 데이터가 왜 안 보이죠?” 같은 사용자 피드백.
- 이유: REPEATABLE_READ에선 한 트랜잭션 안에서 반복 조회 시 같은 결과를 보장(=신선도↓).
- 대응: UI용 조회 API만 READ_COMMITTED로 하여 신선도를 높임(여전히 커밋된 데이터만 읽음)
3-2. SERIALIZABLE 선택 케이스
1) 스냅샷 기반의 Write Skew를 확실히 막아야 할 때
- 징후: “조건을 만족하는 행이 없을 때만 삽입/갱신”하는 규칙이 동시성에서 깨짐.
- 예: 동일 자원 동시간대 이중 예약 금지, “활성 세션은 최대 N개” 등 범위 불변식.
- 대응 우선순위
- 유니크 인덱스로 스키마에서 강제 (가능하면 최우선)
- 잠금 읽기(FOR UPDATE / PESSIMISTIC_WRITE)로 “없음” 판단 구간 잠금
- 그래도 불안하면 아주 짧은 구간만 SERIALIZABLE
- 경전역/광범위하게 SERIALIZABLE을 쓰면 처리량 급감 + 재시도 폭증하므로 주의.
💡 Write Skew란?
두 트랜잭션(T1, T2)이 각자 “조건을 만족하는 행이 없다”고 판단해서 서로 다른 행을 추가/수정하고 둘 다 커밋 해버려, 전역 불변식(“동시에 한 명만 가능”, “중복 예약 금지” 등)이 깨지는 현상
3-3. READ_UNCOMMITTED을 선택 케이스
- 특성: 더티 리드 허용(커밋 전 데이터까지 읽음) → 일관성 붕괴 위험 큼
- 용도: 앱 코드에서는 거의 금지. 모니터링/샘플링 같은 허용 오차가 매우 큰 오프라인 분석에나 한시적 사용.
'Backend > Spring' 카테고리의 다른 글
[String] Transaction - rollback 속성 (0) | 2025.08.31 |
---|---|
[Spring] Transaction - @TransactionalEventListener를 이용한 이벤트 처리 (3) | 2025.08.17 |
[Spring] Transaction - 트랜잭션 전파(Transaction Propagation) (3) | 2025.08.17 |
[TDD] Test Double (0) | 2025.08.03 |
[Spring] @Value와 @ConfigurationProperties (0) | 2023.07.09 |