PDF 미리보기 API에 IP 기반 무료 횟수 제한 적용
목차
IP 기반 rate-limit 없이 돌아가던 PDF 추출 API에 무료 미리보기 횟수 제한을 걸었다.
왜 지금이었나
route.ts 하나만 열어두면 누가 루프 돌려서 대량 추출해도 막을 방법이 없었다. 서버 비용은 요청량에 비례해서 올라가는 구조인데, 인증 없이 접근 가능한 엔드포인트를 그냥 놔두는 건 사실상 열린 수도꼭지나 마찬가지다. 팀에서 "무료 미리보기"라는 UX 컨셉이 확정된 순간부터 이 작업은 기술 부채가 아니라 즉시 처리해야 할 운영 리스크로 격상됐다.
rate-limit을 언제 걸 것이냐는 항상 "기능이 안정화된 다음에"로 밀리는 경향이 있다. 그런데 그 "다음"이 오기 전에 트래픽이 튀거나 악의적 사용이 들어오면 그때는 이미 늦다. 이번에는 기능 출시와 거의 동시에 제한을 붙이는 방향으로 우선순위를 잡았고, 결과적으로 맞는 판단이었다고 본다.
구조 — ratelimit.ts와 route.ts 분리
변경이 두 파일에 걸쳐 있다는 점이 이번 작업의 핵심이다. rate-limit 로직을 route.ts 안에 직접 때려 넣는 게 가장 빠르지만, 그렇게 하면 나중에 다른 엔드포인트에서도 같은 로직을 복붙하게 된다. 처음부터 ratelimit.ts로 분리해서 "IP 기반 슬라이딩 윈도우 몇 회" 같은 정책이 한 곳에서 관리되도록 만들었다.
// src/lib/ratelimit.ts (패턴 예시)
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
export const previewRatelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "24 h"),
analytics: true,
});
// src/app/api/extract/route.ts (패턴 예시)
import { previewRatelimit } from "@/lib/ratelimit";
import { headers } from "next/headers";
export async function POST(req: Request) {
const ip = headers().get("x-forwarded-for") ?? "anonymous";
const { success, remaining } = await previewRatelimit.limit(ip);
if (!success) {
return new Response("Too Many Requests", { status: 429 });
}
// 이하 PDF 추출 로직
}
이렇게 분리해두면 나중에 "로그인 유저는 제한 다르게 적용하자"는 요구가 들어와도 ratelimit.ts에 limiter 하나 추가하고 route.ts에서 분기만 치면 된다. 변경 범위가 명확하게 유지된다.
트레이드오프 정리
| 항목 | IP 기반 제한 | 세션/토큰 기반 제한 |
|---|---|---|
| 구현 복잡도 | 낮음 | 높음 (인증 연동 필요) |
| 우회 가능성 | VPN/프록시로 가능 | 계정 생성 남용 가능 |
| UX 마찰 | 로그인 불필요 | 가입 필요 |
| 적합한 단계 | 무료 미리보기 초기 | 유료 전환 이후 |
지금 단계에서는 IP 기반이 맞다. 인증 시스템이 붙기 전에 "일단 무료로 몇 번 써보게 하자"는 방향이라면, 가입 요구 없이 횟수 제한만 걸어두는 게 UX와 운영 비용 사이의 균형점이다. 우회 가능성이 있다는 건 알지만, 그 우회 비용(VPN 세팅)이 일반 사용자에게는 충분한 마찰이 된다.
코드리뷰에서 나온 포인트들
이번 PR에서 팀 내부적으로 몇 가지 논의가 있었다.
x-forwarded-for신뢰 문제 — 헤더를 클라이언트가 조작할 수 있다는 지적. 배포 인프라가 신뢰된 프록시를 경유하는 구조라면 괜찮지만, 그렇지 않으면 헤더 위조로 제한을 우회할 수 있다. 지금 환경에서는 허용 범위 안이라고 판단했고, 추후 인프라가 바뀌면 재검토 예정.- fallback 처리 — Redis 장애 시 rate-limit 자체가 실패하면 어떻게 할 것이냐. fail-open(제한 없이 통과)이냐 fail-closed(차단)냐. 무료 미리보기 특성상 fail-open으로 잡았고, 과금되는 기능이라면 반대로 잡았을 것.
remaining헤더 노출 — 남은 횟수를 응답 헤더에 담아서 프론트가 UI에 표시할 수 있게 하는 것. 작은 디테일이지만 UX 측면에서 꽤 중요하다. "3번 남았습니다" 같은 메시지를 보여줄 수 있으면 사용자 입장에서 납득이 된다.
이런 논의들이 PR 단계에서 나왔다는 게 팀 입장에서는 긍정적이다. 기능을 빠르게 치고 나가면서도 운영 리스크를 같이 짚는 리뷰 문화가 조금씩 자리를 잡고 있다는 신호로 읽힌다.
다음 단계는 인증 붙고 나서 유저 ID 기반으로 제한을 전환하는 것, 그리고 어드민에서 IP별 사용량을 모니터링하는 뷰를 붙이는 것 정도다.
🛒 이 글과 어울리는 추천 상품
*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.
댓글 0
첫 댓글 달아줘.