리액트(React)로 컴포넌트를 분리하며 프로젝트를 개발하다 보면 누구나 한 번쯤 마주치는 악몽이 있습니다. 바로 '프롭스 드릴링(Props Drilling)'입니다.

로그인한 유저 정보나 다크모드 설정값처럼 앱 전체에서 쓰이는 데이터를 단 하나의 하위 컴포넌트에 전달하기 위해, 부모 ➔ 자식 ➔ 손자 ➔ 증손자 컴포넌트를 거쳐 불필요하게 props를 계속 넘겨주다 보면 코드가 지저분해지고 유지보수가 불가능해집니다.

처음에는 Redux(리덕스)를 도입해 보려 했지만, 단순한 상태 하나 만드는데 Action, Reducer, Store 등 보일러플레이트 코드가 너무 많아 배보다 배꼽이 더 큰 느낌을 받았습니다.

오늘은 보일러플레이트 없이 가볍고 직관적인 Zustand(주스탠드)를 도입하여 프롭스 드릴링을 단 10분 만에 해결했던 경험과 실무 활용 꿀팁을 공유해 보려고 합니다.

1. 내가 마주친 문제: "증손자 컴포넌트 하나 때문에 부모들이 고통받다"

헤더에 있는 장바구니 카운트와, 메인 상품 목록 페이지에 있는 '장바구니 담기' 버튼을 동기화해야 하는 상황이었습니다.

TypeScript
 
// ❌ 프롭스 드릴링으로 고통받던 과거 컴포넌트 구조
<App>
  <Header cartCount={cartCount} /> {/* 헤더에 전달 */}
  <MainPage>
    <ProductList>
      <ProductCard onAddToCart={handleAddToCart} /> {/* 3단계를 거쳐 함수 전달 */}
    </ProductList>
  </MainPage>
</App>

중간에 있는 MainPage나 ProductList는 onAddToCart 함수를 직접 쓰지도 않으면서 단순히 자식에게 넘겨주기 위해 props를 억지로 받아야 했습니다. 이 구조는 나중에 컴포넌트 위치가 바뀌거나 기능이 추가될 때마다 치명적인 리팩토링 비용을 발생시켰습니다.

2. 왜 Redux 대신 Zustand를 선택했는가?

전역 상태 라이브러리를 고민할 때 가장 먼저 떠오른 것은 Redux였지만, 작은 규모의 기능 하나를 추가하기 위해 작성해야 할 보일러플레이트 코드가 너무 많았습니다. 반면 Zustand는 다음과 같은 압도적인 장점이 있었습니다.

  1. 단순한 훅(Hook) 기반: 별도의 Context Provider로 앱 전체를 감쌀 필요가 없음.
  2. 극도로 가벼운 번들 크기: 수십 KB에 달하는 다른 라이브러리에 비해 약 1KB 수준으로 초경량.
  3. 타입스크립트(TypeScript) 완벽 지원: 복잡한 제네릭 설정 없이 직관적인 타입 추론 가능.

3. 실무 적용: 10줄로 만드는 Zustand 전역 스토어

Zustand의 핵심은 create 함수를 사용해 스토어(Store)를 정의하고, 필요한 컴포넌트에서 일반 커스텀 훅처럼 가져다 쓰는 것입니다.

TypeScript
 
// store/useCartStore.ts (장바구니 전역 스토어)
import { create } from 'zustand';

interface CartState {
  cartCount: number;
  addToCart: () => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>((set) => ({
  cartCount: 0,
  // set 함수를 사용해 상태를 불변성 유지하며 업데이트
  addToCart: () => set((state) => ({ cartCount: state.cartCount + 1 })),
  clearCart: () => set({ cartCount: 0 }),
}));

Provider로 최상단 App 컴포넌트를 감싸는 과정조차 필요 없이, 이 파일 하나로 전역 상태 세팅이 끝납니다.

인터페이스 정의와 Zustand create 스토어 구조


4. 컴포넌트에서 사용하기: "필요한 곳에서 바로 꺼내 쓴다"

이제 프롭스 드릴링 없이, 상태가 필

요한 컴포넌트에서 훅을 호출하기만 하면 됩니다.

TypeScript
 
// 1. 헤더 컴포넌트: cartCount 값만 구독
import { useCartStore } from '../store/useCartStore';

export function Header() {
  const cartCount = useCartStore((state) => state.cartCount);
  return <header>장바구니: 🛒 {cartCount}개</header>;
}

// 2. 상품 카드 컴포넌트: addToCart 함수만 호출
export function ProductCard() {
  const addToCart = useCartStore((state) => state.addToCart);
  return <button onClick={addToCart}>장바구니 담기</button>;
}

중간 컴포넌트들을 거칠 필요 없이 각자 필요한 데이터와 함수만 직접 구독하므로, 불필요한 리렌더링도 방지되고 코드가 엄청나게 깔끔해졌습니다.

담은 상품 수에 따라 달라지는 금액

5. 결론 및 느낀 점

Zustand를 도입하고 나서 리액트 상태 관리의 피로도가 절반 이하로 줄어들었습니다.

  • 상태 하나 만드는데 1분도 채 걸리지 않는 압도적인 생산성
  • 불필요한 props 전달이 사라져 깔끔해진 컴포넌트 트리 구조

컴포넌트 깊이가 깊어지면서 props를 넘겨주는 데 스트레스를 받고 계신다면, 무거운 라이브러리 대신 가볍고 직관적인 Zustand로 전역 상태 관리를 시작해 보시길 강력히 추천합니다!

리액트(React) 프로젝트의 규모가 커지면 필연적으로 마주하게 되는 현상이 있습니다. 바로 Props Drilling입니다. 최상위 컴포넌트에 있는 데이터를 5단계 아래에 있는 자식 컴포넌트로 전달하기 위해, 중간에 있는 컴포넌트들이 그 데이터를 사용하지 않음에도 불구하고 단순히 '전달'만 하는 고통스러운 상황을 말합니다.

Props Drilling 그 자체는 반드시 나쁜 것은 아니지만, 깊이가 깊어질수록 코드를 추적하기 어렵게 만들고 리팩토링을 불가능하게 만드는 주범이 됩니다. 오늘은 이 문제를 해결하기 위한 세 가지 전략을 단계별로 알아보겠습니다.


1. 첫 번째 단계: 컴포넌트 합성 (Component Composition)

많은 개발자가 Props Drilling을 발견하면 즉시 Context API나 상태 관리 라이브러리를 떠올립니다. 하지만 리액트 팀에서 권장하는 가장 첫 번째 해결책은 컴포넌트 합성입니다.

1.1 children을 활용한 구조 개선

데이터를 하위로 계속 내려보내는 대신, 컴포넌트를 조립하는 방식으로 구조를 변경해 보세요.

JavaScript
 
// 기존 방식 (Drilling 발생)
<Parent data={data}>
  <Intermediate data={data}>
    <Child data={data} />
  </Intermediate>
</Parent>

// 컴포넌트 합성 방식
<Parent>
  <Intermediate>
    <Child data={data} />
  </Intermediate>
</Parent>

이렇게 하면 Intermediate 컴포넌트는 data를 알 필요가 없습니다. 단순히 children으로 받은 내용을 렌더링 하기만 하면 됩니다. 데이터를 직접 사용하는 컴포넌트를 부모 수준으로 끌어올림으로써 의존성을 끊어내는 것이 핵심입니다.


2. 두 번째 단계: Context API (의존성 주입)

합성만으로 해결하기에 구조가 너무 복잡하거나, 앱 전체에서 공유되어야 하는 데이터(테마, 로그인 유저 정보 등)가 있다면 리액트 내장 기능인 Context API를 사용합니다.

2.1 Context API의 목적

Context는 '전역 상태 관리' 도구라기보다 '의존성 주입(Dependency Injection)' 도구에 가깝습니다. 특정 범위(Provider) 내에 있는 모든 컴포넌트가 계층 구조와 상관없이 데이터에 접근할 수 있게 해줍니다.

2.2 주의할 점: 리렌더링 최적화

Context의 값이 바뀌면 해당 Provider를 구독하고 있는 모든 하위 컴포넌트가 리렌더링 됩니다. 따라서 자주 바뀌는 동적인 데이터보다는 변경 빈도가 낮은 설정 데이터를 관리하는 데 적합합니다.

JavaScript
 
const UserContext = createContext();

function App() {
  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  );
}

function UserProfile() {
  const user = useContext(UserContext); // 중간 단계 생략하고 바로 사용
  return <div>{user.name}</div>;
}

3. 세 번째 단계: 외부 상태 관리 라이브러리 (Zustand)

복잡하고 빈번하게 변하는 상태(장바구니, 필터링 조건 등)를 관리해야 한다면 전용 라이브러리의 힘을 빌려야 합니다. 과거에는 Redux가 대세였지만, 최근에는 설정이 간편하고 성능이 뛰어난 Zustand가 각광받고 있습니다.

3.1 왜 Zustand인가?

  • 보일러플레이트 제로: Redux처럼 복잡한 초기 설정이 필요 없습니다.
  • 선택적 구독 (Selector): 컴포넌트가 필요한 상태만 선택해서 구독할 수 있어, 불필요한 리렌더링이 발생하지 않습니다.
  • 리액트 외부에서도 접근 가능: 훅(Hook) 형태뿐만 아니라 일반 JS 로직에서도 상태를 읽고 쓸 수 있습니다.

3.2 실전 예시

JavaScript
 
import { create } from 'zustand';

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));

function AddButton({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>담기</button>;
}

4. 어떤 상황에서 무엇을 선택해야 할까?

가장 중요한 것은 상황에 맞는 도구를 선택하는 선구안입니다.

  1. 컴포넌트 합성: Props 전달 단계가 2~3단계 내외이며, UI 구조를 변경함으로써 해결 가능한 경우. (가장 깔끔한 코드 유지 가능)
  2. Context API: 다크 모드, 현재 언어(i18n), 로그인 정보처럼 앱 전체가 알아야 하지만 자주 바뀌지 않는 정보를 다룰 때.
  3. Zustand: 여러 컴포넌트가 복잡한 상태를 서로 공유하고, 업데이트가 매우 빈번하며, 성능 최적화가 필수적인 경우.

+ Recent posts