외부 API에 오래 걸리는 작업을 맡기는 구조는 대부분 이렇게 시작합니다.
pending 행을 만들고 크레딧을 차감한다taskId를 받는다success로, 실패하면 failed로 바꾸고 크레딧을 환불한다저는 이미지를 영상으로 바꾸는 서비스를 만들고 있고, 렌더링에 1~5분이 걸립니다. 위 구조는 잘 동작했습니다. 사용자가 탭을 끝까지 열어두는 동안에는요.
문제는 3번과 4번 사이에 있습니다. 상태를 전이시키는 주체가 클라이언트뿐이라면, 클라이언트가 사라지는 순간 그 행은 영원히 pending으로 남습니다. 실패했어도 환불이 일어나지 않습니다. 사용자는 돈을 냈고, 결과물은 없고, 아무도 그 사실을 모릅니다.
탭은 생각보다 자주 닫힙니다. 결제하러 Stripe Checkout으로 이동하는 것만으로도 폴링은 끊깁니다.
이 글은 그 뒤처리를 만들면서 여섯 번 고쳐 쓴 기록입니다.
가장 먼저 필요한 건 서버 쪽 정리 작업(sweep)입니다. pending으로 남아 있는 고아 행들을 주기적으로 훑어서, 외부 API에 실제 상태를 물어보고 마무리 짓는 코드입니다.
중요한 건 호출 지점을 하나로 두지 않는 것입니다. 저는 세 군데서 같은 함수를 부릅니다.
Cron 하나에만 의존하면 주기 사이의 공백이 그대로 사용자 대기 시간이 됩니다. 반대로 사용자 요청에만 의존하면 다시는 돌아오지 않는 사용자의 행이 영원히 남습니다. 둘 다 필요합니다.
호출 지점이 셋이면 당연히 동시에 실행됩니다. 크론이 도는 중에 사용자가 페이지를 열 수 있습니다. 환불이 두 번 나가면 안 됩니다.
락을 잡거나 큐를 두는 대신, 상태 전이 자체를 조건부 UPDATE 한 줄로 만들었습니다.
const updated = await db
.update(generations)
.set({ status: "failed", error: userMessage, completedAt: new Date() })
.where(
and(eq(generations.taskId, taskId), eq(generations.status, "pending")),
)
.returning({ userId: ..., creditsCharged: ... });
if (updated.length === 0) return; // 이미 다른 쪽이 처리했다
// 여기까지 온 호출자만 환불을 수행한다
WHERE status = 'pending'이 핵심입니다. 두 프로세스가 같은 행에 동시에 들어와도, 실제로 행을 바꾸는 쪽은 하나뿐이고 나머지는 빈 배열을 받습니다. "내가 이 작업을 선점했는가"를 UPDATE의 반환값으로 판단하는 것이죠.
덕분에 정리 작업을 몇 개든 마음 놓고 병렬로 돌릴 수 있습니다. 분산 락 없이 얻는 멱등성치고는 값이 싼 편입니다.
여기에 환불 원장(ledger)에 gen-failed:{taskId} 형태의 참조 키와 유니크 인덱스를 걸어두면, 정상 경로와 정리 경로가 서로에 대해서도 멱등해집니다.
정리 작업이 너무 부지런하면 클라이언트 폴링과 싸웁니다. 그래서 생성된 지 2분이 지난 행만 대상으로 삼습니다.
const DEFAULT_OLDER_THAN_MS = 2 * 60 * 1000;
정리 작업의 역할은 정상 경로를 대체하는 게 아니라 정상 경로가 사라졌을 때 뒤를 받는 것입니다. 이 유예가 없으면 멀쩡히 폴링 중인 작업까지 건드리게 됩니다.
외부 API가 state: "success"를 돌려줬는데 결과 URL을 못 꺼내는 경우가 있습니다. 결과 URL이 모델마다 다른 필드명으로 오기 때문에, 새 모델을 붙이면 파서가 조용히 실패합니다.
이때 success + result_url = NULL로 행을 닫으면 최악입니다.
failed가 아니니 환불도 안 됨성공도 실패도 아닌 채로 돈만 묶입니다. 그래서 이 상태는 유예 후 실패로 확정하고 환불합니다.
const SUCCESS_NO_URL_REFUND_AFTER_MS = 30 * 60 * 1000; // 30분
유예 기준 시각을 무엇으로 잡느냐도 한 번 틀렸습니다. 처음에는 행 생성 시각(createdAt)을 썼는데, 30초짜리 긴 클립은 렌더링에만 몇 분을 쓰기 때문에 유예 창을 생성 시간이 거의 다 먹어버립니다. 지금은 외부 API가 알려주는 완료 시각을 우선 사용하고, 그 값이 밀리초 epoch처럼 보이지 않으면(초 단위로 주는 API도 있습니다) 믿지 않고 무시합니다.
const completeMs =
typeof info.completeTime === "number" && info.completeTime > 1e12
? info.completeTime
: null;
const ageMs = Date.now() - (completeMs ?? new Date(gen.createdAt).getTime());
이게 여섯 번째 수정에서야 잡힌, 가장 비쌌던 버그입니다.
정리 작업은 한 번에 최대 50행만 봅니다. 서버리스 환경에서 실행 시간 예산이 있으니 당연한 제한입니다. 그런데 ORDER BY가 없었습니다.
동시에 이런 행들이 있었습니다. 외부 API 기록이 만료됐거나 삭제됐거나, 우리가 모르는 상태 문자열(cancelled, timeout …)에 빠진 행들. 어떤 모델도 하루씩 돌지는 않으니 이건 명백히 죽은 작업인데, 코드는 "아직 실패라고 확신할 수 없다"며 계속 다음 라운드로 미뤘습니다.
두 조건이 만나면 이렇게 됩니다.
죽은 좀비 행이 50개를 채우면, 그 뒤에 생긴 정상 고아 행은 영원히 스윕 창에 들어오지 못합니다.
좀비는 환불되지 않은 채 계속 쌓이고, 정작 정리해줘야 할 신선한 고아들은 순서가 오지 않습니다. 조용히, 로그도 거의 남기지 않고 망가집니다.
고친 방법은 두 가지입니다.
첫째, 정렬을 명시합니다.
.orderBy(asc(generations.createdAt))
.limit(maxRows)
경계가 있는 스윕은 인덱스 순서에 기대지 말고 결정론적으로 밀린 작업을 소화해야 합니다.
둘째, 절대 상한을 둡니다.
const STUCK_PENDING_FAIL_AFTER_MS = 24 * 60 * 60 * 1000;
24시간이 지난 pending 행은 외부 API가 뭐라고 답하든, 심지어 조회 자체가 실패하든 실패로 확정하고 환불합니다. 이후로는 상태 API도 외부가 아니라 우리 DB의 행을 근거로 답하므로, 뒤늦게 도착한 성공 응답이 다시 크레딧을 가져가는 일도 없습니다.
"확신이 설 때까지 재시도"는 안전해 보이지만, 상한이 없으면 그냥 영원히 미결입니다.
반대 방향의 규칙도 필요합니다. 정리 루프의 catch에서는 환불하지 않습니다.
} catch (err) {
result.queryErrors++;
// 외부 상태를 모르는 상태다. 환불하지 말고 다음 라운드에 다시 본다.
}
네트워크 오류로 조회에 실패한 것과 작업이 실패한 것은 다릅니다. 전자에서 환불해버리면, 실제로는 성공해서 영상이 나온 작업까지 공짜가 됩니다. 모를 때의 기본값은 "아무것도 하지 않고 다시 본다" 여야 합니다. 단, 교훈 5의 24시간 상한만은 이 규칙보다 우선합니다. 하루가 지나면 모르는 것도 실패로 취급합니다.
성공으로 확정할 때는 행을 쓰기 전에 결과 파일을 우리 스토리지로 옮깁니다.
외부 API가 주는 URL은 임시 URL이고 며칠 뒤 만료됩니다. 정상 경로에서는 폴링하던 클라이언트 요청이 그 저장을 수행하지만, 정리 대상이 된 행은 정의상 아무도 폴링하지 않는 행입니다. 여기서 저장을 미루면 그 영상은 나중에 확실히 사라집니다.
외부 비동기 작업에는 최소 세 겹의 경계가 필요합니다.
| 겹 | 역할 | 없으면 |
|---|---|---|
| 정상 경로 | 클라이언트 폴링으로 상태 전이 | — |
| 정리 스윕 | 클라이언트가 사라진 행을 마무리 | 탭 닫으면 크레딧 증발 |
| 절대 상한 | 며칠째 미결인 행을 강제 종료 | 좀비가 스윕 창을 점유 |
그리고 이 셋을 안전하게 겹치게 만드는 도구가 조건부 UPDATE 한 줄입니다. 상태 전이를 선점 행위로 바꾸면, 몇 겹을 쌓아도 환불은 정확히 한 번만 나갑니다.
돌아보면 제가 놓친 건 전부 같은 종류였습니다. 정상 경로만 그려놓고 "그 경로가 중간에 사라지면?"을 상태로 취급하지 않은 것입니다. 탭이 닫히는 것도, 결과 URL이 안 오는 것도, 외부 기록이 만료되는 것도 예외 상황이 아니라 그냥 자주 일어나는 상태입니다. 상태로 인정하고 종료 조건을 주면 대부분 해결됩니다.
글쓴이는 사진 한 장을 짧은 영상으로 바꾸는 Stivio를 개인 개발하고 있습니다.
이 글의 내용은 해당 제품의 실제 구현과 여섯 차례의 수정 기록을 바탕으로 합니다.