본문으로 건너뛰기

안동민 개발노트

본문 시작

컴포넌트 간 상태 공유의 어려움

상태 끌어올리기와 단방향 데이터 흐름을 복습하고 깊은 컴포넌트 트리에서 props drilling이 만드는 결합을 분석합니다.

상태 관리 입문에서는 리액트 애플리케이션 개발에서 가장 중요하면서도 도전적인 주제 중 하나인 상태 관리(State Management)를 다룹니다.

첫 단계로, 왜 상태 관리라는 별도 개념과 라이브러리가 필요한지, 그리고 리액트 기본 기능만으로 컴포넌트 간 상태 공유가 왜 어려운지를 짚어봅니다.

이 어려움을 이해하는 것이 효과적인 상태 관리 솔루션을 선택하고 활용하는 첫걸음입니다.

컴포넌트 간 상태 공유의 어려움

여러 컴포넌트가 같은 상태를 읽고 변경할 때는 데이터 소유자와 갱신 경로를 먼저 정해야 합니다. Props 전달, Context, 외부 저장소 중 어떤 경계를 선택할지 비교합니다.

  1. 상태 1
    리액트의 상태와 데이터 흐름 복습

    리액트의 핵심은 컴포넌트 기반 아키텍처와 단방향 데이터 흐름(Unidirectional 데이터 흐름)입니다.

  2. 상태 2
    컴포넌트 간 상태 공유의 일반적인 시나리오

    리액트 애플리케이션을 개발하다 보면 필연적으로 여러 컴포넌트가 동일한 상태를 참조하거나 변경해야 하는 상황에 직면합니다.

  3. 상태 3
    상태 공유의 어려움: Props Drilling

    리액트의 기본인 props를 사용해서 위와 같은 상태 공유 시나리오를 해결하려고 하면 곧바로 Props Drilling이라는 문제에 부딪히게 됩니다.


리액트의 상태와 데이터 흐름 복습

리액트의 핵심은 컴포넌트 기반 아키텍처단방향 데이터 흐름(Unidirectional Data Flow)입니다.

  • 컴포넌트의 상태 (useState): 각 컴포넌트는 독립적인 상태(state)를 가질 수 있으며, 이 상태가 변경되면 해당 컴포넌트와 그 하위 컴포넌트들이 다시 렌더링됩니다.
  • 프롭스 (props): 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하는 유일한 방법입니다. 데이터는 항상 위에서 아래로(부모에서 자식으로) 흐릅니다.

이러한 단방향 데이터 흐름은 애플리케이션의 동작을 예측 가능하게 만들고 디버깅을 쉽게 한다는 큰 장점을 가지고 있습니다.

하지만 특정 상황에서는 오히려 상태 공유를 복잡하게 만듭니다.


컴포넌트 간 상태 공유의 일반적인 시나리오

리액트 애플리케이션을 개발하다 보면 필연적으로 여러 컴포넌트가 동일한 상태를 참조하거나 변경해야 하는 상황에 직면합니다.

몇 가지 예시를 들어보겠습니다.

쇼핑 카트 (장바구니)
  • ProductList 컴포넌트 (상품 목록 표시)
  • AddToCartButton 컴포넌트 (상품 추가 버튼)
  • CartIcon 컴포넌트 (장바구니에 담긴 상품 개수 표시)
  • CartModal 컴포넌트 (장바구니 상세 내용 표시) 이 모든 컴포넌트들은 장바구니 상태(상품 목록, 총 개수, 총 가격)를 공유하고 변경해야 합니다.
사용자 인증 (로그인 상태)
  • LoginPage 컴포넌트 (로그인 폼)
  • Header 컴포넌트 (로그인/로그아웃 버튼, 사용자 이름 표시)
  • Dashboard 컴포넌트 (로그인 여부에 따라 내용 변경) 애플리케이션 전반에 걸쳐 사용자의 로그인 상태가 공유되어야 합니다.
다크 모드/라이트 모드 테마
  • ThemeToggle 컴포넌트 (테마 전환 버튼)
  • Header, Footer, MainContent 등 다양한 컴포넌트 (테마에 따라 스타일 변경)

현재 테마 모드 상태는 여러 컴포넌트에 영향을 미칩니다.


상태 공유의 어려움: Props Drilling

Props Drilling과 Context 경계

theme 같은 공유 값이 깊은 컴포넌트에서만 필요하면 중간 계층은 값을 읽지 않아도 계속 props를 넘기게 된다.

  1. Props Drilling 경로
    공유 상태를 만든다.

    MainContent · 사용하지 않고 전달한다. · ContentSection · 또 한 번 넘긴다. · Consumer · 실제 값을 읽는다.

  2. 전달 계층

    두세 단계 이상 반복되면 구조 신호다.

  3. 사용 위치

    값을 쓰는 곳이 멀리 흩어져 있는가.

  4. 변경 빈도

    자주 바뀌면 Context 분할을 검토한다.

리액트의 기본인 props를 사용해서 위와 같은 상태 공유 시나리오를 해결하려고 하면 곧바로 Props Drilling이라는 문제에 부딪히게 됩니다.

Props Drilling이란?

상태(state)를 필요한 자식 컴포넌트에 도달시키기 위해, 중간에 위치한 여러 컴포넌트들을 거쳐 props로 계속해서 전달해야 하는 현상을 의미합니다.

Props Drilling 예시: 테마 전환

간단한 테마 전환 예시를 통해 Props Drilling의 문제를 시각화해 보겠습니다.

App.js (Props Drilling 예시)
import React, { useState } from 'react';
import Header from './Header';
import MainContent from './MainContent';
import Footer from './Footer';

function App() {
  const [theme, setTheme] = useState('light'); // 최상위 App에서 테마 상태 관리

  const toggleTheme = () => {
    setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
      <h1>프롭스 드릴링 예시</h1>
      <button onClick={toggleTheme}>테마 전환 ({theme})</button>
      {/* Header, MainContent, Footer에 theme과 toggleTheme을 전달해야 함 */}
      <Header theme={theme} toggleTheme={toggleTheme} />
      <MainContent theme={theme} />
      <Footer theme={theme} />
    </div>
  );
}

export default App;
Header.js
import React from 'react';

function Header({ theme, toggleTheme }) { // theme, toggleTheme을 props로 받음
  return (
    <header style={{ backgroundColor: theme === 'light' ? '#eee' : '#333', color: theme === 'light' ? '#333' : '#eee', padding: '15px', borderRadius: '5px', marginBottom: '20px' }}>
      <h2>헤더</h2>
      <p>현재 테마: {theme}</p>
      {/* Header 내부에서 토글 버튼을 사용하지 않아도 props를 계속 내려줘야 함 */}
      {/* <button onClick={toggleTheme}>테마 전환 (여기에 있어도)</button> */}
    </header>
  );
}

export default Header;
MainContent.js
import React from 'react';
import ContentSection from './ContentSection'; // 더 깊은 자식 컴포넌트

function MainContent({ theme }) { // theme을 props로 받음
  return (
    <div style={{ backgroundColor: theme === 'light' ? '#f8f8f8' : '#555', color: theme === 'light' ? '#333' : '#eee', padding: '20px', borderRadius: '5px', marginBottom: '20px' }}>
      <h3>메인 콘텐츠</h3>
      <p>이곳은 주요 내용이 표시되는 공간입니다.</p>
      {/* ContentSection에 theme을 또 전달해야 함 */}
      <ContentSection theme={theme} />
    </div>
  );
}

export default MainContent;
ContentSection.js
import React from 'react';

function ContentSection({ theme }) { // theme을 props로 받음
  return (
    <div style={{ border: `1px solid ${theme === 'light' ? '#ccc' : '#888'}`, padding: '15px', borderRadius: '5px', marginTop: '15px' }}>
      <h4>콘텐츠 섹션</h4>
      <p>테마에 따라 이 텍스트의 색깔도 변합니다.</p>
    </div>
  );
}

export default ContentSection;
Footer.js
import React from 'react';

function Footer({ theme }) { // theme을 props로 받음
  return (
    <footer style={{ backgroundColor: theme === 'light' ? '#eee' : '#333', color: theme === 'light' ? '#333' : '#eee', padding: '15px', borderRadius: '5px' }}>
      <p>&copy; 2024 테마 앱. 현재 테마: {theme}</p>
    </footer>
  );
}

export default Footer;

위 예시에서 theme 상태는 App 컴포넌트에서 선언되었지만, 실제 theme 값을 사용하는 ContentSection 컴포넌트까지 도달하기 위해 Header, MainContent를 거쳐 props로 계속해서 전달되어야 합니다.

심지어 Header 컴포넌트는 theme 값을 직접 사용하지 않더라도 toggleTheme 함수를 하위 컴포넌트에 전달해야 한다면 계속해서 props를 받아야 합니다.

프롭스 드릴링의 문제점

코드의 복잡성 증가: 중간 컴포넌트들은 자신에게는 필요 없는 props를 단순히 전달하기 위해 정의해야 하므로, 코드가 불필요하게 길어지고 복잡해집니다.

유지보수성 저하: 상태를 사용하는 컴포넌트가 변경되거나, 중간에 새로운 컴포넌트가 추가되면, 이 props를 전달하는 모든 상위/중간 컴포넌트의 코드도 함께 수정해야 합니다.

이는 작은 변경에도 큰 영향을 미칠 수 있습니다.

디버깅의 어려움: 특정 상태의 값이 어디서부터 오는지, 어떤 컴포넌트들을 거쳐 전달되는지 파악하기 어려워집니다.

성능 저하 가능성 (간접적): props가 변경되면 해당 컴포넌트와 모든 자식 컴포넌트가 재렌더링됩니다.

비록 리액트가 효율적인 재렌더링을 하지만, 불필요한 props 전달은 잠재적으로 최적화를 어렵게 만들 수 있습니다.


상태 관리의 필요성

리액트의 단방향 데이터 흐름은 예측 가능하고 안정적인 구조를 제공하지만, 애플리케이션의 규모가 커지고 컴포넌트 트리가 깊어지며 여러 컴포넌트가 광범위하게 상태를 공유해야 할 때, 프롭스 드릴링과 같은 문제에 직면하게 됩니다.

아래 다이어그램은 상태가 여러 컴포넌트에 퍼질 때 어떤 신호를 보고 공용 상태 관리로 옮길지 정리한 것입니다.

공용 상태로 옮길 신호는 같은 값이 여러 곳에서 함께 바뀌는 순간이다

props drilling 자체보다 동일한 상태와 변경 함수가 여러 갈래로 전달되고 수정 규칙이 퍼질 때 저장 위치를 다시 설계한다.

  1. props drilling 자체보다
  2. 동일한 상태

    변경 함수가 여러 갈래로 전달되고 수정 규칙이 퍼질 때 저장 위치를 다시 설계한다.

마지막으로 상태를 어디에 둘지 결정할 때는 "공유 여부"만 보지 말고, 변경 빈도와 영향 범위를 함께 봐야 합니다.

같은 값이라도 한 화면 안에서만 빠르게 변하면 지역 상태가 낫고, 여러 화면에서 같은 기준으로 읽고 바꾸면 Context나 전용 상태 관리 도구를 검토하는 식으로 배치 기준을 세우면 됩니다.

상태 배치 결정 기준

공유가 필요하다는 이유만으로 모든 값을 전역화하지 않습니다. 누가 읽고, 누가 바꾸며, 얼마나 자주 바뀌는지를 함께 보면 저장 위치가 분명해집니다.

  1. Local
    가까운 컴포넌트에 둔다

    입력값, 탭 열림, 모달 표시처럼 화면 일부에서만 쓰이는 값은 가장 가까운 곳에 둡니다.

  2. Lift
    공통 부모로 올린다

    형제 컴포넌트가 같은 값을 읽거나 바꾸면 공통 부모에 두고 props로 내려보내는 것이 단순합니다.

  3. Shared
    Context나 store 분리

    여러 화면과 깊은 트리가 같은 상태를 공유하면 공용 통로나 상태 관리 도구가 유지보수 비용을 낮춥니다.

  4. 범위

    누가 읽나

  5. 빈도

    자주 바뀌나

상태 위치를 정할 때는 지역 상태, 상태 끌어올리기, 공용 관리 중 어느 범위가 가장 작은 충분한 해법인지 먼저 비교해 보세요.

상태 위치 결정 기준

가까운 컴포넌트만 쓰는 값은 지역에 두고, 여러 화면이 같은 기준으로 읽는 값은 공용 위치를 검토합니다.

  1. 지역 상태
    한 컴포넌트 안에서 끝남

    입력값, 열림 여부, 탭 선택처럼 가까운 UI만 바꾸는 값에 적합합니다.

  2. 상태 끌어올리기
    형제 컴포넌트가 함께 씀

    공통 부모가 값을 들고 필요한 자식에게 props로 내려줍니다.

  3. 공용 관리
    트리 여러 곳에서 필요

    인증, 테마, 장바구니처럼 전달 경로가 길어질 때 검토합니다.

이러한 문제들을 해결하고, 애플리케이션 전반에 걸쳐 상태를 효율적이고 전역적으로 관리하기 위해 상태 관리 라이브러리(State Management Library)가 필요해집니다.

상태 관리 라이브러리는 공통된 상태를 컴포넌트 트리의 최상단이나 특정 위치에 저장하고, 필요한 컴포넌트에서만 이 상태에 직접 접근하고 변경할 수 있도록 하는 메커니즘을 제공합니다.


컴포넌트 간 상태 공유의 어려움은 여기까지입니다.

이 장에서는 리액트의 기본 상태 관리 방식과 단방향 데이터 흐름의 장점을 복습하고, 여러 컴포넌트 간에 상태를 공유할 때 발생하는 주요 문제점인 프롭스 드릴링의 개념과 그로 인한 어려움을 구체적인 예시를 통해 이해했습니다.

이제 왜 별도의 상태 관리 솔루션이 필요한지에 대한 공감대가 형성되었을 것입니다.


상태 공유 문제는 도구 선택보다 값의 소유자와 소비자 사이의 거리를 먼저 확인해야 합니다.

원거리 상태 공유 리스크

props drilling이 생기면 먼저 상태 위치를 다시 보고, 필요할 때 Context나 전역 상태 도구로 읽는 범위를 넓힙니다.

  1. 1
    owner

    값을 실제로 바꾸는 이벤트가 어디에서 발생하는지 상태 소유자를 찾습니다.

  2. 2
    consumer

    값을 읽기만 하는 컴포넌트가 트리 여러 곳에 흩어져 있는지 확인합니다.

  3. 3
    drilling

    중간 컴포넌트가 쓰지 않는 props를 전달만 하면 구조 신호로 봅니다.

  4. 4
    lift

    까운 공통 부모로 상태를 올리면 충분한지 먼저 확인합니다.

  5. 5
    split

    서로 관련 없는 값은 같은 상태 묶음으로 만들지 않습니다.

리액트의 상태와 데이터 흐름 복습과 컴포넌트 간 상태 공유 시나리오를 중심으로 정리한 보조 다이어그램입니다.

상태 소유자와 공유 범위를 정하는 지도

도구보다 먼저 모든 소비자와 변경 지점을 품는 “가장 가까운 충분한 소유자”를 찾는다.

  1. READ
    필요한 값만 아래로

    Summary · CardList · 소비자는 원본을 복사하지 않고 화면만 그린다.

  2. OWNER
    Dashboard가 단일 원본을 보유

    읽는 곳과 바꾸는 곳을 모두 포함하는 공통 부모

  3. WRITE
    변경 의도만 위로

    Filter · Toolbar · 자식은 state를 복제하지 않고 이벤트를 알린다.

  4. Local

    한 컴포넌트만 읽고 쓴다.

  5. Lift up

    같은 화면의 형제가 공유한다.