들어가며
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 관점에서 더 잘 활용할 수 있을 것 같습니다.
감사합니다.