프로젝트 규모를 키우는 프롭 드릴링 (Prop Drilling) 🐛
Summary
불필요하게 많은 컴포넌트 계층을 통해 데이터를 전달하는 프롭 드릴링은 코드 가독성, 유지보수성, 재사용성을 심각하게 저해하고 컴포넌트 간 강한 결합도를 유발합니다. 대신 React Context API나 전역 상태 관리 라이브러리, 또는 컴포넌트 합성 패턴을 활용하여 깨끗하고 효율적인 데이터 흐름을 만들어야 합니다.
Why Wrong?
프롭 드릴링은 부모 컴포넌트에서 멀리 떨어진 자식 컴포넌트로 데이터를 전달하기 위해, 중간에 위치한 여러 컴포넌트들이 실제로는 사용하지 않는 props를 단순히 전달만 하는 현상을 말합니다.
이 안티패턴은 다음과 같은 심각한 문제를 야기합니다:
- 유지보수성 저하: 데이터의 출처와 최종 소비처를 파악하기 어려워집니다. 중간 컴포넌트들이 불필요한
props
를 갖게 되어, 데이터 흐름이 복잡하고 불투명해집니다.props
의 이름이나 형태가 변경되면, 데이터를 사용하지 않는 중간 컴포넌트들까지 모두 수정해야 하는 연쇄적인 변경이 발생합니다. - 강한 결합도: 중간 컴포넌트들이 특정 데이터 구조에 의존하게 되어 컴포넌트 간의 결합도가 높아집니다. 이는 컴포넌트의 재사용성을 떨어뜨리고, 독립적인 테스트를 어렵게 만듭니다.
- 코드 가독성 저하: 컴포넌트의
props
목록이 불필요하게 길어지고, 실제 컴포넌트의 역할과 무관한props
가 많아져 코드를 이해하기 어렵게 만듭니다.
프롭 드릴링은 직접적인 렌더링 성능 저하를 유발하지는 않지만 (불필요한 리렌더링은 React.memo
등으로 해결 가능), 개발 속도를 현저히 떨어뜨리고 버그 발생률을 높여 전반적인 개발 비용을 증가시키는 숨겨진 주범입니다.
How to Fix?
프롭 드릴링을 해결하고 깨끗한 데이터 흐름을 구축하기 위한 몇 가지 효과적인 방법이 있습니다.
-
React Context API 사용: 애플리케이션의 전역적인 상태(테마, 인증 정보 등)나 여러 컴포넌트에서 공유되는 데이터를 관리할 때 유용합니다.
Context
를 사용하면Provider
에서 제공된 데이터를 자식 컴포넌트들이 계층에 상관없이 직접Consumer
(혹은useContext
훅)를 통해 접근할 수 있습니다. 이는 중간 컴포넌트가props
를 전달할 필요 없게 만들어줍니다.// 1. Context 생성 const UserContext = React.createContext(null); // 2. Provider로 데이터 제공 function App() { const user = { name: 'Alice', role: 'admin' }; return ( <UserContext.Provider value={user}> <Dashboard /> </UserContext.Provider> ); } // 3. Consumer에서 데이터 사용 (중간 컴포넌트 생략 가능) function Dashboard() { return ( <div> <h1>대시보드</h1> <Sidebar /> <MainContent /> </div> ); } function Sidebar() { return <UserProfile />; // UserProfile이 UserContext를 사용 } function UserProfile() { const user = React.useContext(UserContext); return <div>환영합니다, {user.name}님! ({user.role})</div>; }
-
전역 상태 관리 라이브러리 활용 (Redux, Zustand, Jotai, Recoil 등): 애플리케이션의 상태가 복잡하거나 규모가 큰 경우, 전용 상태 관리 라이브러리를 사용하는 것이 효과적입니다. 이들은 중앙 집중식 상태 저장소를 제공하여 어떤 컴포넌트든 필요한 상태에 직접 접근하고 업데이트할 수 있도록 합니다. 이는
Context
와 유사하게 프롭 드릴링을 방지하는 강력한 방법입니다. -
컴포넌트 합성(Composition) 패턴 사용 (
children
Prop): 중간 컴포넌트가 단순히props
를 전달하는 역할만 한다면,children
prop
을 활용하여 자식 컴포넌트를 직접 전달하는 방식으로 구조를 변경할 수 있습니다. 이는 중간 컴포넌트가 데이터에 대해 '알' 필요 없이 단순히 렌더링 역할만 하도록 만듭니다.// Before (prop drilling) function Layout({ user, children }) { // user prop을 받아서 자식에게 전달 return ( <div> <Header user={user} /> {children} </div> ); } // After (composition - Layout이 user prop을 직접 받지 않고, UserContext를 Header에서 직접 사용) function Layout({ children }) { return ( <div> <Header /> {/* Header가 Context에서 user를 가져옴 */} {children} </div> ); } function App() { const user = { name: 'Charlie' }; return ( <UserContext.Provider value={user}> <Layout> <Content /> </Layout> </UserContext.Provider> ); }
이러한 패턴들을 적절히 사용하여 컴포넌트 간의 결합도를 낮추고, 데이터 흐름을 명확하게 하며, 코드의 유지보수성과 확장성을 높일 수 있습니다.
Before Code (Bad)
// App.js
function App() {
const currentUser = { id: 1, name: 'John Doe', email: 'john@example.com' };
return (
<PageLayout currentUser={currentUser}> {/* 데이터 전달 시작 */}
<UserProfileSection />
</PageLayout>
);
}
// PageLayout.js
function PageLayout({ currentUser, children }) {
// PageLayout은 currentUser를 직접 사용하지 않지만, 자식에게 전달해야 함
return (
<div className="page-layout">
<Header currentUser={currentUser} /> {/* Header로 데이터 전달 */}
<main>{children}</main>
<Footer />
</div>
);
}
// Header.js
function Header({ currentUser }) {
// Header도 currentUser를 직접 사용하지 않고, Avatar 컴포넌트에게 전달
return (
<header className="app-header">
<h1>My Application</h1>
<Avatar currentUser={currentUser} /> {/* Avatar로 데이터 전달 */}
</header>
);
}
// Avatar.js
function Avatar({ currentUser }) {
// Avatar 컴포넌트가 비로소 currentUser의 name을 사용
return (
<div className="user-avatar">
<span>{currentUser.name.charAt(0)}</span>
<p>{currentUser.name}</p>
</div>
);
}
// UserProfileSection.js (App.js에서 Layout의 children으로 넘어온 컴포넌트)
function UserProfileSection() {
return (
<section className="user-profile-section">
{/* ... 다른 UI 요소 ... */}
<h2>User Profile Details</h2>
<p>This section displays user details.</p>
{/* 이 부분에서 만약 currentUser가 필요하다면, 또 다시 prop drilling이 발생할 수 있음 */}
</section>
);
}
After Code (Good)
// UserContext.js (Context 정의)
import React, { createContext, useContext } from 'react';
const UserContext = createContext(null);
export const UserProvider = ({ children, user }) => {
return (
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
);
};
export const useUser = () => {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
};
// App.js
import { UserProvider } from './UserContext';
import { PageLayout } from './PageLayout';
import { UserProfileSection } from './UserProfileSection';
function App() {
const currentUser = { id: 1, name: 'Jane Doe', email: 'jane@example.com' };
return (
<UserProvider user={currentUser}> {/* 데이터 제공은 여기서만 */}
<PageLayout>
<UserProfileSection />
</PageLayout>
</UserProvider>
);
}
// PageLayout.js
import { Header } from './Header';
export function PageLayout({ children }) {
// PageLayout은 이제 currentUser prop을 받을 필요 없음
return (
<div className="page-layout">
<Header /> {/* Header가 Context에서 user를 가져옴 */}
<main>{children}</main>
<Footer />
</div>
);
}
// Header.js
import { Avatar } from './Avatar';
export function Header() {
// Header는 이제 currentUser prop을 받을 필요 없음
return (
<header className="app-header">
<h1>My Application</h1>
<Avatar /> {/* Avatar가 Context에서 user를 가져옴 */}
</header>
);
}
// Avatar.js
import { useUser } from './UserContext';
export function Avatar() {
const currentUser = useUser(); // Context에서 직접 데이터 접근
return (
<div className="user-avatar">
<span>{currentUser.name.charAt(0)}</span>
<p>{currentUser.name}</p>
</div>
);
}
// UserProfileSection.js
import { useUser } from './UserContext';
export function UserProfileSection() {
const currentUser = useUser(); // Context에서 직접 데이터 접근
return (
<section className="user-profile-section">
<h2>User Profile Details</h2>
<p>Name: {currentUser.name}</p>
<p>Email: {currentUser.email}</p>
</section>
);
}