이 글은 Udemy의 【한글자막】 React 완벽 가이드 2025 with React Router & Redux 를 수강하고 정리한 내용입니다.
React_Study/10-Advanced-State-Managemen-with-Context-useReducer at main · An0401na/React_Study
Contribute to An0401na/React_Study development by creating an account on GitHub.
github.com
🎈Prop Drilling
React에서 상태나 함수를 하위 컴포넌트로 넘기다 보면 어느새 중간 컴포넌트들이 prop을 '전달만' 하고 있는 걸 발견하게 됩니다. 이게 바로 Prop Drilling 문제 입니다. 예시를 들어서 설명해보겠습니다.
상품을 조회하고 장바구니에 담을 수 있는 간단한 쇼핑 앱을 만든다고 가정해봅시다.
이 앱의 최상위 컴포넌트인 App에는 두 가지 주요 영역이 있습니다.
- Shop: 여러 개의 상품을 보여주는 영역
- Header: 장바구니 정보를 보여주는 영역
Shop 컴포넌트는 각 상품을 나타내는 여러 개의 Product 컴포넌트로 구성되어 있고,
Header 컴포넌트는 장바구니 UI를 담당하는 CartModal, 그리고 장바구니에 담긴 상품 리스트를 보여주는 Cart 컴포넌트로 구성됩니다.
상품을 장바구니에 담기 위해 필요한 함수인 onUpdateCart는 App 컴포넌트에 정의되어 있고,
이 함수는 최종적으로 Product에서 사용되지만, App → Shop → Product를 거쳐야 전달됩니다.
또한 cart 상태 역시 App → Header → CartModal → Cart 순으로 전파되어야 합니다.
이처럼 실제로 prop을 사용하지 않는 중간 컴포넌트들까지 prop을 전달해야 하는 구조가 만들어지고,
컴포넌트가 많아질수록 전달 경로는 복잡해지며 가독성과 유지보수성이 떨어지게 됩니다.
이것이 바로 Prop Drilling입니다.
🎈Component Composition(컴포넌트 합성)으로 Prop Drilling 줄이기
위와 같은 Prop Drilling 문제는 컴포넌트 합성(Component Composition)을 통해 어느 정도 완화할 수 있습니다.
💢 Before
// App.jsx
<Shop onAddItemToCart={handleAddItemToCart} />
// Shop.jsx
export default function Shop({ onAddItemToCart }) {
return (
<ul>
{DUMMY_PRODUCTS.map((product) => (
<Product {...product} onAddToCart={onAddItemToCart} />
))}
</ul>
);
}
이 경우 onAddItemToCart는 실제로는 Product 컴포넌트에서 사용하는 함수인데, Shop이 그저 전달만 하고 있었습니다.
✅ After (컴포넌트 합성 활용)
// App.jsx
<Shop>
{DUMMY_PRODUCTS.map((product) => (
<Product {...product} onAddToCart={handleAddItemToCart} />
))}
</Shop>
// Shop.jsx
export default function Shop({ children }) {
return <ul>{children}</ul>;
}
이제 Shop은 children을 렌더링하는 단순한 래퍼 역할만 하며, Product에서 필요한 prop을 직접 받을 수 있게 되어 불필요한 prop 전달을 줄이며 Prop Drilling 문제를 해결할 수 있습니다.
그러나 위 해결 방법으로 모든 컴포넌트 층에 사용하기는 적합하지 않습니다.
그 이유는 모든 컴포넌트에서 이 방법을 사용해 문제를 해결한다면 결국 모든 컴포넌트가 앱 컴포넌트에 포함될 것이고 나머지 컴포넌트는 감싸는 래퍼 용도로만 사용될 것이기 때문입니다.
이 문제를 다시 해결하기 위해서 아래에서 Context API에 대해 설명하겠습니다.
🎈Context API
Context는 React 앱 전체에 전역 상태를 공유할 수 있도록 도와주는 기능입니다. 간단히 말해, 중간 컴포넌트들을 불필요한 prop 전달을 거치지 않고도 필요한 컴포넌트에서 직접 데이터에 접근할 수 있게 해줍니다. 이를 통해 Prop Drilling 문제를 효과적으로 해결할 수 있습니다.
전과 같은 상황에서, 더 이상 onUpdateCart 함수나 Cart 상태를 props를 통해 하나하나 전달할 필요가 없습니다. Shop, Product, Header, CardModal, Cart 등 하위 컴포넌트 모두가 Cart Context에 접근하여 장바구니 상태를 읽거나 업데이트 할 수 있습니다.
Context를 사용하기 위해선 보통 3가지 단계로 구성됩니다:
1. Context 생성
import { createContext } from 'react';
export const CartContext = createContext({
items: [],
});
- createContext()를 사용해 Context를 생성합니다.
- 여기서 지정한 값은 기본값이며, Provider 없이 Context를 사용할 경우에만 사용됩니다.
- 즉, 실질적인 데이터는 다음 단계인 Provider에서 제공합니다.
2. State 연결 & Provider로 값 제공
Context를 사용하고자 하는 컴포넌트들을 Provider로 감싸고, useState로 관리되는 실제 상태 값을 value로 전달합니다. 예를 들어, 장바구니 데이터를 상태로 관리하고자 할 때 다음과 같이 useState를 사용해 상태를 정의할 수 있습니다.
const [shoppingCart, setShoppingCart] = useState({
items: [],
});
이 상태를 CartContext.Provider의 value로 전달하면, 하위 컴포넌트들은 Context를 통해 이 데이터를 사용할 수 있게 됩니다.
<CartContext.Provider value={shoppingCart}>
<Header />
<Shop>
<Product {...product} onAddToCart={handleAddItemToCart} />
</Shop>
</CartContext.Provider>
- 이렇게 하면 더 이상 shoppingCart를 props로 직접 전달하지 않아도, CartContext에 연결된 모든 컴포넌트에서 장바구니 상태에 접근할 수 있습니다.
- useContext(CartContext)를 사용하는 모든 컴포넌트는 이 value 값에 접근할 수 있습니다.
💡 참고로, createContext() 함수에서 설정하는 초기값은 Provider 없이 Context를 사용할 경우에만 사용됩니다. 실질적으로 하위 컴포넌트에 전달되는 값은 value={shoppingCart}에서 지정한 값입니다. 또한 초기값을 설정할 경우 자동완성에 도움을 받을 수 있다.
💡 React 19 이상에서는 .Provider를 생략할 수 있습니다! Context 객체를 바로 JSX 태그처럼 사용할 수 있습니다.
<CartContext value={shoppingCart}>
<Header />
<Shop>
<Product {...product} onAddToCart={handleAddItemToCart} />
</Shop>
</CartContext>
3. useContext로 값 사용
하위 컴포넌트에서는 useContext(CartContext)를 사용하여 값에 직접 접근할 수 있습니다.
import { useContext } from "react";
import { CartContext } from "../store/shopping-cart-context.jsx";
export default function Cart() {
const cartCtx = useContext(CartContext);
return (
<div id="cart">
{cartCtx.items.length === 0 && <p>No items in cart!</p>}
{cartCtx.items.length > 0 && (/* 생략 */)}
</div>
);
}
💡 React 19에서는 use() 훅도 사용할 수 있습니다.
React 19에서는 새로운 use() 훅을 통해 조건문 안에서도 Context 값을 사용할 수 있습니다.
import { use } from 'react';
import { CartContext } from "../store/shopping-cart-context.jsx";
export default function Cart() {
if (true) {
const cartCtx = use(CartContext);
// 조건 안에서도 훅 사용 가능!
}
return (
<div id="cart">
{/* 렌더링 */}
</div>
);
}
- useContext()는 컴포넌트 최상위 레벨에서만 사용 가능하지만,
- use() 훅은 if문, 반복문 등 어느 곳에서든 자유롭게 사용할 수 있는 장점이 있습니다.
💡 Context는 단순한 값뿐만 아니라, 상태와 함께 동작 함수도 함께 전달할 수 있습니다.
const ctxValue = {
items: shoppingCart.items,
addItemToCart: handleAddItemToCart, //함수 추가
};
return (
<CartContext.Provider value={ctxValue}>
{/* 컴포넌트들 */}
</CartContext.Provider>
);
하위 컴포넌트에서는 이렇게 사용합니다:
import { useContext } from "react";
import {CartContext} from "./store/shopping-cart-context.jsx";
export default function Product({ ...prop }) {
const { addItemToCart } = useContext(CartContext);
return (
<button onClick={() => addItemToCart(id)}>Add to Cart</button>
);
}
props 없이도 함수 호출이 가능하니 코드가 훨씬 깔끔해집니다.
💡컨텍스트 값이 바뀌면 컴포넌트가 재실행 됩니다.
useContext()로 값을 사용하고 있는 컴포넌트는, Context 값이 변경되면 React에 의해 자동으로 다시 실행됩니다. Context 값을 사용 중인 모든 컴포넌트가 자동으로 다시 렌더링됩니다.
이는 상태(state)나 props와 마찬가지로, 변경된 데이터를 기반으로 최신 UI를 보여주기 위한 동작입니다.
🎈 React에서 Context를 사용하는 더 깔끔한 방법: Context Provider를 별도 컴포넌트로 분리하기
🔧 Before
shopping-cart-context.jsx
export const CartContext = createContext({
items: [],
addItemToCart: () => {},
updateItemQuantity: () => {},
});
App.jsx
import { useState } from "react";
import { CartContext } from "./store/shopping-cart-context";
import { DUMMY_PRODUCTS } from "./dummy-products";
import Header from "./components/Header";
import Shop from "./components/Shop";
function App() {
const [shoppingCart, setShoppingCart] = useState({ items: [] });
function handleAddItemToCart(id) {
// 로직 생략
}
function handleUpdateCartItemQuantity(productId, amount) {
// 로직 생략
}
const ctxValue = {
items: shoppingCart.items,
addItemToCart: handleAddItemToCart,
updateItemQuantity: handleUpdateCartItemQuantity,
};
return (
<CartContext.Provider value={ctxValue}>
<Header />
<Shop products={DUMMY_PRODUCTS} />
</CartContext.Provider>
);
}
export default App;
위와 같이 CartContext를 따로 파일을 분리해두었지만, 정작 아이템을 추가하거나 수량을 변경하는 상태 관리 코드는 모두 App.jsx 내부에 존재합니다.
이 구조는 간단한 앱에서는 문제가 없지만, 여러 개의 Context가 필요한 복잡한 앱에서는 문제가 됩니다.
예를 들어 Cart 외에 User, Theme, Auth 등 다른 Context가 추가되면, 각각의 상태 관리 함수들이 모두 App.jsx에 쌓이게 되죠. 결과적으로 App 컴포넌트는 상태 관리 코드의 집합소가 되고, 이는 유지보수에 취약하며, 컴포넌트 역할 분리 원칙에도 어긋나게 됩니다.
✅ After: CartContextProvider로 상태 관리까지 분리
CartContext만이 아니라, 상태와 그에 관련된 모든 로직을 별도의 Provider 컴포넌트로 추출할 수 있습니다.
이제 CartContextProvider를 만들고, 그 안에서 상태 관리와 Context 제공을 함께 처리합니다.
shopping-cart-context.jsx
import { createContext, useState } from "react";
import { DUMMY_PRODUCTS } from "../dummy-products.js";
export const CartContext = createContext({
items: [],
addItemToCart: () => {},
updateItemQuantity: () => {},
});
export default function CartContextProvider({ children }) {
const [shoppingCart, setShoppingCart] = useState({ items: [] });
function handleAddItemToCart(id) {
// 로직 생략
}
function handleUpdateCartItemQuantity(productId, amount) {
// 로직 생략
}
const ctxValue = {
items: shoppingCart.items,
addItemToCart: handleAddItemToCart,
updateItemQuantity: handleUpdateCartItemQuantity,
};
return (
<CartContext.Provider value={ctxValue}>
{children}
</CartContext.Provider>
);
}
App.jsx
import CartContextProvider from "./store/shopping-cart-context";
function App() {
return (
<CartContextProvider>
<Header />
<Shop />
</CartContextProvider>
);
}
여기서 핵심은 children prop을 활용하여 CartContextProvider가 Context를 사용해야 하는 모든 컴포넌트를 감싸도록 만든 점입니다. 상태 관리와 핸들러 로직은 모두 CartContextProvider 안으로 옮겨졌고, App 컴포넌트는 필요한 컴포넌트만 렌더링하는 역할만 하게 됩니다. App 컴포넌트는 View 계층만 담당하는 단순한 구조로 바뀌었습니다.
🎈 useReducer 란?
useReducer는 상태(state)와 그 상태를 어떻게 바꿀지 정해주는 로직(reducer 함수)을 관리하는 React의 내장 훅입니다.
🤔 useState와의 차이점은?
구분 | useState | useReducer |
사용 시기 | 단순한 상태 관리 | 상태가 복잡하거나, 여러 조건에 따라 바뀔 때 |
상태 타입 | 보통 하나의 값 (예: 숫자, 문자열) | 주로 객체로 관리 |
상태 변경 방식 | 직접 setState 호출 | dispatch로 액션 전달 후 reducer에서 처리 |
예시 | setCount(count + 1) | dispatch({ type: 'INCREMENT' }) |
🔧 Before: useState로 상태 관리
기존에는 useState를 사용해서 장바구니 상태를 관리했었습니다. 이렇게 되면 상태 변경 로직이 여러 함수에 나누어져 있고 로직이 복잡해질수록 setState로 관리하는 코드가 길어지게 됩니다.
const [shoppingCart, setShoppingCart] = useState({ items: [] });
function handleAddItemToCart(id) {
// 아이템 추가 로직이 여기
}
function handleUpdateCartItemQuantity(productId, amount) {
// 수량 업데이트 로직이 또 여기
}
이렇게 되면 상태를 바꾸는 로직이 여러 함수에 흩어져 있고, 기능이 많아질수록 setState가 점점 복잡하고 길어집니다.
✅ After: useReducer 로 전환
1. useReducer 생성
const [shoppingCartState, shoppingCartDispatch] = useReducer(
shoppingCartReducer, // 상태를 어떻게 바꿀지 정의한 reducer 함수
{ items: [] } // 초기 상태
);
- shoppingCartState: 현재 상태
- shoppingCartDispatch: 액션을 발생시키는 함수
- shoppingCartReducer: 상태를 업데이트하는 함수
2. Reducer 함수 만들기
function shoppingCartReducer(state, action) {
if (action.type === "ADD_ITEM") {
// 아이템 추가 로직
return { // 업데이트 된 상태 반환
...state,
items: updatedItems,
};
}
if (action.type === "UPDATE_ITEM") {
// 수량 업데이트 로직
return { // 업데이트 된 상태 반환
...state,
items: updatedItems,
};
}
return state; // 기본은 이전 상태 그대로 반환
}
- state: snapshot으로 찍은 현재 상태 객체 (ex. { items: [...] })
- action: 상태를 어떻게 바꿀지를 담은 객체 (예: { type: 'ADD_ITEM', payload: 1 })
📌 우리가 상태 업데이트를 위해 써야 하는 코드는 이전보다 조금 길어질 수 있습니다.
하지만 중요한 차이는 그 로직이 React 컴포넌트 바깥, 리듀서 함수 안에 모여있다는 점이에요.
그리고 useReducer를 사용할 때는 상태를 직접 다루는 특별한 setState 함수를 기억하지 않아도 됩니다.
그저 dispatch로 "이런 행동을 하겠다!"라고 의도(action)만 전달하면,
React가 자동으로 가장 최신 상태의 스냅샷을 reducer에 전달해줍니다.
👉 즉, 이전 상태 참조 문제 없이 안정적으로 상태를 변경할 수 있죠!
3. Reducer 함수 사용
function handleAddItemToCart(id) {
shoppingCartDispatch({ type: "ADD_ITEM", payload: id });
}
function handleUpdateCartItemQuantity(productId, amount) {
shoppingCartDispatch({
type: "UPDATE_ITEM",
payload: { productId, amount },
});
}
이렇게 하면 App이나 Context Provider 컴포넌트는 dispatch만 호출하면 되고,
실제 상태 업데이트 로직은 모두 reducer 안에서 깔끔하게 처리됩니다.
🔄 useReducer로 상태가 바뀌는 흐름, 어떻게 작동할까?
우리가 흔히 useState로 상태를 관리할 때는 상태를 바꾸고자 할 때 setState를 직접 호출하죠.
그런데 useReducer를 쓰면 그 흐름이 달라집니다. 대신 훨씬 더 구조적이고 예측 가능한 방식으로 동작합니다.
shoppingCartDispatch({ type: "ADD_ITEM", payload: itemId });
이렇게 dispatch 함수를 호출하면 무슨 일이 일어날까요?
- dispatch()가 액션을 발생시킴
- 위 예시에서는 "ADD_ITEM"이라는 행동의 의도(type)와, 어떤 아이템을 추가할지에 대한 정보(payload)를 보냈어요.
- 리듀서 함수가 호출됨
- useReducer는 이 dispatch를 받자마자, 우리가 정의해둔 shoppingCartReducer() 함수에 현재 상태(state)와 보낸 action을 넘겨줘요.
- 이 리듀서 함수는 “어떤 액션을 어떻게 처리할지”에 대한 규칙을 정해둔 곳이죠.
function shoppingCartReducer(state, action) { if (action.type === "ADD_ITEM") { // 새로운 상태 계산 return { ...state, items: [...state.items, action.payload], }; } return state; // 기본은 상태 그대로 반환 }
- 리듀서에서 반환한 새 상태로 업데이트
- 리듀서 함수는 바뀐 상태(예: items가 추가된 배열)를 새로운 객체로 만들어 반환합니다.
- useReducer는 이 새 상태를 shoppingCartState에 저장합니다.
- 상태를 제공하는 컨텍스트의 value가 변경됨
- 예를 들어 ShoppingCartContext.Provider의 value={{ shoppingCartState, shoppingCartDispatch }}가 있었다면, 이 중 shoppingCartState가 바뀌면서 전체 value 객체가 새로 생성됩니다.
- 그 컨텍스트를 구독 중인 컴포넌트들이 리렌더링됨
- useContext(ShoppingCartContext)로 해당 값을 쓰고 있는 컴포넌트들이 자동으로 리렌더링되며, 최신 shoppingCartState 값을 받아 UI를 업데이트해요.
📌 왜 이 흐름이 좋을까?
- 상태 변경 로직이 하나의 리듀서 함수 안에 깔끔하게 정리됨
- 복잡한 상태도 다양한 액션을 통해 관리할 수 있음
- 상태 업데이트가 항상 이전 상태를 기반으로 안전하게 처리됨
- 컨텍스트와 함께 쓰면 앱 전체에서 상태를 예측 가능하게 공유할 수 있음
'Programming > React' 카테고리의 다른 글
[React] React로 퀴즈 앱 만들기 | 개발 회고 (1) | 2025.04.16 |
---|---|
[React] Side Effects / useEffect 훅 / useCallback 훅 (0) | 2025.04.11 |
IntelliJ에서 React prettier 설정 (0) | 2025.04.08 |
[React] React 완벽 가이드 - React로 프로젝트 관리 앱 만들기 | 개발 회고 (0) | 2025.04.08 |
GitHub Pages에 리액트 프로젝트(React + Vite) 배포하기 (0) | 2025.03.23 |