이 글에서는 Isomorphic JavaScript가 무엇인지, 왜 필요한지, 그리고 실제로 서버와 클라이언트 환경 차이로 인한 문제를 어떻게 해결하는지에 대해 다룹니다
Isomorphic은 그리스어 iso(같은) + morphe(형태)의 합성어입니다. 수학에서는 "동형사상"이라고 번역하는데, 겉모습은 달라도 본질은 같다는 의미입니다.
웹 개발에서는 같은 코드가 서버와 브라우저 양쪽에서 실행될 수 있다는 뜻으로 사용됩니다. Universal JavaScript라고도 부릅니다.
// Isomorphic 코드
function calculatePrice(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}
// Node.js 서버에서도 실행 가능
// 브라우저에서도 실행 가능
PHP나 JSP 시절에는 서버와 클라이언트 코드가 완전히 분리되어 있었습니다. 서버는 PHP로 HTML을 생성하고, 클라이언트는 jQuery로 인터랙션을 처리했습니다. 같은 로직을 두 번 구현해야 했죠.
// 과거
서버: PHP로 날짜 포맷팅
클라이언트: JavaScript로 또 날짜 포맷팅
→ 두 번 구현, 버그 가능성 2배
SPA 시대가 되면서 또 다른 문제가 생겼습니다. 클라이언트가 모든 렌더링을 담당하니 초기 로딩이 느리고 SEO 문제가 발생했습니다.
// 한 번만 작성
function formatDate(date) {
return new Intl.DateTimeFormat('ko-KR').format(date);
}
// 서버: SSR 시 사용
// 클라이언트: 동적 업데이트 시 사용
// 같은 함수, 같은 결과
코드를 한 번만 작성하면 되고, 서버와 클라이언트가 항상 같은 결과를 보장합니다. 유지보수도 한 곳만 하면 됩니다.
API | 서버 (Node.js) | 브라우저 |
---|---|---|
window | X | O |
document | X | O |
localStorage | X | O |
fs (파일시스템) | O | X |
process | O | X |
console | O | O |
fetch | O (Node 18+) | O |
Next.js 프로젝트를 하다 보면 이런 에러를 자주 만납니다.
// ReferenceError: window is not defined
const width = window.innerWidth;
// ReferenceError: document is not defined
const element = document.getElementById('app');
// ReferenceError: localStorage is not defined
const token = localStorage.getItem('token');
로컬 개발 환경에서는 잘 동작하다가 빌드하면 갑자기 에러가 발생합니다. 서버에는 window, document, localStorage가 없기 때문입니다.
// 브라우저 전용
function saveToLocalStorage(key, value) {
localStorage.setItem(key, value); // 서버에서 에러
}
// Node.js 전용
const fs = require('fs');
function readFile(path) {
return fs.readFileSync(path); // 브라우저에서 에러
}
// DOM 직접 조작
function updateTitle(text) {
document.title = text; // 서버에 document 없음
}
// 순수 JavaScript 로직
export function validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
// 조건부 처리
export async function loadData() {
if (typeof window === 'undefined') {
// 서버: DB 직접 접근
const db = await import('./server/database');
return db.query('SELECT * FROM users');
} else {
// 클라이언트: API 호출
const response = await fetch('/api/users');
return response.json();
}
}
가장 기본적인 패턴입니다.
export const isClient = () => typeof window !== 'undefined';
export const isServer = () => typeof window === 'undefined';
// 사용
if (isClient()) {
// 브라우저 전용 코드
const width = window.innerWidth;
}
Next.js의 dynamic import를 활용하면 특정 컴포넌트를 클라이언트에서만 로드할 수 있습니다.
import dynamic from 'next/dynamic';
const MapComponent = dynamic(
() => import('./MapComponent'),
{
ssr: false, // 서버 렌더링 비활성화
loading: () => <p>지도 로딩중...</p>
}
);
환경에 따라 다른 구현을 제공합니다.
export const storage = {
get: (key) => {
if (typeof window !== 'undefined') {
return localStorage.getItem(key);
}
// 서버에서는 메모리 사용
return memoryStorage[key];
},
set: (key, value) => {
if (typeof window !== 'undefined') {
localStorage.setItem(key, value);
} else {
memoryStorage[key] = value;
}
}
};
Next.js에서 useLayoutEffect를 사용하면 경고가 발생합니다.
function Component() {
useLayoutEffect(() => {
const width = element.offsetWidth;
// DOM 측정 로직
}, []);
return <div>내용</div>;
}
// Warning: useLayoutEffect does nothing on the server,
// because its effect cannot be encoded into the
// server renderer's output format.
useLayoutEffect는 DOM 업데이트 직후, 브라우저가 화면을 그리기 전에 동기적으로 실행됩니다. 서버에는 DOM도 없고 브라우저의 paint 과정도 없기 때문에 React가 경고를 띄웁니다.
// 실행 순서
1. 컴포넌트 렌더링
2. DOM 업데이트
3. useLayoutEffect 실행 (동기)
4. 브라우저 Paint
5. useEffect 실행 (비동기)
서버에는 3, 4번 과정이 없음
// hooks/useIsomorphicLayoutEffect.js
import { useEffect, useLayoutEffect } from 'react';
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
서버에서는 useEffect를 사용해 경고를 피하고, 클라이언트에서는 useLayoutEffect를 사용해 원래 의도대로 동작하게 합니다.
import { useIsomorphicLayoutEffect } from './hooks';
function TooltipComponent() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useIsomorphicLayoutEffect(() => {
// 서버: 실행 안 됨
// 클라이언트: DOM 측정
const rect = element.getBoundingClientRect();
setPosition({ x: rect.x, y: rect.y });
}, []);
return (
<div style={{ position: 'absolute', ...position }}>
툴팁
</div>
);
}
Toss의 구현에서 눈여겨볼 점은 Deno 런타임까지 고려했다는 것입니다. 일반적으로 typeof window === 'undefined'만 체크하는 경우가 많은데, Toss는 'Deno' in globalThis를 추가로 체크합니다.
출처 : https://github.com/toss/slash
localStorage를 서버와 클라이언트 모두에서 사용할 수 있게 만든 패턴입니다.
class IsomorphicStorage {
constructor() {
this.store = new Map();
}
getItem(key) {
if (typeof window !== 'undefined') {
return localStorage.getItem(key);
}
return this.store.get(key);
}
setItem(key, value) {
if (typeof window !== 'undefined') {
localStorage.setItem(key, value);
} else {
this.store.set(key, value);
}
}
}
export const storage = new IsomorphicStorage();
Node.js 구버전에서는 fetch가 없었기 때문에 이런 패턴을 사용했습니다.
export const fetch = (() => {
if (typeof window !== 'undefined') {
return window.fetch;
} else {
return require('node-fetch');
}
})();
쿠키를 서버와 클라이언트 모두에서 다룰 수 있게 합니다.
export const cookies = {
get(name) {
if (typeof window !== 'undefined') {
// 브라우저: document.cookie 파싱
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop().split(';').shift();
}
} else {
// 서버: req.headers.cookie 파싱
// Next.js의 경우 cookies() 함수 사용
}
}
};
Isomorphic JavaScript의 핵심은 환경 차이를 인식하고 적절히 처리하는 것입니다.
// 나쁜 예
const width = window.innerWidth; // 서버에서 에러
// 좋은 예
const width = typeof window !== 'undefined' ? window.innerWidth : 0;
// 더 좋은 예
const useWindowWidth = () => {
const [width, setWidth] = useState(0);
useEffect(() => {
if (typeof window !== 'undefined') {
setWidth(window.innerWidth);
}
}, []);
return width;
};
SSR과 CSR의 장점을 모두 활용하려면 Isomorphic 패턴은 필수입니다. 한 번 작성한 코드를 서버와 클라이언트 모두에서 사용할 수 있다는 것은 큰 장점입니다. 다만 환경 차이를 항상 염두에 두고 코드를 작성해야 합니다.
useIsomorphicLayoutEffect는 이런 패턴의 좋은 예시입니다. 작은 차이지만 SSR 환경에서 안전하게 코드를 실행할 수 있게 해줍니다.