개발 공부/DB

왜 서로 다른 값을 INSERT 했는데 데드락이 걸릴까?

gmelon 2026. 2. 2. 00:42

최근 서로 다른 트랜잭션에서 겹치지 않는 FK 값을 가진 엔티티들을 동시에 삽입할 때 데드락이 발생한 케이스가 있었다. 분명 범위가 겹치지 않는데 왜 데드락이 걸리는 걸까?


1. 결론: 유니크/외래 키 제약조건이 데드락을 유발한다

핵심 원인

외래 키(Foreign Key)이거나 유니크 제약 조건(Unique Constraint)이 걸려 있다면, 데드락 발생 확률이 비약적으로 높아진다.

데드락 발생 메커니즘

상황: FK 에는 유니크 제약 조건이 걸려있고 기존 데이터는 10, 20 이 존재한다고 가정. 격리 수준은 REPEATABLE READ.

  1. 트랜잭션 A (INSERT 12): 유니크 체크를 위해 10~20 사이에 공유 잠금(S-Lock, 갭 락)을 획득
  2. 트랜잭션 B (INSERT 14): 마찬가지로 10~20 사이에 공유 잠금(S-Lock)을 요청. S-Lock끼리는 호환되므로 B도 락을 획득
  3. 트랜잭션 A (실제 쓰기 시도): S-Lock을 X-Lock으로 업그레이드 필요. 하지만 B가 S-Lock을 잡고 있어 대기
  4. 트랜잭션 B (실제 쓰기 시도): 마찬가지로 A가 S-Lock을 잡고 있어 대기

결과: A는 B를 기다리고, B는 A를 기다리는 전형적인 데드락(Deadlock)에 빠지게 된다.

왜 "겹치지 않는 값"인데도 문제가 되는가?

여기서 핵심은 갭 락의 범위다. 트랜잭션 A가 12를, 트랜잭션 B가 14를 삽입하려 해도, 둘 다 10~20이라는 동일한 갭 구간에 대해 공유 잠금을 걸게 된다. 값 자체는 다르지만, 잠금 범위가 겹치기 때문에 데드락이 발생하는 것이다.

격리 수준에 따른 차이

격리 수준 갭 락(Gap Lock) 사용 10~20 사이 INSERT 시 동작 (유니크/FK 존재 시) 결과
REPEATABLE READ 사용함 (기본) A와 B가 10~20 구간 전체를 공유 잠금(S-Lock)으로 잡음 데드락 (Deadlock)
READ COMMITTED 사용 안 함 A는 12, B는 14 위치만 각각 점유 성공 (Success)

2. 배경 지식: 왜 이런 일이 일어나는가?

위 데드락 메커니즘을 제대로 이해하려면 InnoDB의 잠금 방식에 대한 배경 지식이 필요하다.

갭 락(Gap Lock)이란?

갭 락(Gap Lock)은 MySQL InnoDB 스토리지 엔진에서 사용되는 독특한 잠금 방식으로, 레코드 자체가 아니라 레코드와 레코드 사이의 '간격(Gap)'을 잠그는 기능이다. 일반적인 '레코드 락'이 실제 존재하는 데이터 줄(Row)을 잠그는 것이라면, 갭 락은 데이터가 존재하지 않는 '비어 있는 공간'을 잠그는 것이다.

항목 설명
잠금 대상 레코드와 바로 인접한 레코드 사이의 간격
핵심 목적 새로운 레코드가 생성(INSERT)되는 것을 제어

왜 갭 락이 필요한가? (팬텀 리드 방지)

갭 락의 주된 목적은 '팬텀 리드(Phantom Read)' 현상을 막기 위함이다.

팬텀 리드: 트랜잭션 내에서 동일한 쿼리를 두 번 실행했을 때, 첫 번째 쿼리에는 없던 레코드가 다른 트랜잭션의 INSERT 작업으로 인해 두 번째 쿼리에서 갑자기 나타나는(유령처럼 보이는) 현상이다. 갭 락은 이 '사이 공간'에 새로운 데이터가 끼어드는 것을 막아 데이터의 일관성을 보장한다.

 

즉, 갭 락은 트랜잭션 도중에 예상치 못한 새로운 데이터가 추가되는 것을 방지하는 안전장치다.

격리 수준에 따른 갭 락 동작 차이

트랜잭션의 격리 수준(Isolation Level)은 데이터베이스가 잠금을 처리하는 방식, 특히 갭 락(Gap Lock)의 사용 여부를 결정짓는 핵심 요소다.

REPEATABLE READ (InnoDB 기본값)

MySQL InnoDB의 기본 격리 수준인 REPEATABLE READ에서는 데이터 정합성을 위해 넥스트 키 락(Next-Key Lock, 레코드 락 + 갭 락)을 적극적으로 사용한다.

REPEATABLE READ 동작 방식

  1. 유니크/FK 체크: 트랜잭션 A가 12를 넣기 위해, 트랜잭션 B가 14를 넣기 위해 중복 여부나 부모 키 존재 여부를 확인한다.
  2. 갭 락 형성: 이때 InnoDB는 단순히 12, 14라는 값만 확인하는 것이 아니라, 해당 값이 포함된 범위인 10과 20 사이의 간격(Gap) 전체에 대해 공유 잠금(S-Lock)을 건다. 이를 통해 다른 트랜잭션이 이 범위에 데이터를 넣어 '팬텀 리드(Phantom Read)'가 발생하는 것을 방지한다.
  3. 데드락 발생: A와 B 모두 10~20 사이의 갭 락(S-Lock)을 획득한 상태에서, 각자 자신의 데이터를 INSERT 하려고 시도한다. INSERT를 하려면 배타적 잠금(X-Lock)이 필요한데, 상대방이 이미 S-Lock을 걸고 놓아주지 않으므로 서로 대기하다가 데드락에 빠진다.

결과: 데드락 발생 가능성이 매우 높다. 유니크/FK 체크가 "이 범위는 내가 지킨다"라는 갭 락을 유발하기 때문이다.

READ COMMITTED - 데드락 회피 가능

READ COMMITTED 격리 수준에서는 데이터베이스의 동작 방식이 크게 달라진다. 가장 큰 차이점은 "갭 락(Gap Lock)을 기본적으로 비활성화한다"는 것이다.

READ COMMITTED 동작 방식

  1. 갭 락 미사용: 이 격리 수준에서는 넥스트 키 락을 사용하지 않고, 오직 레코드 락(Record Lock) 위주로 동작한다. 갭 락은 외래 키 제약 조건 확인이나 중복 키 확인과 같은 아주 특수한 경우를 제외하고는 사용되지 않는다.
  2. 개별 잠금: 트랜잭션 A는 12를 삽입하기 위해 12라는 값에 대해서만 유니크 체크를 수행하고 잠금을 시도한다(인덱스 레코드 간의 '사이'를 잠그지 않음). 트랜잭션 B는 14에 대해서만 작업을 수행한다.
  3. 충돌 없음: A와 B가 작업하려는 공간이 물리적으로 다르므로(12 vs 14), 10과 20 사이라는 '범위' 때문에 서로 부딪힐 일이 없다.

결과: 데드락이 발생하지 않고 A, B 모두 성공한다. 범위 잠금(갭 락)이 사라졌기 때문이다.

READ COMMITTED에서는 유니크 제약조건을 어떻게 보장하나?

READ COMMITTED 에서 갭락을 집지 않으면, 유니크 제약조건은 어떻게 보장하는걸까?

Real MySQL 8.0에 따르면, "READ COMMITTED 격리 수준에서는 갭 락을 사용하지 않지만, 외래 키 제약 조건의 확인이나 중복 키 확인(Unique Key Check)을 위해서는 갭 락을 사용한다"고 명시되어 있다.

 

즉, REPEATABLE READ(이하 RR)와는 락을 거는 '목적'과 '범위'가 다르다.

  1. RR의 갭 락: 팬텀 리드(Phantom Read) 방지가 주목적. 따라서 "이 범위(10~20) 사이에 아무것도 넣지 마"라고 공간 전체를 강력하게 잠금.
  2. RC의 갭 락: 제약 조건 확인(Constraint Check)이 주목적. "내가 넣으려는 이 값(12)이 중복되는지, 혹은 부모 키가 있는지만 확인하겠다"는 의도로 동작.

이 차이는 실제 데드락 발생 여부를 결정짓는 핵심이 된다.

  1. REPEATABLE READ (데드락 발생)
    1. A가 12를 넣기 위해 10~20 사이의 모든 갭을 잠금
    2. B가 14를 넣기 위해 10~20 사이의 모든 갭을 잠금
    3. 서로가 같은 Gap을 잠그고 비켜주지 않으므로 충돌
  2. READ COMMITTED (성공)
    1. RC에서는 갭 락을 사용하더라도, 그 범위가 삽입하려는 특정 인덱스 레코드나 그 인접한 위치의 검증으로 제한됨
    2. 인덱스 유니크 스캔: 데이터를 넣기 전, 해당 값이 인덱스에 존재하는지 확인
    3. 최소한의 잠금: 값이 없다면 '해당 키 값(예: 12)'에 대해서만 다른 트랜잭션이 끼어들지 못하게 제어. RR처럼 광범위한 범위에 락을 걸지 않음
    4. 결과적으로 A는 12, B는 14라는 서로 다른 물리적 대상을 다루게 되어 충돌 없이 동시에 삽입에 성공함.

정리하면 READ COMMITTED에서도 유니크/FK 확인을 위해 내부적으로 특수한 형태의 락(갭 락의 일종)이 사용되는 것은 맞지만, 그 범위가 입력하려는 특정 값에 집중되므로, RR에서 발생하는 '불필요한 범위 잠금으로 인한 데드락'을 피할 수 있다.

넥스트 키 락(Next-Key Lock)이란?

넥스트 키 락(Next-Key Lock)은 레코드 락과 갭 락을 합쳐 놓은 형태의 잠금이다.

넥스트 키 락 = 레코드 락(Record Lock) + 갭 락(Gap Lock)

 

InnoDB에서는 갭 락을 단독으로 사용하는 경우도 있지만, 대부분 넥스트 키 락 형태로 사용한다. InnoDB는 레코드를 검색하거나 스캔할 때, 해당 레코드만 잠그는 것이 아니라 그 레코드 앞의 간격(Gap)까지 함께 잠가서 다른 트랜잭션이 그 사이에 데이터를 넣지 못하도록 한다.

특징 갭 락 (Gap Lock) 넥스트 키 락 (Next-Key Lock)
잠금 대상 레코드와 레코드 사이의 비어 있는 공간(Gap) 레코드 자체 + 그 레코드의 바로 앞 간격(Gap)
레코드 포함 여부 ❌ (실제 데이터는 잠그지 않음) ✅ (실제 데이터도 함께 잠금)
주요 목적 새로운 데이터의 생성(INSERT) 방지 레코드 변경 방지 + 새로운 데이터 생성 방지
사용 시점 주로 넥스트 키 락의 일부로 사용됨 InnoDB의 일반적인 범위 검색 시 기본 잠금 방식

 

데이터베이스에 10번30번 사원 데이터만 있다고 가정해 보자. (20번은 없음)

락 종류 설명
레코드 락 (10번) 10번 사원 데이터만 수정/삭제 불가능
갭 락 (10~30 사이) 10과 30 사이의 공간만 잠금. 15번이나 20번 신규 사원 등록(INSERT) 불가능. 하지만 이미 존재하는 10, 30번 데이터 수정은 가능
넥스트 키 락 (30번 기준) 30번 사원 데이터 수정 불가능(레코드 락) + 10과 30 사이 공간에 신규 등록 불가능(갭 락)

 

10과 30 사이에 갭 락만 단독으로 걸렸다면:

  • 가능: 10번이나 30번 레코드의 내용을 수정(UPDATE)하거나 삭제(DELETE)하는 것
  • 불가능: 10과 30 사이(예: 15, 20 등)에 새로운 데이터를 입력(INSERT)하는 것

 

이론적으로 갭 락은 레코드를 잠그지 않지만, 실제 MySQL InnoDB 환경에서는 넥스트 키 락이 기본으로 작동한다.

InnoDB에서 범위 검색(예: SELECT ... WHERE id BETWEEN 10 AND 30 FOR UPDATE)을 수행하면:

  • 검색된 레코드(10, 30)에는 레코드 락이 걸리고
  • 그 사이에는 갭 락이 함께 걸린다
  • 결과적으로 10, 30 데이터도 수정하지 못하고, 그 사이에 데이터도 넣지 못하는 상태가 된다

인서트 인텐션 락(Insert Intention Lock)

INSERT 작업 시 갭 락과 어떻게 상호작용하는지 이해하면, 데드락 발생 원리가 더 명확해진다.

갭 락(Gap Lock)인서트 인텐션 락(Insert Intention Lock)은 서로 "충돌(Conflict)하여 대기를 유발하는 관계"다.

락 종류 역할 (비유) 관계 설명
갭 락 (Gap Lock) "통행 금지" 표지판 특정 범위에 새로운 데이터가 들어오지 못하게 막음
인서트 인텐션 락 "진입 신호" INSERT를 하기 위해 해당 갭에 진입하겠다는 의사 표시
결과 대기 (Waiting) 갭 락이 해제될 때까지 인서트 인텐션 락은 대기 상태에 빠짐

호환성 비교

락 조합 결과
갭 락 vs 갭 락 호환됨. 여러 트랜잭션이 동시에 같은 구간에 갭 락을 걸 수 있음(공유 가능)
갭 락 vs 인서트 인텐션 락 충돌함. 누군가 갭 락을 걸고 있다면, 그 갭이 풀리기 전까지는 새로운 데이터를 넣을 수 없음
인서트 인텐션 락 vs 인서트 인텐션 락 호환됨. 같은 갭이라도 삽입 위치가 다르면 서로 기다리지 않고 동시에 락을 획득 가능

 

이 호환성 표가 데드락의 핵심이다. 갭 락끼리는 호환되기 때문에 A와 B가 동시에 같은 구간에 S-Lock(갭 락)을 걸 수 있고, 이후 둘 다 X-Lock으로 업그레이드하려 할 때 서로를 기다리게 되는 것이다.

 

INSERT 시 락이 걸리는 과정 정리

  1. 인서트 인텐션 락 획득 시도: 삽입하려는 레코드 사이의 간격에 대해 락을 요청한다
  2. 기존 갭 락 확인: 누군가 그 간격을 막고 있는지(Gap Lock) 확인한다. 막혀 있으면 대기 (Wait), 비어 있으면 통과 (인서트 인텐션 락 획득)
  3. 레코드 락 획득: 실제 데이터가 들어갈 공간에 레코드 락(Record Lock)을 건다
  4. 데이터 입력: 데이터를 쓴다

INSERT를 하기 위해 일반적인 갭 락을 먼저 잡아야 하는 것은 아니다. 오히려 INSERT는 최소한의 잠금(레코드 락)만으로 처리되도록 설계되어 있으며, 인서트 인텐션 락은 다른 트랜잭션끼리 서로 방해하지 않고 동시에 데이터를 넣을 수 있게 해주는 장치다.


3. 케이스별 잠금 범위

여기부터는 그냥 같이 궁금해서 찾아본 내용

 

InnoDB 스토리지 엔진이 데이터를 잠그는 범위는 '인덱스(Index)'의 상태'검색 조건'에 따라 크게 달라진다. InnoDB는 레코드 자체가 아니라 인덱스를 잠그는 방식으로 작동하기 때문이다.

Case A: 유니크 인덱스(PK 등)로 '단건' 조회 시 (Record Lock)

가장 효율적인 케이스다. 프라이머리 키나 유니크 인덱스를 통해 정확히 1개의 레코드만 조회하는 경우다.

항목 내용
잠금 범위 해당 레코드(인덱스)만 딱 잠금
특징 갭 락이 필요 없으므로 레코드 락(Record Lock)만 걸림

Case B: 유니크 인덱스가 아니거나, 범위 검색 시 (Next-Key Lock)

일반적인 인덱스(Non-Unique Index)를 사용하거나 범위 조건(<, >, BETWEEN 등)으로 검색하는 경우다.

항목 내용
잠금 범위 검색된 레코드 + 그 레코드의 바로 앞 간격(Gap)을 모두 잠금
특징 넥스트 키 락이라고 함. 다른 트랜잭션이 검색 범위 사이에 새로운 데이터를 끼워 넣는 것(INSERT)을 막음

Case C: 인덱스가 없는 컬럼으로 업데이트할 때 (Full Table Scan)

가장 위험한 케이스다. UPDATE나 DELETE 조건에 인덱스가 없는 컬럼을 사용하면 심각한 성능 저하가 발생할 수 있다.

항목 내용
잠금 범위 테이블의 모든 레코드를 잠금
특징 InnoDB는 인덱스를 통해 대상을 찾는데, 사용할 인덱스가 없으면 테이블을 풀 스캔하면서 모든 레코드에 잠금을 건다. 예를 들어, 테이블에 30만 건의 데이터가 있다면 30만 건 전체가 잠김

요약

케이스 잠금 범위
단건 조회(Unique Index) 해당 레코드 1개만 잠금 (Gap Lock 없음)
범위/일반 조회 레코드 + 앞쪽 간격(Gap)까지 잠금 (Next-Key Lock)
인덱스 미사용 테이블의 모든 레코드를 잠금

4. MySQL은 인덱스를 기반으로 락을 건다

핵심 의미

'MySQL은 인덱스를 기반으로 락을 건다'는 말의 핵심은 "데이터베이스가 실제 변경하려는 레코드뿐만 아니라, 그 레코드를 찾기 위해 검색한 인덱스의 범위까지 모두 잠근다"는 뜻이다.

 

InnoDB 스토리지 엔진은 레코드 자체(데이터 파일의 행)를 직접 잠그는 것이 아니라, 인덱스의 엔트리(Entry)를 잠그는 방식으로 구현되어 있다. 이로 인해 인덱스 설계에 따라 잠금 범위가 예상보다 훨씬 커질 수 있다.

예제

출처: Real MySQL 8.0

 

다음과 같은 사원 정보 테이블(employees)이 있고, first_name 컬럼에만 인덱스가 걸려 있다고 가정해 보자.

테이블 구조:

  • 테이블: employees
  • 인덱스: ix_first_name (first_name 컬럼만 인덱스 생성됨)
  • 데이터: first_name이 'Georgi'인 사원은 총 253명, 그중 last_name이 'Klassen'인 사원은 1명

실행 쿼리

UPDATE employees SET hire_date=NOW() 
WHERE first_name='Georgi' AND last_name='Klassen';

어떤 데이터가 잠길까?

상식적으로는 조건에 맞는 'Georgi Klassen' 1명만 잠겨야 할 것 같지만, 실제로는 first_name 인덱스를 통해 검색된 'Georgi' 이름을 가진 253명 전원에게 락이 걸린다.

 

이유

  1. UPDATE를 수행하기 위해 InnoDB는 ix_first_name 인덱스를 사용하여 first_name='Georgi'인 레코드를 먼저 찾는다
  2. 이때 검색된 모든 인덱스 레코드에 잠금을 걸기 때문이다
  3. last_name에 대한 조건은 인덱스에 없으므로, 일단 잠금을 건 뒤에 데이터를 읽어서 확인하는 과정(필터링)을 거친다

결과: 이 쿼리가 실행되는 동안, 다른 트랜잭션은 Georgi라는 이름을 가진 다른 사원(예: 'Georgi Kim')의 정보를 수정할 수 없게 된다.

인덱스가 아예 없는 경우 (최악의 시나리오)

만약 테이블에 인덱스가 하나도 없다면 상황은 더 심각해진다.

  • UPDATE 문장이 실행될 때, 조건을 만족하는 레코드를 찾기 위해 테이블의 처음부터 끝까지 모든 레코드를 스캔(Full Table Scan)해야 한다
  • 이 과정에서 테이블에 있는 모든 레코드(예: 30만 건)를 다 잠그게 된다
  • 즉, 단 한 명의 정보를 바꾸기 위해 전체 테이블이 잠기는 결과를 초래하여, 동시성 처리가 사실상 불가능해진다

왜 중요한가?

"인덱스를 기반으로 락을 건다"는 것은 다음을 의미한다.

  1. 잠금 범위는 검색 범위와 같다: 실제 수정되는 데이터가 1건이라도, 인덱스 검색 조건에 걸린 데이터가 1000건이면 1000건 모두 잠긴다
  2. 인덱스 설계가 동시성 성능을 결정한다: 적절한 인덱스가 없으면 불필요하게 많은 데이터가 잠겨서 다른 작업들이 대기해야 하는 상황(성능 저하)이 발생한다

5. 팬텀 리드(Phantom Read)와 격리 수준

갭 락이 존재하는 근본적인 이유를 이해하기 위한 배경 지식이다.

격리 수준별 팬텀 리드 발생 여부

격리 수준 (Isolation Level) 팬텀 리드 발생 여부 특징
READ UNCOMMITTED 발생 커밋되지 않은 데이터도 읽으므로, 다른 트랜잭션이 막 INSERT 한 데이터를 바로 볼 수 있음
READ COMMITTED 발생 다른 트랜잭션이 데이터를 INSERT 하고 COMMIT 하면, 내 트랜잭션이 끝나지 않았어도 두 번째 조회 시 그 데이터를 볼 수 있음
REPEATABLE READ InnoDB는 미발생 일반적인 DBMS 표준에서는 발생할 수 있지만, MySQL InnoDB는 넥스트 키 락을 사용하여 발생하지 않음
SERIALIZABLE 미발생 가장 엄격한 수준으로, 읽기 작업도 잠금을 걸어 다른 트랜잭션의 접근을 차단

넥스트 키 락의 방지 메커니즘 (REPEATABLE READ)

InnoDB는 REPEATABLE READ 격리 수준에서 넥스트 키 락(Next-Key Lock)을 사용하여 팬텀 리드를 방지한다.

핵심 원리

단순히 존재하는 레코드만 잠그는 것이 아니라, 레코드가 들어갈 수 있는 '사이 공간(Gap)'까지 잠가버려서 다른 트랜잭션이 새로운 데이터를 INSERT 하지 못하게 막는다.

실제 사례

employees 테이블에 emp_no(사원번호)가 500,000번인 사원만 있고, 그 이후 번호는 없는 상태라고 가정해 보자.

트랜잭션 A (사용자 A)가 사원 번호 500,000번 이상의 사원을 조회한다:

-- 트랜잭션 A 시작
BEGIN;
SELECT * FROM employees WHERE emp_no >= 500000 FOR UPDATE;
-- 결과: 사원번호 500,000번 사원 1명 조회됨

 

이때, 트랜잭션 B (사용자 B)가 새로운 사원(500,001번)을 등록하려고 시도한다:

-- 트랜잭션 B 시작
INSERT INTO employees (emp_no, name) VALUES (500001, 'Lara');

 

Case 1: READ COMMITTED (넥스트 키 락 미사용)

  1. 트랜잭션 A는 500,000번 레코드에만 레코드 락을 건다 (갭 락 없음)
  2. 트랜잭션 B는 500,001번 데이터 INSERT에 성공하고 COMMIT 한다
  3. 트랜잭션 A가 다시 동일한 SELECT 쿼리를 실행하면, 아까는 없던 500,001번 'Lara'가 조회된다

결과: 팬텀 리드 발생. 데이터 정합성이 깨짐.

 

Case 2: REPEATABLE READ (InnoDB 기본, 넥스트 키 락 사용)

  1. 트랜잭션 A가 조회할 때, InnoDB는 넥스트 키 락을 건다. 잠금 범위: 500,000번 레코드 + 500,000번 이후의 모든 간격(Gap) (무한대까지)
  2. 트랜잭션 B가 INSERT ... VALUES (500001, ...)을 시도한다
  3. 하지만 500,000번 이후의 공간은 트랜잭션 A가 갭 락으로 막아두었으므로, 트랜잭션 B는 대기 상태(Waiting)에 빠진다
  4. 트랜잭션 A가 작업을 마치고 COMMIT 하거나 ROLLBACK 해야만 갭 락이 풀리고, 그때 비로소 트랜잭션 B의 INSERT가 실행된다

결과: 트랜잭션 A가 작업하는 동안에는 새로운 데이터가 끼어들 수 없으므로 팬텀 리드가 발생하지 않음.

 


6. MVCC와 락의 관계

MVCC가 없으면 SELECT도 항상 락을 잡아야 할까?

그렇다. MVCC(Multi-Version Concurrency Control) 기능이 없다면, SELECT 쿼리도 데이터의 일관성을 보장하기 위해 격리 수준에 따라 '잠금(Lock)'을 잡아야 한다.

 

MySQL InnoDB는 MVCC(언두 로그 활용) 덕분에 READ COMMITTED나 REPEATABLE READ에서도 SELECT 작업 시 락을 전혀 걸지 않는다. 누군가 데이터를 수정 중(UPDATE)이라도 대기하지 않고, 언두 로그에 있는 과거 버전의 데이터를 즉시 읽어온다.


마무리

다시 처음 질문으로 돌아가서,

"서로 다른 트랜잭션에서 겹치지 않는 FK 값을 가진 엔티티들을 동시에 삽입할 때 왜 데드락이 발생하는가?"

 

답: 값은 다르지만, 갭 락의 범위가 겹치기 때문이다.

유니크/FK 제약조건이 있으면 INSERT 전에 중복 체크를 위해 해당 구간에 공유 잠금(S-Lock)을 건다. 이 S-Lock은 갭 락 형태로 걸리는데, 갭 락끼리는 호환되므로 여러 트랜잭션이 동시에 같은 구간을 잠글 수 있다. 문제는 실제 쓰기를 위해 X-Lock으로 업그레이드하려 할 때, 상대방의 S-Lock 때문에 서로 대기하게 되면서 데드락이 발생한다.

해결 방안

만약 외래 키나 유니크 인덱스에 의한 데드락이 빈번하게 발생하여 서비스에 지장을 주고 있다면, 격리 수준을 READ COMMITTED로 변경하는 것을 고려해 볼 수 있다.

단, 격리 수준을 낮추면 '팬텀 리드(Phantom Read)'나 '논 리피터블 리드(Non-Repeatable Read)' 현상이 발생할 수 있으므로, 데이터 정합성이 매우 중요한 집계성 쿼리나 금전 처리 로직에서는 주의가 필요하다. 일반적으로 웹 서비스 환경에서는 READ COMMITTED로도 충분한 경우가 많다.

그 외에도 애플리케이션 레벨에서 동시성을 제어하는 방법을 고려해볼 수 있다.