리액트 상태 관리에 대하여

리액트 상태 관리를 어떻게 하냐에 따라 의미 없는 리렌더 등 성능 이슈가 생길 수 있고 어떤 상태 관리 라이브러리를 쓰며 어떤 구조로 상태를 설계해서 다루냐에 따라서 유지보수 관점에서 코드의 라이프 사이클이 크게 짧아질 수도 길어 질 수도 있다. 상태 설계는 만드는 개발자마다 중요하게 생각하는 지점이 갈릴 수 도 있고 한번 설계되면 프로젝트를 새로 만들지 않는 이상 다시 구조를 만들기 쉽지 않다. 최적의 상태 관리를 위해서는 여러 인사이트들이 필요하다. 이 글에서는 리액트의 상태와 상태 관리가 무엇인지 상태는 어떻게 관리되면 좋은지 써보려고 한다.
리액트 상태와 상태 관리는 무엇인가
HTTP를 공부한 사람이라면 " HTTP는 Stateless하다 "는 말을 들어봤을 것이다. HTTP는 모든 요청을 독립적으로 처리하기 때문에, 이전 요청이든 이후 요청이든 기억하지 않는다. Stateless하다는 것은, 각 요청이 완전히 독립적이며 요청에 따른 결과만을 반환한다는 뜻이다. 따라서 서버 입장에서는 요청 간의 관계나 사이드 이펙트를 고려할 필요가 없다. 그렇다면 HTTP와 달리 Stateful한 시스템, 즉 상태를 사용하는 시스템은 어떻게 동작할까?
상태는 요청에 따라 변화하는 값이며, 그 변화가 프로그램의 동작에 반영된다. 이전 요청에서의 상태 변경은 이후 요청의 결과에 영향을 줄 수 있고, 이처럼 상태는 시간에 따라 변하며 일종의 라이프사이클을 가진다. 상태의 변화는 보통 도메인 정책에 따라 정해진 사이드 이펙트를 유발한다. 예를 들어, 상품 재고 수량을 차감하거나 결제 상태를 변경하는 등의 동작이 이에 해당한다.
리액트에서 상태란 무엇일까? 호기심이 많은 사람이라면 useState
대신 일반 변수에 값을 직접 할당해 리렌더링을 유도해보려 했을 것이다. 상태도 결국 값의 일종이기 때문에, 단순히 변수에 값을 할당하고 바꾸면 UI에 반영될 것처럼 보이기도 한다. 하지만 단순한 변수의 값과 리액트의 상태는 분명히 다르다. 리액트에서 상태(state) 란, UI를 결정짓는 값이며, 그 값이 변경되었을 때 컴포넌트를 다시 렌더링하게 만드는 트리거 역할을 한다. 따라서 상태를 변경할 때는 반드시 useState
의 setter 함수처럼, 리액트에게 "상태가 바뀌었다"고 알려주는 방식으로 처리해야 한다. 즉, 어떤 값이 UI에 반영됐다고 해서 그 값이 상태인 것은 아니다. 상태 변경 함수(setter)를 통해 리액트가 리렌더링을 수행했기 때문에 UI에 반영된 것이다. 이는 상태 변경에 따른 사이드 이펙트의 결과이지, 값 자체가 상태여서 자동으로 반영된 것이 아니다.
import { useState } from "react";
function App() {
const [count, setCount] = useState(0);
// setCount를 호출하면 이 컴포넌트가 다시 호출되면서 변화된 State를 반영해서 새로 그린다
return (
<div>
<button
onClick={() => {
setCount((prev) => prev + 1);
}}
>
카운트 업
</button>
<div>{`count: ${count}`}</div>
</div>
);
}
export default App;
import { useState } from "react";
let count = 0; // 단순한 값은 아무리 변화시켜도 리렌더를 만들지 않는다
function App() {
return (
<div>
<button
onClick={() => {
value++;
}}
>
카운트 업
</button>
<div>{`count: ${count}`}</div>
</div>
);
}
export default App;
그렇다면 리액트의 상태 관리란 무엇일까? 많은 사람들이 상태와 상태 관리를 혼용하지만, 사실 이 둘은 명확히 구분된다. 상태는 그 자체로 UI를 결정짓는 값을 의미한다. 반면, 상태 관리는 그 값이 도메인 정책에 따라 언제, 어떻게 바뀌어야 하는지를 정의하고 제어하는 로직 전체를 말한다. 작게는 컴포넌트 내부의 상태 변화에 도메인 규칙을 적용하는 것부터, 크게는 애플리케이션 전체에 영향을 미치는 전역 상태까지,이 모든 흐름과 책임을 포함하는 것이 바로 상태 관리다.
우리가 쓰고있는 Zustand, Jotai, React Query, Redux 이런 상태관리 도구들은 복잡한 상태 변경이 정확한 사이드 이펙트와 함께 예측 가능하게 일어나도록 돕기 위해 사용된다. 이처럼 다양한 도구들이 존재하는 이유는, 상태의 종류와 성격이 각각 다르기 때문에, 그에 맞는 다른 접근 방식이 필요하기 때문이다.
리액트 상태는 크게는 범위로 나눠볼 수 있고 더 세세하게 나눠 보자면 관심사의 측면으로 다시 나눠볼 수 있다. 범위의 측면에서 본다면 몇몇 컴포넌트에 국한되서 영향을 주는 로컬 상태와 프로젝트 전체에 영향을 줄 수 있는 전역 상태로 나뉜다. 다시 관심사의 측면에서 전역 상태는 UI에 영향을 주는 UI 상태와 서버에서 영향을 받는 캐싱된 서버 상태로 나눠 볼 수 있다. 이에 따라 상태 관리 하는 법들을 생각해보자
로컬 상태 관리
상태는 관련 컴포넌트들과 최대한 가까이 배치 되는게 좋다. ( State Colocation will make your react app faster )
상태가 관련 컴포넌트와 멀어질수록 상태와 컴포넌트 사이에 있는 관련 없는 컴포넌트의 리렌더까지 일으킬 위험이 크다. 또 상태들은 관심사에 따라 잘 분리가 되야 후에 코드 수정시 사이드 이펙트를 최소화 할 수 있다. 서로 관련 없는 컴포넌트들의 상태가 한번에 관리되면 결합도가 높아지게 되고 후에 어플리케이션이 비대화 될수록 의도치 않은 영향을 줄 수 있는 가능성이 높아진다. 코드들은 격리되어 있지 않아 코드의 재사용성 또한 떨어진다. 이에 따라 안정적인 리액트 상태 관리를 위해 전역 상태 보단 로컬 상태 사용을 지향 해야 한다.
로컬 상태는 useState
훅을 통해 선언되며, 해당 컴포넌트에서 선언된 상태는 자기 자신과 하위(자식) 컴포넌트에 영향을 준다. 이 상태가 변경되면, 리액트는 별도의 작업이 없다면 해당 컴포넌트와 그 하위 트리 전체를 리렌더링한다. 즉, 자식 컴포넌트의 상태나 의도와는 무관하게 전파되는 리렌더링이 일어날 수 있다. (리액트는 기본적으로 정확한 UI 동기화를 우선시하며, 성능 최적화는 개발자가 명시적으로 수행해야 할 책임으로 남겨둔다.)
여러분들의 코드에 선언된 로컬 상태가 해당 위치에 선언된 이유는 다양하다.
- 컴포넌트 스스로가 UI를 그리기 위해 직접 사용하기 위해
- 제어 역전(inversion of control)을 위해 상위 컴포넌트에서 상태를 통제하기 위해
- 다수의 자식 컴포넌트에게 공통으로 영향을 주기 위해
위의 경우 중 다수의 자식 컴포넌트에게 영향을 미치는 공통의 상태는 자식의 자식의 자식까지 영향을 미치기 위해 최상단부터 최하단 까지 내려가는 경우도 많다. 이 전달이 여러 단계(자식 → 자식의 자식 → 그 자식...)로 이어질 때, 이를 Props Drilling이라고 부른다. 특히, 최상위 컴포넌트에 많은 비즈니스 로직이 몰려 있는 경우, 하위로 내려가는 Props가 많아지면서 의도를 알기 어려운 Props가 중간 컴포넌트들에 퍼지게 되고, 이는 결국 유지보수를 어렵게 만든다.
다음으로 로컬 상태의 전파를 슬기롭게 할 수 있는 Props Drilling을 제어하는 방법들을 생각해보자
Props Drilling 을 제어하는 법들
의미 있는 Props 선언하기
리액트는 선언형 프로그래밍 형태로 구성된다. 선언형을 통해서 가독성, 재사용성, 유지보수성을 높인다. Props 선언도 이와 같은 맥락으로 이루어져야 한다.
<Button color="blue" size="large" disabled />
color, size, disabled 등 명확하게 Button의 관점에서 어떻게 그릴 것인지 표현하게 된다. Button 같은 최하단 컴포넌트는 명확하게 Props를 선언하기 쉽지만 Props drilling의 중간 단계에 있는 컴포넌트는 이와 같이 명확하게 Props를 선언하는 게 쉽지않다.
[선언형의 의미가 약한 Prop 전달 예시]
const App = () => {
const user = useUser();
const members = useMembers();
// Layout의 props 정보만으로는 데이터의 사용처를 알기 어렵고,
// 이를 파악하려면 Layout 내부 구현을 직접 열어봐야 한다.
return (
<Layout
userName={user.name}
members={members}
title={"띠비 채팅방"}
count={members.count}
/>
);
};
const Layout = ({ userName, members, title, count }) => {
// Layout 내부에서도 props 간의 의미나 사용처가 불명확하다.
return (
<div>
<Title text={title} count={count} />
<MemberList members={members} />
<Sidebar userName={userName} />;
</div>
);
};
const MemberList = ({ members }) =>
members.map(({ member }) => <MemberItem member={member} />);
const MemberItem = ({ member }) => (
<div>
<img src={member.profileImage} />
<span>{member.name}</span>
</div>
);
const Sidebar = ({ userName }) => {
return <Profile name={userName} />;
};
const Profile = ({ name }) => <p>Hello, {name}</p>;
이 코드에서는 전달되는 Props들이 무엇에 사용되는지 한눈에 파악하기 어렵다. 결국 Layout 컴포넌트의 내부를 열어봐야만 전체 흐름을 이해할 수 있게 된다.
[선언형의 의미를 살려본 Prop 전달 예시]
const App = () => {
const user = useUser();
const members = useMembers();
const sideBarProps = {
userName: user.name,
};
const memberListProps = {
items: members.map((member) => ({
profileImage: member.profileImage,
name: member.name,
})),
};
// 최상위에서부터 props의 목적이 구조적으로 명확하게 드러난다.
return (
<Layout
sidebar={sideBarProps}
memberList={memberListProps}
title={"띠비 채팅방"}
/>
);
};
const Layout = ({ title, sidebar, memberList }) => {
// Layout 내부에서도 각 props의 역할이 구분되어 있어 이해하기 쉽다.
return (
<div>
<Title text={title} count={memberList.length} />
<MemberList {...memberList} />
<Sidebar {...sidebar} />;
</div>
);
};
const MemberList = ({ members }) =>
members.map(({ member }) => <MemberItem member={member} />);
const MemberItem = ({ member }) => (
<div>
<img src={member.profileImage} />
<span>{member.name}</span>
</div>
);
const Sidebar = ({ userName }) => {
return <Profile name={userName} />;
};
const Profile = ({ name }) => <p>Hello, {name}</p>;
이처럼 의미 단위로 Props를 묶어 전달하면, Props의 목적이 명확해지고 중간 컴포넌트에서도 코드의 선언형 구조를 유지할 수 있다.
⚠️ memo 사용해서 리렌더 방지 시 주의할 점
Props를 객체 형태로 묶어 전달하는 경우, React.memo
를 활용해 최적화하고자 할 때 주의할 점이 있다.
const sideBarProps = { userName: user.name };
위 코드는 App
이 리렌더될 때마다 sideBarProps
객체가 새로 생성되므로 React.memo
를 적용해도 불필요한 재렌더링이 발생할 수 있다. 왜냐하면 memo
는 얕은 비교만 수행하기 때문이다. 이 문제를 해결하려면 useMemo
를 사용해 Props 객체를 메모이제이션 해야 한다.
const sideBarProps = useMemo(() => ({ userName: user.name }), [user.name]);
const memberListProps = useMemo(
() => ({
items: members.map((member) => ({
profileImage: member.profileImage,
name: member.name,
})),
}),
[members]
);
이렇게 하면 user.name
이나 members
가 변경되지 않는 이상 불필요하게 새로운 객체를 만들지 않게 되어 React.memo
의 효과가 제대로 발휘된다.
Context API로 Props 구출하기
Context API를 일종의 전역 상태 관리 라이브러리의 대체 기술로 생각하는 사람도 많다. (일단 나부터!) 하지만 정확히 말하면, Context API는 단순한 상태 전달 도구에 가깝다. 전역 상태 관리 라이브러리들은 보통 도메인 정책에 따라 상태의 흐름과 변경 로직을 체계적으로 관리할 수 있도록 구성되어 있다. 반면, Context API는 상태를 "어떻게 변경할지"에 대한 로직은 제공하지 않고, 단순히 값을 컴포넌트 트리 아래로 전달하는 역할만 한다.
하지만 단순히 로컬 상태를 하위 컴포넌트에 전달하는 용도라면, Context API는 Props drilling 문제를 해결하는 매우 유용한 도구가 될 수 있다. Props를 통한 상태 전달은, 부모 컴포넌트에서 내려온 값을 자식 컴포넌트가 UI에 반영하기 위해 사용된다. 하지만 상태가 여러 단계의 컴포넌트를 거쳐 전달되어야 하는 경우, 중간 컴포넌트들은 그 값을 사용하지 않더라도 Props만 받아서 전달해주는 역할만 하게 된다. Context API를 사용하면 중간 컴포넌트를 거치지 않고, 필요한 자식 컴포넌트에 직접 값을 주입할 수 있기 때문에 Props drilling 문제를 깔끔하게 해결할 수 있다.
const UserContext = createContext();
const MemberContext = createContext();
const App = () => {
const user = useUser();
const members = useMembers();
return (
<MemberContext.Provider value={{ members }}>
<UserContext.Provider
value={{
user,
}}
>
<Layout title="띠비 채팅방" />
</UserContext.Provider>
</MemberContext.Provider>
);
};
const Layout = ({ title }) => {
const members = useContext(MemberContext);
return (
<div>
<Title text={title} count={members.length} />
<MemberList />
<Sidebar />
</div>
);
};
const MemberList = () => {
const members = useContext(MemberContext);
return members.map((member, idx) => <MemberItem key={idx} member={member} />);
};
const MemberItem = ({ member }) => (
<div>
<img src={member.profileImage} />
<span>{member.name}</span>
</div>
);
const Sidebar = () => {
return <Profile />;
};
const Profile = () => {
const user = useContext(UserContext);
return <p>Hello, {user.name}</p>;
};
하지만 Context API를 사용할 때는 주의할 점도 있다. Context를 사용하는 컴포넌트는 반드시 해당 Context의 Provider 내부에서 렌더링되어야 하며, Provider 없이 렌더링될 경우 정의되지 않은 값을 참조하거나 에러가 발생할 수 있다. 이로 인해 컴포넌트의 재사용성이 떨어질 수 있으며, 컨텍스트에 의존하는 컴포넌트가 많아질수록 구조가 복잡해지고 추적이 어려워지는 단점도 존재한다.
합성이나 Render Props를 사용해보자
따로 Props를 내리지 않고 미리 최상단에서 원하는 자식 컴포넌트를 그려서 내리는 방법도 있다.
const App = () => {
const user = useUser();
const members = useMembers();
return (
<Layout
sidebar={<Sidebar userName={user.name} />}
memberList={
<MemberList
items={members.map((member) => ({
profileImage: member.profileImage,
name: member.name,
}))}
/>
}
title={"띠비 채팅방"}
/>
);
};
const Layout = ({ title, sidebar, memberList }) => {
return (
<div>
<Title text={title} count={memberList.length} />
{memberList}
{sidebar}
</div>
);
};
이렇게 하면 굳이 불필요한 Props를 최하단까지 내리지 않고 원하는 컴포넌트에 Props를 주입한채로 Props로 내리면 되기 때문에 Props drilling 문제가 완화 된다.
Props Getter를 사용해보자
Props drilling이 발생하는 근본적인 원인 중 하나는 도메인 정책 판단이 최하단 컴포넌트에서 이루어지는 구조 때문이다. 정책 판단은 보통 최상위에서 한 번만 처리되면 되는 일인데, 이를 하위 컴포넌트에서 처리하게 되면 해당 판단에 필요한 모든 정보를 중간 컴포넌트를 거쳐 Props로 전달해야 다. 이로 인해 Props drilling이 발생한다.
이럴 때, 정책 판단은 상위에서 수행하고, 하위에서는 결과만 사용하는 구조로 바꾸면 Props 전달을 간결하게 만들 수 있다.
이 구조에서 유용하게 쓰이는 방식이 바로 Props getter 패턴이다.
[하위 컴포넌트에서 정책 로직을 처리하기 위해 Props 주입한 예시]
const App = () => {
const user = useUser();
const members = useMembers();
const chat = useChat();
return <Layout user={user} members={members} chat={chat} />;
};
const Layout = ({ user, members, chat }) => {
return (
<div>
<MemberList members={members} user={user} chat={chat} />
</div>
);
};
const MemberList = ({ members, user, chat }) =>
members.map(({ member }) => (
<MemberItem member={member} user={user} chat={chat} />
));
const MemberItem = ({ member, user, chat }) => {
// 메시지를 생성하기 위해 user와 chat props를 받아야 함
// 그러나 이 로직은 사실 상위에서 판단해도 되는 도메인 정책임
const [show, setShow] = useState(false);
return (
<div onClick={() => setShow(true)}>
<img src={member.profileImage} />
<span>{member.name}</span>
{show && `${user.name}가 ${chat.name}에서 ${member.name}를 눌렀다`}
</div>
);
};
이 구조는 user
, chat
을 단순히 문장 생성에만 사용하지만, 모든 중간 컴포넌트에서 해당 데이터를Props로 넘겨줘야 하는 부담이 있다. 이로 인해 컴포넌트 간 결합도가 높아지고 유지보수성이 떨어진다.
[Props getter로 하위 컴포넌트에서 정책 로직을 상위로 올린 예시]
const App = () => {
const user = useUser();
const members = useMembers();
const chat = useChat();
// 도메인 정책 로직을 App에서 한 번에 처리
const getMessage = (memberName) =>
`${user.name}가 ${chat.name}에서 ${memberName}를 눌렀다`;
return <Layout members={members} getMessage={getMessage} />;
};
const Layout = ({ members, getMessage }) => {
return (
<div>
<MemberList members={members} getMessage={getMessage} />
</div>
);
};
const MemberList = ({ members, getMessage }) =>
members.map(({ member }) => (
<MemberItem member={member} getMessage={getMessage} />
));
const MemberItem = ({ member, getMessage }) => {
const [show, setShow] = useState(false);
const message = getMessage(member.name);
return (
<div onClick={() => setShow(true)}>
<img src={member.profileImage} />
<span>{member.name}</span>
{show && message}
</div>
);
};
위 방법으로 MemberItem
은 이제 user
와 chat
에 대한 의존성이 없다. 오직 getMessage
함수 하나 만으로 필요한 메시지를 생성할 수 있다.
전역 상태 관리
전역 상태는 언제 필요한가
리액트 컴포넌트 설계에 대하여 에서는 “전역 상태는 독이다” 라고 다뤘다. 전역 상태를 사용하게되면 사용이 쉬운 만큼, 의존성과 복잡도, 사이드 이펙트의 위험도 함께 증가한다. 일반적인 경우 상태는 전역 상태보다 관련 컴포넌트 가까운 로컬 상태로서 관리되는게 권장된다. Prop Drilling 과 같은 이슈도 위에서 소개한 방법들로 해결 가능하다. 하지만 페이지 전반에서 사용되는 컴포넌트인 경우 로컬 상태만으로 관리 하기 어려울 수 있다. 이를테면 컨텍스트 메뉴, 팝업 등의 컴포넌트가 그에 해당한다. 이때는 전역 상태를 이용하는것도 좋다. 또, 언어나 다크 모드같은 변화가 잦지 않고 서비스 전반에 걸친 상태도 전역 상태를 이용 할 수 있다. 상태는 본인의 기준에 의해 개발자가 설계하게 되는 것이며 뭐든 절대적인 것은 없다. 하지만 한번 설계된 상태는 애플리케이션의 코드 라이프 사이클 동안 계속 다뤄지는 부분이기 때문에 신중해야한다.
리액트 쿼리를 쓰는 이유
과거에는 호출 된 데이터를 서버 캐싱을 위해서 전역 상태 관리 라이브러리를 쓰는 경우가 많았다. Redux에서는 비동기 API 호출을 전역 상태에 담기위해서 redux-thunk, redux-saga 등 미들웨어 라이브러리들이 쓰이고 있었다. 하지만 본질적으로 서버 캐시와 UI 상태는 다르다. 서버 캐싱 되는 데이터는 본래 서버에 저장되어 있고 빠른 접근을 위해 클라이언트에 저장하는 상태이다. 예를 들면 서버에서 가져오는 유저 데이터 등이 있다. 반면, UI 상태는 오로지 우리 앱의 인터렉션을 제어하기 위한 UI에서만 유용한 상태다. 예를 들면, Modal의 isOpen 과 같은 데이터이다. 둘은 섞이면 안되고 본질에 따른 분리가 필요하다.
또, 전역 상태 관리 라이브러리에서 서버 상태를 캐싱을 하게되면 서버 상태가 특정 시점에 캡쳐 되버린다. 서버 데이터를 캐싱한 클라이언트의 상태를 인터렉션에 따라 update해서 서버 데이터와 동기화 한다고 하여도 시간이 지남에 따라서 본질적으로는 다른 데이터가 되고 관리도 어렵다.
전역 상태 라이브러리에서 서버 상태를 캐싱 하기 위해서 전역에서 호출이 강제 될 수 있다. 전역 상태에서 서버 데이터를 저장하기 위해서 미리 초기화 시점에 최상단 컴포넌트에서 호출을 해서 저장을 하기 때문에 데이터를 쓰는 시점과 데이터를 호출 하는 시점이 달라 질 수 있다. 이는 데이터에 접근하는 시점에 데이터가 있음을 보장할 수 없게 되고 애플리케이션의 크기가 커지면 커질수록 데이터의 흐름을 따라가기 힘들게 된다.
전역 상태에서 서버 상태를 관리하지말고 React-Query 등과 같은 서버 캐싱 전용 라이브러리를 사용해야한다. 서버 캐싱은 아주 어려운 작업이다. custom hooks를 만들어서 따로 넣을수도 있겠지만 진정한 캐싱의 개념을 쓰기 위해서는 라이브러리의 도움을 받는게 좋다. 주기적으로 정해진 시점에 따라 데이터가 out-of-date이 되지않게 서버 데이터를 polling을 하고 에러가 나면 영리하게 재호출 할수도 있다. 서버 응답을 메모리에 캐싱하면서 재검증 로직과 함께 비용을 줄인다. 또 원하는 시점에 호출을 하기 때문에 해당 데이터를 이용하는 컴포넌트에서 직접 호출을 하게된다. 전역 컴포넌트의 상태로부터 독립된 컴포넌트는 재사용이 가능하게 된다.
최근의 전역 상태관리 트렌드 라이브러리
과거에는 Redux가 전역 상태 관리의 표준처럼 쓰이던 시기가 있었다. Redux는 안정적인 상태 유지를 위해 강한 제약을 요구하는 구조를 택한다.
Redux의 핵심은 다음 세 가지를 명확히 분리한다:
- 어플리케이션 상태 (state)
- 무엇이 일어났는지 (dispatch를 통한 action 발생)
- 어떻게 상태를 바꿀 것인지 (reducer를 통한 변경)
이 구조는 Flux 패턴 기반의 단방향 데이터 흐름을 따르며, 예측 가능한 상태 관리를 가능하게 한다.
하지만 이러한 구조는 다음과 같은 문제점이 있다:
- 간단한 기능 하나 추가할 때도 action → dispatch → reducer → state → selector까지 수정이 필요
- 상태 관리 자체가 비대해지고 장황해짐
- 프로젝트 규모가 커질수록 복잡도와 확장성 저하 문제 발생
이러한 문제를 해결하고자, Daishi Kato라는 일본 개발자가 가볍고 선언적인 상태 관리 라이브러리들을 연이어 출시한다.
- Jotai (원자 단위 상태)
- Zustand (함수 기반 글로벌 스토어)
- Valtio (프록시 기반 상태 추적)
세 라이브러리는 Redux의 무거움을 벗어난 실용적인 상태 관리 도구들이고 최근 트렌드가 되고 있다.

여전히 Redux가 가장 많이 쓰이고 있지만 무서운 속도로 따라잡고있다. 만들어진 철학이 모두 다르기 때문에 취향에 맞게 취사 선택도 가능하다. (필자는 참고로 Zustand를 선호한다.)
마무리
이 글 역시 「리액트 컴포넌트 설계에 대하여」 와 마찬가지로, 4년 만에 다시 쓰게 된 글이다. 그동안 리액트 컴포넌트 생태계도 많은 변화가 있었지만, 상태 관리는 그보다 더 큰 변화와 전환점을 맞이한 영역처럼 느껴진다. 이제는 신규 프로젝트에서 Redux가 사용되는 경우가 드물고, 많은 개발자들이 전역 상태보다 로컬 상태를 우선시하는 흐름도 확연히 자리 잡았다.
그만큼 리액트 생태계가 생산성과 유지보수성을 동시에 만족시키는 방향으로 진화해왔음을 느낄 수 있었다. 리액트에서 상태 관리는 단순한 기술 선택이 아니라, 애플리케이션의 방향성과 수명을 결정짓는 핵심 설계다. 올바른 상태 범위 설정, 관심사의 분리는결국 서비스의 유지보수성과 확장성을 결정짓는 핵심 요소다. 이 글이 상태 관리 설계에 대해 고민하는 분들께 작지만 실용적인 인사이트가 되었기를 바란다.