이중 실행을 막았더니, 크래시 한 번에 영원히 잠겼다
백그라운드 잡의 중복 실행을 막으려고 넣은 상태 가드가, 배포 중 프로세스가 죽자 잡을 '생성 중' 상태에 영구히 가둬버렸다. 잠금에는 반드시 풀리는 길이 함께 있어야 한다는 이야기.
핵심 요약
이중 실행을 막는 상태 가드는 프로세스가 SIGKILL·OOM으로 죽는 순간 영구 잠금이 된다. catch도 finally도 실행되지 않기 때문이다. 해법은 잠금과 함께 해제 경로를 배송하는 것 — 일정 시간 진행이 없으면 실패로 전환하는 stale 복구를 넣되, 진행 중 오탐을 막는 liveness 판정과 복구 자체의 경합을 막는 조건부 갱신(CAS)까지 갖춰야 완성이다.
목차
백그라운드 잡의 이중 실행을 막는 가드를 넣었다. 조건부 갱신으로 상태를 확인하는, 교과서적인 코드였다. 리뷰도 통과했고 테스트도 전부 통과했다. 그런데 이 가드에는 배포 한 번으로 기능 전체를 영원히 잠가버릴 수 있는 구멍이 있었다. 하루 뒤에 그걸 발견하고 고친 기록이다.
가드를 넣은 이유
문제의 기능은 관리자 도구의 배치 오디오 생성 잡이다. 잡 하나에 아이템이 수십 개 달려 있고, 아이템마다 외부 음성 합성 API를 호출한다. 호출은 건당 과금이고 한 번에 몇 분씩 걸린다.
이런 잡에서 제일 무서운 건 이중 실행이다. 관리자가 버튼을 두 번 누르거나, 브라우저 탭 두 개에서 같은 잡을 돌리면 같은 오디오를 두 번 만들고 두 번 과금된다. 그래서 시작 지점에 가드를 넣었다. 잡의 상태가 GENERATING이 아닐 때만 GENERATING으로 바꾸고 진행하는, 조건부 갱신(compare-and-set) 한 줄이다.
const claimed = await prisma.job.updateMany({
where: { id: jobId, status: { not: "GENERATING" } },
data: { status: "GENERATING" },
});
if (claimed.count === 0) {
throw new ConflictException("이미 생성 중인 잡입니다");
}
동시에 두 요청이 들어와도 데이터베이스가 한쪽만 통과시킨다. 여기까지는 맞는 코드다. 문제는 이 코드가 절반이라는 데 있다.
크래시는 catch 되지 않는다
생성이 끝나면 상태를 COMPLETED로, 도중에 예외가 나면 FAILED로 바꾼다. try/catch로 감싸두었으니 어떤 에러가 나도 상태는 복구된다, 라고 생각하기 쉽다.
그런데 프로세스가 예외 없이 죽는 경우가 있다. 대표적으로 두 가지다.
- 배포다. 컨테이너 오케스트레이터는 새 버전을 띄우면서 옛 태스크에 종료 신호를 보내고, 유예 시간이 지나면 SIGKILL을 날린다. SIGKILL은 잡을 수 없다. catch도, finally도, 프로세스 종료 훅도 실행되지 않는다.
- OOM이다. 메모리가 넘치면 커널이 프로세스를 그 자리에서 끝낸다. 역시 아무 코드도 실행될 기회가 없다.
몇 분씩 걸리는 잡이라면 생성 도중에 배포가 겹치는 건 시간문제다. 그 순간 무슨 일이 벌어지는지 따라가 보자. 상태는 GENERATING인 채로 남는다. 관리자가 다시 생성 버튼을 누른다. 가드가 본다. 상태가 GENERATING이네? 이미 실행 중이군. 거부. 한 시간 뒤에 눌러도 거부. 다음 날 눌러도 거부. 실제로 실행 중인 프로세스는 어디에도 없는데, 데이터베이스에 남은 상태 하나가 모든 재시도를 막는다.
이중 실행을 막으려고 건 자물쇠인데, 열쇠를 쥔 프로세스가 죽어버린 것이다. 이 상태에 이름을 붙이자면 crash-wedge다. 크래시 한 번에 쐐기가 박혀 빠지지 않는다.
발견 경위: 형제 코드 세 곳에는 있었다
이 구멍은 장애로 발견한 게 아니라 코드를 다시 훑다가 발견했다. 같은 코드베이스에 비슷한 가드를 쓰는 기능이 세 개 더 있었는데(음성 전사, 자산 파이프라인, CDN 워밍업) 세 곳 모두에 있는 코드가 이 잡에만 없었다. 오래 멈춘 잡을 실패로 되돌리는 stale 복구다.
패턴을 절반만 복사한 셈이다. 가드는 눈에 잘 띈다. 중복 실행이라는 명확한 공포가 있고, 코드도 한 줄이라 리뷰에서 "이중 실행 막았나요?"라는 질문에 바로 답이 된다. 반면 해제 경로는 문제가 터지기 전까지 없다는 사실 자체가 보이지 않는다. 잠그는 코드와 푸는 코드가 별개 커밋, 별개 관심사로 취급되는 순간 이런 비대칭이 생긴다.
해법: 스스로 풀리는 잠금
고치는 방향은 정해져 있다. 잠금에 수명을 준다. 분산 잠금에서 lease라고 부르는 그 개념이다. 조회 경로에 복구 로직을 하나 얹었다. 잡을 읽을 때 상태가 GENERATING인데 마지막 진행 흔적이 30분보다 오래됐으면, 잡을 FAILED로 아이템을 ERROR로 전환한다. FAILED는 가드를 다시 통과할 수 있는 상태라, 이 전환이 곧 잠금 해제다.
말로 하면 한 문장인데, 제대로 만들려니 세 가지 함정이 더 있었다.
오래 걸리는 것과 죽은 것을 구별하기
아이템이 많은 잡은 정상적으로도 30분을 넘길 수 있다. 잡의 시작 시각만 보고 판단하면 열심히 돌고 있는 대형 잡을 죽었다고 오판해서 실패 처리해버린다.
다행히 진행 중인 잡은 아이템 하나를 끝낼 때마다 그 아이템의 갱신 시각을 건드린다. 그래서 살아있음의 기준을 잡의 갱신 시각과 아이템들의 최신 갱신 시각 중 더 최근 값으로 잡았다. 아이템 하나가 재시도까지 포함해 최악 3분이니, 30분 동안 아무 갱신이 없다는 건 어떤 정상 실행에서도 나올 수 없는 신호다. 타임아웃은 잡 전체 시간이 아니라 진행 신호 사이의 간격에 걸어야 한다.
복구 코드끼리, 그리고 진짜 워커와 경합하기
복구를 조회 경로에 넣었으니 여러 요청이 동시에 복구를 시도할 수 있다. 더 고약한 경우도 있다. 죽은 줄 알았던 워커가 사실 살아 있어서, 내가 "오래됐네"라고 읽은 직후에 잡을 COMPLETED로 끝내버리는 경우다. 그 위에 무조건 FAILED를 덮어쓰면 멀쩡히 완료된 잡을 실패로 만든다.
그래서 복구 자체도 가드와 같은 조건부 갱신으로 쓴다. "상태가 여전히 GENERATING이고 갱신 시각이 여전히 기준보다 오래된 행만" 바꾼다.
const recovered = await prisma.job.updateMany({
where: {
id: jobId,
status: "GENERATING",
updated_at: { lt: staleThreshold },
},
data: { status: "FAILED", error_message: "auto-recovered" },
});
// count가 0이면 그 사이 워커가 정리한 것 - 아무것도 하지 않는다
갱신된 행 수가 0이면 읽기와 쓰기 사이에 누군가 먼저 처리한 것이니 조용히 물러난다. 잠금을 도입하며 배운 기법을 잠금을 푸는 데에도 똑같이 쓰는 셈이다.
크론 대신 조회 경로에서
복구를 주기적 크론으로 돌릴 수도 있었다. 대신 잡을 조회하는 순간에 검사하는 쪽을 골랐다. 고착된 잡이 문제가 되는 순간은 결국 누군가 그 잡을 보러 왔을 때다. 보는 사람이 없는 잡을 부지런히 청소할 필요는 없고, 크론이라는 인프라 한 조각과 그것대로의 실패 모드를 추가로 짊어질 이유도 없었다.
여기까지 갖추고 테스트 다섯 개를 붙였다. 고착 잡이 복구되는지, 잡은 끝났는데 아이템만 고착된 경우를 따로 복구하는지, 방금 시작한 잡을 건드리지 않는지, 오래됐지만 아이템이 갱신 중인 잡을 살려두는지, 복구 경합에서 물러나는지.
잠금을 걸 때 물어야 할 세 가지
이 패턴은 이 잡만의 문제가 아니다. TTL 없이 잡은 Redis 잠금, 트랜잭션 밖에서 세운 is_processing 불리언 플래그, 해제 큐가 없는 advisory lock, 전부 같은 계열이다. 무언가를 잠그는 코드를 쓸 때마다 세 가지를 물으면 이 함정을 피할 수 있다.
- 잠근 주체가 예고 없이 죽으면 누가 푸는가? catch와 finally는 답이 아니다. SIGKILL과 OOM은 코드를 실행할 기회를 주지 않는다.
- 살아있는 것과 죽은 것을 무엇으로 구별하는가? 시작 시각이 아니라 마지막 진행 신호를 봐야 한다.
- 해제 로직 자체의 경합은 처리했는가? 복구도 결국 동시성 코드다. 잠글 때 쓴 조건부 갱신을 풀 때도 써야 한다.
한 문장으로 줄이면 이렇다. 잠금을 추가하는 커밋에는 잠금이 풀리는 경로가 함께 들어 있어야 한다. 가드만 있고 해제가 없는 코드는 아직 절반이고, 그 절반은 언젠가 배포 타이밍이 맞아떨어지는 날 나머지 절반을 요구한다.
자주 묻는 질문
백그라운드 잡이 '처리 중' 상태에서 멈춰서 다시 실행되지 않아요. 왜 그런가요?
잡을 시작할 때 상태를 '처리 중'으로 바꾸고, 같은 상태면 재실행을 막는 가드가 있는 경우가 많습니다. 처리 도중 프로세스가 SIGKILL이나 OOM으로 죽으면 상태를 되돌리는 코드가 실행되지 못해 잡이 그 상태에 영구히 갇힙니다. 일정 시간 진행이 없으면 실패 상태로 전환하는 stale 복구를 넣어야 합니다.
stale 복구의 타임아웃은 어떻게 정하나요?
잡 전체 시간이 아니라 '진행 신호 사이의 최대 간격'을 기준으로 잡습니다. 아이템 단위로 상태를 갱신한다면 아이템 하나의 최악 처리 시간(재시도 포함)보다 충분히 길게 잡으면, 오래 걸리는 큰 잡도 오탐하지 않습니다.
관련 글
- 💻 개발
지우면 끝날 문제를, 나는 오래 붙들고 있었다
certificate pinning 버그로 앱이 죽었을 때, 지우면 십 분이면 끝날 일을 살린 채 고치려다 몇 시간을 썼습니다. 장애 앞에서 복구보다 완벽한 수정을 택했던 날의 회고.
- 💻 개발
AI가 코드를 짜주는 시대, Git은 왜 더 중요해졌나
AI가 코드를 다 써주는데 Git을 배워야 하나요? 순서가 거꾸로다. AI가 코드를 쓰기 때문에 Git이 필요하다. 거의 모든 코드를 AI와 함께 쓰는 1인 개발자가 정리한, AI 시대에 Git·GitHub와 친해져야 하는 네 가지 이유.
- 💻 개발
Next.js로 MDX 블로그 직접 만들기 - 이 블로그를 만든 방법
CMS 없이 마크다운 파일만으로 굴러가는 블로그를 Next.js 16 App Router 로 만든 과정. 폴더 구조, MDX 설정, SEO 까지 실제 코드 흐름으로 정리했습니다.