Inngest와 Next.js: 완전 가이드 (2026)
Source: Dev.to
Serverless 함수에는 실행 시간 제한이 있습니다. Vercel Pro에서는 60초를 제공합니다. 이 정도 시간으로는 환영 이메일 시퀀스를 전송하거나, 업로드된 CSV를 처리하거나, 여러 외부 API 호출이 필요한 워크플로를 실행하기에 충분하지 않습니다.
Inngest는 Next.js API 라우트를 신뢰할 수 있고 재시도 가능한 관찰 가능한 백그라운드 작업으로 변환함으로써 이 문제를 해결합니다—Redis 없이, 워커 프로세스 없이, 별도의 큐 인프라 없이 가능합니다.
전체 가이드와 모든 코드 예제는 stacknotice.com에서 확인하세요.
How It Works
함수(functions)를 정의하고, 이벤트(events)에 반응하도록 합니다. 이벤트가 발생하면 Inngest가 HTTP를 통해 함수를 호출합니다. 함수가 실패하면 Inngest가 재시도합니다. 함수에 여러 단계가 있다면 Inngest가 각 단계를 체크포인트하여 중간에 실패해도 처음부터 다시 시작하지 않습니다.
Setup
npm install inngest
// lib/inngest/client.ts
import { Inngest } from 'inngest'
export const inngest = new Inngest({ id: 'my-app' })
// app/api/inngest/route.ts
import { serve } from 'inngest/next'
import { inngest } from '@/lib/inngest/client'
import { welcomeSequence } from '@/lib/inngest/functions/welcome'
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [welcomeSequence],
})
Welcome Email Sequence
export const welcomeSequence = inngest.createFunction(
{ id: 'welcome-email-sequence', retries: 3 },
{ event: 'user/signed-up' },
async ({ event, step }) => {
const { userId, email, name } = event.data
// 각 단계는 독립적으로 재시도됩니다
await step.run('send-welcome-email', async () => {
await resend.emails.send({
from: 'hello@yourapp.com',
to: email,
subject: `Welcome, ${name}!`,
html: `
Thanks for signing up...
`,
})
})
await step.sleep('wait-2-days', '2 days')
await step.run('send-tips-email', async () => {
await resend.emails.send({
from: 'hello@yourapp.com',
to: email,
subject: 'Top 5 tips to get the most out of the app',
html: `
Here are the tips...
`,
})
})
await step.sleep('wait-5-more-days', '5 days')
const user = await step.run('check-user-plan', async () => {
return db.query.users.findFirst({ where: eq(users.id, userId) })
})
if (user?.plan === 'free') {
await step.run('send-upgrade-email', async () => {
await resend.emails.send({
from: 'hello@yourapp.com',
to: email,
subject: "You've been on the free plan for a week...",
html: `
Here's what Pro unlocks...
`,
})
})
}
}
)
// Route Handler에서 트리거 — `inngest.send()`는 즉시 반환됩니다
await inngest.send({
name: 'user/signed-up',
data: { userId: user.id, email, name },
})
Steps: The Core Primitive
step.run()은 체크포인트가 됩니다 — 뒤쪽 단계가 실패해도 앞쪽 단계는 다시 실행되지 않습니다:
export const processUpload = inngest.createFunction(
{ id: 'process-csv-upload', retries: 2 },
{ event: 'file/uploaded' },
async ({ event, step }) => {
const rows = await step.run('parse-csv', async () => {
const response = await fetch(event.data.fileUrl)
return parseCSV(await response.text())
})
const { valid, invalid } = await step.run('validate-rows', async () => {
return validateCSVRows(rows)
})
await step.run('insert-to-database', async () => {
const batches = chunk(valid, 100)
for (const batch of batches) {
await db.insert(contacts).values(batch).onConflictDoNothing()
}
})
await step.run('notify-user', async () => {
await sendNotification(event.data.userId, {
message: `Import complete: ${valid.length} added, ${invalid.length} skipped`,
})
})
}
)
Fan-Out: Parallel Steps
const [salesReport, usageReport, churnReport] = await Promise.all([
step.run('generate-sales', () => buildSalesReport(month, year)),
step.run('generate-usage', () => buildUsageReport(month, year)),
step.run('generate-churn', () => buildChurnReport(month, year)),
])
// 동시에 실행 — 세 작업이 모두 끝날 때까지 기다렸다가 다음으로 진행
Cron Jobs
export const weeklyDigest = inngest.createFunction(
{ id: 'weekly-digest', retries: 2 },
{ cron: '0 9 * * MON' }, // 매주 월요일 UTC 09:00
async ({ step }) => {
const users = await step.run('get-users', async () => {
return db.query.users.findMany({ where: eq(users.weeklyDigest, true) })
})
// 대규모 리스트는 개별 사용자 이벤트로 팬아웃
await inngest.send(
users.map((user) => ({
name: 'digest/send-to-user',
data: { userId: user.id },
}))
)
return { queued: users.length }
}
)
waitForEvent: Human-in-the-Loop
함수를 일시 정지하고 외부 이벤트가 발생할 때까지 기다립니다:
export const approvalWorkflow = inngest.createFunction(
{ id: 'content-approval-workflow' },
{ event: 'content/submitted' },
async ({ event, step }) => {
await step.run('notify-reviewer', async () => {
await sendReviewRequest(event.data.reviewerId, event.data.contentId)
})
// 승인 대기 – 최대 48시간
const approval = await step.waitForEvent('wait-for-approval', {
event: 'content/reviewed',
match: 'data.contentId',
timeout: '48h',
})
if (!approval) {
await step.run('escalate', () => escalateToAdmin(event.data.contentId))
return { status: 'escalated' }
}
if (approval.data.approved) {
await step.run('publish', () => publishContent(event.data.contentId))
return { status: 'published' }
}
}
)
Type-Safe Events
type Events = {
'user/signed-up': {
data: { userId: string; email: string; name: string; plan: 'free' | 'pro' }
}
'file/uploaded': {
data: { fileUrl: string; userId: string; fileName: string }
}
}
export const inngest = new Inngest({
id: 'my-app',
schemas: new EventSchemas().fromRecord(),
})
// 이제 inngest.send()는 이벤트 이름과 데이터 형태를 타입 검사합니다
Error Handling
import { NonRetriableError } from 'inngest'
// 재시도 가능한 오류 — 그냥 throw
throw new Error('External API failed')
// 재시도 불가능한 오류 — NonRetriableError 사용
throw new NonRetriableError('Invalid email — will not retry')
Inngest vs Trigger.dev
| Feature | Inngest | Trigger.dev |
|---|---|---|
| Step functions | step.run() | task() |
| waitForEvent | ✅ Built-in | Manual polling |
| Self‑host | ❌ | ✅ Open-source |
| Free tier | 50k runs/month | 50k runs/month |
Human-in-the-loop 워크플로는 Inngest를, 자체 호스팅이 필요하면 Trigger.dev를 선택하세요.
Local Dev
npx inngest-cli@latest dev
시각화 대시보드는 http://localhost:8288에서 확인할 수 있으며, 여기서 테스트 이벤트를 트리거할 수 있습니다.
