모두에게 같은 페이지 + 내 상태 올리기
https://github.com/big-cute-team/plick-frontend/pull/304
지난 글에서는 루트 레이아웃이 쿠키를 읽던 코드를 브라우저로 옮겼습니다. 그 결과 홈, FAQ, 팀 프로필이 정적·ISR이 됐습니다. 하지만 기사, 릴스, 투표 네 화면은 여전히 ƒ(Dynamic)로 남았습니다. 이번 글은 그 네 화면을 캐시 가능하게 전환합니다.
1. 문제 상황
남은 네 화면은 레이아웃이 아니라 페이지가 직접 토큰을 실어 데이터를 받고 있었습니다.
기사 상세: 본문 + 내가 좋아요 눌렀나(likedByMe) + 내가 어디에 투표했나(myVote)
릴 상세, 릴스 피드: 릴 목록 + likedByMe
투표 목록: 토론 목록 + myVote
응답 하나에 두 종류의 값이 섞여 있는 게 문제입니다.
하트 하나 때문에 페이지 전체가 "이 사람 전용"이 됩니다. 그래서 no-store가 붙고 캐시도, bfcache도 안 됩니다.

2. 해결 방향
지난 글에서 정한 원칙을 그대로 이어 갑니다. 페이지는 누구에게나 같게 만들어 캐시하고, 내 정보는 브라우저가 나중에 얹습니다. 유튜브 좋아요, 네이버 뉴스 공감이 이렇게 동작합니다.
이렇게 하려면 "여러 기사의 내 상태를 한 번에 알려 주는 API"가 필요해서 백엔드에 요청했습니다(KAN-663).
GET /api/v1/users/me/article-states?ids=7908,8107,8110
[
{ "articleSummaryId": 7908, "liked": true, "vote": null },
{ "articleSummaryId": 8107, "liked": false, "vote": "OPTION_B" }
]
흐름은 이렇게 바뀝니다.
전: 서버가 토큰을 실어 본문+내 상태를 한 번에 받음 → 사람마다 다른 HTML → no-store
후: 서버는 토큰 없이 본문만 받음 → 누구에게나 같은 HTML → 60초 ISR 캐시 브라우저가 뜬 뒤 → 화면에 보이는 기사 id를 모아 내 상태 API 한 번 → 하트·투표 표시
3. 구현 중 생긴 추가 사항
유저별 다른 값이 두개 더 있었ㅅ브니다.
기사 상세는 댓글 첫 페이지도 서버에서 토큰을 실어 받고 있었습니다. 댓글에는 "내가 이 댓글에 좋아요 눌렀나"가 있습니다. 또 내가 차단한 사람의 댓글은 "차단한 사용자의 댓글입니다"로 가려져서 옵니다. 내 상태 API로는 해결할 수 없는 값이라, 댓글은 서버에서 받지 않고 브라우저에서만 받기로 했습니다.
또한 로그인한 사용자라면 백엔드가 안 본 릴을 먼저 줍니다. 첫 페이지를 익명으로 캐시하면 이 개인화가 빠집니다. 선택지는 둘이었습니다.
A. 첫 페이지를 익명으로 캐시한다. 빠르지만 첫 30개는 누구에게나 최신순이다.
B. 빈 껍데기만 캐시하고 첫 페이지를 브라우저에서 받는다. 개인화는 지키지만 첫 릴이 늦게 뜬다(LCP 악화).
A를 골랐습니다. 공유 링크로 들어오는 릴은 원래 개인화가 없고, 두 번째 페이지부터는 개인화가 다시 붙어서 손해가 첫 페이지뿐입니다.
4. 구현
1단계: 기사 하나당 캐시 하나, 요청은 한 번
좋아요 상태는 기사마다 따로 저장했습니다. 그래야 릴스를 내려 새 기사가 오면 그 기사만 물어볼 수 있고, 같은 기사가 피드와 상세에 동시에 떠도 값이 하나로 유지됩니다.
문제는 카드 10장이 동시에 뜨면 서버에 10번 물어본다는 점이었습니다. 그래서 같은 순간에 들어온 질문을 모아 한 번에 보내는 장치를 뒀습니다. 식당 웨이터가 주문을 하나씩 주방에 나르지 않고 모아서 한 번에 가져가는 것과 같습니다. (릴스api는 현재 한번에 10장씩 가져오기에 생기는 이슈)
확인해 보니 릴스 첫 화면 카드 10장의 상태가 요청 한 번으로 왔습니다.
카드 1: load(8372) ┐
카드 2: load(8333) ├─ setTimeout(0) 동안 모음 → GET ?ids=8372,8333,...,8282 한 번
... │
카드 10: load(8282)┘
검증결과ㅏ 릴스 첫 화면 10장이 요청 한 번(n=10)으로 나갔습니다.
<요청한건, 아이디는 10개>

2단계: 하트가 비어 보이는 순간 줄이기
캐시된 HTML에는 하트가 늘 비어 있습니다. 전에는 HTML에 이미 차 있던 하트가 이제 API 왕복 한 번 뒤에 찹니다.
이 틈을 줄이려고 내가 누른 기사만 localStorage에 적어 두고, 응답이 오기 전에는 그 기록으로 먼저 채웁니다. 응답이 오면 응답 값을 따릅니다.
주의할 점은 하이드레이션입니다. 서버 HTML은 빈 하트인데 브라우저가 첫 렌더부터 찬 하트를 그리면 불일치 에러가 납니다. 지난 글에서 만든 AuthProvider가 이 문제를 풀어 줬습니다. 하이드레이션 중에는 세션을 "모름"으로 두고, 첫 화면을 그리기 직전(useLayoutEffect)에 세션을 읽습니다. 그 시점에 기록을 넣으면 불일치 없이 첫 프레임부터 하트가 찹니다.
이 기록에는 주인이 있어야 합니다. 게스트로 누른 기록이 소셜 로그인 뒤에 보이면 안 되기 때문입니다. 그래서 세션 종류(게스트/소셜)를 함께 적어 두고, 종류가 바뀌면 기록을 지웁니다.
3단계: 누르면 다시 묻지 않는다
좋아요나 투표를 누르면 서버 응답이 곧 정답입니다. 그래서 API를 다시 부르지 않고 캐시에 바로 적습니다. 다시 묻는 건 두 경우뿐입니다.
bfcache로 돌아왔을 때(pageshow의 persisted): 그사이 다른 기기에서 눌렀을 수 있음
세션이 바뀌었을 때(로그인·로그아웃)
4단계: 서버 fetch에서 토큰 빼기
네 화면에서 getAccessToken()을 지웠습니다. 쿠키를 읽는 마지막 자리였습니다. 그리고 revalidate = 60을 걸었습니다. 기사 상세에는 좋아요 수, 댓글 수, 표 수가 보여서 더 길게 잡지 않았습니다.
[postId] 라우트에는 빈 generateStaticParams를 넣었습니다. 이 함수가 아예 없으면 요청마다 렌더(ƒ)되고, 빈 배열이라도 있으면 첫 요청 때 그려서 캐시(●)됩니다. 지난 글의 프로필 화면과 같은 이유입니다.
5단계: 댓글은 서버에서 아예 안 그리기
댓글 서버 조회를 빼는 것만으로는 부족했습니다. 댓글 목록은 suspense 쿼리라서, 데이터가 없으면 서버 렌더 중에 스스로 받으러 갑니다. 그러면 ISR 생성 중에 익명 댓글이 HTML에 굳고, 브라우저에서는 다시 받으면서 결과가 어긋납니다.
그래서 댓글 목록을 next/dynamic의 ssr: false로 감쌌습니다. 서버는 뼈대만 그리고, 마운트된 뒤 브라우저가 토큰을 실어 받습니다.
5. 결과


감수할 부분
투표하고 60초 안에 새로고침하면 ✓는 내 표를 가리키지만 표 수는 캐시된 값이라 내 표가 빠져 있습니다(27표 → 26표). ISR 주기만큼 늦을 뿐이고, 다음 재생성 때 맞춰집니다.
릴스 첫 페이지 30개는 개인화가 빠집니다.
기사 상세 API를 부를 때마다 백엔드가 남기던 열람 이벤트가 이제 재생성 때만 남습니다. 브라우저가 따로 보내는 조회 기록은 그대로입니다.
6. 남은 과제
prod 배포 뒤 재측정: 시뮬레이터에서 릴스 → x.com → 뒤로가기 때 ALB 로그에 문서 재요청이 사라지는지, 실제 망에서 하트가 몇 ms 뒤에 차는지
게스트 발급을 페이지 응답에서 분리하고 CloudFront 문서 캐시 켜기(KAN-665)
데스크톱 웹 이식(KAN-643)