SSR/CSR 경계에서 터지는 에러 막기
서버와 클라이언트 환경 차이로 발생하는 런타임 에러를 방지하는 패턴들
서버와 클라이언트 환경 차이로 발생하는 런타임 에러를 방지하는 패턴들
Next.js로 개발하다 보면 로컬에서는 잘 되는데 배포하면 터지는 에러를 자주 만난다. 대부분 SSR과 CSR 환경 차이에서 오는 문제다.
“window is not defined” - 이 에러를 안 본 프론트엔드 개발자가 있을까? 서버에서는 브라우저 API가 없으니 당연한 에러인데, 막상 만나면 당황스럽다.
// 이러면 SSR에서 터짐
const width = window.innerWidth;
function MyComponent() {
return <div style={{ width }}>...</div>;
}
서버에서 이 코드가 실행되는 순간 에러가 난다. 컴포넌트 바깥에서 window를 참조하면 모듈 로드 시점에 실행되기 때문.
// 이것도 마찬가지
const savedTheme = localStorage.getItem('theme');
localStorage도 브라우저 API다. 서버에서는 존재하지 않는다.
// 모듈 레벨에서 이벤트 리스너 등록하면 안 됨
window.addEventListener('resize', handleResize);
제일 간단한 방법은 환경 체크다.
const isBrowser = typeof window !== 'undefined';
// 사용 예
const width = isBrowser ? window.innerWidth : 0;
근데 이걸 매번 쓰기 귀찮고, 코드도 지저분해진다. 패턴화하는 게 좋다.
브라우저 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>,
});
서버에서 렌더링한 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 경계 에러는 패턴만 알면 대부분 예방 가능하다.
처음엔 번거롭게 느껴지는데, 익숙해지면 자연스럽게 된다. 새 컴포넌트 만들 때 “이거 서버에서 돌아도 되나?” 한 번만 생각하면 된다.