서버리스 환경의 백그라운드 작업 분리: Upstash QStash + Redis 적용

온프레미스 Docker로 만든 쇼핑몰을 Vercel + Supabase로 옮긴 뒤,
마지막으로 남은 단계가 백그라운드 작업 분리였습니다.

이 글은 "왜 굳이 분리하는가"부터 "계정 없이 어떻게 검증했는가",
그리고 "실제로 무엇에 걸렸는가"까지를 정리한 것입니다.

문제: 요청 경로에 남아 있는 후처리

주문 API가 하는 일은 이렇습니다.

POST /api/orders
1. 재고 확인하고 차감
2. 주문 생성
3. 확인 메일 발송 ← 여기

1과 2는 트랜잭션으로 묶여 있어 안전합니다. 문제는 3번입니다.
메일 발송을 요청 경로에 두면 두 가지가 나빠집니다.

응답이 느려집니다. 메일 API가 800ms 걸리면 주문 응답도 800ms 늘어납니다.
고객 입장에서는 "주문 버튼을 눌렀는데 한참 멈춰 있는" 경험이 됩니다.

더 나쁜 건 장애 전파입니다. 메일 서비스가 죽으면 어떻게 될까요?
코드를 어떻게 짜든 결론은 둘 중 하나입니다.

예외를 던진다 → 메일 장애 때문에 주문이 실패합니다

예외를 삼킨다 → 주문은 되지만 메일은 영영 안 갑니다

둘 다 좋지 않습니다. 재고는 이미 차감됐고 주문은 DB에 있는데,
후처리만 조용히 사라지는 상황이 특히 곤란합니다.

큐는 이 딜레마를 없앱니다. 주문은 즉시 확정하고, 후처리는 큐에 넘깁니다.
실패하면 큐가 재시도합니다. 주문과 메일의 운명이 분리됩니다.

설계 원칙: 호출부를 먼저 고정한다

여기서 제가 택한 방식은, 큐가 필요해지기 전에 호출부만 미리 고정하는 것이었습니다.
1단계(온프레미스)에서 이미 이렇게 써 뒀습니다.

// 주문 확정 후, 트랜잭션 밖에서
await enqueue({ name: "order.created", payload: { orderId: order.id } });

그때 enqueue 는 그냥 같은 프로세스에서 함수를 부르는 것이 전부였습니다.
큐라고 부르기도 민망한 코드였죠. 하지만 목적은 달랐습니다.

나중에 진짜 큐를 붙일 때 고칠 곳이 한 파일이 되게 하는 것.

실제로 3단계에서 바꾼 것은 queue.ts 안쪽뿐입니다.
createOrder 는 한 글자도 건드리지 않았습니다.

구조: 드라이버 경계와 처리 내용의 분리

POST /api/orders
└ createOrder() 트랜잭션: 재고 차감 + 주문 생성
└ enqueue(order.created)
├ memory : 핸들러를 즉시 실행
└ upstash : QStash에 넘기고 즉시 반환
↓ 몇 초 뒤, 바깥에서
POST /api/jobs 서명 검증 → 핸들러 실행

파일은 셋으로 나눴습니다.

파일 | 역할
jobs.ts | 작업의 처리 내용. 드라이버와 무관하게 한 곳
queue.ts | 드라이버 선택 (memory / QStash)
api/jobs/route.ts | 워커 엔드포인트

jobs.ts 를 따로 둔 이유가 있습니다. 같은 핸들러를 두 경로에서 부르기 때문입니다.
memory 드라이버는 enqueue 가 직접 부르고, upstash 드라이버는 QStash가
/api/jobs 를 호출해서 부릅니다. 처리 내용은 어느 쪽이든 같아야 하니 한 곳에 둡니다.

QStash: pull 대신 push

여기서 서버리스 특유의 사정이 하나 있습니다.

보통 "워커를 분리한다"고 하면 큐에서 작업을 꺼내오는 상주 프로세스를 떠올립니다.
그런데 Vercel 같은 환경에는 상주 프로세스를 둘 자리가 없습니다.
요청이 오면 인스턴스가 뜨고, 없으면 사라집니다.

QStash는 이 문제를 뒤집어서 풉니다. 가져가는(pull) 대신 배달합니다(push).

enqueue → QStash에 "이 URL로 이 내용을 보내줘" 라고 등록
↓
QStash가 우리 앱의 /api/jobs 를 HTTP로 호출

우리 쪽은 그냥 API 라우트 하나만 있으면 됩니다. 상주 워커가 필요 없습니다.
실패하면 QStash가 재시도까지 해 줍니다.

워커 엔드포인트 보호: 토큰이 아니라 서명

이 구조에는 짚고 넘어갈 지점이 있습니다.
/api/jobs 는 인터넷에 열려 있어야 합니다. QStash가 바깥에서 호출하니까요.

관리자 토큰으로 막을 수가 없습니다. QStash에게 우리 토큰을 줄 수는 없으니까요.
대신 서명으로 검증합니다. QStash가 요청에 서명을 붙여 보내고, 우리가 확인합니다.

const raw = await req.text(); // 원본 문자열 그대로
const signature = req.headers.get("upstash-signature") ?? "";
const valid = await receiver.verify({ signature, body: raw });
if (!valid) return fail("서명 검증에 실패했습니다.", 401);

여기에 함정이 하나 있습니다. 반드시 원본 문자열로 검증해야 합니다.
req.json() 으로 파싱한 뒤 JSON.stringify 로 되돌리면 키 순서나 공백이 달라져서
서명이 안 맞습니다. req.text() 를 쓰는 이유입니다.

그리고 서명 키가 설정돼 있지 않으면 통과가 아니라 차단하도록 했습니다.
설정 누락이 무방비로 이어지면 안 되니까요. 이건 관리 API에 쓴 원칙과 같습니다.

멱등성: 재시도를 견디는 핸들러

QStash가 재시도한다는 건 같은 작업이 두 번 올 수 있다는 뜻입니다.
핸들러가 멱등하지 않으면 재시도가 오히려 사고가 됩니다.

// 안전: 몇 번 실행돼도 결과가 같습니다
await prisma.order.update({
where: { id: orderId },
data: { processedAt: new Date() },
});

// 위험: 두 번 오면 두 배가 됩니다
await prisma.stats.update({
where: { id },
data: { orderCount: { increment: 1 } },
});

"재시도해 주니 안심"이 아니라 "재시도를 견디게 짜야 한다"가 맞습니다.

요청 제한: 프로세스 카운터에서 Redis 공유 카운터로

큐와 함께 손본 것이 요청 제한입니다. 1단계에서는 이렇게 돼 있었습니다.

const buckets = new Map<string, Bucket>(); // 프로세스 안 카운터

로컬에서는 잘 동작합니다. 그런데 서버리스에서는 거의 무의미합니다.
인스턴스가 여러 개면 각자 따로 세기 때문입니다.
인스턴스가 10개면 실질 한도가 10배가 됩니다.

이건 위험한 종류의 코드입니다. 동작하는 것처럼 보이지만 실제로는 방어가 안 됩니다.
그래서 주석에 한계를 명시해 두고, 3단계에서 Redis 공유 카운터로 바꿨습니다.

const count = await redis.incr(key); // 원자적
if (count === 1) await redis.expire(key, windowSec);
return { ok: count <= limit, ... };

INCR 이 원자적이라는 점이 핵심입니다. "읽고 나서 쓰기"로 하면
동시 요청이 같은 값을 보고 둘 다 통과합니다.
재고 차감에서 조건부 갱신을 쓴 것과 정확히 같은 이유입니다.

설계 결정: rateLimit() 을 async 로

여기서 하나 결정할 것이 있었습니다. 기존 함수는 동기였습니다.

export function rateLimit(...): RateLimitResult

메모리 카운터만 있을 땐 동기로 충분했습니다. 하지만 공유 저장소는 네트워크 왕복이 필요해서
동기일 수가 없습니다. 그래서 async로 바꾸고 호출부도 함께 고쳤습니다.

동기 시그니처를 유지하려고 애쓸 수도 있었지만, 그러면 3단계에서
호출부를 전부 고쳐야 합니다. 경계를 만들어 둔 의미가 사라지죠.
경계는 나중 모습에 맞춰 두는 편이 낫습니다.

검증: 로컬 QStash·Redis 로 재현

Upstash 계정을 만들기 전에 로컬에서 실제로 돌려볼 수 있습니다.
QStash는 개발 서버를 제공합니다.

npx @upstash/qstash-cli dev

실행하면 로컬 엔드포인트와 토큰, 서명 키를 출력해 줍니다.
그 값으로 앱을 띄우면 진짜 QStash와 똑같이 동작합니다.

Redis 쪽은 serverless-redis-http 컨테이너를 쓰면 Upstash REST API와 호환됩니다.

docker run -d -p 8079:80 \
-e SRH_MODE=env -e SRH_TOKEN=test-token \
-e SRH_CONNECTION_STRING="redis://host.docker.internal:6380" \
hiett/serverless-redis-http:latest

이렇게 해서 확인한 것들입니다.

비동기 처리 - 주문 직후 processedAt 이 null 이었다가 1초 뒤 채워짐

서명 검증 - 서명 없음도, 위조 서명도 모두 401

공유 카운터 - 정확히 10건에서 429, Redis에 TTL 60초 키 생성 확인

processedAt 이 처음엔 비어 있다가 나중에 채워지는 것,
이게 "비동기로 처리됐다"는 가장 단순한 증거입니다.
그래서 스모크 테스트에도 이 확인을 넣었습니다. 워커가 죽으면 배포할 때 걸립니다.

함정: 클라우드 DB 스키마 미반영

3단계 배포 후 온프레미스는 정상인데 Vercel 만 500 이 나는 상황이 있었습니다.

원인은 스키마였습니다. 이번에 Order.processedAt 컬럼을 추가했는데,
온프레미스 배포 워크플로우는 prisma db push 를 자동으로 돌리지만
Vercel 경로에는 그 단계가 없었습니다.

그래서 이런 모양이 됩니다.

빌드는 성공합니다 (코드에는 문제가 없으니까요)

런타임에 "그런 컬럼 없음"으로 깨집니다

빌드가 초록불이라 더 헷갈립니다. 클라우드 DB에 스키마를 따로 반영해 주니 해결됐습니다.

교훈은 이렇습니다. 관리형 호스팅으로 옮기면 "배포"에서 빠지는 단계가 생깁니다.
컨테이너 배포에 익숙하면 마이그레이션이 배포에 딸려 온다고 생각하기 쉬운데,
Vercel은 앱만 올립니다. DB는 내 몫입니다.

원격에 스모크 테스트를 쏘면 이런 상황이 바로 드러납니다.
"배포됐다"와 "동작한다"는 다른 이야기입니다.

온프레미스는 memory 드라이버 유지

온프레미스는 3단계 이후에도 memory 드라이버를 유지합니다.

QStash는 우리 앱을 바깥에서 호출합니다. 사설망 주소로는 배달할 수 없습니다.
굳이 쓰려면 워커 엔드포인트를 공개 도메인으로 노출해야 하는데,
그건 얻는 것에 비해 치르는 비용이 큽니다.

같은 코드가 환경에 따라 다른 드라이버로 도는 것, 이게 처음부터 노린 구조입니다.
환경변수 하나로 갈립니다.

QUEUE_DRIVER=memory # 온프레미스
QUEUE_DRIVER=upstash # Vercel

정리

분리하는 이유는 속도보다 장애 격리입니다. 메일 서비스가 죽어도 주문은 되어야 합니다.

경계를 먼저 만들어 두면 이관이 파일 하나 수정으로 끝납니다. 호출부는 안 건드립니다.

서버리스에는 상주 워커를 둘 자리가 없습니다. QStash는 pull 대신 push로 풉니다.

워커 엔드포인트는 공개될 수밖에 없어 서명으로 지킵니다. 원본 문자열로 검증해야 합니다.

재시도가 있으면 핸들러는 멱등해야 합니다.

경계는 나중 모습에 맞춰 둡니다. 동기 시그니처를 고집했으면 결국 다 고쳐야 했습니다.

계정 없이도 로컬에서 끝까지 검증할 수 있습니다.

관리형 호스팅으로 가면 배포에서 빠지는 단계가 생깁니다. DB 마이그레이션이 대표적입니다.

관련 글

온프레미스로 돌던 서비스를 Vercel + Supabase 로 옮기고 워커를 분리하기까지,
같은 작업에서 나온 기록입니다.

Supabase에 붙었더니 ENOTFOUND: 직결, 풀러(Supavisor), 그리고 IPv6
직결 주소가 IPv6 전용이라 붙지 않는 문제와, 커넥션 풀러의 두 가지 모드.

Cloudflare Tunnel로 Vercel을 연결하면 안 되는 이유
DEPLOYMENT_NOT_FOUND 를 응답 헤더로 추적한 기록과 도메인 연결 방식 선택.

Vercel Hobby 플랜은 기본이 "AI 학습 옵트인"입니다
무료 플랜의 데이터 사용 조항과, 올리기 전에 꺼야 하는 이유.

큐 드라이버를 memory에서 Upstash로 전환하기: 준비와 점검
전환 전 상태를 동작으로 확인하는 법과 각 환경변수의 역할.