개발 slecs

API 호출 제한 로직을 미들웨어로 통합해 중복 제거

목차

API rate limit 미들웨어를 범용으로 뽑아냈다.

기존에 특정 엔드포인트마다 개별적으로 박아두던 호출 빈도 제한 로직이 있었는데, 이게 점점 여러 라우트로 번지면서 "이거 어디서 또 해야 해?" 소리가 팀 내에서 나오기 시작했다. 중복이 쌓이면 나중에 정책을 바꿀 때 한 곳만 고치고 나머지를 놓치는 게 뻔하다. 그래서 이번에 src/lib/api-rate-limit.ts에 범용 rate limit 로직을 분리하고, src/middleware.ts에서 이를 연결하는 구조로 정리했다.

왜 미들웨어 레벨에서 처리해야 하나

rate limit을 라우트 핸들러 안에 넣는 건 초반엔 빠른 것처럼 보인다. 그런데 실제로 운영하다 보면 문제가 생긴다.

  • 정책 변경 시 산탄총 수술: 한도를 "분당 60 → 30"으로 바꾸면 건드려야 하는 파일이 한두 개가 아님
  • 누락 위험: 새 라우트 추가할 때 rate limit 코드 붙이는 걸 깜빡하기 쉬움 — 리뷰에서도 잡기 애매
  • 테스트 분산: 로직이 흩어져 있으면 단위 테스트도 따로따로 써야 함

미들웨어로 올리면 "이 미들웨어만 믿으면 된다"는 신뢰 지점이 생긴다. 팀원 입장에서도 새 API 만들 때 rate limit을 신경 쓸 인지 부하가 확 줄어든다.

구조 잡은 방식

api-rate-limit.ts범용(generic) 으로 뽑았다는 게 핵심이다. 엔드포인트마다 한도나 윈도우를 다르게 적용해야 하는 경우가 반드시 생기기 때문에, 고정값으로 만들면 금방 또 특수 케이스가 넘쳐난다.

// api-rate-limit.ts — 개념적 패턴 예시
export function createRateLimiter(options: RateLimitOptions) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = resolveKey(req, options.keyStrategy); // IP, userId 등
    const count = await store.increment(key, options.windowMs);

    if (count > options.max) {
      return res.status(429).json({
        error: "Too Many Requests",
        retryAfter: options.windowMs / 1000,
      });
    }

    res.setHeader("X-RateLimit-Limit", options.max);
    res.setHeader("X-RateLimit-Remaining", options.max - count);
    next();
  };
}

middleware.ts에서는 이 팩토리를 가져다가 라우트 그룹별로 다른 옵션을 주입하는 식으로 연결한다. 인증 관련 API는 더 빡빡하게, 일반 조회 API는 느슨하게 — 그 의사결정이 미들웨어 연결 코드 한 곳에 모이게 된다.

적용 대상 권장 정책 방향 키 전략
인증/로그인 엔드포인트 짧은 윈도우, 낮은 한도 IP 기반
인증 후 일반 API 넉넉한 윈도우 userId 기반
공개 조회 API 중간 수준 IP 기반
어드민 API 별도 정책 또는 bypass 역할 기반

코드리뷰에서 챙긴 것들

이번 PR에서 팀원들한테 특히 짚어달라고 한 포인트가 몇 가지 있었다.

429 응답 형식: Retry-After 헤더를 빠뜨리면 클라이언트가 얼마나 기다려야 할지 모른다. 스펙(RFC 6585)에도 명시돼 있고, 프론트엔드 팀이 재시도 로직 짤 때 이 헤더를 쓰기 때문에 꼭 넣어야 한다고 리뷰 코멘트로 남겼다.

키 충돌 설계: IP 기반 키를 쓸 때 로드밸런서 뒤에서 X-Forwarded-For를 제대로 파싱하지 않으면 모든 요청이 같은 키로 묶여서 정상 사용자까지 막힌다. 이 부분은 실제로 예전에 한 번 터졌던 경험이 있어서 코드 안에 명시적으로 처리했다.

스토어 선택: 인메모리 Map으로 시작하면 빠르지만 서버 인스턴스가 여러 개면 카운트가 인스턴스별로 따로 돌아간다. Redis 같은 외부 스토어가 없는 상황이라면 이 한계를 팀이 인지하고 있어야 한다. 문서 주석에 이 트레이드오프를 명시했다.

보안 관련 미들웨어는 "만들었다"보다 "제대로 동작하지 않았을 때 어떻게 되는지"를 더 깊이 생각해야 한다고 다시 한번 느꼈다. 단순한 유틸 함수 하나처럼 보여도 이게 없으면 서비스 전체가 무방비 상태가 된다는 걸 팀 전체가 체감하게 만드는 것도 팀장으로서 챙겨야 할 몫이다.

끝.


🛒 이 글과 어울리는 추천 상품

*위 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

댓글 0

첫 댓글 달아줘.