prop drilling은 prop을 쓰지 않는 컴포넌트들을 거쳐, 트리 더 아래의 컴포넌트가 쓸 수 있도록 prop을 넘기는 것입니다. 여기서 user는 App에서 Page와 Sidebar를 거쳐, 그것을 읽는 유일한 컴포넌트인 Avatar에 도달합니다.
네 단계인데, user에 관심 있는 것은 마지막 하나뿐입니다. Console을 보면 Page와 Sidebar가 화면에 보여 주지도 않는 값 때문에 실행됩니다. 버튼을 클릭하면 다시 로그를 남깁니다.
왜 문제가 될까
두세 단계는 괜찮습니다. 트리가 커지면 prop drilling이 문제가 됩니다.
- 중간 컴포넌트가 쓰지 않는 props를 나릅니다. 이제
Page와Sidebar의 시그니처에userprop이 있어서, 그것이 무엇을 위한 것인지 궁금해하지 않고는 코드를 읽을 수 없습니다. - 모든 변경이 모든 단계를 건드립니다.
user.avatarUrl이나onLogout같은 두 번째 값을 추가하면 양 끝만이 아니라 경로의 모든 컴포넌트를 수정해야 합니다. - 컴포넌트를 옮기면 연결이 끊어집니다.
Avatar를 다른 가지로 옮기면user를 새 경로로 다시 이어야 합니다. - prop을 빠뜨리기 쉽습니다. 한 단계에서 전달을 잊으면 깊은 컴포넌트가
undefined를 받는데, 그것을 잃어버린 단계에서는 아무 오류도 나지 않습니다.
직접 해 보세요. Avatar가 호출해야 하는 onLogout 함수를 App에 추가하면 네 컴포넌트를 모두 수정하게 됩니다.
해결책 1: children을 이용한 컴포지션
사람들이 건너뛰는 해결책입니다. 깊은 컴포넌트가 데이터로 렌더링할 수 있도록 데이터를 내려보내는 대신, 데이터를 가진 컴포넌트가 깊은 컴포넌트를 직접 렌더링하고, 완성된 요소를 children(또는 다른 prop)으로 내려보내세요. 중간 단계는 슬롯을 렌더링할 뿐 user를 보지 않습니다.
Page와 Sidebar는 이제 사용자가 존재한다는 것조차 모릅니다. App이 user가 있는 곳에서 Avatar를 만들고 완성된 요소로 넘겨줍니다. 이런 레이아웃 컴포넌트는 재사용할 수 있게 되고, onLogout을 추가하는 것도 이제 App과 Avatar만 바꾸면 됩니다. 둘 다 여전히 클릭할 때마다 로그를 남기는데, App이 렌더링될 때마다 새 요소를 넘기기 때문입니다. 컴포지션은 렌더링 횟수가 아니라 코드를 단순하게 만듭니다. children 페이지에서 더 많은 슬롯 패턴을 다룹니다.
컴포지션은 데이터를 가진 컴포넌트가 깊은 부분의 모습까지 정할 수 있을 때 통합니다. 깊은 컴포넌트가 라이브러리 안에 있거나, 데이터를 가진 쪽이 렌더링하지 않는 구조 안에 있을 때는 도움이 되지 않습니다.
해결책 2: 컨텍스트
여러 깊이의 많은 컴포넌트가 같은 값(테마, 로그인한 사용자, 현재 언어)을 필요로 한다면 컨텍스트에 넣으세요. Provider 아래의 어떤 컴포넌트든 useContext로 읽을 수 있고, 중간의 어떤 것도 바뀌지 않습니다.
Avatar와 Greeting은 서로 다른 깊이에서 사용자를 읽고, Page와 Sidebar는 props를 받지 않습니다. Page 아래 어디에든 세 번째 리더를 추가해도 나머지를 건드리지 않고 동작합니다. 대가는 데이터 흐름이 더 이상 props에 보이지 않고, 값이 바뀌면 모든 리더가 렌더링된다는 것입니다. useContext 페이지에서 Provider, 기본값, 그리고 그 리렌더링을 다룹니다.
해결책 3: 상태 관리 라이브러리
많은 컴포넌트가 읽고 자주 바뀌는 큰 앱 state(장바구니, 문서 편집기, 실시간 데이터)라면, Redux Toolkit, Zustand, Jotai 같은 상태 관리 라이브러리로 각 컴포넌트가 자기가 쓰는 조각만 구독하게 할 수 있습니다. drilling도 피하고, 큰 컨텍스트 하나의 "모든 소비자가 렌더링되는" 비용도 피합니다. 대신 의존성이 하나 늘고 익혀야 할 개념도 늘어나므로, 세 단계를 거치는 prop 하나를 고치려고 쓰지 말고 앞의 두 해결책으로 충분하지 않을 때 쓰세요.
// Zustand, for comparison (not available in the editor here)
const useCart = create((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}));
function CartCount() {
const count = useCart((s) => s.items.length); // renders only when the count changes
return <span>{count}</span>;
}
prop drilling이 괜찮을 때
props를 넘기는 것이 React의 데이터 흐름 방식이며, 가장 읽기 쉬운 패턴입니다. JSX만 보고도 값을 위에서 아래까지 따라갈 수 있습니다. 일부를 사용하는 컴포넌트 한두 개를 거치거나, 밀접하게 관련된 짧은 컴포넌트 사슬을 거치는 prop은 해결해야 할 문제가 아닙니다. state를 가장 가까운 공통 부모로 끌어올리고 아래로 넘기세요. 중간 단계가 쓸모없는 props를 나르기 시작할 때만 접근 방식을 바꾸세요.
자주 묻는 질문
React에서 prop drilling이란 무엇인가요?
트리 깊숙한 곳의 컴포넌트가 읽을 수 있도록, prop을 쓰지 않는 여러 단계의 컴포넌트를 거쳐 prop을 아래로 넘기는 것입니다. 중간의 각 컴포넌트는 prop을 받아서 그대로 전달합니다.
prop drilling은 나쁜 것인가요?
그 자체로는 아닙니다. 값을 두세 단계 아래로 넘기는 것은 명시적이고 따라가기 쉽습니다. 여러 단계가 쓰지도 않는 props를 나르게 되어, 이름을 바꾸거나 필드를 추가할 때마다 관련 없는 파일까지 건드리게 될 때 문제가 됩니다.
prop drilling은 어떻게 피하나요?
먼저 컴포지션을 시도하세요. 부모가 깊은 컴포넌트를 만들어 children이나 다른 prop으로 넘기면 중간 단계는 데이터를 볼 일이 없습니다. 여러 깊이의 많은 컴포넌트가 그 값을 필요로 한다면 컨텍스트를 쓰세요. 자주 바뀌는 큰 state라면 상태 관리 라이브러리를 고려하세요.
prop drilling의 해결책은 컨텍스트뿐인가요?
아니요, 그리고 최선이 아닌 경우도 많습니다. children을 이용한 컴포지션은 컨텍스트를 추가하지 않고도 drilling을 없애며, 데이터 흐름을 코드에서 계속 볼 수 있게 해 줍니다.