main-logo

Next.js 레이아웃과 템플릿

동작 원리와 최적화

profile
doworld
2024년 11월 24일 · 0 분 소요

들어가며

Next.js를 사용해오면서 레이아웃 컴포넌트는 프로젝트 초반에 구성을 하며 생성하고 사용을 하지만,

템플릿은 그에 비해 사용을 잘 안하게 되는 것 같아 이 둘에 대해 살펴보았습니다.

글로 이해 하는 것 보다 코드를 천천히 읽어보면 더욱 이해가 수월할 것 같습니다.

 

레이아웃의 내부 동작

레이아웃 컴포넌트는 Next.js의 서버 컴포넌트 아키텍처에서 특별한 위치를 차지합니다.

기본적으로 서버 컴포넌트로 동작하며, 한 번 렌더링된 후에는 세그먼트 트리에서 지속적으로 재사용됩니다.

// app/layout.tsx
import { headers } from 'next/headers'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  // 🚫 Anti-pattern: 매 요청마다 실행되지만 UI에 반영되지 않음
  const headersList = headers()
  const userAgent = headersList.get('user-agent')
  
  return (
    <html>
      <body>
        {/* userAgent 값이 변경되어도 리렌더링되지 않음 */}
        <div data-user-agent={userAgent}>
          {children}
        </div>
      </body>
    </html>
  )
}

레이아웃의 이러한 특성은 성능상의 이점을 제공하지만, 동시에 몇 가지 주의해야 할 패턴을 만듭니다:

 

서버 상태 캐싱

// app/dashboard/layout.tsx
import { getServerSession } from 'next-auth'

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  // ⛔️ 세션 정보가 캐시되어 실시간 반영이 안될 수 있음
  const session = await getServerSession()
  
  return (
    <div>
      {/* 권장되지 않는 패턴 */}
      <div>Welcome, {session?.user?.name}</div>
      {children}
    </div>
  )
}

// ✅ 추천하는 패턴: 클라이언트 컴포넌트로 분리
'use client'
function UserGreeting() {
  const { data: session } = useSession()
  return <div>Welcome, {session?.user?.name}</div>
}

 

템플릿의 렌더링 사이클

템플릿 컴포넌트는 React의 리컨실레이션(reconciliation) 과정에서 완전히 새로운 인스턴스를 생성합니다.

이는 메모리 관점에서 중요한 의미를 가집니다.

// app/template.tsx
'use client'

import { useState, useEffect, useRef } from 'react'

export default function Template({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false)
  const renderCount = useRef(0)

  useEffect(() => {
    renderCount.current += 1
    setMounted(true)
    
    // 메모리 누수 방지를 위한 클린업
    return () => {
      // 여기서 이벤트 리스너나 구독 해제
    }
  }, [])

  // 성능 모니터링
  useEffect(() => {
    const startTime = performance.now()
    
    return () => {
      const endTime = performance.now()
      console.log(`Template render cycle: ${endTime - startTime}ms`)
    }
  }, [])

  return (
    <div data-render-count={renderCount.current}>
      {children}
    </div>
  )
}

 

고급 최적화 기법

레이아웃에서의 스트리밍과 서스펜스

레이아웃에서 스트리밍을 활용하면 초기 페이지 로딩 성능을 크게 개선할 수 있습니다:

// app/layout.tsx
import { Suspense } from 'react'
import Loading from './loading'

export default function Layout({
  children,
  analytics,
  team
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="flex">
      <div className="flex-1">{children}</div>
      <div className="w-80 flex-none space-y-4">
        {/* 비동기 컴포넌트의 계단식 스트리밍 */}
        <Suspense fallback={<Loading />}>
          {analytics}
        </Suspense>
        <Suspense fallback={<Loading />}>
          {team}
        </Suspense>
      </div>
    </div>
  )
}

 

템플릿의 메모리 관리

템플릿은 매번 새로운 인스턴스가 생성되므로, 메모리 관리가 중요합니다:

// app/dashboard/template.tsx
'use client'

import { useEffect, useRef } from 'react'
import { usePathname } from 'next/navigation'

export default function DashboardTemplate({ children }: { children: React.ReactNode }) {
  const cleanup = useRef<Array<() => void>>([])
  const pathname = usePathname()

  useEffect(() => {
    // 리소스 집약적인 기능 초기화
    const chart = initializeChart()
    const websocket = initializeWebSocket()
    
    // 클린업 함수 등록
    cleanup.current.push(() => {
      chart.destroy()
      websocket.close()
    })

    return () => {
      // 등록된 모든 클린업 함수 실행
      cleanup.current.forEach(fn => fn())
      cleanup.current = []
    }
  }, [pathname])

  return <div className="dashboard">{children}</div>
}

function initializeChart() {
  // 차트 초기화 로직
  return {
    destroy: () => {
      // 차트 정리 로직
    }
  }
}

function initializeWebSocket() {
  // WebSocket 연결 로직
  return {
    close: () => {
      // 연결 종료 로직
    }
  }
}

 

성능 모니터링과 디버깅

레이아웃 성능 측정

// lib/performance.ts
export function createLayoutPerformanceMonitor() {
  const startTime = performance.now()
  let markedPoints: Record<string, number> = {}

  return {
    mark(name: string) {
      markedPoints[name] = performance.now() - startTime
    },
    getResults() {
      return markedPoints
    }
  }
}

// app/layout.tsx
import { createLayoutPerformanceMonitor } from '@/lib/performance'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const monitor = createLayoutPerformanceMonitor()

  // 주요 지점 성능 측정
  monitor.mark('layout-start')
  
  const session = await getServerSession()
  monitor.mark('session-loaded')
  
  const result = await someHeavyOperation()
  monitor.mark('heavy-operation-complete')

  console.log('Layout Performance:', monitor.getResults())

  return <div>{children}</div>
}

 

템플릿 렌더링 최적화

React DevTools의 Profiler를 효과적으로 활용하여 템플릿의 렌더링 성능을 모니터링할 수 있습니다:

// app/template.tsx
'use client'

import { Profiler, ProfilerOnRenderCallback } from 'react'

const onRender: ProfilerOnRenderCallback = (
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) => {
  console.table({
    id,
    phase,
    actualDuration,
    baseDuration,
    startTime,
    commitTime,
  })
}

export default function Template({ children }: { children: React.ReactNode }) {
  return (
    <Profiler id="template" onRender={onRender}>
      <div className="template">
        {children}
      </div>
    </Profiler>
  )
}

 

실제 사용 사례와 안티패턴

레이아웃에서 피해야 할 패턴

1. 동적 데이터 직접 사용

// 🚫 Anti-pattern
export default function Layout() {
  const data = await fetch('...')  // 캐시되어 실시간 데이터가 반영되지 않음
}

// ✅ Better: 클라이언트 컴포넌트로 분리
export default function Layout() {
  return (
    <ClientDataComponent>
      {children}
    </ClientDataComponent>
  )
}

 

2. 라우트 변경에 따른 상태 관리

// 🚫 Anti-pattern
export default function Layout() {
  const pathname = usePathname()  // 레이아웃에서는 변경 감지 불가
}

// ✅ Better: 클라이언트 컴포넌트에서 처리
'use client'
function RouteAwareComponent() {
  const pathname = usePathname()
  // 라우트 변경에 따른 로직
}

 

템플릿의 효과적인 활용

1. 페이지별 에러 바운더리

'use client'

class TemplateErrorBoundary extends React.Component {
  state = { hasError: false, error: null }

  static getDerivedStateFromError(error) {
    return { hasError: true, error }
  }

  componentDidMount() {
    // 에러 리포팅 서비스 초기화
  }

  render() {
    if (this.state.hasError) {
      return <ErrorDisplay error={this.state.error} />
    }
    return this.props.children
  }
}

export default function Template({ children }) {
  return (
    <TemplateErrorBoundary>
      {children}
    </TemplateErrorBoundary>
  )
}

 

마치며

레이아웃과 템플릿은 단순한 UI 구성요소가 아닌, Next.js의 렌더링 아키텍처와 밀접하게 연관된 고급 기능입니다. 각각의 특성을 이해하고 적절히 활용하면 더 효율적이고 유지보수가 용이한 애플리케이션을 구축할 수 있습니다.

특히 서버 컴포넌트와 클라이언트 컴포넌트의 경계, 상태 관리, 메모리 관리, 그리고 성능 최적화 측면에서 각 컴포넌트의 특성을 잘 활용하는 것이 중요합니다.

그동안 UI 관점에서 주로 사용을 해왔었지만 앞으로는 프론트엔드 개발자의 UX 관점에서 더 잘 활용할 수 있을 것 같습니다.

감사합니다.