React 상태 올리기 — 대부분의 경우 하지 않는 게 낫다

10 min read
React상태 관리useState리팩토링안티패턴
React 상태 올리기 — 대부분의 경우 하지 않는 게 낫다

React 공식 문서가 "lift state up(상태 끌어올리기)"을 워낙 강조하다 보니, 많은 개발자가 반사적으로 상태를 상위 컴포넌트로 올린다. 문제는 올릴 필요가 없는 상태까지 올리는 경우가 생각보다 많다는 거다. 결과물은 prop drilling, 불필요한 리렌더, 그리고 "왜 이 상태가 여기에 있지?" 싶은 코드다.

정의

lift state up은 여러 컴포넌트가 같은 상태를 공유해야 할 때, 그 상태를 가장 가까운 공통 부모로 옮기는 패턴이다. 이 패턴 자체는 틀리지 않았다. 틀린 건 "공유하지도 않는 상태"를 부모에 올려놓는 습관이다.

핵심 원칙: 상태는 그 상태를 쓰는 컴포넌트에 가장 가깝게 둔다. 올릴 이유가 생겼을 때만 올린다.

[💡 잠깐! 이 용어는?] Prop drilling: 상위 컴포넌트의 상태를 하위 컴포넌트에서 쓰기 위해 중간 컴포넌트들이 props로 계속 내려받아 전달하는 현상. 중간 컴포넌트는 실제로 그 prop을 쓰지 않으면서 단순 전달만 하기 때문에 결합도가 올라간다.


언제 올려야 하고, 언제 올리지 말아야 하나

상황판단
두 개 이상의 형제 컴포넌트가 같은 상태를 봐야 한다올려라
부모가 자식의 상태 변화를 감지해야 한다올려라
자식 컴포넌트 하나에서만 쓰인다두어라
"나중에 공유할 수도 있으니까"두어라 (그때 올려도 늦지 않다)

올리는 작업은 언제든 할 수 있지만, 한번 올려놓은 상태를 다시 내리는 건 훨씬 귀찮다. 올리지 않는 쪽이 기본값이 되어야 한다.


불필요하게 올린 경우

아래는 Modal의 열림 상태를 부모로 올린 패턴이다. Modal 자신만 이 상태를 쓰는데도 부모에 상태가 있다.

src/App.tsx
import { useState } from 'react'
import { Modal } from './components/Modal'
 
export function App() {
  const [open, setOpen] = useState(false)
 
  return (
    <div>
      <Modal open={open} setOpen={setOpen} />
    </div>
  )
}
src/components/Modal.tsx
type ModalProps = {
  open: boolean
  setOpen: (open: boolean) => void
}
 
export function Modal({ open, setOpen }: ModalProps) {
  return (
    <>
      <button onClick={() => setOpen(true)}>열기</button>
      {open && (
        <div className="modal">
          <button onClick={() => setOpen(false)}>닫기</button>
          <p>모달 내용</p>
        </div>
      )}
    </>
  )
}

이 코드의 문제는 세 가지다.

  1. Appopen 값을 전혀 쓰지 않는다. 그저 보관만 한다.
  2. App이 리렌더될 때마다 setOpen 함수 참조가 새로 생긴다. Modal의 메모이제이션이 깨질 여지가 생긴다.
  3. Modal을 다른 곳에서 재사용하려면 open/setOpen 쌍을 매번 제공해야 한다.

상태를 쓰는 곳으로 내린다

Modal만 이 상태가 필요하면, 상태는 Modal 안에 있어야 한다.

src/components/Modal.tsx
import { useState } from 'react'
 
export function Modal() {
  const [open, setOpen] = useState(false)
 
  return (
    <>
      <button onClick={() => setOpen(true)}>열기</button>
      {open && (
        <div className="modal">
          <button onClick={() => setOpen(false)}>닫기</button>
          <p>모달 내용</p>
        </div>
      )}
    </>
  )
}
src/App.tsx
import { Modal } from './components/Modal'
 
export function App() {
  return (
    <div>
      <Modal />
    </div>
  )
}

App은 Modal의 내부 상태를 알 필요가 없다. Modal은 자기 생명주기를 스스로 관리한다. 컴포넌트가 한 가지 일만 하는 구조가 유지된다.

비유하자면, 리프팅 안 한 상태는 개인 방의 서랍이고, 부모로 올린 상태는 거실의 공용 테이블이다. 내 옷을 거실 테이블에 두는 건 누가 봐도 이상하다. 가족이 공용으로 쓰는 리모컨만 거실에 두면 된다.


진짜 올려야 하는 경우

상태를 올려야 하는 상황은 따로 있다. 부모가 값을 진짜로 사용하거나, 여러 자식이 동시에 봐야 하는 경우다.

src/components/TabContainer.tsx
import { useState } from 'react'
import { TabList } from './TabList'
import { TabPanel } from './TabPanel'
 
const tabs = ['개요', '리뷰', '설정']
 
export function TabContainer() {
  const [activeIndex, setActiveIndex] = useState(0)
 
  return (
    <div>
      <TabList
        tabs={tabs}
        activeIndex={activeIndex}
        onSelect={setActiveIndex}
      />
      <TabPanel
        title={tabs[activeIndex]}
        content={`${tabs[activeIndex]} 내용`}
      />
    </div>
  )
}

이 경우 activeIndexTabList가 바꾸고 TabPanel이 읽는다. 두 형제 컴포넌트가 같은 값을 봐야 하므로 공통 부모인 TabContainer에 상태를 두는 게 맞다. 이게 진짜 lift state up이다.


제어 컴포넌트와 비제어 컴포넌트

이 패턴을 생각할 때 가장 좋은 프레임은 제어/비제어 구분이다.

구분상태 위치예시
비제어(Uncontrolled)컴포넌트 내부<Modal />, <Accordion />
제어(Controlled)부모에서 props로 주입<Modal open={...} onClose={...} />

라이브러리 컴포넌트는 보통 둘 다 지원한다. 안 써도 되는 경우엔 그냥 <Modal />로 쓰고, 부모에서 제어가 필요해지는 순간 props를 받아 제어 모드로 전환할 수 있게 설계한다.

src/components/Modal.tsx
import { useState } from 'react'
 
type ModalProps = {
  open?: boolean
  onOpenChange?: (open: boolean) => void
}
 
export function Modal({ open: controlledOpen, onOpenChange }: ModalProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
 
  const isControlled = controlledOpen !== undefined
  const open = isControlled ? controlledOpen : uncontrolledOpen
 
  const setOpen = (next: boolean) => {
    if (!isControlled) {
      setUncontrolledOpen(next)
    }
    onOpenChange?.(next)
  }
 
  return (
    <>
      <button onClick={() => setOpen(true)}>열기</button>
      {open && (
        <div className="modal">
          <button onClick={() => setOpen(false)}>닫기</button>
          <p>모달 내용</p>
        </div>
      )}
    </>
  )
}

기본은 내부 상태(비제어)로 동작하고, open prop이 주어지면 제어 모드로 전환된다. 호출자가 필요할 때만 상태를 소유하는 구조다.


상태를 올리기 전에 던지는 질문

코드 리뷰에서 상태를 올리는 PR을 만났을 때, 혹은 내가 올리려고 할 때 스스로 물어볼 만한 질문들이다.

  • 부모가 이 값을 읽거나 쓰는가? 안 쓴다면 올리지 마라.
  • 형제 컴포넌트 중 이 상태를 봐야 하는 다른 것이 있는가? 없다면 올리지 마라.
  • 올리지 않으면 불편해지는 구체적인 장면이 있는가? 상상 속 장면이면 올리지 마라.

"나중에 공유할 수도 있으니까" 같은 이유는 YAGNI 원칙 위반이다. 실제로 공유할 일이 생겼을 때 올려도 리팩토링 비용은 크지 않다.

[💡 잠깐! 이 용어는?] YAGNI(You Aren't Gonna Need It): 지금 필요하지 않은 기능은 만들지 말라는 원칙. 미래의 요구사항을 예측해서 미리 설계하면 대부분 빗나가거나 과잉 설계로 이어진다.


마무리

lift state up은 필요할 때만 쓰는 도구지, 모든 상태를 올리라는 규칙이 아니다. 상태를 올리기 전에 멈춰서 "정말 공유가 필요한가?"를 물어보는 습관을 들이는 것이 포인트다.

  • 기본 위치는 상태를 쓰는 컴포넌트 내부
  • 형제끼리 공유해야 하거나 부모가 값을 써야 할 때만 올린다
  • 재사용 가능한 컴포넌트는 제어/비제어 양쪽을 모두 지원하도록 설계한다
  • "나중에 공유할 수도 있어서" 같은 예측은 근거가 되지 않는다

참고:

관심 있을 만한 포스트

Angular 1에서 React로 — Strangler 패턴으로 1년간 점진적 마이그레이션한 이야기

대규모 티켓 플랫폼을 Angular 1에서 React로 마이그레이션하면서 적용한 Strangler 패턴과 7가지 교훈을 정리한다.

ReactAngular

Coaction v1.0 — Web Worker로 멀티스레딩 상태 관리하기

JavaScript 단일 스레드 한계를 극복하는 상태 관리 라이브러리 Coaction의 동작 방식, Zustand와의 차이, Standard/Shared 두 가지 모드 사용법을 정리한다.

CoactionWeb Worker

React Compiler의 한계 — 뭘 최적화하고 뭘 못 하는가

React Compiler가 자동 메모이제이션으로 해결하는 것과 해결하지 못하는 것. 컴파일러 기반 UI 프레임워크의 능력 경계를 정리했다.

ReactReact Compiler

LCP 28초짜리 React 앱을 1초로 깎아낸 기록 — 4단계 성능 수술 프레임워크

번들 분석부터 에셋 최적화까지, React 앱의 LCP를 단계적으로 개선하는 실전 프레임워크를 다룬다.

React성능 최적화

Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지

Velog 스타일 카드 UI와 MDX 렌더링 상세 페이지 구현. 반응형 그리드, SEO 메타데이터, 정적 사이트 생성까지.

Next.jsReact

SVG 아이콘 — 코드 배포 없이 프로덕트 팀이 직접 관리하는 법

CSS mask-image와 S3를 조합해 개발자 개입 없이 아이콘을 교체하는 패턴을 소개한다.

SVGCSS

VS Code 1.116 — 에이전트 디버깅, 포그라운드 터미널, 내장 Copilot

2026년 4월 VS Code 1.116이 에이전트 경험, 터미널, Chat UX, 내장 브라우저를 개선한 핵심 변경사항을 정리한다.

VS Code1.116

Naver FE News 2026년 4월 — 49MB 웹 페이지부터 Temporal Stage 4까지

Naver FE News 2026년 4월호에서 프론트엔드 개발자가 주목할 6가지 소식을 선별해 정리한다.

Naver FE NewsTemporal