들어가기 전에 ISR에 대한 간단한 설명
ISR(Incremental Static Regeneration)은 Next.js의 렌더링 전략 중 하나다. 이름이 길지만 개념은 단순하다. 빌드 시점에 정적 HTML을 미리 만들어두되, 일정 시간이 지나면 백그라운드에서 조용히 다시 생성해주는 방식이다.
Next.js에는 크게 세 가지 렌더링 방식이 있다. SSG(Static Site Generation)는 빌드 때 HTML을 한 번 만들고 그걸 계속 서빙한다. 빠르지만 내용이 바뀌면 재배포해야 한다. SSR(Server-Side Rendering, App Router에서는 Dynamic Rendering)은 요청이 올 때마다 서버가 HTML을 새로 그린다. 항상 최신이지만 느리고 서버 부하가 크다.
ISR은 이 둘의 중간이다. 평소에는 SSG처럼 캐시된 정적 HTML을 빠르게 내려주다가, 설정한 revalidate 시간(예: 5분)이 지난 뒤 첫 요청이 들어오면 그 사용자에게는 기존 캐시를 보여주고 백그라운드에서 새 HTML을 만든다. 다음 방문자부터는 갱신된 페이지를 받는다. "거의 실시간이어야 하지만, 초 단위로 최신일 필요는 없는" 콘텐츠에 잘 맞는다. 블로그 글, 상품 상세, 그리고 이번 글과 같은 페이지들이다.
SEO 핵심 페이지가 Dynamic으로 렌더링되고 있었다
내가 만든 로스트아크 팬사이트 상세 페이지는 유입의 상당 부분을 책임지는 SEO 핵심 랜딩이다. 그런데 어느 순간부터 이 페이지가 Next.js 빌드 로그에서 Dynamic으로 표시되고 있었다. 댓글, 좋아요, 북마크, 조회수 증가까지 한 페이지에서 한 번에 처리하다 보니, 요청이 들어올 때마다 서버가 전체 HTML을 새로 렌더링하고 있었던 것이다.
문제는 크롤러의 관점에서 명확했다. 구글봇은 "게임 공략 본문"이라는 정적인 콘텐츠를 읽으러 왔는데, 서버는 그때마다 로그인 여부를 확인하고, 좋아요 상태를 조회하고, 조회수를 1 증가시키고 있었다. 크롤러에게는 전혀 필요 없는 연산이 매 요청마다 HTML 생성 경로에 얹혀 있었다.
돌이켜보면 구조적인 문제였다.
한 컴포넌트 트리 안에 성격이 완전히 다른 두 종류의 데이터가 공존하고 있었다.
하나는 모든 방문자에게 동일한 콘텐츠 셸이다. 공략 본문, 제목, 썸네일, 메타 태그, JSON-LD, 관련 공략 목록 같은 것들. 이건 편집자가 글을 수정하지 않는 한 바뀌지 않는다. 다른 하나는 요청마다 달라지는 사용자별 상태다. 이 사람이 좋아요를 눌렀는지, 북마크 했는지, 어떤 댓글을 썼는지. 여기에 더해 조회수 증가라는 부수 효과까지 같은 렌더 경로에 섞여 있었다.
개별 기능은 다 필요한 것들이지만, 한 렌더 경로에 몰아넣은 대가로 페이지 전체가 동적 렌더링으로 내려앉은 셈이다.
그래서 렌더 경로를 둘로 나눴다
접근 방식은 단순했다. 크롤러가 읽어야 하는 것과 개인 사용자에게만 필요한 것을 분리했다.
공략 본문, 메타 정보, JSON-LD, 관련 공략은 ISR로 유지했다. generateStaticParams로 주요 슬러그를 미리 빌드하고, revalidate: 300으로 5분마다 재생성되도록 설정했다. 본문을 수정해도 5분 안에 반영되므로 운영상 체감 지연도 거의 없다.
댓글, 좋아요, 북마크는 hydration 이후 클라이언트 컴포넌트에서 fetch로 불러오도록 옮겼다. 초기 HTML에는 스켈레톤만 들어가고, 실제 상태는 따로 가져온다. 크롤러는 이 fetch를 실행하지 않으니 HTML 경로에서 완전히 빠진다.
조회수 증가는 가장 확실하게 분리해야 했다. 렌더 중에 DB에 write를 걸면 그 순간 페이지는 절대 정적일 수 없다. 그래서 조회수 증가를 별도 엔드포인트로 만들고, 클라이언트에서 따로 호출하도록 바꿨다. 페이지 이탈 시점에도 손실 없이 전송되고, HTML 생성 경로에서는 완전히 사라졌다.
Dynamic에서 SSG + ISR로
빌드 로그에서 Dynamic에서 SSG + Revalidate로 바뀌었다. 크롤러가 받는 HTML은 사용자 상태가 개입하지 않은 안정적인 형태로 고정됐고, TTFB도 CDN 캐시 덕분에 눈에 띄게 짧아졌다. 사용자 인터랙션은 그대로 유지되고 있다. 좋아요도 잘 눌리고, 조회수도 정확히 집계된다.
이 글을 읽는 분이라면 "검색엔진이 읽어야 하는 콘텐츠"와 "개인 사용자가 확인해야하는 상태"를 같은 렌더 경로에 두지 않았는지 확인해보기 바란다.
