logo

DowanKim

페이지 응답에서 쿠키 제외 + 클라우드 프론트 캐싱 설정으로 페이지 cdn 캐싱하기

2026년 10월 5일65
PLick

(이전 두개의 글에서 이어지는 내용입니다)

이제 빌드 결과표에서 동적으로 만들어지는 페이지는, my, 로그인, 라이브 화면 정도만 남았습니다.

서버는 응답마다 이건 캐시해도 된다고 말을 합니다 - s-maxage

그러나 클라우드 프론트는 여전히 모든 문서를 Miss로 원본까지 보냅니다

서버가 캐시중이긴 하지만, 여전히 cdn은 페이지를 캐시하지 못하고, s3에 있는 정적 파일들만 캐싱중입니다.

문제 상황

서버는 캐시해도 된다고 말하지만 클라우드프론트는 매번 miss

지금 클라우드 프론트 기본 비헤이비어의 캐시 정책은 CachingDisabled 입니다. 원본의 설정이 어떻든 캐싱하지않습니다.

기본 비헤이비어 *
  캐시 정책         Managed-CachingDisabled
  오리진 요청 정책  Managed-AllViewer (쿠키·Host 전부 원본으로)

1년 캐시해도 되는 응답에 토큰이 현재 포함되어있음

쿠키 없이 FAQ를 요청했을 때 결과입니다

GET /faq  (쿠키 없음, 아이폰 사파리 UA)

HTTP/1.1 200 OK
Cache-Control: s-maxage=31536000
x-nextjs-cache: HIT
set-cookie: accessToken=eyJhbGci…; Max-Age=3300; HttpOnly
set-cookie: refreshToken=_dI05hY5…; Max-Age=1209600; HttpOnly
set-cookie: guestExpiresAt=2026-10-…
set-cookie: sessionKind=guest
set-cookie: guestNotice=issued
set-cookie: refreshRetry=; Expires=Thu, 01 Jan 1970
set-cookie: guestFromLogout=; Expires=Thu, 01 Jan 1970
set-cookie: plick_did=1a171cfc…
set-cookie: plick_path=direct

cache control 에는 1년동안 누구에게 줘도 같다고 되어있습니다.

x nextjs cache 도 hit인걸 보면 넥스트도 이미 만들어 둔 html을 꺼내 줍니다. 본문 자체는 누구에게나 같습니다

다만 그 아래줄을 보면, 방금 이 요청을 위해 발급한 게스트 토큰이 실려 있습니다 클라우드 프론트가 이 응답을 저장하면 다음에 faq를 여는 사람도 같은 set cookie를 받습니다. 첫 방문자의 게스트 계정으로 로그인된 채 좋아요를 누르게 됩니다.

왜 페이지 응답에 토큰이 있는거지

저희는 들어온 사람을 모두 게스트로 시작하게 하고 있습니다. 로그인 없이도 첫 탭부터 좋아요, 투표, 댓글이 되게 하려는 설계입니다.

그 발급을 프록시가 맡고 있습니다. 쿠키 없는 페이지 요청이 오면 be에 게스트를 만들어 달라고 하고, 받은 토큰을 응답 쿠키로 심습니다

브라우저 ── GET /faq (쿠키 없음) ──▶ 프록시 ── POST /auth/guest ──▶ BE
                                        ◀── 토큰 ──
         ◀── FAQ HTML + Set-Cookie(토큰) ──

원래는 no-store라 문제가 안되었지만, 이제는 문제가 될 수 있습니다

쿠키 설명

image.png

토큰만의 문제 뿐만 아니라, 기기 식별자가 공유되면 방문자 수 집계가 무너지고, 지우는 쿠키조차 set-cookie라 남의 브라우저에서 값을 지웁니다. 프록시가 페이지 응답에 쿠키를 하나라도 심는 한 cdn 캐시는 켤 수 없습니다.

요청하나에 게스트 하나라 게스트 무한생성

/faq      refreshToken=_dI05hY5…
/         refreshToken=_vdsfrhM…
/debates  refreshToken=v6dsRghI…
/reels    refreshToken=kdzLlhyT…
/faq      refreshToken=7qsG6bJ-…

쿠키없이 매번 요청을 연속적으로 했더니 refreshToken 앞자리가 다 다른걸 볼 수 있습니다.

검색 크롤러는 UA로 걸러 발급하지 않지만, 브라우저 UA를 단 요청이면 js를 돌리든 말든 계정이 생깁니다.

문제 총정리

1.앞의 두 글로 문서 대부분이 정적,isr 이 됐고 서버는 s-maxage로 캐시해도 된다고 말함

2.그런데 프록시가 첫 방문 응답에 게스트 토큰을 포함한 쿠키 아홉개를 심음

3.그래서 클라우드프론트 문서 캐시를 켜면 한 사람의 토큰이 다음 사람에게 나감

4.또하나 문제는 페이지 요청한번만다 게스트가 하나씩 생김

물론 들어온 사람은 모두 게스트로 시작한다는 요구사항은 지켜야 합니다.

해결방안

클로드에게 물어봤습니다. 어떻게 하는게 나을지

image.png

방법 5를 고르고, 방법 1은 안전장치로 같이 가져가야 할 것 같습니다.

방법 1이 안전장치로 있어야 하는 이유는

남는 경우 1: 토큰 재발급

로그인 토큰(accessToken) 쿠키는 55분이 지나면 브라우저가 스스로 버립니다. 남는 건 교환권인 refreshToken뿐입니다. 이 상태에서 페이지를 열면 프록시가 교환권을 새 토큰으로 바꿔서 그 페이지 응답에 쿠키로 심어 줍니다. 이걸 페이지 응답에서 하는 이유는 MY 화면 때문입니다. MY는 서버가 "이 사람의 활동 내역"을 그려서 내려주는 화면이라, 그리기 전에 쓸 수 있는 토큰이 있어야 합니다. 이 교체를 브라우저로 미루면 그 한 번은 MY가 로그아웃 상태처럼 그려집니다.

남는 경우 2: 광고 링크로 들어온 첫 요청 인스타 광고는 m.plick.co.kr/?utm_source=instagram 같은 주소로 들어옵니다. 프록시는 이 주소를 보고 "이 사람은 인스타로 왔다"를 plick_path 쿠키에 적어 둡니다. 그래야 나중에 좋아요나 가입 같은 행동이 인스타 유입으로 집계됩니다. 이 정보는 주소에만 있어서, 그 요청의 응답에서 적는 게 가장 간단하고 정확합니다.

두 경우 모두 응답에 set cookie가 붙습니다. 이 응답을 cdn이 저장하면 안되기에, 프록시가 쿠키를 하나라도 심은 응답은 no-store를 유지해야합니다.

나머지 분석 쿠키는 이렇게 옮깁니다.

plick_did(기기 식별자): 페이지 응답이 아니라 게스트 발급 응답에서 같이 심습니다. 화면을 연 사람은 모두 idle에 발급을 거치니 늦어도 그때는 기기가 정해집니다. 그 전에 나가는 /be 호출이 있으면 그 응답에서 먼저 심습니다.

plick_path(유입 경로): 값이 direct면 아예 심지 않습니다. 쿠키가 없을 때 읽는 기본값이 원래 direct라서 결과가 같습니다. direct가 아닌 값은 위의 광고 주소 경우라 안전핀을 탑니다.

마지막으로 CloudFront 기본 비헤이비어를 "원본의 Cache-Control을 따른다"로 바꿉니다. 정적·ISR 문서는 s-maxage대로 엣지에 저장되고, MY나 로그인처럼 private인 동적 화면은 지금처럼 저장되지 않습니다.

구현

1. 게스트를 받아 가는 API

POST /api/session/guest를 따로 만들어줍니다.

지금 프록시가 하던 일 : be에 게스트 요청, 토큰 쿠키 심기 를 옮긴 라우트 입니다

전: GET /faq → 프록시가 BE에 게스트 요청 → FAQ HTML + Set-Cookie(토큰)
후: GET /faq → FAQ HTML (쿠키 없음)
    화면이 뜬 뒤 → POST /api/session/guest → {issued: true} + Set-Cookie(토큰)

be는 토큰을 json 본문으로 줍니다. 브라우저 js가 그걸 받으면 js가 토큰을 손에 쥐게 됩니다. 토큰은 지금처럼 js 가 읽지 못하는 httponly 쿠키로 둬서 넥스트 서버가 받아 쿠키로 바꿔 심어 줍니다

이 라우트는 세가지 상황에서 거절합니다

  • 크롤러 : 전에느느 프록시가 ua로 걸렀지만, 이제 발급이 js에 묶이니 js를 돌리는 구글 봇이 부를 수 있어서 이 라우트에서 막습니다
  • 다른 사이트에서 온 요청 : 남의 사이트가 폼으로 이 주소를 접속하면 방문자 브라우저에게 게스트가 심어집니다. 같은 출처가 아니면 거절
  • 이미 세션이 있는 요청 : 탭두개를 연 경우 새로 만들지 않고 issued: false로 답함

2. 브라우저가 한가할 때 부르기

이전에 만든 AuthProvider가 화면이 뜨기 전에 sessionKind 쿠키를 읽어 세션이 있는지 압니다. 없으면 브라우저의 바쁜일이 끝나고 위 API를 부릅니다. 화면을 그리는 시점이 끝나고 진해됩니다.

로컬 기준 화면이 뜨고 0.25초 이후 발급되고, 끛나면 지금부터 기록이 쌓여요 라는 토스트가 뜹니다

faq-guest-toast.jpg

3. 발급 전에 누르면 기다림

0.25초 안에 좋아요를 누를 수 있습니다. 좋아요, 투표, 댓글은 요청을 보내기 전에 ensureSession() 을 기다리게 했습니다

하트 탭 → 하트는 바로 채움(낙관적 갱신)
        → ensureSession() : 세션 있으면 바로 통과, 없으면 발급 요청을 같이 기다림
        → 좋아요 요청

여기서 지난 글의 내용과 충돌이 발새합니다. 지난 글에서 "세션 종류가 바뀌면 내 상태 캐시를 지운다"는 규칙을 넣어 뒀습니다. 로그인한 사람의 하트가 로그아웃 뒤에 남으면 안 되니까요. 그런데 "세션 없음 → 게스트"도 세션 종류가 바뀐 것이라, 발급을 기다리던 좋아요가 채워 둔 하트가 캐시째 날아갈 수 있었습니다. 방금 생긴 게스트는 누른 게 하나도 없으니 지울 것도 물을 것도 없습니다. 그래서 새로 받은 게스트일 때는 캐시를 지우지 않고 빈 칸만 "안 누름"으로 채우게 했습니다. 덤으로 첫 방문의 내 상태 조회 요청 하나가 사라졌습니다.

4. next가 쿠키를 버리는 현상

api응답 확인 결과 쿠키가 없었습니다.

GET /be/api/v1/debates  (Cookie: plick_landing=...)
HTTP/1.1 200 OK
cache-control: no-cache, no-store, ...
(set-cookie 없음)

/be 요청은 next.config.js의 rewrites가 be 주소로 넘겨줍니다. 넥스트 라우터는 rewrite의 목적지가 외부 주소면 미들웨어가 만든 응답 헤더를 버립니다. 요청헤더가 be까지 가지만, 브라우저로 돌아가는 set cookie는 사라집니다.

be 요청의 응답 헤더

requestHandler! /be/api/v1/debates?size=1 {
  resHeaders: null,
  finished: true
}

반대로 미들웨어가 직접 외부로 rewrite하면 응답 헤더를 실은 채로 넘깁니다. 그래서 쿠키를 심어야 하는 /be 요청만 프록시가 직접 be로 넘기게 했습니다.

5. 안전장치

프록시 맨끝에, 어느 갈래로든 응답에 쿠키를 심거나 지웟으면 그 응답을 private,no-store로 덮어버리게 했습니다

결과(로컬)

image.png

Cloudfront 문서 캐시 설정 켜기

CloudFront 콘솔 → 정책 → 캐시 → 캐시 정책 생성.

image.png

이름: plick-mobile-documents (예시)

TTL: 최소 0, 최대 31536000, 기본 0

기본 0이 중요합니다. 원본이 Cache-Control을 안 주면 저장하지 않는다는 뜻입니다. 관리형 CachingOptimized는 기본이 하루라, 헤더 없는 응답을 하루씩 들고 있게 됩니다.

캐시 키: 헤더는 rsc 하나 포함, 쿠키는 없음, 쿼리 문자열은 전체

CloudFront는 응답의 Vary 헤더를 보고 캐시를 나누지 않습니다. Next는 같은 주소로 HTML과 RSC(화면 전환용 데이터)를 둘 다 주므로, _rsc 쿼리와 rsc 헤더가 키에 있어야 둘이 섞이지 않습니다.

쿠키를 키에 넣으면 사람마다 캐시가 따로라 넣지 않습니다. 쿠키는 원본 요청 정책(AllViewer)이 그대로 원본에 넘깁니다.

압축: Gzip, Brotli 켬

비헤이비어에 붙이기

image.png

배포(dev-m: E8Z1P2UP1OTYK) → 동작 → 기본값(*) → 편집.

캐시 정책: 위에서 만든 정책

원본 요청 정책: Managed-AllViewer 그대로

/_next/static/* 비헤이비어는 건드리지 않습니다

image.png

오류 페이지 탭에서 500·502·503·504의 오류 캐싱 최소 TTL도 확인합니다. 기본 10초라, 원본이 잠깐 넘어진 응답이 10초 동안 엣지에 남을 수 있습니다. 0으로 둡니다.

확인

GET /faq      Miss → Hit   cache-control: s-maxage=31536000
GET /         Miss → Hit   cache-control: s-maxage=20, stale-while-revalidate=…
GET /debates  Miss → Hit   cache-control: s-maxage=60, …
GET /reels    Miss → Hit   cache-control: s-maxage=60, …

네 화면 모두 두 번째부터 Hit from cloudfront이고, 응답에 set-cookie는 한 줄도 없습니다. Hit 응답에는 엣지에 머문 시간(age: 12)이 붙습니다.

curl-summary.png

지켜야 할 것도 확인했습니다.

  • 세션 쿠키를 든 MY(/me)는 두 번 요청해도 둘 다 Miss이고 private, no-store입니다. 사람마다 다른 화면은 엣지에 남지 않습니다.
  • 같은 FAQ 주소라도 RSC 요청(rsc: 1)은 text/x-component로, 일반 요청은 text/html로 따로 저장됩니다. 캐시 키에 rsc 헤더와 쿼리를 넣은 덕입니다.
  • 브라우저로 처음 들어가면 화면이 뜬 뒤 0.26초에 게스트가 생기고, 좋아요(73 → 74)와 댓글이 됩니다.

엣지에서 받는 게 얼마나 빠른지도 재 봤습니다. 같은 FAQ를 Hit로 10번, 쿼리를 매번 바꿔 강제로 Miss로 10번 받았습니다.

image.png

큰차이는 없지만 그래도 시간의 이득이 있습니다. 또한 이 자체만으로 넥스트 서버의 부하가 줄어들 것입니다.

e2e에서 생긴 이슈

dev 배포 직후 매번 도는 E2E가 하나 실패했습니다. 처음 온 사람이 곧장 MY에 들어가면 "계정 연동"이 보여야 하는데 "로그인"이 떠 있었습니다.

MY는 쿠키를 보고 서버가 그리는 화면입니다. 전에는 MY 응답 전에 프록시가 게스트를 만들어 줬으니 서버가 게스트 화면을 그렸습니다. 이제는 서버가 먼저 "세션 없음" 화면을 그리고, 그 뒤에 브라우저가 게스트를 받습니다. 게스트가 생겨도 이미 그려진 화면은 그대로입니다. 누구에게나 같은 FAQ나 홈에서는 생길 수 없는 문제라 놓쳤습니다.

그래서 MY의 로그인 안내를 작은 컴포넌트로 감싸, 게스트가 생기면 router.refresh()로 서버 화면만 한 번 다시 받게 했습니다. 받는 동안은 안내를 숨겨 "로그인"이 보였다가 바뀌는 깜빡임도 없앴습니다.

감수해야할 요소

  • 첫 방문의 첫 API 호출(릴 조회 기록 등)이 게스트 발급보다 먼저 나가면 익명으로 남습니다. 전에는 첫 바이트 전에 게스트가 생겨 조회 기록에 사용자가 붙었습니다.
  • 첫 방문자가 탭 두 개를 거의 동시에 열면 게스트가 둘 생길 수 있습니다. 나중 쿠키가 남고 먼저 생긴 계정은 14일 뒤 정리됩니다.

마무리

블로그 총 3편 정리

  • 1.레이아웃이 쿠키를 읽어 모든 페이지가 Dynamic이었다 → 쿠키 읽기를 브라우저로
  • 2.하트 하나 때문에 페이지가 사람마다 달랐다 → 누구에게나 같은 페이지 + 내 상태는 나중에
  • 3.누구에게나 같은 페이지에 내 토큰이 실려 있었다 → 게스트는 브라우저가 따로 받아 가게