본문 바로가기
MySQL

데드락: 당황하지 말고 줄을 서시오

by kkodecaffeine 2025. 1. 7.

시작하며;

아무 소리가 없던 채널에서 연달아 로그가 쏟아졌다. 심쿵했다. 로그 내용은 아래와 같다. 내용은 MySQL 에서 트랜잭션을 실행하는 도중, 두 개 이상의 트랜잭션이 서로의 리소스를 기다리며 교착 상태가 (Deadlock) 발생했을 때 MySQL 이 이를 해결하기 위해 한 트랜잭션을 강제로 롤백하면서 발생하는 에러라고 한다.

Deadlock found when trying to get lock; try restarting transaction

Deadlock 이란 무엇인가;

MySQL 은 데이터를 변경하거나 조회할 때, 다른 작업과 충돌하지 않도록 잠금을 건다. 예를 들어, 테이블 A의 데이터를 변경 중이라면, MySQL 은 해당 데이터를 다른 트랜잭션이 접근하지 못하도록 잠금을 걸게 된다. 하지만 작업 순서가 뒤섞이거나 서로 기다리는 상황이 생기면 Deadlock (교착 상태) 이 발생한다.
 
예를 들어보자:

  1. 작업 1: ExampleEntity 테이블 잠금 (유저 레벨을 업데이트하려고 잠금)
  2. 작업 2: ExampleEntity2 테이블 잠금 (경험치를 업데이트하려고 잠금)
  3. 작업 3: ExampleEntity3 테이블 잠금 (새 경험치 기록을 삽입하려고 잠금)

이 상황에서 병렬로 실행되며 작업 순서가 꼬이면 다음과 같은 Deadlock이 발생할 수 있다.

  • 작업 2가 ExampleEntity2 잠금을 잡은 상태에서 ExampleEntity 잠금을 요청
  • 작업 1이 이미 ExampleEntity 잠금을 잡고 있어서 대기
  • 작업 1이 ExampleEntity2 잠금을 요청
  • 작업 2가 이미 ExampleEntity3 잠금을 잡고 있어서 대기

결과적으로 두 작업 모두 끝나지 않고 무한히 기다리는 상태(Deadlock) 가 발생한다.


문제 상황: 기존 코드;

기존 코드는 다음과 같이 구성되어 있다. 이 코드는 한 트랜잭션 안에서 병렬적으로 처리되며 Deadlock 이 발생할 가능성을 키운다. 왜냐하면 Promise.all 을 사용하여 여러 작업을 동시에 실행했기 때문이다.

await this.dataSource.transaction(async (transactionalEntityManager) => {
  const userTransaction = () => {
    if (currentLevel !== userExpInfo.currentLevel) {
      return transactionalEntityManager
        .createQueryBuilder()
        .update(ExampleEntity)
        .set({
          example: example,
        })
        .where('seq = :userSeq', { userSeq })
        .execute();
    }
  };

  const [example, example2] = await Promise.all([
    transactionalEntityManager
      .createQueryBuilder()
      .insert()
      .into(ExampleEntity2)
      .values(example)
      .execute(),

    transactionalEntityManager
      .createQueryBuilder()
      .update(ExampleEntity3)
      .set({
        example: example,
      })
      .where('userSeq = :userSeq', { userSeq })
      .execute(),

    userTransaction(),
  ]);
});

줄을 서시오;

Deadlock 을 피하기 위해 트랜잭션 내 병렬 처리를 순차 처리로 변경했다. 한 번에 하나의 작업만 실행하여 MySQL 이 잠금을 걸고 해제하는 과정을 명확히 순서대로 진행하도록 만드는 것이다.

await this.dataSource.transaction(async (transactionalEntityManager) => {
  if (currentLevel !== userExpInfo.currentLevel) {
    await transactionalEntityManager
      .createQueryBuilder()
      .update(ExampleEntity)
      .set({
        example: example,
      })
      .where('seq = :userSeq', { userSeq })
      .execute();
  }

  await transactionalEntityManager
    .createQueryBuilder()
    .insert()
    .into(ExampleEntity2)
    .values(example)
    .execute();

  await transactionalEntityManager
    .createQueryBuilder()
    .update(ExampleEntity3)
    .set({
      example: example,
    })
    .where('userSeq = :userSeq', { userSeq })
    .execute();
});

최적화와 예방;

Deadlock 은 시스템에서 완전히 피하기 어렵지만, 몇 가지 전략을 통해 발생 확률을 효과적으로 줄일 수 있었다. 먼저, 잠금 순서를 통일하는 것이 중요하다. 여러 트랜잭션이 동일한 리소스를 잠글 때, 항상 동일한 순서로 잠금을 요청하도록 설계하면 교착 상태의 가능성을 크게 줄일 수 있다. 그리고 큰 트랜잭션을 작은 단위로 분리하여 한 번에 처리하는 데이터를 최소화하는 것도 중요하다. 잠금 유지 시간을 단축할 수 있기 때문이다. 마지막으로, 조회나 업데이트 쿼리에 적절한 인덱스를 추가하여 불필요한 잠금을 방지하고, 쿼리 성능을 최적화할 수 있다. 이와 같은 접근 방식을 통해 문제를 해결했고, 특히 잠금 순서를 통일하여 긴급 패치를 배포한 이후로 동일한 에러는 더 이상 발생하지 않았다.


return;

'MySQL' 카테고리의 다른 글

[MySql] 의존성 서브 쿼리를 겪어보니..  (0) 2021.08.26