Skip to main content
Advertisement

실전 고수 팁

React 애플리케이션을 프로덕션 수준으로 만들기 위한 패턴과 도구들을 소개합니다. 커스텀 훅 설계, 프로젝트 구조, 에러 바운더리, Storybook, 성능 디버깅까지 실무에서 바로 적용할 수 있는 내용을 다룹니다.


커스텀 훅 설계 원칙 및 패턴

단일 책임 원칙

// 나쁜 예: 너무 많은 책임을 가진 훅
function useUserPage() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [theme, setTheme] = useState('light');
const [isModalOpen, setIsModalOpen] = useState(false);
// ...
}

// 좋은 예: 단일 책임
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
if (!userId) return;
setLoading(true);
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);

return { user, loading, error };
}

function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}

재사용 가능한 패턴

// 비동기 상태 관리 패턴
function useAsync(asyncFn, deps = []) {
const [state, setState] = useState({
data: null,
loading: false,
error: null
});

const execute = useCallback(async (...args) => {
setState({ data: null, loading: true, error: null });
try {
const data = await asyncFn(...args);
setState({ data, loading: false, error: null });
return data;
} catch (error) {
setState({ data: null, loading: false, error });
throw error;
}
}, deps); // eslint-disable-line

return { ...state, execute };
}

// 사용
function UserProfile({ userId }) {
const { data: user, loading, error, execute: loadUser } = useAsync(fetchUser);

useEffect(() => {
loadUser(userId);
}, [userId, loadUser]);

if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error.message} />;
return <UserCard user={user} />;
}

// 로컬 스토리지 동기화
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 = useCallback((value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (err) {
console.error(err);
}
}, [key, storedValue]);

return [storedValue, setValue];
}

// 이전 값 추적
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}

// 디바운스
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);

return debouncedValue;
}

// 검색 컴포넌트에서 활용
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);

useEffect(() => {
if (debouncedQuery) onSearch(debouncedQuery);
}, [debouncedQuery, onSearch]);

return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

프로젝트 폴더 구조

Feature-based 구조 (권장)

src/
├── features/ ← 기능 단위로 분리
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.jsx
│ │ │ └── LoginForm.test.jsx
│ │ ├── hooks/
│ │ │ └── useAuth.js
│ │ ├── services/
│ │ │ └── authService.js
│ │ ├── store/
│ │ │ └── authSlice.js
│ │ └── index.js ← Public API
│ ├── products/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── index.js
│ └── cart/
│ ├── components/
│ ├── hooks/
│ └── index.js
├── shared/ ← 공용 코드
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.jsx
│ │ │ ├── Button.test.jsx
│ │ │ └── Button.stories.jsx
│ │ └── Modal/
│ ├── hooks/
│ │ ├── useToggle.js
│ │ └── useDebounce.js
│ ├── utils/
│ │ ├── formatDate.js
│ │ └── formatPrice.js
│ └── types/
│ └── index.ts
├── pages/ ← 라우팅 단위
│ ├── HomePage.jsx
│ └── ProductPage.jsx
├── app/ ← 앱 설정
│ ├── store.js
│ ├── router.jsx
│ └── App.jsx
└── main.jsx
// features/auth/index.js (Barrel Export)
// 외부에서 auth 기능에 접근하는 공개 API만 노출
export { LoginForm } from './components/LoginForm';
export { useAuth } from './hooks/useAuth';
export { authSlice, authReducer } from './store/authSlice';
// 내부 구현체는 외부에 노출하지 않음

// 사용
import { LoginForm, useAuth } from '@/features/auth';
// 내부 파일 직접 접근 금지
// import { loginService } from '@/features/auth/services/authService'; ← 규칙 위반

Layer-based 구조 (소규모 프로젝트)

src/
├── components/ ← UI 컴포넌트
├── pages/ ← 페이지 컴포넌트
├── hooks/ ← 커스텀 훅
├── services/ ← API 호출
├── store/ ← 상태 관리
├── utils/ ← 유틸리티 함수
└── types/ ← TypeScript 타입

에러 바운더리

에러 바운더리는 자식 컴포넌트의 JavaScript 에러를 잡아 폴백 UI를 표시합니다.

// ErrorBoundary.jsx
import { Component } from 'react';

class ErrorBoundary extends Component {
state = { hasError: false, error: null, errorInfo: null };

// 렌더링 중 에러 발생 시 호출
static getDerivedStateFromError(error) {
return { hasError: true, error };
}

// 에러 로깅
componentDidCatch(error, errorInfo) {
this.setState({ errorInfo });

// 에러 모니터링 서비스로 전송
logErrorToService(error, {
componentStack: errorInfo.componentStack,
digest: errorInfo.digest
});
}

handleReset = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
};

render() {
if (this.state.hasError) {
// fallback prop으로 커스텀 에러 UI 지정 가능
if (this.props.fallback) {
return this.props.fallback({
error: this.state.error,
resetError: this.handleReset
});
}

return (
<div className="error-boundary">
<h2>문제가 발생했습니다</h2>
<p>{this.state.error?.message}</p>
<button onClick={this.handleReset}>다시 시도</button>
</div>
);
}

return this.props.children;
}
}

// 사용
function App() {
return (
<ErrorBoundary
fallback={({ error, resetError }) => (
<div>
<h2>오류: {error.message}</h2>
<button onClick={resetError}>복구</button>
</div>
)}
>
<MainContent />
</ErrorBoundary>
);
}

// 세분화된 에러 바운더리
function ProductPage() {
return (
<div>
<ErrorBoundary fallback={<ProductHeaderFallback />}>
<ProductHeader />
</ErrorBoundary>

<ErrorBoundary fallback={<p>리뷰를 불러올 수 없습니다</p>}>
<ProductReviews />
</ErrorBoundary>

<ErrorBoundary fallback={<CartFallback />}>
<AddToCartSection />
</ErrorBoundary>
</div>
);
}

react-error-boundary 라이브러리

import { ErrorBoundary, useErrorBoundary } from 'react-error-boundary';

// 더 간편한 API
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => window.location.reload()}
onError={(error, info) => logError(error, info)}
>
<Main />
</ErrorBoundary>
);
}

function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<h2>무언가 잘못되었습니다:</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
);
}

// 함수형 컴포넌트에서 에러 바운더리 트리거
function useErrorHandler() {
const { showBoundary } = useErrorBoundary();

return (error) => {
if (error) showBoundary(error);
};
}

function DataComponent() {
const handleError = useErrorHandler();

useEffect(() => {
fetchData()
.then(setData)
.catch(handleError); // 에러 바운더리로 전달
}, []);
}

Storybook 도입

Storybook은 컴포넌트를 독립적으로 개발하고 문서화하는 도구입니다.

도입 이유

1. 컴포넌트 독립 개발
- 실제 앱 없이 컴포넌트 단독 개발 가능
- API 없이도 다양한 상태 테스트

2. 시각적 문서화
- 자동으로 컴포넌트 카탈로그 생성
- 디자이너/기획자와 소통 용이

3. 회귀 테스트
- 시각적 변경사항 감지
- Chromatic 등과 연동

4. 접근성 테스트
- @storybook/addon-a11y로 접근성 자동 검사

기본 설정

# 설치
npx storybook@latest init

# 실행
npm run storybook
// Button.stories.jsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

// 메타데이터
const meta: Meta<typeof Button> = {
component: Button,
title: 'Shared/Button', // 카탈로그 위치
tags: ['autodocs'], // 자동 문서 생성
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
description: '버튼 스타일 변형'
},
size: {
control: 'radio',
options: ['sm', 'md', 'lg']
},
disabled: {
control: 'boolean'
}
}
};

export default meta;
type Story = StoryObj<typeof Button>;

// 기본 스토리
export const Primary: Story = {
args: {
variant: 'primary',
children: '버튼'
}
};

export const Secondary: Story = {
args: {
variant: 'secondary',
children: '취소'
}
};

export const Disabled: Story = {
args: {
disabled: true,
children: '비활성화'
}
};

// 인터랙션 테스트
export const WithInteraction: Story = {
args: {
children: '클릭'
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
await expect(button).toHaveFocus();
}
};
// 복잡한 컴포넌트 스토리
// LoginForm.stories.jsx
import { LoginForm } from './LoginForm';
import { userEvent, within } from '@storybook/testing-library';

export default {
component: LoginForm,
title: 'Features/Auth/LoginForm',
};

export const Empty = {};

export const FilledIn = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('이메일'), 'alice@example.com');
await userEvent.type(canvas.getByLabelText('비밀번호'), 'password123');
}
};

export const Submitting = {
parameters: {
msw: {
handlers: [
http.post('/api/login', async () => {
await delay(2000); // 2초 지연으로 로딩 상태 확인
return HttpResponse.json({ success: true });
})
]
}
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('이메일'), 'test@example.com');
await userEvent.type(canvas.getByLabelText('비밀번호'), 'pass');
await userEvent.click(canvas.getByRole('button', { name: '로그인' }));
}
};

성능 디버깅 (React DevTools Profiler)

React DevTools Profiler 사용법

// Profiler API로 직접 측정
import { Profiler } from 'react';

function onRenderCallback(
id, // 컴포넌트 트리 식별자
phase, // 'mount' | 'update' | 'nested-update'
actualDuration, // 실제 렌더링 시간 (ms)
baseDuration, // 메모이제이션 없이 예상 시간
startTime, // 렌더링 시작 시간
commitTime, // 커밋 시간
interactions // 렌더링을 트리거한 이벤트
) {
if (actualDuration > 16) { // 60fps 기준 16ms 이상
console.warn(`느린 렌더링 감지: ${id} - ${actualDuration.toFixed(2)}ms`);
}
}

function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<MainContent />
</Profiler>
);
}

불필요한 리렌더링 방지

// 1. React.memo로 불필요한 리렌더링 방지
const ExpensiveList = memo(function ExpensiveList({ items, onSelect }) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});

// 2. useCallback: 함수 참조 안정화
function Parent({ userId }) {
const [selectedId, setSelectedId] = useState(null);

// useCallback 없으면 매 렌더링마다 새 함수 생성 → memo 효과 없음
const handleSelect = useCallback((id) => {
setSelectedId(id);
trackSelection(userId, id);
}, [userId]);

return <ExpensiveList items={items} onSelect={handleSelect} />;
}

// 3. useMemo: 비용 큰 계산 캐싱
function DataTable({ data, sortField, filterText }) {
const processedData = useMemo(() => {
return data
.filter(item => item.name.includes(filterText))
.sort((a, b) => a[sortField] > b[sortField] ? 1 : -1);
}, [data, sortField, filterText]);

return <Table rows={processedData} />;
}

// 4. 상태 분리로 리렌더링 범위 최소화
// 나쁜 예: 상태 하나가 변해도 전체 리렌더링
function BadComponent() {
const [state, setState] = useState({
count: 0,
name: 'Alice',
theme: 'dark'
});
}

// 좋은 예: 독립적인 상태 분리
function GoodComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Alice');
const [theme, setTheme] = useState('dark');
// count가 변해도 name, theme을 사용하는 곳은 리렌더링 안 됨
}

why-did-you-render 도구

// 개발 환경에서 불필요한 리렌더링 감지
// wdyr.js
import React from 'react';

if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true,
trackHooks: true,
logOwnerReasons: true,
});
}

// 특정 컴포넌트만 감시
ExpensiveComponent.whyDidYouRender = true;

// 또는
const ExpensiveComponent = memo(function ExpensiveComponent(props) {
return <div>{props.value}</div>;
});
ExpensiveComponent.whyDidYouRender = {
logOnDifferentValues: true,
customName: 'ExpensiveComponent'
};

번들 크기 최적화

// 1. 코드 분할 (Code Splitting)
import { lazy, Suspense } from 'react';

// 라우트 수준 코드 분할
const AdminPage = lazy(() => import('./pages/AdminPage'));
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'));

function Router() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/admin" element={<AdminPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
</Routes>
</Suspense>
);
}

// 2. 동적 임포트로 대형 라이브러리 지연 로딩
async function handleExport(data) {
const { default: XLSX } = await import('xlsx'); // xlsx는 크기가 큼
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Data');
XLSX.writeFile(wb, 'export.xlsx');
}

// 3. 트리 쉐이킹을 위한 임포트
// 나쁜 예: 전체 lodash 임포트
import _ from 'lodash';
const result = _.pick(obj, ['a', 'b']);

// 좋은 예: 필요한 것만
import pick from 'lodash/pick';
const result = pick(obj, ['a', 'b']);

실전 패턴 모음

Compound Component 패턴

// 관련 컴포넌트들을 하나의 네임스페이스로 묶기
function Select({ children, value, onChange }) {
return (
<SelectContext.Provider value={{ value, onChange }}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}

function SelectTrigger({ children }) {
const { value } = useContext(SelectContext);
return <button className="select-trigger">{children || value}</button>;
}

function SelectContent({ children }) {
return <div className="select-content">{children}</div>;
}

function SelectItem({ value, children }) {
const { onChange } = useContext(SelectContext);
return (
<div className="select-item" onClick={() => onChange(value)}>
{children}
</div>
);
}

// 컴포넌트 구성
Select.Trigger = SelectTrigger;
Select.Content = SelectContent;
Select.Item = SelectItem;

// 사용
<Select value={lang} onChange={setLang}>
<Select.Trigger />
<Select.Content>
<Select.Item value="ko">한국어</Select.Item>
<Select.Item value="en">English</Select.Item>
</Select.Content>
</Select>

Render Props 패턴

// 로직을 공유하되 UI는 호출자가 결정
function DataFetcher({ url, render }) {
const { data, loading, error } = useFetch(url);
return render({ data, loading, error });
}

// 사용
<DataFetcher
url="/api/users"
render={({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <UserList users={data} />;
}}
/>

고수 팁

1. 커스텀 훅에서 클린업 자동화

function useEventListener(target, type, handler, options) {
const handlerRef = useRef(handler);
handlerRef.current = handler;

useEffect(() => {
const el = target instanceof Window ? target : target?.current;
if (!el) return;

const listener = (e) => handlerRef.current(e);
el.addEventListener(type, listener, options);
return () => el.removeEventListener(type, listener, options);
}, [target, type, options]);
}

// 사용
useEventListener(window, 'resize', () => updateLayout());
useEventListener(buttonRef, 'click', handleClick);

2. 중앙화된 에러 처리

// axios interceptor 스타일의 전역 에러 처리
function useGlobalErrorHandler() {
const { showBoundary } = useErrorBoundary();
const showToast = useToast();

useEffect(() => {
function handleUnhandledRejection(e) {
const error = e.reason;
if (error instanceof NetworkError) {
showToast({ message: '네트워크 오류', type: 'error' });
} else if (error instanceof AuthError) {
redirectToLogin();
} else {
showBoundary(error);
}
}

window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => window.removeEventListener('unhandledrejection', handleUnhandledRejection);
}, []);
}

3. 성능 측정 자동화

function PerformanceMonitor({ children, componentName }) {
const startTime = useRef(performance.now());

useEffect(() => {
const mountTime = performance.now() - startTime.current;
if (mountTime > 100) {
console.warn(`${componentName} 마운트 시간: ${mountTime.toFixed(2)}ms`);
}
}, []);

return children;
}
Advertisement