DevInsight

개발의 판단과 맥락을 기록하는 곳

Backend
조회 2약 6분 읽기

Vercel Functions 크론 잡, 매번 딱 한 건만 처리하도록 설계한 이유

Next.js API Route를 Vercel Functions 크론 잡으로 운영할 때 외부 API 호출로 인한 타임아웃을 피하려면, 동시에 여러 건을 처리하려는 욕심을 버려야 한다. 최대 1건 처리, 멱등키 기반 재시도, 중복 실행 방지, 로그 기반 관측 전략을 소개한다.

DevInsight 편집팀 발행

AI 보조 초안과 편집 검수를 거쳐 발행했습니다.

#Next.js#Vercel Functions#Cron Job#Serverless#Timeout#Retry#Idempotency#OpenAI#Supabase#Observability

Vercel Functions에서 크론 작업을 짤 때 가장 먼저 부딪히는 건 "이걸 왜 이렇게 설계했지?"라는 의문이 아니라, "10초짜리 함수 하나에 모든 외부 API 호출을 다 밀어넣는 게 왜 안 될까?"라는 착각이다. 한 번에 전부 처리하고 싶어지는 건 당연한 욕심이다. 대기열 테이블에서 미처리 레코드를 50개 꺼내고, 각각 OpenAI API로 텍스트를 생성하고, 그 결과를 Supabase에 다시 써넣고, 완료 표시까지 찍는 하나의 API Route. 로컬에선 잘 돌았다. 배포하고 나면 타임아웃이다.

크론 작업이 실행되는 Vercel Functions는 빌링 플랜에 따라 10초(Hobby), 60초(Pro, 기본값), 최대 800초(Fluid Compute)까지만 살아 있다. OpenAI의 GPT-4o로 긴 프롬프트 하나 처리하는 데 2040초는 기본이고, Supabase upsert나 select에 네트워크 왕복이 더해지면 한 건당 평균 35초는 금방 사라진다. 50건을 순차 처리하면 최소 150초. Pro 플랜 60초 안에 절대 끝날 수 없는 구조다. 게다가 Vercel Cron은 지정된 시간에 HTTP GET 요청을 단 한 번 보내는 스케줄러일 뿐이다. 직전 실행이 끝나지 않았는데 다음 틱이 도래하면 인스턴스가 하나 더 뜨고, 같은 레코드를 두 번 처리하는 지옥이 시작된다.

Fluid Compute로 800초까지 늘리거나 Extended Max Duration 베타로 30분까지 설정하면 단기적으론 해결된다. 하지만 이건 시간을 번 거지, 설계를 고친 게 아니다. 트래픽이 늘거나 처리할 레코드가 500건이 되는 순간 똑같은 문제가 반복된다. 근본 해결은 매 실행마다 정확히 한 건만 처리하겠다고 못을 박는 것이다.

"한 건만 하고 끝내겠습니다"라는 약속

크론 핸들러가 실행될 때마다 대기열에서 단 하나의 레코드만 꺼내 처리하고 즉시 종료한다. 실행 빈도는 크론 표현식으로 조정한다. 예컨대 */2 * * * *(2분마다)로 설정하면 1시간에 30건, 하루에 720건까지 처리할 수 있다. 이 방식의 장점은 여러 가지인데, 정작 중요한 건 단순함이 아니라 실패의 폭발 반경이 1건으로 제한된다는 점이다.

// app/api/cron/process-next/route.ts
import { NextResponse } from "next/server";

export const maxDuration = 30; // Pro 플랜에서는 여유 있게

export async function GET(req: Request) {
  const userAgent = req.headers.get("user-agent");
  if (userAgent !== "vercel-cron/1.0") {
    return new NextResponse("Not Found", { status: 404 });
  }

  const { data: item, error } = await supabase
    .from("pending_tasks")
    .select("id, payload")
    .eq("status", "pending")
    .order("created_at", { ascending: true })
    .limit(1)
    .single();

  if (!item || error) {
    return NextResponse.json({ processed: 0, reason: "no_pending" });
  }

  const lockKey = `task:${item.id}`;
  const { data: locked } = await supabase
    .rpc("try_acquire_lock", { p_key: lockKey, p_ttl_seconds: 120 });

  if (!locked) {
    return NextResponse.json({ processed: 0, reason: "locked" });
  }

  try {
    await processItem(item);
    await supabase
      .from("pending_tasks")
      .update({ status: "completed", completed_at: new Date().toISOString() })
      .eq("id", item.id);
  } finally {
    await supabase.rpc("release_lock", { p_key: lockKey });
  }

  return NextResponse.json({ processed: 1, task_id: item.id });
}

maxDuration은 한 건을 처리하는 데 필요한 시간보다 살짝 길게 잡는다. OpenAI 호출에 평균 25초 걸린다면 30초로 설정한다. 혹시라도 이 한 건이 30초를 넘기면 Vercel이 함수를 죽이겠지만, 다른 49건은 전혀 영향을 받지 않는다. 실패한 한 건은 status가 pending으로 남아 있으므로 다음 틱에서 자연스럽게 재처리된다.

락은 왜 두 겹으로 걸어야 하는가

위 코드에서 이미 Postgres advisory lock(try_acquire_lock)을 썼는데, 첫 단계에서 status = 'pending'인 레코드를 single()로 가져오는 것만으로 충분하지 않냐고 생각할 수 있다. 충분하지 않다.

Vercel Cron은 "best-effort delivery"다. 네트워크 hiccup이 발생하면 Vercel이 같은 시간 윈도우에 요청을 재시도할 수 있다. 또 배포 직후에 크론이 이중 실행되는 현상도 다수 보고되었다. 두 인스턴스가 동시에 같은 pending 레코드를 select해 가는 건 시간 차 문제라 막을 수 없다. advisory lock은 데이터베이스 수준에서 원자적으로 한쪽만 통과시키는 마지막 방어선이다.

Postgres advisory lock 대신 Upstash Redis의 SET NX EX를 쓸 수도 있다. 다만 Redis는 외부 서비스라 네트워크 지연이 추가되고, 인스턴스가 죽으면 모든 락이 날아간다는 점을 감안해야 한다. Supabase Postgres를 이미 쓰고 있다면 pg_try_advisory_lock 함수를 rpc로 감싸 쓰는 게 가장 가볍다.

create or replace function try_acquire_lock(p_key text, p_ttl_seconds int) returns boolean language plpgsql as $$ begin return pg_try_advisory_lock(hashtext(p_key)); end; $$; create or replace function release_lock(p_key text) returns boolean language plpgsql as $$ begin return pg_advisory_unlock(hashtext(p_key)); end; $$;

주의할 점 하나. advisory lock은 DB 세션 단위다. Supabase의 트랜잭션 풀링 모드에서는 세션이 공유되므로 예측 불가능한 동작이 나올 수 있다. Session 모드로 연결하거나, 별도 테이블에 고유 제약을 거는 방식으로 대체해야 한다.

한 건 처리에도 실패하면 어떻게 되나 — 멱등키 기반 재시도

OpenAI API가 429를 뱉거나 Supabase 연결이 순간적으로 끊기는 건 드문 일이 아니다. 한 건만 처리하는 구조에선 실패가 곧 그 건의 재시도 여부로 직결된다. 실패를 감지하는 순간 무턱대고 다시 시도하면 안 된다. 처리 중이던 레코드의 일부 사이드 이펙트가 이미 적용됐을 수 있기 때문이다.

가령 OpenAI에 텍스트 생성을 요청해서 응답은 받았는데, Supabase에 업데이트 치는 순간 타임아웃이 났다고 하자. 다음 틱에서 같은 레코드를 다시 pending 상태로 발견하고 OpenAI를 또 호출하면 토큰 비용이 이중으로 나가고, Supabase에는 중복 레코드가 쌓인다.

이걸 막으려면 외부 API 호출마다 멱등키를 심어야 한다. OpenAI API 자체는 멱등키를 지원하지 않으므로, 결과를 받는 즉시 DB에 기록하고 그 기록의 존재 여부로 재처리 여부를 판단한다.

async function processItem(item: TaskItem) {
  const existing = await supabase
    .from("task_results")
    .select("id")
    .eq("task_id", item.id)
    .maybeSingle();

  if (existing) return; // 이미 처리 완료

  const openaiResponse = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: item.payload.prompt }],
  });

  await supabase.from("task_results").insert({
    task_id: item.id,
    result: openaiResponse.choices[0].message.content,
    input_tokens: openaiResponse.usage?.prompt_tokens,
    output_tokens: openaiResponse.usage?.completion_tokens,
  });
}

task_results 테이블에 task_id로 unique constraint를 걸어두면 같은 작업에 대한 결과가 두 번 insert되는 걸 DB 레벨에서 막을 수 있다. processItem이 중간에 죽어도, 다음 실행에서 existing 체크가 걸려서 OpenAPI 호출은 스킵되고 insert만 재시도된다. 여기서 중요한 건 maybeSingle()이 null이면 그제야 OpenAI를 부르고, null이 아니면 insert만 시도한다는 흐름이다. insert도 이미 성공한 상태에서 재시도됐다면 unique constraint 위반으로 실패하겠지만, 그건 이미 데이터가 있으니 문제가 아니다. try-catch로 감싸서 조용히 넘기면 된다.

Supabase의 경우 멱등키가 필요한 mutate 작업(UPSERT, INSERT)은 RPC로 한 번에 묶지 말고, 가능하면 개별 호출로 풀어서 각 호출 전에 완료 여부를 확인하는 습관을 들여야 한다. "한 번에 다 처리하는" RPC는 서버리스 환경에서 가장 위험한 패턴이다.

실행 내역을 남기지 않으면 관측 불가능한 블랙박스가 된다

매 실행마다 딱 1건을 처리하는 구조는 실패를 고립시키는 데 탁월하지만, 역으로 "아무것도 처리되지 않고 있는 상황"을 놓치기 쉽다. 크론이 정상적으로 실행되고 있는지, 왜 pending이 계속 쌓이는지 알려면 최소한의 실행 로그를 DB에 남겨야 한다.

const runLog = {
  started_at: new Date().toISOString(),
  task_id: item?.id ?? null,
  outcome: item ? "processed" : "no_pending",
  duration_ms: Date.now() - startTime,
  error: null as string | null,
};

실패했을 땐 outcome: "failed"와 함께 error.message를 기록한다. 이 로그를 기반으로 다음과 같은 쿼리가 가능해진다.

  • 지난 1시간 동안 몇 건이 처리되었는가
  • 최근 10회 실행 중 실패율이 몇 퍼센트인가
  • 특정 task_id가 몇 번 재시도되었는가

단순히 console.log에 찍는 것과 달리, DB에 남긴 로그는 Vercel의 로그 보존 기간(기본 1시간, Pro 3일)과 무관하게 영구히 조회할 수 있다. cron_run_logs 같은 테이블 하나만 추가하면 된다. 여기에 task_id 인덱스를 걸어두면 특정 작업의 전체 생애 주기를 한눈에 추적할 수 있다.

한 걸음 더 나아가, 연속 3회 실패 시 Discord/Slack 웹훅으로 알림을 보내는 회로도 붙여두는 게 좋다. 크론이 조용히 망가지는 걸 가장 늦게 발견하는 사람은 항상 그 기능에 의존하는 사용자다.

동시 실행이 절대 일어나면 안 되는 이유

앞서 advisory lock으로 동시 실행을 막는다고 했지만, 크론 구조 자체로 중복 실행 가능성을 줄이는 것도 중요하다. Vercel Cron은 다이나믹 라우트를 지원하므로, 하나의 /api/cron/process 엔드포인트를 여러 크론 항목이 공유하는 게 가능하다. 절대 그러면 안 된다.

크론마다 전용 경로를 분리한다: /api/cron/process-text, /api/cron/process-image, /api/cron/process-embedding. 각 경로는 자신이 담당하는 작업 큐만 바라본다. 이렇게 분리하면 두 크론이 동시에 실행되어도 서로 다른 테이블 혹은 다른 status 조건을 보므로 충돌 가능성이 기하급수적으로 줄어든다.

vercel.json에서는 다음과 같이 분리해서 정의한다.

{ "crons": [ { "path": "/api/cron/process-text", "schedule": "*/2 * * * *" }, { "path": "/api/cron/process-image", "schedule": "*/5 * * * *" }, { "path": "/api/cron/process-embedding", "schedule": "*/1 * * * *" } ] }

공통 유틸리티는 별도 모듈로 빼고, 각 라우트 핸들러는 15줄 이내로 유지한다. 핸들러가 비대해지면 누군가 "그냥 여기서 두 건 처리하자"는 유혹을 이기지 못한다.

크론 접근 보호와 Prefetch 함정

크론 엔드포인트는 공개 URL이다. user-agent: vercel-cron/1.0을 확인하는 것만으로는 부족하다. 누구나 curl로 같은 User-Agent를 흉내 낼 수 있다. CRON_SECRET 같은 공유 비밀키를 요구하는 게 기본이다.

const secret = req.headers.get("x-cron-secret");
if (secret !== process.env.CRON_SECRET) {
  return new NextResponse("Not Found", { status: 404 });
}

여기서 401이 아니라 404를 반환하는 건 의도적이다. 401을 반환하면 엔드포인트의 존재 자체를 확인시켜주는 꼴이 된다. 404를 반환하고, 로그에는 경고를 남긴다. Cache-Control: no-store 응답 헤더도 반드시 추가해야 한다.

Next.js의 <Link> prefetch가 크론 경로를 건드리는 사례도 드물게 보고됐다. 브라우저가 미리 가져가면서 작업이 실행되는 일을 막으려면, 크론 경로는 절대 프론트엔드 컴포넌트에서 참조하지 않는다. 디버그용 어드민 페이지에서도 /api/cron/... 경로로 링크를 걸지 말아야 한다.

800초를 줘도 안 되는 건 안 된다

Vercel의 Extended Max Duration 베타로 최대 30분까지 늘릴 수 있게 되었지만, 이걸 크론 작업의 근본 해결책으로 삼으면 안 된다. 더 긴 타임아웃은 더 긴 장애 시간을 의미한다. 30분 동안 뭔가를 처리하다가 실패하면, 그 30분 동안 쌓인 pending 레코드들까지 다 밀리고, 장애 복구 시간도 30분 단위로 늘어난다.

한 건씩 30초 안에 처리하고, 실패 시 30초 후에 재시도하는 구조는 평균 복구 시간(MTTR)을 극단적으로 낮춘다. 1건이 실패해도 2분 후면 다음 시도가 이루어지고, 나머지 대기열은 영향받지 않는다. 이는 서버리스의 특성을 역으로 이용한 설계다. 함수가 언제든 죽을 수 있다는 제약을 받아들이고, 그 위에 죽어도 괜찮은 작은 실행 단위를 쌓아 올리는 것이다.

크론 간격을 조정하는 것도 잊지 말아야 한다. 1분마다 실행하는 크론이 평균 처리 시간 45초짜리 작업을 다루면, 락이 있더라도 두 번째 인스턴스가 계속 "locked" 응답을 받으며 CPU 시간만 소비한다. 실행 간격은 단일 작업 처리 시간의 1.5배 이상으로 잡는 게 합리적이다. 30초 작업이면 1분 간격, 50초 작업이면 2분 간격이 적당하다.

요컨대 "딱 1건"이라는 제약은 단순한 안전장치가 아니라, 서버리스 환경에서의 크론 작업을 설계할 때 출발점으로 삼아야 할 원칙이다. 처리량이 부족하면 실행 빈도를 높이거나, 완전히 분리된 다수의 크론으로 수평 확장한다. 절대 하나의 크론 핸들러가 두 건 이상을 루프로 처리하게 만들지 않는다. 그 순간 모든 실패가 연쇄되기 시작한다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Backend 전체 보기
Backend

앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전

Supabase에서 RLS를 활성화하면 클라이언트 요청과 SQL 에디터 직접 조회의 결과가 달라지는 이유를 권한 체계부터 추적한다. anon 키와 service_role 키의 차이, auth.uid()가 기대한 사용자 ID를 반환하지 않는 조건, 그리고 정책이 충돌할 때 PostgreSQL이 선택하는 동작 방식을 실제 사례 중심으로 분석한다. 마지막으로 RLS 정책을 로컬과 프로덕션에서 안전하게 테스트하는 절차를 정리했다.

#Supabase#RLS#PostgreSQL#Row Level Security
Backend

DB 폴링과 작별할 시간을 아는 기술

DB 폴링만으로 버티던 서비스가 커넥션 풀 고갈과 작업 지연 누적이라는 벽에 부딪히는 순간이 온다. 이 글은 그 신호를 감지하는 구체적인 기준과 함께, Redis/BullMQ 등 메시지 큐 도입 시 실제로 발생하는 consumer 확장·재시도·데드레터 관리 같은 운영 부담을 생략 없이 다룬다. 큐 도입은 해결책이자 새 책임이며, '정말 필요한가'를 판단하는 실용적인 기준을 제시한다.

#메시지 큐#DB 폴링#Redis#비동기 처리

이전 글

LLM이 JSON만 뱉기로 약속했을 때 실제로 일어나는 일

다음 글

Next.js SEO, 아무도 에러를 내지 않는 실패들

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독