개발 노트/Backend,CMS,API

[Backend] API 캐시(Cache)와 레이트 리밋(Rate Limit) 적용할 때 놓치는 실무 운영 기준 정리

pposooj 2026. 5. 21. 12:21

웹이든 앱이든 서버를 같이 만지며 운영하다 보면, 같은 콘텐츠를 여러 사용자가 반복해서 조회하는 상황을 자주 보게 됩니다. 공지사항, 배너, 카테고리 목록 같은 공개 데이터는 매번 DB에서 무겁게 긁어올 필요가 없죠. 이럴 때 캐시를 적절히 붙여주면 응답 속도도 빨라지고 DB 부하도 크게 줄일 수 있습니다.

반대로 시스템을 보호하는 고민도 해야 합니다. 봇이 들어와서 목록 API를 과도하게 긁어가거나, 특정 검색 API를 짧은 시간에 마구 호출하면 서버 자원이 금방 바닥납니다. 이때는 Rate Limit(요청 제한)을 걸어서 필터링을 해줘야 합니다.

처음에는 '캐시는 빠르게 만드는 기능, 레이트 리밋은 과도한 요청을 막는 기능' 정도로 쉽게 생각하기 쉬운데요. 실무에서 기준 없이 대충 붙였다가는 오히려 데이터가 꼬이거나 앱이 터지는 장애 원인이 됩니다. 저의 경우도 예전에 캐시 키 설계나 무효화 타이밍 때문에 고생했던 기억이 있어서, 나중에 또 까먹지 않으려고 실무에서 챙겨야 할 설계 기준을 정리해 둡니다.

1. 기준 없이 적용했을 때 발생하는 문제들

아무런 가이드라인 없이 캐시와 레이트 리밋을 적용하면 운영 환경에서 다음과 같은 골치 아픈 일들이 터집니다.

  • 공개 콘텐츠 목록을 매번 DB에서 조회해서 전체적인 서버 부하가 커짐
  • 캐시 Key에 페이지 번호나 검색 조건이 빠져서, 다른 조건을 검색해도 똑같은 결과가 재사용됨
  • 사용자별 응답이 공유 캐시에 들어가서 다른 사람에게 내 개인정보가 노출됨 (가장 위험함)
  • CMS(관리자 페이지)에서 데이터를 수정했는데, 앱 API에는 오래된 캐시 데이터가 계속 내려감
  • 디도스성 과도한 요청이 들어와도 제어가 안 돼서 전체 API 응답이 마비됨
  • 레이트 리밋에 걸렸을 때 서버가 HTML 에러 페이지를 뱉어서 앱이 응답을 파싱하다가 크래시가 남
  • 로그나 캐시 키 정보에 실제 사용자의 토큰이나 IP, 개인정보가 고스란히 남음

캐시와 레이트 리밋은 양날의 검이라서 응답 범위와 실패 처리 기준을 명확히 정하지 않으면 안 됩니다.

2. 캐시해도 되는 API vs 조심해야 할 API

모든 API를 무작정 캐시하면 안 됩니다. 가장 먼저 해야 할 일은 "이 응답 값을 다른 사용자에게 그대로 보여줘도 되는가?"를 따져보는 것입니다.

API 유형 캐시 적합도 이유
공지사항 목록 높음 모든 사용자가 동일한 데이터를 조회하므로 효과가 큼
배너 목록 높음 변경 빈도가 낮고 완전한 공개 데이터임
카테고리 목록 높음 고정된 구조 데이터라 반복 조회가 절대적으로 많음
관광지 공개 목록 중간 검색/필터 조건이 다양하므로 Key에 이를 잘 조합해야 함
사용자 즐겨찾기 낮음 사용자별 데이터라 공유 캐시 적용 시 데이터 오염 위험
마이페이지 조회 낮음 개인정보가 직접 포함되어 있어 캐시에서 제외하는 게 안전
관리자 API 주의 데이터의 최신성과 실시간 권한 체크가 성능보다 우선임

💡 중요 포인트

사용자별 응답을 잘못 캐시하면 단순 성능 저하가 아니라 보안 사고로 이어집니다. 캐시를 적용하기 전에 해당 데이터의 스코프를 명확히 나누고, 처음 캐시를 도입할 때는 완전한 공개 API부터 차근차근 시작하는 편이 안전합니다.

3. 안정적인 Cache Key 설계 기준

캐시 Key는 응답을 구분하는 유일한 식별자입니다. 페이지 번호, 검색어, 정렬 방식, 필터 조건이 달라지면 응답 데이터도 당연히 달라지므로 이 조건들이 Key에 반드시 누락 없이 반영되어야 합니다.

Laravel 환경을 기준으로 개념을 짜보면 아래와 같이 꼼꼼하게 파라미터를 묶어줘야 합니다.

PHP
 
final class CacheKey
{
    public static function noticeList(array $params): string
    {
        // 파라미터 기본값 정제 및 검증
        $page = max(1, (int) ($params['page'] ?? 1));
        $limit = min(50, max(1, (int) ($params['limit'] ?? 20)));
        $keyword = trim((string) ($params['keyword'] ?? ''));
        $category = trim((string) ($params['category'] ?? ''));
        $sort = (string) ($params['sort'] ?? 'latest');

        // 구조화된 캐시 키 생성
        return implode(':', [
            'api',
            'v1',
            'notices',
            'list',
            "page={$page}",
            "limit={$limit}",
            'keyword=' . sha1($keyword), // 검색어 길이나 특수문자 대응을 위해 hash 변환
            "category={$category}",
            "sort={$sort}",
        ]);
    }
}

여기서 주의하셔야 할 점은 검색어(keyword)입니다. 검색어를 Key에 그대로 밀어 넣으면 문자열이 너무 길어지거나 공백, 특수문자가 섞여서 캐시 시스템이 오작동할 수 있습니다. 그래서 sha1() 같은 해시 함수로 변환해서 Key 길이를 일정하게 맞추는 게 안전합니다.

만약 검색어에 개인정보 성격의 텍스트가 들어갈 수 있다면, 로그나 캐시 서버 메모리에 날것으로 남지 않도록 보안 처리를 한 번 더 검토해야 합니다. Key에 조건 하나만 빼먹어도 다른 사람이 검색한 결과가 나한테 나오는 대형 사고가 터집니다.

4. TTL 설정과 캐시 무효화(Invalidation) 전략

TTL(유지 시간)을 무조건 길게 잡으면 캐시 적중률은 올라가겠지만 데이터 최신성이 떨어집니다. 반대로 너무 짧으면 DB를 계속 찌르겠죠. 정답은 없지만 실무 데이터 성격에 따라 아래처럼 유연하게 분리합니다.

  • 카테고리 구조: 1시간 ~ 1일 (변경 빈도가 매우 낮음)
  • 이벤트 배너: 5분 ~ 30분 (노출 기간 수시 변경 가능성)
  • 공지사항 목록: 1분 ~ 10분 (최신성 중요도가 중간 수준)
  • 일반 검색 결과: 30초 ~ 5분 (조건이 다양하고 실시간성 필요)

처음에는 무조건 짧은 TTL로 시작해서 모니터링을 거치며 늘려나가는 것을 추천합니다.

더 중요한 건 캐시 무효화(Invalidation)입니다. CMS에서 관리자가 공지사항 제목을 수정하거나 비공개 처리를 했는데, 앱 API에서는 옛날 캐시 때문에 10분 동안 계속 노출되면 운영상 곤란하겠죠. 수정 즉시 캐시를 날려주는 구조가 결합되어야 합니다.

PHP
 
final class NoticeCacheInvalidator
{
    public static function flushListCache(): void
    {
        // Laravel에서 Tag 기반 캐시(Redis 등)를 지원하는 경우 tag로 묶어 밀어버리는 게 편합니다.
        Cache::tags(['api:v1:notices'])->flush();
    }
}

만약 프로젝트에서 사용하는 캐시 저장소가 Tag 기능을 지원하지 않는다면(예: Memcached 일부 환경이나 기본 파일 캐시), 캐시를 생성할 때 Key Prefix나 생성된 캐시 키 목록을 Redis Set 같은 곳에 따로 관리하면서 일괄 삭제하는 로직을 직접 구현해야 하므로 도입 전 인프라 환경 확인이 필수적입니다.

팩토리 무효화 검토 시점

  • CMS에서 게시물의 공개/비공개 상태가 변경될 때
  • 데이터의 내용(제목, 이미지, 본문 등)이 수정되거나 삭제/복구될 때
  • 정렬 순서나 카테고리 기준 값이 바뀔 때

5. 시스템을 보호하는 Rate Limit 기본 구조

서버 자원을 고갈시키는 무분별한 요청을 제어하기 위해 레이트 리밋을 세팅할 때는 공개 API인증된 사용자 API의 기준을 다르게 가져가야 합리적입니다.

PHP
 
// Laravel RouteServiceProvider 또는 미들웨어 단에서의 개념 구성
RateLimiter::for('public-api', function ($request) {
    // 로그인 안 한 공개 API는 접근 IP 기준으로 타이트하게 제한
    return Limit::perMinute(60)->by($request->ip());
});

RateLimiter::for('user-api', function ($request) {
    // 인증된 회원은 고유 ID 기준으로 조금 더 여유 있게 허용
    return Limit::perMinute(120)->by(optional($request->user())->id ?: $request->ip());
});

무거운 쿼리가 들어가는 검색 API 같은 곳은 리밋 수치를 낮게 잡고, 가벼운 카테고리 목록 같은 곳은 봇의 수집 속도를 감안하더라도 조금 더 넉넉하게 열어주는 식으로 API 성격별 가중치 배분이 필요합니다.

6. 앱(Client)을 배려한 429 JSON 응답 포맷

많은 개발자가 레이트 리밋 정책을 세우면서 '차단' 자체에만 집중한 나머지 에러 응답 포맷을 놓치곤 합니다. 서버 기본 Nginx나 프레임워크 에러 설정 그대로 두면 뜬금없이 웹용 HTML 에러 페이지(<html><body>Too Many Requests...)가 리턴되기도 합니다.

이러면 API를 받아 쓰는 모바일 앱 단에서는 파싱 에러(Parsing Error)가 나면서 앱이 강제 종료되거나 예외 처리를 못 하게 됩니다. 반드시 사전에 약속된 JSON 규격으로 내려주어야 합니다.

JSON
 
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "단시간에 너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해 주세요.",
    "retryAfterSeconds": 30
  }
}

이렇게 정돈된 구조로 내려주면, 클라이언트(앱) 개발자분들이 이 JSON 계약을 보고 retryAfterSeconds 값을 파싱해 30초 동안 재시도 버튼을 비활성화하거나 부드러운 토스트 안내 메시지를 띄워주는 등 깔끔한 UX 처리가 가능해집니다. 내부 차단 규칙이나 상세 IP 정보까지 메시지에 구태여 노출할 필요는 없습니다.

7. 실무 배포 전 최종 체크리스트

코드를 다 짜고 스테이징이나 운영 서버에 올리기 전에 아래 7가지 질문에 제대로 대응했는지 체크해 봅니다.

  • [ ] page, keyword, filter, sort 파라미터가 빠짐없이 cache key에 반영되는가?
  • [ ] 사용자 고유 데이터나 권한별 응답이 공통 공유 캐시에 들어가지 않도록 격리했는가?
  • [ ] CMS에서 컨텐츠를 수정/삭제했을 때 관련 캐시 묶음이 즉시 무효화(Flush) 되는가?
  • [ ] 설정한 TTL 주기가 데이터의 비즈니스 최신성 요구사항과 매칭되는가?
  • [ ] Rate Limit 한도 초과 시 429 상태코드와 함께 표준화된 JSON 에러 응답이 내려가는가?
  • [ ] 공개 API와 회원 전용 API의 요청 제한 기준(IP vs UserID)을 분리했는가?
  • [ ] 에러 로그나 캐시 키 관리 대장에 실제 토큰, IP, 민감한 개인정보가 날것으로 남지 않는가?

💡 마치며

백엔드 개발을 하면서 캐시와 레이트 리밋은 서버의 숨통을 틔워주고 서비스를 안정적으로 유지해 주는 고마운 도구입니다. 하지만 단순히 Cache::remember() 같은 내장 메서드 하나 붙여놓고 끝내기에는 운영 환경에서 고려해야 할 변수가 참 많습니다.

가장 좋은 시작점은 변경이 거의 없고 노출되어도 상관없는 완전한 공개 콘텐츠 API부터 캐시를 적용해 보는 것입니다. 성능을 무작정 올리는 구조를 고민하기보다, 엉뚱한 데이터가 섞여서 나가지 않도록 안전한 격리 범위를 명확히 선언하는 것이 장애를 예방하는 가장 확실한 길입니다.