배경
- 최근 우리 서비스는 Sentry를 통해 데드락 알람을 자주 받고 있다
- 처음에는 중복 인덱스를 발견해서 제거하는 작업을 진행했다
- 그럼에도 불구하고 동일한 로직에서 데드락이 발생하는 중이다
- 조금은 빈도가 감소했을 수 있는데, 데드락 모니터링과 수집 및 통계화를 다른 회사에서는 어떻게 하는지 리서치가 필요해 보인다
- 아무튼 데드락을 분석해보니, 동일한 레코드의 DELETE-INSERT가 하나의 트랜잭션 내 수행되는 API에서 발생하였다
- 명확한 원인을 파악하고 전략을 세우기 위해 분석을 시도하였다
InnoDB Deadlock 분석
InnoDB 엔진 내부 상태를 확인하는 방법
RDMBS 클라이언트에서 위 명령어를 입력:
SHOW ENGINE INNODB STATUS
- 명령어는 InnoDB 엔진 내부의 상태를 확인 가능하며, 보통 Deadlock을 분석하는데 사용
- 참고) 최근에 발생한 Deadlock만 기록된다는 점을 유의
- RDMBS 클라이언트에서 위 명령어를 입력하면 LATEST DETECTED DEADLOCK 섹션을 분석할 수 있다
- 아래 예시는 우리 프로덕트 환경에서 발생한 로그에서 중요한 정보들은 마스킹 처리하고 가져왔다
------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-04-17 09:24:30
*** (1) TRANSACTION:
TRANSACTION 10539257, ACTIVE 0 sec starting index read
mysql tables in use 3, locked 3
LOCK WAIT 2 lock struct(s), heap size 1128, 1 row lock(s)
MySQL thread id 958294, OS thread handle MASKED_HANDLE, query id MASKED_QUERY_ID xxx.xxx.xxx.xxx sample_db executing
DELETE FROM sample_table WHERE (조건 생략)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id MASKED_SPACE page no MASKED_PAGE n bits MASKED_INDEX index `sample_index` of table `sample_db`.`sample_table` trx id 10539257 lock_mode X
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id MASKED_SPACE page no MASKED_PAGE index `sample_index` of table `sample_db`.`sample_table` lock_mode X waiting
*** (2) TRANSACTION:
TRANSACTION 10539266, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 17 lock struct(s), heap size 1128, 13 row lock(s)
MySQL thread id 958304, OS thread handle MASKED_HANDLE, query id MASKED_QUERY_ID xxx.xxx.xxx.xxx sample_db update
INSERT INTO sample_table (컬럼 생략) VALUES (데이터 일부 마스킹)
*** (2) HOLDS THE LOCK(S):
(생략 가능하거나 중요 키만 남김)
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id MASKED_SPACE page no MASKED_PAGE index `sample_index` lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (1)
분석 방법:
- TRANSACTION - 각각의 트랜잭션이 어떤 SQL 쿼리를 실행하고 있는지 파악
- HOLDS THE LOCKS(S) - 해당 트랜잭션이 어떤 레코드/인덱스를 점유(락 보유) 하고 있는지 확인
- WAITING FOR THIS LOCK TO BE GRANTED - 어떤 레코드를 점유하려다 대기 상태에 빠졌는지 확인
- 트랜잭션 간 연결 분석 - 어떤 트랜잭션이 락을 잡고, 락을 기다리는지 관계를 분석
- WE ROLL BACK TRANSACTION (번호) - 어떤 트랜잭션이 롤백되었는지 확인
Deadlock 발생 원인
- Deadlock이 발생한 트랜잭션은 아래와 같음
- 비동기 트랜잭션이 특정 유저에 의해 동시에 같은 로직을 타서 발생
- 동일한 레코드를 삭제하고, 새롭게 삽입하는 덮어쓰기 로직
START TRANSACTION;
DELETE FROM SAMPLE_TABLE
WHERE 조건1
AND EXISTS (조건2);
INSERT INTO SAMPLE_TABLE (...) VALUES (...), (...);
COMMIT;
트랜잭션 관계를 시간흐름에 맞춰 아래와 같이 정리할 수 있음:
시간 흐름 | 트랜잭션 1 (TX1) | 트랜잭션 2 (TX2) |
T0 | DELETE 실행 (heap 158 삭제 플래그 설정) | |
T1 | INSERT 준비 (heap 158에 삽입 시도) | |
T2 | Gap 정리 대기 (heap 158 비우기 시도) | |
T3 | DELETE 실행 시도 (heap 158 접근) | |
T4 | heap 158 락 획득 시도 (대기 중) | |
T5 | Gap 정리 완료 못하고 대기 계속 | |
T6 | 락 대기 중 교착 발생 | 락 대기 중 교착 발생 |
T7 | 롤백 대상 선정 (InnoDB가 TX1 롤백) |
여기서 알아야할 중요한 포인트 두 가지:
- 미세한 Gap 정리 타이밍 중 다른 트랜잭션이 들어오면 데드락이 발생
- 다른 트랜잭션의 wait이 없었다면 하나의 트랜잭션 내에서 삭제-삽입 로직은 정상적으로 실행됨
빈도를 낮추는 방법
- 여러 트랜잭션이 동시에 같은 데이터에 접근하면 충돌은 자연스럽게 발생
- InnoDB는 이 충돌을 감지해서 무한정 대기하지 않게 한쪽을 롤백시키는 메커니즘을 갖음
- 따라서, 데드락은 문제라기 보단 정상적인 복구 절차에 해당
- 문제는 빈도와 복구를 제어하는 것
1. 쿼리 분리: DELETE → COMMIT → INSERT
- 삭제와 삽입을 다른 트랜잭션 내에서 처리되도록 하는 방법
- 이 경우 어플리케이션 복잡성이 증가
- 복잡성1) 삽입에서 실패하는 경우, 재시도에 대한 로직이 필요
- 복잡성2) 동기적인 방식이면 클라이언트에게 어떻게 응답할지에 대한 추가적인 고민이 필요
- 이 방식은 비즈니스 트랜잭션 단위의 원자성을 잃을 수 있으므로 신중히 고려
2. 트랜잭션 락 획득 순서를 고정
방법 | 설명 | 특징 |
---|---|---|
항상 같은 조건, 같은 순서로 데이터를 조작 | DELETE → INSERT → UPDATE 처럼 항상 동일한 작업 순서를 지키기 | - SQL 실행 순서 고정 - 메서드 내부 논리 순서 고정 |
락 잡는 순서를 고정 | 여러 테이블을 접근할 때 A → B 테이블 순서로 항상 락을 잡는다 | - DB Deadlock 예방에 중요 - 예를 들어 항상 작은 ID → 큰 ID 순으로 처리 |
ORDER BY 강제 적용 | DELETE, UPDATE 쿼리에도 필요하면 ORDER BY 사용해서 락 순서를 예측 가능하게 만든다 |
- 특히 다중 row 업데이트 시 유용 |
Composite Key 기준 고정 | 복합키 기반이라면 항상 키1 → 키2 → 키3 순서로 조건을 쓴다 | - 인덱스 타고 락 걸리는 순서가 예측 가능 |
정렬에 대한 예시:
- 여러 사람 데이터를 수정하는 트랜잭션이면
- 항상 ID 오름차순으로 수정
- 여러 테이블을 조작하는 트랜잭션이면
- 항상 테이블 A → B → C 순서로 조작
- DELETE/INSERT라면
- 항상 DELETE를 먼저 하고, 삽입은 정해진 인덱스 순서로 INSERT
3. 트랜잭션 범위를 최소화
@Service
public class MyService {
// 트랜잭션 없는 읽기 메서드
public SomeDto readData(Long id) {
return myRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException());
}
// 트랜잭션 필요한 쓰기 메서드
@Transactional
public void deleteAndInsert(Long id, NewData data) {
myRepository.deleteById(id);
myRepository.save(data);
}
// 서비스 진입 메서드
public void process(Long id, NewData data) {
SomeDto dto = readData(id); // 트랜잭션 없이 조회
deleteAndInsert(id, data); // 트랜잭션 안에서 삭제 -> 삽입
}
}
- 단순한 조회와 조립 로직에서는 트랜잭션이 필요없는 경우 분리하여 트랜잭션 범위를 최소화
- 삭제 -> 삽입 메서드에 트랜잭션을 걸어 비즈니스 관점의 원자성을 지킴
- 참고) Spring에서는 실제 DB 접근할 때 트랜잭션이 진짜 시작됨
모니터링 전략 - Deadlock 감지 및 로깅
1. 애플리케이션 단에서 처리
다음과 같은 플로우로 테이블에 저장:
- 트랜잭션 시작
- DELETE/INSERT 시도 → Deadlock 감지 → InnoDB가 자동 롤백
- 트랜잭션 소멸
- 예외 캐치 (
DeadlockLoserDataAccessException
,SQLException 40001
) - 예외 캐치 블록 안에서 새로 트랜잭션 시작해서 기록하거나 로그 남김
DB에서 Deadlock 발생시 Spring 애플리케이션 레벨에서 (JDBC, ORM, MyBatis, JPA 등) 아래와 같은 예외를 감지하여 처리:
예외 | 설명 |
---|---|
DeadlockLoserDataAccessException |
Spring DataAccessException 중 하나, Deadlock 감지 시 발생 |
MySQLTransactionRollbackException |
MySQL JDBC 드라이버가 Deadlock 에러코드를 보고 던지는 예외 |
SQLState 40001 |
Deadlock 발생 시 SQL 표준 코드 - ERROR 1213 (40001): Deadlock found when trying to get lock |
@Service
public class MyService {
@Transactional
public void processSomething() {
try {
doDelete();
doInsert();
} catch (DeadlockLoserDataAccessException ex) {
deadlockLoggingService.saveDeadlockEvent(ex); // 새 트랜잭션으로 기록
throw ex; // 원래 예외를 다시 던짐 (필요 시 재시도 로직 추가)
}
}
}
- 발생시각, 트랜잭션 종류, Deadlock 에러 메시지 등을 특정 테이블 저장 혹은 로깅
- 새 트랜잭션 (
@Transactional(propagation = REQUIRES_NEW)
) 으로 열어야 안전
요약
- Deadlock은 시스템이 스스로 복구하는 정상 작동 중 하나
- 트랜잭션은 짧게, 순서는 고정, 충돌은 피하되 재시도는 필수
- 보통 DELETE → INSERT는 안전하지만, 갭락의 짧은 정리 타이밍을 항상 고려
- 모든 긴 트랜잭션은 데드락과 lock wait timeout 리스크를 내포함