DB

데드락(Deadlock) — MikroORM/PostgreSQL 실전 예제로 이해하기

Joonfluence 2026. 1. 5.

데드락(Deadlock) 완전 정복 — MikroORM/PostgreSQL 실전 예제로 이해하기

데드락은 이론적으로는 단순하지만, 실무에서는 예상치 못한 곳에서 터진다. 이 글에서는 데드락의 발생 조건을 정리하고, MikroORM + PostgreSQL 환경에서 실제로 데드락이 발생하는 3가지 패턴을 쿼리 레벨까지 추적하며, 각 해결 방법의 트레이드오프를 다룬다.

데드락이란

두 개 이상의 트랜잭션이 서로가 잡고 있는 리소스를 기다리면서 영원히 진행하지 못하는 상태다. 둘 다 상대방이 먼저 놓아주길 기다리지만, 아무도 양보하지 않는다.

데드락 발생의 4가지 조건

데드락은 아래 4가지가 동시에 성립해야 발생한다. 하나라도 깨뜨리면 데드락은 발생하지 않는다.

1. 상호 배제 (Mutual Exclusion) — 리소스를 하나의 트랜잭션만 점유할 수 있다. SELECT ... FOR UPDATE로 Row Lock을 걸면 다른 트랜잭션은 해당 Row에 접근하지 못한다.

2. 점유 대기 (Hold and Wait) — 하나의 리소스를 잡고 있으면서 다른 리소스도 요청한다. Product A의 Lock을 보유한 채로 Product B의 Lock을 요청하는 상황이다.

3. 비선점 (No Preemption) — 다른 트랜잭션이 잡고 있는 Lock을 강제로 뺏을 수 없다. Lock은 해당 트랜잭션이 COMMIT 또는 ROLLBACK할 때만 해제된다.

4. 순환 대기 (Circular Wait) — TX1이 TX2를 기다리고, TX2가 TX1을 기다리는 순환 구조가 형성된다.


케이스 1: 교차 업데이트 — 가장 흔한 데드락 패턴

상황

두 개의 API가 거의 동시에 호출된다. 하나는 주문 처리로 Product A → B 순서로 재고를 차감하고, 다른 하나는 재고 이동으로 Product B → A 순서로 재고를 변경한다.

// API 1: 주문 처리 — Product A 먼저, Product B 다음
@Post('/order')
async createOrder() {
  const em = this.orm.em.fork();

  await em.transactional(async (em) => {
    // Step 1: Product A에 Row Lock
    const productA = await em.findOneOrFail(
      Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
    );
    productA.stock -= 1;

    await someSlowBusinessLogic(); // 복잡한 비즈니스 로직

    // Step 2: Product B에 Row Lock 필요
    // 💀 TX2가 Product B를 잡고 있어서 대기
    const productB = await em.findOneOrFail(
      Product, { id: 2 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
    );
    productB.stock -= 1;
  });
}

// API 2: 재고 이동 — Product B 먼저, Product A 다음
@Post('/transfer')
async transferStock() {
  const em = this.orm.em.fork();

  await em.transactional(async (em) => {
    // Step 1: Product B에 Row Lock
    const productB = await em.findOneOrFail(
      Product, { id: 2 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
    );
    productB.stock -= 5;

    await someSlowBusinessLogic();

    // Step 2: Product A에 Row Lock 필요
    // 💀 TX1이 Product A를 잡고 있어서 대기
    const productA = await em.findOneOrFail(
      Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
    );
    productA.stock += 5;
  });
}

쿼리 레벨에서 무슨 일이 벌어지나

시간 순서대로 실제 SQL을 추적하면 데드락이 어떻게 형성되는지 보인다.

-- TX1 (주문 처리)                       TX2 (재고 이동)
-- ──────────────                       ──────────────
BEGIN;                                  BEGIN;

SELECT * FROM product
WHERE id = 1
FOR UPDATE;
-- ✅ Product 1 Lock 획득
                                        SELECT * FROM product
                                        WHERE id = 2
                                        FOR UPDATE;
                                        -- ✅ Product 2 Lock 획득

SELECT * FROM product
WHERE id = 2
FOR UPDATE;
-- ⏳ 대기... TX2가 Product 2 잡고 있음
                                        SELECT * FROM product
                                        WHERE id = 1
                                        FOR UPDATE;
                                        -- ⏳ 대기... TX1이 Product 1 잡고 있음

-- 💀 DEADLOCK 발생

순환 대기 구조가 만들어진다.

TX1 ─── Product 1 (Lock 보유) ───→ Product 2 (Lock 대기)
 ↑                                        │
 │                                        ↓
TX2 ─── Product 2 (Lock 보유) ───→ Product 1 (Lock 대기)

PostgreSQL이 데드락을 감지하면 한쪽 트랜잭션을 강제 롤백시킨다.

ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678;
        blocked by process 5679.
        Process 5679 waits for ShareLock on transaction 5677;
        blocked by process 1234.

케이스 2: 암묵적 데드락 — FOR UPDATE 없이도 발생한다

명시적으로 PESSIMISTIC_WRITE를 쓰지 않아도 데드락이 발생할 수 있다. UPDATE 문 자체가 Row Lock을 건다는 사실을 모르면 당한다.

// TX1
await em.transactional(async (em) => {
  // UPDATE는 자동으로 Row Lock을 건다
  await em.nativeUpdate(Product, { id: 1 }, { stock: raw('stock - 1') });
  // → Product 1에 Lock

  await someSlowBusinessLogic();

  await em.nativeUpdate(Product, { id: 2 }, { stock: raw('stock - 1') });
  // → Product 2에 Lock 필요 → 💀 대기
});

// TX2 (동시 실행)
await em.transactional(async (em) => {
  await em.nativeUpdate(Product, { id: 2 }, { stock: raw('stock + 5') });
  // → Product 2에 Lock

  await someSlowBusinessLogic();

  await em.nativeUpdate(Product, { id: 1 }, { stock: raw('stock + 5') });
  // → Product 1에 Lock 필요 → 💀 대기
});

실제 쿼리를 보면 SELECT ... FOR UPDATE가 없는데도 동일한 구조다.

-- TX1                                  TX2
BEGIN;                                  BEGIN;

UPDATE product SET stock = stock - 1
WHERE id = 1;
-- Row Lock on id=1
                                        UPDATE product SET stock = stock + 5
                                        WHERE id = 2;
                                        -- Row Lock on id=2

UPDATE product SET stock = stock - 1
WHERE id = 2;
-- ⏳ 대기...
                                        UPDATE product SET stock = stock + 5
                                        WHERE id = 1;
                                        -- ⏳ 대기...

-- 💀 DEADLOCK

SELECT ... FOR UPDATE는 명시적으로 Lock을 거는 것이고, UPDATE는 암묵적으로 Lock을 거는 것이다. 결과는 동일하다. MikroORM의 em.flush()도 내부적으로 UPDATE를 실행하므로, 변경된 엔티티의 flush 순서에 따라 암묵적 데드락이 발생할 수 있다.


케이스 3: 범위 업데이트와 인덱스 락 — 실무에서 자주 당하는 패턴

단일 Row가 아니라 범위 쿼리로 여러 Row를 업데이트할 때, Lock이 걸리는 순서가 인덱스 스캔 방향에 따라 달라질 수 있다.

// TX1: 카테고리 A 상품 가격 일괄 인상
await em.transactional(async (em) => {
  await em.nativeUpdate(
    Product,
    { category: 'A', price: { $lt: 10000 } },
    { price: raw('price * 1.1') }
  );
  // → WHERE category = 'A' AND price < 10000
  // → 조건에 해당하는 여러 Row에 Lock
});

// TX2: 가격 범위로 할인율 업데이트
await em.transactional(async (em) => {
  await em.nativeUpdate(
    Product,
    { price: { $gte: 5000, $lte: 15000 } },
    { discount: 10 }
  );
  // → WHERE price >= 5000 AND price <= 15000
  // → 범위가 겹치는 Row에서 데드락 가능
});
-- 범위 쿼리의 함정:
-- PostgreSQL은 인덱스 스캔 순서대로 Row Lock을 건다
-- 두 쿼리가 다른 인덱스를 사용하면 Lock 순서가 달라질 수 있다

-- TX1: row 101 Lock → row 102 Lock → row 103 Lock → ...
-- TX2: row 103 Lock → row 102 Lock → row 101 Lock → ...
--      (다른 인덱스 또는 역방향 스캔)
-- 💀 DEADLOCK

이 패턴이 특히 위험한 이유는 코드만 봐서는 데드락이 예상되지 않기 때문이다. 단일 쿼리 안에서 여러 Row에 Lock이 걸리고, 그 순서를 개발자가 제어하기 어렵다.


해결 방법

1. Lock 순서 통일 — 순환 대기 조건을 깨뜨린다

데드락의 4가지 조건 중 "순환 대기"를 원천 차단하는 가장 근본적인 해결책이다. 여러 리소스에 Lock을 걸어야 할 때, 항상 같은 순서(예: ID 오름차순)로 Lock을 건다.

async updateProducts(ids: number[], updates: Partial<Product>[]) {
  const sortedIds = [...ids].sort((a, b) => a - b); // 항상 ID 오름차순

  await em.transactional(async (em) => {
    for (const id of sortedIds) {
      const product = await em.findOneOrFail(
        Product, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE }
      );
      // 비즈니스 로직 적용
    }
  });
}

TX1이 Product 1 → 2 순서로 Lock을 걸고, TX2도 Product 1 → 2 순서로 Lock을 건다면 순환이 발생하지 않는다. TX2는 Product 1을 기다리고, TX1이 끝나면 TX2가 진행된다.

2. Lock Timeout — 무한 대기를 방지한다

await em.transactional(async (em) => {
  // PostgreSQL — 5초 대기 후 포기
  await em.getConnection().execute('SET lock_timeout = 5000');

  try {
    const product = await em.findOneOrFail(
      Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
    );
    product.stock -= 1;
  } catch (e) {
    // ERROR: canceling statement due to lock timeout
    // 재시도 로직 또는 에러 처리
  }
});

데드락 자체를 예방하지는 않지만, 데드락이 발생했을 때 빠르게 실패하도록 한다. PostgreSQL이 데드락을 감지하기까지 기본적으로 1초(deadlock_timeout)가 걸리는데, lock_timeout을 설정하면 그 전에 먼저 포기할 수 있다.

3. 낙관적 락 (Optimistic Lock) — Lock을 걸지 않는다

Lock 자체를 걸지 않으므로 데드락이 구조적으로 발생하지 않는다. 대신 커밋 시점에 충돌을 감지한다.

@Entity()
export class Product {
  @PrimaryKey()
  id!: number;

  @Property()
  stock!: number;

  @Property({ version: true })
  version!: number; // MikroORM이 자동 관리
}

// 사용
await em.transactional(async (em) => {
  const product = await em.findOneOrFail(Product, { id: 1 });
  product.stock -= 1;

  // flush 시점에 version 체크
  // UPDATE product SET stock = ?, version = version + 1
  //   WHERE id = 1 AND version = 3
  // → version이 바뀌었으면 OptimisticLockError 발생
});
-- 낙관적 락의 실제 쿼리
-- Lock 없이 읽고:
SELECT * FROM product WHERE id = 1;
-- version=3, stock=100

-- 커밋 시 version 조건으로 업데이트:
UPDATE product
SET stock = 99, version = 4
WHERE id = 1 AND version = 3;
-- affected rows = 0이면 → 다른 트랜잭션이 먼저 수정한 것
-- → OptimisticLockError 발생 → 재시도

동시 충돌이 적은 환경(대부분의 웹 애플리케이션)에서 적합하다. 충돌이 빈번하면 재시도가 많아져서 오히려 비효율적이다.

4. 트랜잭션 범위 최소화 — 항상 적용해야 하는 원칙

// ❌ 트랜잭션 안에서 너무 많은 일을 한다
await em.transactional(async (em) => {
  const product = await em.findOneOrFail(
    Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
  );
  // Lock 잡은 상태에서...
  await sendEmail(user);           // 외부 API 호출 (느림)
  await generateInvoice(order);    // 파일 생성 (느림)
  await notifySlack(channel);      // 또 외부 호출 (느림)
  product.stock -= 1;
});

// ✅ Lock 보유 시간을 최소화한다
await sendEmail(user);
await generateInvoice(order);
await notifySlack(channel);

// 트랜잭션은 DB 작업만
await em.transactional(async (em) => {
  const product = await em.findOneOrFail(
    Product, { id: 1 }, { lockMode: LockMode.PESSIMISTIC_WRITE }
  );
  product.stock -= 1;
  // 바로 커밋 → Lock 즉시 해제
});

Lock을 잡고 있는 시간이 짧으면 다른 트랜잭션이 같은 리소스를 기다리는 시간도 짧아지고, 데드락이 발생할 확률이 급격히 줄어든다.


해결 방법 비교

해결 방법 원리 적합한 경우 주의점
Lock 순서 통일 순환 대기 조건 제거 어떤 리소스를 Lock할지 미리 아는 경우 범위 쿼리에서는 적용 어려움
Lock Timeout 무한 대기 방지 범용적 안전장치 데드락 예방이 아닌 빠른 실패
낙관적 락 Lock 자체를 안 걸음 충돌이 적은 환경 충돌 빈번 시 재시도 비용
트랜잭션 범위 최소화 Lock 보유 시간 단축 항상 적용 가능 비즈니스 로직 분리 설계 필요

핵심 정리

  • UPDATE 문은 암묵적으로 Row Lock을 건다. FOR UPDATE만 Lock이 아니다.
  • 데드락은 Lock 순서가 다를 때 발생한다. 항상 같은 순서로 Lock을 걸면 순환 대기를 차단할 수 있다.
  • 범위 쿼리는 Lock 순서를 예측하기 어렵다. 인덱스 스캔 방향에 따라 Row Lock 순서가 달라질 수 있다.
  • 트랜잭션 안에서 외부 API 호출, 파일 I/O 등 느린 작업을 하지 않는다. Lock 보유 시간이 길어지면 데드락 확률이 높아진다.
  • PostgreSQL은 데드락을 감지하면 한쪽을 강제 롤백한다. 애플리케이션에서 이를 잡아서 재시도하는 로직이 필요하다.
반응형

댓글