문맥이 필요한 Next.js 서버
Zustand는 자바스크립트 환경의 상태관리 라이브러리로 높은 범용성과 간편한 적용법으로 많이 사용된다. 그런데 Next.js는 Zustand의 속성과 상충되는 면이 있어 추가적인 세팅이 필요하다. Zustand 공식 문서에는 별도의 탭에 이유를 간단하게 설명해놓았는데, 하나씩 살펴보며 Next.js와 Zustand에 대한 이해를 점검해보려 한다.
“작고 빠르며 확장 가능한 베어본 상태 관리 솔루션입니다. Zustand는 훅 기반의 편안한 API를 가지고 있습니다. 보일러플레이트적이거나 독단적이지 않지만, 명확하고 플럭스 스타일을 위한 충분한 규칙을 가지고 있습니다.” -
Zustand Introduction
소개글에서 강조하듯이 Zustand는 React나 기본 JS처럼 일반적인 환경에서 사용할 경우 장황한 기반 작업없이 바로 사용할 수 있다.
/**@ 예시코드 */
const useStore = create((set) => ({
rabbits: 0,
increasePopulation: () => set((state) => ({ rabbits: state.rabbits + 8 })),
}));
/** 다른 파일 **/
function RabbitCounter() {
const rabbits = useStore((state) => state.rabbits);
return <h1>{rabbits} around here ... </h1>;
}
function Controls() {
const increasePopulation = useStore((state) => state.increasePopulation)
return <button onClick={increasePopulation}>a month later ... </button>
}
만약 Redux 세팅 코드를 적었다면 80%는 여기서 뒤로가기를 눌렀을 것이다.
그러나 Next.js 환경에서는 위 장점이 온전히 발휘되지 못한다. 공식적으로 Context 안에서 스토어를 초기화하고 저장하는 것을 권장한다. 때문에 기존 스토어 생성 -> 사용
의 두 단계에서 Context 생성 -> 스토어 생성 -> 스토어 초기화 -> Provider 등록 -> 사용
의 다섯 단계로 과정이 늘어난다.
예시 코드는 공식 문서에서 볼 수 있다.
이렇게 세팅의 차이가 나는 이유는 Zustand의 스토어가 전역 변수 기반이기 때문이다. Next.js는 서버와 클라이언트, 두 가지 환경에서 모두 실행되므로 전역 변수의 동작이 Zustand의 가정과 다르다.
앞에선 전역 변수라고 했지만 정확히는 모듈 상태 기반이다. 전역 변수라고 하면 window
의 속성처럼 모든 파일에서 공유되는 값을 떠올릴 수도 있지만 이를 사용하는 것은 지나치게 개방적이므로 바람직하지 않다.
모듈은 전역 공간을 안전하게 관리하기 위해 도입된 기능으로 파일 단위로 생성된다. 모듈은 싱글톤이 결합된 즉시실행함수와 유사하게 동작한다. 즉시실행함수로 유발되는 클로져를 통해 모듈 내부에 상태를 은닉하면서 내보내고 싶은 요소만 지정하여 캡슐화할 수 있다. 싱글톤처럼 모듈당 하나의 인스턴스만 생성하여 include 간에 상태가 공유될 수 있다.
Zustand는 컴포넌트 트리와 분리되어 존재되어야 할 스토어의 위치를 모듈로 선택함으로써 높은 범용성과 세팅의 간결함을 얻었다고 볼 수 있다. 만약 스토어가 반드시 Context에 존재해야 한다고 하면 툴이 React에 종속되었을 것이다.
Next.js도 전역 공간이 모듈로 이루어진다는 점은 다르지 않다. 대신 사용자 중점으로 생각했을 때 구조가 다르다.
React 같은 일반 SPA 환경에서는 클라이언트만 존재하므로 사용자마다 하나의 전역 공간을 독점적으로 갖는다. 반대로 SSR 환경인 Next.js는 서버를 추가로 갖는데, 서버는 클라이언트와 일대다 관계로 구성되어 사용자들의 요청을 통합적으로 처리한다. 때문에 서버의 전역 공간은 모든 사용자가 공유한다고 볼 수 있다. 이 점이 Next.js에서 모듈을 사용하지 않는 핵심 이유이다. 스토어를 사용자간에 공유되는 곳에 둔다면 사용자의 상태가 다른 사용자에게 노출될 가능성이 생긴다.
요청당 스토어: Next.js 서버는 동시에 여러 요청을 처리할 수 있습니다. 이는 스토어가 요청마다 생성되어야 하며 요청 간에 공유되어서는 안 된다는 것을 의미합니다. -
Zustand 공식문서
위 문구처럼 서버에서도 요청이 들어온 클라이언트마다 스토어를 따로 생성해야한다. 서버에서 요청이 들어올 때마다 파일을 생성하지는 않으니 모듈은 부적합하다. 대신 요청마다 별개로 생성되는 VDOM이 적합해보인다. 또한 DOM 자체에는 영향을 주지 않으면서 다른 컴포넌트에서 스토어에 접근이 쉬워야 한다. 따라서 스토어의 생성 장소로 Context가 선택되는 것은 자연스럽다고 볼 수 있다.
Next.js의 클라이언트 컴포넌트에 대해 나처럼 오개념이 있었다면 다음과 같이 생각했을 수도 있다.
어쩌피
useStore
는 훅이라서 클라이언트 컴포넌트에서만 사용 가능하니까 스토어 생성도 클라이언트 모듈에서만 이루어지는 거 아닌가?
아니다. 클라이언트 컴포넌트는 서버에서도 실행된다. SSR 과정에서 Next.js는 서버 컴포넌트를 렌더링해 만든 RSC Payload 데이터와 클라이언트 컴포넌트 코드를 조합해서 초기 HTML을 만들고 클라이언트로 보낸다.
내가 가지고 있던 오개념은 서버에서 클라이언트 컴포넌트 부분이 스킵된다는 것이었다. 서버 컴포넌트를 RSC Payload로 변환하는 과정에서 클라이언트 컴포넌트 부분은 전달받을 props와 함께 placeholder로 남겨진다. 이 과정에선 사실상 스킵되는 것이 맞지만 RSC Payload를 만드는 과정과 HTML을 만드는 과정은 별개이다.
HTML은 처음 브라우저로 보내져 DOM을 형성하고, RSC Payload는 생성된 DOM 정보와 함께 React에서 VDOM을 형성하는데 사용된다. 이후 클라이언트 컴포넌트 코드가 브라우저에서 실행되며 Hydration이 이루어진다.
따라서 클라이언트 컴포넌트인 Context에서 useRef
로 연결된 스토어 생성 및 초기화는 서버에서도 이루어진다. 다만 이후 액션은 전달되지 않기에 초기화만 이루어진다. 하위 클라이언트 컴포넌트에선 useStore
로 스토어 상태 초기값을 가져와 HTML 형성에 활용한다.
서버에선 훅을 실행할 수 없다는 것도 오개념이었다. 서버 컴포넌트가 상태를 가지지 않는 것이 Next.js의 철칙이기 때문에 그 안에서 훅을 사용하지 못하게 막을 뿐이다.
서버에서도 스토어의 초기화가 이루어진다는 건 이해했다. 그런데 상태 업데이트 없이 초기값만 필요하다면 그냥 useStore
가 서버 환경에서는 초기값만 반환하도록 하거나, 요청마다 스토어 상태를 리셋하는 기능을 추가하면 해결할 수 있지 않을까? Context를 추가할 필요가 없으니 보일러플레이트도 줄일 수 있을 것이다.
해당 방법의 문제는 초기화의 루트가 다양화된다는 것이다. 클라이언트에서는 스토어 생성 과정에서 상태 초기화가 이루어지는 반면, 서버에서는 별개의 기능으로 초기화가 이루어진다. 물론 대부분의 경우 두 루트를 같은 값으로 설정하겠지만 불일치의 가능성이 생겨난다.
SSR 친화적: Next.js 애플리케이션은 두 번 렌더링됩니다. 첫 번째는 서버에서, 그리고 다시 클라이언트에서입니다. 클라이언트와 서버에서 다른 출력이 나오면 "hydration 오류"가 발생합니다. 이를 방지하기 위해서는 스토어를 서버에서 초기화한 후 클라이언트에서 동일한 데이터로 다시 초기화해야 합니다. 이에 대해 더 자세히 알아보려면 저희의 SSR 및 Hydration 가이드를 참조해 주세요. -
Zustand 공식문서
서버와 클라이언트의 스토어에서 초기화값의 불일치가 일어난다면 hydration 오류가 발생한다. Context 내부에서 스토어를 생성하면 서버와 클라이언트 모두 스토어 생성 과정에서 초기화되므로 오류 발생 가능성을 최소화할 수 있다.
해당 파트 내용은 추론으로 정확하지 않을 수 있습니다.
Next.js가 모든 페이지를 SSR으로 처리하는 건 아니다. 초기 로딩 이후의 페이지 이동에는 SPA처럼 CSR을 활용하는 하이브리드 라우팅 모델을 사용한다.
SPA 라우팅 친화적: Next.js는 클라이언트 사이드 라우팅에 하이브리드 모델을 지원합니다. 이는 스토어를 리셋하기 위해서는 컴포넌트 수준에서 Context를 사용하여 초기화해야 함을 의미합니다. -
Zustand 공식문서
문제는 이 경우에도 서버 컴포넌트의 정보는 필요하다. 보통 서버 컴포넌트에는 데이터를 패칭하는 로직이 포함되어있다. 별개의 API라면 클라이언트에서 따로 요청 넣을 수 라도 있겠지만 SQL 쿼리라면 아예 불가능하다. 때문에 서버 컴포넌트의 데이터인 RSC Payload는 CSR이더라도 받아와야 하는데, 이는 스토어가 초기값인 상태 기반일 것이다. 서버 데이터를 사용하기 위해선 hydration을 위해 클라이언트 스토어도 잠시 리셋되어야 한다. 이와 동시에 스토어의 기존 상태를 잃어서는 안될 것이다.(페이지를 이동했더니 로그인이 풀린다면?) 즉, 일종의 temp
스토어가 필요하다.
스토어가 모듈 기반으로 존재한다면 실행 환경에서 하나밖에 존재할 수 없으므로 불가능하다. Context를 이용해 컴포넌트 수준에 존재해야 같은 종류의 스토어 두 개가 따로 존재할 수 있다.
React는 VDOM이 현재 버전과 새로 렌더링되는 버전으로 두 개가 있다. 새로 렌더링 되는 VDOM에선 초기화된 임시 스토어를 사용해 hydration을 마친 뒤, 현재 버전의 스토어를 가져와 재렌더링한다면 현재 상태를 라우팅 사이에 유지할 수 있을 것이다. Next.js는 partial 렌더링을 지원하므로 트리 최상단에 위치한 Provider를 바꿔치기 하는 건 가능할 것이다.
참고: 라우트마다 스토어를 생성하는 것은 페이지(라우트) 컴포넌트 수준에서 스토어를 생성하고 공유해야 한다는 것을 의미합니다. 라우트마다 스토어를 생성할 필요가 없다면 이 방법을 사용하지 않는 것이 좋습니다. -
Zustand 공식문서
이는 공식 문서에 라우트별 스토어를 지양하는 문구이다. 처음에 보았을 때는 제대로 이해하지 못했는데 개인적으론 Context를 상태 관리에 사용하지 않기 때문인 것으로 해석했다. Context를 useState
와 조합해 상태관리 용으로 사용한다면 하위 컴포넌트들의 리렌더링을 최소화하기 위해 최대한 범위를 좁혀 아래 트리에 Provider를 위치시키는 게 바람직하다. 반면에 Zustand는 요청마다 스토어를 분리해 저장하기 위한 용도로만 사용한다. useRef
를 이용하기 때문에 초기화 시 한 번만 렌더링에 관여한다. 상위에 다른 컴포넌트가 있다면 불필요하게 Provider도 리렌더링의 대상이 된다. 때문에 스토어를 페이지 컴포넌트 수준으로 내렸을 때 얻을 이점이 없어 최상단에 놓는 걸 권유하는 것으로 보인다.