레이블이 React인 게시물을 표시합니다. 모든 게시물 표시
레이블이 React인 게시물을 표시합니다. 모든 게시물 표시

일요일

Next.js hydration 불일치 에러 완벽 해결 가이드

Next.js SSR 사용 시 hydration 불일치 에러가 자주 발생합니다.

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

원인:
1. 서버와 클라이언트 렌더링 로직 차이
2. Date.now()나 Math.random() 같은 비결정적 값 사용
3. window, localStorage 등 브라우저 API를 SSR 단계에서 접근

해결방법:
1. useEffect 내에서만 클라이언트 전용 콘텐츠 렌더링 - isClient 상태로 분기 처리
2. suppressHydrationWarning 속성 사용 - 작은 불일치는 무시 (남용 금지)
3. force-dynamic으로 동적 렌더링 - 정적 생성 대신 매번 렌더링
4. localStorage 접근은 반드시 useEffect 안에서 처리

정리: 원인을 파악하면 대부분 useEffect 패턴으로 해결 가능합니다.

목요일

Next.js Hydration 불일치 에러 해결하기

🔍 검색 키워드: Next.js hydration 불일치, SSR 하이드레이션 에러, hydration mismatch 해결, Next.js 렌더링 차이

Next.js Hydration 불일치 에러 해결하기

증상

Next.js 앱을 개발하거나 프로덕션에 배포했을 때, 브라우저 콘솔에 다음과 같은 에러가 나타난다:

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

또는 더 자세한 메시지:

Warning: Text content did not match. Server: "Monday" Client: "Sunday"

페이지는 렌더링되지만, 콘솔에 에러가 쌓이고 JavaScript가 제대로 작동하지 않을 수 있다. 특히 동적 콘텐츠나 클라이언트 전용 데이터가 있을 때 자주 발생한다.

원인

Hydration 불일치는 서버에서 렌더링한 HTML과 클라이언트에서 처음 렌더링한 React 컴포넌트가 다를 때 발생한다.

주요 원인들:

  1. 타임존/로케일 의존 데이터: new Date(), toLocaleDateString() 등이 서버와 클라이언트에서 다른 값 반환
  2. Math.random() 사용: 서버와 클라이언트가 다른 난수 생성
  3. 클라이언트 전용 hook 오남용: useEffect 없이 클라이언트 전용 코드 실행
  4. 브라우저 API 직접 접근: window, document 같은 브라우저 객체에 SSR 중 접근
  5. 조건부 렌더링 불일치: if (isMobile) 등의 조건이 서버와 클라이언트에서 다름

해결방법

1. useEffect로 클라이언트 전용 렌더링 (권장)

export default function Page() {
    const [mounted, setMounted] = React.useState(false);
    
      React.useEffect(() => {
          setMounted(true);
            }, []);
            
              if (!mounted) {
                  return <div>로딩 중...</div>;
                    }
                    
                      return <div>{new Date().toLocaleDateString()}</div>;
                      }

이렇게 하면 서버에서는 "로딩 중..." 렌더링, 클라이언트에서 hydrate 후 날짜 표시 → 불일치 없음.

2. suppressHydrationWarning 사용 (임시방편)

export default function Page() {
    return (
        <div suppressHydrationWarning>
              {new Date().toLocaleDateString()}
                  </div>
                    );
                    }

⚠️ 경고만 무시하고 실제 불일치는 해결 안 함. 간단한 경우만 사용.

3. 동적 콘텐츠는 서버에서 처리

// app/page.jsx (서버 컴포넌트)
  import { getServerData } from '@/lib/api';
  
  export default async function Page() {
    const data = await getServerData();
      return <div>{data.title}</div>;
      }

서버에서 필요한 데이터를 모두 fetch → 클라이언트에서는 같은 데이터 사용 → 불일치 자동 해결.

4. 브라우저 API는 useEffect 안에서만

export default function Page() {
    const [scrollY, setScrollY] = React.useState(0);
    
      React.useEffect(() => {
          const handleScroll = () => setScrollY(window.scrollY);
              window.addEventListener('scroll', handleScroll);
                  return () => window.removeEventListener('scroll', handleScroll);
                    }, []);
                    
                      return <div>스크롤: {scrollY}px</div>;
                      }

5. 타임존 문제 해결

// lib/date.js
  export function getCurrentDate() {
    // 서버와 클라이언트 모두 동일한 timezone 사용
      const now = new Date();
        return now.toISOString().split('T')[0];
        }
        
        // app/page.jsx
        import { getCurrentDate } from '@/lib/date';
        
        export default function Page() {
          const dateStr = getCurrentDate();
            return <div>{dateStr}</div>;
            }

정리표

상황 원인 해결법
new Date().toString() 다름 서버/클라이언트 타임존 차이 ISO 문자열 사용 또는 useEffect로 지연
Math.random() 불일치 난수 생성 차이 서버에서 난수 생성 후 props로 전달
window.location 에러 SSR 중 브라우저 API 접근 useEffect 안에서만 접근
모바일/데스크톱 렌더링 다름 미디어쿼리 판정 차이 CSS media query 사용 또는 useEffect 지연
조건부 렌더링 불일치 클라이언트 상태 기반 조건 서버/클라이언트 동일 조건 또는 useEffect 지연

TIP: Next.js 13+ 서버 컴포넌트를 최대한 활용하면 이런 문제가 대폭 줄어든다. 클라이언트 컴포넌트는 진짜 필요한 부분만 사용하자.

수요일

React useEffect cleanup 누락 — 타이머·이벤트 리스너가 중복으로 쌓이는 이유

🔍 검색 키워드: react useEffect cleanup, useEffect 정리 함수, setInterval 중복 실행, addEventListener 중복 등록, StrictMode 두 번 실행, useEffect 메모리 누수

요약: useEffect가 타이머·구독·이벤트 리스너처럼 계속 살아 있는 것을 만들어 놓고 정리(cleanup) 함수를 반환하지 않으면, 의존성이 바뀌어 effect가 다시 실행될 때마다 이전 것이 그대로 남아 중복으로 쌓인다. setup이 만든 것을 그대로 되돌리는 함수를 return하면 해결된다. React 공식 문서 기준으로 cleanup은 의존성이 바뀐 커밋마다 setup보다 먼저, 그리고 언마운트 후 한 번 더 실행된다.

useEffect의 cleanup은 언제 실행되나요?

React 공식 useEffect 문서는 실행 순서를 다음과 같이 못 박는다.

  • 컴포넌트가 화면에 추가될 때(mount) setup 코드가 실행된다.
  • 의존성이 바뀐 커밋마다, 먼저 이전 props·state로 cleanup이 실행되고 그다음 새 값으로 setup이 실행된다.
  • 컴포넌트가 화면에서 제거된 뒤(unmount) cleanup이 마지막으로 한 번 더 실행된다.

문서는 cleanup이 "setup 함수가 하던 일을 멈추거나 되돌려야 한다"고 설명한다. 즉 cleanup은 선택 사항이 아니라 setup의 거울상이다. setup에서 무언가를 만들었다면 cleanup에서 그걸 없애야 한다.

테스트 환경

아래 숫자는 이 환경에서 직접 실행한 결과다. 다만 React 런타임을 띄워 컴포넌트를 렌더한 것이 아니라, effect가 반복 실행될 때 타이머와 리스너가 어떻게 쌓이는지를 Node에서 같은 구조로 재현한 것이다. React의 동작 순서 자체는 공식 문서를 근거로 한다.

  • macOS 27.0.1 (Darwin 27.0.0, arm64)
  • node --version 출력: v20.20.2

cleanup이 없으면 실제로 무엇이 쌓이나요?

1. 타이머: 되돌리지 않으면 그대로 중첩된다

effect가 세 번 실행되는 상황을 그대로 만들어 보자.

let ticks = 0;
const timers = [];

function effectNoCleanup() {           // cleanup 없는 useEffect
  timers.push(setInterval(() => { ticks++; }, 20));
}
for (let i = 0; i < 3; i++) effectNoCleanup();   // 의존성이 바뀌어 3번 재실행

// cleanup 있는 버전
let ticks2 = 0, cur = null;
const effectWithCleanup = () => {
  if (cur) clearInterval(cur);         // 이전 effect의 cleanup
  cur = setInterval(() => { ticks2++; }, 20);
};
for (let i = 0; i < 3; i++) effectWithCleanup();

직접 실행 결과 (각각 약 100ms 동안 관찰)

cleanup 없음 - 약 100ms 동안 tick: 12 회 (타이머 3 개 동시 작동)
cleanup 있음 - 약 100ms 동안 tick: 4 회 (타이머 1개)

같은 시간에 콜백이 세 배로 돈다. 화면에서는 "숫자가 갑자기 빨리 올라간다", "API가 몇 배로 호출된다"로 나타난다. 의존성이 자주 바뀌는 컴포넌트라면 재실행 횟수만큼 배수로 늘어난다.

2. 이벤트 리스너: 여기는 사정이 조금 다르다

"cleanup이 없으면 리스너가 무조건 쌓인다"는 설명을 자주 보는데, 절반만 맞다. DOM의 addEventListener는 같은 타입·같은 콜백 참조·같은 capture 옵션이면 중복 등록을 무시한다. 직접 확인해 보자.

const t2 = new EventTarget();
let c2 = 0;
const h2 = () => { c2++; };
t2.addEventListener("ping", h2);
t2.addEventListener("ping", h2);        // 같은 참조를 두 번
t2.dispatchEvent(new Event("ping"));

const t3 = new EventTarget();
let c3 = 0;
for (let i = 0; i < 3; i++)
  t3.addEventListener("ping", () => { c3++; });   // 매번 새 화살표 함수
t3.dispatchEvent(new Event("ping"));

직접 실행 결과

동일 참조 2번 등록 -> 호출: 1 회
매번 새 참조 3번 등록 -> 호출: 3 회

핵심은 이것이다. 컴포넌트 안에서 매 렌더마다 새로 만들어지는 인라인 핸들러를 등록하면 참조가 매번 다르므로 중복 등록이 그대로 쌓인다. 반대로 컴포넌트 밖에 선언한 함수나 useCallback으로 참조를 고정한 핸들러는 같은 참조라 중복 등록이 무시된다. 그래서 "어떤 코드는 리스너가 쌓이고 어떤 코드는 멀쩡해 보이는" 현상이 생긴다. 물론 어느 쪽이든 언마운트 시 리스너를 떼지 않으면 그 자체로 누수이므로 cleanup은 필요하다.

effect는 왜 자꾸 다시 실행되나요?

cleanup 문제가 드러나려면 effect가 반복 실행돼야 하는데, 그 원인은 보통 의존성 배열이다. React 공식 문서는 "React는 각 의존성을 이전 값과 Object.is로 비교한다"고 명시한다. Object.is가 객체와 함수를 어떻게 비교하는지 직접 확인해 보면 이렇다.

$ node -e '...'
Object.is({a:1},{a:1}) = false
Object.is([1],[1])     = false
Object.is(f,f)         = true
Object.is(()=>{},()=>{}) = false

내용이 같아도 렌더마다 새로 만든 객체·배열·함수는 전부 다른 값으로 취급된다. 공식 문서도 "컴포넌트 안에서 정의된 객체나 함수가 의존성에 들어가면 effect가 필요 이상으로 자주 재실행될 위험이 있다"고 경고하며, 해결책으로 그 객체를 effect 안에서 만들라고 안내한다.

// 문제: options가 매 렌더 새로 만들어진다
const options = { serverUrl, roomId };
useEffect(() => {
  const connection = createConnection(options);
  connection.connect();
  return () => connection.disconnect();
}, [options]);          // 매 커밋마다 다른 값

// 해결: 객체를 effect 안으로 옮기고 원시값만 의존성에 둔다
useEffect(() => {
  const options = { serverUrl, roomId };
  const connection = createConnection(options);
  connection.connect();
  return () => connection.disconnect();
}, [roomId]);

어떻게 해결하나요? 상황별 cleanup 패턴

// 1. 타이머
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

// 2. 이벤트 리스너 - 등록과 해제에 같은 참조를 써야 한다
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

// 3. 외부 스토어 구독
useEffect(() => {
  const unsubscribe = store.subscribe(handleChange);
  return unsubscribe;
}, [store]);

// 4. 네트워크 요청 - 늦게 도착한 응답이 새 상태를 덮어쓰지 않게
useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then((r) => r.json())
    .then(setData)
    .catch((e) => { if (e.name !== "AbortError") setError(e); });
  return () => controller.abort();
}, [url]);

// 5. WebSocket
useEffect(() => {
  const ws = new WebSocket(url);
  return () => ws.close();
}, [url]);

2번에서 removeEventListener에 넘기는 함수가 등록할 때와 다른 참조면 해제가 되지 않는다. 등록·해제 모두 effect 안에서 선언한 같은 onResize를 쓰는 것이 중요하다.

개발 모드에서만 두 번 실행되는 건 왜 그런가요?

React 공식 StrictMode 문서는 "Strict Mode가 켜져 있으면 React는 모든 Effect에 대해 개발 환경에서 setup과 cleanup을 한 번 더 실행한다"고 설명한다. 그리고 "이 검사들은 개발 환경에서만 실행되며 프로덕션 빌드에는 영향을 주지 않는다"고 덧붙인다.

즉 콘솔에 로그가 두 번 찍히는 것은 버그가 아니라, cleanup이 setup을 제대로 되돌리는지 확인하는 의도된 스트레스 테스트다. 문서의 표현을 빌리면 setup 대신 setup → cleanup → setup을 돌려서 빠진 cleanup을 눈에 띄게 만드는 것이다. 두 번 실행이 문제를 일으킨다면 StrictMode를 끌 것이 아니라 cleanup을 제대로 구현해야 한다. 프로덕션에서도 사용자가 페이지를 왔다 갔다 하면 같은 상황이 벌어진다.

주의할 점과 한계

  • cleanup에서 상태를 다시 세팅하는 식으로 "정리"하려 들면 안 된다. cleanup은 setup이 만든 외부 자원을 되돌리는 자리다.
  • 구독 해제 함수를 그대로 return할 때, 그 함수가 cleanup 인자를 받는 형태라면 의도치 않은 인자가 전달될 수 있다. 그런 경우는 return () => unsubscribe();처럼 감싸는 편이 안전하다.
  • 이 글의 수치는 Node 20.20.2에서 effect 반복 실행 구조를 재현한 것이다. React 렌더러를 띄워 측정한 값이 아니며, 실행 순서와 StrictMode 동작은 React 공식 문서를 근거로 했다.
  • React 18 이전 버전은 StrictMode의 이중 setup+cleanup 동작이 없다. 버전에 따라 콘솔에 보이는 횟수가 다를 수 있다.

정리표

setup에서 만든 것cleanup에서 할 일빠뜨리면 생기는 증상
setInterval / setTimeoutclearInterval / clearTimeout타이머가 중첩되어 콜백이 배수로 실행
addEventListener같은 참조로 removeEventListener인라인 핸들러면 중복 등록, 언마운트 후에도 잔존
store.subscribe반환된 unsubscribe 호출사라진 컴포넌트가 계속 상태 갱신 시도
fetch / axios 요청AbortController로 abort늦게 온 응답이 최신 상태를 덮어씀
new WebSocketws.close()연결이 계속 쌓여 서버 커넥션 고갈
외부 라이브러리 인스턴스destroy() 등 해제 API 호출DOM 노드와 리스너가 메모리에 잔존

자주 묻는 질문(FAQ)

cleanup 함수는 반드시 반환해야 하나요?

setup이 아무것도 만들지 않았다면 필요 없다. 예를 들어 값을 계산해서 state에 넣기만 하는 effect에는 되돌릴 것이 없다. 반대로 타이머, 구독, 이벤트 리스너, 소켓처럼 effect가 끝난 뒤에도 계속 살아 있는 것을 만들었다면 반드시 반환해야 한다.

개발 모드에서 effect가 두 번 실행되는데 StrictMode를 꺼야 하나요?

끄지 않는 편이 좋다. React 공식 문서에 따르면 이 동작은 개발 환경에서만 실행되고 프로덕션 빌드에는 영향을 주지 않으며, 빠진 cleanup을 드러내기 위한 의도된 검사다. 두 번 실행되어 문제가 생긴다면 그 문제는 프로덕션에서도 재마운트 시 똑같이 발생한다.

이벤트 리스너는 중복 등록이 안 된다고 들었는데 왜 쌓이나요?

같은 함수 참조일 때만 중복이 무시되기 때문이다. 직접 확인한 결과 동일 참조를 두 번 등록하면 핸들러가 1회만 호출됐지만, 매번 새로 만든 화살표 함수를 세 번 등록하니 3회 호출됐다. 컴포넌트 안에서 인라인으로 정의한 핸들러는 렌더마다 새 참조라서 쌓인다.

cleanup을 넣었는데도 중복 실행이 줄지 않습니다. 무엇을 봐야 하나요?

removeEventListener에 넘기는 함수가 등록 때와 같은 참조인지 먼저 확인한다. 그다음 의존성 배열을 본다. 렌더마다 새로 만들어지는 객체나 함수가 의존성에 들어 있으면 effect가 매 커밋마다 재실행되므로, 정리와 재생성이 끝없이 반복되며 네트워크 요청 같은 부작용이 계속 발생한다.

관련 글: React useEffect 무한루프 — 의존성 배열 검증과 최적화 전략 · React useEffect 무한루프 문제 해결하기

공식 문서: React — useEffect · React — StrictMode

최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기

목요일

React useEffect 무한루프 문제 해결하기