Vercel Serverless Function 타임아웃 해결기
Vercel Serverless Function 타임아웃 해결기
들어가며
요즘은 클라이언트 사이드에서도 AI API를 직접 연동하는 경우가 많아졌다.
간단한 요약이나 분류 정도는 응답이 금방 오기 때문에 별다른 문제가 없다.
하지만 입력 데이터가 커지기 시작하면 이야기가 달라진다.
두꺼운 책 한 권 분량의 텍스트를 넣는다든가, 여러 구간을 순차적으로 AI에게 넘겨야 하는 경우, 모델의 연산 속도에 따라 전체 소요 시간이 수 분에서 십수 분까지 늘어난다.
로컬 환경에서는 그냥 기다리면 되지만 서버리스 환경에서는 그만큼 기다려주지 않는다.
이 글은 AI 퀴즈 자동 생성 서비스를 만들면서, Vercel Serverless Function의 타임아웃에 부딪혀 해결한 과정을 기록한 것이다.
서비스 구조
기존 파이프라인은 다음과 같다.
책 텍스트를 적당한 구간으로 나누고, 구간마다 문제를 생성한 뒤, 생성된 문제를 검수하고 DB에 이를 저장한다.
텍스트 분할 (N개 구간)
→ 구간별 문제 생성 (Claude Sonnet)
→ 구간별 문제 검수 (Claude Opus)
→ DB 저장
→ 완료
이 전체 흐름이 Next.js API Route의 after() 콜백 안에서 실행되고 있었다.
사용자에게는 "생성을 시작했습니다"라는 응답을 먼저 보내고, 백그라운드에서 파이프라인을 돌리는 구조다.
// POST /api/quiz
export const maxDuration = 300 // 5분
export async function POST(request: NextRequest) {
// 퀴즈 레코드 생성 → 즉시 응답 반환
after(async () => {
// 백그라운드에서 전체 파이프라인 실행
const questions = await generateQuizWithClaude(...) // Sonnet × N구간
const verified = await verifyQuizWithClaude(...) // Opus × N구간
await saveToDatabase(verified)
})
}
응답은 바로 가고, 무거운 작업은 뒤에서 처리되니 얼핏 합리적인 구조로 보인다.
증상
퀴즈를 생성하면 진행 상태가 "생성중 → 검증중 → 완료"로 바뀌어야 하는데, "검증중"에서 영원히 넘어가지 않는 현상이 발생했다.
DB를 직접 조회해보니 verifying 상태로 멈춘 퀴즈가 3개 있었다.
| 퀴즈 | 구간 수 | 멈춘 위치 |
|---|---|---|
| 두꺼운 책1 | 20개 | 구간 4/20 검수 중 |
| 두꺼운 책2 | 20개 | 구간 8/20 검수 중 |
| 두꺼운 책3 | 8개 | 구간 8/8 검수 중 |
그런데 구간이 적은 짧은 텍스트(3~4개 구간)는 정상적으로 완료가 되고 있었다.
여기서 패턴을 확인할 수 있었는데, 구간이 많으면 실패하고 적으면 성공한다는 것이다.
실패를 좌우하는 요소는 바로 소요시간이었다.
원인 분석
Vercel Serverless Function의 실행 시간 제한
Vercel에 배포된 Next.js API Route는 Serverless Function으로 실행된다.
maxDuration으로 최대 실행 시간을 설정할 수 있지만, 플랜별로 상한이 정해져 있다.
| Vercel 플랜 | 최대 maxDuration |
|---|---|
| Hobby | 60초 |
| Pro | 300초 (5분) |
| Enterprise | 900초 (15분) |
Pro 플랜 기준 최대 5분. 이 이상은 설정으로 해결할 수 없다. 즉, API 요청 후 5분 안에 결과물을 받지 못하면 해당 함수 호출이 죽어버리는 것이다.
after()도 같은 함수 안에서 실행된다
Next.js 15의 after() API는 응답을 먼저 보내고 나머지를 백그라운드에서 실행하는 기능이다.
하지만 이 작업은 같은 Serverless Function 인스턴스 안에서 돌아간다.
즉, maxDuration 제한을 동일하게 받는다.
[Serverless Function 시작]
↓ 응답 반환 (즉시)
↓ after() 콜백 실행 시작
↓ ... 구간별 생성 (Sonnet, ~100초) ...
↓ ... 구간별 검수 (Opus, ~300-600초) ...
💀 5분 타임아웃 → 프로세스 강제 종료
별도 프로세스가 뜨는 것이 아니라, 같은 함수의 수명 안에서 돌아가는 것이다.
소요 시간 계산
[생성 단계 - Sonnet]
구간당 ~5초 × 20구간 = ~100초
[검수 단계 - Opus]
구간당 ~15-30초 × 20구간 = ~300-600초
총 예상: 400-700초 → maxDuration 300초 초과
구간이 적으면(3~4개) 총 소요 시간이 5분 미만이라 성공하고, 구간이 많으면(8개 이상) 실패 확률이 급격히 올라가는 구조였다.
강제 종료 시 에러 핸들링이 불가능하다
이 문제에서 가장 치명적인 부분이다.
타임아웃으로 프로세스가 kill되면, try-catch 블록이 실행되지 않는다.
일반적인 에러라면 catch에서 상태를 failed로 바꿔주면 된다.
하지만 Vercel이 함수를 강제 종료하면 catch도 finally도 실행되지 않는다.
DB에는 마지막으로 업데이트된 verifying 상태가 영구적으로 남게 되고, 사용자는 영원히 "검증중..." 화면을 보게 된다.
타임아웃 kill은 catch할 수 없다. 이것이 이 문제의 핵심이다.
해결: Client-Driven Batching
핵심 아이디어
하나의 긴 Serverless Function 호출 대신, 구간별로 개별 HTTP 요청으로 분리한다.
각 요청은 1개 구간만 처리하므로 타임아웃에 걸리지 않는다.
기존: after() → 생성(20구간) → 검수(20구간) → 💀 타임아웃
개선:
요청 1: 텍스트 분석 + 중요도 평가 → ~10초 ✅
요청 2: 구간 1 출제 → ~30초 ✅
요청 3: 구간 2 출제 → ~30초 ✅
...
요청 21: 구간 20 출제 → ~30초 ✅
요청 22: 전체 문제 검수 → ~30초 ✅
구현
프론트엔드가 while 루프로 생성 과정을 주도한다.
한 번 호출할 때마다 1단계만 처리하고 돌아오는 구조다.
// 프론트엔드
const driveGeneration = async () => {
while (!abortRef.current) {
const { data } = await fetch(`/api/quiz/${quizId}/process`, {
method: 'POST',
}).then(r => r.json())
if (data.progress) setProgressText(data.progress)
if (data.done) break
}
}
백엔드는 현재 진행 상태를 DB에서 읽어 다음 할 일을 판단한다.
// POST /api/quiz/[quizId]/process
export const maxDuration = 120
export async function POST(request, { params }) {
const genParams = quiz.generation_params
if (!genParams.chunksTotal) {
// 아직 분석 안 됨 → 분석 후 return
}
if (genParams.chunksGenerated < genParams.chunksTotal) {
// 다음 구간 1개 생성 후 return
}
// 전부 생성 완료 → 검수 후 return { done: true }
}
이 패턴의 장점
- 타임아웃 해소: 각 요청이 30~60초이므로
maxDuration: 120으로 충분하다. - 자동 재개: 브라우저를 닫았다 열어도 DB의
chunksGenerated값부터 이어서 재개된다. - 실시간 진행률: 구간이 완료될 때마다 UI가 즉시 업데이트된다.
- 에러 복구: 한 구간이 실패해도 해당 구간만 재시도하면 되므로, 전체를 다시 시작할 필요가 없다.
별도 인프라(큐, 워커) 없이 프론트엔드의 while 루프만으로 긴 파이프라인을 안전하게 실행할 수 있다는 점에서, 서버리스 환경에서는 실용적인 패턴이다.
추가 개선: 모델 역할 재배치
기존에는 Sonnet(빠름)으로 출제하고, Opus(느리지만 정확함)로 검수하고 있었다.
하지만 역할을 바꾸는 것이 더 합리적이라고 판단했다.
| 기존 | 변경 | |
|---|---|---|
| 문제 출제 | Sonnet | Opus |
| 문제 검수 | Opus | Sonnet |
문제 출제는 창작이다. 텍스트를 깊이 이해하고 좋은 선지를 구성해야 하므로 더 뛰어난 모델이 필요하다. 반면 검수는 확인 작업이다. 정답이 맞는지, 문제가 명확한지 체크하는 것이므로 빠른 모델이면 충분하다.
client-driven batching 덕분에 Opus가 구간당 30초 걸려도 문제없다.
비싼 모델은 가치 있는 곳에 쓰는 것이 비용 대비 품질 최적화에 맞다.
교훈
이번 과정에서 얻은 것들을 정리하면 다음과 같다.
서버리스 환경에서 긴 작업은 반드시 분할해야 한다.
after()든 백그라운드 워커든, 실행 시간 상한이 존재한다.
N개 항목을 순차 처리하는 작업이라면, "입력 크기에 비례해서 시간이 늘어나는가?"를 반드시 확인해야 한다.
타임아웃 kill은 catch할 수 없다.
프로세스가 강제 종료되면 정리 코드가 실행되지 않는다.
"실패 시 catch에서 처리하면 되겠지"라는 전제는 서버리스에서 통하지 않으며, stuck 상태가 만들어지는 것은 순식간이다.
client-driven batching은 서버리스와 궁합이 좋다.
큐도 워커도 필요 없이, 프론트엔드의 루프 하나로 긴 파이프라인을 안정적으로 실행할 수 있다.
별도 인프라 없이 문제를 해결할 수 있다는 점에서 개인 프로젝트나 소규모 서비스에 특히 유용한 패턴이다.
comments
loading…