
상태 관리: Props Drilling 지옥 탈출
React의 영원한 숙제, 상태 관리. 할아버지 컴포넌트에서 손자 컴포넌트로 데이터를 줄 때 왜 전역 상태(Redux, Zustand)를 써야 할까?

React의 영원한 숙제, 상태 관리. 할아버지 컴포넌트에서 손자 컴포넌트로 데이터를 줄 때 왜 전역 상태(Redux, Zustand)를 써야 할까?
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

첫 React 프로젝트 때 컴포넌트 트리를 5단계로 쪼갰다. "재사용성이 좋다"는 말에 현혹돼서 Header, UserSection, ProfileCard, AvatarWrapper, UserAvatar까지. 깔끔한 구조라고 착각했는데, 유저 정보를 맨 위에서 맨 아래로 전달하려니 props를 5번 연속으로 내려야 했다.
<Header user={user} />
<UserSection user={user} />
<ProfileCard user={user} />
<AvatarWrapper user={user} />
<UserAvatar user={user} />
중간 3개 컴포넌트는 user를 쓰지도 않는데 그냥 전달만 한다. 나중에 user에 theme 정보를 추가하려니까 5개 파일을 다 뜯어고쳐야 했다. 이게 Props Drilling이었다.
React 공식 문서를 보니 Context API가 있었다. "전역 상태"를 만들어서 어디서든 꺼내 쓸 수 있다고. Provider로 감싸고 useContext로 꺼내면 끝. 마법 같았다.
const UserContext = createContext();
function App() {
const [user, setUser] = useState({ name: "John", theme: "dark" });
return (
<UserContext.Provider value={{ user, setUser }}>
<Header />
</UserContext.Provider>
);
}
function UserAvatar() {
const { user } = useContext(UserContext);
return <img src={user.avatar} />;
}
Props Drilling은 사라졌다. 근데 문제가 생겼다. user 안에 있는 theme만 바꿨는데, user를 쓰는 모든 컴포넌트가 다 리렌더링됐다. Header도, Sidebar도, Footer도. 심지어 user.name만 쓰는 컴포넌트도. Context 값이 바뀌면 Provider 아래 전체가 리렌더링되는 구조였다.
성능 최적화를 해보려고 useMemo, React.memo를 온갖 곳에 붙였는데, 코드가 더러워지기만 했다. Context를 여러 개로 쪼개는 방법도 있었지만, 그럼 또 Provider 지옥이 시작된다.
<UserContext.Provider>
<ThemeContext.Provider>
<CartContext.Provider>
<NotificationContext.Provider>
<App />
뭔가 잘못됐다는 생각이 들었다.
어느 날 시니어 개발자가 코드 리뷰에서 한 마디 했다. "상태를 전부 전역으로 올리면 안 돼. 로컬 상태, 전역 상태, 서버 상태를 구분해야 해."
세 종류로 나눈다는 게 신선했다.
내가 Context API에 때려박은 건 전역 상태와 서버 상태가 섞여 있었다. 분리하고 나니 각자 맞는 도구를 쓸 수 있었다.
컴포넌트 안에서만 쓰는 상태는 useState가 최적이다. 가볍고 빠르고 간단하다.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
문제는 이 상태를 다른 컴포넌트와 공유하려는 순간 시작된다. 상태를 부모로 올리고 (Lifting State Up), props로 내려주고. 3단계만 넘어가도 지옥이다.
React가 기본 제공하는 전역 상태 관리 도구. Provider/Consumer 패턴.
장점:Redux는 "상태 관리의 정석"으로 불린다. 하지만 그 대가로 엄청난 보일러플레이트를 요구한다.
아키텍처:Redux Toolkit (RTK)이 나와서 코드가 많이 줄었지만, 여전히 개념이 많고 무겁다. 아주 복잡한 상태 로직이 아니라면 과한 선택일 수 있다.
Zustand는 독일어로 "상태"라는 뜻이다. Redux의 철학을 가져오되, 보일러플레이트를 최소화했다.
핵심 특징:Zustand 스토어 생성:
import { create } from "zustand";
const useStore = create((set) => ({
user: { name: "John", age: 25 },
increaseAge: () => set((state) => ({ user: { ...state.user, age: state.user.age + 1 } })),
}));
// 사용
function Profile() {
// selector를 쓰면 age가 변할 때만 리렌더링!
const age = useStore((state) => state.user.age);
return <div>{age}</div>;
}
Context API와 달리, selector를 사용하면 내가구독한 데이터가 변할 때만 리렌더링된다. 이게 가장 큰 장점이다.
API에서 가져온 데이터는 "우리가 소유한 상태"가 아니다. 서버의 데이터를 잠깐 빌려온 거다. 그래서 다른 도구가 필요하다.
React Query (TanStack Query) 예제:const { data, isLoading } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
staleTime: 5000,
});
Redux에 API 데이터를 넣으면 캐싱, 재요청, 로딩 상태 관리 등을 전부 직접 짜야 한다. React Query는 이걸 자동화한다.
어떤 상태 관리 도구를 쓸지 고민될 때 이 흐름도를 따라간다.
시작
│
├─ 서버에서 가져온 데이터인가?
│ └─ YES → React Query / TanStack Query
│
├─ 한 컴포넌트 안에서만 쓰는가?
│ └─ YES → useState
│
├─ 2~3개 컴포넌트가 공유하는가?
│ └─ YES → 상태를 부모로 올리고 props로 전달
│
├─ 여러 곳에서 쓰지만 거의 안 바뀌는가? (테마, 언어)
│ └─ YES → Context API
│
├─ 자주 바뀌고 여러 컴포넌트가 구독하는가?
│ ├─ 팀이 Redux에 익숙한가? → Redux Toolkit
│ ├─ 간단하게 시작하고 싶은가? → Zustand
│ └─ 상태 간 의존성이 복잡한가? → Jotai / Recoil
│
└─ 대규모 엔터프라이즈 + 상태 히스토리 추적 필요
└─ Redux + Redux DevTools
My first React project had a component tree 5 levels deep. Header → UserSection → ProfileCard → AvatarWrapper → UserAvatar. I thought it was "clean architecture" until I needed to pass user data from top to bottom. Five times. Through components that didn't even use the data.
<Header user={user} />
<UserSection user={user} />
<ProfileCard user={user} />
<AvatarWrapper user={user} />
<UserAvatar user={user} />
When I added a theme property to user, I had to modify all five files. That's when I learned what Props Drilling really meant.
I found Context API in the React docs. "Global state without props!" sounded magical. Wrap with Provider, extract with useContext, done.
const UserContext = createContext();
function App() {
const [user, setUser] = useState({ name: "John", theme: "dark" });
return (
<UserContext.Provider value={{ user, setUser }}>
<Header />
</UserContext.Provider>
);
}
Props Drilling vanished. But I traded one problem for another. When I changed theme, every component using that Context re-rendered. Header, Sidebar, Footer. Even components only reading user.name. Context value changes trigger re-renders of all consumers.
I plastered useMemo and React.memo everywhere trying to optimize. Code got uglier. Splitting Context into multiple pieces led to Provider Hell.
<UserContext.Provider>
<ThemeContext.Provider>
<CartContext.Provider>
<NotificationContext.Provider>
<App />
Something was fundamentally wrong.
A senior developer dropped this in code review: "Don't lift everything to global state. Separate local state, global state, and server state."
That distinction changed everything.
useState is perfect.I'd been cramming global state and server state into Context. Once I separated them, I could use the right tool for each.
For component-scoped state, useState is optimal. Lightweight, fast, simple.
The problem starts when you need to share this state. Lift to parent, pass as props. Three levels down and you're in hell.
React's native global state solution. Provider/Consumer pattern.
Pros:Redux is the "textbook solution". Store, Action, Reducer, Dispatch. It enforces a strict unidirectional data flow, which makes debugging easy (Time Travel Debugging!).
But the boilerplate...
You need to write 3-4 files just to add a counter.
Redux Toolkit (RTK) reduced this significantly with createSlice, but it still feels "heavy" for typical apps. It introduces many concepts (Thunk, Middleware, Immutability) that might be overkill.
Zustand (German for "State") takes Redux's philosophy but removes the boilerplate.
Core Features:import { create } from "zustand";
const useStore = create((set) => ({
user: { name: "John", age: 25 },
increaseAge: () => set((state) => ({ user: { ...state.user, age: state.user.age + 1 } })),
}));
function Profile() {
// This component ONLY re-renders when age changes
const age = useStore((state) => state.user.age);
return <div>{age}</div>;
}
This automatic selector optimization is why I prefer Zustand over Context API for complex global state.
Data from APIs isn't "Your State". It's a snapshot of the server's state. Using Redux/Zustand for this is a bad idea because you have to manually handle loading, error, caching, and re-fetching.
React Query (TanStack Query) automates all of this. It keeps your client state synced with the server with zero effort.
Before you reach for Context or Redux to fix Prop Drilling, consider Component Composition.
Instead of:
<Page user={user} />
<Layout user={user} />
<Header user={user} />
<Avatar user={user} />
Do this:
function Page({ user }) {
return (
<Layout>
<Header>
<Avatar user={user} />
</Header>
</Layout>
);
}
By passing Avatar as a child to Header, Header doesn't need to know about user props at all.
This simple pattern solves 80% of Prop Drilling cases without adding any libraries.
State management isn't about "Which library is the best?". It's about "What kind of state is this?".
I used to start every project by installing Redux. Now I start with just useState and React Query. I only add Zustand when I truly feel the pain of Prop Drilling.
That is the path to a clean, maintainable, and happy codebase.
The landscape is changing again with Next.js App Router. With Server Components, we don't need to fetch data on the client. We just query the DB directly in the component.
// Server Component
async function UserProfile({ id }) {
const user = await db.user.findUnique({ where: { id } });
return <div>{user.name}</div>;
}
This removes the need for Client-side Server State Management (React Query) for GET requests. And Server Actions handle mutations without API routes.
The need for global client state (Redux/Zustand) is shrinking. It's becoming exclusively for UI State (Sidebar open, optimistic updates). The future is Less State on the Client. And that's a good thing.