troubleshooting2025-06-13·11 min·26/348

Next.js 레이아웃 공유 문제 해결 방법

Next.js App Router에서 여러 레이아웃 간 상태 공유와 레이아웃 전환 문제를 해결하는 방법을 알아봅니다.

Next.js 레이아웃 공유 문제 해결 방법

Introduction

Next.js App Router의 레이아웃은 파일 시스템 기반으로 동작하며, 각 레이아웃은 독립적인 상태를 가집니다. 문제는 레이아웃 간 상태를 공유하거나 레이아웃을 전환할 때 발생합니다. 이 글에서는 레이아웃 공유 문제를 해결하는 다양한 패턴을 다룹니다.

Environment

# 프로젝트 구조
my-app/
├── app/
│   ├── layout.tsx           # 루트 레이아웃
│   ├── dashboard/
│   │   ├── layout.tsx       # 대시보드 레이아웃
│   │   └── page.tsx
│   ├── settings/
│   │   ├── layout.tsx       # 설정 레이아웃
│   │   └── page.tsx
│   └── page.tsx
├── contexts/
│   └── AppContext.tsx
└── package.json

# 기술 스택
Next.js: 14.2.5
React: 18.3.1

Problem

레이아웃 전환 시 상태가 유실됩니다:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // 이 상태는 대시보드 레이아웃에서만 유지됨
  const [sidebarOpen, setSidebarOpen] = useState(true);
  
  return (
    
{children}
); } // ❌ 설정 페이지로 이동하면 sidebarOpen 상태 유실

Analysis

App Router의 레이아웃은:

  1. 파일 경로별로 독립적으로 렌더링
  2. 레이아웃 간 상태 자동 공유 안 됨
  3. 서버 컴포넌트에서는 useState 사용 불가
  4. 클라이언트 컴포넌트에서만 상태 관리 가능

Solution

1. Context를 이용한 상태 공유

// contexts/AppContext.tsx
'use client';

import { createContext, useContext, useState, ReactNode } from 'react';

interface AppState {
  sidebarOpen: boolean;
  theme: 'light' | 'dark';
  notifications: number;
}

interface AppContextType extends AppState {
  setSidebarOpen: (open: boolean) => void;
  toggleSidebar: () => void;
  setTheme: (theme: 'light' | 'dark') => void;
  incrementNotifications: () => void;
}

const AppContext = createContext(undefined);

export function AppProvider({ children }: { children: ReactNode }) {
  const [sidebarOpen, setSidebarOpen] = useState(true);
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [notifications, setNotifications] = useState(0);
  
  const toggleSidebar = () => setSidebarOpen(!sidebarOpen);
  const incrementNotifications = () => setNotifications(n => n + 1);
  
  return (
    
      {children}
    
  );
}

export function useApp() {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error('useApp must be used within AppProvider');
  }
  return context;
}
// app/layout.tsx
import { AppProvider } from '@/contexts/AppContext';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
      
        
          {children}
        
      
    
  );
}

2. 커스텀 훅으로 레이아웃 상태 관리

// hooks/useLayoutState.ts
'use client';

import { useState, useEffect, useCallback } from 'react';

interface LayoutState {
  sidebarOpen: boolean;
  breadcrumbs: Array<{ label: string; href: string }>;
  title: string;
}

const STORAGE_KEY = 'layout-state';

export function useLayoutState() {
  const [state, setState] = useState(() => {
    // 로컬 스토리지에서 초기 상태 복원
    if (typeof window !== 'undefined') {
      const saved = localStorage.getItem(STORAGE_KEY);
      if (saved) {
        return JSON.parse(saved);
      }
    }
    
    return {
      sidebarOpen: true,
      breadcrumbs: [],
      title: '',
    };
  });
  
  // 상태 변경 시 로컬 스토리지에 저장
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state]);
  
  const toggleSidebar = useCallback(() => {
    setState(prev => ({ ...prev, sidebarOpen: !prev.sidebarOpen }));
  }, []);
  
  const setBreadcrumbs = useCallback((breadcrumbs: LayoutState['breadcrumbs']) => {
    setState(prev => ({ ...prev, breadcrumbs }));
  }, []);
  
  const setTitle = useCallback((title: string) => {
    setState(prev => ({ ...prev, title }));
  }, []);
  
  return {
    ...state,
    toggleSidebar,
    setBreadcrumbs,
    setTitle,
  };
}

3. URL 기반 상태 동기화

// hooks/useUrlState.ts
'use client';

import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useCallback } from 'react';

export function useUrlState(key: string, defaultValue: T) {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();
  
  const value = searchParams.get(key) 
    ? JSON.parse(searchParams.get(key)!) 
    : defaultValue;
  
  const setValue = useCallback((newValue: T) => {
    const params = new URLSearchParams(searchParams);
    params.set(key, JSON.stringify(newValue));
    router.push(`${pathname}?${params.toString()}`);
  }, [key, defaultValue, searchParams, router, pathname]);
  
  return [value, setValue] as const;
}
// components/Sidebar.tsx
'use client';

import { useUrlState } from '@/hooks/useUrlState';

export function Sidebar() {
  const [isOpen, setIsOpen] = useUrlState('sidebar', true);
  
  return (
    
  );
}

4. 레이아웃 컴포넌트 분리

// components/Layout.tsx
'use client';

import { useApp } from '@/contexts/AppContext';
import { Sidebar } from './Sidebar';
import { Header } from './Header';

interface LayoutProps {
  children: React.ReactNode;
  showSidebar?: boolean;
}

export function Layout({ children, showSidebar = true }: LayoutProps) {
  const { sidebarOpen, theme } = useApp();
  
  return (
    
{showSidebar && }
{children}
); }
// app/dashboard/layout.tsx
import { Layout } from '@/components/Layout';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
      {children}
    
  );
}

// app/settings/layout.tsx
import { Layout } from '@/components/Layout';

export default function SettingsLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
      {children}
    
  );
}

Lessons Learned

  1. Context 위치: 루트 레이아웃에 Context Provider 배치
  2. 지속성 전략: 로컬 스토리지 또는 URL 파라미터로 상태 유지
  3. 컴포넌트 분리: 레이아웃 공통 로직을 별도 컴포넌트로 분리
  4. 서버 컴포넌트 고려: 서버 컴포넌트에서는 상태 관리 불가능

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.