Trigger.dev로 백그라운드 작업 정의하기: 태스크, 재시도·타임아웃, 스케줄, 웹훅, Next.js 연동

이 글의 핵심

백그라운드 작업을 직접 구축하려면 큐 브로커, 워커 프로세스, 재시도 정책, 배포 파이프라인을 따로 조합해야 합니다. Trigger.dev가 이 부분을 대신 맡을 때 서버리스 함수와 역할을 어떻게 나눌지, 파이프라인을 태스크로 쪼개는 기준, 멱등 키로 중복 실행을 막는 방법과 빌드 시 흔한 이슈를 정리합니다.

이 글의 핵심

Trigger.dev는 오래 걸리는 작업, 재시도가 필요한 작업, 스케줄이나 외부 이벤트에 반응하는 작업을 API 요청 처리에서 분리해 타입스크립트 코드로 정의하게 해 주는 백그라운드 작업 플랫폼입니다. 실행, 로그, 재시도는 관리형 클라우드나 자체 호스팅 인스턴스가 맡습니다.

이 글은 태스크와 런의 개념, 재시도·타임아웃·큐 설정, 크론과 사용자별 스케줄, 웹훅 처리, Next.js 연동, 그리고 데이터 파이프라인을 태스크로 나누는 방법을 차례로 다룹니다. 예제는 v4 SDK(@trigger.dev/sdk) 기준이며, v3에서는 import 경로가 @trigger.dev/sdk/v3입니다. CLI와 대시보드, 플랜별 제한은 시기에 따라 바뀌므로 운영 전 공식 문서를 함께 확인하시기 바랍니다.


Trigger.dev의 핵심 개념

코드 우선이 의미하는 것

백그라운드 작업을 직접 구축하려면 큐 브로커(Redis, SQS 등), 워커 프로세스, 배포 파이프라인, 재시도 정책을 각각 조합해야 합니다. Trigger.dev는 이 조합을 제품으로 묶어서, 개발자는 태스크 코드와 설정 파일(trigger.config.ts)에 집중하게 합니다. 태스크는 task({ id, run }) 형태로 실행 로직을 한곳에 모으고, 각 실행은 대시보드에서 상태와 로그를 따로 추적할 수 있으며, 페이로드와 반환값의 타입이 트리거하는 쪽까지 이어집니다.

런(Run)과 페이로드(Payload)

태스크가 한 번 실행될 때마다 런(run)이 생성됩니다. 런은 입력 페이로드, 상태, 시도(attempt) 기록, 로그의 묶음입니다. API 라우트에서 tasks.trigger(...)를 호출하면 런이 큐에 들어가고, 호출은 실행 완료를 기다리지 않고 런 id가 담긴 핸들을 곧바로 반환합니다. 사용자 요청 처리와 실제 작업 실행이 분리되는 지점이 여기입니다.

서버리스 함수와의 역할 분담

Vercel Functions나 AWS Lambda 같은 요청-응답 모델은 사용자 인터랙션 처리에 적합하지만, 실행 시간 상한이 있고 실패한 작업을 다시 실행해 주지 않습니다. 대용량 파일 처리, 속도 제한이 있는 외부 API 호출, 여러 단계로 나뉜 파이프라인처럼 실행 시간·재시도·동시성 제어가 핵심인 작업을 요청 밖으로 옮길 때 Trigger.dev 같은 작업 러너가 선택지가 됩니다.


용어: Job, Task, Trigger

일반적으로 “백그라운드 잡”이라고 부르는 것을 Trigger.dev v3 이후 SDK에서는 task로 정의합니다.

일반적인 표현Trigger.dev에서의 대응
백그라운드 잡(job)task({ ... })로 정의한 태스크
잡 실행 1회런(run)
잡 정의의 이름태스크 id

태스크 id는 프로젝트 안에서 고유해야 하며, 대시보드 검색과 tasks.trigger("task-id", payload) 호출에 그대로 쓰입니다.

트리거는 태스크를 시작하는 경로를 뜻합니다. 애플리케이션 코드에서 helloWorld.trigger(...)나 tasks.trigger("hello-world", payload)로 직접 실행할 수도 있고, 크론 같은 시간 기반 스케줄로 실행할 수도 있으며, Stripe나 GitHub 웹훅을 받은 API 라우트가 트리거할 수도 있습니다. 개발 중에는 대시보드에서 페이로드를 넣어 수동으로 실행해 볼 수 있습니다. 태스크는 무엇을 할지, 트리거는 언제 어떤 입력으로 실행할지를 정한다고 나눠서 생각하면 설계가 깔끔해집니다.


태스크 정의와 기본 실행 흐름

최소 예시: Hello World 태스크

CLI로 초기화하면 /trigger 디렉터리와 trigger.config.ts가 생성됩니다. 태스크는 이 디렉터리 안에 정의합니다.

import { task } from "@trigger.dev/sdk";

export const helloWorld = task({
  id: "hello-world",
  run: async (payload: { message: string }) => {
    console.log(payload.message);
    return { ok: true };
  },
});

run은 async 함수입니다. 페이로드와 반환값은 직렬화되어 저장되므로 기본적으로 JSON으로 표현 가능한 값을 쓰는 것이 안전합니다(SDK가 Date 등 일부 타입은 superjson으로 처리하지만, 클래스 인스턴스나 함수는 넘어가지 않습니다).

다른 코드에서 트리거하기

태스크를 import하면 페이로드 타입이 검사됩니다.

import { helloWorld } from "@/trigger/hello-world";

export async function enqueueGreeting() {
  const handle = await helloWorld.trigger({ message: "Hello from app code" });
  return handle.id;
}

handle.id는 런 id입니다. 애플리케이션 로그에 이 값을 함께 남겨 두면 요청과 백그라운드 런을 연결해 추적하기 쉽습니다. 트리거하는 쪽이 Next.js 클라이언트 번들처럼 태스크 코드를 함께 번들하면 안 되는 위치라면, 다음 절의 Route Handler 예시처럼 import type과 tasks.trigger<typeof task>("id", payload)를 쓰면 타입만 가져오고 태스크 구현은 번들에 들어가지 않습니다.

CLI 개발 루프

npx trigger.dev@latest init
npx trigger.dev@latest dev

dev 명령은 /trigger의 변경을 감시하며 태스크를 개발 환경에 등록하고, 트리거된 런을 로컬 머신에서 실행합니다. Next.js 개발 서버와 동시에 띄워 두어야 하므로 concurrently로 두 스크립트를 묶는 방법이 공식 가이드에 소개되어 있습니다.


재시도와 타임아웃

재시도(retry)

run에서 예외가 던져지면 그 시도(attempt)는 실패로 처리되고, 재시도 정책에 따라 다시 실행됩니다. 프로젝트 전체 기본값은 trigger.config.ts의 retries.default에서 정하며(init이 생성하는 설정은 최대 3회), 태스크 단위로 덮어쓸 수 있습니다.

import { task } from "@trigger.dev/sdk";

export const flakyExternalApi = task({
  id: "flaky-external-api",
  retry: {
    maxAttempts: 10,
    factor: 1.8,
    minTimeoutInMs: 500,
    maxTimeoutInMs: 30_000,
    randomize: true,
  },
  run: async (payload: { orderId: string }) => {
    // 외부 API 호출: 일시적 오류는 재시도로 흡수
    return { orderId: payload.orderId, synced: true };
  },
});

지수 백오프는 재시도 간격을 점점 늘려 장애 중인 외부 시스템에 요청이 몰리는 것을 줄이고, randomize(지터)는 동시에 실패한 여러 런이 같은 시각에 재시도하지 않도록 간격을 흩어 놓습니다.

잘못된 API 키, 권한 부족, 형식이 틀린 페이로드처럼 다시 실행해도 결과가 같은 오류는 재시도할 이유가 없습니다. 이런 경우에는 SDK의 AbortTaskRunError를 던지면 남은 재시도 없이 런이 바로 실패로 끝납니다.

import { task, AbortTaskRunError } from "@trigger.dev/sdk";

export const chargeCustomer = task({
  id: "charge-customer",
  run: async (payload: { customerId: string }) => {
    const res = await fetch(`https://api.example.com/charge/${payload.customerId}`, { method: "POST" });
    if (res.status === 401 || res.status === 400) {
      throw new AbortTaskRunError(`재시도 불가 오류: HTTP ${res.status}`);
    }
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // 5xx 등은 재시도
    return { charged: true };
  },
});

maxDuration: 실행 시간 상한

무한히 도는 런은 비용과 동시성 슬롯을 계속 차지하므로 상한을 두는 것이 좋습니다. maxDuration은 초 단위이며, wait.for 같은 대기 중에 체크포인트된 시간은 포함되지 않고 실제로 실행된 시간만 셉니다. 태스크 옵션이나 trigger.config.ts의 전역 기본값으로 지정할 수 있습니다.

export const boundedTask = task({
  id: "bounded-task",
  maxDuration: 300, // 5분
  run: async () => {
    return { finished: true };
  },
});

ttl: 너무 늦게 시작되면 버리는 작업

TTL(time-to-live)은 런이 큐에서 기다릴 수 있는 시간입니다. 그 안에 실행이 시작되지 않으면 런은 만료(expired) 처리되고 실행되지 않습니다. 몇 분 안에 보내지 않으면 의미가 없는 알림이나 유효 기간이 짧은 토큰 처리에 씁니다. TTL은 트리거할 때 옵션으로 지정합니다.

await sendPushNotification.trigger(
  { userId: "user_123", message: "주문이 출발했습니다" },
  { ttl: "10m" }
);

개발 환경에서는 dev 프로세스가 꺼져 있을 때 런이 무한정 쌓이지 않도록 기본 TTL이 적용되어 있습니다.

큐(queue)와 동시성

queue 옵션의 concurrencyLimit는 이 태스크의 런이 동시에 몇 개까지 실행될지를 제한합니다. 외부 API의 요청 한도, DB 커넥션 수, 메모리 사용량에 맞춰 상한을 걸면 트래픽이 몰려도 하위 시스템이 버틸 수 있습니다. 한도를 넘는 런은 실패하지 않고 큐에서 대기합니다.

export const oneAtATime = task({
  id: "one-at-a-time",
  queue: { concurrencyLimit: 1 },
  run: async () => {
    return {};
  },
});

스케줄링

크론 기반 배치

정기 집계, 리포트 생성, 데이터 정리 같은 작업은 schedules.task로 정의합니다. 크론 식은 기본적으로 UTC 기준이며, 타임존을 함께 지정할 수 있습니다.

import { schedules } from "@trigger.dev/sdk";

export const dailyReport = schedules.task({
  id: "daily-report",
  cron: { pattern: "0 9 * * *", timezone: "Asia/Seoul" }, // 매일 오전 9시(KST)
  run: async (payload) => {
    // payload.timestamp: 이번 실행의 예정 시각
    // payload.lastTimestamp: 직전 실행의 예정 시각(첫 실행이면 undefined)
    return { generatedFor: payload.timestamp.toISOString() };
  },
});

스케줄 태스크를 설계할 때는 같은 시각에 대한 실행이 두 번 일어나도 결과가 같은지(멱등성)를 먼저 확인해야 합니다. 재시도나 수동 재실행이 있으면 같은 시각의 런이 다시 돌 수 있기 때문입니다. payload.lastTimestamp를 쓰면 “직전 실행 이후 바뀐 것만” 처리하는 증분 배치를 만들 수 있습니다. 실행 시간이 스케줄 간격보다 길어질 수 있다면 maxDuration과 concurrencyLimit: 1로 겹치는 실행을 막습니다. 서머타임이 있는 타임존은 일 년에 두 번 실행 시각이 건너뛰거나 겹칠 수 있다는 점도 고려해야 합니다.

동적 스케줄(사용자별)

사용자가 알림 시각을 직접 고르는 기능처럼 스케줄이 사용자마다 다르면, 코드에서 크론을 고정하지 않고 SDK로 스케줄을 생성합니다. externalId로 사용자와 스케줄을 연결하고, deduplicationKey로 같은 사용자의 스케줄이 중복 생성되지 않게 합니다.

import { schedules } from "@trigger.dev/sdk";

// cron 없이 정의한 schedules.task를 대상으로 지정
await schedules.create({
  task: userDigest.id,
  cron: "0 8 * * *",
  timezone: "Asia/Seoul",
  externalId: user.id,               // run에서 payload.externalId로 받음
  deduplicationKey: `${user.id}-digest`,
});

웹훅과 이벤트

웹훅 수신 → 태스크 트리거

결제·배송·GitHub 같은 외부 서비스가 HTTP POST로 이벤트를 보내면, Next.js Route Handler(app/api/.../route.ts)가 서명을 검증하고 페이로드를 파싱한 뒤 태스크를 트리거하고 곧바로 200을 반환합니다. 웹훅 응답 시간은 짧게 유지하면서 실제 비즈니스 로직은 재시도와 실행 기록이 있는 태스크로 옮길 수 있습니다.

웹훅 제공자는 응답이 늦거나 실패하면 같은 이벤트를 다시 보내므로, 이벤트 id를 idempotencyKey로 넘겨 같은 이벤트로 런이 두 번 생기지 않게 하는 것이 좋습니다.

await processStripeEvent.trigger(
  { eventId: event.id, type: event.type },
  { idempotencyKey: `stripe-${event.id}` }
);

같은 idempotencyKey로 다시 트리거하면 새 런을 만들지 않고 기존 런의 핸들을 돌려줍니다. 키의 유효 기간은 idempotencyKeyTTL 옵션으로 조정할 수 있습니다.

이벤트를 코드로 모델링하기

애플리케이션 설계에서는 OrderPaid, UserSignedUp 같은 내부 도메인 이벤트와 Stripe의 invoice.paid 같은 외부 스키마를 구분하는 것이 좋습니다. 태스크 페이로드를 내부 도메인 이벤트 형태로 정규화해 두면, 외부 웹훅 스키마가 바뀌어도 웹훅을 받는 어댑터 코드만 수정하면 됩니다.


Next.js 통합

초기화와 환경 변수

Next.js 프로젝트 루트에서 CLI로 초기화하면 trigger.config.ts와 /trigger 예제가 생성됩니다. 앱 코드에서 태스크를 트리거하려면 TRIGGER_SECRET_KEY를 로컬(.env.local)과 배포 환경에 설정해야 합니다. 키는 대시보드의 API Keys 화면에서 환경(dev, prod)별로 확인합니다.

App Router: Route Handler에서 트리거

import type { helloWorldTask } from "@/trigger/example";
import { tasks } from "@trigger.dev/sdk";
import { NextResponse } from "next/server";

export async function POST() {
  const handle = await tasks.trigger<typeof helloWorldTask>("hello-world", "James");
  return NextResponse.json(handle);
}

공식 예시는 GET으로 되어 있지만, 부작용이 있는 작업을 GET에 두면 링크 미리보기나 프리페치만으로도 실행될 수 있으므로 실제 코드에서는 POST를 쓰는 편이 안전합니다. 문서는 Edge 런타임에서도 tasks.trigger를 쓸 수 있다고 안내하지만, 환경 변수 접근 방식 등은 배포 플랫폼마다 다르므로 운영 전에 확인해야 합니다.

Server Actions

서버 액션("use server")에서도 태스크를 트리거할 수 있습니다. 서버 액션은 클라이언트에서 직접 호출 가능한 공개 엔드포인트이므로, 액션 안에서 세션과 권한을 반드시 검증해야 합니다. 그렇지 않으면 누구나 비용이 드는 태스크를 반복 실행시킬 수 있습니다.

빌드/CI에서의 흔한 이슈

공식 가이드에는 CI에서 Next.js 빌드가 Route Handler를 정적으로 생성하려다 SDK가 비밀 키를 요구해 실패하는 사례가 나옵니다. 해당 라우트를 동적으로 지정하면 빌드 시점에 실행되지 않습니다.

export const dynamic = "force-dynamic";

Vercel 환경 변수 동기화

trigger.config.ts의 빌드 확장 syncVercelEnvVars를 쓰면 Vercel 프로젝트의 환경 변수를 Trigger.dev 배포에 자동으로 맞출 수 있습니다. Vercel 액세스 토큰과 프로젝트 ID 설정이 필요합니다.

프런트엔드 실시간 UI

@trigger.dev/react-hooks의 useRealtimeRun 등으로 런 상태를 구독하면 진행률 UI를 만들 수 있습니다. 브라우저에는 비밀 키 대신 해당 런만 읽을 수 있는 Public Access Token을 발급해 넘겨야 합니다.


실전 데이터 처리 파이프라인

이 절은 파일 수집 → 검증 → 변환 → 적재 흐름을 태스크로 나누는 방법을 보여 줍니다. 코드는 구조를 설명하기 위한 것으로, 실제 스키마와 저장소에 맞게 바꿔야 합니다.

파이프라인을 태스크로 쪼개는 기준

단계를 나누는 기준은 실패 패턴과 재시도 정책입니다. 다운로드는 네트워크 오류가 많아 공격적으로 재시도해도 되지만, 스키마 검증 실패는 재시도해도 소용이 없고, DB 적재는 중복 쓰기를 피하기 위해 보수적으로 재시도해야 합니다. 이렇게 성격이 다른 단계를 한 태스크에 넣으면 재시도 정책을 하나로 정할 수 없습니다. 또 단계가 나뉘어 있으면 대시보드에서 어느 단계에서 멈췄는지 바로 보입니다.

오케스트레이션 예시: 부모 태스크가 자식 태스크를 트리거

import { task } from "@trigger.dev/sdk";

export const ingestFile = task({
  id: "ingest-file",
  retry: { maxAttempts: 5, minTimeoutInMs: 1_000, maxTimeoutInMs: 60_000, randomize: true },
  run: async (payload: { url: string; tenantId: string }, { ctx }) => {
    // 1) 다운로드(의사 코드)
    const raw = await downloadText(payload.url);

    // 2) 검증 + 정규화
    const rows = parseAndValidate(raw);

    // 3) 변환 태스크로 위임. 재시도로 이 줄이 다시 실행돼도
    //    같은 런에서 자식이 중복 생성되지 않도록 idempotencyKey 사용
    await transformRows.trigger(
      { tenantId: payload.tenantId, rows },
      { idempotencyKey: `${ctx.run.id}-transform` }
    );

    return { imported: rows.length };
  },
});

export const transformRows = task({
  id: "transform-rows",
  queue: { concurrencyLimit: 4 },
  run: async (payload: { tenantId: string; rows: unknown[] }) => {
    const transformed = payload.rows.map(normalizeRow);
    await loadToWarehouse.trigger({ tenantId: payload.tenantId, rows: transformed });
    return { count: transformed.length };
  },
});

export const loadToWarehouse = task({
  id: "load-to-warehouse",
  retry: { maxAttempts: 8 },
  maxDuration: 900,
  run: async (payload: { tenantId: string; rows: unknown[] }) => {
    // DWH/DB 적재(비즈니스 키 기준 upsert)
    return { loaded: payload.rows.length };
  },
});

async function downloadText(url: string): Promise<string> {
  return "raw";
}
function parseAndValidate(raw: string): unknown[] {
  return [];
}
function normalizeRow(row: unknown): unknown {
  return row;
}

각 단계가 독립적으로 재시도될 수 있도록 경계를 나눈 것이 핵심입니다. 이 예시는 trigger()로 자식을 실행만 시키고 결과를 기다리지 않습니다. 부모가 자식의 결과를 받아 다음 단계를 결정해야 한다면 triggerAndWait()를 쓰고, 이때 부모 런은 기다리는 동안 체크포인트되어 실행 시간이 과금되지 않습니다.

예시처럼 행 전체를 페이로드로 넘기면 데이터가 클 때 페이로드 크기 제한에 걸리거나 저장 비용이 커집니다. 실제로는 파일을 오브젝트 스토리지에 두고 키만 넘기거나, 청크 단위로 나눠 batchTrigger로 여러 런을 만드는 편이 낫습니다. 태스크를 많이 쪼갤수록 운영할 대상도 늘어나므로, 팀과 데이터 규모에 맞게 조정해야 합니다.

flowchart LR
  A[외부 파일/이벤트] --> B[ingest-file]
  B --> C[transform-rows]
  C --> D[load-to-warehouse]
  B -. 실패 시 재시도 .-> B
  C -. 큐 동시성 제한 .-> C
  D -. maxDuration 상한 .-> D

데이터 정합성: 멱등 키와 중복 방지

파이프라인에서 가장 흔한 사고는 같은 입력을 두 번 적재하는 것입니다. 재시도는 같은 런 안에서 run 함수를 처음부터 다시 실행하므로, 적재 도중 실패한 시도가 이미 일부 행을 썼을 수 있습니다. 따라서 트리거 단계의 idempotencyKey(같은 작업으로 런이 두 번 생기는 것 방지)와 저장소 단계의 방어(비즈니스 키 기반 upsert, import 배치 id 기록, 원본 파일 해시로 중복 탐지)를 함께 써야 합니다. 둘 중 하나만으로는 “런 중복”과 “시도 중복”을 모두 막을 수 없습니다.


운영 시 고려할 점

페이로드에는 민감 정보나 대용량 바이너리를 직접 넣지 말고 스토리지 키나 서명 URL을 넣는 것이 좋습니다. 페이로드는 대시보드에 그대로 표시되고 저장되기 때문입니다. 태스크가 쓰는 시크릿은 Trigger.dev 대시보드의 환경 변수에 등록합니다. tenantId, requestId 같은 상관관계 키를 페이로드에 일관되게 넣거나 tags 옵션으로 런에 태그를 달아 두면 대시보드에서 검색하기 쉽습니다. 메모리가 많이 필요한 작업은 machine 옵션으로 더 큰 머신 프리셋을 지정합니다.

트러블슈팅

증상확인할 것
로컬에서 태스크가 실행되지 않음trigger.dev dev 프로세스가 떠 있는지, 태스크가 trigger.config.ts의 dirs 안에 있는지
Next.js 빌드가 비밀 키를 요구함해당 라우트에 dynamic = "force-dynamic" 지정
같은 오류로 재시도가 반복됨영구적인 오류는 AbortTaskRunError로 즉시 중단
스케줄 실행 시각이 기대와 다름크론 기준(기본 UTC)과 timezone 설정, 서머타임
런이 실행되지 않고 Expired 상태큐 대기가 TTL을 넘음, 동시성 한도와 TTL 값 확인

공식 문서


같이 보면 좋은 글