Isomorphic: 서버와 클라이언트에서 모두 사용 가능한 코드 만들기

Y·2025년 8월 18일
1
post-thumbnail

이 글에서는 Isomorphic JavaScript가 무엇인지, 왜 필요한지, 그리고 실제로 서버와 클라이언트 환경 차이로 인한 문제를 어떻게 해결하는지에 대해 다룹니다

목차

  1. Isomorphic이란
  2. 왜 필요한가
  3. 환경 차이와 문제점
  4. Isomorphic 패턴
  5. useIsomorphicLayoutEffect
  6. 다른 활용 사례
  7. 마무리

Isomorphic이란

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 문제가 발생했습니다.

Isomorphic의 장점

// 한 번만 작성
function formatDate(date) {
  return new Intl.DateTimeFormat('ko-KR').format(date);
}

// 서버: SSR 시 사용
// 클라이언트: 동적 업데이트 시 사용
// 같은 함수, 같은 결과

코드를 한 번만 작성하면 되고, 서버와 클라이언트가 항상 같은 결과를 보장합니다. 유지보수도 한 곳만 하면 됩니다.

환경 차이와 문제점

사용 가능한 API 차이

API서버 (Node.js)브라우저
windowXO
documentXO
localStorageXO
fs (파일시스템)OX
processOX
consoleOO
fetchO (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가 없기 때문입니다.

Isomorphic하지 않은 코드

// 브라우저 전용
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 없음
}

Isomorphic한 코드

// 순수 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();
  }
}

Isomorphic 패턴

패턴 1: 환경 체크

가장 기본적인 패턴입니다.

export const isClient = () => typeof window !== 'undefined';
export const isServer = () => typeof window === 'undefined';

// 사용
if (isClient()) {
  // 브라우저 전용 코드
  const width = window.innerWidth;
}

패턴 2: 동적 Import

Next.js의 dynamic import를 활용하면 특정 컴포넌트를 클라이언트에서만 로드할 수 있습니다.

import dynamic from 'next/dynamic';

const MapComponent = dynamic(
  () => import('./MapComponent'),
  { 
    ssr: false, // 서버 렌더링 비활성화
    loading: () => <p>지도 로딩중...</p>
  }
);

패턴 3: 조건부 Polyfill

환경에 따라 다른 구현을 제공합니다.

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;
    }
  }
};

useIsomorphicLayoutEffect

문제 상황

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 Slash 라이브러리의 구현

Toss의 구현에서 눈여겨볼 점은 Deno 런타임까지 고려했다는 것입니다. 일반적으로 typeof window === 'undefined'만 체크하는 경우가 많은데, Toss는 'Deno' in globalThis를 추가로 체크합니다.

출처 : https://github.com/toss/slash

다른 활용 사례

Isomorphic Storage

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();

Isomorphic Fetch

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 환경에서 안전하게 코드를 실행할 수 있게 해줍니다.

참고 자료

profile
타입스크립트를 기반으로 프론트엔드와 백엔드 개발을 하고 있습니다. 인프라와 DevOps 영역도 틈틈이 공부하고 있고, 기술적 구현과 함께 비즈니스 관점에서의 고민도 놓치지 않으려 합니다

0개의 댓글