MySQL InnoDB Deadlock 재시도 전략

MySQL 심화: InnoDB Deadlock 메커니즘과 재시도 전략 요약 이미지
요약 이미지(직접 생성). 무단 사용 금지.

MySQL 심화: InnoDB Deadlock 메커니즘과 재시도 전략

1) 버전 기준

  • MySQL 기준 버전: 8.4 Reference Manual
  • 공식 문서 기준: InnoDB Deadlocks / Deadlock Detection / Handling

2) 핵심 개념

MySQL 공식 매뉴얼은 deadlock을 트랜잭션들이 서로 잠금을 기다려 진행할 수 없는 상태로 정의합니다. InnoDB는 deadlock을 감지하면 트랜잭션 하나를 롤백해 교착 상태를 해소합니다. 따라서 애플리케이션은 deadlock 롤백을 전제로 재시도 로직을 가져야 합니다.

  • InnoDB deadlock 감지 후 victim 트랜잭션 롤백
  • 애플리케이션 측 재시도(backoff 포함) 권장
  • 관측: InnoDB status, innodb_print_all_deadlocks 옵션

3) 트레이드오프

  • 동시성 극대화: 처리량은 높지만 잠금 경합과 deadlock 위험 증가
  • 접근 순서 통일/락 범위 축소: deadlock 감소 효과가 있으나 구현 복잡도 증가
  • 로그 수집 강화: 원인 분석은 쉬우나 로그량/보관 비용 증가

4) 장애 재현-해결

재현 시나리오

  1. 세션 A가 row 1을 잠금 후 row 2 접근 시도
  2. 세션 B가 row 2를 잠금 후 row 1 접근 시도
  3. 상호 대기로 deadlock 발생
  4. InnoDB가 한 트랜잭션을 롤백

해결 절차

  1. 모든 경로에서 레코드 접근 순서를 통일합니다.
  2. 트랜잭션을 짧게 유지하고 잠금 범위를 최소화합니다.
  3. deadlock 오류를 애플리케이션에서 포착해 재시도합니다.
  4. 필요 시 deadlock 상세 로그를 활성화해 패턴을 수집합니다.

5) 체크리스트

  • [ ] deadlock 재시도 로직(멱등성 포함)이 구현되어 있는가?
  • [ ] 엔터티 접근 순서가 서비스 전반에서 일관적인가?
  • [ ] 장시간 트랜잭션이 잠금 경합을 만들지 않는가?
  • [ ] deadlock 관측 절차(로그/상태조회)가 런북에 있는가?
  • [ ] deadlock 로그량과 보관 정책이 합의되어 있는가?

실전 재시도 코드 예시 (Java/Spring)

Spring 환경에서 deadlock 재시도를 구현하는 실전 패턴입니다. @Retryable 어노테이션 또는 수동 재시도 루프를 사용할 수 있습니다.

@Service
public class OrderService {
    private static final int MAX_RETRIES = 3;
    
    @Transactional
    public void processOrder(Long orderId) {
        for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
            try {
                // 엔터티 접근 순서를 항상 ID 오름차순으로 통일
                updateInventory(orderId);
                updatePayment(orderId);
                return;
            } catch (DeadlockLoserDataAccessException e) {
                if (attempt == MAX_RETRIES) throw e;
                try { Thread.sleep(50 * attempt); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
            }
        }
    }
}

핵심 포인트: 재시도 시 지수 백오프(exponential backoff)를 적용하면 동시 트랜잭션 간 충돌 확률이 줄어듭니다. 또한 재시도 로직은 반드시 트랜잭션 경계 바깥에 위치해야 합니다. 트랜잭션 내부에서 재시도하면 이미 롤백된 트랜잭션 위에서 작업하게 되어 데이터 불일치가 발생할 수 있습니다.

6) 공식 링크

  • Deadlocks in InnoDB: https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlocks.html
  • Deadlock Detection: https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlock-detection.html
  • How to Minimize and Handle Deadlocks: https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlocks-handling.html

7) 실전: Deadlock 재시도 구현 (Spring + NestJS)

InnoDB 데드락은 동시 트랜잭션 환경에서 피할 수 없습니다. 핵심은 예방보다 감지 후 재시도입니다. MySQL은 데드락을 감지하면 비용이 적은 트랜잭션을 롤백하므로, 애플리케이션에서 자동 재시도 로직을 구현해야 합니다.

Spring Boot — @Retryable 활용

import org.springframework.retry.annotation.Retryable;
import org.springframework.retry.annotation.Backoff;
import org.springframework.dao.DeadlockLoserDataAccessException;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    @Retryable(
        retryFor = DeadlockLoserDataAccessException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100, multiplier = 2, random = true)
    )
    @Transactional
    public void placeOrder(OrderRequest request) {
        // 재고 차감 → 주문 생성 → 결제 처리
        inventoryRepository.decreaseStock(request.getProductId(), request.getQuantity());
        Order order = Order.create(request);
        orderRepository.save(order);
        paymentService.process(order);
    }
}

NestJS + TypeORM — 수동 재시도

import { Injectable, Logger } from '@nestjs/common';
import { DataSource, QueryFailedError } from 'typeorm';

@Injectable()
export class OrderService {
  private readonly logger = new Logger(OrderService.name);

  constructor(private dataSource: DataSource) {}

  async placeOrder(dto: OrderDto, retries = 3): Promise<Order> {
    for (let attempt = 1; attempt <= retries; attempt++) {
      try {
        return await this.dataSource.transaction(async (manager) => {
          await manager.query(
            'UPDATE inventory SET stock = stock - ? WHERE product_id = ? AND stock >= ?',
            [dto.quantity, dto.productId, dto.quantity],
          );
          const order = manager.create(Order, dto);
          return manager.save(order);
        });
      } catch (error) {
        if (
          error instanceof QueryFailedError &&
          (error as any).errno === 1213 &&  // ER_LOCK_DEADLOCK
          attempt < retries
        ) {
          const delay = Math.min(100 * Math.pow(2, attempt), 2000);
          this.logger.warn(`Deadlock detected, retry ${attempt}/${retries} after ${delay}ms`);
          await new Promise((r) => setTimeout(r, delay));
          continue;
        }
        throw error;
      }
    }
  }
}

8) 데드락 발생 줄이기: 인덱스와 접근 순서

  • 일관된 테이블/행 접근 순서: 트랜잭션 A가 inventory → order 순서면, 트랜잭션 B도 동일 순서를 유지합니다.
  • 인덱스 최적화: WHERE 절에 인덱스가 없으면 테이블 풀 스캔 → 갭 락 확대 → 데드락 확률 증가.
  • 트랜잭션 범위 최소화: 외부 API 호출을 트랜잭션 밖으로 빼세요.
-- 데드락 로그 확인
SHOW ENGINE INNODB STATUSG

-- 최근 데드락 정보 조회 (MySQL 8.0+)
SELECT * FROM performance_schema.data_locks
WHERE LOCK_STATUS = 'WAITING';

-- 갭 락 최소화를 위한 인덱스 추가
ALTER TABLE inventory ADD INDEX idx_product_id (product_id);

9) 관련 글

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux