프론트엔드 개발자라면 한번쯤은 Props drilling이 뭔지, 사용했을 때 어떤 장단점이 있는지에 대한 질문을 받아봤을 것 같습니다. 저는 실무에서 자연스럽게 습득한 개념이라 명확하게 설명하기에는 어려움이 있었는데, 이번 기회에 머릿속에 흩어져 있던 지식을 체계적으로 정리해보고 싶다는 생각에 개념부터 장단점, 해결 방법까지 깊이 있게 알아보며 제 것으로 만들 수 있었습니다.
Props Drilling이란?
React 기반 프레임워크에서 상위 컴포넌트의 상태(state)를 하위 컴포넌트로 전달하기 위해 여러 컴포넌트를 거쳐 props를 넘겨주는 과정을 의미합니다.
예를 들어, App 컴포넌트의 상태를 GrandChild 컴포넌트에서 사용해야 할 때, 중간에 있는 Parent 컴포넌트가 해당 props를 직접 사용하지 않더라도 오직 전달을 위해 props를 넘겨주는 상황이 있습니다.
// App 컴포넌트가 'data' 상태를 가지고 있음
function App() {
const data = "중요한 데이터";
return <Parent data={data} />;
}
// Parent 컴포넌트는 'data'를 사용하지 않지만, GrandChild로 전달해야 함
function Parent({ data }) {
return <GrandChild data={data} />;
}
// GrandChild 컴포넌트가 드디어 'data'를 사용함
function GrandChild({ data }) {
return <p>{data}</p>;
}
장점과 단점 알아보기
props drilling은 무조건 나쁜 것만은 아니며, 상황에 따라 장단점이 뚜렷한 방법입니다.
장점
- 데이터가 어디서 어디로 전달되는지 명확하게 파악할 수 있습니다.
- 별도의 라이브러리나 복잡한 설정 없이 React의 기본 기능만으로 상태를 전달할 수 있습니다.
단점
- 컴포넌트 구조가 깊어질수록 어떤 props가 어디서 사용되는지 파악하기 어렵고, 코드 수정이 번거로워집니다.
- 중간에 있는 컴포넌트들은 props가 변경되면 자신과 관련 없는 데이터임에도 불구하고 리렌더링 될 수 있어 성능 저하의 원인이 됩니다.
- 특정 상위 컴포넌트에 강하게 의존하게 되어 다른 곳에서 재사용하기 어려워집니다.
Props Drilling으로 발생하는 문제
- 상태의 이름이나 구조를 변경해야 할 때, 그 상태를 전달하는 모든 경로의 컴포넌트 코드를 일일히 수정해야 하는 번거로움이 발생합니다.
- 중간 다리 역할을 하는 컴포넌트들이 실제로는 필요 없는 데이터에 대해 의존성을 갖게 되어, 컴포넌트 간 결합도가 높아집니다.
- React.memo와 같은 성능 최적화 기법을 적용하기 어려워집니다. 중간 컴포넌트들은 불필요한 props를 받기 때문에, 이 props가 변경될 때마다 불필요한 리렌더링이 발생할 수 있습니다.
문제를 피하기 위해서는?
1. React Context API 사용
전역적으로 데이터를 공유해야 할 때 유용하게 사용할 수 있습니다. Context를 사용하면 데이터를 필요로 하는 컴포넌트가 중간 컴포넌트를 거치지 않고 직접 데이터에 접근할 수 있습니다.
// 1. Context 생성
const DataContext = React.createContext();
// 2. Provider로 데이터 제공
function App() {
const data = "중요한 데이터";
return (
<DataContext.Provider value={data}>
<Parent />
</DataContext.Provider>
);
}
// 3. Consumer(useContext 훅)로 데이터 사용
function GrandChild() {
const data = useContext(DataContext);
return <p>{data}</p>;
}
2. 상태 관리 라이브러리 활용 (Redux, Zustand 등)
애플리케이션의 상태를 컴포넌트 외부의 중앙 집중화된 저장소인 Store에서 관리하는 방식입니다. 어떤 컴포넌트든 스토어에 직접 접근하여 상태를 가져오거나 변경할 수 있으므로 props drilling이 자연스럽게 해결됩니다. 복잡한 전역 상태 관리에 적합합니다.
3. 컴포넌트 컴포지션 (Component Composition)
컴포넌트를 더 유연하게 조합하여 props drilling을 피하는 방법입니다. 자식 컴포넌트를 props로 직접 전달(children prop 활용)하면, 중간 컴포넌트는 자식에게 어떤 props가 필요한지 알 필요가 없어집니다. 컴포넌트의 재사용성을 높일 수 있습니다.
// Parent 컴포넌트는 children을 그대로 렌더링할 뿐,
// GrandChild에 어떤 props가 필요한지 모른다.
function Parent({ children }) {
return (
<div>
<h1>Parent Component</h1>
{children}
</div>
);
}
// App에서 GrandChild에 직접 props를 전달하고,
// 그 자체를 Parent의 children으로 넘긴다.
function App() {
const data = "중요한 데이터";
return (
<Parent>
<GrandChild data={data} />
</Parent>
);
}
Props Drilling은 언제 사용해도 괜찮을까?
Props drilling은 복잡한 애플리케이션에서 문제를 일으킬 수 있지만, 모든 상황에서 피해야 할 절대적인 '안티패턴'은 아닙니다. 오히려 잘못된 상황에 더 복잡한 기술을 도입하는 것이 '오버 엔지니어링'이 될 수 있습니다. 다음과 같은 경우에는 Props drilling이 더 실용적이고 합리적인 선택으로 볼 수 있습니다.
1) 컴포넌트의 깊이가 2-3단계로 얕고 구조가 단순할 때
→ 1-2단계의 props 전달은 코드의 흐름을 추적하기 쉽고 유지보수 비용이 거의 발생하지 않습니다. App → Wrapper → Component 정도의 구조에서는 데이터가 어떻게 흘러가는지 한눈에 파악할 수 있기 때문에, 상태 관리 라이브러리를 사용하는 것보다 효율적인 방법일 수도 있습니다.
ex) 페이지 레이아웃 컴포넌트가 자식에게 간단한 제목(title)이나 상태(isOpen)을 전달하는 경우
2) 애플리케이션의 복잡도가 낮고 전역 상태가 거의 없을 때
→ 프로젝트의 규모가 작거나 전역적으로 공유해야 할 상태가 거의 없는 경우에는 상태 관리의 복잡성이 낮습니다. 대부분의 상태는 특정 컴포넌트 트리 안에서만 사용되고 소멸되기 때문에 가장 직관적이고 간단한 전달 방식인 Props Drilling을 사용할 수 있습니다.
3) 외부 라이브러리를 추가하고 싶지 않을 때
→ Redux나 Zustand와 같은 상태 관리 라이브러리를 추가하는 것은 번들 사이즈 증가, 보일러플레이트 코드 추가 등의 비용이 발생하는 작업입니다. 따라서 간단한 상태 전달을 위해 이런 비용을 감수하는 것은 비효율적일 수 있습니다.
4) 오버 엔지니어링을 피하고 싶은 경우
→ 아직 닥치지 않은 미래의 문제를 미리 해결하려는 것을 위해 '나중에 앱이 복잡해질 수도 있으니까 미리 Zustand를 도입하자!'와 같은 접근 방식은 당장의 개발 속도를 늦추고 코드의 복잡성을 불필요하게 높입니다. YAGNI(You Ain't Need It) 원칙에 따라, 현재의 문제에 가장 단순하고 적합한 해결책을 사용하는 것이 좋습니다.
성능 최적화 방법
Props drilling을 사용하면서도 성능을 개선하려면 불필요한 리렌더링을 막아야 합니다. React는 부모가 리렌더링되면 자식도 리렌더링되는 것이 기본 동작이기 때문입니다.
1) React.memo 활용하기
React.memo는 컴포넌트의 props를 기억(memoization)하는 고차 컴포넌트(HOC)입니다. 컴포넌트를 React.memo로 감싸주면, React는 이 컴포넌트가 다음에 렌더링될 때 새로운 props와 이전 props를 얕게 비교(shallow comparison)합니다. 만약 props가 같다면, React는 컴포넌트를 다시 렌더링하지 않고 이전에 기억해 둔 결과를 재사용합니다. 이를 통해 불필요한 렌더링 사이클을 건너뛸 수 있어 성능이 향상됩니다.
2) useCallback과 useMemo 사용하기
React.memo는 props를 비교할 때 참조 동일성을 확인합니다. 하지만 JavaScript에서 함수나 객체는 컴포넌트가 리렌더링될 때마다 새로 생성되므로, 내용은 같더라도 메모리 주소(참조)가 달라집니다. 이 때문에 React.memo는 props가 변경되었다고 착각하고 불필요한 리렌더링을 실행하게 됩니다.
- useCallback → 함수를 메모이제이션합니다. 의존성 배열([])의 값이 변경되지 않는 한, 이전에 생성된 함수를 그대로 재사용하여 참조 동일성을 보장합니다.
- useMemo → 값(주로 객체나 배열)을 메모이제이션합니다. 복잡한 연산의 결과값을 기억하고, 의존성 배열의 값이 변경되지 않는 한 새로운 값을 다시 계산하지 않습니다.
이 훅으로 React.memo가 올바르게 동작하도록 도와주는 역할을 할 수 있습니다.
// 부모 컴포넌트
const handleSave = useCallback(() => {
// ...저장 로직
}, [dependency]);
// 자식에게 props로 함수 전달
return <ChildComponent onSave={handleSave} />;
// 자식 컴포넌트
// onSave 함수의 참조가 동일하게 유지되므로 불필요한 리렌더링 방지
export default React.memo(ChildComponent);
코드 리팩토링 예시
Before: Props Drilling 발생
문제 상황 분석
이 코드에서 진짜 문제는 ProfilePage와 NavigationBar 컴포넌트입니다. 이들은 user 객체가 무엇인지, 어떻게 사용하는지 전혀 관심이 없고 그저 상위 컴포넌트로부터 user를 받아서 하위 컴포넌트로 전달하는 역할만 수행하고 있습니다. 하지만 이 구조는 다음과 같은 단점이 있습니다.
단점
- 결합도 증가: Avatar가 아닌 ProfilePage와 NavigationBar도 user prop에 대해 알아야만 합니다.
- 유지보수 저하: 만약 user prop의 이름을 userData로 바꾸려면, 관련 없는 중간 컴포넌트들의 코드까지 모두 수정해야 합니다.
- 재사용성 감소: NavigationBar를 user prop이 없는 다른 곳에서 사용하려면 불필요한 수정이 필요합니다.
function App() {
const [user, setUser] = useState({ name: 'Alice', avatarUrl: '...' });
return <ProfilePage user={user} />;
}
function ProfilePage({ user }) {
return <NavigationBar user={user} />;
}
function NavigationBar({ user }) {
return <Avatar user={user} />;
}
function Avatar({ user }) {
return <img src={user.avatarUrl} alt={user.name} />;
}
After: React Context로 리팩토링
해결 과정 분석
- Context 생성 (React.createContext): UserContext라는 이름의 데이터 터널을 만듭니다. 이 터널은 애플리케이션 어디서든 접근할 수 있는 통로 역할을 합니다.
- Provider로 데이터 제공 (<UserContext.Provider>): App 컴포넌트에서 <UserContext.Provider value={user}>를 사용해 터널의 입구를 만들고, user 데이터를 터널 안으로 흘려보냅니다. 이제 이 Provider로 감싸진 모든 하위 컴포넌트들은 이 터널에 접근할 권한을 갖게 됩니다.
- useContext로 데이터 소비 (useContext): Avatar 컴포넌트는 useContext(UserContext) 훅을 사용해 터널에 직접 연결하고 필요한 user 데이터를 꺼내 씁니다. 중간에 ProfilePage나 NavigationBar를 거칠 필요가 전혀 없습니다.
결과
ProfilePage와 NavigationBar는 더 이상 user 데이터에 대해 신경 쓰지 않아도 됩니다. 각자 본연의 역할에만 충실할 수 있게 되어 컴포넌트 간의 결합도가 낮아지고, 코드의 재사용성과 유지보수성이 크게 향상되었습니다.
// 1. Context 생성
const UserContext = React.createContext(null);
function App() {
const [user, setUser] = useState({ name: 'Alice', avatarUrl: '...' });
// 2. Provider로 데이터 제공
return (
<UserContext.Provider value={user}>
<ProfilePage />
</UserContext.Provider>
);
}
// 중간 컴포넌트들은 더 이상 user prop이 필요 없음
function ProfilePage() {
return <NavigationBar />;
}
function NavigationBar() {
return <Avatar />;
}
function Avatar() {
// 3. useContext로 데이터 직접 소비
const user = useContext(UserContext);
return <img src={user.avatarUrl} alt={user.name} />;
}
언제 무엇을 써야 할까?
| 솔루션 | 추천하는 상황 | 특징 및 고려사항 |
| 컴포넌트 컴포지션 | UI나 레이아웃 관련 props 전달을 피하고 싶을 때 | 가장 간단하고 React스러운 방법으로 컴포넌트의 재사용성을 높임 |
| React Context API | 테마, 언어 설정, 사용자 인증 등 업데이트가 잦지 않은 전역 데이터를 다룰 때 | 설정이 간단하지만 Provider 하위의 모든 컴포넌트가 영향을 받을 수 있음 |
| 상태 관리 라이브러리 | 여러 곳에서 자주 변경되는 복잡한 전역 상태를 다룰 때 (예: 쇼핑카트, 복잡한 폼) | 강력하지만 초기 설정이 복잡하고 번들 크기가 커짐. 오버 엔지니어링 주의 |
프론트엔드 개발자라면 한번쯤은 Props drilling이 뭔지, 사용했을 때 어떤 장단점이 있는지에 대한 질문을 받아봤을 것 같습니다. 저는 실무에서 자연스럽게 습득한 개념이라 명확하게 설명하기에는 어려움이 있었는데, 이번 기회에 머릿속에 흩어져 있던 지식을 체계적으로 정리해보고 싶다는 생각에 개념부터 장단점, 해결 방법까지 깊이 있게 알아보며 제 것으로 만들 수 있었습니다.
'📚 CS > React' 카테고리의 다른 글
| [React] Fiber 아키텍처 (0) | 2025.09.27 |
|---|---|
| [React] useRef 알아보기 (0) | 2025.09.16 |
| [React] useEffect, useLayoutEffect 비교하기 (0) | 2025.09.14 |
| [React] 클래스형 컴포넌트 vs 함수형 컴포넌트 (0) | 2025.09.12 |
| [React] React Hooks: 함수형 컴포넌트 알아보기 (0) | 2025.09.11 |