
Prop Drilling은 React 컴포넌트 트리에서 발생하는 현상으로, 실제로 데이터를 사용하는 하위 컴포넌트에 도달하기까지 여러 중간 컴포넌트들이 해당 데이터를 단순히 전달하는 매개체 역할만 하는 것을 의미한다. 즉, 중간에 위치한 컴포넌트들은 자신들이 직접 사용하지도 않는 props를 오직 하위 컴포넌트에 전달하기 위한 목적으로만 받아서 넘기는 과정을 거치게 된다.
// 📮 App.js
const App = () => {
const [user, setUser] = useState("Byul");
return <MainPage user={user} />;
}
// 🚚 MainPage.js
const MainPage = ({ user }) => {
return <Header user={user} />;
}
// 🚚 Header.js
const Header = ({ user }) => {
return <UserProfile user={user} />;
}
// 💌 UserProfile.js
const UserProfile = ({ user }) => {
return <div>Welcome, {user}!</div>;
}
위에 코드처럼 user 데이터는 UserProfile 컴포넌트에서만 사용되지만, 이 데이터를 전달하기 위해 MainPage와 Header 컴포넌트를 거쳐야 한다. 이러한 현상이 바로 Prop Drilling이다.
props를 통해 데이터가 전달되는 경로가 코드상에서 명확하게 보이기 때문에 어떤 컴포넌트가 어떤 데이터를 사용하는지 쉽게 파악할 수 있다. 특히 코드를 실행하지 않고도 정적 분석만으로 데이터의 흐름을 파악할 수 있어 코드 변경 시 애플리케이션의 다른 부분에 미치는 영향을 예측하기도 쉽다.
별도의 상태 관리 라이브러리 없이도 컴포넌트 간에 데이터를 전달할 수 있기 때문에 특히 작은 규모의 애플리케이션에서는 가장 직관적인 해결책이 된다. 컴포넌트를 작은 단위로 분해하더라도 데이터 전달 구조가 명확하게 유지되어 코드의 가독성을 해치지 않는다.
추가적인 설정이나 라이브러리 설치 없이 바로 구현이 가능하며, 데이터가 전달되는 경로를 따라가기 쉽기 때문에 디버깅 과정도 순조롭다. 코드를 수정해야 할 때도 영향을 받는 부분을 쉽게 파악하고 수정할 수 있어 유지 보수 측면에서도 장점을 가진다.
중간 컴포넌트들이 실제로 사용하지 않는 props를 단순히 전달만 하는 역할을 하게 되면서 코드의 복잡성이 증가하게 되고, props의 이름이나 구조를 수정할 때마다 여러 컴포넌트를 동시에 수정해야 하는 번거로움이 생긴다. 특히 애플리케이션의 규모가 커질수록 이러한 문제는 더욱 심각해진다.
중간 컴포넌트들이 특정 props에 의존하게 되면서 다른 상황에서 해당 컴포넌트를 재사용하기가 어려워지며, 불필요한 props가 컴포넌트 분리 과정에서도 계속 남아있게 되어 컴포넌트의 독립성을 해치게 된다.
props 전달 체인으로 인해 컴포넌트들이 강하게 결합되면서 독립적인 개발과 테스트가 어려워지고, props 전달이 누락되거나 잘못된 경우에도 기본값으로 인해 문제를 즉시 발견하기 어려울 수 있다.
Context API는 컴포넌트 트리 전체에 데이터를 공유할 수 있게 해주는 기능이다. Props를 여러 단계에 걸쳐 전달할 필요 없이, 필요한 컴포넌트에 직접 데이터를 가져다 쓸 수 있다.
const UserContext = React.createContext();
// 최상위 컴포넌트에서 Provider 설정
const App = () => {
const user = { name: "Byul", age: 26 };
return (
<UserContext.Provider value={user}>
<MainPage />
</UserContext.Provider>
);
}
// 필요한 컴포넌트에서 직접 데이터 사용
const UserProfile = () => {
const user = React.useContext(UserContext);
return <div>Welcome, {user.name}!</div>;
}
⚠️ Context를 중첩해서 사용하면 Provider hell이 발생할 수 있고, Context를 구독하는 컴포넌트는 해당 Context에 의존성이 생겨 다른 상황에서 재사용하기 어려워진다. 예를 들어 UserContext를 사용하는 컴포넌트는 항상 UserContext.Provider 내부에서만 사용할 수 있게 된다.
Redux, Recoil, Zustand 등의 상태 관리 라이브러리를 사용하면 더 체계적으로 전역 상태를 관리할 수 있다. 이러한 라이브러리들은 복잡한 상태 관리가 필요한 대규모 애플리케이션에서 특히 유용하다. 상태 변경을 예측 가능하게 만들고, 디버깅을 용이하게 하며, 상태 관리 로직을 중앙화할 수 있다.
// Redux
const UserProfile = () => {
const user = useSelector(state => state.user);
return <div>Welcome, {user.name}!</div>;
}
⚠️ 작은 규모의 프로젝트에서는 보일러플레이트 코드(액션, 리듀서, 스토어 설정 등)가 오히려 개발 복잡도를 높일 수 있다. 간단한 상태 관리만 필요한 상황에서 Redux와 같은 라이브러리를 도입하면 간단한 상태 변경에도 여러 파일을 수정해야 하는 번거로움이 생긴다.
자식 컴포넌트를 props로 전달하는 방식으로, 중간 컴포넌트들은 데이터 전달에 관여하지 않아도 된다. 이 방식은 컴포넌트의 재사용성을 높이고 불필요한 props 전달을 줄일 수 있다.
const App = () => {
const user = { name: "Byul" };
return (
<Layout>
<Header>
<UserProfile user={user} />
</Header>
</Layout>
);
}
const Layout = ({ children }) => <div>{children}</div>;
const Header = ({ children }) => <header>{children}</header>;
⚠️ 여러 레벨에 걸친 컴포넌트들이 같은 데이터를 필요로 할 때는 오히려 구조가 복잡해질 수 있다. 특히 공통 조상 컴포넌트가 멀리 있는 경우, 컴포넌트 구조 전체를 재구성해야 할 수도 있다.
관련된 로직과 상태를 하나의 Hook으로 묶어서 필요한 컴포넌트에서 직접 사용할 수 있게 한다. 이 방식은 상태 관리 로직을 재사용 가능한 형태로 추출할 수 있으며, 컴포넌트의 로직을 더 깔끔하게 관리할 수 있다.
const useUser = () => {
const [user, setUser] = useState(null);
useEffect(() => {
// 사용자 데이터 로직
}, []);
return user;
}
// 필요한 컴포넌트에서 직접 사용
const UserProfile = () => {
const user = useUser();
return <div>Welcome, {user.name}!</div>;
}
⚠️ Custom Hook은 여러 컴포넌트에서 공유해야 하는 상태의 경우 각 컴포넌트마다 독립적인 상태가 생성되어 동기화 문제가 발생할 수 있다. 예를 들어 useUser 훅을 여러 컴포넌트에서 사용하면, 각각 독립적인 user 상태를 가지게 되어 한곳에서 업데이트해도 다른 곳에는 반영되지 않는다.
결국 각 방법들은 저마다의 장단점이 있기 때문에 상황에 맞는 적절한 선택이 중요하다.
🪄 애플리케이션의 규모가 작고, 전역적으로 공유해야 하는 간단한 상태가 있다면 → Context API
🪄 상태 관리가 복잡하고 팀 단위 협업이 필요한 대규모 프로젝트라면 → 상태 관리 라이브러리
🪄 특정 컴포넌트 트리 내에서만 데이터를 공유하면 된다면 → 컴포넌트 합성
🪄 비즈니스 로직을 재사용한다면 → Custom Hooks
각 상황에 맞는 적절한 도구를 선택하면, Prop Drilling 문제를 해결할 수 있다.