이미지 로딩 중...
AI Generated
2025. 11. 5. · 7 Views
React 실전 프로젝트 컴포넌트 설계 완벽 가이드
실전 React 프로젝트에서 컴포넌트를 효과적으로 설계하고 구조화하는 방법을 배워보세요. 컴포넌트 분리 원칙부터 상태 관리, 재사용성 향상까지 실무에 바로 적용할 수 있는 설계 패턴을 다룹니다.
목차
- 단일_책임_원칙_컴포넌트
- Container-Presenter_패턴
- Compound_컴포넌트_패턴
- Custom_Hooks_활용
- Props_Drilling_해결
- 조건부_렌더링_최적화
- Error_Boundary_패턴
1. 단일_책임_원칙_컴포넌트
시작하며
여러분이 대시보드 페이지를 만들 때 하나의 거대한 컴포넌트에 모든 로직을 넣어본 적 있나요? 데이터 fetching, 상태 관리, UI 렌더링, 이벤트 핸들링까지 모두 한 곳에 있으면 코드가 수백 줄로 늘어나고, 나중에 수정할 때마다 어디를 고쳐야 할지 헷갈립니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 프로젝트 초기에는 빠르게 개발하다 보니 컴포넌트가 점점 비대해지고, 나중에는 유지보수가 거의 불가능한 스파게티 코드가 되어버립니다.
한 컴포넌트를 수정하면 예상치 못한 곳에서 버그가 발생하고, 재사용도 어렵습니다. 바로 이럴 때 필요한 것이 단일 책임 원칙(Single Responsibility Principle)입니다.
각 컴포넌트가 하나의 명확한 역할만 수행하도록 설계하면, 코드를 이해하기 쉽고 테스트하기 쉬우며 재사용하기 좋은 구조를 만들 수 있습니다.
개요
간단히 말해서, 단일 책임 원칙은 하나의 컴포넌트가 하나의 이유로만 변경되어야 한다는 설계 원칙입니다. 각 컴포넌트는 하나의 명확한 목적을 가져야 합니다.
프로젝트가 커질수록 컴포넌트를 작고 집중된 단위로 나누는 것이 필수적입니다. 예를 들어, 사용자 프로필 페이지를 만든다면 UserProfile, UserAvatar, UserInfo, UserStats로 나누어 각각이 자신의 역할에만 집중하도록 해야 합니다.
이렇게 하면 UserAvatar를 다른 곳에서도 재사용할 수 있고, UserStats만 수정하고 싶을 때 다른 부분에 영향을 주지 않습니다. 기존에는 하나의 거대한 컴포넌트에서 모든 것을 처리했다면, 이제는 각 기능을 독립적인 컴포넌트로 분리하여 조립하는 방식으로 개발할 수 있습니다.
핵심 특징은 명확한 책임 분리, 높은 재사용성, 그리고 쉬운 테스트입니다. 이러한 특징들이 장기적으로 프로젝트의 유지보수 비용을 크게 줄여주고, 팀 협업을 원활하게 만듭니다.
코드 예제
// ❌ 나쁜 예: 모든 책임이 한 컴포넌트에
function UserDashboard() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
useEffect(() => {
// 데이터 fetching, 상태 관리, UI 렌더링 모두 한 곳에
fetch('/api/user').then(res => setUser(res.data));
fetch('/api/posts').then(res => setPosts(res.data));
}, []);
// 100줄 이상의 복잡한 JSX...
}
// ✅ 좋은 예: 책임별로 분리
function UserDashboard() {
return (
<div>
<UserProfile />
<UserPosts />
<UserStats />
</div>
);
}
function UserProfile() {
const { user } = useUser(); // Custom Hook으로 데이터 로직 분리
return <div>{user.name}</div>;
}
설명
이것이 하는 일: 단일 책임 원칙을 적용하면 각 컴포넌트가 명확한 목적을 가지고, 그 목적만을 위해 존재하게 됩니다. 위 예제에서는 대시보드를 UserProfile, UserPosts, UserStats로 분리했습니다.
첫 번째로, 나쁜 예에서는 UserDashboard 컴포넌트가 데이터 fetching, 상태 관리, UI 렌더링을 모두 담당합니다. 이렇게 하면 컴포넌트가 비대해지고, 한 부분을 수정할 때 다른 부분에 영향을 줄 수 있습니다.
또한 UserProfile 부분만 다른 페이지에서 재사용하고 싶어도 불가능합니다. 그 다음으로, 좋은 예에서는 각 기능을 독립적인 컴포넌트로 분리했습니다.
UserProfile은 사용자 정보만 보여주고, UserPosts는 게시글만 보여주며, UserStats는 통계만 보여줍니다. 각 컴포넌트는 자신의 데이터를 Custom Hook을 통해 독립적으로 관리하므로, 다른 컴포넌트에 영향을 주지 않습니다.
마지막으로, UserDashboard는 이제 레이아웃만 담당하는 단순한 컴포넌트가 되었습니다. 이렇게 하면 각 컴포넌트를 독립적으로 테스트할 수 있고, 필요한 곳에서 자유롭게 재사용할 수 있습니다.
여러분이 이 패턴을 사용하면 코드 가독성이 크게 향상되고, 버그 발생 시 문제가 있는 컴포넌트만 집중해서 수정할 수 있습니다. 또한 새로운 팀원이 합류했을 때 각 컴포넌트의 역할이 명확하므로 빠르게 프로젝트를 이해할 수 있습니다.
실전 팁
💡 컴포넌트 이름만 봐도 역할을 알 수 있도록 명확하게 지어주세요. UserProfileCard보다는 UserAvatar, UserName, UserBio로 나누는 것이 좋습니다.
💡 한 컴포넌트가 100줄을 넘어가면 분리할 수 있는지 검토해보세요. 보통 50-80줄 정도가 적당합니다.
💡 "이 컴포넌트가 변경되는 이유가 몇 가지인가?"를 자문해보세요. 2가지 이상이라면 분리가 필요합니다.
💡 데이터 fetching 로직은 Custom Hook으로 분리하여 컴포넌트는 UI 렌더링에만 집중하도록 하세요.
💡 props가 5개를 넘어가면 컴포넌트가 너무 많은 일을 하고 있다는 신호입니다. 더 작은 단위로 쪼개보세요.
2. Container-Presenter_패턴
시작하며
여러분이 게시글 목록을 보여주는 컴포넌트를 만들 때, API 호출, 로딩 상태 관리, 에러 처리, 그리고 실제 UI 렌더링 코드가 뒤섞여 있어서 어디가 비즈니스 로직이고 어디가 화면 표시인지 구분이 안 되는 경우가 있었나요? 이런 문제는 특히 복잡한 데이터 처리가 필요한 페이지에서 심각합니다.
로직과 UI가 섞여 있으면 디자인을 변경할 때 비즈니스 로직까지 건드리게 되고, 반대로 로직을 수정할 때 UI가 깨질 수 있습니다. 또한 Storybook으로 UI를 테스트하기도 어렵습니다.
바로 이럴 때 필요한 것이 Container-Presenter 패턴입니다. 비즈니스 로직을 담당하는 Container와 화면 표시를 담당하는 Presenter를 분리하면, 각각을 독립적으로 개발하고 테스트할 수 있습니다.
개요
간단히 말해서, Container-Presenter 패턴은 "똑똑한 컴포넌트"와 "멍청한 컴포넌트"를 분리하는 설계 방법입니다. Container는 데이터와 로직을 다루고, Presenter는 받은 props를 화면에 표시하기만 합니다.
실무에서 이 패턴은 매우 유용합니다. 디자이너가 UI를 변경하고 싶을 때 Presenter만 수정하면 되고, 백엔드 API가 변경되어도 Container만 수정하면 됩니다.
예를 들어, 제품 목록 페이지에서 ProductListContainer는 API 호출과 필터링을 담당하고, ProductListPresenter는 받은 데이터를 카드 형태로 예쁘게 보여주기만 합니다. 기존에는 하나의 컴포넌트에서 useState, useEffect, API 호출, 그리고 복잡한 JSX를 모두 처리했다면, 이제는 Container가 로직을 처리하고 Presenter에게 필요한 데이터만 전달하는 방식으로 분리할 수 있습니다.
핵심 특징은 관심사의 분리, UI의 재사용성, 그리고 테스트 용이성입니다. Presenter는 순수 함수처럼 동작하므로 같은 props를 주면 항상 같은 결과를 보여줍니다.
이는 Storybook으로 다양한 시나리오를 쉽게 테스트할 수 있게 해줍니다.
코드 예제
// Container: 비즈니스 로직 담당
function PostListContainer() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// API 호출 및 데이터 처리
fetchPosts()
.then(data => setPosts(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, []);
// Presenter에게 필요한 데이터만 전달
return <PostListPresenter posts={posts} loading={loading} error={error} />;
}
// Presenter: UI 렌더링만 담당
function PostListPresenter({ posts, loading, error }) {
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div className="post-list">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
설명
이것이 하는 일: Container-Presenter 패턴은 하나의 기능을 두 개의 컴포넌트로 나누어, 비즈니스 로직과 UI를 완전히 분리합니다. 위 예제에서는 게시글 목록 기능을 Container와 Presenter로 나누었습니다.
첫 번째로, PostListContainer는 모든 상태 관리와 데이터 처리를 담당합니다. useEffect로 API를 호출하고, loading과 error 상태를 관리하며, 받아온 데이터를 가공합니다.
이 컴포넌트는 "어떻게 데이터를 가져오고 처리할 것인가"에만 집중합니다. 그 다음으로, PostListPresenter는 Container로부터 받은 props를 화면에 표시하기만 합니다.
loading이 true면 스피너를 보여주고, error가 있으면 에러 메시지를 보여주고, 그렇지 않으면 게시글 목록을 렌더링합니다. 이 컴포넌트는 상태를 가지지 않으며, 같은 props를 받으면 항상 같은 결과를 렌더링하는 순수 컴포넌트입니다.
마지막으로, Container는 Presenter를 렌더링하면서 필요한 데이터를 props로 전달합니다. 이 구조 덕분에 Presenter를 Storybook에서 쉽게 테스트할 수 있습니다.
loading={true}를 전달하면 로딩 화면을 볼 수 있고, error를 전달하면 에러 화면을 볼 수 있습니다. 여러분이 이 패턴을 사용하면 UI 디자인 변경이 필요할 때 Presenter만 수정하면 되고, API 스펙이 변경되어도 Container만 수정하면 됩니다.
또한 같은 Presenter를 다른 Container와 조합하여 재사용할 수 있습니다. 예를 들어, ProductListPresenter를 FeaturedProductsContainer와 SearchResultsContainer에서 모두 사용할 수 있습니다.
실전 팁
💡 Presenter는 절대 상태를 가지면 안 됩니다. useState나 useEffect를 사용하지 마세요. 모든 데이터는 props로 받아야 합니다.
💡 Container의 이름은 ~Container, Presenter의 이름은 ~Presenter 또는 ~View로 명확하게 구분하세요.
💡 Presenter는 재사용을 고려해 components/ui 폴더에, Container는 features 또는 pages 폴더에 두는 것이 좋습니다.
💡 이벤트 핸들러도 Container에서 정의하고 Presenter에게 props로 전달하세요. Presenter는 단순히 onClick={onItemClick}처럼 호출만 해야 합니다.
💡 최근에는 Custom Hook으로 로직을 분리하는 방법도 많이 사용됩니다. usePostList() Hook을 만들어 Container 대신 사용할 수 있습니다.
3. Compound_컴포넌트_패턴
시작하며
여러분이 드롭다운 메뉴 컴포넌트를 만들 때, props가 너무 많아져서 어떤 props가 무엇을 하는지 헷갈린 적 있나요? showIcon, iconPosition, hasArrow, arrowColor, itemPadding 같은 props들이 계속 늘어나면서 컴포넌트 사용법이 너무 복잡해집니다.
이런 문제는 특히 재사용 가능한 UI 라이브러리를 만들 때 심각합니다. 모든 경우를 props로 처리하려다 보면 props가 수십 개가 되고, 사용자는 어떤 조합이 유효한지 알기 어렵습니다.
또한 새로운 기능을 추가할 때마다 기존 props와의 충돌을 걱정해야 합니다. 바로 이럴 때 필요한 것이 Compound 컴포넌트 패턴입니다.
여러 하위 컴포넌트를 조합하여 사용하는 방식으로, 복잡한 props 대신 선언적이고 유연한 API를 제공할 수 있습니다.
개요
간단히 말해서, Compound 컴포넌트는 여러 개의 작은 컴포넌트를 조합하여 하나의 완전한 기능을 만드는 패턴입니다. HTML의 select와 option처럼, 각 부분을 독립적으로 제어할 수 있습니다.
실무에서 이 패턴은 복잡한 UI 컴포넌트를 만들 때 매우 효과적입니다. Dropdown, Tabs, Modal 같은 컴포넌트는 여러 부분으로 구성되어 있고, 각 부분의 순서나 스타일을 자유롭게 커스터마이징해야 하는 경우가 많습니다.
예를 들어, Select 컴포넌트를 만들 때 Select.Trigger, Select.List, Select.Item으로 나누면 사용자가 원하는 구조로 자유롭게 조합할 수 있습니다. 기존에는 <Dropdown title="메뉴" items={items} showIcon={true} iconPosition="left" />처럼 모든 것을 props로 전달했다면, 이제는 컴포넌트를 조합하여 <Dropdown><Dropdown.Trigger>메뉴</Dropdown.Trigger><Dropdown.List>...</Dropdown.List></Dropdown>처럼 명확하게 표현할 수 있습니다.
핵심 특징은 유연성, 명확한 구조, 그리고 확장성입니다. Context API를 사용하여 부모와 자식 간에 상태를 공유하므로, 각 하위 컴포넌트는 필요한 정보에 자동으로 접근할 수 있습니다.
이는 props drilling 없이도 깊은 계층 구조를 만들 수 있게 해줍니다.
코드 예제
// Compound 컴포넌트 구현
const TabContext = createContext();
function Tabs({ children, defaultValue }) {
const [activeTab, setActiveTab] = useState(defaultValue);
// Context로 상태 공유
return (
<TabContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabContext.Provider>
);
}
function TabList({ children }) {
return <div className="tab-list">{children}</div>;
}
function Tab({ value, children }) {
const { activeTab, setActiveTab } = useContext(TabContext);
return (
<button
className={activeTab === value ? 'active' : ''}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
function TabPanel({ value, children }) {
const { activeTab } = useContext(TabContext);
return activeTab === value ? <div>{children}</div> : null;
}
// 하위 컴포넌트를 부모에 연결
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// 사용 예시
function App() {
return (
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Tab value="profile">프로필</Tabs.Tab>
<Tabs.Tab value="settings">설정</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile">프로필 내용</Tabs.Panel>
<Tabs.Panel value="settings">설정 내용</Tabs.Panel>
</Tabs>
);
}
설명
이것이 하는 일: Compound 컴포넌트 패턴은 하나의 큰 컴포넌트를 여러 개의 작은 하위 컴포넌트로 나누고, Context API로 상태를 공유하여 함께 작동하도록 만듭니다. 위 예제는 Tab 컴포넌트를 구현한 것입니다.
첫 번째로, Tabs 부모 컴포넌트는 activeTab 상태를 관리하고 TabContext.Provider로 하위 컴포넌트들에게 공유합니다. defaultValue로 초기 활성 탭을 설정할 수 있습니다.
이렇게 하면 모든 하위 컴포넌트가 현재 어떤 탭이 활성화되어 있는지 알 수 있습니다. 그 다음으로, TabList, Tab, TabPanel 같은 하위 컴포넌트들은 useContext(TabContext)로 부모의 상태에 접근합니다.
Tab 컴포넌트는 클릭 시 setActiveTab을 호출하여 활성 탭을 변경하고, TabPanel은 activeTab이 자신의 value와 일치할 때만 내용을 보여줍니다. 각 컴포넌트는 props로 상태를 전달받지 않아도 필요한 정보에 접근할 수 있습니다.
마지막으로, Tabs.List = TabList처럼 하위 컴포넌트를 부모에 연결하면, 사용자는 <Tabs.Tab>처럼 점 표기법으로 접근할 수 있습니다. 이는 관련된 컴포넌트들이 하나의 네임스페이스에 묶여 있다는 것을 명확하게 보여줍니다.
여러분이 이 패턴을 사용하면 컴포넌트의 구조를 자유롭게 변경할 수 있습니다. TabList의 위치를 바꾸거나, Tab의 순서를 변경하거나, 심지어 TabList 없이 Tab만 사용할 수도 있습니다.
또한 새로운 하위 컴포넌트를 추가할 때 기존 API를 변경할 필요가 없어, 하위 호환성을 유지하면서 기능을 확장할 수 있습니다. Radix UI, Headless UI 같은 유명한 라이브러리들이 모두 이 패턴을 사용합니다.
실전 팁
💡 하위 컴포넌트의 이름은 부모 컴포넌트 이름으로 시작하도록 하세요. Tabs.List, Tabs.Tab처럼 명확한 관계를 보여줍니다.
💡 Context에는 상태와 상태 변경 함수만 넣고, UI 관련 로직은 각 하위 컴포넌트에 두세요. Context는 통신 수단일 뿐입니다.
💡 TypeScript를 사용한다면 각 하위 컴포넌트의 타입을 명확히 정의하여 잘못된 조합을 방지하세요.
💡 성능 최적화를 위해 Context 값을 useMemo로 메모이제이션하고, 자주 변경되는 값과 그렇지 않은 값을 별도 Context로 분리하세요.
💡 Radix UI나 Headless UI의 소스코드를 참고하면 실제 프로덕션 레벨의 Compound 컴포넌트 구현을 배울 수 있습니다.
4. Custom_Hooks_활용
시작하며
여러분이 여러 컴포넌트에서 동일한 API 호출 로직을 사용할 때, 같은 코드를 복사-붙여넣기하다가 한 곳을 수정하면 다른 곳도 모두 수정해야 하는 번거로움을 겪어본 적 있나요? 사용자 정보를 가져오는 로직이 5개의 컴포넌트에 중복되어 있다면 유지보수가 악몽이 됩니다.
이런 문제는 실무에서 매우 흔합니다. 특히 인증, 데이터 fetching, 폼 관리, 로컬 스토리지 접근 같은 공통 로직들이 여기저기 흩어져 있으면, 버그를 고칠 때마다 모든 곳을 찾아다녀야 하고, 한 곳이라도 빠뜨리면 일관성이 깨집니다.
바로 이럴 때 필요한 것이 Custom Hooks입니다. 비즈니스 로직을 재사용 가능한 Hook으로 만들면, 여러 컴포넌트에서 같은 로직을 쉽게 공유할 수 있고, 한 곳만 수정하면 모든 곳에 적용됩니다.
개요
간단히 말해서, Custom Hook은 React의 내장 Hook들(useState, useEffect 등)을 조합하여 만든 재사용 가능한 함수입니다. "use"로 시작하는 이름을 가지며, 컴포넌트의 로직을 독립적으로 추출할 수 있습니다.
실무에서 Custom Hook은 필수적입니다. 같은 로직을 여러 컴포넌트에서 사용해야 할 때, Hook으로 만들어두면 코드 중복을 없애고 일관성을 유지할 수 있습니다.
예를 들어, 사용자 인증 상태를 확인하는 로직을 useAuth() Hook으로 만들어두면, 모든 페이지에서 const { user, login, logout } = useAuth()처럼 간단하게 사용할 수 있습니다. 기존에는 Higher-Order Component나 Render Props 패턴으로 로직을 재사용했다면, 이제는 더 간단하고 직관적인 Hook으로 같은 목적을 달성할 수 있습니다.
Hook은 컴포넌트 계층 구조를 복잡하게 만들지 않습니다. 핵심 특징은 로직의 재사용성, 테스트 용이성, 그리고 관심사의 분리입니다.
Hook은 컴포넌트와 독립적으로 테스트할 수 있고, 같은 Hook을 다른 프로젝트에서도 사용할 수 있습니다. 또한 비즈니스 로직을 UI에서 완전히 분리할 수 있어, Container-Presenter 패턴보다 더 간결한 코드를 작성할 수 있습니다.
코드 예제
// Custom Hook: API 호출 로직 재사용
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 데이터 fetching 로직
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
.catch(err => setError(err))
.finally(() => setLoading(false));
}, [userId]);
// 필요한 상태와 함수를 반환
return { user, loading, error };
}
// Custom Hook: 로컬 스토리지 동기화
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
// 값이 변경될 때마다 로컬 스토리지에 저장
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// 컴포넌트에서 사용
function UserProfile({ userId }) {
const { user, loading, error } = useUser(userId);
const [theme, setTheme] = useLocalStorage('theme', 'light');
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div className={theme}>{user.name}</div>;
}
설명
이것이 하는 일: Custom Hook은 React의 내장 Hook들을 조합하여 특정 기능을 캡슐화하고, 여러 컴포넌트에서 재사용할 수 있도록 만듭니다. 위 예제에서는 useUser와 useLocalStorage 두 개의 Custom Hook을 만들었습니다.
첫 번째로, useUser Hook은 사용자 데이터를 가져오는 모든 로직을 담고 있습니다. useState로 user, loading, error 상태를 관리하고, useEffect로 API를 호출합니다.
userId가 변경될 때마다 새로운 데이터를 가져오도록 의존성 배열에 userId를 넣었습니다. 이 Hook을 사용하는 컴포넌트는 복잡한 데이터 fetching 로직을 몰라도 되고, 단순히 필요한 값들만 받아서 사용하면 됩니다.
그 다음으로, useLocalStorage Hook은 상태를 로컬 스토리지와 자동으로 동기화합니다. 초기 렌더링 시 로컬 스토리지에서 값을 읽어오고, 값이 변경될 때마다 useEffect로 로컬 스토리지에 저장합니다.
useState의 초기값으로 함수를 전달하여 첫 렌더링 시에만 로컬 스토리지를 읽도록 최적화했습니다. 마지막으로, UserProfile 컴포넌트는 이 두 Hook을 사용하여 매우 간결해졌습니다.
const { user, loading, error } = useUser(userId)처럼 필요한 로직을 한 줄로 가져올 수 있습니다. 만약 useUser의 내부 구현을 변경하더라도(예: React Query로 마이그레이션), 컴포넌트 코드는 전혀 수정할 필요가 없습니다.
여러분이 Custom Hook을 사용하면 같은 로직을 여러 컴포넌트에서 일관되게 사용할 수 있고, 로직만 독립적으로 테스트할 수 있습니다. 또한 useFetch, useDebounce, useIntersectionObserver 같은 범용 Hook을 만들어 라이브러리처럼 재사용할 수 있습니다.
React 커뮤니티에는 이미 수많은 고품질 Custom Hook 라이브러리가 있으니 적극 활용하세요.
실전 팁
💡 Custom Hook 이름은 반드시 "use"로 시작해야 합니다. 이래야 React가 Hook의 규칙을 검사할 수 있습니다.
💡 Hook은 컴포넌트 최상위에서만 호출해야 합니다. 조건문이나 반복문 안에서 호출하지 마세요.
💡 복잡한 Hook은 여러 개의 작은 Hook으로 나누세요. useAuth는 내부적으로 useUser와 usePermissions를 조합할 수 있습니다.
💡 @testing-library/react-hooks를 사용하면 컴포넌트 없이 Hook만 독립적으로 테스트할 수 있습니다.
💡 usehooks-ts, ahooks 같은 라이브러리에서 검증된 Custom Hook들을 참고하여 배우세요. 직접 만들기 전에 이미 있는지 확인하는 것도 좋습니다.
5. Props_Drilling_해결
시작하며
여러분이 최상위 컴포넌트에 있는 사용자 정보를 5단계 아래의 컴포넌트에서 사용해야 할 때, 중간에 있는 모든 컴포넌트에 props로 전달하다가 코드가 너무 복잡해진 적 있나요? App → Layout → Page → Section → Card → Button 순서로 user props를 전달해야 한다면, 중간 컴포넌트들은 user를 사용하지도 않으면서 단지 전달만 하는 역할을 합니다.
이런 문제는 Props Drilling이라 불리며, 실무에서 가장 흔한 문제 중 하나입니다. 컴포넌트 계층이 깊어질수록 props 전달이 복잡해지고, 중간 컴포넌트의 시그니처가 계속 바뀌면서 유지보수가 어려워집니다.
또한 어떤 props가 어디서 사용되는지 추적하기도 힘듭니다. 바로 이럴 때 필요한 것이 Context API입니다.
Context를 사용하면 중간 컴포넌트를 거치지 않고도 필요한 곳에서 직접 데이터에 접근할 수 있습니다.
개요
간단히 말해서, Context API는 컴포넌트 트리 전체에 데이터를 제공하는 React의 내장 기능입니다. Provider로 데이터를 제공하고, 필요한 곳에서 useContext로 가져와 사용합니다.
실무에서 Context는 전역 상태 관리에 필수적입니다. 사용자 인증 정보, 테마 설정, 언어 설정처럼 많은 컴포넌트에서 필요한 데이터는 Context로 관리하는 것이 효율적입니다.
예를 들어, ThemeContext를 만들어두면 앱의 어떤 컴포넌트에서든 const { theme, toggleTheme } = useContext(ThemeContext)로 테마에 접근할 수 있습니다. 기존에는 props를 여러 단계를 거쳐 전달했다면, 이제는 Provider로 감싸고 필요한 곳에서 useContext로 바로 가져올 수 있습니다.
중간 컴포넌트들은 이 데이터의 존재조차 알 필요가 없어집니다. 핵심 특징은 Props Drilling 제거, 전역 상태 관리, 그리고 선택적 구독입니다.
컴포넌트는 필요한 Context만 구독하므로, 관련 없는 상태 변경에는 리렌더링되지 않습니다. 또한 여러 Provider를 조합하여 복잡한 상태 구조를 만들 수 있습니다.
코드 예제
// Context 생성
const UserContext = createContext();
// Provider 컴포넌트
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const login = (userData) => setUser(userData);
const logout = () => setUser(null);
// value 객체를 메모이제이션하여 불필요한 리렌더링 방지
const value = useMemo(
() => ({ user, login, logout }),
[user]
);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
// Custom Hook으로 사용 편의성 향상
function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
// 최상위에서 Provider로 감싸기
function App() {
return (
<UserProvider>
<Layout>
<Page />
</Layout>
</UserProvider>
);
}
// 깊은 곳에서 직접 접근
function UserButton() {
// Props drilling 없이 바로 접근
const { user, logout } = useUser();
return user ? (
<button onClick={logout}>{user.name} 로그아웃</button>
) : (
<button>로그인</button>
);
}
설명
이것이 하는 일: Context API는 컴포넌트 트리의 어느 깊이에서든 데이터에 접근할 수 있게 해주는 메커니즘입니다. 위 예제에서는 사용자 인증 정보를 관리하는 UserContext를 만들었습니다.
첫 번째로, createContext()로 Context를 생성하고, UserProvider 컴포넌트를 만들어 user 상태와 login, logout 함수를 관리합니다. Provider의 value에 이 값들을 담아 하위 컴포넌트들에게 제공합니다.
useMemo로 value 객체를 메모이제이션하여, user가 변경될 때만 새 객체를 생성하도록 최적화했습니다. 이렇게 하지 않으면 매 렌더링마다 새 객체가 생성되어 모든 소비자 컴포넌트가 불필요하게 리렌더링됩니다.
그 다음으로, useUser라는 Custom Hook을 만들어 Context 사용을 더 편리하게 만들었습니다. 이 Hook은 useContext(UserContext)를 호출하고, Provider 밖에서 사용되면 에러를 던져 잘못된 사용을 방지합니다.
이렇게 하면 컴포넌트에서 useContext(UserContext) 대신 useUser()로 더 간결하게 사용할 수 있습니다. 마지막으로, App 컴포넌트에서 UserProvider로 전체 앱을 감싸면, 그 아래 어떤 컴포넌트에서든 useUser()로 사용자 정보에 접근할 수 있습니다.
UserButton 컴포넌트는 트리의 깊은 곳에 있지만 props 없이도 user와 logout에 접근합니다. Layout이나 Page 컴포넌트는 user를 사용하지 않으므로 props로 전달받을 필요가 없습니다.
여러분이 Context를 사용하면 컴포넌트 간의 결합도가 낮아지고, 중간 컴포넌트들이 불필요한 props로 오염되지 않습니다. 또한 ThemeContext, LanguageContext, AuthContext 같은 여러 Context를 조합하여 복잡한 전역 상태를 관리할 수 있습니다.
다만 Context는 변경이 잦은 상태에는 적합하지 않으므로, 그런 경우에는 Redux나 Zustand 같은 상태 관리 라이브러리를 고려하세요.
실전 팁
💡 Context value는 항상 useMemo로 메모이제이션하세요. 그렇지 않으면 Provider가 리렌더링될 때마다 모든 소비자가 리렌더링됩니다.
💡 하나의 Context에 너무 많은 상태를 넣지 마세요. 자주 변경되는 상태와 그렇지 않은 상태를 분리하여 별도 Context로 만드세요.
💡 Custom Hook (예: useUser)을 만들어 Context 사용을 캡슐화하면, 나중에 구현을 변경할 때 컴포넌트 코드를 수정하지 않아도 됩니다.
💡 TypeScript를 사용한다면 Context의 타입을 명확히 정의하여 잘못된 사용을 컴파일 타임에 잡을 수 있습니다.
💡 전역 상태가 복잡해지면 Redux Toolkit이나 Zustand를 고려하세요. Context는 간단한 전역 상태에 적합합니다.
6. 조건부_렌더링_최적화
시작하며
여러분이 대시보드 페이지를 만들 때, 로딩 중일 때, 에러가 발생했을 때, 데이터가 없을 때, 데이터가 있을 때를 모두 처리하다 보니 if문과 삼항 연산자가 여러 개 중첩되어 코드를 읽기 어려워진 적 있나요? 특히 여러 조건을 조합해야 할 때 JSX가 복잡해집니다.
이런 문제는 실무에서 매우 흔합니다. 복잡한 조건부 렌더링은 가독성을 떨어뜨리고, 버그를 만들기 쉬우며, 성능 문제도 발생시킬 수 있습니다.
예를 들어, 조건에 따라 컴포넌트를 렌더링하지 않아야 하는데 실수로 렌더링하면 불필요한 API 호출이나 계산이 발생할 수 있습니다. 바로 이럴 때 필요한 것이 조건부 렌더링 최적화 패턴입니다.
Early return, 논리 연산자 활용, 조건부 컴포넌트 분리 같은 기법으로 깔끔하고 성능 좋은 코드를 작성할 수 있습니다.
개요
간단히 말해서, 조건부 렌더링 최적화는 복잡한 조건 로직을 명확하고 효율적으로 처리하는 방법입니다. Early return으로 특수 케이스를 먼저 처리하고, 논리 연산자로 간단한 조건을 표현하며, 복잡한 경우는 별도 컴포넌트로 분리합니다.
실무에서 이런 최적화는 코드 품질과 성능 모두에 중요합니다. 특히 데이터 fetching이 있는 페이지에서는 로딩, 에러, 빈 데이터, 정상 데이터 상태를 명확히 구분해야 합니다.
예를 들어, 게시글 목록 페이지에서 로딩 중이면 스피너를 보여주고, 에러면 에러 메시지를, 데이터가 없으면 빈 상태 UI를, 데이터가 있으면 목록을 보여줘야 합니다. 기존에는 삼항 연산자를 여러 번 중첩하여 {loading ?
<Spinner /> : error ? <Error /> : data.length === 0 ?
<Empty /> : <List />}처럼 작성했다면, 이제는 Early return으로 각 케이스를 명확히 분리할 수 있습니다. 핵심 특징은 가독성 향상, 성능 최적화, 그리고 유지보수성입니다.
조건을 명확히 분리하면 각 케이스를 독립적으로 수정할 수 있고, 불필요한 렌더링을 방지하여 성능을 개선할 수 있습니다.
코드 예제
// ❌ 나쁜 예: 중첩된 삼항 연산자
function ProductList({ loading, error, products }) {
return (
<div>
{loading ? (
<Spinner />
) : error ? (
<ErrorMessage error={error} />
) : products.length === 0 ? (
<EmptyState />
) : (
<div>
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</div>
);
}
// ✅ 좋은 예: Early return 활용
function ProductList({ loading, error, products }) {
// 특수 케이스를 먼저 처리
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (products.length === 0) return <EmptyState message="상품이 없습니다" />;
// 정상 케이스는 깔끔하게
return (
<div className="product-grid">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
// 논리 연산자 활용
function UserGreeting({ user }) {
return (
<div>
{/* user가 있을 때만 렌더링 */}
{user && <h1>안녕하세요, {user.name}님!</h1>}
{/* premium이 true일 때만 렌더링 */}
{user?.isPremium && <PremiumBadge />}
{/* 값이 있으면 표시, 없으면 기본값 */}
<p>포인트: {user?.points || 0}</p>
</div>
);
}
설명
이것이 하는 일: 조건부 렌더링 최적화는 복잡한 조건 로직을 읽기 쉽고 효율적으로 만듭니다. 위 예제에서는 Early return 패턴과 논리 연산자 활용을 보여줍니다.
첫 번째로, 나쁜 예에서는 삼항 연산자가 4단계로 중첩되어 있습니다. loading → error → empty → normal 순서로 확인하는데, 코드를 읽을 때 어디가 어떤 케이스인지 파악하기 어렵습니다.
또한 나중에 새로운 조건(예: unauthorized)을 추가하려면 중첩이 더 깊어져 가독성이 더 나빠집니다. 그 다음으로, 좋은 예에서는 Early return을 사용하여 각 특수 케이스를 먼저 처리합니다.
if (loading) return <Spinner />처럼 한 줄로 명확하게 표현되어, 어떤 조건에서 무엇을 보여주는지 즉시 알 수 있습니다. 모든 특수 케이스를 처리한 후 마지막에 정상 케이스를 렌더링하므로, 정상 케이스 코드에는 조건문이 전혀 없어 매우 깔끔합니다.
마지막으로, UserGreeting 예제는 논리 연산자를 활용한 간단한 조건부 렌더링을 보여줍니다. {user && ...}는 user가 존재할 때만 뒤의 JSX를 렌더링하고, {user?.isPremium && ...}는 optional chaining으로 안전하게 속성에 접근합니다.
{user?.points || 0}은 값이 없으면 기본값 0을 표시합니다. 여러분이 이 패턴을 사용하면 코드를 읽는 사람이 각 조건의 의미를 빠르게 파악할 수 있습니다.
또한 Early return으로 특수 케이스를 먼저 처리하면, 그 아래 코드는 정상 상태만 고려하면 되므로 로직이 단순해집니다. 성능 측면에서도, 조건이 맞지 않으면 즉시 return하여 불필요한 계산을 하지 않습니다.
실전 팁
💡 복잡한 조건은 변수로 추출하세요. const hasData = !loading && !error && data.length > 0처럼 의미 있는 이름을 주면 가독성이 향상됩니다.
💡 && 연산자 사용 시 주의: 0이나 빈 문자열은 falsy이므로 {count && <span>{count}</span>}는 count가 0일 때 "0"을 렌더링합니다. {count > 0 && ...}처럼 명시적으로 비교하세요.
💡 여러 컴포넌트가 같은 로딩/에러 패턴을 사용한다면, withLoadingState 같은 HOC나 Suspense/ErrorBoundary를 고려하세요.
💡 null과 undefined는 렌더링되지 않지만, false, 0, ""는 다르게 동작합니다. {false}는 아무것도 렌더링하지 않지만, {0}은 "0"을 렌더링합니다.
💡 복잡한 조건부 렌더링은 별도 컴포넌트로 분리하세요. <ConditionalContent loading={loading} error={error} data={data} />처럼 캡슐화하면 재사용도 가능합니다.
7. Error_Boundary_패턴
시작하며
여러분이 프로덕션 환경에서 예상치 못한 에러로 인해 전체 앱이 하얀 화면만 보여주고 멈춰버린 경험이 있나요? 사용자는 무슨 일이 일어났는지 모르고, 개발자도 정확히 어디서 에러가 발생했는지 파악하기 어렵습니다.
이런 문제는 React 애플리케이션에서 치명적입니다. 한 컴포넌트의 에러가 전체 앱을 무너뜨리면, 사용자는 아무것도 할 수 없게 됩니다.
특히 외부 API 호출, 서드파티 라이브러리, 잘못된 데이터 처리 같은 곳에서 예기치 않은 에러가 발생할 수 있습니다. 바로 이럴 때 필요한 것이 Error Boundary입니다.
컴포넌트 트리의 일부에서 발생한 에러를 잡아내고, 전체 앱이 깨지는 대신 폴백 UI를 보여줄 수 있습니다.
개요
간단히 말해서, Error Boundary는 하위 컴포넌트에서 발생한 JavaScript 에러를 잡아내고, 에러 로그를 기록하며, 폴백 UI를 보여주는 React 컴포넌트입니다. componentDidCatch와 getDerivedStateFromError 생명주기 메서드를 사용하는 클래스 컴포넌트입니다.
실무에서 Error Boundary는 안정적인 사용자 경험을 위해 필수적입니다. 특히 대규모 애플리케이션에서는 다양한 팀이 만든 컴포넌트들이 조합되므로, 한 부분의 에러가 전체에 영향을 주지 않도록 격리하는 것이 중요합니다.
예를 들어, 대시보드에서 위젯 하나가 에러가 나더라도 다른 위젯들은 정상적으로 작동해야 합니다. 기존에는 try-catch로 개별 함수의 에러만 처리할 수 있었다면, 이제는 Error Boundary로 컴포넌트 트리 전체의 렌더링 에러를 잡을 수 있습니다.
또한 Sentry 같은 에러 모니터링 서비스와 통합하여 프로덕션 에러를 추적할 수 있습니다. 핵심 특징은 에러 격리, 우아한 에러 처리, 그리고 에러 로깅입니다.
Error Boundary로 감싼 부분만 폴백 UI로 대체되고, 나머지 앱은 정상적으로 작동합니다. 또한 에러 정보를 수집하여 모니터링 서비스로 전송할 수 있습니다.
코드 예제
// Error Boundary 클래스 컴포넌트
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
// 에러 발생 시 state 업데이트
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
// 에러 로깅 및 모니터링
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
// Sentry 같은 서비스로 전송
// logErrorToService(error, errorInfo);
}
// 에러 상태 리셋
resetError = () => {
this.setState({ hasError: false, error: null });
}
render() {
if (this.state.hasError) {
// 폴백 UI
return (
<div className="error-fallback">
<h2>문제가 발생했습니다</h2>
<p>{this.state.error?.message}</p>
<button onClick={this.resetError}>다시 시도</button>
</div>
);
}
return this.props.children;
}
}
// 사용 예시: 각 섹션을 별도로 보호
function Dashboard() {
return (
<div>
<ErrorBoundary>
<UserProfile />
</ErrorBoundary>
<ErrorBoundary>
<RecentActivity />
</ErrorBoundary>
<ErrorBoundary>
<Analytics />
</ErrorBoundary>
</div>
);
}
설명
이것이 하는 일: Error Boundary는 React 컴포넌트 트리에서 발생하는 JavaScript 에러를 잡아내고, 앱이 완전히 멈추는 대신 폴백 UI를 보여줍니다. 위 예제는 기본적인 Error Boundary 구현과 사용법을 보여줍니다.
첫 번째로, ErrorBoundary 클래스 컴포넌트는 hasError와 error 상태를 가집니다. getDerivedStateFromError는 하위 컴포넌트에서 에러가 발생하면 자동으로 호출되어 hasError를 true로 설정합니다.
이 메서드는 렌더링 단계에서 호출되므로 부수 효과(side effect)를 만들면 안 됩니다. 그 다음으로, componentDidCatch는 에러가 발생한 후 호출되어 에러 로깅을 담당합니다.
여기서 console.error로 에러를 기록하거나, Sentry, LogRocket 같은 에러 모니터링 서비스로 전송할 수 있습니다. errorInfo에는 어떤 컴포넌트에서 에러가 발생했는지 컴포넌트 스택 정보가 포함되어 있어 디버깅에 유용합니다.
마지막으로, render 메서드는 hasError 상태에 따라 폴백 UI나 정상 children을 렌더링합니다. 폴백 UI에는 "다시 시도" 버튼을 제공하여 사용자가 에러를 해결하고 다시 시도할 수 있도록 합니다.
resetError 함수는 상태를 초기화하여 컴포넌트를 다시 렌더링합니다. 여러분이 Error Boundary를 사용하면 각 기능 영역을 독립적으로 보호할 수 있습니다.
Dashboard 예제에서는 UserProfile, RecentActivity, Analytics를 각각 별도의 Error Boundary로 감싸, 한 섹션이 에러가 나도 다른 섹션은 정상적으로 작동합니다. 또한 프로덕션 환경에서 발생하는 에러를 자동으로 수집하여, 사용자가 신고하기 전에 문제를 파악하고 수정할 수 있습니다.
실전 팁
💡 Error Boundary는 이벤트 핸들러의 에러는 잡지 못합니다. onClick 안의 에러는 try-catch로 직접 처리해야 합니다.
💡 react-error-boundary 라이브러리를 사용하면 함수 컴포넌트처럼 선언적으로 사용할 수 있습니다: <ErrorBoundary FallbackComponent={ErrorFallback}>
💡 개발 환경에서는 에러가 발생하면 에러 오버레이가 표시되지만, 프로덕션에서는 폴백 UI만 보입니다. 실제 동작을 확인하려면 빌드 버전을 테스트하세요.
💡 에러 경계를 너무 많이 만들면 관리가 어려워집니다. 페이지 레벨, 주요 섹션 레벨에만 배치하고, 작은 컴포넌트까지 감쌀 필요는 없습니다.
💡 Sentry를 연동할 때는 Sentry.captureException(error)를 componentDidCatch에서 호출하여 자동으로 에러를 수집하고, 소스맵을 업로드하면 실제 소스 코드 위치를 확인할 수 있습니다.