Upstash 환경변수 7줄, 각각 무엇을 막는가

왜 이 주제가 중요한가

Vercel 같은 서버리스 환경에 백그라운드 작업 큐와 요청 제한을 붙이려고 문서를 펼치면
환경변수 목록이 먼저 나온다. Upstash 기준으로 일곱 줄이다.

QUEUE_DRIVER=upstash
RATELIMIT_DRIVER=upstash

QSTASH_TOKEN=
QSTASH_CURRENT_SIGNING_KEY=
QSTASH_NEXT_SIGNING_KEY=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

처음 보면 많다고 느낀다. 큐 하나 붙이는데 토큰이 세 개다. 그래서 대충 복사해 붙이고
넘어가기 쉬운데, 이 목록은 실수하면 조용히 깨지는 종류의 설정이다. 값이 틀렸을 때
바로 죽는 게 아니라 몇 주 뒤에 이상하게 죽는다.

각 값이 무엇을 막는지 알고 넣으면 그 함정을 대부분 피할 수 있다. 결론부터 말하면,
자격증명이 다섯 개인 이유는 신뢰의 방향이 세 개이기 때문이다.

핵심 개념

스위치 둘: 코드가 아니라 값이 갈린다

앞의 두 줄은 자격증명이 아니라 스위치다.

QUEUE_DRIVER=upstash # memory | upstash
RATELIMIT_DRIVER=upstash # memory | upstash

memory 는 같은 프로세스에서 즉시 처리하고, upstash 는 바깥으로 넘긴다.
호출부는 enqueue() 하나만 알고 어느 쪽인지 모른다. 그래서 값만 바꾸면 갈린다.

이런 경계를 처음부터 두는 편이 좋다. 큐 없이 시작해서 나중에 QStash 를 끼워넣으면,
후처리를 부르던 자리를 전부 찾아 고쳐야 한다. 경계가 있으면 배포 설정만 바뀐다.

두 스위치는 서로 독립이다. 큐만 upstash 로 두고 요청 제한은 memory 로 둘 수 있다.
실제로 Redis 없이도 큐는 완결된다.

자격증명 다섯: 방향이 셋이다

여기가 핵심이다. 세 종류의 값이 하는 일이 서로 다르다.

값 | 방향 | 하는 일
QSTASH_TOKEN | 우리 → QStash | 우리가 큐에 작업을 넣을 권한
QSTASH_*_SIGNING_KEY | QStash → 우리 | 워커를 두드리는 게 진짜 QStash 인지
UPSTASH_REDIS_REST_* | 우리 ↔ Redis | 공유 카운터 읽고 쓰기

QSTASH_TOKEN 은 나가는 방향이다. 작업을 큐에 넣을 때 쓴다. Upstash 입장에서
"이 요청이 네 계정 것이냐"를 확인하는 평범한 API 키다. 없으면 publish 가 거절된다.

서명 키는 들어오는 방향이다. 이쪽이 처음에 이해가 안 가는 부분이다.

QStash 는 작업을 앱에 HTTP 요청으로 배달한다. 우리가 QStash 를 폴링하는 게 아니라,
QStash 가 인터넷에서 우리 /api/jobs 를 두드린다. 그러니 그 엔드포인트는 공개돼
있어야 한다. 관리자 토큰으로 막을 수가 없다. QStash 가 그 토큰을 모르기 때문이다.

그래서 방향을 뒤집어서 막는다. QStash 가 요청에 서명을 붙이고, 우리는 서명 키로 그
서명을 검증한다. 키를 아는 쪽이 우리라는 점이 다르다. 우리가 보내는 게 아니라
받은 것을 확인하는 용도다.

이걸 놓치면 워커가 아무나 실행할 수 있는 엔드포인트가 된다. 남이 작업 이름과 페이로드를
그대로 흉내내 던지면 실행된다. 그래서 서명 키가 없을 때는 통과가 아니라 차단이어야
한다. 설정이 빠졌다는 이유로 열어두면 안 된다.

서명 키가 두 개인 이유는 키 회전 때문이다. Upstash 가 서명 키를 교체하는 시점에
옛 키로 서명된 요청과 새 키로 서명된 요청이 섞인다. 검증기는 current 로 먼저 확인하고
실패하면 next 로 한 번 더 확인한다. 그래서 교체 중에도 작업이 안 끊긴다.

하나만 넣으면 평소엔 잘 돌다가 회전 시점에 조용히 실패한다. 재현하기 아주 나쁜 버그다.
"몇 달 전에 넣고 잘 되던 게 갑자기"의 전형이다.

Redis 두 개는 공유 상태를 위한 것이다. 요청 제한에만 쓴다. URL 과 TOKEN 이 나뉜
이유는 Upstash Redis 가 REST 로 말하기 때문이다. 일반 Redis 프로토콜이 아니라 HTTP 라서
주소와 인증이 따로다. 서버리스에서 TCP 커넥션 풀을 들고 있을 수 없으니 이 형태가 맞다.

실제 적용 포인트

메모리 카운터는 왜 부족한가

요청 제한을 memory 로 두면 카운터가 프로세스 안에 있다. 인스턴스가 셋이면 한도도
세 배가 된다. 인스턴스마다 자기 몫을 따로 세기 때문이다.

단순 반복 호출은 막지만 분산된 남용은 못 막는다. 트래픽이 적을 때는 인스턴스가 하나라
차이가 안 보이다가, 트래픽이 늘어 인스턴스가 늘어나는 순간 제한이 헐거워진다.
제한이 가장 필요한 시점에 가장 약해지는 셈이다.

Redis 를 붙이는 이유가 이거다. 인스턴스가 몇 개든 카운터가 하나가 된다.

다만 대가가 있다. 제한을 거는 경로마다 Redis 왕복이 한 번씩 붙는다. 그래서 리전을
앱·DB 와 같은 쪽으로 잡는 게 좋다. 서울에서 도는 앱이 미국 리전 Redis 를 보면 그
왕복이 응답 시간에 그대로 들어간다.

목록에 없는 두 개

문서에 나오는데 위 일곱 줄에 안 들어간 값이 둘 있다. 이유가 서로 다르다.

APP_URL 은 QStash 가 워커를 부를 공개 주소다. 큐 입장에서 배달지 주소라 반드시
필요한데, Vercel 이 VERCEL_URL 을 자동으로 넣어주므로 생략할 수 있다. 프로토콜이
빠진 형태로 오니 코드에서 보정한다.

다만 배포마다 주소가 바뀌므로 커스텀 도메인이 있으면 명시하는 편이 낫다. 안 그러면
QStash 콘솔 실패 로그가 이미 사라진 프리뷰 주소로 가득 찬다.

QSTASH_URL 은 로컬 개발 서버를 가리킬 때만 쓴다. 운영에서는 비워야 한다.
채워두면 SDK 가 그 주소로 publish 를 시도하고, 로컬 주소가 박혀 있으면 배포 후 조용히
실패한다.

계정 없이 먼저 검증할 수 있다

값을 발급받기 전에 경로 전체를 확인하는 방법이 있다. QStash 는 로컬 개발 서버를 제공한다.

# 터미널 1 - 자격증명을 출력한다
npx @upstash/qstash-cli dev

# 터미널 2 - 출력된 값으로 앱 실행
QUEUE_DRIVER=upstash \
APP_URL=http://127.0.0.1:2015 \
QSTASH_URL=http://127.0.0.1:8080 \
QSTASH_TOKEN=<출력값> \
QSTASH_CURRENT_SIGNING_KEY=<출력값> \
QSTASH_NEXT_SIGNING_KEY=<출력값> \
npx next dev -p 2015

이렇게 띄우고 작업을 하나 만들면 로그에 워커 호출이 찍힌다.

POST /api/jobs 200
[job] order.created 처리 완료

응답의 처리 시각이 처음에는 비어 있다가 몇 초 뒤에 채워지는 것이 비동기 처리의 증거다.
서명 없이 워커를 두드려서 401 이 오는지도 같이 본다.

이 단계를 먼저 밟으면 이득이 있다. 실계정을 붙인 뒤에 실패하면 원인 후보가
자격증명·주소·서명·코드 넷으로 늘어난다. 코드를 먼저 걸러두면 남는 후보가 줄어든다.

주의할 점

큐는 열어두고, 제한은 막는다

로컬에서 QStash 를 죽여놓고 요청을 넣어보다 발견한 것이다.

publish 가 실패하면 예외가 호출부까지 올라가서 데이터는 커밋됐는데 응답은 500 이
됐다. 나쁜 이유는 500 자체가 아니다. 클라이언트가 재시도하면 같은 주문이 두 번 생기고
재고가 두 번 깎인다.

후처리를 큐로 뺀 목적이 "외부 서비스 장애가 본 작업 실패로 번지지 않게" 하는 것인데,
정확히 그게 뒤집힌 셈이다. 큐를 붙였더니 장애 지점이 하나 늘어난 꼴이 된다.

그래서 큐에 넣는 함수는 던지지 않게 두는 편이 낫다. publish 가 실패하면 기록하고,
같은 프로세스에서 한 번 직접 처리한다. 핸들러가 멱등하면 안전하다.

요청 제한은 반대다. Redis 가 응답하지 않으면 그냥 막는다. 셀 수 없을 때 통과시키면
제한이 있다는 말이 의미를 잃는다.

같은 "외부 서비스 장애"인데 방향이 반대인 이유는 실패했을 때 잃는 것이 다르기
때문이다. 후처리를 못 하면 나중에 채우면 되지만, 제한을 못 걸면 그 사이에 벌어진 일은
되돌릴 수 없다.

이 선택 때문에 RATELIMIT_DRIVER 와 Redis 값 두 개는 반드시 같이 넣어야 한다.
스위치만 켜고 값을 빼면 해당 경로가 전부 500 이 된다.

없으면 어떻게 되는가

값 | 없으면
QUEUE_DRIVER | memory 로 동작(고장은 아님)
RATELIMIT_DRIVER | 인스턴스별 한도(실질 N 배)
QSTASH_TOKEN | 큐에 넣지 못함
QSTASH_CURRENT_SIGNING_KEY | 워커가 차단됨
QSTASH_NEXT_SIGNING_KEY | 평소엔 되다가 키 회전 때 깨짐
UPSTASH_REDIS_REST_URL / _TOKEN | 스위치가 upstash 면 해당 경로 500

위 두 줄과 아래 다섯 줄의 성격이 다르다는 점을 보면 좋다. 스위치는 빠져도 앱이 돌지만
동작이 달라지고, 자격증명은 빠지면 해당 기능이 멈춘다. 가장 위험한 것은 다섯 번째다.
빠져도 지금은 아무 일이 없다.

듀오랩스가 보는 관점

환경변수 목록은 설정 파일이 아니라 설계의 요약본이다.

값이 다섯 개인 이유를 한 줄로 하면 이렇다. 나가는 인증과 들어오는 인증은 다른 문제이고,
공유 상태는 또 다른 문제다. 하나로 합칠 수 있는 게 아니었다. 목록이 길다고 느꼈던 건
문제를 하나로 보고 있었기 때문이다.

그리고 이런 값은 넣는 순간이 아니라 몇 달 뒤에 문제가 되는 종류다. 키 회전 때
깨지는 서명, 인스턴스가 늘어야 헐거워지는 제한, 배포 주소가 바뀌어야 죽는 워커.
전부 처음에는 잘 돌고, 조건이 바뀌었을 때 조용히 어긋난다.

그래서 값을 넣기 전에 각각이 무엇을 막는지 한 번 짚고 가는 시간이 아깝지 않다.
그 자리에서 5분이고, 안 하면 나중에 재현 안 되는 버그로 며칠이다.