Worker는 제품이 아니다: Vercel·Supabase·QStash로 이해하는 비동기 처리

서버리스 프로젝트에 비동기 처리를 추가하려고 하면 서비스 구성이 갑자기 복잡해 보인다. Vercel, Supabase, Upstash까지 연결했는데 Worker라는 이름이 하나 더 등장하기 때문이다.

여기서 가장 중요한 점은 Worker가 별도 제품의 이름이 아니라는 것이다. Worker는 백그라운드에서 코드를 실행하는 역할을 뜻한다. 지금 사용 중인 Vercel이 웹 화면과 API뿐 아니라 Worker 코드까지 실행할 수 있다.

따라서 작은 프로젝트에서는 네 번째 클라우드 서비스를 추가할 필요가 없다.

세 서비스만으로 만든 Worker 구조

주문 후처리를 예로 들면 전체 구조는 다음과 같다.

사용자
│
▼
Vercel 주문 API ──────> Supabase
│ 주문 저장
▼
Upstash QStash
│
▼
Vercel Worker API ────> Supabase
처리 결과 저장

서비스별 역할은 명확하다.

서비스 | 역할
Vercel | 화면, 주문 API, Worker API 실행
Supabase | 상품·주문·처리 상태 영구 저장
Upstash QStash | 작업 보관, 전달, 실패 시 재시도

QStash는 작업 코드를 직접 실행하지 않는다. 작업을 받을 HTTPS 주소로 메시지를 전달한다. Vercel의 /api/jobs가 이 요청을 받아 후처리 코드를 실행하면 그 함수가 바로 Worker다.

주문 하나가 처리되는 순서

동기식 구조에서는 주문 저장과 모든 후처리가 끝난 뒤에야 사용자에게 응답할 수 있다.

주문 저장 → 이메일 발송 → 통계 반영 → 완료 응답

이메일 서비스가 느려지면 주문 화면도 함께 느려진다. 비동기 구조에서는 사용자가 기다려야 하는 일과 나중에 처리해도 되는 일을 분리한다.

1. 주문 API가 Supabase에 주문을 PENDING으로 저장한다.
2. 주문 API가 QStash에 order.created 작업을 등록한다.
3. 사용자에게 주문 접수 응답을 반환한다.
4. QStash가 Vercel의 Worker API를 호출한다.
5. Worker가 이메일, 로그, 통계 같은 후처리를 실행한다.
6. Supabase의 processedAt 또는 상태를 COMPLETED로 변경한다.

재고 확인과 결제처럼 주문 성공 여부를 결정하는 핵심 작업은 사용자 요청 안에서 확정해야 한다. 이메일, 알림, 보고서처럼 잠시 늦어져도 주문 자체가 깨지지 않는 작업을 Worker로 보내는 편이 안전하다.

코드는 어떻게 나누면 좋을까

Next.js App Router 프로젝트라면 다음 정도로 충분하다.

src/app/api/orders/route.ts 사용자 주문 접수
src/app/api/jobs/route.ts QStash가 호출하는 Worker 엔드포인트
src/lib/queue.ts QStash에 작업을 등록하는 경계
src/lib/jobs.ts 실제 후처리 로직

route.ts에는 HTTP 요청을 받고 응답하는 코드만 두고, 실제 작업은 jobs.ts에 분리한다. 이렇게 하면 실행 장소가 바뀌어도 핵심 로직을 다시 작성할 필요가 없다.

처음에는 Vercel 함수가 jobs.ts를 호출한다. 작업이 커지면 같은 로직을 컨테이너에서 호출하도록 옮길 수 있다.

Worker가 별도 서버 없이 실행되는 이유

Vercel 함수는 요청이 들어올 때 실행되고 작업이 끝나면 반환된다. 개발자가 상시 실행 프로세스를 직접 운영하지 않아도 된다. QStash가 필요할 때 Worker 주소를 호출하므로, 작은 프로젝트에 계속 대기하는 Worker 컨테이너가 없어도 된다.

현재 Vercel 공식 문서에 따르면 Fluid Compute가 활성화된 Hobby 플랜의 함수 실행 시간은 기본값과 최대값 모두 300초다. 몇 초에서 몇 분 안에 끝나는 주문 후처리 실험에는 충분한 범위다. 실행 시간이 길어지면 함수가 끝날 때까지 기다리게 만들기보다 작업을 더 작은 단계로 나누는 것이 먼저다.

반드시 지켜야 할 두 가지

QStash 요청인지 검증한다

/api/jobs가 공개 URL이면 아무나 호출할 수 있어서는 안 된다. QStash가 요청에 담아 보내는 서명을 검증하고, 검증되지 않은 요청은 거부해야 한다.

Worker를 멱등하게 만든다

QStash는 대상 API가 실패하면 다시 전달한다. 네트워크 상황에 따라 같은 작업이 두 번 도착할 가능성을 전제로 해야 한다.

예를 들어 processedAt이 이미 기록된 주문은 다시 처리하지 않도록 한다.

processedAt 없음 → 작업 실행 → processedAt 기록
processedAt 있음 → 이미 완료된 작업이므로 종료

이메일처럼 중복 실행의 영향이 큰 작업이라면 작업 ID를 함께 저장하고 유일성 제약으로 한 번만 처리되게 만드는 편이 좋다.

작업이 커지면 Worker를 어디로 옮길까

Worker는 역할이므로 실행 서비스는 작업 성격에 따라 교체할 수 있다.

작업 성격 | 적합한 실행 방식
수초~수분의 주문 후처리 | Vercel Function
여러 단계로 이어지는 비동기 흐름 | QStash와 여러 Vercel Function
수십 분 이상 걸리는 이미지·데이터 처리 | Google Cloud Run Jobs 같은 컨테이너 작업 서비스
VPC 안에서 계속 실행되는 큐 소비자 | AWS ECS/Fargate 같은 컨테이너 서비스

Google Cloud Run Jobs는 요청을 계속 듣는 웹 서비스가 아니라, 컨테이너 작업을 실행하고 종료하는 방식이다. 공식 문서상 작업 제한 시간은 기본 10분이며 설정에 따라 최대 7일까지 늘릴 수 있다. AWS Fargate는 서버를 직접 프로비저닝하지 않고 ECS 작업이나 서비스를 컨테이너로 실행할 수 있다.

처음부터 이 구조를 모두 준비할 필요는 없다. 후처리 시간이 Vercel 함수의 범위를 실제로 넘어가거나, 특정 네트워크·CPU·메모리 요구가 생겼을 때 이전하면 된다.

작은 프로젝트에 맞는 시작점

Worker 구조를 이해하기 위한 기능은 거창할 필요가 없다.

사용자가 주문을 생성한다.

화면에 처리 대기를 표시한다.

QStash가 Worker를 호출한다.

Worker가 몇 초 뒤 처리 완료로 변경한다.

화면이 상태를 다시 조회해 완료를 보여준다.

이 작은 흐름만으로 동기와 비동기의 차이, 메시지 전달, 재시도, 멱등성, 상태 확인을 모두 경험할 수 있다.

정리

Worker를 추가한다는 말은 반드시 새로운 서비스를 구매한다는 뜻이 아니다. 현재 구성에서는 Vercel이 웹 서버이자 Worker 실행 환경이다.

Vercel = 코드 실행
Supabase = 데이터 저장
QStash = 작업 전달과 재시도
Worker = Vercel에서 실행되는 후처리 코드의 역할

작은 프로젝트에서는 /api/jobs를 Worker로 시작하고, 실제 제약이 생겼을 때만 컨테이너 서비스로 옮기는 것이 가장 단순하다. 제품보다 역할을 먼저 이해하면 서버리스 구성이 훨씬 덜 복잡하게 보인다.

참고 자료

Vercel 함수 실행 시간 설정

Upstash QStash 보안과 서명 검증

Google Cloud Run Jobs 생성과 실행

AWS ECS와 Fargate 시작하기