최근 서버를 설계하면서 조회 API의 캐시 전략에 대해 깊게 고민하게 됐다. 특히 트래픽이 늘어날수록 서버 부하를 어떻게 줄일 것인가, 그리고 보안 이슈 없이 캐시를 어떻게 적용할 것인가는 피할 수 없는 주제다.
이 글은 다음 질문에서 출발한다.
- GET 조회 API에 Cache-Control은 왜 필요한가?
- Redis 캐시와 HTTP 캐시는 무엇이 다른가?
- Cache-Control을 컨트롤러마다 두는 게 맞을까?
- 중앙에서 캐시 정책을 통제하는 방법은?
1. 캐시는 왜 쓰는가? (진짜 이유)
캐시를 쓰는 이유를 흔히 "응답을 빠르게 하기 위해서"라고 말하지만, 실무에서는 더 중요한 목적이 있다.
캐시는 트래픽이 커질수록 서버가 같은 일을 반복하지 않게 막기 위한 장치다.
예를 들어 일정 조회 API가 초당 수천 번 호출될 때, 매번 DB를 조회한다면 결국 병목은 DB에서 발생한다. 캐시는 이 반복을 제거해 서버와 DB를 보호한다.
2. HTTP 캐시 vs Redis 캐시
2-1. HTTP 캐시 (브라우저 / CDN / 프록시)
- 서버 밖에서 동작
- 같은 요청이면 서버까지 아예 오지 않음
- Cache-Control 헤더로 제어
Cache-Control: max-age=30
위 설정이 있으면, 같은 요청은 30초 동안 서버를 거치지 않는다.
2-2. Redis 캐시 (애플리케이션 캐시)
- 서버 내부에서 동작
- GET/POST 구분 없음
- DB 조회/계산 결과를 캐싱
val key = "schedule:${date}"
redis.get(key) ?: service.getSchedule(date).also {
redis.set(key, it, 30)
}
중요한 차이점은 HTTP 캐시는 서버까지 요청을 막고, Redis 캐시는 서버 내부에서 DB 접근을 막는다는 점이다.
3. 왜 로그인/인증 API에는 HTTP 캐시를 쓰면 안 될까?
HTTP 캐시는 클라이언트 또는 중간자에 저장된다. 로그인 정보나 개인정보 응답이 캐시되면 심각한 보안 사고로 이어질 수 있다.
그래서 인증/개인정보 API의 철칙은 다음과 같다.
Cache-Control: no-store
- 로그인
- /me, /profile
- 토큰 재발급
이런 API는 HTTP 캐시 금지, 대신 필요하면 Redis 캐시만 사용한다.
4. GET 조회 API에서 Cache-Control은 어디에 두는 게 좋을까?
처음에는 컨트롤러마다 이렇게 두게 된다.
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(30, TimeUnit.SECONDS))
.body(response)
하지만 API가 늘어나면 캐시 정책이 흩어지고 관리가 어려워진다.
이때 등장하는 게 중앙 통제 방식이다.
5. Cache-Control을 중앙에서 통제하는 Interceptor
Spring에서는 HandlerInterceptor를 사용해 Cache-Control을 중앙에서 관리할 수 있다.
5-1. 기본 예시
class CacheControlInterceptor : HandlerInterceptor {
override fun postHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
modelAndView: ModelAndView?
) {
if (request.requestURI.startsWith("/schedule")) {
response.setHeader("Cache-Control", "max-age=30")
}
}
}
이렇게 하면 /schedule/**로 들어오는 모든 요청에 대해 캐시 정책을 한 곳에서 관리할 수 있다.
5-2. 실무에서 반드시 필요한 안전장치
그대로 쓰면 위험할 수 있기 때문에, 실무에서는 다음 조건을 꼭 추가한다.
- GET 요청만 적용
- 민감한 API는 캐시 금지
- 컨트롤러에서 이미 설정한 헤더는 존중
class CacheControlInterceptor : HandlerInterceptor {
override fun postHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
modelAndView: ModelAndView?
) {
// 이미 설정된 Cache-Control이 있으면 덮어쓰지 않음
if (response.getHeader("Cache-Control") != null) return
// GET 요청만 대상
if (request.method != "GET") return
// 인증/개인정보 API는 캐시 금지
if (request.requestURI.startsWith("/auth") ||
request.requestURI.startsWith("/me")) {
response.setHeader("Cache-Control", "no-store")
return
}
// schedule 조회 캐시
if (request.requestURI.startsWith("/schedule")) {
response.setHeader("Cache-Control", "max-age=30")
}
}
}
6. 중앙 통제 방식의 장점
- 컨트롤러 코드가 깔끔해진다
- 캐시 정책 변경을 한 곳에서 관리
- 팀 단위 규칙을 강제할 수 있다
컨트롤러는 비즈니스 로직에 집중하고, 캐시는 정책으로 관리하는 구조가 된다.
7. 정리
- 캐시는 성능보다 안정성을 위한 도구다
- HTTP 캐시와 Redis 캐시는 역할이 다르다
- 민감한 데이터에는 HTTP 캐시를 절대 쓰지 않는다
- GET 조회 API는 Cache-Control을 명시적으로 관리하자
- Interceptor를 활용하면 중앙 통제가 가능하다
캐시는 옵션이 아니라 API 스펙의 일부다.
조회 API를 설계할 때부터 캐시 전략을 함께 고민하면, 트래픽이 커져도 훨씬 안정적인 서버를 만들 수 있다.
English Ver.
https://jaemoi8.tistory.com/57
Practical Strategies for Centralized Cache-Control in GET APIs
Recently, while designing a server, I found myself thinking deeply about cache strategies for read-only APIs. As traffic grows, questions like how to reduce server load and how to apply caching without introducing security risks become unavoidable.This art
jaemoi8.tistory.com
'개발' 카테고리의 다른 글
| 소셜 로그인을 위한 서버 설계(코드가 아닌 사전 설계) (0) | 2026.01.23 |
|---|---|
| Spring Boot 인증 - JWT 정체와 생성 원리 (0) | 2026.01.22 |
| 공간정보 뷰에서 데이터가 없으면 지점이 사라지는 문제와 SQL 튜닝 과정 — KEEP (DENSE_RANK LAST …) 활용하기 (0) | 2026.01.07 |
| HTTP 통신의 실제 모습 – 라이브러리가 감추고 있던 바이트 스트림 처리 (0) | 2025.12.09 |
| Spring Boot(Kotlin) — 12편. 완성된 프로젝트 템플릿 공유 (0) | 2025.11.27 |