hyuko

[TIL] 격리수준은 락으로 구현된다 본문

TIL (Today I Learned)

[TIL] 격리수준은 락으로 구현된다

hyuko12 2026. 5. 26. 15:09
728x90

생각 정리용 글. 지난주 시리즈에서 @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 가 테이블 전체를 잠그는 걸 실측

참고

728x90