CDN 을 붙였더니 남의 이름이 보인다

CDN 을 붙였더니 내 계정에 다른 사람 이름이 보인다는 문의가 들어왔다. 에러는 한 줄도 없고 지표는 오히려 좋아졌다. 캐시는 주소만 보고 사람은 안 본다.

속도를 올리려고 CDN 을 붙였다. 정적 파일은 물론이고 페이지까지 캐시하도록 설정했다.

며칠 뒤 사용자에게서 이상한 문의가 온다. 내 계정으로 들어갔는데 다른 사람 이름이 보인다는 것.

처음엔 안 믿었다. 재현이 안 되니까. 비슷한 문의가 여러 건 쌓이고 나서야 캐시를 열어 봤다. 로그인한 사용자의 페이지가 통째로 저장돼 있었다. 그게 다음 사람에게 그대로 나가고 있었다.

이건 성능 문제가 아니다. 개인정보 유출 사고다. 그리고 이런 사고는 로그에 에러 한 줄 안 남긴다.

응답 코드는 200, 지표는 정상. 오히려 캐시 적중률이 올라서 성능이 좋아 보인다.

같은 주소에 다른 사람이 있다

캐시는 "같은 주소면 같은 응답"이라는 전제 위에 서 있다. 그런데 로그인한 사용자의 페이지는 주소가 같아도 사람마다 내용이 다르다. 전제가 깨지는 지점이다.

무엇을 기준으로 캐시를 나눌지 정하는 게 캐시 키다. 기본은 주소만 본다. 쿠키나 인증 헤더는 안 본다. 그래서 A 가 받은 응답이 B 에게 고스란히 나간다.

그럼 왜 이렇게 설정돼 있었을까? 대개 응답 쪽에서 "캐시해도 된다"고 말했기 때문이다. 캐시 제어 헤더를 명시하지 않으면 중간 캐시가 나름의 규칙으로 판단할 수 있다. 사람마다 다른 응답인데 그 사실을 알려 주지 않은 거다.

이 사고는 정말 조용하다. 에러가 안 나고, 응답 코드도 200 이고, 지표도 멀쩡하다. 캐시 적중률이 오르니 성능 지표는 오히려 좋아 보인다. 사용자가 알려 주기 전까지 아무도 모른다.

발견이 늦는 이유가 하나 더 있다. 재현이 어렵다. 개발자가 확인할 때는 자기 브라우저에 캐시가 없거나 다른 노드로 붙어서 정상으로 보인다. 여러 사람이 짧은 시간에 같은 노드를 거쳐야 그제야 드러난다.

개인화된 응답엔 금지를 적어 둔다

개인화된 응답은 캐시 금지를 명시한다:

코드
· 로그인 사용자 응답 — 명시적으로 막는다
 Cache-Control: private, no-store

 Cache-Control: no-cache
 이건 '저장하지 마라'가 아니라 '쓰기 전에 확인하라'다

· 공유 캐시만 막고 브라우저 캐시는 허용하려면
 Cache-Control: private, max-age=0

· 응답이 요청 헤더에 따라 달라진다면 반드시 알린다
 Vary: Cookie, Authorization, Accept-Language
 Vary: Cookie 는 쿠키가 조금만 달라도 캐시가 갈라진다
 적중률이 사실상 0 이 되므로 캐시를 끄는 것과 같다

· 안전한 기본값 — 막아 두고 필요한 것만 연다
 기본 no-store, 정적 자산 경로만 캐시 허용

✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.

사고와 느림 중에 고르라면

원칙은 하나다. 막아 두고 필요한 것만 연다.

기본값을 캐시 허용으로 두면 새로 만든 엔드포인트 하나만 실수해도 사고가 난다. 기본을 금지로 두면 실수해 봐야 좀 느려지는 게 다다. 사고냐 느림이냐를 고르는 문제라 답은 분명하다.

실무에선 응답을 세 종류로 나눠 두면 편하다.

공개 정적 자산. 이미지, 스크립트, 스타일시트 같은 것들이다. 오래 캐시하고, 내용이 바뀌면 파일 이름을 바꾼다. 이름에 내용 해시를 넣는 게 표준이다.

공개 동적 응답. 로그인 없이 누구나 같은 걸 보는 페이지다. 짧게는 캐시해도 된다.

개인화된 응답. 캐시하지 않는다. 필요하다면 브라우저 캐시만 열고 공유 캐시는 막는다.

헤더 뜻도 정확히 알아야 한다. 제일 흔한 오해가 "no-cache" 다. 저장을 금지하는 게 아니라 쓰기 전에 원본에 확인하라는 뜻이다. 저장 자체를 막으려면 "no-store" 를 써야 한다. 이 둘을 헷갈려서 개인정보가 저장되는 일이 실제로 있다.

"private" 은 공유 캐시는 저장하지 말고 브라우저만 저장하라는 뜻이다. 로그인 사용자 응답에 자주 붙는다. 하지만 공용 컴퓨터를 떠올리면 민감한 응답에는 이것도 모자라다.

응답이 요청 헤더에 따라 달라진다면 그 사실도 알려야 한다. 다만 쿠키를 기준으로 삼으면 사실상 캐시가 안 된다. 세션 쿠키는 사람마다 다르니 캐시가 전부 갈라진다.

새 엔드포인트를 내보내기 전에

  1. 기본값은 캐시 금지, 정적 자산 경로만 명시적으로 연다. 실수의 결과가 사고가 아니라 느림이 되게.
  2. 개인화된 응답엔 저장 금지를 명시적으로 건다. 중간 캐시의 판단에 맡길 일이 아니다.
  3. no-cache 와 no-store 의 차이는 팀 전체가 알아야 한다. 실제 사고로 이어지는 혼동이다.
  4. 정적 자산은 파일 이름에 내용 해시를 넣고 길게 캐시한다. 무효화 고민이 사라진다.
  5. 응답이 헤더에 따라 달라지면 그 사실을 알리되, 쿠키 기준은 캐시를 사실상 끄는 거라는 걸 알고 쓴다.
  6. 인증이 필요한 경로는 CDN 설정에서 아예 우회시키는 게 안전하다. 애플리케이션 헤더 한 겹에만 기대지 말고 두 겹으로 막자.
  7. 캐시 적중률이 갑자기 올랐다면 반가워하기 전에 확인부터. 좋은 신호가 아닐 수 있다.
  8. 배포 전 점검 목록에 넣는다. 새 엔드포인트마다 캐시 정책을 정하는 게 규칙이 돼야 한다.

실제로 뭐가 캐시되고 있나

실제로 무엇이 캐시되는가:

코드
· 로그인 상태로 요청해 최종 헤더를 본다
$ curl -sI -H 'Cookie: session=...' https://example.com/mypage
 Cache-Control: private, no-store   ← 이래야 한다
 Cache-Control: public, max-age=600 ← 사고다

· 어느 층에서 저장됐는지
 CDN 이 붙이는 적중 여부 헤더를 확인한다
 HIT 이 뜨면 그 층에 저장돼 있다는 뜻

· 두 사용자로 같은 주소를 연달아 요청해 본다
 서로 다른 세션으로 요청했을 때
 응답 본문에 상대의 정보가 섞이는지 확인

· 캐시 적중률이 갑자기 올랐는지
 좋은 신호가 아닐 수 있다

· 무효화에 걸리는 시간을 미리 재 둔다
 사고 시 대응 속도가 여기에 달려 있다

핵심 한 줄캐시는 같은 주소면 같은 응답이라고 믿는다. 그 믿음이 깨지는 곳을 막아라.

자주 묻는 것

Cache-Control 의 no-cache 와 no-store 는 뭐가 다를까?

no-cache 는 저장을 막지 않는다. 쓰기 전에 원본에 확인하라는 뜻이다. 저장 자체를 막으려면 no-store 를 써야 하고, 로그인 사용자 응답에는 private, no-store 를 건다.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 운영에서 실제로 터지는 문제를 한 편씩 적는다. 같은 실수를 두 번 하지 않으려고 쓰는 기록이다.

장애·확장 글 22편 모두 보기 →