장애 복구 코드를 짤 때 흔히 하나만 본다. "재시도하면 되지 않나", "락을 걸면 되지 않나" 식으로 개별 버그를 따로 고친다. 문제는 이 세 가지가 실제 장애에서는 따로 오지 않는다는 점이다. 주문 처리 파이프라인이 중간에 죽어서 재시작하면(Partial Write), 재시작 로직이 앞 단계를 다시 실행해 결제가 중복되고(Idempotency 부재), 여러 워커가 동시에 같은 작업을 재개하려 들면 잔액 갱신이 서로를 덮어쓴다(Race Condition).

즉 이 셋은 "장애가 언제든 일어날 수 있다"는 같은 전제에서 갈라져 나온 증상이다. 하나만 막으면 나머지 둘이 그 자리를 대신 채운다. 이 글은 세 개념을 각각 정의 → 실제 증상 → 방어 기법 순서로 정리하고, 마지막에는 이 셋을 한 워크플로우 안에서 동시에 방어하는 방법을 다룬다.

Race Condition: 공유 상태를 둘러싼 경쟁

정의

경쟁 상태(Race Condition)는 두 개 이상의 실행 흐름(스레드, 프로세스, 워커, 이벤트 핸들러)이 같은 공유 상태에 동시에 접근할 때, 실행 순서와 타이밍에 따라 최종 결과가 달라지는 비결정적 버그다. 같은 코드, 같은 입력이라도 스케줄링이 매번 다르게 겹치기 때문에 재현이 어렵고, 로컬 테스트는 통과하다가 프로덕션 부하 상황에서만 드러나는 경우가 많다.

실제 증상

두 스레드가 동시에 같은 값을 읽고 쓸 때 stale write가 발생하는 과정을 보여주는 다이어그램
두 스레드가 동시에 같은 값을 읽고 쓸 때 발생하는 stale write 문제
  • stale write wins (지연 도착이 최신 상태를 덮어씀): 최신 상태를 담은 이벤트가 먼저 처리됐는데, 네트워크 지연으로 늦게 도착한 과거 이벤트가 그 위에 다시 과거 값을 써버린다. 잔액이나 재고 수치가 시간상 "되돌아가는" 것처럼 보인다.
  • lost update (갱신 유실): 잔액이나 재고 같은 값을 읽고 → 계산하고 → 쓰는 3단계 사이에 다른 실행 흐름이 끼어들어, 한쪽의 갱신이 통째로 사라진다. 결제 시스템에서는 "잔액 재계산 오류"로 나타난다.
  • 교착 상태(deadlock): 두 실행 흐름이 서로 다른 순서로 락을 요청해 영구 대기에 빠진다. 엄밀히는 별개 범주지만, "공유 상태 동시 접근" 문제군에서 함께 다뤄진다.

다음은 두 워커가 계좌 잔액을 동시에 차감하는 전형적인 lost update 버그다.

// 버그: read-modify-write 사이에 다른 워커가 끼어들 수 있다
function withdraw(accountId, amount) {
  balance = db.read(accountId)          // 워커 A, B 모두 1000을 읽음
  if (balance < amount) {
    throw new Error("insufficient funds")
  }
  newBalance = balance - amount         // A: 1000-300=700, B: 1000-500=500
  db.write(accountId, newBalance)       // 나중에 쓰는 쪽이 이긴다 -> 한쪽 차감 유실
}

방어 기법

버전 번호로 stale write를 감지하고 방어하는 방식을 보여주는 이미지
버전 번호로 stale write를 감지하고 방어하는 방식
  • 단조 증가 버전 번호(optimistic concurrency control): 상태에 revision 필드를 두고, 갱신 전에 들어오는 리비전과 현재 리비전을 비교해 낮은(오래된) 갱신은 버린다.
  • 버전 벡터(version vector, vector clock): 여러 노드가 독립적으로 갱신하는 분산 환경에서는 단일 리비전으로 "무엇이 최신인가"를 전역적으로 정할 수 없다. 노드별 카운터 벡터로 인과 순서를 추적해 진짜 충돌과 단순 선후 관계를 구분한다.
  • 비교 후 교체(compare-and-swap, CAS): read-modify-write를 하나의 원자적 연산으로 묶어 중간에 끼어들 틈을 없앤다.
  • 락 / 트랜잭션 격리 수준: 임계 영역을 명시하고 상호 배제를 보장한다. 다만 락 범위가 넓으면 처리량이 떨어지고 데드락 위험이 생기므로, 가능하면 버전 비교 방식을 먼저 검토한다.

같은 예시를 버전 비교(CAS)로 방어하면 아래처럼 된다. 낡은 버전으로 쓰려는 시도는 실패로 처리되고, 호출자가 최신 상태를 다시 읽어 재시도한다.

// 방어: 버전 비교로 stale write를 감지하고 재시도
function withdraw(accountId, amount) {
  while (true) {
    { balance, version } = db.read(accountId)
    if (balance < amount) {
      throw new Error("insufficient funds")
    }
    newBalance = balance - amount

    // version이 그대로일 때만 갱신 (compare-and-swap)
    updated = db.compareAndSwap(accountId, {
      expectedVersion: version,
      newBalance: newBalance,
      newVersion: version + 1
    })

    if (updated) return newBalance
    // 다른 워커가 먼저 갱신했다면 최신 상태로 재시도
  }
}

Partial Write: 중간에 끊기는 다단계 작업

정의

부분 쓰기(Partial Write)는 여러 단계로 구성된 쓰기 작업이 중간에 실패해서 전체가 아니라 일부만 반영된 상태로 남는 문제다. 실패 원인은 프로세스 크래시, 워커 강제 종료, 네트워크 단절, 타임아웃 등 다양하다. 단일 트랜잭션으로 "전부 성공 or 전부 실패"를 보장할 수 없는 다단계·장시간 작업 — 여러 외부 API를 순차 호출하는 파이프라인 같은 경우 — 에서 특히 자주 발생한다.

실제 증상

체크포인트 없이 재시작해 이미 완료한 단계가 중복 실행되는 과정을 보여주는 다이어그램
체크포인트 없이 재시작하면 이미 완료한 단계가 중복 실행됨
  • 재시작 시 중복 부작용: 작업이 중간에 죽었다가 재시작되면 이미 완료된 앞 단계를 다시 실행해 부작용이 중복된다. 결제 재과금, 알림/이메일 중복 발송, 이미 만든 리소스를 또 만들어 낭비하거나 충돌하는 경우가 대표적이다.
  • 어중간한 상태로 영구 정지: 재시작 로직 자체가 없으면 작업이 실패한 지점에서 그대로 멈춰, "진행 중"도 "완료"도 아닌 상태로 남는다. 주문 상태가 "결제 완료"와 "배송 준비" 사이 어딘가에 걸려버리는 식이다.
  • 부분 커밋으로 인한 데이터 불일치: 관련 레코드 중 일부만 갱신되어 참조 무결성이 깨지거나, 다운스트림 소비자가 불완전한 데이터를 읽는다.

영상 인코딩 파이프라인을 예로 들면, 여러 단계를 순서대로 실행하다가 3단계에서 프로세스가 죽으면 재시작 시 처음부터 다시 돌아 앞 단계의 부작용(과금, 알림)이 중복된다.

// 버그: 중간 실패 시 처음부터 재실행 -> 이미 끝난 단계의 부작용이 중복된다
async function encodeVideo(jobId) {
  await chargeCustomer(jobId)          // 1단계: 과금
  await transcodeSegments(jobId)       // 2단계: 인코딩
  await notifyUser(jobId)              // 3단계: 알림 발송 (여기서 크래시)
  await markDone(jobId)
}
// 프로세스 재시작 -> encodeVideo(jobId) 재호출 -> 과금이 또 발생한다

방어 기법

  • 원자적 단위로 분해(chunking): 전체 작업을 재시도 가능한 최소 단위로 쪼갠다. 각 단위는 그 자체로 성공/실패를 판정할 수 있어야 한다.
  • 증분 체크포인트(incremental checkpoint): 각 단위가 끝날 때마다 "이 단위는 완료됐다"는 사실을 영속 저장소에 기록한다. 재시작 로직은 체크포인트를 조회해 완료된 단위는 건너뛰고 그다음부터 이어서 실행한다.
  • 완료 판정과 실제 산출물을 함께 검증: 체크포인트가 "완료"라고 기록했어도 실제 산출물(파일, 레코드)이 존재하는지 별도로 확인한다. 체크포인트 기록 자체가 partial write의 희생양이 될 수 있기 때문이다.
  • 작업 단위의 순서 독립성 확보: 가능하면 각 단위가 서로 독립적으로 재실행 가능하게 설계해, 재개 시 순서 문제로 인한 추가 실패를 줄인다.
// 방어: 단계마다 체크포인트를 남기고, 재개 시 완료된 단계는 건너뛴다.
// 체크포인트 기록 자체도 partial write에 당할 수 있으므로 "완료" 표시만 믿지 않고
// 실제 산출물(artifact)이 있는지 별도로 검증한다.
async function encodeVideo(jobId) {
  const checkpoint = await loadCheckpoint(jobId)  // 마지막 완료 단계 조회

  if (!(await isStageDone(jobId, STAGE.CHARGED, checkpoint))) {
    await chargeCustomer(jobId)
    await saveCheckpoint(jobId, STAGE.CHARGED)
  }
  if (!(await isStageDone(jobId, STAGE.TRANSCODED, checkpoint))) {
    await transcodeSegments(jobId)
    await saveCheckpoint(jobId, STAGE.TRANSCODED)
  }
  if (!(await isStageDone(jobId, STAGE.NOTIFIED, checkpoint))) {
    await notifyUser(jobId)
    await saveCheckpoint(jobId, STAGE.NOTIFIED)
  }
  await markDone(jobId)
}

// 체크포인트가 "완료"라고 말해도 실제 산출물이 없을 수 있고(체크포인트 기록 직전 크래시),
// 반대로 체크포인트는 못 남겼는데 산출물은 이미 만들어졌을 수도 있다(기록 직후 크래시).
// 두 경우 모두 실제 산출물 조회로 판정한다.
async function isStageDone(jobId, stageId, checkpoint) {
  if (checkpoint.stage >= stageId) return true  // 체크포인트상 완료

  const artifact = await fetchArtifact(jobId, stageId)
  if (artifact) {
    // 산출물은 있는데 체크포인트가 못 따라간 경우 -> 기록만 보정하고 재실행은 건너뛴다
    await saveCheckpoint(jobId, stageId)
    return true
  }
  return false  // 산출물도 없음 -> 실제로 미완료, 실행해야 한다
}
// 3단계에서 죽어도 재시작 시 CHARGED, TRANSCODED는 건너뛰고 NOTIFIED부터 재개한다

다만 체크포인트만으로는 부족하다. chargeCustomer(jobId)를 재호출했을 때 그 함수 자체가 "같은 jobId로는 한 번만 과금한다"는 계약을 지키지 않으면, 체크포인트 기록이 살짝 어긋나는 순간(체크포인트는 못 남겼는데 과금 API 호출은 성공한 경우) 여전히 중복 과금이 난다. 이 계약이 바로 다음 절의 idempotency다.

Idempotency: 재시도를 안전하게 만드는 계약

정의

멱등성(Idempotency)은 동일한 요청 또는 작업을 한 번 수행하든 N번 반복 수행하든 관측 가능한 결과와 부작용이 동일해야 한다는 성질이다. 수학의 멱등 연산(f(f(x)) = f(x))에서 따온 개념으로, 분산 시스템·네트워크 통신에서는 "같은 요청이 여러 번 도착해도 실제 효과는 한 번만 일어난 것과 같아야 한다"는 의미로 쓰인다.

실제 증상 (멱등성이 없을 때)

  • 재시도 로직에서의 부작용 중복: 클라이언트가 요청을 보냈지만 응답을 받기 전에 타임아웃이 나면, 클라이언트는 "실패했다"고 판단해 같은 요청을 재전송한다. 하지만 서버는 이미 첫 요청을 처리했을 수 있다. 멱등성이 없으면 결제가 두 번 청구되거나, 동일 리소스가 중복 생성되거나, 알림이 중복 발송된다.
  • 최소 1회(at-least-once) 전달의 필연적 결과: 메시지 큐나 이벤트 스트리밍처럼 "최소 1회 전달"을 보장하는 시스템(정확히 1회 전달은 대개 매우 비싸거나 불가능하다)에서는 동일 메시지의 중복 수신이 정상 동작이다. 소비자 측이 멱등적이지 않으면 반드시 문제가 생긴다.

결제 API를 예로 들면, 클라이언트 재시도가 서버 쪽 중복 처리로 그대로 이어지는 경우다.

// 버그: 재시도가 그대로 중복 청구로 이어진다
async function charge(cardToken, amountCents) {
  const result = await paymentGateway.charge(cardToken, amountCents)
  return result
}
// 클라이언트: 3초 타임아웃 -> "실패"로 간주 -> charge()를 다시 호출
// 실제로는 첫 호출이 게이트웨이에서 이미 성공했을 수 있다 -> 이중 청구

방어 기법

  • 완료 마커 / sentinel: 각 요청·작업 단위를 식별하는 고유 키(요청 ID, idempotency key)를 두고, 처리가 끝나면 "이 키는 이미 처리 완료"라는 sentinel을 영속 저장소에 기록한다.
  • 재시도 시 sentinel 우선 확인: 요청이 재시도되면 실제 로직을 다시 실행하기 전에 먼저 sentinel을 조회한다. 완료 표시가 있으면 재실행하지 않고 이전에 저장해둔 결과를 그대로 반환한다.
  • idempotency key 패턴: 클라이언트가 요청 생성 시 고유 키를 발급해 헤더나 바디에 포함시키고, 서버는 그 키로 처리 이력을 조회해 중복을 판별한다. 결제 API에서 흔한 패턴이다.
  • 자연히 멱등적인 연산 설계: 가능하면 연산 자체를 멱등적으로 만든다. "잔액에 100 더하기" 대신 "잔액을 최종값 X로 설정", "레코드 추가" 대신 "없으면 추가(upsert)".
// 방어: idempotency key를 먼저 "예약"해 동시 요청까지 안전하게 처리한다.
// findByIdempotencyKey -> charge -> saveIdempotencyRecord를 순서대로 호출하는 것만으로는
// 두 요청이 findByIdempotencyKey를 동시에 통과해버릴 수 있어(atomic하지 않음) 여전히 이중 청구가 난다.
async function charge(idempotencyKey, cardToken, amountCents) {
  // 1) unique 제약(idempotencyKey UNIQUE)으로 pending 레코드를 원자적으로 예약한다.
  //    두 요청이 동시에 들어와도 INSERT는 단 하나만 성공한다.
  let record
  try {
    record = await db.insertPendingRecord(idempotencyKey, { status: "PENDING" })
  } catch (err) {
    if (err.code === "UNIQUE_VIOLATION") {
      // 동시에 도착한 두 번째 요청 -> 첫 번째 요청이 끝날 때까지 기다렸다가 같은 결과를 재사용한다
      return await waitForCompletion(idempotencyKey)
    }
    throw err
  }

  if (record.status === "COMPLETED") {
    return record.result   // 이미 끝난 재시도 -> 재실행하지 않고 그대로 반환
  }

  // 2) provider 쪽에도 같은 키를 전달해, DB 예약이 실패하는 상황에서도
  //    게이트웨이 자체의 idempotency key 처리로 중복 청구를 한 번 더 막는다.
  const result = await paymentGateway.charge(cardToken, amountCents, {
    idempotencyKey
  })

  // 3) 실행 결과와 상태 전이를 하나의 트랜잭션(또는 조건부 갱신)으로 묶어
  //    "청구는 끝났는데 기록은 못 남은" 상태가 남지 않게 한다.
  await db.transaction(async (tx) => {
    await tx.completeRecord(idempotencyKey, {
      expectedStatus: "PENDING",
      newStatus: "COMPLETED",
      result
    })
  })

  return result
}
// 두 요청이 동시에 도착해도 하나만 INSERT(예약)에 성공해 실제 청구를 수행하고,
// 나머지는 UNIQUE 제약에 걸려 완료를 기다렸다가 같은 result를 반환한다 -> 실제 청구는 정확히 한 번

세 가지를 함께 방어하는 법

세 개념은 따로 고치면 서로의 빈틈을 채우지 못한다. Partial write를 체크포인트로만 막으면, 재개 로직 자체가 멱등적이지 않은 한 체크포인트 기록과 실제 부작용 사이의 미세한 간격에서 여전히 중복이 생긴다. 반대로 idempotency key만 도입하고 체크포인트가 없으면, 어느 단계까지 끝났는지 알 방법이 없어 처음부터 전체를 재실행하게 된다. 그리고 여러 워커가 동시에 같은 작업을 재개하려는 순간, 멱등성과 체크포인트만으로는 부족하고 race condition 방어(버전 비교, 락)까지 함께 필요하다.

실무에서는 워크플로우를 설계할 때 이 셋을 한 세트로 넣는다.

  • 체크포인트(partial write 방어): 각 단계가 끝날 때마다 진행 상태를 영속 저장소에 기록해, 어디서 재개할지 알 수 있게 한다.
  • idempotency key(재시도 안전성): 각 단계의 부작용(과금, 알림, 리소스 생성)을 고유 키에 묶어, 같은 단계가 두 번 실행돼도 실제 효과는 한 번만 일어나게 한다.
  • 버전 번호(race condition 방어): 체크포인트 레코드 자체에 버전을 붙여, 여러 워커가 동시에 같은 작업을 재개하려 할 때 하나만 성공하고 나머지는 최신 상태를 다시 읽고 물러나게 한다.

세 가지를 합치면 영상 인코딩 파이프라인의 재개 로직은 이렇게 정리된다.

// 체크포인트 + idempotency key + 버전 관리를 함께 적용한 재개 로직
async function resumeJob(jobId, workerId) {
  const { checkpoint, version } = await db.read(jobId)

  // 1) 버전 비교(CAS)로 다른 워커의 동시 재개를 막는다 (race condition 방어)
  const claimed = await db.compareAndSwap(jobId, {
    expectedVersion: version,
    newOwner: workerId,
    newVersion: version + 1
  })
  if (!claimed) {
    return  // 다른 워커가 먼저 점유에 성공했다 -> 조용히 물러난다
  }

  // 2) 체크포인트를 기준으로 미완료 단계부터 이어서 실행 (partial write 방어)
  for (const stage of remainingStages(checkpoint)) {
    // 3) 각 단계는 idempotency key로 중복 부작용을 막는다 (idempotency 방어)
    const idempotencyKey = `${jobId}:${stage.name}`
    await runStageIdempotently(jobId, idempotencyKey, stage)
  }

  await markDone(jobId)
}

// idempotency key로 재실행 여부를 판단하고, 성공 시 "결과 저장 + 체크포인트 갱신"을
// 하나의 트랜잭션으로 묶어 둘 사이에 어중간한 상태가 남지 않게 한다.
async function runStageIdempotently(jobId, idempotencyKey, stage) {
  const existing = await db.findByIdempotencyKey(idempotencyKey)
  if (existing?.status === "COMPLETED") {
    return existing.result   // 이미 끝난 단계 -> 재실행하지 않는다
  }

  const result = await stage.run(jobId)

  // idempotency 레코드 저장과 체크포인트 갱신을 한 트랜잭션으로 커밋한다.
  // 트랜잭션이 실패하면 둘 다 반영되지 않으므로 재시도 시 stage.run()부터 다시 시도해도 안전하다.
  await db.transaction(async (tx) => {
    await tx.saveIdempotencyRecord(idempotencyKey, { status: "COMPLETED", result })
    await saveCheckpoint(tx, jobId, stage.name)
  })

  return result
}

// 체크포인트 레코드 자체도 버전을 가진다: 갱신 직전 버전과 다르면(다른 워커가 끼어들었거나
// 재시도가 겹친 경우) CAS 실패로 감지해 예외를 던진다 -> 상위에서 최신 상태로 재시도한다.
async function saveCheckpoint(tx, jobId, stageName) {
  const { version } = await tx.read(jobId)
  const updated = await tx.compareAndSwap(jobId, {
    expectedVersion: version,
    newStage: stageName,
    newVersion: version + 1
  })
  if (!updated) {
    throw new Error(`checkpoint version conflict: ${jobId} at stage ${stageName}`)
  }
}

이 글을 읽고 나서 확인해야 할 것은 하나다. 지금 다루는 재시도·재시작 로직이 이 세 방어를 각각 하나씩 갖추고 있는가. 체크포인트 없이 재시도만 있으면 처음부터 다시 돈다. idempotency 없이 체크포인트만 있으면 그 경계에서 여전히 중복이 생긴다. 버전 관리 없이 둘만 있으면 여러 워커가 동시에 재개할 때 다시 경쟁 상태로 돌아간다. 세 가지가 한 세트로 있어야 장애 복구가 새로운 버그를 만들지 않는다.

이 세 가지를 실제 장애에서 어떻게 좁혀 고쳤는지는 프로그래밍 카테고리에도 더 있다. 재시도·재시작 로직에서 겪은 구체적인 실패 사례를 함께 보면 방어가 왜 한 세트로 필요한지 더 분명해진다.