· React· SSR· Error Handling· Next.js

SSR/CSR 경계에서 터지는 에러 막기

서버와 클라이언트 환경 차이로 발생하는 런타임 에러를 방지하는 패턴들

Next.js로 개발하다 보면 로컬에서는 잘 되는데 배포하면 터지는 에러를 자주 만난다. 대부분 SSR과 CSR 환경 차이에서 오는 문제다.

“window is not defined” - 이 에러를 안 본 프론트엔드 개발자가 있을까? 서버에서는 브라우저 API가 없으니 당연한 에러인데, 막상 만나면 당황스럽다.

흔한 실수들

window 객체 직접 참조

// 이러면 SSR에서 터짐
const width = window.innerWidth;

function MyComponent() {
  return <div style={{ width }}>...</div>;
}

서버에서 이 코드가 실행되는 순간 에러가 난다. 컴포넌트 바깥에서 window를 참조하면 모듈 로드 시점에 실행되기 때문.

localStorage 접근

// 이것도 마찬가지
const savedTheme = localStorage.getItem('theme');

localStorage도 브라우저 API다. 서버에서는 존재하지 않는다.

이벤트 리스너 등록

// 모듈 레벨에서 이벤트 리스너 등록하면 안 됨
window.addEventListener('resize', handleResize);

기본적인 가드 패턴

제일 간단한 방법은 환경 체크다.

const isBrowser = typeof window !== 'undefined';

// 사용 예
const width = isBrowser ? window.innerWidth : 0;

근데 이걸 매번 쓰기 귀찮고, 코드도 지저분해진다. 패턴화하는 게 좋다.

useEffect 안에서 처리하기

브라우저 API는 useEffect 안에서 쓰면 안전하다. useEffect는 클라이언트에서만 실행되니까.

function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    function handleResize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    handleResize(); // 초기값 설정
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

초기값을 0으로 잡은 이유가 있다. 서버에서 렌더링할 때는 0으로 그리고, 클라이언트에서 hydration 후에 실제 값으로 업데이트된다.

클라이언트 전용 컴포넌트

특정 컴포넌트 전체가 클라이언트에서만 동작해야 할 때가 있다. 지도, 에디터, 차트 같은 라이브러리들이 그렇다.

function ClientOnly({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) return null;

  return <>{children}</>;
}

// 사용
<ClientOnly>
  <MapComponent />
</ClientOnly>

서버에서는 null을 렌더링하고, 클라이언트에서 마운트된 후에야 자식을 렌더링한다.

Next.js를 쓴다면 dynamic import도 방법이다.

import dynamic from 'next/dynamic';

const MapComponent = dynamic(() => import('./MapComponent'), {
  ssr: false,
  loading: () => <div>지도 로딩 중...</div>,
});

hydration mismatch 주의

서버에서 렌더링한 HTML과 클라이언트에서 렌더링한 결과가 다르면 React가 경고를 뱉는다. 심하면 화면이 깨진다.

// 이런 코드는 mismatch 발생
function TimeDisplay() {
  return <span>{new Date().toLocaleTimeString()}</span>;
}

서버에서 렌더링하는 시간과 클라이언트에서 hydration하는 시간이 다르니까 당연히 결과가 다르다.

해결 방법은 클라이언트에서만 시간을 표시하는 것.

function TimeDisplay() {
  const [time, setTime] = useState<string | null>(null);

  useEffect(() => {
    setTime(new Date().toLocaleTimeString());
    const timer = setInterval(() => {
      setTime(new Date().toLocaleTimeString());
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  if (!time) return <span>--:--:--</span>;
  return <span>{time}</span>;
}

서버에서는 placeholder를 보여주고, 클라이언트에서 실제 시간으로 교체한다.

정리

SSR/CSR 경계 에러는 패턴만 알면 대부분 예방 가능하다.

  1. 브라우저 API는 useEffect 안에서
  2. 클라이언트 전용 컴포넌트는 ClientOnly 래퍼나 dynamic import
  3. 서버/클라이언트 결과가 달라질 수 있는 코드는 주의

처음엔 번거롭게 느껴지는데, 익숙해지면 자연스럽게 된다. 새 컴포넌트 만들 때 “이거 서버에서 돌아도 되나?” 한 번만 생각하면 된다.