
서론
프로젝트를 진행하면서 시간을 다루는 일이 생겼고, 이로 인해 의도치않게 Hydration Mismatch가 발생했다. 문제에 대해서는 해결했지만 그 과정에서 App Router를 사용하면서 Client Component와 Server Component에 대한 렌더링 환경 차이를 정확하게 인지하지 못한 점과 문제가 발생했을때 문제 포인트를 빠르고 정확하게 캐치하지 못했다는 점에서 알게모르게 혼동되었던 이 개념들을 다시 정리하려고 한다.
본론
현재 회사에서 진행중인 프로젝트는 Nextjs 14 App Router를 사용중이다.
기존에는 Page Router를 사용했다가 마이그레이션을 최근에서야 마무리하게되었는데, 아직 일부 개념들에 있어서는 Page Router에 갇혀있다는 느낌이 들기도 했다.
이를 하나씩 해결하기 위해 기본적으로 Client Component와 Server Component에 대해 Nextjs 공식문서에서 정리한 내용을 살펴보자
https://nextjs.org/learn/react-foundations/server-and-client-components
React Foundations: Server and Client Components | Next.js
Learn about the server and client environments and when to use each.
nextjs.org
처음에는 App Router의 Client Component와 Server Component를 공부해본 사람이라면 알만한 내용들이 적혀져있다.
클라이언트와 서버의 개념에 대해 다시한번 언급하면서도 결국 서버와 클라이언트에서 작성하는 코드는 같지 않기때문에 각 환경에 맞는 행동을 해야한다는 점을 강조한다.
Client Component는 일반적으로 클라이언트에서 사용하는 컴포넌트이기때문에 React 상태를 핸들링하거나 이벤트핸들러를 가지고있는 컴포넌트, 즉 상호작용을 가지고있는 컴포넌트를 가리킨다고 한다.
Server Component의 경우 기본 개념에서와 동일한 맥락으로 이벤트 핸들러를 가지고있지 않은 컴포넌트, 즉 상호작용을 가지고있지 않은 컴포넌트를 가리키며 일반적으로는 DB에 접근하여 데이터를 가지고오는 Network Boundary 역할을 한다고 한다.
이러한 내용을 보다보면 개인적으로는 한가지 모호했던 점이 있는데 기존의 SSR, CSR, SSG 등 여러가지 페이지단위의 렌더링 방식을 나누던 Page Router 방식에서 이제는 이를 더 세밀하게 다루기 위해 컴포넌트 단위로 운영방식을 다르게 가지고가는 과정에서 결국 이들 컴포넌트는 '어떻게 렌더링되는지' 에 대한 부분이 명확하게 정의되지 않았었다.
이에 대해 정의를 내려보면 App Router에서 Server Component는 당연히 서버에서 만들고 번들에도 포함이 안되기때문에 SSR로 동작할 것이다. 그렇다면 Client Component는 어떻게 동작하는가?
결론부터 이야기하자면 이 역시 SSR로 동작한다.
다만 둘의 동작에는 약간의 차이가 있는데, Server Component는 서버에서 렌더링을 수행하며 이 과정에서 일반적으로 사용하는 api 호출에 대해서도 수행한 뒤 이에 대한 response를 즉각적으로 활용할 수 있다. 이 컴포넌트의 경우에는 유저와의 상호작용이 없는 컴포넌트이기때문에 서버에서 만들어낸 결과물로도 충분하고 추가적인 동작이 필요하지 않기때문에 번들에도 포함이 되지 않는다.
이제 문제의 Client Component를 알아보자.
Client Component는 실제로 유저와의 상호작용을 가지고있는 컴포넌트이기때문에 서버에서 만들어낸 결과물로는 끝낼 수 없다.
최초 서버에서 렌더링하는 시점에 Client Component 역시도 그대로 렌더링을 수행하여 HTML을 만들어낸다. 이때 React State에 따라 조건부 렌더링이 수행되는 곳이 있다면 별도 동작이 없는 상태이기때문에 초기값을 기준으로 렌더링을 수행한다. 이렇게 서버에서 수행한 렌더링의 결과물이 만들어질 수 있는 것이다. 다만 이 컴포넌트는 서버에서 만드는 것만으로 끝나는게 아니기때문에 클라이언트에서도 수행될 수 있도록 번들에 코드를 포함시켜준다.
그렇다면 이제 유저와의 상호작용을 할 수 있는 컴포넌트로 만들기 위해 클라이언트에서도 받아온 번들을 통해 렌더링을 수행한다. 클라이언트에서도 렌더링을 수행했다면 이에 대한 HTML이 만들어졌을 것이다.
이제 여기서 나타나는 것이 마른 HTML에 이벤트 핸들러와 같은 JS를 부착해주는 하이드레이션(Hydration) 단계인 것이다.
물론 여기서 한가지 짚고 넘어갈 점은 클라이언트에서 만들어낸 결과물은 실제 DOM 은 아니고 가상 DOM 이라는 점이다.
하이드레이션은 쉽게 생각하면 서버에서 만들어낸 결과물을 바탕으로 수행해야할 동작이 정해져있는 것이다. 클라이언트 사이드의 렌더링을 기다린 후 서버에서 지시한대로 정해진 위치에 수행해야하는 동작(이벤트 리스너 부착) 을 그대로 이행하는 과정이라고 볼 수 있는 것이다.
동일한 맥락에서 Nextjs에서 개발을 하다보면 종종 마주칠 수 있는 문제인 Hydration Mismatch를 확인해보자.
Hydration Mismatch란 직역하면 하이드레이션 과정에서 매치가 안되었다는 것이다. 즉 위에서 언급한대로 서버에서 만든 결과물대로 클라이언트에 이행해야하는데, 클라이언트에서 약속된 결과물이 아닌 다른 결과물이 나와버려 이를 그대로 이행할 수가 없다는 것이다.
예를 들면 이런 상황들이 있을 것이다.
'use client'
const TestComponent = () => {
const isServer = typeof window === 'undefined'
return (
isServer ? <div>서버입니다.</div> : <div>클라이언트 입니다.</div>
)
}
렌더링 사이클을 거치지 않고 단순 조건문으로 서버와 클라이언트에 따라 다른 렌더링 결과물을 내도록 만들었다고 가정해보자.
이런 상황에서는 서버에서 만든 결과물과 클라이언트에서 만든 결과물이 아래와 같이 될 것이다.
// 서버 사이드 렌더링 결과물
<div>서버입니다.</div>
// 클라이언트 사이드 렌더링 결과물
<div>클라이언트 입니다.</div>
이렇게 된다면 하이드레이션을 진행하기 위해 대상 노드를 찾아가는 과정에서 다른 노드로 판별이 되어 mismatch가 일어나는 것이다.
이와 동일한 맥락으로
- 두 렌더링 결과물에서 대상 노드의 태그 종류가 다른경우 (ex. 서버에서는 div, 클라이언트에서는 span)
- 두 렌더링 결과물에서 대상 노드의 className이 다른 경우 (ex. 서버에서는 className="A", 클라이언트에서는 className="B")
와 같은 케이스에서도 hydration mismatch 가 일어날 수 있는 것이다. 물론 이외의 케이스도 존재하지만 이를 전부 다루지는 않겠다.
(https://nextjs.org/docs/messages/react-hydration-error)
그렇다면 이러한 결과물을 토대로 한가지 문제를 바라보자. 이 상황에서는 Hydration Mismatch가 일어날까?
'use client'
const LONGEST_FUTURE = dayjs('2100-01-01')
const TestComponent2 = () => {
const [updateTime, setUpdateTime] = useState(new Date())
const isFuture = dayjs(updateTime).isAfter(LONGEST_FUTURE)
return (
isFuture ? <div>먼 미래입니다.</div> : <div>그건 아니에요</div>
)
}
일반적으로 Hydration Mismatch를 아주 자주 일으키는 상황은 시간을 다룰때이다.
이유는 서버에서의 렌더링 시점과 클라이언트의 렌더링 시점이 다르기때문에 둘의 결과물이 달라지는 상황이 생길 수 있기 때문이다.
하지만 위의 경우는 아니다.
두 렌더링 결과물에 있어서 state값은 다를지라도 이 상태가 직접적으로 렌더링 결과물에 영향을 주지 못하기 때문이다. (사는동안은 시간을 조작하지 않는 이상 아마 isFuture가 true가 될 일은 없을것이다.)
여기서 강조하고싶은 점은 new Date() 와 같이 가변적인 값을 사용하더라도 결국 이 값이 렌더링 결과물에 어떤 영향을 끼쳤는지에 따라 Hydration의 성공과 실패가 나누어진다는 것이다.
그래서 실제로 Mismatch를 방지하기 위해 권장하는 방법은 조건부로 렌더링하고자 하는 로직을 hydration 이후 시점으로 미루는 useEffect를 활용한 방식이다.
// https://nextjs.org/docs/messages/react-hydration-error#solution-1-using-useeffect-to-run-on-the-client-only
import { useState, useEffect } from 'react'
export default function App() {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
return <h1>{isClient ? 'This is never prerendered' : 'Prerendered'}</h1>
}
이렇게 하게된다면 서버사이드 렌더링과 클라이언트 사이드 렌더링의 비교 시점까지는 isClient가 바뀌지 않기때문에 hydration을 안전하게 수행하고, 이후에 useEffect를 수행하여 Client 사이드에서 렌더링하려고했던 결과물을 그려줄 수 있기 때문에 권장하는 방식 중 하나이다.
하지만 이 방식의 경우 반드시 클라이언트 사이드 렌더링 시점 이후에 실행된다는 점에서 SSR을 사용했음에도 불구하고 Layout Shift가 발생하는 아쉬운 경험이 생길 수 있다.
이러한 문제를 조금 더 보완해주는 방법은 아래와 같다.
// https://tkdodo.eu/blog/avoiding-hydration-mismatches-with-use-sync-external-store#usesyncexternalstore
const emptySubscribe = () => () => {}
function LastUpdated() {
const date = React.useSyncExternalStore(
emptySubscribe,
() => lastUpdated.toLocaleDateString(),
() => null
)
return date ? <span>Last updated at: {date}</span> : null
}
이 방법은 React 18에서 제공하는 useSyncExternalStore를 활용하는 방법으로 최초 Hydration을 수행하는 SSR이 동작할때에는 서버와 클라이언트의 최초 렌더링에서는 서버쪽 SnapShot을 사용하고(하이드레이션 까지도 서버 SnapShot을 사용한다.), 이후 하이드레이션 이후에 클라이언트쪽 SnapShot을 사용하기때문에 Hydration mismatch에서 안전하고, Client Side Navigation을 수행할 때에는 기존 방식처럼 최초 렌더링 이후 다음 렌더링 사이클까지 기다리는 것이 아니라 바로 클라이언트 SnapShot을 반환해주기 때문에 보다 더 좋은 경험을 제공할 수 있게 된다.
결론
- Nextjs의 App Router에서 'use client'나 'use server'는 CSR인지 SSR인지를 나누는 요소가 아니다.
- use client를 사용하는 클라이언트 컴포넌트도 SSR을 수행한다. 단지 CSR도 수행하며 이에 대한 결과물을 비교하여 Hydration을 수행하는 과정이 있을 뿐이다.
- Hydration은 두 사이드의 렌더링 결과물이 같을 때 수행할 수 있다. mismatch가 일어났다면 시점에 따라 달라질 수 있는 상태에 집중하기보다는 이 상태로 인해 렌더링 결과물에 영향을 끼칠만한 부분을 먼저 찾아보자
- 부득이하게 두 상황에 따라 다른 렌더링 결과물을 반환해야한다면 단순하게 useEffect를 활용한 방식보다는 useSyncExternalStore를 활용하는 방법도 고민해보자.
부록
실제로 서버 컴포넌트와 클라이언트 컴포넌트가 렌더링되는 과정은 조금 더 디테일하다. 위의 내용에서는 이를 심층적으로 다루는 것이 주 목적은 아니었기에 이곳에 간단히 정리해둔다.
서버 컴포넌트
1. 서버에서 React 컴포넌트 트리 구성
2. 서버 컴포넌트 실행 및 데이터 페칭
3. 클라이언트 컴포넌트 자리 표시자 생성
4. RSC 페이로드 생성 (서버 컴포넌트 결과 + 클라이언트 컴포넌트 자리 표시자)
5. HTML 스트리밍
클라이언트 컴포넌트 (+ 하이드레이션)
1. 서버에서 생성된 HTML 수신
2. JavaScript 번들 로드
3. React 초기화
4. RSC 페이로드 수신 및 처리
5. 클라이언트 컴포넌트 하이드레이션 시작
6. 이벤트 핸들러 부착
7. 하이드레이션 완료
'개발 > React' 카테고리의 다른 글
eslint-lint-sprinkles-rule 제작기 - (3) sprinkles-rule 내부를 구성하는 코드 설명 (0) | 2025.03.02 |
---|---|
eslint-lint-sprinkles-rule 제작기 - (2) sprinkles-rule 작성을 위한 배경 지식 (0) | 2025.02.16 |
eslint-lint-sprinkles-rule 제작기 - (1) 문제인지 (0) | 2025.02.02 |
React 19 긍정 검토하기 (0) | 2025.01.19 |
Polymorphic Component를 정의하는 여러가지 방법 해체분석 (0) | 2025.01.05 |