이번에는 서버 컴포넌트에서 초기 데이터를 fetch하고, 클라이언트 컴포넌트에서 리액트 쿼리의 무한 스크롤 훅으로 추가 데이터를 가져오는 ✨하이브리드 렌더링 ✨ 을 구현해보았다.
🎯 next.js에서의 무한 스크롤
next.js에서는 리액트와 달리 무한 스크롤로 추가 데이터를 가져올 때 신경 써야 할 부분이 좀 있었다.
특히 app router 환경이라면 서버 컴포넌트의 데이터 페칭과 클라이언트 컴포넌트의 리액트 쿼리 사용을 잘 분리해서 구현해야 됐다.
문제
내가 원하는 데이터 페칭 및 무한 스크롤의 동작 흐름은 이러했다.
서버 컴포넌트에서 initialData fetch → 클라이언트 컴포넌트에서 initialData를 받아 렌더링 → 스크롤 시 클라이언트 컴포넌트에서 추가 데이터 렌더링
그래서 먼저 MainPage.tsx(서버 컴포넌트)에서 fetchServerData()로 initialData를 fetch 하고, 이를 ActivityList.tsx(클라이언트 컴포넌트)에 바로 보냈다.
그리고 ActivityList에 무한 스크롤 훅을 적용했는데, 훅이 호출되면 추가 데이터는 잘 불러왔지만 이상하게도 MainPage까지 리렌더링되면서 스크롤이 최상단으로 튀어버렸다.
무한 스크롤이 정상적으로 작동한다면 ActivityList에서 추가 데이터들만 렌더링 되고, 그외 요소들은 리렌더링되면 안되는데 왜 ActivityList를 넘어 MainPage까지 리렌더링 되었을까?
원인
🔎 원래 구조
MainPage (Server Component, async)
└─ ActivityList (Client Component, useInfiniteQuery)
- ActivityList 안에서 fetchNextPage() 호출 → React Query 상태 변경 발생
- Client Component(ActivityList)에서 state가 변하면 React는 위쪽 Server Component(MainPage) 까지도 다시 렌더링 시도 (hydration mismatch 방지용)
- 그 결과 MainPage → section → ActivityList 전체가 언마운트 후 다시 마운트
- 당연히 DOM이 갈아엎어지니까 스크롤이 최상단으로 튀어버림 🌀
여기서 내가 놓친 부분은 'hydration mismatch'였다.
나는 서버 컴포넌트가 initialData를 내려주기만 하고, 리렌더링은 자식 클라이언트 컴포넌트에서만 일어날 줄 알았는데 hydration mismatch 방지 기능 때문에 부모 컴포넌트인 MainPage까지 리렌더링 된 것이다.
해결
🌟 변경 후 구조
MainPage (Server Component, async)
└─ MainPageClient (Client Component)
└─ ActivityList (렌더링 only)
- MainPage는 이제 단순히 initialData만 내려주는 정적인 Server Component
- 무한 스크롤과 React Query 상태 변경은 MainPageClient 내부에서만 일어남
- React는 MainPageClient를 하나의 독립적인 Client 경계로 취급 → 상태 변경 시 MainPageClient 아래만 다시 렌더링
- 따라서 MainPage(서버 부분)는 전혀 건드려지지 않고, section DOM도 유지 ✅
- 결과: ActivityList UI만 업데이트 → 스크롤 위치 보존됨 🎉
- 작업 과정:
- 원래 서버 컴포넌트로만 존재했던 MainPage로부터 MainPage.client.tsx라는 별도의 클라이언트 컴포넌트를 생성.
- 여기에 initialData를 전달하고, 해당 클라이언트 컴포넌트에서 useInfiniteQuery(리액트 쿼리 훅)와 useIntersectionObserver(무한 스크롤 커스텀 훅) 적용.
- 기존에 initialData를 전달받던 ActivityList는 이제 MainPageClient로부터 무한 스크롤로 갱신된 data를 받아 '렌더링만 담당'.
마무리
리액트 쿼리의 무한 스크롤 기능과 서버/클라이언트 컴포넌트를 잘 활용하려면
MainPage (초기 데이터 fetch) - MainPageClient (리액트 쿼리 무한 스크롤) - ActivityList (리스트 렌더링)처럼
중간에 클라이언트 컴포넌트를 하나 더 두는 구조로 설계해야겠다.
'✨회고' 카테고리의 다른 글
| 9/1 회고 - zustand와 'use client' (0) | 2025.09.12 |
|---|---|
| 8/29 회고 - headless ui의 Menu vs ListBox | sharp 라이브러리의 용도에 관해 (0) | 2025.09.12 |
| 8/23 회고 - debounce.ts (with TypeScript) (0) | 2025.09.12 |
| 자바스크립트 리팩토링 회고🔍 - 폼 유효성 검사 (0) | 2025.05.01 |