반응형

Next.js SSR에서 조건부 렌더링 시 발생하는 Hydration Error의 원인과 해결법

Next.js에서 서버사이드 렌더링(SSR)을 사용할 때 조건부 렌더링이 섞여 있다면 종종 다음과 같은 에러를 보게 된다.

Hydration failed because the initial UI does not match what was rendered on the server.

이 에러는 단순한 경고가 아니다. 서버가 렌더링한 HTML과 클라이언트가 브라우저에서 렌더링한 결과가 다르다는 뜻이다. 즉, 서버와 클라이언트의 렌더 트리가 불일치한다는 의미다.


1. Next.js의 렌더링 구조

Next.js에서 페이지가 렌더링되는 과정은 다음과 같다.

(1) Server Rendering

서버는 React 컴포넌트를 실행해 HTML 문자열을 만든다. 이 HTML은 브라우저로 전달되어 바로 화면에 표시된다. 이 시점에서는 자바스크립트가 실행되지 않는다.

(2) Hydration

클라이언트는 서버에서 전달된 HTML을 React 트리로 복원한다. 서버가 만든 DOM과 클라이언트가 만든 가상 DOM이 완전히 일치해야 한다. 조금이라도 다르면 Hydration Error가 발생한다.

(3) Interaction

하이드레이션이 끝나면 브라우저에서 실제 이벤트 핸들러가 연결되고, 인터랙션이 가능해진다.


2. 문제의 핵심: 조건부 렌더링 불일치

Hydration Error는 대부분 조건부 렌더링이 서버와 클라이언트에서 다르게 평가될 때 발생한다.

서버는 window, localStorage, matchMedia 같은 브라우저 전용 객체를 모른다. 따라서 이런 값으로 조건을 걸면 서버와 클라이언트의 렌더링 결과가 달라진다.

잘못된 예시

export default function Nav() {
  const isMobile = window.innerWidth < 768;
  return isMobile ? <MobileNav /> : <DesktopNav />;
}
  • 서버: window가 없음 → isMobilefalse<DesktopNav />
  • 클라이언트: window.innerWidth < 768true<MobileNav />

결과적으로 두 렌더 트리가 다르기 때문에 React는 “서버가 만든 HTML과 클라이언트의 계산 결과가 다르다”고 판단하고 에러를 발생시킨다.


3. Hydration Error를 일으키는 일반적인 케이스

구분 원인 예시
브라우저 전용 객체 window, localStorage, navigator, matchMedia
시간 의존 Date.now(), new Date()
랜덤 값 Math.random(), uuid()
사용자 환경에 따라 분기 다크 모드 감지, 뷰포트 크기 체크
App Router 혼합 문제 Server Component에서 Client Component를 조건부로 렌더링

4. 안전한 해결법

1) 마운트 후 렌더링 (Client Only Render)

SSR 시에는 빈 껍데기만 내보내고, 클라이언트 마운트 이후 실제 UI를 그린다.

"use client";
import { useEffect, useState } from "react";

export default function SafeRender() {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);

  if (!mounted) return <div aria-hidden />; // SSR과 동일한 마크업 유지
  return <ClientOnlyUI />;
}

서버와 클라이언트가 동일한 HTML 구조를 유지하므로 Hydration Error를 피할 수 있다.


2) SSR 비활성화 (Dynamic Import)

SSR이 불필요한 컴포넌트는 아예 클라이언트 전용으로 만든다.

import dynamic from "next/dynamic";
const ClientOnly = dynamic(() => import("./ClientOnly"), { ssr: false });

export default function Page() {
  return <ClientOnly />;
}

이 방법은 지도, 차트, 애니메이션처럼 브라우저 환경에 의존적인 컴포넌트에 적합하다.


3) 서버에서 조건을 계산해 클라이언트로 전달

조건 분기 로직을 서버에서 계산하면 클라이언트에서 다시 판단하지 않아도 된다.

// server component
export default async function Page() {
  const userAgent = headers().get("user-agent");
  const isMobile = /mobile/i.test(userAgent);
  return <Nav isMobile={isMobile} />;
}

// client component
"use client";
export function Nav({ isMobile }: { isMobile: boolean }) {
  return isMobile ? <MobileNav /> : <DesktopNav />;
}

이 방식은 SSR과 CSR의 일관성을 유지할 수 있어 가장 이상적인 형태다.


4) suppressHydrationWarning으로 예외 처리

렌더 결과가 달라도 실제 UI에 영향이 없는 경우(예: 시간 표시) 경고만 숨길 수 있다.

<span suppressHydrationWarning>{new Date().toLocaleTimeString()}</span>

단, 이 방법은 임시방편일 뿐 근본적인 해결책은 아니다.


5. 정리

원인 설명 해결 방법
브라우저 전용 코드 window, localStorage 마운트 후 렌더링
환경에 따른 분기 화면 크기, 테마, 언어 서버에서 계산 후 props로 전달
SSR 불필요 컴포넌트 애니메이션, 지도 등 dynamic(..., { ssr: false })
UI 일시적 불일치 시계, 랜덤 값 suppressHydrationWarning

결론

Next.js에서 SSR을 사용할 때 Hydration Error는 “서버와 클라이언트가 서로 다른 HTML을 만든다”는 신호다.

SSR 단계에서는 브라우저 전용 코드를 실행하지 않도록 설계하고, 조건부 렌더링은 마운트 이후로 미루는 것이 안전하다.

SSR과 CSR이 동일한 렌더 트리를 유지하는 것이 Next.js에서 안정적인 UI를 만드는 핵심이다.

반응형
반응형

리팩토링은 어려워


얼마 전 react-query 로 데이터 호출 프로세스를 변경했다. 이 때만해도 데이터가 술술 잘 불러와지더라니 낌새를 챘어야 했다. 컴포넌트를 하나 둘 추가하다 보니 Mypage 로 이동할 때마다 에러를 마주치게 되었다.

 

Should have a queue ~~

 

아니 이건 또 처음 보는 에런데,, 당황의 연속,,

구글링을 열심히 해보니 React Hook 의 규칙 중 하나인 “Hook 은 항상 동일한 순서로 호출되어야 한다” 는 규칙을 위반했을 때 발생한다고 한다. 그런데 이전까지는 잘되다가 갑자기 안되는 이유는 무엇?

범인은 바로 너


지피티가 알려준 나의 코드의 문제 부분을 함께 찾아보게 되었다. 예전의 나는 응애였기에 몰라 하란대로 한 부분이었는데 지금 와서 보니 너무 얼척이 없는 문제를 일으키고 있었다.. 공부의 중요성 ,,,,,,,

*// 문제가 있는 코드*

const purchaseUserIds = referralPurchases

.map(*purchase* => purchase?.user_id)

.filter(*id* => !!id);

*// 반복문 안에서 Hook을 호출하는 잘못된 패턴*

const userInfoQueries = purchaseUserIds.map(*userId* => useUserByUserId(userId));

 

문제 원인

useQuery 로 불러온 데이터를 순회하면서 또 useQuery 써버리기

정확히 말하면 반복문 내에서 Hook 을 호출하면 렌더링마다 Hook의 호출 순서가 달라질 수 있기 때문에 동적 Hook 호출이 되고 이는 리액트 규칙 위반이다.

React Hook 규칙


  1. 최상위에서만 호출
    1. 컴포넌트 혹은 커스텀 훅의 최상위 스코프에서만 훅을 호출
    2. 조건문, 반복문, 중첩된 함수 안에서 훅을 호출하면 안됨
  2. 렌더링마다 동일한 순서로 호출
    1. 컴포넌트가 매번 렌더링될 때 훅이 호출되는 순서와 개수가 변하면 안됨
    2. 호출 순서가 바뀌면 이전 렌더에서 저장해둔 “훅의 큐” 를 찾아올 수 없어 오류가 발생

왜 동일한 순서여야 할까?


리액트의 훅 동작 순서를 파악해보자.

  • 컴포넌트가 렌더링 될 때 마다, 리액트는 훅 호출 순서를 따라 hook1, hook2, … 순으로 큐를 꺼내 상태를 연결
  • 만약 어떤 렌더에서 hook2 가 호출되지 않거나, hook4 가 먼저 호출 되면 리액트는 이전에 저장된 큐가 없다고 판단하며 에러를 던짐

리액트가 훅 순서를 기억하는 이유 → 복작한 트리 구조를 따로 추적하지 않아도됨 → 단일 연결 리스트 형태로 훅 상태를 관리

해결 방법 (useQueries)


useQueries 는 훅 순서를 고정해 동적으로 쿼리를 생성할 수 있게 도와준다. 이 훅이 내가 원하는 기능을 구현하기에 안성맞춤이다고 느끼게 되었다.

import { useQueries } from '@tanstack/react-query';

*// 해결된 코드*

const userInfoQueries = useQueries({

queries: purchaseUserIds.map(userId => ({

queryKey: ['user', userId],

queryFn: () => useUserByUserId(userId),

enabled: Boolean(userId),

})),

});

오늘의 교훈


“Hook은 마법 같은 편의 기능이지만, 그 뒤에 숨은 내부 메커니즘을 이해해야 비로소 제대로 쓸 수 있다.”

— 익명의 React 개발자 —

반응형

'Error' 카테고리의 다른 글

[Next] Hydration Error  (0) 2025.11.02
[Blockchain] NFT metadata가 잘못올라가다.  (0) 2025.06.08
[Blockchain] RPC url 을 구매하다.  (0) 2025.06.01
[Blockchain] RPC server down  (0) 2025.05.18
[WebView] How to Open in Chrome Browser  (0) 2025.04.27
반응형

오픈을 하루 앞두고 부담감이 솟구치지만 그래도 뭔가 잘 되어가고 있다는 기분에 그나마 버틸만 한 월요일 아침. 팀장님 왈 ‘NFT id 가 1001번 부터 url이 잘못되어있어요.’

 

예?!

아 엑셀 실수..


나는 보통 thirdweb에서 NFT 데이터를 관리하고 NFT를 민팅하는데 csv 파일을 사용해서 올린다. 이전에 했을때는 csv 파일에서 셀 우측하단을 잡고 주욱 잡아 늘리면 아래 행으로 복사가 되었다. 근데 여기서 이름에 해당하는 컬럼의 id 값만 바뀌고 image url은 안바뀌었는데 어찌된 영문인지 이번에는 url 도 같이 바뀌었다. 오마이갓

 

이래서 더블체킹을 해야하는데.. 여튼 자책할시간은 없다. 방법을 찾아보았다.

updateBaseUri


다행스럽게도 updateBaseUri 함수가 구현되어 있었다. nft 를 민팅하면 baseurl 을 기준으로 id 값별 url이 매핑된다. 그래서 tokenId = 1 이라면 baseUrl/1 이렇게 metadata가 매핑된다.

 

하지만, 나는 처음에 baseUrl 이 이미지만 바뀌는 구나! 나는 이미지만 바꾸면되니까 이걸로 고정시켜버리자! 해서 이미지 url을 붙여버렸더니 역시나 안됐다..

 

(이래서 선공부 후개발 해야하는데 급한대로 하다보니 이렇다.)

 

그래서 이걸 어떻게 해야하나 고민을 하다가 대박적인 아이디어가 떠올랐다.

그냥 다시 민팅해~


그렇다. 가장 간단하고 빠른 방법. 다시 민팅

 

일단 기존에 나는 두번의 배치 민팅을 통해 nft 를 배포했기 때문에 두개의 baseUrl 이 필요했다. 그래서 새로운 더미 컨트랙트를 배포하고 두번의 동일한 nft를 배포한 후 새로운 uri 를 적용시키기로 했다.

 

결과는 성공적.. 이렇게 내 오전 업무는 날라갔다.

 

오늘의 교훈

  • 더블체킹을 하자.
  • baseUrl 에 대해 알게 되었다.
반응형
반응형

이전 회차에서 rpc url 이 다운되어 새로운 rpc url을 fallback 형식으로 변경했다. 이는 보기엔 잘 되는것 처럼 보였으나,, 블록체인 비즈니스에 대해 잘 모르던 나에게 한 차례 위기가 찾아왔다.

나 사실 공짜아님 ㅋ


drpc 뭐시기 라는 url 을 사용하고 있던 와중 갑자기 408 에러 (맞나?) 가 뜨게 되어 에러 메시지를 보니 다음과 같았다.

??? : 프리티어 끝났다. 더 쓰려면 결제하던지 말던지 ㅋ

왜 공용이겠어


일단 운영서버를 돌려야하니까 급한대로 다시 polygon-main url 을 적용했다. 어느정도 해결이 되는 듯 했으나,, 저번부터 나를 괴롭히던 view 함수 호출 시 이전 값이 랜덤하게 불러와지는 현상이 다시 발생하게 되었다.

 

아 왜그래 진짜

 

이 오류를 해결하기 위해 지피티를 열심히 괴롭힌 결과, polygon-main url은 아마도 공용 url 이니까 여러개의 url을 fallback 했을 것 이고, 그렇기 때문에 가끔 이전 값을 보내줄 수 있다고 설명해줬다.. 고마워 지피티야..

 

이 오류를 안 이후 얼마되지 않아 바로 호출되었고 사태에 대해 설명했다. 솔루션에 대해 고민을 해야했고 찾아본 결과 두 가지로 추려졌다.

  1. 직접 노드를 구매해 관리하기
  2. Alchemy, Infura 등 노드 관리 서비스 이용하기

메인 체인이 확정되지 않았기 때문에 노드를 구매한다는 것 자체가 리스키 했기 때문에 금방 사설 노드를 사용하는것으로 결정 되었다.

새로운 서비스는 어려워


https://www.alchemy.com/

 

Alchemy - the web3 development platform

Whether you're a beginner developer, startup, web3 market leader, or a large enterprise, Alchemy makes multichain web3 development easy.

www.alchemy.com

 

우리 서비스는 rpc call 이 많지 않기때문에 요금제를 크게 잡을 필요가 없어 프리티어 윗단계로 진행했다. 대시보드를 생성하고나니 Alchemy 도메인과 API key를 받게 되었고, 이를 합쳐서 wagmi 에 적용만 해준다면 손쉽게 적용할 수 있었다.

 

이제 새로고침을 한 후 rpc call 이 얼마나 사용되나 볼까? 했더니 얼래 ? 새로고침했다고 call 이 90번이나 호출된 것이다. 도대체 어디서,,

 

지금은 당장 개발이 급해 최적화를 할 시간은 없지만 언젠가는 해결해야할 문제가 생겼다.. 야호

 

여튼 rpc 가 여러모로 나를 많이 괴롭혔다. 여기서 얻은 교훈. 어떻게든 돈을 벌 수 있다(?) 는 아니고 너무 공짜 좋아하지 말자 입니다.

반응형

+ Recent posts