Upstash Redis로 서버리스 캐시·레이트 리밋: REST vs TCP, Vercel·Workers 통합, QStash
이 글의 핵심
Upstash는 TCP Redis와 HTTP REST를 모두 제공하는 서버리스 호환 Redis 호스팅입니다. 레이트 리밋·캐싱·백그라운드 작업(QStash)까지 엣지 런타임과 함께 쓰는 패턴을 한 번에 정리합니다.
Upstash Redis는 서버리스 함수와 엣지 런타임에서 연결 관리 부담 없이 Redis를 쓸 수 있도록 만든 관리형 서비스입니다. 이 글은 REST와 TCP 접근 방식의 차이, 레이트 리밋과 캐싱 패턴, Vercel과 Cloudflare Workers 연동, QStash, 비용 구조를 설명합니다. 가격과 한도는 자주 바뀌므로 운영 전에 공식 가격 문서를 확인하세요.
서버리스에서 Redis를 쓸 때의 문제
일반적인 Redis 클라이언트는 TCP 연결을 오래 유지하고 그 위로 명령을 주고받습니다. 장기 실행 서버라면 연결 몇 개를 재사용하면 되지만, Lambda나 Vercel Functions, Cloudflare Workers처럼 인스턴스가 짧게 살거나 동시 실행 수가 크게 출렁이는 환경에서는 인스턴스마다 연결을 새로 맺게 됩니다. 그러면 콜드 스타트마다 TCP와 TLS 핸드셰이크 비용이 붙고, 동시 실행이 늘 때 Redis의 연결 수 한도에 닿기 쉽습니다.
Upstash는 일반 Redis 프로토콜(TCP) 접속과 함께, Redis 명령을 HTTP 요청으로 보내는 REST API를 제공합니다. HTTP 요청은 연결 상태를 들고 있을 필요가 없어 짧게 사는 함수와 잘 맞고, TCP 클라이언트 라이브러리를 쓸 수 없는 엣지 런타임에서도 동작합니다. 과금은 저장 용량과 명령 수 기반이라, 트래픽이 거의 없는 기간에는 비용도 거의 나오지 않습니다. 대신 트래픽이 늘면 명령 수에 비례해 비용이 오르므로, 사용량이 많고 꾸준한 서비스라면 고정 요금 플랜이나 다른 호스팅과 비교해 봐야 합니다.
엣지 함수나 API 라우트에서 캐시 조회와 카운터 증가가 필요할 때, 사용자나 API 키 기준의 레이트 리밋이 필요할 때, 브로커를 직접 운영하지 않고 백그라운드 작업을 넘기고 싶을 때(QStash와 조합) Upstash가 잘 맞습니다. 반대로 초당 명령 수가 매우 많은 파이프라인이나 대용량 배치가 핵심이면, 명령당 HTTP 왕복과 과금 구조가 불리하므로 일반 Redis 클러스터를 먼저 검토합니다.
REST와 TCP
| 방식 | 특징 | 사용처 |
|---|---|---|
| Redis 프로토콜(TCP) | ioredis, node-redis 같은 표준 클라이언트 사용 | 장기 실행 서버, 컨테이너 |
| REST API | 명령을 HTTP 요청으로 실행 | 엣지 런타임, 짧게 사는 서버리스 함수 |
Upstash 콘솔에서 TCP 접속용 Redis URL과 REST URL 및 토큰을 함께 발급받을 수 있습니다.
REST 방식에서는 명령 하나가 HTTP 요청 하나입니다. 한 요청을 처리하면서 Redis 명령을 다섯 번 순서대로 보내면 네트워크 왕복도 다섯 번이 됩니다. @upstash/redis는 여러 명령을 한 HTTP 요청으로 묶는 redis.pipeline(), 원자적으로 실행하는 redis.multi(), Lua 스크립트를 실행하는 redis.eval()을 제공하므로, 연속된 명령은 이들로 묶어 왕복 수를 줄입니다. 파이프라인으로 묶어도 과금되는 명령 수는 그대로입니다.
공식 클라이언트 @upstash/redis
npm install @upstash/redis
Redis.fromEnv()는 UPSTASH_REDIS_REST_URL과 UPSTASH_REDIS_REST_TOKEN 환경 변수를 읽어 클라이언트를 만듭니다.
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
export async function cacheUserProfile(userId: string) {
const key = `user:profile:${userId}`;
const cached = await redis.get<Record<string, unknown>>(key);
if (cached) return cached;
const fresh = await fetchProfileFromOrigin(userId);
await redis.set(key, fresh, { ex: 300 }); // 300초 TTL
return fresh;
}
async function fetchProfileFromOrigin(userId: string) {
// DB 등 원본 조회 로직
return { id: userId, name: 'example' };
}
@upstash/redis는 객체를 저장할 때 자동으로 JSON 직렬화하고, 읽을 때 다시 파싱합니다. 편리하지만 다른 클라이언트(예: TCP로 붙는 Python 서비스)와 같은 키를 공유한다면 직렬화 형식이 맞는지 확인해야 합니다. 이 동작은 클라이언트 생성 시 automaticDeserialization: false로 끌 수 있습니다.
레이트 리밋
레이트 리밋은 짧은 시간 창 안의 요청 수를 세어야 합니다. 서버리스 인스턴스가 수백 개로 늘어나면 각 인스턴스의 메모리 카운터로는 전체 요청 수를 알 수 없으므로, 모든 인스턴스가 공유하는 Redis 카운터가 자주 쓰입니다.
Upstash는 고정 윈도우, 슬라이딩 윈도우, 토큰 버킷 알고리즘을 @upstash/ratelimit 라이브러리로 제공합니다.
npm install @upstash/ratelimit @upstash/redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '1 m'), // 분당 100회
prefix: 'rl:api',
});
export async function checkApiLimit(userId: string) {
const { success, limit, remaining, reset } = await ratelimit.limit(userId);
return { success, limit, remaining, reset };
}
슬라이딩 윈도우는 고정 윈도우보다 경계 시점에 요청이 몰리는 문제가 적지만, 요청 하나당 Redis 명령을 더 많이 씁니다. analytics: true를 켜면 콘솔에서 통계를 볼 수 있지만 요청마다 기록용 명령이 추가되고, 이 기록은 비동기로 나가므로 서버리스 환경에서는 응답 후 함수가 멈추기 전에 끝나도록 limit() 결과의 pending 프로미스를 waitUntil에 넘겨야 합니다.
식별자는 IP보다 사용자 ID, API 키의 해시, 테넌트 ID처럼 실제 사용 주체를 가리키는 값이 좋습니다. 회사나 통신사 NAT 뒤의 많은 사용자가 같은 IP로 보이면 정상 사용자까지 함께 차단되기 때문입니다. 비로그인 요청처럼 IP밖에 없는 경우에는 IP 기준 한도를 넉넉하게 잡습니다.
응답에는 남은 횟수와 초기화 시각을 X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset 같은 헤더로 알려 주는 관행이 있고, 한도를 넘었을 때는 429 상태 코드와 Retry-After 헤더를 돌려줍니다.
캐싱 전략
Cache-Aside
앞의 cacheUserProfile처럼 읽을 때 캐시를 먼저 보고, 없으면 원본에서 가져와 캐시에 넣는 방식입니다. 구현이 단순하고 캐시가 비어도 원본에서 다시 채워지므로 장애에 강합니다. 다만 인기 키가 만료된 직후 여러 요청이 동시에 캐시 미스를 겪으면 모두 원본으로 몰리는 캐시 스탬피드가 생길 수 있습니다. 인기 키에는 SET key value NX EX 10 같은 짧은 잠금으로 원본 조회를 한 요청만 하게 하거나, 만료 전에 백그라운드에서 미리 갱신하는 방법을 씁니다.
TTL과 키 설계
app:v1:user:{id}처럼 도메인과 버전을 키 접두사에 넣으면, 데이터 구조를 바꿀 때 접두사의 버전만 올려 옛 캐시를 자연스럽게 버릴 수 있습니다. SCAN이나 KEYS로 패턴 삭제를 하면 키 수만큼 명령과 시간이 들어 서버리스 환경에서 특히 비싸므로, 무효화가 필요한 키는 이름을 계산할 수 있게 설계합니다. 세션과 임시 데이터에는 반드시 TTL을 걸고, 큰 JSON을 한 키에 통째로 넣기보다 실제로 읽는 필드만 캐시합니다.
일관성 기대치
캐시는 원본과 잠시 어긋날 수 있는 계층입니다. 결제 금액이나 재고처럼 틀리면 안 되는 값은 DB의 제약 조건과 트랜잭션으로 지키고, Redis 값은 표시용이나 보조 수단으로 둡니다.
Vercel 연동
Vercel 프로젝트의 환경 변수에 UPSTASH_REDIS_REST_URL과 UPSTASH_REDIS_REST_TOKEN을 등록합니다. NEXT_PUBLIC_ 접두사를 붙이면 클라이언트 번들에 들어가므로, 토큰은 서버 코드에서만 읽어야 합니다.
// app/api/quote/route.ts
import { Redis } from '@upstash/redis';
import { NextResponse } from 'next/server';
const redis = Redis.fromEnv();
export async function GET() {
const cached = await redis.get<string>('quote:daily');
if (cached) {
return NextResponse.json({ quote: cached, source: 'cache' });
}
const quote = await fetchQuoteFromApi();
await redis.set('quote:daily', quote, { ex: 86_400 });
return NextResponse.json({ quote, source: 'origin' });
}
async function fetchQuoteFromApi() {
return 'Serverless is a process, not a destination.';
}
@upstash/redis는 fetch만 쓰므로 Node.js 런타임과 Edge 런타임 모두에서 동작합니다. 클라이언트를 핸들러 밖 모듈 범위에서 만들면 같은 인스턴스가 재사용될 때 객체를 다시 만들지 않습니다.
Cloudflare Workers 연동
Workers는 connect() API로 TCP 소켓을 열 수 있지만, ioredis 같은 Node.js용 Redis 클라이언트는 Node의 net 모듈을 전제로 해서 그대로 쓰기 어렵습니다. 그래서 REST 기반인 @upstash/redis를 쓰는 것이 일반적이며, Workers용 진입점은 env 객체에서 설정을 읽습니다.
import { Redis } from '@upstash/redis/cloudflare';
export interface Env {
UPSTASH_REDIS_REST_URL: string;
UPSTASH_REDIS_REST_TOKEN: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const redis = Redis.fromEnv(env);
const count = await redis.incr('edge:hit:count');
return new Response(JSON.stringify({ count }), {
headers: { 'content-type': 'application/json' },
});
},
};
토큰은 wrangler secret put UPSTASH_REDIS_REST_TOKEN으로 등록해 설정 파일에 평문으로 남기지 않습니다.
INCR 자체는 원자적이므로 전 세계에서 동시에 호출해도 증가분이 사라지지는 않습니다. 다만 Upstash의 글로벌 데이터베이스에서는 쓰기가 기본 리전으로 가고 읽기는 가까운 읽기 리전에서 처리될 수 있어, 방금 쓴 값을 다른 리전에서 바로 읽으면 이전 값이 보일 수 있습니다. 또 모든 엣지 요청이 기본 리전까지 왕복하므로, 쓰기가 많은 키는 엣지라도 지연이 줄지 않습니다.
QStash
QStash는 HTTP 엔드포인트로 메시지를 전달해 주는 서비스입니다. 메시지를 발행하면 QStash가 지정한 URL로 HTTP 요청을 보내고, 실패하면 재시도하며, 지연 전송과 cron 스케줄도 지원합니다. Redis와 달리 데이터를 저장해 두고 조회하는 용도가 아니라, 작업을 나중에 다른 엔드포인트에서 처리하게 넘기는 전달 계층입니다.
흔한 구성은 API 핸들러가 요청을 받으면 QStash에 작업을 발행하고 바로 응답하며, QStash가 작업용 엔드포인트(별도 서버리스 함수)를 호출해 무거운 처리를 하게 하는 것입니다. 진행 상태나 중복 실행 방지 키는 Redis에 기록합니다. 이렇게 하면 사용자 응답 시간과 백그라운드 처리 시간이 분리되고, 함수 실행 시간 제한에도 덜 걸립니다.
import { Client } from '@upstash/qstash';
const client = new Client({ token: process.env.QSTASH_TOKEN! });
export async function enqueueEmail(payload: { to: string; body: string }) {
await client.publishJSON({
url: 'https://api.example.com/jobs/email',
body: payload,
retries: 3,
});
}
작업 엔드포인트는 인터넷에서 접근 가능해야 하므로, 요청이 실제로 QStash에서 왔는지 Upstash-Signature 헤더를 검증해야 합니다. @upstash/qstash의 Receiver에 현재 서명 키와 다음 서명 키를 넣고 verify()를 호출하면 되며, 검증에는 파싱하기 전의 원본 요청 본문이 필요합니다. QStash는 실패 시 재시도하므로 같은 메시지가 두 번 이상 도착할 수 있다는 전제로, 작업 엔드포인트를 멱등하게 만들어야 합니다.
비용과 한도
무료 플랜에는 데이터 크기, 월간 명령 수, 대역폭, 초당 명령 수 등에 상한이 있고, 유료 플랜은 명령 수에 비례해 과금되는 종량제와 월 정액에 용량과 기능이 묶인 고정 요금제로 나뉩니다. 구체적인 수치는 자주 바뀌므로 가격 문서와 콘솔에서 확인합니다.
종량제에서는 명령 수가 곧 비용이므로 코드 습관이 비용에 직결됩니다. 반복문 안에서 키를 하나씩 GET하면 키 수만큼 과금되므로 MGET이나 파이프라인으로 묶고, TTL 없이 쌓이는 키는 저장 용량 비용과 메모리 한도 문제로 이어집니다. 레이트 리밋 키를 엔드포인트와 사용자 조합으로 지나치게 잘게 나누면 키 수와 명령 수가 함께 늘어납니다. 개발용과 운영용 데이터베이스를 분리해 두면 테스트 중에 운영 한도를 소모하는 일을 막을 수 있습니다.
장기 실행 환경에서 TCP 클라이언트 쓰기
컨테이너나 VM처럼 프로세스가 오래 사는 환경에서는 ioredis나 node-redis로 Upstash의 Redis URL(TLS 사용, rediss://)에 붙는 편이 명령당 HTTP 오버헤드가 없어 유리합니다. 이때는 연결 수, 타임아웃, 재연결 설정을 명시하고, 동시 실행이 늘 때 연결 한도에 닿지 않는지 부하 테스트로 확인합니다.
Lambda는 실행 환경이 재사용되긴 하지만 호출 사이에 동결되므로, 핸들러 밖에서 만든 TCP 연결이 동결 중 서버 쪽 유휴 타임아웃으로 끊겨 다음 호출에서 오류가 날 수 있습니다. 재연결 처리를 넣거나 REST 클라이언트를 쓰는 편이 단순합니다. 같은 팀 안에서도 장기 실행 서비스는 TCP, 서버리스와 엣지는 REST로 런타임별로 나누는 경우가 많습니다.
자주 쓰는 명령
| 명령 | 용도 | 메모 |
|---|---|---|
GET / SET | 캐시, 플래그 | SET에 EX로 TTL을 함께 지정 |
INCR / DECR | 카운터, 할당량 | 원자적이라 동시 요청에도 증가분이 사라지지 않음 |
SET NX EX | 잠금 | 만료가 있어 잠금을 잡은 함수가 죽어도 영구히 남지 않음 |
EXPIRE | TTL 조정 | 키 누수 방지 |
DEL | 무효화 | 패턴 삭제 대신 이름을 계산할 수 있는 키 목록으로 삭제 |
SET NX EX로 만든 잠금은 해제할 때 주의가 필요합니다. 잠금 값에 요청마다 고유한 값을 넣고, 해제 시 값이 자기 것인지 확인한 뒤 지우는 Lua 스크립트를 써야 합니다. 그냥 DEL하면 자기 잠금이 이미 만료되어 다른 요청이 새로 잡은 잠금을 지울 수 있습니다.
로컬 개발과 테스트
로컬에서는 무료 플랜의 원격 데이터베이스를 쓰는 방법이 가장 단순하지만 명령 한도를 소모합니다. Docker로 Redis를 띄우면 한도 걱정이 없지만, @upstash/redis는 REST로만 통신하므로 로컬 Redis 앞에 REST 호환 프록시(예: 커뮤니티의 serverless-redis-http)를 두거나, 로컬에서는 TCP 클라이언트를 쓰도록 환경별로 분기해야 합니다. 팀 단위로 스테이징 전용 데이터베이스를 두는 방법도 있습니다.
CI에서 커밋마다 통합 테스트가 실제 Redis를 많이 호출하면 비용과 한도 문제가 생길 수 있으므로, 단위 테스트는 인메모리 목으로 처리하고 실제 연결 테스트는 일부 스모크 테스트로 제한하는 편이 안전합니다.
트러블슈팅
지연이 갑자기 늘었다면 먼저 한 요청에서 보내는 명령 수를 확인합니다. REST 방식에서는 반복문 안의 GET 같은 N+1 호출이 왕복 수를 그대로 늘리므로 MGET이나 파이프라인으로 묶습니다. 값이 큰 키는 직렬화와 전송 시간이 늘어나므로 필요한 필드만 남기고, 함수가 데이터베이스와 다른 리전에서 실행되고 있지 않은지도 확인합니다.
명령 한도에 걸린 것 같다면 콘솔의 사용량 그래프에서 어떤 시점에 명령이 급증했는지 보고, analytics가 켜진 레이트 리밋이나 반복 호출 코드가 원인인지 확인합니다.
엣지에서 읽은 값이 기대와 다르다면 글로벌 데이터베이스의 읽기 리전 복제 지연일 가능성이 큽니다. 쓰기 직후 같은 값을 반드시 읽어야 하는 흐름이라면 그 읽기를 기본 리전으로 보내거나, 쓰기 결과를 응답으로 직접 사용합니다.
REST 토큰은 데이터베이스 전체에 접근할 수 있는 비밀 값이므로 저장소와 로그에 남기지 않고, 플랫폼의 시크릿 저장소로 관리합니다. 읽기 전용 접근만 필요한 곳에는 콘솔에서 발급하는 읽기 전용 토큰을 씁니다.