시스템 디자인SYSTEM DESIGN
학습 시작하기 →
WORKERS & RECOVERY

워커가 죽었는데, 작업은 계속 실행 중이라면?

다시 실행하는 것만으로는 부족합니다. 이전 워커가 늦게 돌아와도 결과를 덮어쓰지 못하게 했습니다.

멈춘 워커의 통로를 닫고 다른 워커로 노란 작업 타일을 인계하는 처리 모형
내용의 흐름을 표현한 개념 이미지

작업 상태는 ‘실행 중’인데 한참을 기다려도 끝나지 않습니다. 워커 프로세스는 이미 재시작됐습니다. 새 워커는 기존 작업이 누군가의 소유라고 생각해 건드리지 않습니다. 프로세스를 다시 띄우는 일과, 멈춘 작업을 다시 진행시키는 일은 다릅니다. 재시작 버튼이 있어도 이 둘이 연결되지 않으면 대기만 길어집니다.

이번 글에서는 작업 하나를 두 워커가 이어받는 순서를 Node.js 상태 모델로 실행했습니다. 앞의 워커가 조용히 멈춘 경우뿐 아니라, 늦게 돌아와 결과를 저장하려는 경우까지 넣었습니다. 제가 더 신경 쓴 쪽은 후자입니다. 다시 실행하는 데 성공했어도, 이전 결과가 나중에 덮어쓰면 복구가 끝난 것이 아니니까요.

1. ‘실행 중’이라는 글자에는 유효기간이 없습니다

가장 단순한 작업 테이블은 pending, running, done 정도의 상태를 갖습니다. 워커가 하나를 가져가 running으로 바꾼 뒤 처리하고 done으로 바꿉니다. 정상 흐름에서는 충분해 보입니다. 그런데 running을 기록한 직후 프로세스가 종료되면 누가 그 상태를 정리할까요?

시작 시각을 보고 오래된 작업을 다시 pending으로 돌리는 방법이 떠오릅니다. 하지만 오래 걸린다고 반드시 죽은 것은 아닙니다. 외부 API가 느리거나 큰 파일을 처리하는 워커는 아직 일하고 있을 수 있습니다. 반대로 프로세스 목록에 살아 있다고 작업이 진행되는 것도 아닙니다. 연결을 기다리며 멈췄을 수도 있습니다.

그래서 상태 옆에 ‘언제까지 이 워커의 소유로 인정할 것인지’를 둡니다. 임대 기간, 즉 lease입니다. 워커는 작업 소유권을 일정 시간 빌리고, 계속 진행할 수 있을 때 연장합니다. 기간이 끝나면 다른 워커가 가져갈 수 있습니다. 죽었다는 사실을 완벽히 증명하기보다, 소유권의 효력이 끝나는 규칙을 정하는 겁니다.

Amazon SQS의 visibility timeout도 처리 중 메시지를 일정 시간 다른 소비자에게 숨겼다가, 삭제되지 않으면 다시 처리할 수 있도록 하는 경계를 제공합니다. 다만 이 시간이 중복 전달 자체를 없애 주는 것은 아닙니다.

2. 소유자 이름만으로는 늦게 온 워커를 막기 어렵습니다

worker-a가 작업을 가져갔습니다. lease가 끝나 worker-b가 이어받았습니다. 이때 worker-a가 뒤늦게 결과를 들고 돌아올 수 있습니다. A의 계산이 틀렸다고 단정할 수는 없습니다. 다만 지금 저장할 권리가 A에게 남아 있느냐가 문제입니다.

워커 이름만 비교하는 것보다, 작업을 가져갈 때마다 세대 번호를 늘리는 방법이 명확합니다. A는 1번 세대, B는 2번 세대를 받습니다. 결과 저장과 heartbeat 연장 모두 현재 소유자와 세대가 일치하고 lease가 남아 있어야 허용합니다. 같은 프로세스 이름이 재사용되더라도 이전 티켓으로는 현재 작업을 바꾸지 못합니다.

  1. A가 작업 확보세대 1 · 만료 시각 기록
  2. A의 연장 중단소유권 인정 기간 종료
  3. B가 원자적으로 확보세대 2 · 새 만료 시각
  4. 현재 세대만 완료A의 늦은 결과는 거절

실제 DB에서는 조회해서 비교한 뒤 무조건 UPDATE하는 두 단계로 끝내면 안 됩니다. 비교와 갱신 사이에 소유자가 바뀔 수 있기 때문입니다. 소유자·세대·만료 조건을 UPDATE의 WHERE절에 넣고, 바뀐 행이 정확히 한 개인지 확인하는 식으로 조건부 갱신해야 합니다. 아래는 그 조건을 보여주는 개념 SQL입니다.

UPDATE jobs
SET state = 'done', result = :result
WHERE id = :job_id
  AND state = 'running'
  AND owner = :owner
  AND generation = :generation
  AND lease_until > :store_now;
-- 변경된 행이 0개라면 현재 소유권으로 저장하지 못한 것입니다.

여기서 store_now는 워커가 제각각 보내는 시각이 아니라, 저장소에서 일관되게 판단하는 시각이어야 합니다. 다른 테이블에 결과를 쓴다면 완료 표시와 결과 저장을 같은 트랜잭션에 묶어야 합니다. 위 UPDATE만 보호하고 앞에서 결과를 이미 덮어썼다면 효과가 없습니다.

3. A의 결과를 일부러 늦게 돌려줬습니다

실행 예제는 벽시계나 sleep 대신 논리 시각을 사용합니다. 0초에 A가 10초짜리 lease를 받고, 5초의 B 요청은 거절됩니다. 11초에는 B가 작업을 가져갑니다. A가 12초에 완료를 시도하면 거절되고, B의 13초 완료만 남습니다. 시간을 기다리지 않아도 매번 같은 순서로 확인할 수 있습니다.

2026-09-09 실행 · Node.js v24.13.1 · 단일 프로세스 상태 모델
검사결과의미
lease 만료 전 B의 확보거절유효한 소유권 유지
만료 뒤 B의 인계시도 횟수 2새 세대로 이어받음
A의 늦은 완료거절이전 세대 결과 차단
최종 저장된 결과worker-b현재 소유자 결과만 반영
이전 세대 heartbeat거절지난 소유권을 되살리지 않음
3회 시도 소진 후 재확보dead 상태무한 재시도 대신 격리

heartbeat를 정상 연장하면 다른 워커가 아직 가져가지 못하는 경우도 검사했습니다. 반대로 lease가 정확히 끝난 시각에는, 아직 새 워커가 오지 않았더라도 기존 워커의 완료를 거절하도록 정했습니다. 만료 뒤 완료를 허용할 것인지는 구현마다 선택이지만, 이 예제의 경계는 명시적으로 닫았습니다.

워커 인계·늦은 완료 실험 받기 · Node.js ↗외부 큐나 DB 없이 소유권, 세대, 만료 조건을 확인합니다.
node worker-recovery.mjs
# staleCompletionRejected: true
# resultOwner: worker-b
# effectsWithoutKey: 2
# effectsWithKey: 1
# assertions: PASS

Map과 동기 메서드로 원자적 상태 전이를 표현했기 때문에 실제 분산 장애 실험은 아닙니다. 운영에 넣기 전에는 DB 잠금, 트랜잭션 격리, 연결 단절, 프로세스 강제 종료를 실제 저장소와 함께 확인해야 합니다. 여기서 얻은 것은 장애 순서별로 무엇을 허용하고 거절해야 하는지에 대한 실행 가능한 기준입니다.

4. 결과 저장을 막아도, 이미 보낸 알림은 돌아오지 않습니다

이 부분이 꽤 중요합니다. 워커가 알림 API 호출에 성공한 뒤, 작업 완료를 기록하기 직전에 죽었다고 해보겠습니다. 다시 시작한 워커는 미완료 작업을 보고 같은 알림을 보냅니다. 작업 테이블에 최종 결과가 한 번만 남아도, 수신자는 두 번 받았을 수 있습니다.

예제에서는 외부 수신자를 함수로 표현했습니다. 같은 작업을 두 번 전달하자 단순 함수의 처리 횟수는 2회였습니다. 작업의 의도를 나타내는 같은 멱등 키를 수신자에게 보내고 수신자가 기존 결과를 돌려주는 경우에는 1회였습니다. 재시도마다 새 키를 만들면 중복을 구분할 수 없으므로, 같은 논리 작업의 재시도는 같은 키를 써야 합니다.

키만 같고 내용이 달라도 같은 성공을 돌려주면 안 됩니다. 첨부 코드에서는 동일 키에 다른 payload가 오면 거절합니다. 실제 API에서도 키의 범위, 요청 내용 비교, 결과 보관 기간을 함께 확인해야 합니다. Amazon Builders’ Library의 멱등 API 설계는 이런 재시도와 호출 의도의 구분을 설명합니다.

Outbox도 같은 관점에서 봐야 합니다. DB 변경과 발송할 이벤트를 함께 기록하는 데는 유용하지만, 이벤트 전달이 한 번뿐이라고 보장하는 장치는 아닙니다. 재전달을 받는 쪽의 멱등 처리를 별도로 설계해야 합니다.

5. 재시도는 횟수보다 이유를 먼저 나눕니다

잠깐의 연결 장애와 잘못된 입력을 같은 간격으로 계속 재시도하면, 고쳐지지 않는 작업이 정상 작업을 밀어냅니다. 입력 검증 실패는 바로 격리할 수 있고, 일시적인 제한이나 네트워크 오류는 지연을 늘려 다시 시도할 수 있습니다. 외부 호출이 성공했는지 알 수 없는 timeout은 결과 조회부터 필요한 경우도 있습니다.

관측한 상황먼저 할 일피해야 할 처리
연결 실패·일시 제한상한 있는 backoff와 편차 적용모든 워커의 즉시 동시 재시도
입력 형식 오류원인과 입력 식별자를 남겨 격리같은 입력을 무한 반복
외부 결과 불명멱등 키로 결과 조회·대사새 키로 무조건 재실행
시도 예산 소진실패 큐·운영 확인 경로 제공삭제하고 성공률에서 제외

lease 길이와 재시도 간격도 별개입니다. lease는 현재 소유권을 언제까지 인정할지 정하고, backoff는 다음 시도를 언제 허용할지 정합니다. lease를 길게만 늘리면 느린 작업의 오인계는 줄어들 수 있지만, 실제로 죽은 작업을 찾는 시간도 길어집니다. heartbeat 주기와 작업 시간 분포를 함께 보아야 합니다.

예제의 10초 lease와 최대 3회는 테스트 경계를 만들기 위한 값입니다. 어떤 서비스에도 그대로 권장하는 설정은 아닙니다. DB 큐를 직접 구성한다면 작업을 가져가는 트랜잭션을 짧게 유지하고, 긴 외부 호출 동안 행 잠금을 계속 잡지 않는지도 확인해야 합니다. PostgreSQL의 SKIP LOCKED 설명은 여러 소비자가 큐 형태의 테이블을 읽을 때 참고할 만하지만, lease와 재시도 정책까지 대신 만들어 주지는 않습니다.

6. 워커가 다시 떴다는 알림만으로는 부족합니다

저라면 복구 대시보드에서 프로세스 수보다 오래된 실행 중 작업을 먼저 보겠습니다. 새 워커가 열 개 떠 있어도 작업이 같은 상태에 묶여 있으면 사용자에게 달라진 것은 없습니다. 가장 오래 대기한 작업의 나이와 lease가 만료된 작업 수가 실제 막힌 곳을 보여줍니다.

  • 작업 확보 직후 종료해도 만료 뒤 다시 가져갈 수 있는지 확인합니다.
  • 결과 저장 직전 종료했을 때 이전 워커의 늦은 완료가 거절되는지 봅니다.
  • 외부 호출 직후 종료했을 때 같은 멱등 키로 재시도하는지 검사합니다.
  • heartbeat 실패와 정상 진행을 구분해 소유권을 연장하는지 봅니다.
  • 격리된 작업에 원인, 시도 횟수, 재실행 가능 여부가 남는지 확인합니다.
  • 늦은 완료 거절 수와 중복 수신 수를 성공 수와 별도로 관측합니다.

실패 큐를 다시 넣을 때도 원인을 고치지 않고 전체 재실행을 누르는 것은 조심해야 합니다. 처리 대상 버전과 멱등 키를 유지할지, 새 작업으로 시작할지 정한 뒤 작은 범위부터 확인하는 편이 낫습니다.

복구는 다시 시작하는 것보다, 누가 끝낼 수 있는지의 문제입니다

이번 모델에서는 워커를 더 늘리지 않았습니다. 소유권에 만료 시각과 세대 번호를 붙였고, 그 조건을 결과 저장에서도 확인했습니다. 그 결과 A가 늦게 돌아와도 B가 이어받은 작업을 덮어쓰지 못했습니다.

작업이 멈췄을 때 재실행부터 생각하는 것은 자연스럽습니다. 다만 “다시 실행해도 되는가?”와 “지금 이 워커가 저장해도 되는가?”를 함께 확인해야 합니다. 거기에 외부 부작용의 멱등 경계까지 맞추면, 재시작은 그제야 복구의 한 단계가 됩니다.