1년차 프론트엔드 개발자의 Next.js 학습 여정 🔥
오늘은 Next.js의 레이아웃 시스템과 React 상태관리 패턴을 함께 배워보았습니다!
Next.js 13+ App Router에서 가장 중요한 개념 중 하나입니다!
// app/layout.tsx
'use client';
import { useState } from 'react';
export default function Layout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex">
{/* 사이드바 상태가 페이지 이동해도 유지됩니다! */}
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
토글
</button>
</aside>
<main>{children}</main>
</div>
);
}
Layout 핵심 포인트:
layout.tsx// app/template.tsx
'use client';
import { useEffect, useState } from 'react';
export default function Template({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
// 페이지 이동할 때마다 새로 실행됩니다!
setMounted(true);
console.log('Template이 새로 마운트되었습니다!');
}, []);
return (
<div className={`page-transition ${mounted ? 'fade-in' : ''}`}>
{children}
</div>
);
}
Template 핵심 포인트:
template.tsx| 상황 | 사용할 것 | 이유 |
|---|---|---|
| 헤더, 사이드바, 네비게이션 | Layout | 상태 유지 필요 |
| 페이지 전환 애니메이션 | Template | 매번 새로 시작 |
| 로딩 인디케이터 | Template | 페이지별 초기화 |
| 전역 상태 공유 | Layout | 상태 보존 |
상태 관리의 영원한 고민! 언제 무엇을 써야 할까요?
// ✅ useState가 적합한 경우
function SimpleCounter() {
const [count, setCount] = useState(0);
const [isVisible, setIsVisible] = useState(false);
const [userName, setUserName] = useState('');
return (
<div>
<button onClick={() => setCount(count + 1)}>
카운트: {count}
</button>
<button onClick={() => setIsVisible(!isVisible)}>
{isVisible ? '숨기기' : '보이기'}
</button>
<input
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder="사용자명"
/>
</div>
);
}
useState 사용 시기:
// ✅ useReducer가 적합한 경우
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
isLoading: boolean;
}
type TodoAction =
| { type: 'ADD_TODO'; payload: string }
| { type: 'TOGGLE_TODO'; payload: number }
| { type: 'SET_FILTER'; payload: 'all' | 'active' | 'completed' }
| { type: 'SET_LOADING'; payload: boolean };
const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{ id: Date.now(), text: action.payload, completed: false }
]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'SET_FILTER':
return {
...state,
filter: action.payload
};
case 'SET_LOADING':
return {
...state,
isLoading: action.payload
};
default:
return state;
}
};
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, {
todos: [],
filter: 'all',
isLoading: false
});
const addTodo = (text: string) => {
dispatch({ type: 'ADD_TODO', payload: text });
};
// ... 컴포넌트 렌더링 로직
}
useReducer 사용 시기:
실제 프로젝트에서 어떻게 적용하는지 보여드릴게요!
app/
├── layout.tsx # 전역 레이아웃 (헤더, 사이드바)
├── template.tsx # 페이지 전환 애니메이션
├── page.tsx # 홈페이지
├── dashboard/
│ ├── layout.tsx # 대시보드 전용 레이아웃
│ └── page.tsx # 대시보드 페이지
└── profile/
└── page.tsx # 프로필 페이지
// app/layout.tsx
'use client';
import { useReducer } from 'react';
interface AppState {
sidebarOpen: boolean;
theme: 'light' | 'dark';
user: { name: string; avatar: string } | null;
}
type AppAction =
| { type: 'TOGGLE_SIDEBAR' }
| { type: 'SET_THEME'; payload: 'light' | 'dark' }
| { type: 'SET_USER'; payload: { name: string; avatar: string } | null };
const appReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) {
case 'TOGGLE_SIDEBAR':
return { ...state, sidebarOpen: !state.sidebarOpen };
case 'SET_THEME':
return { ...state, theme: action.payload };
case 'SET_USER':
return { ...state, user: action.payload };
default:
return state;
}
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const [state, dispatch] = useReducer(appReducer, {
sidebarOpen: false,
theme: 'light',
user: null
});
return (
<html lang="ko">
<body className={state.theme}>
<div className="app-layout">
{/* 헤더 - 상태가 유지됩니다 */}
<header className="header">
<button onClick={() => dispatch({ type: 'TOGGLE_SIDEBAR' })}>
☰
</button>
<h1>My App</h1>
<button onClick={() => dispatch({
type: 'SET_THEME',
payload: state.theme === 'light' ? 'dark' : 'light'
})}>
{state.theme === 'light' ? '🌙' : '☀️'}
</button>
</header>
<div className="content-wrapper">
{/* 사이드바 - 상태가 유지됩니다 */}
<aside className={`sidebar ${state.sidebarOpen ? 'open' : 'closed'}`}>
<nav>
<a href="/">홈</a>
<a href="/dashboard">대시보드</a>
<a href="/profile">프로필</a>
</nav>
</aside>
{/* 메인 컨텐츠 */}
<main className="main-content">
{children}
</main>
</div>
</div>
</body>
</html>
);
}
// app/template.tsx
'use client';
import { useEffect, useState } from 'react';
export default function Template({ children }: { children: React.ReactNode }) {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
// 페이지 전환 애니메이션
setIsLoaded(false);
const timer = setTimeout(() => setIsLoaded(true), 100);
return () => clearTimeout(timer);
}, [children]);
return (
<div className={`page-transition ${isLoaded ? 'loaded' : 'loading'}`}>
{children}
</div>
);
}
/* globals.css */
.page-transition {
opacity: 0;
transform: translateY(20px);
transition: all 0.3s ease-in-out;
}
.page-transition.loaded {
opacity: 1;
transform: translateY(0);
}
.sidebar {
width: 0;
overflow: hidden;
transition: width 0.3s ease;
}
.sidebar.open {
width: 250px;
}
// ❌ 잘못된 예: 복잡한 상태를 useState로
function BadExample() {
const [user, setUser] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
// 상태들이 서로 연관되어 있는데 따로 관리하고 있음...
}
// ✅ 올바른 예: 관련된 상태들을 useReducer로 통합
function GoodExample() {
const [state, dispatch] = useReducer(userReducer, {
user: null,
loading: false,
error: null,
posts: [],
page: 1
});
// 하나의 dispatch로 여러 상태를 일관성 있게 업데이트
const fetchUserData = async (userId: string) => {
dispatch({ type: 'FETCH_START' });
try {
const userData = await api.getUser(userId);
dispatch({ type: 'FETCH_SUCCESS', payload: userData });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
}
// 🏠 Layout: 상태 유지가 중요한 경우
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }) {
const [filters, setFilters] = useState({}); // 필터 상태 유지
const [selectedItems, setSelectedItems] = useState([]); // 선택 항목 유지
return (
<div>
<FilterPanel filters={filters} onChange={setFilters} />
<ItemSelector selected={selectedItems} onChange={setSelectedItems} />
{children}
</div>
);
}
// ✨ Template: 매번 초기화가 필요한 경우
// app/template.tsx
export default function Template({ children }) {
const [animationState, setAnimationState] = useState('entering');
useEffect(() => {
// 페이지 이동할 때마다 애니메이션 재시작
setAnimationState('entering');
const timer = setTimeout(() => setAnimationState('entered'), 300);
return () => clearTimeout(timer);
}, [children]);
return (
<div className={`page ${animationState}`}>
{children}
</div>
);
}
Metadata API + 상태 끌어올리기 + SEO 최적화
"Layout은 상태 유지, Template은 애니메이션"
이 한 문장만 기억해도 90%는 해결됩니다. 그리고 상태 관리는 복잡도가 기준이에요. 3개 이상의 관련된 상태가 있으면 useReducer를 고려해보세요!
"사용자가 다른 페이지로 이동한 후 다시 돌아왔을 때, 이전 상태가 유지되어야 사용자 경험이 좋아질까?"
• 사이드바 열림/닫힘 상태
• 네비게이션 메뉴 활성 탭
• 사용자 설정 (테마, 언어)
• 검색어와 필터 상태
• 장바구니/찜한 상품
• 알림 읽음/안읽음 상태
• 대시보드 위젯 배치
• 사용자 인증 상태
• 페이지별 로딩 상태
• 폼 입력 데이터
• 페이지별 에러 상태
• 일회성 모달 상태
• API 호출 결과 데이터
• 페이지별 스크롤 위치
• 임시/휘발성 데이터
💡 핵심 포인트: