| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 트랜잭션
- go
- 코틀린
- MySQL
- Spring Boot
- Spring
- 스프링
- 도커
- 데드락
- Java
- TCP
- 회고
- hikaricp
- Kotlin
- 자바
- thread
- Security
- OOP
- Python
- JVM
- 2026-04
- 객체지향
- 프록시
- netty
- Rust
- react
- 상속
- Wil
- springboot
- Til
- Today
- Total
hyuko
[TIL] 격리수준은 락으로 구현된다 본문
생각 정리용 글. 지난주 시리즈에서 @Transactional 의 propagation(트랜잭션이 겹칠 때 어떻게 묶이나)까지 봤다. 그 다음 자연스러운 질문 — "그래서 그 트랜잭션이 다른 트랜잭션과 동시에 돌 때, 서로 뭘 보고 뭘 못 보나". 이게 격리수준(isolation level)이고, 결국 락(lock)으로 구현된다. 이번엔 MySQL InnoDB 기준으로 격리수준 4단계와 그걸 떠받치는 락 종류를 공식 문서로 정리.
먼저 지난주와 이어지는 한 줄 — Spring 의 @Transactional(isolation = ...) 은 새 트랜잭션이 시작될 때만(propagation REQUIRED/REQUIRES_NEW) 적용된다. 기존 트랜잭션에 참여하면 무시되고 그쪽 격리수준을 따른다. 그러니 propagation 을 먼저 이해해야 isolation 이 정확히 보인다.
1. 격리수준 4단계와 3가지 이상현상
격리수준은 "동시 트랜잭션 사이에서 어떤 이상현상(anomaly)을 허용할 거냐" 의 다이얼이다. 막아야 할 3가지:
- Dirty Read — 아직 커밋 안 된 남의 변경을 읽음
- Non-Repeatable Read — 같은 행을 두 번 읽었는데 값이 다름(중간에 누가 커밋)
- Phantom Read — 같은 범위를 두 번 조회했는데 행 개수가 다름(중간에 누가 insert)
격리수준 Dirty Non-Repeatable Phantom
| READ UNCOMMITTED | 허용 | 허용 | 허용 |
| READ COMMITTED | 막음 | 허용 | 허용 |
| REPEATABLE READ (InnoDB 기본) | 막음 | 막음 | 막음* |
| SERIALIZABLE | 막음 | 막음 | 막음 |
★ 표준 SQL 의 REPEATABLE READ 는 phantom 을 허용하지만, InnoDB 의 RR 은 next-key lock 덕분에 phantom 까지 막는다. 교과서와 다른 InnoDB 만의 특징. (뒤에서 자세히)
공식 문서가 못 박는 두 가지:
"The default isolation level for InnoDB is REPEATABLE READ."
SERIALIZABLE: "this level is like REPEATABLE READ, but InnoDB implicitly converts all plain SELECT statements to SELECT ... FOR SHARE if autocommit is disabled."
여기서 주의 — MySQL(InnoDB) 의 기본은 REPEATABLE READ 인데, PostgreSQL · Oracle · SQL Server 는 기본이 READ COMMITTED. DB 갈아탈 때 격리수준 기본값이 다르다는 걸 모르면 동작이 미묘하게 바뀐다.
2. MVCC — 락 없이 읽는 쪽 (consistent nonlocking read)
격리수준이 "락" 만으로 되는 건 아니다. 일반 SELECT(non-locking read)는 락을 안 걸고 스냅샷을 읽는다(MVCC). RR 과 RC 의 진짜 차이가 여기 있다 — 스냅샷을 언제 찍느냐.
공식 문서:
REPEATABLE READ: "Consistent reads within the same transaction read the snapshot established by the first read."
READ COMMITTED: "Each consistent read, even within the same transaction, sets and reads its own fresh snapshot."
REPEATABLE READ READ COMMITTED
─────────────── ──────────────
첫 SELECT 시점 스냅샷 고정 매 SELECT 마다 새 스냅샷
→ 트랜잭션 내내 같은 값 → 다른 트랜잭션 커밋이 즉시 보임
→ non-repeatable read 차단 → non-repeatable / phantom 허용
그래서 RR 에서는 일반 SELECT 를 아무리 반복해도 트랜잭션 시작 시점의 세상을 본다. non-locking read 는 락 경합이 아예 없다 — 이게 첫 글에서 본 "동시성" 의 핵심 한 축.
3. InnoDB 락 종류 — 쓰는 쪽 (locking read / UPDATE / DELETE)
SELECT ... FOR UPDATE/FOR SHARE, UPDATE, DELETE 는 락을 건다. InnoDB 락을 작은 것부터 쌓아 본다.
S / X 락 (행 단위)
- Shared (S): "permits the transaction that holds the lock to read a row."
- Exclusive (X): "permits the transaction that holds the lock to update or delete a row."
- 호환성: S끼리는 공존, X가 끼면 누구와도 충돌(대기).
Intention 락 (테이블 단위 IS/IX)
행 락을 걸기 전에 테이블에 먼저 거는 "예고" 락. "이 테이블의 어떤 행에 S(또는 X) 락 걸 거야" 를 테이블 레벨에 표시해서, 테이블 전체 락과 빠르게 충돌 판정하기 위함.
"Before a transaction can acquire an exclusive lock on a row, it must first acquire an IX lock on the table."
Record / Gap / Next-Key — 세트로 이해
락 정의 막는 것
| Record Lock | "a lock on an index record" | 그 인덱스 레코드의 변경/삭제 |
| Gap Lock | "a lock on a gap between index records" | 그 틈으로의 insert |
| Next-Key Lock | "record lock + gap lock on the gap before the index record" | 레코드 + 그 앞 틈 |
핵심 포인트 둘:
- Record lock 은 항상 인덱스 레코드를 잠근다. 인덱스 없는 테이블이어도 InnoDB 내부 클러스터 인덱스를 잠금. → 그래서 WHERE 가 인덱스를 못 타면 락 범위가 폭발한다.
- Gap lock 은 "purely inhibitive" — 오직 insert 를 막을 뿐. 그래서 S/X 구분이 없고, gap lock 끼리는 서로 충돌 안 한다(공존).
Insert Intention Lock — INSERT 가 행 삽입 직전에 거는 특수 gap lock. 같은 gap 이라도 서로 다른 위치에 insert 하면 대기하지 않게 해준다.
4. next-key lock 이 phantom 을 막는 법 (RR 의 핵심)
RR 이 phantom 까지 막는 비결이 next-key lock 이다. 인덱스에 값 10, 11, 13, 20 이 있을 때, InnoDB 가 거는 next-key lock 구간:
(-∞, 10] (10, 11] (11, 13] (13, 20] (20, +∞)
각 구간은 레코드 + 그 앞 틈 을 함께 잠근다. 그래서 범위 조회에 락을 걸면 그 범위의 틈까지 다 막혀서 남이 insert 를 못 한다 → 다시 조회해도 행이 안 늘어남 → phantom 차단.
공식 문서: "By default, InnoDB operates in REPEATABLE READ (…) InnoDB uses next-key locks for searches and index scans, which prevents phantom rows."
phantom 정의를 다시 보면: 한 트랜잭션이 범위를 읽는 사이 다른 트랜잭션이 그 범위에 행을 insert → 재조회 시 "유령 행" 이 나타남. next-key lock 이 그 틈을 막아 insert 자체를 못 하게 한다.
-- RR 에서
START TRANSACTION;
SELECT * FROM t WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- → (10,20] 구간 gap 까지 잠김
-- 다른 세션: INSERT INTO t VALUES(15, ...) → 대기 (phantom 방지)
5. RR vs RC — 락 동작이 이렇게 다르다
같은 UPDATE 한 줄도 격리수준에 따라 락 동작이 다르다. 공식 문서 예시가 명확하다.
-- t: (1,2),(2,3),(3,2),(4,3),(5,2), b 에 인덱스 없음
UPDATE t SET b = 5 WHERE b = 3;
REPEATABLE READ — 스캔한 모든 행에 락을 걸고 안 푼다:
x-lock(1,2); x-lock(2,3) update; x-lock(3,2); x-lock(4,3) update; x-lock(5,2)
→ 매칭 안 된 (1,2),(3,2),(5,2) 까지 락 유지 + gap lock
READ COMMITTED — WHERE 에 안 맞는 행은 락을 즉시 푼다(semi-consistent read):
x-lock(1,2) → unlock(1,2) ← 안 맞으니 즉시 해제
x-lock(2,3) update; retain
x-lock(3,2) → unlock(3,2)
x-lock(4,3) update; retain
x-lock(5,2) → unlock(5,2)
→ 매칭된 (2,3),(4,3) 만 락 유지, gap lock 없음
공식 문서 (RC):
"InnoDB locks only index records, not the gaps before them, and thus permits the free insertion of new records (…). Gap locking is only used for foreign-key constraint checking and duplicate-key checking."
→ 실무 영향 정리:
REPEATABLE READ READ COMMITTED
| gap lock | 사용 | 거의 없음(FK/중복키만) |
| phantom | 막음 | 허용 |
| 락 범위 | 넓음(스캔 구간 전체) | 좁음(매칭 행만) |
| 동시성 | 낮음 | 높음 |
| 데드락 빈도 | 상대적 높음 | 상대적 낮음 |
그래서 쓰기 동시성이 중요한 서비스는 RC 로 내려서 운영하는 곳이 많다. gap lock 이 사라지니 insert 충돌·데드락이 줄어든다. 대신 phantom 을 애플리케이션이 감수해야 한다. (단 RC 는 row-based binlog 만 지원 — replication 설정 확인 필요.)
6. Spring @Transactional 과의 연결
@Transactional(isolation = Isolation.READ_COMMITTED)
public void updateBalance(...) { ... }
Isolation enum: DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE.
- Isolation.DEFAULT — 드라이버/DB 의 기본값을 그대로 사용. MySQL InnoDB 면 REPEATABLE READ.
- 지난주 propagation 과 직접 이어지는 함정 (공식 문서):
"Optional isolation level. Applies only to propagation values of REQUIRED or REQUIRES_NEW."
→ 즉 isolation 은 새 물리 트랜잭션이 시작될 때만 먹는다. 이미 진행 중인 바깥 트랜잭션에 REQUIRED 로 참여하는 안쪽 메서드에 isolation 을 다르게 줘도 무시되고 바깥 격리수준을 따른다. (지난주 "REQUIRED 는 물리 트랜잭션 1개 공유" 의 직접적 귀결.)
안쪽에서 정말 다른 격리수준이 필요하면 REQUIRES_NEW 로 새 트랜잭션을 떠야 한다 — 그럼 지난주의 "connection 2개 점유 → 풀 사이즈 주의" 가 또 따라온다. 결국 propagation · isolation · 풀 크기가 한 묶음.
7. 한 줄 정리 (면접 답변용)
격리수준의 본질
격리수준은 dirty read / non-repeatable read / phantom 세 이상현상 중 무엇을 허용할지 정하는 다이얼이고, InnoDB 에선 MVCC 스냅샷(읽기) 과 record/gap/next-key lock(쓰기) 의 조합으로 구현됩니다. InnoDB 기본은 REPEATABLE READ 입니다.
next-key lock 과 phantom
next-key lock 은 record lock 에 그 앞 gap lock 을 더한 것으로, 인덱스 구간의 틈까지 잠가 다른 트랜잭션의 insert 를 막습니다. 그래서 InnoDB 의 REPEATABLE READ 는 표준 SQL 과 달리 phantom 까지 방지합니다.
RR vs RC 실무
READ COMMITTED 는 gap lock 을 거의 안 쓰고 WHERE 에 안 맞는 행 락을 즉시 풀어 동시성이 높고 데드락이 적습니다. 쓰기 경합이 큰 서비스에서 RC 로 내려 운영하는 이유입니다. 대신 phantom 을 감수합니다.
Spring 연동 함정
@Transactional(isolation=...) 은 propagation 이 REQUIRED/REQUIRES_NEW 로 새 트랜잭션을 시작할 때만 적용되고, 기존 트랜잭션에 참여하면 무시됩니다.
8. 마무리
지난주가 "트랜잭션이 connection 을 어떻게 잡고, 어떻게 겹치나(propagation)" 였다면, 이번 글은 "그 트랜잭션들이 동시에 돌 때 서로 뭘 보나(isolation), 그리고 그게 락으로 어떻게 구현되나" 였다.
가장 크게 남은 한 줄 — 격리수준은 추상적 개념이 아니라 record/gap/next-key lock 이라는 구체적 메커니즘으로 구현된다. RR 이 phantom 을 막는다는 말이 막연했는데, next-key lock 이 인덱스 구간의 틈을 잠근다는 그림을 보고 나니 "왜" 가 풀렸다. 그리고 UPDATE WHERE b=3 한 줄이 격리수준에 따라 락을 다 잡았다 풀었다 다르게 행동한다는 게 인상적이었다 — 같은 SQL 도 격리수준이 다른 코드인 셈.
다음에 이어서 볼 것:
- 데드락 실제 재현 + SHOW ENGINE INNODB STATUS 로 락 그래프 읽기
- gap lock 때문에 나는 데드락 케이스 (서로 다른 순서로 범위 락)
- 인덱스 없는 컬럼 조건 UPDATE 가 테이블 전체를 잠그는 걸 실측
참고
- MySQL 공식 - Transaction Isolation Levels
- MySQL 공식 - InnoDB Locking
- MySQL 공식 - Phantom Rows
- Spring 공식 - @Transactional Settings (isolation)
'TIL (Today I Learned)' 카테고리의 다른 글
| [TIL] 락이 막혔을 때 — 두 가지 죽음과 재시도 (0) | 2026.05.28 |
|---|---|
| [TIL] InnoDB 데드락 실전 (0) | 2026.05.27 |
| [TIL] Transaction - propagation - self invocation - maxlifetime (0) | 2026.05.21 |
| [TIL] Virtual Thread 를 켜면 Tomcat · HikariCP · @Transactional 은 어떻게 바뀌는가 (0) | 2026.05.20 |
| [TIL] Connection 은 어떻게 트랜잭션에 묶이는가 (0) | 2026.05.19 |