본문 바로가기
💻 웹 개발/투데잇 - AI 자동 식단 일기

Next.js에서 ClientOnly 컴포넌트로 클라이언트-서버 렌더링 일관성 문제 해결하기

by llddang 2025. 5. 10.

 

Next.js의 하이브리드 렌더링 모델은 강력한 이점을 제공하지만, 동시에 클라이언트와 서버 간 렌더링 차이로 인한 도전 과제도 함께 가져옵니다. 우리 프로젝트에서는 9주간의 데이터를 보여주는 캘린더 캐러셀을 구현하면서 이러한 문제에 직면했습니다. 캐러셀은 과거 4주, 현재 주, 미래 4주의 데이터를 표시해야 했으며, 사용자가 화면을 처음 로드했을 때 자연스럽게 '현재 주'를 기본으로 보여주길 원했습니다.

 

하지만 캐러셀 라이브러리는 클라이언트 사이드에서만 완전히 동작하기 때문에, 서버에서 렌더링된 초기 HTML은 항상 캐러셀의 첫 번째 항목(4주 전 데이터)을 표시했습니다. 이후 JavaScript가 로드되고 실행되면 캐러셀이 의도한 '현재 주' 위치로 이동하게 되는데, 이는 사용자에게 갑작스러운 UI 변화를 보여주는 불편한 경험을 제공했습니다.

 

 

문제 분석: 캐러셀 UI 불일치 문제의 원인

이 문제는 엄밀히 말하면 전통적인 hydration 불일치(mismatch)와는 약간 다릅니다. 일반적인 hydration 불일치는 서버에서 렌더링된 HTML과 클라이언트에서 React가 구성한 가상 DOM 사이에 차이가 있을 때 발생합니다. 반면, 우리가 직면한 문제는 JavaScript가 실행되기 전후의 UI 상태 차이에 가깝습니다.

초기에는 캐러셀의 위치 설정을 위해 사용된 transform: translate3d() 스타일을 직접 수정하는 방법을 시도했습니다. 하지만 이 접근법은 정확한 위치 계산에 실패했고, 결과적으로 원하는 효과를 얻지 못했습니다.

문제의 영상: 렌더링 이후 현재 주로 이동함.
문제의 영상: 렌더링 이후 현재 주로 이동함.

 

해결 방안: ClientOnly 컴포넌트 구현 및 useIsClient 훅

문제를 해결하기 위해, 우리는 현재 렌더링 환경을 감지하고 그에 따라 다른 컴포넌트를 렌더링하는 접근 방식을 선택했습니다. 이를 위해 ClientOnly 컴포넌트와 useIsClient 훅을 구현했습니다.

핵심 아이디어는 간단합니다:

  • 서버 렌더링 중에는 클라이언트 전용 기능이 필요 없는 정적 버전의 컴포넌트를 보여줌
  • 클라이언트에서 JavaScript가 실행되면 전체 기능을 갖춘 인터랙티브 버전으로 교체함

이 접근 방식은 사용자에게 일관된 UI 경험을 제공하면서, 서버 사이드 렌더링(SSR)의 이점도 유지할 수 있게 해줍니다.

 

 

구현 세부사항: 코드 분석 및 동작 원리

useIsClient 훅

useIsClient 훅의 구현은 React의 useSyncExternalStore API를 활용하여 현재 렌더링 환경이 클라이언트인지 서버인지 판별합니다:

'use client';
import { useSyncExternalStore } from 'react';

const clientStore = {
  subscribe: () => () => {},
  getClientSnapshot: () => true,
  getServerSnapshot: () => false
};

const useIsClient = () => {
  const isClient = useSyncExternalStore(
    clientStore.subscribe,
    clientStore.getClientSnapshot,
    clientStore.getServerSnapshot
  );

  return isClient;
};

export default useIsClient;

 

이 구현의 핵심은 다음과 같습니다:

  • subscribe 함수는 비어있습니다. 클라이언트/서버 상태는 변경되지 않기 때문입니다.
  • getClientSnapshot은 항상 true를 반환하여 클라이언트 환경임을 나타냅니다.
  • getServerSnapshot은 항상 false를 반환하여 서버 환경임을 나타냅니다.
  • React의 useSyncExternalStore는 서버 렌더링 중에는 getServerSnapshot을, 클라이언트 렌더링 중에는 getClientSnapshot을 사용합니다.

 

ClientOnly 컴포넌트

ClientOnly 컴포넌트는 useIsClient 훅을 활용하여 클라이언트 측에서만 특정 컴포넌트를 렌더링합니다:

'use client';

import { ComponentProps, ComponentType, ReactNode } from 'react';
import useIsClient from '@/hooks/use-is-client';

type ClientOnlyProps = {
  children: ReactNode;
  fallback: ReactNode;
};

type PropsWithoutChildren<T> = Omit<T, 'children'>;

const ClientOnly = Object.assign(
  ({ children, fallback }: ClientOnlyProps) => {
    return useIsClient() ? children : fallback;
  },
  {
    displayName: 'ClientOnly',
    with: <TProps extends ComponentProps<ComponentType> = Record<string, never>>(
      clientOnlyProps: PropsWithoutChildren<ClientOnlyProps>,
      Component: ComponentType<TProps>
    ) =>
      Object.assign(
        (props: TProps) => (
          <ClientOnly {...clientOnlyProps}>
            <Component {...props} />
          </ClientOnly>
        ),
        {
          displayName: `${ClientOnly.displayName}.with(${Component.displayName || Component.name || 'Component'})`
        }
      )
  }
);

export default ClientOnly;

 

이 컴포넌트의 작동 방식은 다음과 같습니다:

  • childrenfallback 두 개의 props를 받습니다.
  • useIsClient()의 결과에 따라 클라이언트에서는 children을, 서버에서는 fallback을 렌더링합니다.
  • 추가로 with 메서드를 제공하여 HOC(Higher-Order Component) 패턴으로도 사용할 수 있게 합니다.

 

활용 사례: 캘린더 및 로티 애니메이션에서의 적용

캘린더 캐러셀 적용 사례

캘린더 컴포넌트에서는 다음과 같이 ClientOnly를 활용했습니다:

const HomeCalendar = async () => {
  return (
    <ClientOnly fallback={<HomeCalendarServerView />}>
      <HomeCalendarClientView />
    </ClientOnly>
  );
};
export default HomeCalendar;

이 구현을 통해:

  • 서버에서는 정적인 HomeCalendarServerView 컴포넌트가 렌더링됩니다.
  • 클라이언트에서는 캐러셀 기능이 포함된 HomeCalendarClientView 컴포넌트로 교체됩니다.
  • 사용자는 일관된 UI를 경험하게 되며, 캐러셀의 위치가 갑자기 변경되는 문제가 해결됩니다.
문제의 영상: 렌더링 이후 현재 주로 이동함.
해결된 영상: ui의 변경 없이 현재 주를 보여줌.

 

 

로티(Lottie) 애니메이션 적용 사례

로티 라이브러리 역시 클라이언트 사이드에서만 동작하는 특성을 가지고 있어 ClientOnly 컴포넌트를 활용했습니다:

  • 서버에서는 정적 이미지를 fallback으로 표시합니다.
  • 클라이언트에서는 인터랙티브한 로티 애니메이션으로 교체됩니다.

이 접근 방식은 사용자에게 초기 로드 시부터 일관된 시각적 경험을 제공하면서, JavaScript가 로드된 후에는 풍부한 인터랙션을 추가할 수 있게 합니다.

 

확장성: with 패턴을 통한 HOC 활용 가능성

ClientOnly 컴포넌트는 직접적인 props 패턴 사용 외에도, Higher-Order Component(HOC) 패턴을 지원하는 with 메서드를 제공합니다. 비록 현재 프로젝트에서는 직접 사용하지 않았지만, 이 패턴은 재사용성과 코드 간결성을 높이는 강력한 옵션을 제공합니다.

// HOC 패턴 사용 예시
const ClientOnlyLottie = ClientOnly.with(
  { fallback: <StaticLottieImage /> },
  LottieAnimation
);

// 사용 방법
const AnimationSection = () => {
  return <ClientOnlyLottie animationData={animationJson} />;
};

이러한 패턴의 장점은 다음과 같습니다:

  1. 코드 재사용성 향상: 동일한 fallback을 여러 위치에서 반복 사용할 때 특히 유용합니다.
  2. 관심사 분리: 컴포넌트의 렌더링 조건과 실제 구현을 분리할 수 있습니다.
  3. 가독성 개선: 복잡한 조건부 렌더링 로직을 간결하게 표현할 수 있습니다.

with 메서드는 TypeScript 제네릭을 활용하여 타입 안전성도 보장하며, 원본 컴포넌트의 props를 그대로 전달받아 사용할 수 있게 합니다.

 

 

후기

이 컴포넌트를 개발하면서 서버와 클라이언트에서 동일한 컴포넌트가 다르게 동작할 수 있다는 점을 실제로 경험했습니다. 처음에는 단순한 CSS 문제인 줄 알고 transform 값을 수정하려 했지만, 알고 보니 서버에서 렌더링된 HTML과 클라이언트에서 JavaScript가 실행된 후의 상태가 달라서 생긴 문제였습니다.

 

useSyncExternalStore를 통해 이 차이를 감지하고 대응하는 방법을 알게 되었을 때 정말 신기했습니다. 특히 조건부 렌더링을 명령형이 아닌 선언적인 방식으로 해결함으로써 React의 철학에 맞게 컴포넌트를 설계할 수 있었다는 점이 가장 만족스러웠습니다. children과 fallback을 통해 "무엇을" 보여줄지 선언하고, React가 "어떻게" 그것을 처리할지 맡기는 방식은 정말 우아하게 느껴졌습니다.

 

결국 30줄 남짓한 간단한 코드로 문제를 해결했고, 나중에 로티 애니메이션에서도 같은 패턴을 재사용할 수 있었던 것은 작지만 의미 있는 성취감을 주었습니다. 처음에는 어려워 보였던 문제가 결국 React의 선언적 패러다임에 맞는 간결한 해결책으로 해결되는 경험은 개발의 재미를 다시 한번 느끼게 해주었습니다.