Inngest 완벽 가이드 — 이벤트 기반 백그라운드 작업
이 글의 핵심
Inngest는 이벤트를 받아 서버리스 함수를 실행하고, 스텝 단위로 체크포인트·재시도를 제공하는 백그라운드 작업 플랫폼입니다. 이 글에서는 핵심 개념, 이벤트·함수 정의, 스텝과 재시도, Cron, Next.js·Vercel 통합, 관측성, 이메일 발송 실전 설계를 다룹니다.
이 글의 핵심
Inngest는 애플리케이션이 이벤트를 발행하면, 등록된 함수를 비동기로 실행하고, 각 스텝의 결과를 저장해 실패 시 부분 재시도를 가능하게 하는 이벤트 기반 실행 플랫폼입니다. 서버리스 환경에서 흔한 “요청 시간 안에 끝내야 하는 작업”과 “나중에 확실히 끝내야 하는 작업”을 분리할 때 유용합니다.
본문에서는 Inngest의 아키텍처적 위치, 이벤트·함수 정의, step.run·step.sleep·step.waitForEvent 등 스텝 API와 재시도 정책, Cron 기반 스케줄, Next.js App Router와 Vercel 배포, 개발용 Dev Server와 프로덕션 모니터링, 마지막으로 주문 확인 이메일을 예로 든 실전 설계를 정리합니다.
1. Inngest의 핵심 개념
1.1 문제 정의
HTTP 요청 핸들러 안에서 결제 웹훅 처리, 대량 이메일, 이미지 변환, 외부 API 연동을 모두 끝내려 하면 타임아웃, 부분 실패 시 복구 어려움, 동시성 폭주가 한꺼번에 발생합니다. 전통적으로는 워커 프로세스와 큐를 직접 운영해 해결하지만, 팀 규모가 작을 때는 운영 부담이 큽니다.
Inngest는 이벤트 드리븐 함수와 내구성 있는 스텝 실행을 제품으로 제공해, “큐 인프라 + 워커 루프 + 재시도 상태 저장”을 최소화합니다.
1.2 구성 요소
- 이벤트(Event): 이름(문자열)과 JSON 페이로드로 구성된 발생 사실입니다.
inngest.send()로 발행하거나, 외부 소스(웹훅 등)에서 Inngest로 전달되도록 연결할 수 있습니다. - 함수(Function): 특정 트리거(이벤트 이름, Cron 등)에 반응해 실행되는 비동기 작업 단위입니다. 하나의 함수 안에서 여러 스텝으로 나뉩니다.
- 스텝(Step):
step.run등으로 표시된 블록 단위입니다. 각 스텝은 실행 후 결과가 기록되며, 재시도 시 성공한 스텝은 재실행하지 않는 것이 기본적인 사용 모델입니다. - Inngest Cloud / Dev Server: 이벤트 수신, 함수 스케줄링, 실행 기록 저장을 담당합니다. 로컬에서는 Inngest Dev Server를 띄워 동일한 흐름을 재현합니다.
1.3 다른 도구와의 관계
Temporal·Cadence는 워크플로우 오케스트레이션에 가깝고, RabbitMQ·Kafka는 메시지 전달 인프라에 가깝습니다. Inngest는 “앱 코드에 가까운 서버리스 함수”와 “스텝 단위 체크포인트” 사이에 위치하며, 복잡한 다중 워커 큐 라우팅보다 제품 개발 속도와 Vercel 등 PaaS와의 접착을 강조하는 편입니다.
2. 이벤트와 함수 정의
2.1 클라이언트 생성
애플리케이션 전역에서 재사용할 Inngest 클라이언트를 한 곳에 둡니다.
// src/inngest/client.ts
import { Inngest } from 'inngest';
export const inngest = new Inngest({
id: 'my-app',
name: 'My App',
});
id는 프로젝트를 구분하는 식별자로, 배포 환경별로 분리할지(예: my-app-prod) 팀 규칙을 정하는 것이 좋습니다.
2.2 이벤트 발행
이벤트는 도메인 사실을 표현하는 이름과, 처리에 필요한 최소 데이터를 담습니다.
// 어딘가의 서비스 레이어 (예: 주문 생성 직후)
import { inngest } from '@/inngest/client';
await inngest.send({
name: 'order/created',
data: {
orderId: 'ord_123',
userId: 'usr_456',
totalCents: 19900,
},
});
send는 여러 이벤트 배열도 받을 수 있어, 한 트랜잭션 후 여러 후속 작업을 한 번에 예약하는 패턴이 가능합니다.
2.3 함수 등록: 이벤트 트리거
// src/inngest/functions/order-notifications.ts
import { inngest } from '../client';
export const notifyOrderCreated = inngest.createFunction(
{
id: 'notify-order-created',
name: '주문 생성 알림',
retries: 5,
},
{ event: 'order/created' },
async ({ event, step }) => {
const { orderId, userId } = event.data;
// step 사용은 다음 절에서 상세히
await step.run('load-order', async () => {
/* DB 조회 */
});
return { ok: true, orderId };
},
);
함수 id는 Inngest 측에서 함수를 식별하는 데 쓰이므로, 배포 후 함부로 바꾸면 동일 로직이 새 함수로 인식될 수 있습니다. 리네이밍이 필요하면 마이그레이션 계획을 세우는 것이 안전합니다.
{ event: 'order/created' }는 단일 이벤트 트리거입니다. 여러 이벤트나 복잡한 매칭이 필요하면 문서의 함수 트리거 고급 옵션을 참고합니다.
3. 스텝과 재시도
3.1 step.run: 부작용의 경계
step.run은 이름과 비동기 함수를 받습니다. 스텝이 한 번 성공하면 그 결과가 직렬화되어 저장되고, 함수 실행이 재개될 때 동일 스텝은 저장된 결과를 반환합니다.
const invoice = await step.run('create-invoice', async () => {
const res = await fetch('https://api.billing.example/invoices', {
method: 'POST',
body: JSON.stringify({ orderId: event.data.orderId }),
});
if (!res.ok) throw new Error(`billing ${res.status}`);
return res.json() as Promise<{ invoiceId: string }>;
});
await step.run('send-email', async () => {
// invoice.invoiceId 사용
});
첫 번째 스텝에서 예외가 나면 해당 스텝만 재시도 대상이 되고, 두 번째 스텝은 아직 실행되지 않았습니다. 첫 스텝이 성공한 뒤 두 번째에서 실패하면, 재시도 시 첫 스텝은 건너뛰고 두 번째부터 다시 시도합니다.
주의: step.run 안에서 비결정적인 값(매번 달라지는 랜덤 등)에 의존해 분기하면, 재시도 시나리오에서 예상과 다른 동작이 나올 수 있습니다. 스텝 밖에서 결정해야 할 값은 이전 스텝의 반환값으로 고정하거나, 명시적으로 이벤트 데이터에 넣습니다.
3.2 step.sleep: 지연
await step.sleep('wait-for-cooldown', '24h');
일정 시간 대기 후 다음 스텝으로 진행합니다. 서버리스에서 “긴 setTimeout”을 직접 두는 것과 달리, Inngest가 깨우기 스케줄을 관리합니다.
3.3 step.waitForEvent: 다른 이벤트 대기
결제 승인, 사용자 승인 같은 외부에서 오는 두 번째 이벤트를 기다릴 때 사용합니다. 타임아웃과 매칭 필드(예: orderId)를 지정해, 조건이 맞는 이벤트가 올 때까지 실행을 일시 정지할 수 있습니다.
const paid = await step.waitForEvent('wait-payment', {
event: 'order/paid',
timeout: '3d',
match: 'data.orderId', // 트리거 이벤트와 대기 이벤트의 동일 필드로 매칭
});
if (!paid) {
await step.run('cancel-hold', async () => {
/* 예약 해제 */
});
return { status: 'expired' };
}
복잡한 조건은 CEL 기반 if 표현식(예: event.data.userId == async.data.userId && …)으로 지정할 수 있으며, match와는 동시에 쓸 수 없습니다. 세부 문법은 Inngest 문서의 waitForEvent·Writing expressions를 따르세요.
3.4 재시도 정책
함수 옵션의 retries 외에도, 스텝 내부에서 던진 오류가 재시도 가능한지 판별됩니다. 일부 SDK 버전에서는 커스텀 에러 타입으로 “재시도하지 말 것”을 표현할 수 있습니다. 외부 API가 4xx로 영구 실패를 알리는 경우, 무한 재시도를 막기 위해 조기 종료·데드 레터 처리 전략을 함께 설계합니다.
4. 스케줄링과 Cron
4.1 Cron 트리거
정기 배치(리포트, 정리 작업, 동기화)는 이벤트 대신 Cron으로 트리거합니다.
export const dailyDigest = inngest.createFunction(
{ id: 'daily-digest', name: '일일 요약' },
{ cron: '0 9 * * *' }, // 매일 09:00 (타임존은 Inngest 설정 확인)
async ({ step }) => {
await step.run('aggregate', async () => {
/* 전일 통계 집계 */
});
await step.run('send-digest', async () => {
/* 이메일 발송 */
});
},
);
Cron 표현식은 타임존이 플랫폼 기본(UTC 등)일 수 있으므로, 비즈니스가 특정 지역 시간이면 Inngest 프로젝트 설정과 문서를 반드시 확인합니다.
4.2 이벤트와 Cron의 공존
동일한 작업을 “사용자가 버튼을 눌렀을 때”와 “매일 자동” 모두에서 실행하려면, 내부 로직을 공유 함수로 두고 Cron 함수와 이벤트 함수에서 각각 step.run을 호출하거나, Cron에서 내부 이벤트를 send해 하나의 핸들러로 모으는 패턴도 흔합니다.
5. Next.js·Vercel 통합
5.1 App Router: 단일 엔드포인트
Next.js App Router에서는 serve로 Inngest가 폴링할 엔드포인트를 노출합니다.
// src/app/api/inngest/route.ts
import { serve } from 'inngest/next';
import { inngest } from '@/inngest/client';
import { notifyOrderCreated, dailyDigest } from '@/inngest/functions';
export const maxDuration = 300;
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [notifyOrderCreated, dailyDigest],
});
maxDuration은 Vercel 등에서 서버리스 함수의 최대 실행 시간입니다. Inngest는 스텝마다 체크포인트를 두므로 긴 워크플로우도 가능하지만, 플랫폼 한도를 넘기면 해당 인스턴스는 강제 종료되므로, 한 스텝이 너무 오래 걸리지 않게 쪼개는 것이 중요합니다.
5.2 Vercel 배포와 환경 변수
Vercel 통합을 쓰면 배포 시 함수 동기화와 환경 변수 주입이 단순해집니다. 일반적으로 다음을 설정합니다.
INNGEST_EVENT_KEY: 앱이 Inngest로 이벤트를 보낼 때 인증INNGEST_SIGNING_KEY: Inngest Cloud가 앱의/api/inngest로 요청할 때 서명 검증
로컬에서는 .env.local에 Dev Server가 안내하는 값을 넣고, 프로덕션은 Vercel 대시보드 또는 통합으로 주입합니다.
5.3 의존성과 빌드
inngest 패키지와 함께, 프로젝트의 TypeScript·번들러 설정이 서버 전용 코드를 클라이언트로 끌어오지 않게 주의합니다. Inngest 관련 모듈은 서버 측(Route Handler, Server Actions, 별도 워커)에서만 import하는 것이 안전합니다.
6. 디버깅과 모니터링
6.1 Inngest Dev Server (로컬)
로컬 개발 시 Inngest Dev Server를 실행하면 대시보드에서 이벤트·함수 실행·스텝 로그를 확인할 수 있습니다. 앱의 serve URL(예: http://localhost:3000/api/inngest)을 Dev Server에 등록하는 흐름은 공식 문서의 Quick Start를 따릅니다.
팁: 이벤트가 함수에 매칭되지 않으면 “함수 미등록·이벤트 이름 불일치·앱 URL 불일치”를 우선 의심합니다.
6.2 프로덕션 대시보드
Inngest Cloud에서는 실행별 상태, 재시도 횟수, 스텝 타임라인, 실패 원인을 볼 수 있습니다. 운영 시 알림(Slack 등)과 연동해 실패율 급증을 감지하면 대응이 빨라집니다.
6.3 관측성 보강
애플리케이션 로그에 runId·event.id·functionId를 남기면, Inngest UI의 실행과 로그 시스템을 상호 참조하기 쉽습니다. OpenTelemetry 등과 연동하는 예시는 Inngest 문서의 관측성 섹션을 참고하세요.
7. 실전: 이메일 발송 시스템
주문이 생성되면 환영 이메일과 영수증 이메일을 순서대로 보내되, 외부 SMTP/API 장애 시 일부만 재시도되도록 스텝으로 나눕니다. 여기서는 Resend 같은 HTTP API를 가정합니다.
// src/inngest/functions/order-emails.ts
import { inngest } from '../client';
export const sendOrderEmails = inngest.createFunction(
{
id: 'send-order-emails',
name: '주문 이메일 발송',
retries: 8,
},
{ event: 'order/created' },
async ({ event, step }) => {
const { orderId, userId } = event.data;
const user = await step.run('fetch-user', async () => {
const r = await fetch(`${process.env.API_URL}/users/${userId}`);
if (!r.ok) throw new Error('user fetch failed');
return r.json() as Promise<{ email: string; name: string }>;
});
await step.run('send-welcome', async () => {
const r = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: '[email protected]',
to: user.email,
subject: `${user.name}님, 주문이 접수되었습니다`,
html: `<p>주문번호 ${orderId}</p>`,
}),
});
if (!r.ok) throw new Error(`resend welcome ${r.status}`);
});
await step.run('send-receipt', async () => {
const r = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: '[email protected]',
to: user.email,
subject: `영수증 #${orderId}`,
html: '<p>상세 내역 …</p>',
}),
});
if (!r.ok) throw new Error(`resend receipt ${r.status}`);
});
return { sent: true, orderId };
},
);
7.1 설계 포인트
- 사용자 조회가 실패하면 아직 이메일을 보내지 않은 상태이므로 전체 함수가 재시도됩니다.
- 환영 메일만 성공하고 영수증에서 실패하면, 재시도 시
fetch-user와send-welcome은 스킵되고send-receipt부터 다시 시도됩니다. 동일 사용자에게 환영 메일이 중복 발송되지 않습니다(스텝 시맨틱을 활용한 멱등성). - 실제 서비스에서는 Resend의 idempotency key 헤더나, DB에 “발송 완료” 플래그를 두어 이중 방어를 할 수 있습니다.
7.2 트랜잭션 경계
DB에 주문을 커밋한 직후 inngest.send를 호출하는 패턴이 일반적입니다. DB 커밋 전에 이벤트를 보내면 주문이 없는데 이메일만 가는 불일치가 생길 수 있고, 반대로 커밋 후 전송 실패 시에는 보상 트랜잭션 또는 재전송 큐를 별도로 두어야 합니다. Inngest는 후자(비동기 처리)를 맡기 쉬운 위치에 있습니다.
8. 베스트 프랙티스와 함정
- 이벤트 이름:
domain/action형태(order/created)로 일관되게 유지하고, 변경 시 구버전 이벤트를 병행 수신할지 결정합니다. - 페이로드 크기: 대용량 바이너리는 스토리지 URL만 넘기고, 함수 안에서 스트리밍 처리합니다.
- 시크릿: 함수 코드에 API 키를 하드코딩하지 말고 환경 변수·시크릿 매니저를 사용합니다.
- 테스트: 스텝 로직은 순수 함수로 분리해 단위 테스트하고, 통합 테스트에서는 Dev Server 또는 모킹된 클라이언트로
send·실행을 검증합니다.
9. 정리
Inngest는 이벤트로 백그라운드 작업을 선언하고, 스텝으로 실행을 쪼개 저장하며, Cron으로 정기 작업을 같은 모델에 얹을 수 있는 플랫폼입니다. Next.js Route Handler 한 곳에 serve를 두고 Vercel에 올리면, 서버리스 한계와 운영 복잡도 사이에서 실용적인 균형을 잡기 좋습니다. 이메일 예제에서 보았듯 step.run 경계가 곧 재시도·중복 방지 설계의 기준이 되므로, 처음부터 스텝을 도메인 의미에 맞게 나누는 습관을 들이면 이후 장애 대응이 수월합니다.
배포 전에는 git add, git commit, git push 후 npm run deploy를 실행하는 워크플로를 프로젝트 규칙에 맞춰 유지하시기 바랍니다.