본문 바로가기

Programming/Database

MariaDB 트랜잭션 격리수준과 데드락 원인 진단법

추천캐릭터 2026. 8. 7. 13:00
728x90

MariaDB 트랜잭션 격리수준과 데드락 원인 진단법

재고 차감이나 포인트 적립처럼 여러 트랜잭션이 같은 행을 동시에 건드리는 로직에서 Deadlock found when trying to get lock 예외를 만난 적이 있을 겁니다. 인덱스도 걸려 있고 코드도 틀린 곳이 없어 보이는데 왜 이런 일이 생길까요. 원인은 대부분 트랜잭션 격리수준과 잠금 순서에 있습니다.

트랜잭션 격리수준 4단계

SQL 표준은 네 가지 격리수준을 정의합니다. 격리수준이 낮을수록 동시성은 좋아지지만 아래 세 가지 이상 현상이 발생할 여지가 커집니다.

격리수준 Dirty Read Non-repeatable Read Phantom Read
READ UNCOMMITTED 발생 발생 발생
READ COMMITTED 방지 발생 발생
REPEATABLE READ 방지 방지 발생(InnoDB는 갭 락으로 대부분 방지)
SERIALIZABLE 방지 방지 방지
  • Dirty Read: 커밋되지 않은 다른 트랜잭션의 변경을 읽는 것
  • Non-repeatable Read: 같은 행을 두 번 읽었는데 값이 달라지는 것
  • Phantom Read: 같은 조건으로 두 번 조회했는데 행 개수가 달라지는 것

MariaDB와 MySQL의 InnoDB는 기본값이 REPEATABLE READ입니다. 오라클이나 PostgreSQL의 기본값인 READ COMMITTED와 다르다는 점을 모르고 마이그레이션하면 잠금 범위 차이로 당황할 수 있습니다. InnoDB는 REPEATABLE READ에서도 갭 락(gap lock)과 넥스트 키 락(next-key lock)을 사용해 팬텀 리드를 상당 부분 막아줍니다.

데드락은 왜 발생하는가

데드락은 두 트랜잭션이 서로 다른 순서로 잠금을 요청하며 상대가 가진 잠금을 기다릴 때 발생합니다.

-- 트랜잭션 A
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;

-- 트랜잭션 B (거의 동시에 실행)
UPDATE accounts SET balance = balance - 500 WHERE id = 2;
UPDATE accounts SET balance = balance + 500 WHERE id = 1;

트랜잭션 A가 id=1을 잠그고 id=2를 기다리는 동안, 트랜잭션 B는 id=2를 잠그고 id=1을 기다리면 서로가 서로를 막는 순환 대기가 만들어집니다. InnoDB는 이 상태를 감지하면 둘 중 하나를 강제로 롤백시켜 순환을 풉니다.

데드락 진단하기

가장 먼저 확인할 명령은 이것입니다.

SHOW ENGINE INNODB STATUS\G

LATEST DETECTED DEADLOCK 섹션에서 어떤 트랜잭션이 어떤 잠금을 기다리다 롤백됐는지 확인할 수 있습니다. 여기서 봐야 할 것은 두 가지입니다. 첫째, 어떤 쿼리가 어떤 인덱스에서 잠금을 잡았는지. 둘째, 잠금을 획득한 순서가 트랜잭션마다 다른지.

Spring Boot에서의 대응

애플리케이션 코드에서 격리수준을 명시적으로 지정할 수 있습니다.

@Transactional(isolation = Isolation.READ_COMMITTED)
public void transfer(Long fromId, Long toId, long amount) {
    Account from = accountRepository.findById(fromId).orElseThrow();
    Account to = accountRepository.findById(toId).orElseThrow();
    from.withdraw(amount);
    to.deposit(amount);
}

격리수준을 낮춘다고 데드락이 사라지지는 않습니다. 근본적인 대응은 두 가지입니다.

  1. 잠금 순서 통일: 여러 행을 갱신해야 한다면 항상 같은 순서(예: ID 오름차순)로 잠그도록 코드를 맞춥니다.
  2. 재시도 처리: 데드락은 완전히 없애기보다 발생 시 재시도하는 편이 실무적입니다. Spring Data JPA는 데드락을 CannotAcquireLockException으로 감싸 던지므로, 이를 잡아 재시도하는 로직을 둘 수 있습니다.
@Retryable(
    retryFor = CannotAcquireLockException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void transferWithRetry(Long fromId, Long toId, long amount) {
    transfer(fromId, toId, amount);
}

실무 체크리스트

  • 짧은 트랜잭션을 유지합니다. 트랜잭션이 길어질수록 잠금 보유 시간이 늘어 충돌 확률이 커집니다.
  • 갱신 대상 행을 ID 오름차순 등 일관된 순서로 정렬해 접근합니다.
  • 배치 작업처럼 대량의 행을 갱신하는 경우 트랜잭션을 작은 단위로 나눕니다.
  • SELECT ... FOR UPDATE를 남발하지 않습니다. 필요한 행만 잠그는 것이 원칙입니다.

마무리

격리수준은 정확성과 동시성 사이의 트레이드오프이고, 데드락은 그 트레이드오프 속에서 잠금 순서가 어긋날 때 나타나는 증상입니다. SHOW ENGINE INNODB STATUS로 원인을 정확히 진단한 뒤, 잠금 순서를 통일하고 재시도 로직을 더하는 것이 가장 현실적인 해법입니다.

참고: Spring Framework 7의 코어 @Retryable 설정은 별도 글에서 지수 백오프와 재시도 예외 구분을 다뤘으니 함께 참고하면 좋습니다.

728x90