웹 서비스를 만들다 보면 성능 최적화를 위해 캐시를 사용하게 된다. 이미지, CSS, JavaScript 같은 정적 파일을 매번 서버에서 새로 받아오면 비효율적이기 때문에 브라우저나 CDN은 응답을 저장해두고 재사용한다. 캐시는 서버 부하를 줄이고 페이지 로딩 속도를 빠르게 만드는 매우 중요한 기술이다.
하지만 캐시는 단순한 성능 기능이 아니다. 어떤 응답을 저장할지, 얼마나 오래 저장할지, 누구에게 다시 보여줄지를 잘못 정하면 보안 취약점으로 이어질 수 있다. 특히 로그인한 사용자의 개인정보가 담긴 응답이 공유 캐시에 저장되거나, 공격자가 조작한 응답이 CDN에 저장되면 다른 사용자에게 잘못된 내용이 전달될 수 있다.
즉, 웹 캐시는 “빠르게 보여주기 위한 기능”이지만, 동시에 “무엇을 누구에게 다시 보여줄 것인가”를 결정하는 보안 경계이기도 하다.
이번 글에서는 웹 캐시의 기본 동작 방식, Cache-Control 헤더, CDN 캐시 구조, 캐시 키 문제, 그리고 Web Cache Poisoning이 어떤 원리로 발생하는지 정리해보려 한다.
1. 웹 캐시란 무엇인가
웹 캐시는 서버 응답을 임시로 저장해두었다가 같은 요청이 다시 들어왔을 때 저장된 응답을 재사용하는 기술이다.
예를 들어 사용자가 다음 파일을 요청한다고 하자.
GET /static/main.css HTTP/1.1Host: example.com서버가 매번 CSS 파일을 새로 보내는 대신, 브라우저는 이전에 받은 응답을 저장해두고 다시 사용할 수 있다.
HTTP/1.1 200 OKContent-Type: text/cssCache-Control: public, max-age=31536000위 응답은 “이 리소스는 공개 캐시에 저장해도 되고, 31536000초 동안 재사용할 수 있다”는 의미이다.
캐시가 사용되는 위치는 여러 곳이다.
| 위치 | 설명 |
|---|---|
| 브라우저 캐시 | 사용자 브라우저 내부에 저장 |
| 프록시 캐시 | 중간 프록시 서버가 응답 저장 |
| CDN 캐시 | 전 세계 엣지 서버가 응답 저장 |
| 서버 내부 캐시 | 백엔드 애플리케이션 또는 메모리 저장소에서 캐싱 |
보통 이미지, 폰트, CSS, JS 같은 정적 파일은 캐시하기 적합하다. 반대로 마이페이지, 결제 내역, 개인 설정, 관리자 페이지처럼 사용자마다 달라지는 응답은 캐싱에 주의해야 한다.
2. Cache-Control 헤더
HTTP 캐시 정책에서 가장 중요한 헤더는 Cache-Control이다. 서버는 이 헤더를 통해 브라우저나 CDN에게 응답을 어떻게 저장해야 하는지 알려준다.
대표적인 값은 다음과 같다.
| 값 | 의미 |
|---|---|
| public | 공유 캐시에 저장 가능 |
| private | 개인 브라우저 캐시에만 저장 가능 |
| no-store | 응답을 저장하지 않음 |
| no-cache | 저장할 수는 있지만 사용 전 서버 검증 필요 |
| max-age | 캐시 유효 시간 지정 |
| s-maxage | 공유 캐시용 유효 시간 지정 |
예를 들어 정적 파일은 다음과 같이 설정할 수 있다.
Cache-Control: public, max-age=31536000, immutable반대로 개인정보가 담긴 응답은 다음과 같이 설정하는 것이 안전하다.
Cache-Control: no-store2.1 public
public은 응답을 브라우저뿐 아니라 CDN 같은 공유 캐시에도 저장할 수 있다는 의미이다.
Cache-Control: public, max-age=86400이미지, CSS, JavaScript, 공개 게시글 페이지처럼 사용자에 따라 내용이 달라지지 않는 리소스에 적합하다.
하지만 로그인 사용자마다 내용이 달라지는 응답에 public이 설정되면 위험하다.
HTTP/1.1 200 OKCache-Control: public, max-age=600
{ "username": "panda", "email": "panda@example.com"}이런 응답이 공유 캐시에 저장되면 다른 사용자에게 같은 응답이 전달될 가능성이 생긴다.
2.2 private
private은 응답을 사용자 개인 브라우저에는 저장할 수 있지만, CDN이나 프록시 같은 공유 캐시에는 저장하면 안 된다는 의미이다.
Cache-Control: private, max-age=300사용자별로 달라지는 응답이지만 브라우저 내부 캐시는 허용하고 싶을 때 사용할 수 있다.
다만 민감한 정보가 들어간 응답이라면 private보다 no-store가 더 안전할 수 있다.
2.3 no-store
no-store는 응답을 어떤 캐시에도 저장하지 말라는 의미이다.
Cache-Control: no-store다음과 같은 응답에는 no-store를 사용하는 것이 좋다.
- 로그인 응답
- 세션 정보 응답
- 마이페이지
- 결제 내역
- 개인정보 조회
- 관리자 기능
- 비밀번호 변경 페이지
no-store는 캐시를 통한 정보 유출을 막는 가장 강한 설정이다.
2.4 no-cache
no-cache는 이름 때문에 헷갈리기 쉽다. “저장하지 말라”는 뜻이 아니라, 저장은 할 수 있지만 재사용하기 전에 서버에 검증하라는 뜻이다.
Cache-Control: no-cache브라우저는 응답을 저장할 수 있지만, 다시 사용하기 전에 서버에게 이 응답이 아직 유효한지 확인해야 한다.
따라서 민감한 정보 저장 자체를 막고 싶다면 no-cache가 아니라 no-store를 사용해야 한다.
3. 캐시 키란 무엇인가
캐시는 요청을 구분하기 위해 기준값을 사용한다. 이를 캐시 키라고 한다.
가장 단순하게는 URL이 캐시 키가 된다.
https://example.com/static/main.css사용자가 같은 URL을 다시 요청하면 캐시는 저장된 응답을 반환할 수 있다.
하지만 실제 웹에서는 URL만으로 응답을 구분하기 어려운 경우가 많다. 같은 URL이라도 요청 헤더, 쿠키, 언어 설정, 인코딩 방식에 따라 응답이 달라질 수 있기 때문이다.
예를 들어 다음 두 요청은 같은 URL을 요청하지만 사용자 쿠키가 다르다.
GET /mypage HTTP/1.1Host: example.comCookie: session_id=user_aGET /mypage HTTP/1.1Host: example.comCookie: session_id=user_b만약 캐시가 쿠키를 고려하지 않고 URL만 기준으로 응답을 저장한다면 문제가 생긴다. user_a의 마이페이지 응답이 캐시에 저장된 뒤, user_b에게 그대로 전달될 수 있기 때문이다.
이것이 캐시 정보 유출의 핵심 원리이다.
4. 개인 응답 캐싱으로 인한 정보 유출
가장 직관적인 캐시 취약점은 개인 응답이 공유 캐시에 저장되는 경우이다.
예를 들어 로그인한 사용자가 /api/me를 요청한다고 하자.
GET /api/me HTTP/1.1Host: example.comCookie: session_id=abc123서버가 다음과 같이 응답했다.
HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: public, max-age=600
{ "id": 1, "name": "panda", "email": "panda@example.com"}여기서 문제는 Cache-Control: public이다. 이 응답은 특정 사용자의 개인정보를 포함하지만, 공유 캐시에 저장 가능하다고 표시되어 있다.
이후 다른 사용자가 같은 API를 요청하면 캐시는 원래 서버까지 가지 않고 저장된 응답을 반환할 수 있다.
GET /api/me HTTP/1.1Host: example.comCookie: session_id=other_user캐시가 잘못 동작하면 다른 사용자에게 panda@example.com이 포함된 응답이 전달될 수 있다.
이런 문제는 단순한 성능 버그가 아니라 개인정보 유출 취약점이다.
5. CDN 캐시와 보안
CDN은 사용자의 위치와 가까운 엣지 서버에서 정적 파일을 제공해 성능을 높이는 서비스이다. 대표적으로 이미지, JS, CSS, 다운로드 파일 등을 빠르게 제공하기 위해 사용된다.
CDN의 기본 구조는 다음과 같다.
| 단계 | 동작 |
|---|---|
| 1 | 사용자가 CDN으로 요청 전송 |
| 2 | CDN이 캐시에 응답이 있는지 확인 |
| 3 | 있으면 CDN이 바로 응답 |
| 4 | 없으면 원본 서버로 요청 |
| 5 | 원본 서버 응답을 CDN이 저장 |
| 6 | 이후 같은 요청에 저장된 응답 반환 |
정적 파일에는 매우 유용하지만, 동적 응답에 CDN 캐시가 적용되면 위험해진다.
예를 들어 다음과 같은 URL이 있다고 하자.
https://example.com/dashboard이 페이지는 로그인한 사용자마다 다른 내용을 보여준다. 그런데 CDN이 URL만 보고 이 페이지를 캐싱하면 첫 번째 사용자의 대시보드가 다른 사용자에게 노출될 수 있다.
따라서 CDN을 사용할 때는 어떤 경로를 캐시할지 명확하게 구분해야 한다.
| 경로 | 캐시 여부 |
|---|---|
| /static/* | 캐시 가능 |
| /assets/* | 캐시 가능 |
| /images/* | 캐시 가능 |
| /api/me | 캐시 금지 |
| /dashboard | 캐시 금지 |
| /admin/* | 캐시 금지 |
캐시 대상 경로를 넓게 잡으면 보안 사고로 이어질 수 있다.
6. Vary 헤더
Vary 헤더는 캐시가 어떤 요청 헤더를 기준으로 응답을 구분해야 하는지 알려준다.
예를 들어 서버가 언어 설정에 따라 다른 응답을 반환한다고 하자.
GET /notice HTTP/1.1Host: example.comAccept-Language: koGET /notice HTTP/1.1Host: example.comAccept-Language: en이 경우 같은 /notice URL이라도 언어에 따라 응답이 달라질 수 있다. 서버는 다음과 같이 응답할 수 있다.
Vary: Accept-Language이 헤더는 캐시에게 Accept-Language 값이 다르면 서로 다른 응답으로 취급하라고 알려준다.
인증과 관련된 응답에서는 쿠키나 Authorization 헤더에 따라 응답이 달라질 수 있다. 그런데 이런 응답은 대체로 공유 캐시에 저장하지 않는 것이 더 안전하다.
Cache-Control: no-store즉, Vary는 캐시 구분 기준을 알려주는 장치이지만, 민감한 응답을 안전하게 만드는 만능 해결책은 아니다.
7. 캐시 키 불일치 문제
캐시 보안에서 중요한 문제 중 하나는 서버가 응답을 만들 때 참고한 값과 캐시가 구분 기준으로 삼는 값이 다를 때 발생한다.
예를 들어 서버가 X-Forwarded-Host 헤더를 사용해 페이지 안의 링크를 만든다고 하자.
GET /reset HTTP/1.1Host: example.comX-Forwarded-Host: attacker.example서버가 이 헤더를 신뢰해서 응답 내부에 다음과 같은 링크를 넣는다면 문제가 생길 수 있다.
<a href="https://attacker.example/reset/confirm">비밀번호 재설정</a>그런데 캐시는 X-Forwarded-Host를 캐시 키에 포함하지 않고 URL만 기준으로 응답을 저장한다고 하자.
캐시 키: https://example.com/reset이 경우 조작된 응답이 캐시에 저장되고, 이후 정상 사용자가 /reset에 접속했을 때 오염된 응답을 받을 수 있다.
이처럼 서버는 어떤 헤더를 보고 응답을 바꾸는데, 캐시는 그 헤더를 구분하지 않는 상황을 캐시 키 불일치 문제라고 볼 수 있다.
8. Web Cache Poisoning이란 무엇인가
Web Cache Poisoning은 공격자가 캐시에 저장될 응답을 조작하여, 이후 다른 사용자에게 오염된 응답이 전달되게 만드는 공격이다.
핵심 흐름은 다음과 같다.
- 공격자가 특정 요청을 조작한다.
- 서버가 조작된 요청을 바탕으로 응답을 생성한다.
- CDN이나 프록시 캐시가 그 응답을 저장한다.
- 다른 사용자가 같은 캐시 키로 요청한다.
- 캐시가 오염된 응답을 반환한다.
여기서 중요한 점은 공격자가 서버를 직접 해킹하지 않아도 된다는 것이다. 서버가 특정 헤더나 파라미터를 응답에 반영하고, 캐시가 이를 잘못 저장하면 문제가 발생할 수 있다.
예를 들어 다음과 같은 응답이 있다고 하자.
HTTP/1.1 200 OKCache-Control: public, max-age=600Content-Type: text/html<script src="https://cdn.example.com/app.js"></script>만약 서버가 특정 헤더를 기반으로 스크립트 주소를 바꾸고, 그 응답이 공유 캐시에 저장된다면 다른 사용자에게도 조작된 스크립트 주소가 포함된 페이지가 전달될 수 있다.
Web Cache Poisoning은 일반적인 XSS와 다르게 “한 번의 응답 조작”이 캐시를 통해 여러 사용자에게 영향을 줄 수 있다는 점에서 위험하다.
9. 캐시와 XSS가 연결되는 경우
캐시 자체가 JavaScript를 실행하는 것은 아니다. 하지만 캐시된 응답 안에 공격자가 조작한 HTML이나 JavaScript 경로가 포함되면 XSS와 연결될 수 있다.
예를 들어 서버가 어떤 요청 값을 검증 없이 HTML에 반영한다고 하자.
<script src="https://example.com/static/app.js"></script>그런데 특정 헤더나 파라미터에 의해 이 값이 바뀔 수 있다면 다음과 같은 문제가 생길 수 있다.
<script src="https://attacker.example/app.js"></script>이 응답이 캐시에 저장되면 이후 정상 사용자도 오염된 응답을 받을 수 있다.
즉, Web Cache Poisoning은 다음 문제들이 함께 겹칠 때 발생하기 쉽다.
- 서버가 특정 요청 값을 응답에 반영함
- 해당 값이 캐시 키에 포함되지 않음
- 응답이 공유 캐시에 저장됨
- 응답이 HTML, JavaScript, 리다이렉트 등 보안에 민감한 동작에 영향을 줌
따라서 캐시 보안은 단순히 Cache-Control만 보는 것이 아니라, 서버가 어떤 요청 값을 신뢰하는지도 함께 봐야 한다.
10. 안전한 캐시 설계 방법
10.1 민감한 응답에는 no-store 사용
개인정보, 인증 상태, 관리자 기능, 결제 정보처럼 민감한 응답에는 다음 헤더를 설정하는 것이 안전하다.
Cache-Control: no-store추가로 오래된 환경까지 고려한다면 다음과 같이 함께 설정할 수 있다.
Cache-Control: no-storePragma: no-cacheExpires: 0다만 현대적인 HTTP 캐시 제어에서는 Cache-Control이 핵심이다.
10.2 정적 파일과 동적 페이지를 분리
정적 파일은 캐시하기 좋지만, 동적 페이지는 사용자 상태에 따라 내용이 달라질 수 있다. 따라서 경로를 명확하게 분리하는 것이 좋다.
/static/*/assets/*/images/*위와 같은 경로는 캐시 대상으로 두기 쉽다.
반대로 다음 경로는 캐시 금지 대상으로 관리하는 것이 좋다.
/api/*/mypage/dashboard/admin/*/auth/*/payment/*경로 설계가 애매하면 CDN 설정도 애매해지고, 실수로 민감한 응답이 캐시될 가능성이 커진다.
10.3 CDN 캐시 규칙을 최소 권한으로 설정
CDN에서 모든 경로를 기본 캐시 대상으로 두는 것은 위험하다. 안전한 방식은 캐시 가능한 경로만 명시적으로 허용하는 것이다.
나쁜 예시는 다음과 같다.
/*전체 경로를 캐시 대상으로 잡으면 동적 페이지나 API까지 캐시될 수 있다.
더 안전한 방식은 다음과 같다.
/static/*/assets/*/images/*캐시는 “필요한 것만 저장한다”는 원칙으로 설정해야 한다.
10.4 요청 헤더를 신뢰하지 않기
서버는 Host, X-Forwarded-Host, X-Original-URL, X-Rewrite-URL 같은 헤더를 조심해서 다뤄야 한다. 이런 헤더들은 프록시나 로드밸런서 환경에서 사용되지만, 잘못 신뢰하면 응답 생성에 영향을 줄 수 있다.
특히 다음과 같은 용도로 직접 사용하면 위험하다.
- 비밀번호 재설정 링크 생성
- 리다이렉트 URL 생성
- 스크립트 주소 생성
- 메타 태그 생성
- canonical URL 생성
외부에서 들어온 헤더를 그대로 신뢰하지 말고, 서버 설정에 저장된 공식 도메인을 기준으로 URL을 생성하는 것이 안전하다.
좋은 방식: 설정 파일의 BASE_URL 사용위험한 방식: 요청 헤더의 Host 값을 그대로 사용10.5 캐시 키와 응답 생성 기준 맞추기
서버가 특정 헤더나 쿠키에 따라 응답을 다르게 만든다면, 캐시도 그 값을 구분할 수 있어야 한다. 그렇지 않으면 서로 다른 요청이 같은 캐시 응답을 공유하게 된다.
하지만 인증 기반 응답이라면 캐시 키를 복잡하게 조정하는 것보다 아예 공유 캐시에 저장하지 않는 것이 안전하다.
Cache-Control: private또는 더 민감한 경우:
Cache-Control: no-store11. 개발자가 확인해야 할 체크리스트
웹 캐시를 사용할 때는 다음 항목을 확인하는 것이 좋다.
- 개인정보가 담긴 응답에
Cache-Control: no-store가 설정되어 있는가? - 로그인, 로그아웃, 회원정보, 결제, 관리자 페이지가 CDN에 캐시되지 않는가?
- CDN이 전체 경로를 캐시하도록 설정되어 있지 않은가?
- 정적 파일과 동적 페이지의 경로가 명확하게 분리되어 있는가?
- 사용자마다 달라지는 API 응답이 공유 캐시에 저장되지 않는가?
public캐시 정책이 민감한 응답에 적용되지 않았는가?- 서버가
Host,X-Forwarded-Host같은 요청 헤더를 응답 생성에 그대로 사용하지 않는가? - 캐시 키에 포함되지 않는 값이 응답에 영향을 주지 않는가?
Vary헤더가 필요한 응답에 적절히 설정되어 있는가?- 캐시 설정 변경 후 실제 응답 헤더를 확인했는가?
캐시는 설정 파일에서만 확인하면 안 된다. 실제 배포 환경에서 브라우저 개발자 도구나 프록시 도구를 통해 응답 헤더를 직접 확인해야 한다.
12. 마무리
오늘은 웹 캐시가 어떻게 정보 유출 취약점으로 이어질 수 있는지 알아보았다. 캐시는 성능 최적화를 위한 기술이지만, 잘못 설정하면 개인 응답이 다른 사용자에게 노출되거나 오염된 응답이 CDN을 통해 전파될 수 있다.
핵심은 간단하다. 공개 정적 파일은 적극적으로 캐시하되, 사용자별로 달라지는 응답은 공유 캐시에 저장하지 않아야 한다. 특히 로그인 상태, 개인정보, 결제, 관리자 페이지와 관련된 응답에는 Cache-Control: no-store를 적용하는 것이 안전하다.
또한 캐시 키와 응답 생성 기준이 어긋나면 Web Cache Poisoning 같은 문제가 발생할 수 있다. 서버가 어떤 요청 값을 신뢰하는지, CDN이 어떤 기준으로 응답을 저장하는지 함께 확인해야 한다.
웹 캐시는 단순히 빠른 사이트를 만들기 위한 기능이 아니다. 잘못된 응답을 저장하지 않고, 잘못된 사용자에게 전달하지 않도록 설계해야 하는 보안 요소이다. 성능 최적화와 보안은 따로 떨어진 개념이 아니라, 같은 구조 위에서 함께 설계되어야 한다.