커스텀 훅 · 성능 최적화 · React Router · 상태 관리 라이브러리
use로 시작하는 커스텀 훅의 개념과 ESLint 규칙, 공통 로직 추출의 이점, 그리고 useFetch/useLocalStorage/useDebounce 직접 구현을 다룹니다. 불필요한 리렌더링을 방지하는 React.memo, 비싼 계산을 메모하는 useMemo, 함수 참조를 유지하는 useCallback, 그리고 렌더링 없이 값을 저장하는 useRef를 익힙니다. react-router-dom v6의 핵심(BrowserRouter/Routes/Route/Link), useParams/useNavigate/useLocation 훅, 중첩 라우트(Outlet), 404 처리, 그리고 보호된 라우트 패턴까지. 전역 상태가 필요한 시점, Zustand vs Redux 비교, create로 store 만들기, 선택적 구독으로 성능 최적화, slice 패턴, devtools 미들웨어를 익힙니다.
이 강의에서 배우는 것
- 1커스텀 훅이 use로 시작해야 하는 이유를 안다
- 2중복 로직을 커스텀 훅으로 추출한다
- 3useFetch — Promise/AbortController 패턴을 적용한다
- 4useLocalStorage — JSON 직렬화·복원을 처리한다
- 5useDebounce — setTimeout/clearTimeout 패턴을 안다
- 6불필요한 리렌더링이 발생하는 3가지 원인을 안다
- 7React.memo로 컴포넌트 메모이제이션을 한다
- 8useMemo로 비싼 계산 결과를 메모한다
- 9useCallback과 React.memo를 함께 써서 자식 리렌더를 막는다
- 10useRef로 DOM 접근/이전 값 저장을 한다
- 11BrowserRouter / Routes / Route 로 라우팅을 설정한다
- 12Link / NavLink로 페이지 전환을 한다
- 13useParams / useNavigate / useLocation 훅을 사용한다
- 14Outlet으로 중첩 레이아웃을 구성한다
- 15Navigate로 보호된 라우트를 만든다
- 16전역 상태가 필요한 시점을 판단한다
- 17Zustand와 Redux의 차이를 안다
- 18create로 store를 만들고 set으로 상태를 변경한다
- 19선택자로 필요한 상태만 구독해 리렌더링을 줄인다
- 20slice 패턴으로 관심사를 분리한다
- 21devtools 미들웨어로 디버깅한다
커스텀 훅 (Custom Hook)
use로 시작하는 커스텀 훅의 개념과 ESLint 규칙, 공통 로직 추출의 이점, 그리고 useFetch/useLocalStorage/useDebounce 직접 구현을 다룹니다.
1. 커스텀 훅이란
// ✅ use로 시작
function useWindowSize() { ... }
function useFetch(url) { ... }
// ❌ use로 시작하지 않으면 훅 규칙 검사 X
function getWindowSize() { ... }- React는 use로 시작하는 함수에서만 훅 규칙 검사
- ESLint react-hooks/rules-of-hooks가 정상 동작
- 다른 개발자가 즉시 훅임을 인식
2. 로직 추출의 이점
추출 전 — 중복
function ComponentA() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
// ... 같은 패턴 반복
}추출 후 — 한 줄로 해결
function ComponentA() {
const { data, isLoading, error } = useFetch('/api/users');
}
function ComponentB() {
const { data, isLoading, error } = useFetch('/api/products');
}3. useFetch
function useFetch(url) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setIsLoading(true);
fetch(url, { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error('실패'); return r.json(); })
.then(setData)
.catch(err => { if (err.name !== 'AbortError') setError(err.message); })
.finally(() => setIsLoading(false));
return () => controller.abort();
}, [url]);
return { data, isLoading, error };
}4. useLocalStorage
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
};
return [storedValue, setValue];
}5. useDebounce
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}성능 최적화
불필요한 리렌더링을 방지하는 React.memo, 비싼 계산을 메모하는 useMemo, 함수 참조를 유지하는 useCallback, 그리고 렌더링 없이 값을 저장하는 useRef를 익힙니다.
1. 리렌더링 원인
- state가 바뀔 때
- props가 바뀔 때
- 부모 컴포넌트가 리렌더링될 때 — 자식도 함께
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>+{count}</button>
<Child /> {/* count와 무관하지만 함께 리렌더링 */}
</>
);
}2. React.memo
// props가 바뀌지 않으면 리렌더링 건너뜀
const ExpensiveChild = React.memo(function ExpensiveChild({ data }) {
console.log('렌더링');
return <div>{data}</div>;
});모든 컴포넌트에 무조건 적용 X — memo 자체도 비교 비용. 렌더링 비용이 큰 컴포넌트에만.
3. useMemo
// ❌ 매 렌더링마다 필터링
const filteredProducts = products.filter(p => p.name.includes(filterKeyword));
// ✅ 의존성이 바뀔 때만 재계산
const filteredProducts = useMemo(
() => products.filter(p => p.name.includes(filterKeyword)),
[products, filterKeyword]
);4. useCallback
function Parent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// ❌ 매 렌더링마다 새 함수 → React.memo 무력화
const handleClick = () => setCount(c => c + 1);
// ✅ 항상 같은 함수 참조
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<MemoizedButton onClick={handleClick}>{count}</MemoizedButton>
</>
);
}
const MemoizedButton = React.memo(function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
});5. useRef
DOM 접근
function InputFocus() {
const inputRef = useRef(null);
const focusInput = () => inputRef.current.focus();
return (
<>
<input ref={inputRef} />
<button onClick={focusInput}>포커스</button>
</>
);
}렌더링 없이 값 저장
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef(0);
useEffect(() => {
prevCountRef.current = count; // ref 변경은 리렌더링 X
});
return <p>현재: {count}, 이전: {prevCountRef.current}</p>;
}6. React DevTools Profiler
- React Developer Tools 확장 설치 (Chrome/Firefox)
- DevTools → Profiler 탭
- 녹화 시작 → 앱 조작 → 녹화 중지
- 어떤 컴포넌트가 얼마나 자주, 오래 렌더링되는지 확인
- 병목을 React.memo / useMemo / useCallback으로 최적화
| 도구 | 대상 | 사용 시점 |
|---|---|---|
| React.memo | 컴포넌트 | 렌더링 비용 크고 props가 자주 안 바뀔 때 |
| useMemo | 계산 결과 | 비싼 계산 반복 |
| useCallback | 함수 | memo 컴포넌트에 함수 prop 전달 시 |
| useRef | DOM/값 | 렌더링 없이 값 유지 |
성능 최적화는 문제가 생겼을 때 하세요. 미리 하면 코드만 복잡해집니다.
React Router
react-router-dom v6의 핵심(BrowserRouter/Routes/Route/Link), useParams/useNavigate/useLocation 훅, 중첩 라우트(Outlet), 404 처리, 그리고 보호된 라우트 패턴까지.
1. 설치 & 핵심 컴포넌트
npm install react-router-dom| 컴포넌트 | 역할 |
|---|---|
| <BrowserRouter> | 앱 전체를 감싸는 라우터 |
| <Routes> | 여러 <Route>를 담는 컨테이너 |
| <Route> | URL 패턴 ↔ 컴포넌트 |
| <Link> | 페이지 이동 (a 태그 대체) |
| <NavLink> | 현재 경로 일치 시 active |
| <Navigate> | 리다이렉트 |
| <Outlet> | 중첩 라우트 자식 렌더링 위치 |
2. 기본 라우팅
// main.jsx
import { BrowserRouter } from 'react-router-dom';
root.render(
<BrowserRouter>
<App />
</BrowserRouter>
);
// App.jsx
import { Routes, Route } from 'react-router-dom';
function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/:id" element={<BlogPostPage />} /> {/* 동적 */}
<Route path="*" element={<NotFoundPage />} /> {/* 404 */}
</Routes>
);
}3. Link / NavLink
<Link to="/blog">블로그</Link>
<NavLink
to="/blog"
style={({ isActive }) => ({
color: isActive ? '#3b82f6' : '#374151',
fontWeight: isActive ? 'bold' : 'normal',
})}
>
블로그
</NavLink>4. useParams
import { useParams } from 'react-router-dom';
// path="/blog/:id"
function BlogPostPage() {
const { id } = useParams(); // 항상 문자열
return <h1>게시물 #{id}</h1>;
}5. useNavigate
import { useNavigate } from 'react-router-dom';
function LoginForm() {
const navigate = useNavigate();
const handleLogin = async () => {
await login();
navigate('/dashboard');
navigate(-1); // 뒤로
navigate('/login', { replace: true }); // 히스토리 교체
};
}6. useLocation
import { useLocation } from 'react-router-dom';
function App() {
const location = useLocation();
// location.pathname: '/blog/42'
// location.search: '?page=1'
// location.hash: '#section1'
// location.state: navigate()로 전달한 데이터
}7. Outlet — 중첩 라우트
<Routes>
<Route path="/blog" element={<BlogLayout />}>
<Route index element={<BlogListPage />} />
<Route path=":id" element={<BlogPostPage />} />
</Route>
</Routes>
function BlogLayout() {
return (
<div>
<nav>블로그 사이드바</nav>
<main>
<Outlet />
</main>
</div>
);
}8. Navigate — 보호된 라우트
import { Navigate } from 'react-router-dom';
function ProtectedRoute({ children }) {
const isLoggedIn = useAuthStore(state => state.isLoggedIn);
if (!isLoggedIn) return <Navigate to="/login" replace />;
return children;
}상태 관리 라이브러리 (Zustand)
전역 상태가 필요한 시점, Zustand vs Redux 비교, create로 store 만들기, 선택적 구독으로 성능 최적화, slice 패턴, devtools 미들웨어를 익힙니다.
1. 전역 상태가 필요한 시점
| 상황 | 해결책 |
|---|---|
| 컴포넌트 내부 | useState |
| 부모-자식 전달 | props |
| 2~3단계 이상 props drilling | useContext |
| 여러 페이지/컴포넌트 공유 | 전역 상태 관리 |
| 복잡한 비동기 + 공유 상태 | 전역 상태 관리 |
장바구니, 로그인 사용자 정보, 알림, 언어 설정 등이 전형적.
2. Zustand vs Redux
| 항목 | Zustand | Redux Toolkit |
|---|---|---|
| 설치 크기 | ~1KB | 큰 편 |
| 학습 곡선 | 낮음 | 높음 |
| 보일러플레이트 | 거의 없음 | 어느 정도 |
| DevTools | 미들웨어로 | 기본 내장 |
| 비동기 | 자유롭게 | createAsyncThunk |
| 적합 규모 | 소~중 | 대규모 팀 |
3. Zustand 기본
npm install zustandimport { create } from 'zustand';
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
function Counter() {
const { count, increment, decrement } = useCounterStore();
return (
<div>
<p>{count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}4. 선택적 구독
// count만 구독 — count가 바뀔 때만 리렌더링
function CountDisplay() {
const count = useCounterStore((state) => state.count);
return <p>{count}</p>;
}
// increment만 구독 — 절대 리렌더링 안 됨 (함수는 변하지 않음)
function IncrementButton() {
const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>+</button>;
}5. slice 패턴
// store/slices/cartSlice.js
export const createCartSlice = (set, get) => ({
cart: [],
addToCart: (item) => set((state) => ({ cart: [...state.cart, item] })),
removeFromCart: (id) => set((state) => ({ cart: state.cart.filter(i => i.id !== id) })),
getTotal: () => get().cart.reduce((sum, item) => sum + item.price, 0),
});
// store/useAppStore.js
import { create } from 'zustand';
import { createCartSlice } from './slices/cartSlice';
import { createUserSlice } from './slices/userSlice';
const useAppStore = create((...args) => ({
...createCartSlice(...args),
...createUserSlice(...args),
}));6. devtools 미들웨어
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 }), false, 'increment'),
}),
{ name: 'MyStore' }
)
);Redux DevTools 확장과 연동돼 상태 변화를 시각적으로 확인.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 주세요.
댓글은 회원만 쓸 수 있습니다.
광고·욕설·비방·개인정보·불법 내용이 담긴 댓글은 숨기거나 지웁니다. 댓글은 닉네임과 함께 공개됩니다. 개인정보처리방침