Dev Thinking
32완료

상태 공유의 지혜 - Lifting State Up 패턴

2025-08-19
7분 읽기

리액트 공식문서 기반의 리액트 입문기

들어가며

여러 컴포넌트가 같은 정보를 바라보는 순간, 동기화는 우리의 손을 타기 시작합니다. 검색 입력과 결과 수, 그리고 실제 목록이 서로 다른 위치에서 갱신되면 언제든 불일치가 생길 수 있습니다. 이 편에서는 이런 상황에서 리액트가 권하는 해결책, 즉 상태를 공통 부모로 끌어올리는 Lifting State Up 패턴을 정리합니다. 먼저 우리가 늘 사용해온 방식으로 간단한 예시를 만들어 보고, 이제 리액트 버전으로 넘어가 보겠습니다.

단방향 데이터 흐름과 SSOT

자바스크립트엔 없는 리액트의 특징으로, 상태를 공통 부모에 두고 아래로만 흘려보내는 단방향 데이터 흐름(one-way data flow)을 활용합니다. 여러 컴포넌트가 같은 데이터를 바라봐야 할 때, 상태의 SSOT(Single Source of Truth)을 유지하여 불일치를 줄입니다. 잠깐 살펴보자면, 입력 컴포넌트는 변경 이벤트를 올려 보내고, 부모는 상태를 업데이트한 뒤 자식들에게 일관된 값을 다시 내려보냅니다.

문제 시나리오: 형제 컴포넌트 불일치

이 시나리오는 바닐라 자바스크립트식 수동 동기화(명령형 접근)에서 흔히 발생하는 상황을 가정합니다. 리액트의 고유 동작 문제가 아니라, 리액트 도입 전/미사용 환경에서 자주 겪는 불일치 사례를 설명합니다.

먼저 우리가 늘 사용해온 방식으로 생각해 보면, 입력 상자·결과 개수·목록이 서로 다른 위치에서 제각각 갱신될 수 있습니다. 작은 어긋남이 누적되면 “입력은 바뀌었는데, 개수나 목록이 이전 상태를 보여주는” 불일치가 발생합니다.

[Input] --(이벤트)--> [업데이트 로직1]
   └───────────────X──> [업데이트 로직2]
   └───────────────X──> [업데이트 로직3]
 
어떤 경로가 빠지거나 순서가 어긋나면 표시들이 어긋남

이 문제를 줄이기 위해 리액트는 상태를 공통 부모에 모으고, 아래로만 값을 전달합니다. 입력은 변경 “요청”만 올리고, 부모가 결정한 최종 상태가 모든 자식에 동일하게 반영됩니다.

상태 끌어올리기 절차

  1. 상태를 “읽는” 컴포넌트들을 식별합니다. 2) 그들의 가장 가까운 공통 부모를 찾습니다. 3) 그 부모로 상태를 옮깁니다. 4) 값은 props로 하향 전달합니다. 5) 변경은 콜백으로 상향 전파합니다. 6) 파생값은 가능한 한 계산으로 처리합니다.

자바스크립트 방식의 특징(개념)

  1. 함께 움직이게 만들기 어렵다: 화면의 여러 곳을 내가 직접 같이 바꿔줘야 합니다. 하나라도 빼먹으면 바로 어긋납니다.
  2. 길이(경로)가 여러 개라 잊기 쉽다: 입력 → 개수 → 목록처럼 업데이트 경로가 흩어져 있어 한 줄 빠뜨리기 쉽습니다.
  3. 타이밍 싸움이 생긴다: 무엇이 먼저/나중에 실행되느냐에 따라 서로 다른 값을 보여줄 수 있습니다.
  4. 커질수록 복잡해진다: 표시 지점이 늘수록 의존 관계와 업데이트 경로를 파악하기가 점점 어려워집니다.
  5. 데이터 출처가 한눈에 안 보인다: 코드만 봐서는 “이 값이 누구 것인지” 알기 어렵습니다.

리액트로 동일한 UI 만들기 (Lifting State Up)

이제 리액트 버전으로 넘어가 보겠습니다. 상태를 공통 부모인 App에 두고, 입력과 결과 표시, 리스트는 모두 props로 값을 전달받습니다. 입력의 변경은 상향(onChange) 전파로 부모 상태를 바꾸고, 부모는 새 상태를 하향으로 다시 내려보냅니다.

import { useState } from 'react';
 
function SearchBox({ filterText, onFilterTextChange }) {
  return (
    <input
      type="text"
      placeholder="검색어 입력"
      value={filterText}
      onChange={e => onFilterTextChange(e.target.value)}
    />
  );
}
 
function ResultCount({ count }) {
  return <p>결과: {count}개</p>;
}
 
function ItemList({ items }) {
  return (
    <ul>
      {items.map(name => (
        <li key={name}>{name}</li>
      ))}
    </ul>
  );
}
 
export default function App() {
  const [filterText, setFilterText] = useState('');
  const allItems = ['React', 'Vue', 'Svelte', 'Angular', 'Solid'];
 
  const filtered = allItems.filter(name => name.toLowerCase().includes(filterText.toLowerCase()));
 
  return (
    <div>
      <h1>검색 필터</h1>
      <SearchBox filterText={filterText} onFilterTextChange={setFilterText} />
      <ResultCount count={filtered.length} />
      <ItemList items={filtered} />
    </div>
  );
}

여기서는 filterText가 오직 App에만 존재합니다. 입력은 onFilterTextChange로 변경 요청만 올리고, 실제 값의 소유권은 부모가 유지합니다. 그 결과 입력, 개수, 리스트가 항상 같은 상태를 바라보게 됩니다. 이제 둘 사이의 차이를 살펴보겠습니다.

리액트 방식의 특징 (Lifting State Up)

  • 1. SSOT(Single Source of Truth) 확립: Lifting State Up 패턴은 여러 컴포넌트가 동일한 상태를 공유해야 할 때, 그 상태를 가장 가까운 공통 부모 컴포넌트로 끌어올려 SSOT로 삼는 것을 핵심으로 합니다. 이는 상태의 불일치(inconsistency)를 근본적으로 방지하고, 어떤 컴포넌트도 독자적으로 상태를 변경하지 못하게 하여 예측 가능한 상태 관리를 가능하게 합니다.
  • 2. 명확한 단방향 데이터 흐름: 리액트의 Lifting State Up은 상태는 부모에서 자식으로 props를 통해 '하향(downward)' 전달하고, 자식 컴포넌트의 상태 변경 요청은 콜백 함수를 통해 부모로 '상향(upward)' 전파하는 명확한 단방향 데이터 흐름을 확립합니다. 이러한 흐름은 데이터의 출처와 변경의 파급 효과를 직관적으로 파악할 수 있게 하여 코드의 가독성과 디버깅 용이성을 크게 향상시킵니다.
  • 3. 제어 컴포넌트 (Controlled Components) 패턴의 활용: 입력 필드와 같은 폼 요소의 값을 리액트의 상태로 관리하는 제어 컴포넌트 패턴은 Lifting State Up과 밀접하게 연결됩니다. 입력 값은 부모 컴포넌트의 상태에서 value prop으로 전달되고, 사용자의 입력에 따른 변경은 onChange와 같은 이벤트 핸들러를 통해 부모의 상태 업데이트 함수(콜백)를 호출하여 이루어집니다. 이는 폼 요소의 상태를 리액트가 완벽하게 제어할 수 있도록 하여, 일관된 사용자 경험과 데이터 유효성 검사를 쉽게 구현할 수 있게 합니다.
  • 4. 관심사 분리와 재사용성 증대: Lifting State Up은 상태 관리의 책임을 공통 부모에게 위임하고, 자식 컴포넌트들은 오직 전달받은 props를 기반으로 UI를 렌더링하는 역할에 집중하게 합니다. 이러한 관심사의 분리는 각 컴포넌트의 응집도를 높이고 결합도를 낮춤으로써, 컴포넌트의 재사용성을 크게 향상시킵니다. 예를 들어, SearchBox, ResultCount, ItemList 컴포넌트들은 자신들이 어떤 데이터에 의해 렌더링되는지 알 필요 없이 오직 props만으로 작동할 수 있게 됩니다.
  • 5. 확장 용이성과 유지보수성: 새로운 표시 컴포넌트가 추가되더라도, 이미 공통 부모에 관리되는 상태를 props로 전달받기만 하면 되므로 상태 공유 로직을 변경할 필요가 없습니다. 이는 애플리케이션의 확장을 용이하게 하고, 상태 관리 로직의 변경이 필요한 경우에도 특정 컴포넌트에 국한되지 않고 SSOT에서만 관리되므로 유지보수 비용을 절감하는 데 큰 이점을 제공합니다.

과도한 끌어올림의 비용과 대안

  • props drilling 증가: 트리 깊이가 깊을수록 중간 단계에 필요 없는 props가 전달됩니다.
  • 불필요 리렌더링: 공통 부모에 모아둔 상태 변화가 많은 자식에게 파급될 수 있습니다.
  • 상태 위치 고착: 너무 일찍 끌어올리면 컴포넌트의 자율성이 떨어집니다.

대안과 완충 장치

  • 파생값은 계산: 굳이 상태로 들지 않고, 기존 상태로부터 파생되는 값은 렌더링 시점에 계산하여 사용합니다.
  • 상태 분리: 독립적으로 관리될 수 있는 상태(예: 로컬 UI 상태)는 각 컴포넌트 내부에 남겨둡니다.
  • Context API: 여러 레벨을 가로지르는 공통 데이터(예: 테마, 현재 사용자 정보)를 전달할 때 props drilling 없이 효율적으로 상태를 공유할 수 있습니다. (4-4편에서 상세히 다룹니다.)
  • useReducer Hook: 상태 업데이트 로직이 복잡할 때, useReducer를 사용하여 상태 관리 로직을 추출하고 명시적으로 만듭니다. (4-3편에서 상세히 설명합니다.) Context API와 함께 사용하면 복잡한 전역 상태 관리에도 효과적입니다. (4-4편에서 상세히 다룹니다.)

이 지점에서 자연스럽게 드는 질문은, "그럼 우리 상황에서 어디까지 끌어올려야 할까?"일 것입니다. 다음 체크리스트는 과도한 상태 끌어올림을 피하면서도 일관성을 확보하도록 판단을 돕는 최소한의 가드레일이라고 생각하고 작성해 보았습니다.

선택 기준 체크리스트

  • 이 상태를 “읽는” 컴포넌트가 여러 개인가? 그리고 서로 형제/떨어진 위치에 있는가?
    • 읽는 주체가 많고 서로 떨어져 있다면, 공통 부모로 끌어올려 SSOT를 만드는 편이 어긋남을 줄입니다.
  • 이 상태를 “변경”하는 이벤트는 어디에서 주로 발생하는가?
    • 변경이 특정 하위에서만 일어난다면, 그 하위에서 콜백으로 상향 전파하고 부모가 최종 소유권을 갖는 구조가 예측 가능합니다.
  • 파생값으로 “계산”해도 되는가(렌더에서 계산 가능하면 상태로 두지 않기)?
    • 길이/합계/필터 결과처럼 원본으로부터 결정 가능한 값은 상태로 들지 않아야 의존성을 줄일 수 있습니다.
  • 트리 깊이가 깊어 중간 단계에 필요 없는 props가 전파되는가?
    • 중간 단계가 과도하게 통로 역할만 한다면, 끌어올리기 대신 Context를 고려해 전달 비용을 낮춥니다.
  • 업데이트 로직이 복잡해 테스트·리듀서가 필요한가?
    • 분기/케이스가 많다면 리듀서로 명시화하여 동작을 문서화하고 테스트하기 쉽습니다.
  • 트리 전역으로 공유해야 하는가 아니면 현재 범위로 충분한가?
    • 전역적 사용이라면 Context로 범위를 확장하고, 아니라면 부모 범위에 머무는 편이 단순합니다.

체크리스트를 통해 끌어올림의 “필요 충분 조건”을 가늠한 뒤, 실제 적용에서는 로컬 상태와 공유 상태를 분리해 혼합하는 전략이 유효합니다. 즉, 공통 데이터는 부모로 올리되, 포커스/열림 같은 미세한 상호작용 상태는 각 컴포넌트에 남겨 관리합니다.

언제 끌어올리지 말아야 하나

이 섹션에서는 상태를 공통 부모로 올리지 않는 편이 더 단순하고 안전한 상황을 예시와 함께 정리합니다. 무엇을, 왜, 언제 로컬로 두어야 하는지 기준을 제시합니다.

  1. 로컬 UI 상태: 입력 필드의 포커스 여부, 드롭다운 메뉴의 열림/닫힘 상태, 일시적인 에러 메시지 등은 해당 컴포넌트 내부에 두는 편이 단순합니다.
    • 공유 필요성이 낮고, 상위로 올리면 불필요한 리렌더링 파급과 props drilling이 증가할 수 있습니다.
  2. 일시적인 전용 상태: 특정 자식 컴포넌트에서만 소비되고 다른 곳과 공유되지 않는 상태.
    • 상태의 소유 범위가 명확하므로 해당 범위에서만 관리하는 것이 컴포넌트의 응집도를 높입니다.
  3. 파생 가능 상태: 배열의 길이, 숫자의 합계, 필터링된 결과 등은 원본 상태로부터 쉽게 계산할 수 있습니다.
    • 불필요한 동기화 지점을 줄여 의존성 및 잠재적인 오류 가능성을 낮춥니다.
  4. 빈번한 업데이트로 파급이 큰 상태: 초 단위로 변경되는 입력 값이나 스크롤 위치 등은 상위로 끌어올릴 경우 불필요한 리렌더링 파급이 커질 수 있습니다.
    • 이러한 상태는 로컬로 유지하거나, debounce/throttle과 같은 최적화 전략을 동반하여 관리하는 것이 좋습니다.

이러한 원칙을 알면 좋은 이유는 단순합니다. 상태의 위치를 올바르게 정하면, 동기화 버그를 예방하면서도 리렌더링 비용을 통제할 수 있고, 컴포넌트의 응집도와 가독성을 함께 확보할 수 있습니다. 결과적으로 유지보수성이 높아지고, 팀 차원에서 예측 가능한 코드베이스를 만들 수 있습니다.

요약

이번 편에서는 상태를 공통 부모로 끌어올리는 Lifting State Up과 수동 동기화 방식의 차이를 사례와 함께 살펴보았습니다. 공부한 것을 정리해보면 다음과 같습니다.

  • 핵심 원칙: 상태를 사용하는 컴포넌트를 찾고, 공통 부모에 상태 배치
  • SSOT: 공통 부모의 상태를 단일로 유지
  • 데이터 흐름: 하향(props)·상향(이벤트)으로 명확한 단방향 흐름
  • 적용 기준: 상태를 공유해야 할 때에만 끌어올리고, 과도한 상향은 지양

참고문서

컴포넌트 간 State 공유하기 (Sharing State Between Components)