
운영 중인 메시지 컨슈머(Consumer) 애플리케이션에서 간헐적으로 데이터 생성 및 삽입(Insert) 과정 중 데드락(Deadlock)이 발생하는 현상이 모니터링 시스템을 통해 감지되었다. 스프링 프레임워크 기반의 데이터 액세스 레이어에서 예외가 분출되었으며, 구체적으로 데이터베이스 트랜잭션이 롤백되는 장애 코드가 기록되었다.
labels={message=(conn=1297882011) Deadlock found when trying to get lock; try restarting transaction} 27
{exception=org.springframework.dao.DeadlockLoserDataAccessException: PreparedStatementCallback; SQL [INSERT INTO ...
애플리케이션 로그에 명시된 예외 스택 트레이스를 기반으로, 데이터베이스 내부의 데이터 정합성 보장 메커니즘과 트랜잭션 격리 수준 간의 충돌 가능성을 열어두고 구체적인 원인 분석(RCA)을 개시하였다.
정확한 데드락 원인을 규명하기 위해 MySQL 데이터베이스 엔진에 직접 접속하여 InnoDB 스토리지 엔진의 내부 상태를 조회하였다. MySQL은 가장 최근에 발생한 데드락 상태만을 메모리에 유지하므로, 장애 발생 직후 스토리지 엔진 상태 덤프를 확보하는 것이 트러블슈팅의 핵심이다.
SHOW ENGINE INNODB STATUS \G;
조회 결과, 두 개의 서로 다른 데이터베이스 커넥션 스레드가 동일한 인덱스 영역 내에서 트랜잭션을 처리하는 과정이 확인되었다.
*** (1) TRANSACTION:
TRANSACTION 7442724002, ACTIVE 0 sec inserting
MySQL thread id 1297611659, OS thread handle 70369820565392
INSERT INTO ...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1495 page no 35219 n bits 8 index idx_display_... trx id 7442724002 lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION:
TRANSACTION 7442723811, ACTIVE 2 sec inserting
MySQL thread id 1297611659, OS thread handle 70382207684496
INSERT INTO ...
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 1495 page no 35219 n bits 8 index idx_display_... trx id 7442723811 lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 1495 page no 35210 n bits 8 index idx_display_... trx id 7442723811 lock_mode X locks gap before rec insert intention waiting
로그에 기록된 메커니즘을 분석하면, 두 트랜잭션이 특정 인덱스 레코드 사이의 빈 공간에 데이터 삽입을 시도하려 할 때, 내부적으로 삽입 의도 락(Insert Intention Lock)을 획득하기 위해 상호 대기 상태에 빠진 구도가 명확히 드러난다.
이 교착 상태의 근본 원인을 파악하기 위해서는 MySQL InnoDB 스토리지 엔진의 트랜잭션 격리 수준과 락 호환성에 대한 아키텍처적 이해가 필요하다.
자바 애플리케이션 및 일반적인 기본 격리 레벨인 Repeatable-Read에서는 한 트랜잭션 내에서 동일한 쿼리를 두 번 조회했을 때 데이터가 달라지는 팬텀 리드(Phantom Read) 현상을 방지해야 한다. 이를 위해 InnoDB는 실제 테이블에 물리적으로 존재하지 않는 데이터 범위(간격)에 대해 다른 트랜잭션의 진입을 막는 갭 락(Gap Lock)을 할당한다.
이와 대조적으로, 데이터를 삽입하고자 하는 스레드는 해당 영역에 데이터를 넣겠다는 일종의 묵시적 시그널인 삽입 의도 락(Insert Intention Lock)을 먼저 호출해야 한다.
여기서 아키텍처적 트레이드오프와 충돌이 발생한다. 갭 락은 읽기 및 공유 모드의 성격을 지니므로 여러 트랜잭션이 동시에 동일한 갭 영역에 갭 락을 중복으로 소유할 수 있다. 그러나 갭 락과 삽입 의도 락은 상호 호환되지 않는다. 특정 영역에 갭 락이 먼저 걸려 있다면, 해당 영역에 삽입 의도 락을 획득하려는 삽입(Insert) 쿼리는 앞선 갭 락이 완전히 해제될 때까지 대기 상태(Waiting)로 블로킹된다.
문제의 도메인 로직을 점검한 결과, 전형적인 Delete And Save(기존 레코드 삭제 후 신규 데이터 삽입) 패턴을 취하고 있었다. 이 패턴 하에서 조건절에 부합하는 물리적 레코드가 존재할 때는 베타 락(Exclusive Lock)이 걸리지만, 만약 조건에 맞는 데이터가 테이블에 존재하지 않는 상태에서 Delete 문이 수행되면 해당 인덱스 가상 범위에 갭 락이 부여된다.
실제 현상을 정밀하게 검증하기 위해 동일 인덱스를 가진 샘플 테이블을 구축하여 동시성 제어 시나리오를 재현하였다.
+-----------+--------+
| age (idx) | name |
+-----------+--------+
| 10 | binghe |
| 15 | mark |
| 18 | hello |
| 20 | hey |
+-----------+--------+
위와 같이 인덱스가 구성된 상태에서, 실제 데이터가 존재하지 않는 가상의 영역(age = 16, 17)을 대상으로 두 개의 데이터베이스 세션이 동시에 트랜잭션을 시작하는 흐름을 시뮬레이션하였다.
| 순서 | Session-1 | Session-2 |
|---|---|---|
| 1 | BEGIN; | BEGIN; |
| 2 | DELETE FROM table_gaplock WHERE age IN (10, 16, 17, 20); |
(존재하지 않는 16, 17 영역에 갭 락 획득) | DELETE FROM table_gaplock WHERE age IN (15, 16, 17, 18);
(동일하게 존재하지 않는 16, 17 영역에 갭 락 획득) |
| 3 | INSERT INTO table_gaplock VALUES (7, 'test', 17);
(Session-2가 17 영역에 갭 락을 쥐고 있으므로 삽입 의도 락 대기 상태 진입) | |
| 4 | | INSERT INTO table_gaplock VALUES (6, 'test2', 16);
(Session-1이 16 영역에 갭 락을 쥐고 있으므로 삽입 의도 락 대기 상태 진입 -> 데드락 발생) |
이 교착 상태의 상세 구조를 트랜잭션 컴포넌트 관점에서 매핑하면 다음과 같다.
Transaction - A Index Transaction - B
+-----------------------+ +---------+ +-----------------------+
| DELETE (10, 16, 17, 20)| | idx_age | | DELETE (15, 16, 17, 18)|
| | +---------+ | |
| 보유 Lock: | | 10 | | 보유 Lock: |
| X -> 10, 20 |------------->+---------+<-----------------| X -> 15, 18 |
| Gap -> 16, 17 | | 11 | | Gap -> 16, 17 |
+-----------------------+ +---------+ +-----------------------+
| 12 |
+---------+
| 13 |
+---------+
| 14 |
+-----------------------+ +---------+ +-----------------------+
| INSERT (17) | | 15 | | INSERT (16) |
| | +---------+ | |
| 실행 시 대기 상태 발생 |------------->| 16 |<-----------------| 실행 시 대기 상태 발생 |
| (Insert Intention) | +---------+ | (Insert Intention) |
| | | 17 | | |
| 상대방의 Gap Lock이 | +---------+ | 상대방의 Gap Lock이 |
| 해제될 때까지 무한대기 | | 18 | | 해제될 때까지 무한대기 |
+-----------------------+ +---------+ +-----------------------+
| 19 |
+---------+
| 20 |
+---------+
두 트랜잭션이 공유 모드로 작동하는 갭 락을 각자 정상적으로 획득한 이후, 상대방이 쥐고 있는 갭 영역 내에 삽입 의도 락을 통과시키려 하면서 서로의 자원 해제만을 기다리는 전형적인 환형 대기(Circular Wait) 상태가 성립된다. 데이터베이스 커널은 이를 감지하고 하나의 트랜잭션을 강제 롤백 처리한다.
이러한 동시성 이슈를 극복하고 시스템의 가용성을 높이기 위해 적용 가능한 해결 방안은 시스템 레이어별로 분리하여 수립할 수 있다.
UPDATE 구문으로 전환하여 무분별한 갭 락 생성을 봉쇄한다.SELECT를 통해 해당 데이터가 실제로 존재하는지 유효성을 검증한 뒤, 실존하는 레코드에 한해서만 삭제 요청을 송신함으로써 가상 범위에 대한 잠금 할당을 방어한다.READ-COMMITTED로 조정한다. 이 레벨에서는 갭 락 자체가 활성화되지 않으므로 본 교착 상태를 기술적으로 원천 회피할 수 있다. 단, 격리 수준 하향에 따른 데이터 정합성 부수 효과(Side Effect)를 면밀히 검토해야 한다.추가적으로 아키텍처 진화 관점에서 본 장애를 고찰하면 다음과 같다. 상기 기술된 갭 락과 삽입 의도 락의 충돌 메커니즘은 MySQL 5.7 및 초기 8.0 시스템에서 빈번히 보고되던 특성이다.
그러나 스토리지 엔진이 내부적으로 고도화됨에 따라 최신 MySQL 8.0 버전에 이르러서는 내부적으로 락의 우선순위(Priority) 제어 알고리즘이 보완되었다. 특정 조건 하에서 갭 락이 선점되어 있더라도 후속 삽입 의도 락의 진입을 일부 허용하거나 충돌 메커니즘을 완화함으로써, 불필요한 상호 대기 시간을 단축하고 데드락이 발생할 확률을 아키텍처 수준에서 감소시키도록 진화하였다. 그럼에도 불구하고 동시성 높은 대규모 백엔드 시스템을 설계할 때는 애플리케이션 레벨에서 잠금의 범위를 예측하고 제어하는 방어적 프로그래밍 기법이 항상 선행되어야 한다.