소파에서 개발하기

TIL: NextJs App Router의 중첩 레이아웃은 상태를 저장하지 못할 수 있다 (w/ 장대하게 실패한 리팩토링 계획) 본문

혼자 발버둥/TIL

TIL: NextJs App Router의 중첩 레이아웃은 상태를 저장하지 못할 수 있다 (w/ 장대하게 실패한 리팩토링 계획)

couch 2024. 9. 13. 00:33

 

항상 개인 노션에 메모만 하다가 오랜만에 정리된 글로 포스팅을 한다.

기본 지식 부족으로 인한 이틀 간의 삽질이 허탈하기도 하고 부끄럽기도 하지만,

기계적으로 기능 만들기에 쫓겨 살다가 짬을 내 코드를 들여다 본 과정이 재밌었다.

스스로를 글 쓰는 개발 동아리에 집어 넣어 일부러라도 글감을 만들게 하는 것도 괜찮겠다는 생각이 든다.


TL;DR

NextJs App Router에서, 최상위 레벨의 layout.js는 한 번 마운트되면 페이지 이동에 상관없이 리렌더링되지 않으면서 상태를 유지한다.

그래서 그 하위에 layout을 중첩해서 사용할 때도 같은 효과가 있을 것이라고 착각했다.

하지만 그 중첩된 layout이 client 컴포넌트가 아니라 server 컴포넌트라면 매번 새로 마운트되므로 같은 효과를 얻지 못한다.

 

관련 문서 내용 : https://nextjs.org/docs/app/building-your-application/routing/layouts-and-templates#nesting-layouts

관련 디스커션 : https://github.com/vercel/next.js/discussions/53026

관련 블로그 글 : https://medium.com/@steveliles/deeply-nested-layouts-in-next-js-3b30e7ba9b9e

 

리팩토링 하려던 문제

몇몇 페이지 내 기능이 복잡해지고 컴포넌트가 늘어나면서 props drilling 깊이가 깊어졌다. 어지럽게 전달되는 prop들을 context api로 관리하여 유지/보수 난이도를 줄이고자 했다.

특히 특정 도메인(메뉴) 하위의 9개 페이지에서 각각 fetch하고 있는 api는 페칭 횟수를 줄이고 여러 곳에서 재활용하고 싶었다.

프로젝트 내의 모든 페이지는 ssr로 작성되고, 내부의 인터랙션 요소들은 csr로 작성된 상태였다.

계획한 인수조건

• studio 메뉴 내 9개의 페이지에서 모두 사용하는 api와 2개의 페이지에서 모두 사용하는 api를 nested layout에서 1회 페칭하여 공유

• local context를 만들고 provider를 사용할 레벨을 결정하기

    ◦ layout 레벨 => options 값 저장에는 편리하지만 페이지 이동 시마다 초기화 로직 필요 => Layout 하위에서는 원래 공유될 테니 따로 생성하지 않아도 되겠다  (이 오판이 사건의 핵심이었다. 핵심 패착이라고나 할까.)

    ◦ page 레벨 => Context Provider가 unmount되면 데이터가 사라지고, mount되면 초기화 (Props drilling이 가장 큰 문제이므로 options도 개별 페이지 context에 저장)

• 3단계 이상 전달되는 prop / parameter를 context에 포함

• context 에 접근하기 위해, 필요하다면 유틸 함수를 react hook으로 전환하거나 감싸서 리턴

발견된 문제 상황

1. nested layout 또는 page를 렌더링할 때, 예상한 순서대로 렌더링되지 않는 현상이 있고 아직 해결방법이 나타나지 않았다는 것을 알았다. (https://github.com/vercel/next.js/discussions/53026) 그래서 layout에서 페칭한 것을 page로 내려주는 것 역시 불가능했다.  (사실 이 문제가 아니더라도 layout에서 page에 데이터 전달은 불가능하다고 docs에 나와 있다.)

두 레이아웃과 내부 페이지에 간단한 콘솔 로그를 추가하면 로그 순서가 다음과 같이 표시될 것으로 예상할 수 있습니다.
* render L0: get authorization, pass auth data next to tree
* render L1: auth is available, get project data, pass project data
* render P2: auth is available, project data is available

하지만 이 로깅을 테스트해보면 반대의 결과가 나옵니다.
* render P2: get authorization, get project data
* render L1: get project data again, pass to project widget
* render L0: get authorization again, pass to user info panel

 

2. nested layout은 root layout과 다르게, 페이지를 이동할 때마다 다시 마운트되었다. 따라서 layout 안에 있는 fetch 코드도 페이지를 이동할 때마다 실행되었다.

문제 발생 원인 분석

렌더링 순서 문제와 레이아웃 내의 데이터 페칭 코드 구조(SSR-CSR의 하이브리드 구성)가 결합된 결과로, 한참 동안의 검색과 chatGpt와의 씨름을 하고 보니 힌트는 이미 next 14 문서에 있었다.

문제가 발생된 가장 중요한 지점은, nested layout과 page가 모두 서버 컴포넌트였는데 내가 그 특징을 깊게 고려하지 않은 채 리팩토링 계획을 세웠다는 것이다.

  1. Next.js 14의 서버 컴포넌트 렌더링 순서 문제
    • React의 서버 컴포넌트 아키텍처와 관련이 있으며, 이 구조에서는 컴포넌트 트리를 위에서 아래로 순차적으로 렌더링하는 대신, 병렬로 렌더링을 시작하고 준비가 된 컴포넌트부터 결과를 스트리밍한다. 이로 인해 다음과 같은 현상이 발생한다:
      1. 깊이 중첩된 컴포넌트(페이지)가 먼저 렌더링을 시작할 수 있다.
      2. 각 컴포넌트는 독립적으로 자신의 데이터를 페칭한다.
      3. 부모 컴포넌트(레이아웃)의 데이터가 자식 컴포넌트에서 즉시 사용 가능하지 않을 수 있다.
      이러한 동작은 성능 최적화를 위한 것이지만, 데이터 흐름과 컴포넌트 간 의존성 관리를 복잡하게 만들 수 있다.
  2. Nested Layout이 매번 새로 평가되는 문제
    • Root Layout의 경우 Next.js App Router 시스템에서 페이지 이동 시 언마운트되지 않는다. 즉, 한 번 페이지가 로드된 이후에는 root layout이 유지되고, 하위 페이지들만 다시 렌더링된다.
    • 하지만 nested layout이 서버 컴포넌트라면, 서버 컴포넌트로 구현된 문제된 레이아웃은 클라이언트 사이드 상태를 유지할 수 없기 때문에, 페이지 전환 시 해당 레이아웃은 다시 평가되어 데이터를 다시 페칭한다.

해결 방법에 대한 고민

검색을 통해 React Cache나 이런저런 방법들을 찾아봤지만, 근본적으로 ‘nested layout에서 상태를 저장하고 페이지 이동시마다 재사용한다’는 명제가 틀렸기 때문에 어떤 조언도 활용할 수 없었다.

몇 가지 대안을 생각해 봤지만 모두 별로였다.

  • 데이터 페칭을 client component에서 한 뒤 context에 올려 다른 곳에서 재사용하는 방법도 생각해 봤다. 하지만 그러려면 도메인 내의 9개나 되는 페이지 중 어떤 것에 처음 접근할 지 알 수 없기에 페이지 내 모든 컴포넌트에서 context 내 데이터 유무를 확인하고 업데이트하는 코드를 가지고 있어야 한다.
  • layout 이하 모든 컴포넌트를 csr로 변경하는 경우, 다른 메뉴들에서의 경험이 달라지므로 경험의 일관성을 해칠 것 같았다. page가 client component가 되면 페이지 별 metadata를 export 할 수 없는 것도 문제였다.
  • 가장 현실적인 대안은 아예 root layout → global context로 레벨을 올리던가 전역 라이브러리의 도움을 받는 방법. 또는 아예 빌드할 때 페칭해서 포함시켜 버리기. 하지만 권한에 따라 해당 메뉴에 접근하지 않을 사용자도 있기 때문에 무조건 페칭하고 보는 전략도 적절하지는 않아 보였다.

결론

이런 교착상태를 팀과 공유하고 나서 일단은 해당 브랜치를 머지하지 않고 기다렸다가 프로젝트에 React-query를 세팅한 뒤 다시 시도하기로 했다. React-query는 예전 버전 프로젝트에서 사용했다가 이번 버전에서는 사용하지 않고 있었는데, 그새 nextjs 14 버전의 여러 업데이트에 대응하기 위한 신기능들이 이미 업데이트되었다고 한다.

하지만 팀 내에서 fetcher를 swagger와 연동하기 위해 커스텀 해둔 부분이 있어서, 그 위에 React-query를 세팅하기 위해 또 몇 번의 시행착오가 있을 것으로 보인다.

이 리팩토링 태스크를 진행하면서 사실 useState를 사용하던 부분을 useReducer로 바꾼다던가, context에 접근하기 위해 일반 유틸 함수였던 것을 react hook으로 변경한다던가 하는 자잘한 개선들이 있었다.

리팩토링을 잠시 홀딩하면서 이런 개선 항목들도 반영되지 못하게 된 것이 아무래도 아쉽다.

잠깐의 달콤한 꿈을 꾸었지만 결국 props drilling을 해결하지 못한 채, 예전의 코드 위에 또다른 기능을 추가하러 간다. 흑흑.

 


첨부 : gpt가 제안했던 (못 쓰는) 해결 / 우회 방법

  • React Cache 사용:
    • 원리: cache 함수는 동일한 인자로 호출된 함수의 결과를 메모이제이션합니다.
    • 해결 방법: 여러 컴포넌트에서 동일한 데이터를 요청하더라도 실제 네트워크 요청은 한 번만 발생합니다. 이는 중복 요청을 방지하고 데이터 일관성을 보장합니다.
  • Server Actions 사용:
    • 원리: 서버 사이드에서 데이터를 가져오는 함수를 정의하고, 이를 서버 컴포넌트에서 직접 호출합니다.
    • 해결 방법: 데이터 페칭 로직을 컴포넌트에서 분리하여 관리할 수 있습니다. 이는 서버 컴포넌트의 렌더링 순서와 관계없이 일관된 방식으로 데이터를 가져올 수 있게 해줍니다.
  • Next.js의 Route Handlers 사용:
    • 원리: 데이터 페칭 로직을 중앙화하고, 클라이언트 측에서 이를 호출합니다.
    • 해결 방법: 서버 컴포넌트의 렌더링 순서에 의존하지 않고, 클라이언트에서 필요한 시점에 데이터를 가져올 수 있습니다. 이는 서버 컴포넌트 렌더링 순서 문제를 우회합니다.
  • getServerSideProps 사용 (페이지 라우터):
    • 원리: 페이지 렌더링 전에 서버 사이드에서 데이터를 미리 가져옵니다.
    • 해결 방법: 모든 데이터가 준비된 후에 페이지 렌더링이 시작되므로, 렌더링 순서 문제가 발생하지 않습니다. 그러나 이 방법은 App Router에서는 사용할 수 없습니다.