내 코드가 스파게티가 된 이유 - Prop Drilling 지옥 탈출기
1. "그냥 전달만 해주면 되잖아?"
React를 처음 배울 때였습니다. 꽤 복잡한 관리자 대시보드를 만들고 있었죠.
가장 상위 컴포넌트인 App.js에 로그인한 유저 정보(user)가 있었습니다.
그런데 저기 구석, 5단계 밑에 있는 Avatar 컴포넌트가 이 유저 사진을 보여줘야 했죠.
컴포넌트 구조는 대충 이랬습니다:
App -> MainLayout -> Header -> UserInfo -> Avatar
저는 단순하게 생각했습니다. "그냥 props로 계속 내려주면 되지. 얼마나 걸린다고."
/* App.js */
<MainLayout user={user} />
/* MainLayout.js */
// 얘는 user가 필요도 없는 레이아웃인데...
<Header user={user} />
/* Header.js */
// 얘도 user가 누군지 관심 없는데...
<UserInfo user={user} />
/* UserInfo.js */
<Avatar user={user} />
처음엔 괜찮아 보였습니다. 코드가 작동은 했으니까요. 하지만 2주 뒤, 기획자가 말했습니다.
"로그아웃 기능을 추가해야 하니까,
logout함수도 Avatar 옆에 버튼으로 만들어주세요."
저는 절망했습니다.
App에 있는 logout 함수를 Avatar까지 전달하기 위해, 중간에 있는 4개의 파일을 모두 열어서 코드를 수정해야 했거든요.
중간에 있는 컴포넌트들은 단지 '데이터 배달부(Courier)' 역할만 하느라 코드가 너저분해졌습니다.
이게 바로 그 유명한 Prop Drilling(속성 내리꽂기) 지옥입니다.
2. 해결책 1 - 데이터 순간이동 (Context API)
React 팀도 이 고통을 알고 있었습니다. 그래서 Context API라는 순간이동 장치를 제공합니다. 마치 전역 방송 시스템과 같습니다.
- 방송국(Provider)을 만듭니다.
- 데이터를 공중에 띄웁니다.
- 필요한 컴포넌트(Consumer)가 안테나(useContext)를 세워서 데이터를 받습니다.
/* UserContext.js */
export const UserContext = createContext(null);
/* App.js */
<UserContext.Provider value={user}>
<MainLayout />
</UserContext.Provider>
/* ... MainLayout, Header, UserInfo는 user를 몰라도 됨 (Clean!) ... */
/* Avatar.js */
import { useContext } from 'react';
import { UserContext } from './UserContext';
const user = useContext(UserContext); // 순간이동으로 받아옴!
return <img src={user.photoUrl} />;
Context의 치명적 단점
하지만 Context는 만능이 아닙니다. "Context 값이 바뀌면, 그걸 구독하는 모든 컴포넌트가 강제로 리렌더링됩니다."
만약 Context에 user 정보뿐만 아니라 theme, lang, notifications까지 다 때려 넣으면?
타자 하나 칠 때마다 앱 전체가 깜빡거리는 성능 지옥을 맛보게 됩니다.
그래서 Context는 "자주 바뀌지 않는 값" (테마, 언어 설정, 로그인 정보)에만 써야 합니다.
3. 해결책 2 - 컴포넌트 합성 (Component Composition)
이건 많은 분들이 모르는, 하지만 가장 React스러운(Idiomatic) 해결책입니다. 데이터를 넘기는 대신, 화면(컴포넌트) 자체를 넘기는 겁니다.
상황을 다시 봅시다. MainLayout은 user 데이터에 관심이 없습니다. 단지 Header를 그릴 공간만 있으면 되죠.
/* Before: Drilling */
function MainLayout({ user }) {
return (
<div className="layout">
<Header user={user} /> {/* Layout이 직접 Header를 그림 */}
<Sidebar />
<Content />
</div>
);
}
/* After: Composition (Inversion of Control) */
function MainLayout({ header }) {
return (
<div className="layout">
{header} {/* "여기에 header가 들어올거야" 라고 구멍만 뚫어놓음 */}
<Sidebar />
<Content />
</div>
);
}
// 사용하는 곳 (App.js)
<MainLayout
header={<Header user={user} />} // 여기서 직접 주입!
/>
이렇게 하면 MainLayout은 자신의 props에서 user를 지워버릴 수 있습니다.
"내 안에 뭐가 들어오든 상관없어. 난 그냥 틀만 제공할 뿐이야."
이것이 바로 제어의 역전(Inversion of Control)입니다. Prop Drilling을 근본적으로 없애는 우아한 방법이죠.
4. 해결책 3 - 전문 배달업체 (Zustand, Redux)
프로젝트가 커지면 Context와 Composition만으로는 부족합니다. 서버 상태(Server State)와 클라이언트 상태(Client State)가 뒤섞이기 시작하죠.
이때는 전문적인 전역 상태 관리 라이브러리를 씁니다. 옛날에는 Redux가 왕이었지만, 보일러플레이트(상용구) 코드가 너무 많았습니다. 요즘 대세는 Zustand입니다. 정말 심플하거든요.
/* store.js */
import { create } from 'zustand';
export const useUserStore = create((set) => ({
user: null,
login: (userData) => set({ user: userData }),
logout: () => set({ user: null }),
}));
/* Avatar.js */
import { useUserStore } from './store';
// 어디서든, 몇 단계 깊이든 상관없이 바로 꺼내 씀
const user = useUserStore((state) => state.user);
const logout = useUserStore((state) => state.logout);
이건 마치 "클라우드 저장소"를 쓰는 것과 같습니다. 데이터를 구름(Store) 위에 올려놓고, 아무 컴포넌트에서나 필요할 때 다운로드 받는 거죠.
6. 원자 단위 상태 관리 (Atomic State) 깊이 들여다보기
Zustand는 하나의 거대한 스토어(Store)를 만드는 방식입니다.
하지만 때로는 useState처럼 가볍게 쓰고 싶을 때가 있죠.
이럴 때 Recoil이나 Jotai 같은 아토믹(Atomic) 패턴을 씁니다.
/* atoms.js (Jotai) */
import { atom } from 'jotai';
export const userAtom = atom(null);
/* Avatar.js */
import { useAtom } from 'jotai';
import { userAtom } from './atoms';
const [user, setUser] = useAtom(userAtom); // useState랑 똑같네?
React Hook과 사용법이 완전히 똑같아서 러닝 커브가 거의 없습니다. 작은 프로젝트라면 Context보다 Jotai가 훨씬 편할 수 있습니다.
7. 패턴의 확장: Compound Components
UI 라이브러리(Radix UI, Headless UI)들은 내부적으로 Prop Drilling을 피하기 위해 Compound Component(합성 컴포넌트) 패턴을 씁니다.
// 지저분한 Props 전달 방식
<Tabs selected={tab} onChange={setTab} items={[...]} />
// 깔끔한 합성 방식
<Tabs>
<Tabs.List>
<Tabs.Trigger value="A">A</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="A">...</Tabs.Content>
</Tabs>
겉으로 보면 Props를 전달하지 않는 것처럼 보입니다.
하지만 내부적으로는 부모(Tabs)가 Context를 통해 자식들에게 상태를 뿌려주고 있습니다.
우리가 "사용자 입장"에서는 Prop Drilling을 신경 쓸 필요가 없게 캡슐화한 것이죠.
8. 마무리 - 택배 기사가 되지 마세요
컴포넌트는 자기 할 일만 해야 합니다. (단일 책임 원칙) 남의 데이터를 배달해주는 택배 기사 노릇을 하게 하지 마세요.
다음에 props를 작성할 때, 3단계 이상 내려가고 있다면 멈추세요.
- 가까우면: 그냥 Props 쓰세요. (1~2단계는 괜찮음)
- 중간 컴포넌트가 고통받으면: Composition(합성)을 먼저 고려하세요. 가장 깔끔합니다.
- 너무 멀거나 전역적이면:
- 자주 안 바뀐다? Context API.
- 자주 바뀐다? Zustand (또는 Jotai, Recoil).
Prop Drilling만 없애도, 여러분의 코드는 훨씬 더 읽기 쉽고 유연해질 겁니다.
My Codebase Became a Spaghetti Monster (Prop Drilling Escape Guide)
1. "Just Pass It Down, Right?"
It happened when I was first learning React. I was building quite a complex admin dashboard.
I had the logged-in user info (user) in the top-level App.js.
But way down in the component tree, 5 levels deep, the Avatar component needed to show the user's profile picture.
The structure looked something like this:
App -> MainLayout -> Header -> UserInfo -> Avatar
I thought simply. "Just pass it down via props. How hard can it be?"
/* App.js */
<MainLayout user={user} />
/* MainLayout.js */
// This layout doesn't even need user data...
<Header user={user} />
/* Header.js */
// Neither does this one...
<UserInfo user={user} />
/* UserInfo.js */
<Avatar user={user} />
It looked fine at first. The code worked. But two weeks later, the PM said:
"We need a logout feature. Please add a logout button next to the Avatar."
I fell into despair.
To pass the logout function from App to Avatar, I had to open and edit 4 intermediate files.
The middle components became cluttered, acting as nothing more than 'Couriers' for data they didn't even use.
This is the infamous Prop Drilling Hell.
2. Solution 1: Data Teleportation (Context API)
The React team knew this pain. That's why they provided a teleportation device called Context API. It's like a global broadcasting system.
- Create a Station (Provider).
- Broadcast the data into the air.
- Any component (Consumer) sets up an Antenna (useContext) to receive it.
/* UserContext.js */
export const UserContext = createContext(null);
/* App.js */
<UserContext.Provider value={user}>
<MainLayout />
</UserContext.Provider>
/* ... MainLayout, Header, UserInfo don't need to know user exists (Clean!) ... */
/* Avatar.js */
import { useContext } from 'react';
import { UserContext } from './UserContext';
const user = useContext(UserContext); // Teleport!
return <img src={user.photoUrl} />;
The Fatal Flaw of Context
But Context isn't a silver bullet. "When a Context value changes, ALL components subscribing to it are forced to re-render."
If you dump user info, theme, lang, and notifications all into one Context?
Your entire app will flash and lag with every keystroke.
So, use Context only for "Infrequently Changed Values" (Theme, Language, Auth).
3. Solution 2: Component Composition
This is a solution many miss, but it's the most Idiomatic React way. Instead of passing data, pass the Screen (Component) itself.
Let's look at the situation again. MainLayout doesn't care about user. It just needs a slot to render the Header.
/* Before: Drilling */
function MainLayout({ user }) {
return (
<div className="layout">
<Header user={user} /> {/* Layout explicitly renders Header */}
<Sidebar />
<Content />
</div>
);
}
/* After: Composition (Inversion of Control) */
function MainLayout({ header }) {
return (
<div className="layout">
{header} {/* "I'll render whatever you give me here" */}
<Sidebar />
<Content />
</div>
);
}
// Usage (App.js)
<MainLayout
header={<Header user={user} />} // Inject it directly here!
/>
Now MainLayout can delete user from its props.
"I don't care what's inside. I just provide the frame."
This is Inversion of Control. It's an elegant way to fundamentally eliminate Prop Drilling.
4. Solution 3: Professional Couriers (Zustand, Redux)
As projects grow, Context and Composition aren't enough. Server State and Client State start getting mixed up.
That's when you use a professional Global State Management Library. In the old days, Redux was king, but the boilerplate was overwhelming. Nowadays, the trend is Zustand. It's incredibly simple.
/* store.js */
import { create } from 'zustand';
export const useUserStore = create((set) => ({
user: null,
login: (userData) => set({ user: userData }),
logout: () => set({ user: null }),
}));
/* Avatar.js */
import { useUserStore } from './store';
// Access immediately from anywhere, no matter how deep
const user = useUserStore((state) => state.user);
const logout = useUserStore((state) => state.logout);
It's like using "Cloud Storage." Upload data to the Cloud (Store), and download it from any component when needed.
6. Bonus: The Rise of Atomic State (Recoil, Jotai)
Zustand is great (Redux-like centralized store), but sometimes you want something more flexible. Enter Atomic State Management.
Libraries like Recoil (by Facebook) and Jotai treat state as tiny "Atoms" floating in your app, rather than one giant store.
/* atoms.js (Jotai) */
import { atom } from 'jotai';
export const userAtom = atom(null);
export const themeAtom = atom('dark');
/* Avatar.js */
import { useAtom } from 'jotai';
import { userAtom } from './atoms';
const [user] = useAtom(userAtom); // Use it exactly like useState!
This feels much more "React-like" because it mimics the useState API.
If you find Context too weak but Redux/Zustand too heavy, Jotai might be your perfect escape pod.
7. Advanced Pattern: Compound Components
Another way to avoid prop drilling within a specific UI widget (like a Dropdown or Tabs) is the Compound Component Pattern.
Instead of:
<Select options={options} onChange={...} selected={...} />
You do:
<Select>
<Select.Trigger />
<Select.List>
<Select.Option value="1">One</Select.Option>
</Select.List>
</Select>
Under the hood, Select uses Context API to share state with Trigger, List, and Option.
But the user of this component doesn't see any props being drilled.
This is how libraries like Radix UI and Headless UI are built. It keeps the API surface clean while handling complex state internally.
7. Which State Manager Should I Choose?
With so many options (Redux, Zustand, Recoil, Jotai, Context), it's confusing. Here is my quick decision matrix for 2024:
- Server State (Data from API): Don't use any global state manager. Use TanStack Query (React Query) or SWR. Caching is hard; let testing libraries handle it.
- Client State (Theme, Modal, User Session):
- Simple? Context API.
- Medium/Large? Zustand. It's the most popular for a reason. Zero boilerplate.
- Atomic/Granular updates? Jotai. Great for heavy interactive apps (like canvas editors).
- Legacy Enterprise? Redux Toolkit (RTK).
Pro Tip: Start with Context. Only reach for Zustand when you feel the pain of re-renders.