
내 코드가 스파게티가 된 이유: Prop Drilling 지옥 탈출기
React 개발 초기, 데이터를 5단계 깊이로 전달하다가 멘붕에 빠진 경험을 공유합니다. Prop Drilling이 왜 유지보수의 적인지, 그리고 Context API, Composition(합성), Zustand를 사용해 이 지옥에서 우아하게 탈출하는 3가지 실제 패턴을 정리합니다.

React 개발 초기, 데이터를 5단계 깊이로 전달하다가 멘붕에 빠진 경험을 공유합니다. Prop Drilling이 왜 유지보수의 적인지, 그리고 Context API, Composition(합성), Zustand를 사용해 이 지옥에서 우아하게 탈출하는 3가지 실제 패턴을 정리합니다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

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(속성 내리꽂기) 지옥입니다.
React 팀도 이 고통을 알고 있었습니다. 그래서 Context API라는 순간이동 장치를 제공합니다. 마치 전역 방송 시스템과 같습니다.
/* 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에 user 정보뿐만 아니라 theme, lang, notifications까지 다 때려 넣으면?
타자 하나 칠 때마다 앱 전체가 깜빡거리는 성능 지옥을 맛보게 됩니다.
그래서 Context는 "자주 바뀌지 않는 값" (테마, 언어 설정, 로그인 정보)에만 써야 합니다.
이건 많은 분들이 모르는, 하지만 가장 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을 근본적으로 없애는 우아한 방법이죠.
프로젝트가 커지면 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) 위에 올려놓고, 아무 컴포넌트에서나 필요할 때 다운로드 받는 거죠.
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가 훨씬 편할 수 있습니다.
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을 신경 쓸 필요가 없게 캡슐화한 것이죠.
컴포넌트는 자기 할 일만 해야 합니다. (단일 책임 원칙) 남의 데이터를 배달해주는 택배 기사 노릇을 하게 하지 마세요.
다음에 props를 작성할 때, 3단계 이상 내려가고 있다면 멈추세요.
Prop Drilling만 없애도, 여러분의 코드는 훨씬 더 읽기 쉽고 유연해질 겁니다.
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.
The React team knew this pain. That's why they provided a teleportation device called Context API. It's like a global broadcasting system.
/* 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} />;
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).
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.
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.
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.
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.
With so many options (Redux, Zustand, Recoil, Jotai, Context), it's confusing. Here is my quick decision matrix for 2024:
Pro Tip: Start with Context. Only reach for Zustand when you feel the pain of re-renders.