리액트를 사용하다 보면 state, 상태 관리, 단일 진리의 원천 같은 용어들을 자주 접하게 됩니다.
오늘은 이러한 상태가 무엇인지, 그리고 프론트엔드 개발자가 상태 관리를 잘하기 위해 어떤 점들을 고민하면 좋을지에 대해 이야기해보려 합니다.
리액트의 상태를 이야기하기 전에, “상태”라는 개념 자체에 대해 먼저 생각해보겠습니다.
네이버 사전 기준 상태는 “사물/현상이 놓여 있는 모양이나 형편”이라고 나옵니다. 하지만 이건 사전적인 정의고, 현실 속에서 상태라 하면 훨씬 더 다양한 맥락에서 사용됩니다.
예를 들어, 현재 자신의 기분 상태를 뜻할 수도 있고, 주문한 택배의 배송 상태를 나타낼 수 도있고, 집에 존재하는 옷장들의 상태, 옷장 안에 있는 각 상/하의들의 상태 등등.. 아주 많은 것들이 떠오릅니다.
그렇다면 리액트의 상태란 무엇일까요? 사실 리액트의 상태도 본질적으로는 앞서 이야기한 현실 속 상태와 크게 다르지 않습니다. 리액트에서 상태란, 컴포넌트가 현재의 정보를 기억하고, 이 정보를 기반으로 UI를 렌더링할 수 있도록 해주는 값입니다. 조금 더 추상화하자면, 값을 저장하고 관리하며 변경에 따라 반응하는 데이터라고 할 수 있는 것이죠.
예를 들어 앞선 옷장으로 예를 들자면, Closet
이라는 컴포넌트 안에는 shirts
, pants
라는 상태 있다면, 이를 기반으로 옷장을 구성할 수 있습니다. 또 각 상/하의 아이템에 대한 구입년도, 버릴지 여부와 같은 정보도 상태로 함께 관리할 수 있습니다.
이처럼 상태는 리액트에서 컴포넌트의 동작과 UI를 결정하는 핵심적인 요소입니다.
(글을 쓰다 보니, 이것 역시 객체 지향적인 설계와 닮아 있다는 생각이 듭니다. 마치 shirts
와 pants
가 모여 하나의 Closet
이라는 공동체를 이루는 것처럼요!)
상태 관리를 할 수 있는 리액트 hook에는 useState
, useReducer
그리고 이 상태를 전역에서 관리할 수 있게 해주는 useContext
가 존재합니다. 각각을 어느 상황에서 사용하는지 소개해보겠습니다.
useState
는 가장 많이 사용되는 리액트 hook입니다. 공식문서를 기반으로 사용방법은 다음과 같습니다.
const [state, setState] = useState(initialState)
배열 구조 분해를 통해 현재 상태 값과, 업데이트 함수를 제공합니다. 상태를 선언함으로써 컴포넌트에 지역상태를 추가할 수 있습니다.
저는 useState
를 다음과 같은 경우에 사용합니다.
useReducer
는 상태가 복잡하거나 상태 간 연관성이 많을 때 자주 사용되는 리액트 hook입니다. 이도 마찬가지로 공식문서를 기반으로 사용방법은 다음과 같습니다.
const [state, dispatch] = useReducer(reducer, initialArg, init?)
이도 useState
와 비슷하게 지역상태를 추가할 수 있습니다.
useState와 다른 점이 없는데 그렇다면 useReducer는 언제 사용하는 것이 좋을까요? 저는 다음과 같은 상황에서 유용하다고 생각합니다.
export const counterReducer = (state: number, action: { type: 'increment' | 'decrement' }) => {
switch (action.type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
default:
return state;
}
};
type State = {
step: number;
data: { name: string; age: number };
};
type Action =
| { type: 'nextStep' }
| { type: 'updateName'; payload: string }
| { type: 'updateAge'; payload: number };
const formReducer = (state: State, action: Action): State => {
switch (action.type) {
case 'nextStep':
return { ...state, step: state.step + 1 };
case 'updateName':
return { ...state, data: { ...state.data, name: action.payload } };
case 'updateAge':
return { ...state, data: { ...state.data, age: action.payload } };
default:
return state;
}
};
type State = {
user: { name: string; email: string };
settings: { darkMode: boolean; notifications: boolean };
};
type Action =
| { type: 'toggleDarkMode' }
| { type: 'updateName'; payload: string };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'toggleDarkMode':
return {
...state,
settings: {
...state.settings,
darkMode: !state.settings.darkMode,
},
};
case 'updateName':
return {
...state,
user: {
...state.user,
name: action.payload,
},
};
default:
return state;
}
};
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: string[] }
| { type: 'FETCH_ERROR'; payload: string };
type State = {
isLoading: boolean;
data: string[];
error: string | null;
};
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'FETCH_START':
return { ...state, isLoading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, isLoading: false, data: action.payload };
case 'FETCH_ERROR':
return { ...state, isLoading: false, error: action.payload };
default:
return state;
}
};
useReducer는 특히 상태 변화를 액션이라는 개념으로 추상화하여 관리하기 때문에, 상태 변화의 의도를 더 명확하게 표현할 수 있다는 장점이 있습니다.
useContext는 직접적인 상태관리 hook은 아니지만, 상태를 전역에서 구독하고 상태를 전달 받을 수 있는 hook입니다.
const value = useContext(SomeContext)
Context
를 사용하면 props drilling없이 컴포넌트 트리 어디서든 필요한 데이터에 접근할 수 있습니다. 다만 전역 상태로 꼭 둬야 하는가?를 염두에 두고 아래와 같은 사항들을 고려해보면 좋습니다.
전역 상태 관리에 대한 고려사항
Context
의 값이 변경되면 해당 Context
를 구독하는 모든 컴포넌트가 리렌더링될 수 있습니다. 따라서 꼭 전역으로 관리해야 하는 상태인지 먼저 고민하고, 리렌더링 비용이 중요한 컴포넌트에는 불필요한 구독을 피하는 것이 좋습니다.리액트에서는 Context
를 통해 전역적인 데이터를 컴포넌트 트리 전체에 전달할 수 있습니다. 하지만 Context
만으로 상태를 관리할 때는 몇 가지 한계가 존재합니다.
export const App = () => {
return (
<AppProvider>
<ToastProvider>
<DataProvider>
<AuthProvider>
<ThemeProvider>
{/* ... 끊임없는 Provider들 ... */}
<Pages />
</ThemeProvider>
</AuthProvider>
</DataProvider>
</ToastProvider>
</AppProvider>
);
};
Context
을 사용할 경우 위 코드 처럼 Provider가 중첩되어 코드 가독성이 떨어집니다.useContext
를 사용하면 Context
를 구독하고 있는 모든 컴포넌트가 값의 일부만 바뀌어도 모두 리렌더링됩니다. 이는 성능에 문제가 발생할 수 있습니다.Context
를 모듈화하고 관리하는 것이 어려워지며, 상태의 소유권이 모호해질 수 있습니다.useContext
+ useReducer
패턴을 써야 하는데, 코드가 복잡해질 수 있습니다.따라서 경우에 따라서는 전역 상태 관리 라이브러리 도입을 고려해볼 수 있습니다.
현재 많이 사용되는 상태 관리 라이브러리로는 Recoil, Zustand, Jotai, MobX 등이 있으며, 각 라이브러리는 사용성과 성능 면에서 차이가 있습니다. 아래는 상태 관리 라이브러리의 트렌드를 보여주는 예시입니다.
각 라이브러리의 특징을 비교하는 것도 흥미로운 주제이지만, 이번 글에서는 다루지 않겠습니다. 이전에 관련 내용을 정리한 글이 있으니, 관심 있는 분들은 여기에서 확인하실 수 있습니다.
좋은 상태라는 것은 단순히 데이터를 저장하는 것 이상으로 언제, 어떻게 변경되는지 예측할 수 있는 상태라고 생각합니다. 이에 대해 정말 많은 고민을 해봤습니다. 아직 그 고민이 끝나지는 않았지만, 현재까지의 경험으로 미루어보았을 때 답할 수 있는 것들은 4가지정도가 있었습니다.
상태는 사용되는 컴포넌트와 가까이 위치해야 합니다. 그래야 언제, 어떤 이벤트에 의해 변경되는지를 코드 상에서 명확하게 파악할 수 있습니다. 멀리 떨어진 곳에 있으면 그 변경 흐름을 추적하기 어려워져 예측가능성이 떨어진다고 생각합니다. '리팩토링'책에서도 언급된 ‘데이터는 사용하는 코드 가까이에 있어야 한다’고 한 것도 이런 맥락에서 이해할 수 있습니다.
중복 없이, 필수적인 값만 상태로 관리해야 합니다. 공식 문서에도 권장하듯, 파생 가능한 데이터는 별도의 상태로 관리하지 않는 것이 좋습니다.
상태를 적절하게 분리하고 조직화해야 합니다. 예를 들어 다음과 같은 상태들이 섞여 있을 수 있습니다.
const [state, setState] = useState({
posts: [],
isLoading: false,
isModalOpen: false,
searchTerm: '',
error: null,
});
현재 코드에서는 UI상태 (isLoading
,isModalOpen
), posts 상태, 검색어 입력 값등 여러 상태들을 하나의 객체로 관리하고 있습니다. 이처럼 서로 다른 책임의 상태들이 한 객체에 섞여 있으면 의도를 파악하기 어렵고, 상태의 역할이 모호햐져서 관심사의 분리가 무너집니다. 또한 searchTerm
하나만 바꾸고 싶어도 전체 객체를 새로 만들어야 하므로, 불필요한 재렌더링이 발생할 수 있습니다.
반면, 아래와 같이 역할별로 상태를 나누면 의도를 명확히 전달할 수 있습니다
// 서버 상태
const [posts, setPosts] = useState([]);
const [error, setError] = useState(null);
// UI 상태
const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
// 폼 입력 상태
const [searchTerm, setSearchTerm] = useState('');
이런 분리 방식을 통해 상태가 무엇을 위한 것인지, 누가 관리해야하는지, 언제 변경되는지가 명확해지고, 자연스럽게 역할과 책임도 나눌 수 있습니다.
SSoT(Single Source of Truthy)원칙을 준수해야 합니다.
데이터는 단 하나의 출처에서만 관리되어야 하며, 불변성을 유지해야 합니다. 이렇게 함으로써 데이터의 일관성을 보장하고 에측 가능한 상태 변화를 구현할 수 있습니다.
이 주제는 너무 중요하고 말하고 싶은 내용이 많아 별도의 블로그 챕터로 자세히 다루겠습니다!
글을 적으면 느꼈던 점은, 결국 리액트의 상태라는 것은 현실 세계에서의 상태와 크게 다르지 않다는 점입니다.
예를 들어, keemsebin이라는 나라는 객체가 있다고 가정해봅시다.
export const Keemsebin = () => {
const [currentLocation, setCurrentLocation] = useState('cafe');
const [mood, setMood] = useState('focused');
const [isHungry, setIsHungry] = useState(false);
// .. 함수들
}
단순한 코드가 아니라 현실의 나를 꽤 정확하게 표현하는 상태들 입니다. 카페에 앉아서 집중해서 글을 쓰고 있고, 아직 배가 안고픈 상태인거죠. 그런데 시간이 지나면 배가 고파지기도 하고, 공부가 잘 안되면 기분이 frustrated
로 변하기도 해요. 그러면 이 상태 값들이 변화하고, 나라는 컴포넌트도 그에 맞게 반응하게 됩니다.
예를 들어 isHungry
가 true
가 되면, 제 UI(?)에는 밥 먹으러 가기 버튼이 보일 수도 있고, mood
가 tired
가 되면 커피 사러가기라는 동작을 트리거할 수도 있겠죠. 이런 과정을 통해 깨달은 건, 상태가 단순히 값으로만 보는 것이 아니라 시간에 따라 변하는 나의 모습을 추상화 한것 이라는 점입니다.
이렇게 생각하다보니, 상태를 단순히 기술적인 값으로만 보는 것이 아니라 현실세계를 모델링하는 하나의 방식으로 바라보게 되었어요. 상태의 변화는 곧 사건(event)이고 그에 따라 UI가 어떻게 반응해야 할지를 결정하는 것이 리액트의 핵심 아닐까요? 결국 좋은 상태 관리라는 것은 단순히 값을 업데이트 하는 것이 아니라 의미 있는 흐름을 설계하는 것에 가깝다는 점을 느끼게 되었습니다.
끗
"상태를 단순히 기술적인 값으로만 보는 것이 아니라 현실세계를 모델링하는 하나의 방식으로 바라보게 되었다"... 이런 관점으로 상태를 바라보게 되었다니 너무 좋은데요
흠..
너무 당연한 이야기를 포장하신것 같군요.
프론트엔드에서의 상태라고 하면, UI 지닌 어떤값이 될 수도 있고 서버에 내려받은 값일수도 있고 사용자 입력 폼값일수도 있고.. 당연히 해당 값들은 시간 또는 사용자와의 인터렉션이 일어나면서 변화합니다.
그리고 당연히 이러한 값들은 현실 세계에 어떤 니즈 또는 도메인을 표현하고 있는건데 이걸 이제서야 바라보게 되었다라는걸 보면.. 부트캠프를 가셔서 공부를 해보심을 추천 드립니다.
그리고 당연히 좋은 상태는 직관적이어야 하고, 해당 도메인을 표현하고 있어야하고 과해서도 안됩니다.(예시로 병원 웹을 만든다라고 가정할 때 회원 가입을 입력할 때 병력이라던가 먹고있는 영양제 약같은 정보를 상태로서 가질수 있겠으나 만약 쇼핑몰이면 이는 필요없기도 하고 직관적이지 않은 상태가 되는거죠)
좋은 상태라는 것은 사실 너무너무 다른 프레임워크던 개발을 하던 당연한 이야기라 생략하도록 하지요.
어떻게 이런 당연한 이야기가 트렌드에 있는지 이해가 불가능..수준이 그렇게 떨어진건가 AI 바이브 코딩에 폐해인가라는 생각이드네요.. 나중에 소설가 함 해보세요.
안녕하세요, 글 잘 읽었습니다.
context api가 전역 상태 관리보다는 의존성 주입을 위한 도구라고 보는 것이 더 알맞을 것 같다고 생각합니다.
https://yoonhaemin.com/tag/technical-thinking/understanding-context-api/
위 글을 한번 읽어보시면 도움이 될 것 같습니다!
흐름 제어 좋은데요? 👍