캐싱 레이어 분리로 평점 즉시 업데이트하기

Overview

리라이팅의 첫 번째 목표로 잡은 사전 렌더링 최적화 작업을 본격적으로 시작했다.

첫 리라이팅 계획:

  • 강의 목록 페이지(/[universityName]) → On-Demand ISR (강의 등록 등 특정 트리거 기준으로 재생성)
  • 강의 상세 페이지(/[universityName]/[lectureId]) → 강의 기본 정보는 정적 생성 + 리뷰 데이터는 클라이언트에서 별도 패칭하여 하이브리드 렌더링

핵심은 변경이 드문 준정적 데이터들은 On-Demand ISR로, 자주 바뀌는 데이터는 동적으로 처리하는 것이었다.

문제 발견: 정적 처리와 실시간 데이터의 충돌

계획대로 ISR을 적용하고 나서 테스트를 해보니 문제가 있었다. 강의 목록 페이지와 강의 상세 페이지는 모두 강의 기본 정보를 포함하는데, 여기에는 강의 제목, 설명, 교수명, 개설 여부 그리고... 평점 데이터가 존재했다.

강의 기본 정보
├── 강의 제목 (정적)
├── 강의 설명 (정적)
├── 교수명 (정적)
├── 개설 여부 (정적)
└── 평점 데이터 ← 문제의 시작

여기서 문제가 발생했다. 기존 계획처럼 강의 정보를 정적으로 생성해버리면, 리뷰 작성에 따른 평점 업데이트가 되지 않게 된다. 평점은 즉시 변경되어야 하는 동적 데이터임에도 ISR 정적 생성에 포함되는 것이다.

제약 조건: 백엔드 API 호환성 유지

해결책은 아주 간단하게 존재하긴 했다. 강의 정보와 평점 데이터의 API를 분리한다면 간단하게 해결된다.

하지만 목표로 세운 것 중에 렌더링 최적화만 있는 게 아니었다. 리라이팅 과정의 리스크를 최소화하기 위해 기존 백엔드 API와의 호환성을 유지하기로 했다.

따라서 이런 문제를 프론트엔드 레벨에서만 해결해야 하는 상황이었다.

근본적 딜레마: 한 번의 패칭으로는 불가능

여러 방법을 고민해보았지만, 결국 똑같은 본질적인 문제가 존재했다.

ISR의 특성: 빌드 타임이나 revalidate 시점에만 데이터를 갱신
평점 데이터의 특성: 리뷰 작성 시 즉시 변경되어야 함

이는 한 번의 데이터 패칭으로는 불가능한 구조라고 생각했으며, 결국은 추가적인 패칭이나 레이어를 두어야 한다고 결론지었다.

해결책 검토 과정

그래서 몇 가지 방법을 간추려봤다.

방법 1: 같은 API 두 번 패칭

가장 간단한 것은 같은 API를 두 번 패칭하는 것이다:

  • 첫 번째 패칭: 강의 정보 데이터 → ISR로 관리
  • 두 번째 패칭: 평점 데이터만 추출 → 동적 상태로 관리

얼핏 보면 이 방법은 Next.js의 request memoization 덕분에 괜찮은 방법처럼 보였다.

하지만 문제가 있었다. Request memoization은 동일한 SSR 렌더링 사이클 내에서만 작동하므로, ISR로 정적 생성을 한 이후 클라이언트에서 동일 API를 다시 요청하면 결국 중복 요청이 발생한다. 즉, 서버 사이드에서 여러 컴포넌트가 같은 fetch를 호출할 때만 이점이 있고, 클라이언트 환경에서는 별도의 네트워크 요청이 일어난다.

방법 2: API Route를 통한 프록시 서버

또 다른 방법으론 API Route를 통한 별도의 프록시 서버를 구축하여 평점 데이터를 분리하는 것을 생각해보았다.

하지만 이 또한 앞선 문제와 비슷하게 추가적인 레이어를 필요로 하며, 결국 복잡성만 증가시키는 결과를 가져온다.

방법 3: 컴포넌트 레벨에서의 데이터 분리

결국 마지막으로 생각한 것은 컴포넌트 레벨에서 클라이언트 캐싱을 통한 데이터 분리였다.

핵심은:

  • 강의 정보 데이터: ISR로 관리
  • 평점 데이터: 별도로 추출하여 React Query를 통해 즉시 관리

하지만 이 또한 추가적인 패칭이 일어나기 때문에 별도의 리소스가 소모되는 것은 같다.

핵심 인사이트: ISR Revalidation 분리

다만 앞선 방향과는 다르게 중요한 차이점이 있었다:

평점이 변경되면:

  • 클라이언트 캐싱(React Query)에만 갱신됨
  • 나머지 준정적 데이터는 ISR revalidation이 일어나지 않음
  • 따라서 불필요한 서버 리소스 사용 방지
  • React Query는 메모리 캐싱이기 때문에 CSR(클라이언트 사이드 렌더링) 이후 상태 관리에는 유용하지만, SSR(서버 사이드 렌더링) 시점에서는 초기 데이터를 dehydrate()/hydrate() 과정을 통해 넘겨주지 않으면 Flickering(스켈레톤 → 실제 평점)이 발생할 수 있다. 이 부분은 다음 글에서 자세히 다루겠다.

리뷰 작성 시 플로우

강의 등록/삭제 시 플로우

이것이 핵심적인 차이점이었다. 데이터의 갱신 주기를 완전히 분리함으로써, 각각에 최적화된 캐싱 전략을 적용할 수 있게 된 것이다.

최종 구조

LectureCardHybrid
├── 정적 데이터 (ISR)
│   ├── 강의명, 교수명, 학과, 강의 유형
│   └── 강의 등록/삭제 시에만 revalidate
└── 동적 데이터 (React Query)  
    ├── 평점 데이터만 별도 처리적절한 캐싱
    └── 리뷰 작성 시 실시간 갱신

결과와 트레이드오프

얻은 것:

  • 서버 부하 대폭 감소 (정적 데이터 ISR 캐싱)
  • 평점 데이터 즉시 업데이트 유지
  • 각 데이터 특성에 맞는 캐싱 전략

지불한 대가:

  • 최초 네트워크 요청 2배 증가 (강의 정보 + 평점 데이터)
  • 컴포넌트 구조 복잡성 증가
  • 완벽하지 못한 구조에 대한 아쉬움

마무리

평점 데이터 하나 때문에 시작된 고민이 결국 데이터의 갱신 주기를 분리하는 해결책으로 이어졌다.

ISR과 React Query 각각의 장점을 섞어, 정적 데이터는 서버 리소스를 아끼고, 동적 데이터는 즉시성을 유지하는 구조로 문제를 해결할 수 있었다.

다만 리뷰 작성 이후 새로고침(F5) 시 평점 부분에서 스켈레톤 UI → 실제 평점으로 바뀌는 Flickering 현상이 남아 있었는데, 이는 React Query의 메모리 캐시가 SSR 시점에는 공유되지 않기 때문이었다.

이 Flickering 문제는 SSR 시점에 React Query의 캐시를 dehydrate()/hydrate() 방식으로 초기화해주는 방식으로 해결할 수 있었고, 이 구현 과정은 다음 글에서 자세히 다뤄보려 한다.

다음 글: SSR Hydration으로 Flickering 해결