Next.js 하이드레이션 지연 문제

contability·2025년 8월 26일

Next.js에서 서버 컴포넌트에서 클라이언트 컴포넌트로의 하이드레이션 과정에서 발생할 수 있는 지연 문제와 그 해결 방법을 정리했다.

1. 번들 크기 문제

지연 상황

  • JavaScript 번들이 큰 경우 다운로드와 파싱 시간이 늘어난다
  • 불필요한 라이브러리가 포함되어 번들 크기가 증가한다
  • 코드 스플리팅이 적용되지 않아 모든 코드가 하나의 번들로 묶인다

해결 방법

동적 임포트로 코드 스플리팅

// 컴포넌트 지연 로딩
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  loading: () => <div>로딩 중...</div>,
  ssr: false // 필요한 경우 SSR 비활성화
});

// 라이브러리 지연 로딩
const Chart = dynamic(() => import('react-chartjs-2'), {
  ssr: false
});

번들 분석과 최적화

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  webpack: (config, { isServer }) => {
    // 불필요한 모듈 제거
    if (!isServer) {
      config.resolve.fallback = {
        ...config.resolve.fallback,
        fs: false,
        net: false,
        tls: false,
      };
    }
    return config;
  },
});

Tree Shaking 최적화

// ❌ 전체 라이브러리 임포트
import _ from 'lodash';

// ✅ 필요한 함수만 임포트
import { debounce } from 'lodash';
// 또는
import debounce from 'lodash/debounce';

2. 부적절한 서버/클라이언트 컴포넌트 분리

지연 상황

  • 서버 컴포넌트로 처리할 수 있는 부분을 클라이언트 컴포넌트로 만든 경우
  • 과도한 클라이언트 컴포넌트 사용으로 하이드레이션 부하가 증가한다
  • 깊은 컴포넌트 트리로 인한 렌더링 지연이 발생한다

해결 방법

적절한 컴포넌트 분리

// app/page.tsx (서버 컴포넌트)
import { Suspense } from 'react';
import StaticContent from './StaticContent';
import InteractiveSection from './InteractiveSection';

export default function Page() {
  return (
    <main>
      <StaticContent /> {/* 서버 컴포넌트 */}
      <Suspense fallback={<div>인터랙션 로딩 중...</div>}>
        <InteractiveSection /> {/* 클라이언트 컴포넌트 */}
      </Suspense>
    </main>
  );
}

// InteractiveSection.tsx
'use client';

import { useState, useEffect } from 'react';

interface UserData {
  id: number;
  name: string;
  email: string;
}

async function fetchUsers(): Promise<UserData[]> {
  const response = await fetch('/api/users');
  if (!response.ok) {
    throw new Error('Failed to fetch users');
  }
  return response.json();
}

export default function InteractiveSection() {
  const [data, setData] = useState<UserData[] | null>(null);
  const [loading, setLoading] = useState(false);
  
  useEffect(() => {
    setLoading(true);
    fetchUsers()
      .then(setData)
      .finally(() => setLoading(false));
  }, []);
  
  if (loading) return <div>데이터 로딩 중...</div>;
  
  return (
    <section>
      <h2>사용자 목록</h2>
      {data?.map(user => (
        <div key={user.id}>
          <p>{user.name} ({user.email})</p>
        </div>
      ))}
    </section>
  );
}

3. 메인 스레드 블로킹

지연 상황

  • 하이드레이션 중 무거운 계산이나 동기 작업이 실행되는 경우
  • useEffect 내에서 무거운 작업이 수행되는 경우
  • Third-party 스크립트가 메인 스레드를 차단하는 경우

해결 방법

작업 스케줄링

// utils/scheduler.ts
export function scheduleWork<T>(
  work: () => T,
  priority: 'high' | 'normal' | 'low' = 'normal'
): Promise<T> {
  return new Promise((resolve) => {
    const timeSlice = priority === 'high' ? 0 : priority === 'normal' ? 5 : 16;
    
    setTimeout(() => {
      resolve(work());
    }, timeSlice);
  });
}

// 사용 예시
useEffect(() => {
  scheduleWork(() => {
    // 무거운 계산 작업
    processLargeDataset();
  }, 'low');
}, []);

Web Workers 활용

// workers/dataProcessor.ts
self.onmessage = function(e) {
  const { data, type } = e.data;
  
  switch (type) {
    case 'PROCESS_DATA':
      const result = heavyDataProcessing(data);
      self.postMessage({ type: 'PROCESSED', result });
      break;
  }
};

// hooks/useWorker.ts
import { useEffect, useRef, useState } from 'react';

export function useWorker(workerUrl: string) {
  const workerRef = useRef<Worker>();
  const [isReady, setIsReady] = useState(false);
  
  useEffect(() => {
    workerRef.current = new Worker(workerUrl);
    setIsReady(true);
    
    return () => workerRef.current?.terminate();
  }, [workerUrl]);
  
  const postMessage = (data: any) => {
    if (workerRef.current) {
      workerRef.current.postMessage(data);
    }
  };
  
  return { worker: workerRef.current, postMessage, isReady };
}

4. 불필요한 리소스 로딩

지연 상황

  • 모든 컴포넌트가 초기에 로드되어 하이드레이션 시간이 증가한다
  • 뷰포트에 보이지 않는 컴포넌트까지 즉시 렌더링된다
  • 중요하지 않은 리소스가 우선순위 높게 로드된다

해결 방법

Intersection Observer를 이용한 지연 로딩

// hooks/useIntersectionObserver.ts
import { useEffect, useRef, useState } from 'react';

export function useIntersectionObserver(options?: IntersectionObserverInit) {
  const [isIntersecting, setIsIntersecting] = useState(false);
  const targetRef = useRef<HTMLDivElement>(null);
  
  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
    }, options);
    
    if (targetRef.current) {
      observer.observe(targetRef.current);
    }
    
    return () => observer.disconnect();
  }, [options]);
  
  return { targetRef, isIntersecting };
}

// 컴포넌트에서 사용
function LazySection() {
  const { targetRef, isIntersecting } = useIntersectionObserver();
  
  return (
    <div ref={targetRef}>
      {isIntersecting ? <HeavyComponent /> : <Placeholder />}
    </div>
  );
}

리소스 우선순위 설정

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ko">
      <head>
        {/* 중요 폰트 미리 로딩 */}
        <link
          rel="preload"
          href="/fonts/main.woff2"
          as="font"
          type="font/woff2"
          crossOrigin="anonymous"
        />
        {/* 중요 CSS 미리 로딩 */}
        <link rel="preload" href="/styles/critical.css" as="style" />
        {/* 다음 페이지 미리 로딩 */}
        <link rel="prefetch" href="/about" />
      </head>
      <body>{children}</body>
    </html>
  );
}

5. 네트워크와 서버 성능

지연 상황

  • 느린 네트워크 환경에서 JavaScript 파일 다운로드가 지연된다
  • 서버 응답이나 API 호출이 느려서 초기 렌더링이 지연된다
  • CDN 설정이 최적화되지 않아 정적 자원 로딩이 느리다

해결 방법

이미지 최적화

// components/OptimizedImage.tsx
import Image from 'next/image';

interface Props {
  src: string;
  alt: string;
  priority?: boolean;
}

export default function OptimizedImage({ src, alt, priority = false }: Props) {
  return (
    <Image
      src={src}
      alt={alt}
      width={800}
      height={600}
      priority={priority}
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  );
}

0개의 댓글