React를 사용하다 보면 한 번쯤은 듣게 되는 말이 있다. “이건 상태 컴포넌트(stateful), 저건 비상태 컴포넌트(stateless)야.”
간단히, 상태 컴포넌트란 상태를 소유 및 관리하는 컴포넌트 그리고 비상태 컴포넌트는 상태를 전달받아 보여주는 컴포넌트 이다.
하지만 Hooks가 도입된 이후 이 구분은 점점 모호해졌다. 나 또한 현업에서 이 워딩을 자주 사용하지는 않았었다. 다만, 리액트 프로젝트를 진행할 때, 컴포넌트들 간의 종속 관계(레벨)와 비즈니스 로직에 따른 최적의 상태 관리를 위한 아키텍처(상태 또는 비상태 컴포넌트로 이뤄진)를 만들기 위해 노력하며 구현을 해왔던 것 같다.
한번은 이 개념을 정확히 짚고 넘어갈 필요가 있다고 생각하여, 이 글에서는 React의 상태(state)를 중심으로 컴포넌트 구조와 관리 전략을 정리하고, UI와 비즈니스 로직을 분리하는 현대적인 React 설계 원칙을 함께 정리해본다.
1. 상태(state)란 무엇인가
React에서 상태(state) 는 “시간에 따라 변할 수 있는 데이터”이다. 렌더링 결과를 바꾸는 유일한 요인으로, React의 핵심 개념 중 하나이다.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
클릭 횟수: {count}
</button>
);
}
위 코드에서 count가 변경될 때마다 React는 컴포넌트를 다시 렌더링한다. 즉, 상태 = UI를 다시 그리게 만드는 데이터이다.
2. Stateless 컴포넌트는 이제 없다
과거에는 클래스 컴포넌트: stateful (상태를 가짐) 와 함수형 컴포넌트: stateless (상태 없음) 가 존재했다.
// 클래스 컴포넌트
class Button extends React.Component {
state = { clicked: false };
render() { ... }
}
// 함수형 컴포넌트
function Button(props) {
return <button>{props.label}</button>;
}
React 16.8 이후 Hooks(useState, useEffect 등) 이 도입되면서 함수형 컴포넌트도 상태를 가질 수 있게 되었다.
function Button() {
const [clicked, setClicked] = useState(false);
return <button onClick={() => setClicked(!clicked)}>{clicked ? "ON" : "OFF"}</button>;
}
즉 이제는 “stateful vs stateless” 대신 “데이터를 다루는 컴포넌트 vs UI만 그리는 컴포넌트”로 구분하는 것이 더 적절해 보인다.
3. Container vs Presentational 패턴
React 컴포넌트 구조를 설계할 때 자주 사용하는 구분 방식이다. 데이터 처리와 UI 표현의 관심사를 분리하기 위해 사용한다.
Container = 데이터를 다루는 컴포넌트 그리고 Presentational =UI만 그리는 컴포넌트
| 구분 | Container Component | Presentational Component |
| 역할 | 데이터 관리, 비즈니스 로직 | UI 렌더링 |
| 데이터 출처 | API, Context, 상태 관리 | props로 전달받음 |
| 관심사 | 상태 관리, 이벤트 처리 | 화면 출력 |
| 예시 | <UserListContainer /> | <UserList />, <UserItem /> |
// Container: 상태와 데이터 관리
function UserListContainer() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch("/api/users")
.then(res => res.json())
.then(setUsers);
}, []);
return <UserList users={users} />;
}
// Presentational: UI 렌더링 전담
function UserList({ users }) {
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
UI와 비즈니스 로직을 분리하면 컴포넌트의 재사용성과 유지보수성이 크게 향상된다.
4. 상태는 어디에 두어야 하는가
가장 간단한 방법으로 상태는 그 상태를 필요로 하는 모든 컴포넌트의 공통 조상에 둔다. 이를 “상태 끌어올리기(Lifting State Up)”라고 한다.
function Parent() {
const [value, setValue] = useState("");
return (
<>
<Input value={value} onChange={setValue} />
<Display value={value} />
</>
);
}
위 예시 코드에서 Input과 Display 모두 value를 사용하므로 부모인 Parent가 상태를 관리한다.
- 상태는 가능한 한 최소한의 컴포넌트에 둔다.
- 그러나 그 상태를 필요로 하는 모든 자식이 접근할 수 있어야 한다.
하지만 앱이 커지고 컴포넌트가 복잡해질수록 모든 상태를 조상에 두는 것은 비효율적이다. 이럴 때는 상태의 성격과 범위에 따라 다른 전략을 사용한다.
즉,
- 상태는 공유 범위가 좁을수록 컴포넌트 내부에 두는 것이 좋다.
- 공통 조상에 두는 것은 단순한 경우에 효과적인 기본 전략이다.
- 상태가 여러 계층으로 흩어지면 Context나 전역 상태 관리 라이브러리를 사용하는 것이 더 적합하다.
5. 상태가 많아질 때의 관리 전략
앱이 커질수록 상태가 여러 컴포넌트에 분산된다. 이때는 각 상태의 “범위”를 기준으로 관리 방법을 선택한다.
| 관리 범위 | 적합한 방식 | 예시 |
| 로컬 상태 | useState, useReducer | 단일 컴포넌트 |
| 전역 상태 | Context API, Redux, Zustand, Recoil | 로그인 정보, UI 테마 |
| 서버 상태 | React Query, SWR | API 데이터, 캐싱, 비동기 관리 |
예를 들어, UI의 단순한 입력값은 useState로 충분하지만, 로그인 세션이나 사용자 설정처럼 여러 컴포넌트에서 필요하다면 Context나 Redux를 사용하는 것이 좋다.
1. 로컬 상태 — useState
가장 기본적인 상태 관리 방식. 단일 컴포넌트 안에서만 사용하는 간단한 UI 상태에 적합하다.
function Counter() {
const [count, setCount] = useState(0); // 로컬 상태
return (
<button onClick={() => setCount(count + 1)}>
클릭 횟수: {count}
</button>
);
}
언제 사용하나: 한 컴포넌트 내에서만 사용하는 단순한 상태.
예시: 모달 열림/닫힘, 입력값, 토글 상태 등.
2. 로컬 상태 (복잡한 로직) — useReducer
복잡한 상태 전이 로직이 있을때 적합하다. 여러 액션에 따라 상태를 일관성 있게 업데이트해야 하는 경우 사용한다.
function reducer(state, action) {
switch (action.type) {
case "increment": return { count: state.count + 1 };
case "decrement": return { count: state.count - 1 };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</>
);
}
언제 사용하나: 상태 업데이트가 여러 종류의 액션에 따라 바뀌는 경우.
예시: Form 입력 단계 관리, 복잡한 UI 토글, step 기반 프로세스 등.
3. 전역 상태 — Context API
여러 컴포넌트가 공통으로 접근해야 하는 상태에 적합하다.
const ThemeContext = createContext();
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
현재 테마: {theme}
</button>
);
}
언제 사용하나: 여러 하위 컴포넌트가 동일한 데이터에 접근할 때.
예시: 다크모드, 다국어 설정, 사용자 로그인 정보.
4. 전역 상태 — Zustand (간결한 대안)
전역 상태를 쉽게 관리할 수 있는 경량 라이브러리. Redux보다 코드가 간결하고 보일러플레이트가 거의 없다.
import { create } from "zustand";
const useStore = create(set => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
}));
function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>Count: {count}</button>;
}
언제 사용하나: Context보다 복잡하고, Redux보다 단순한 전역 상태가 필요할 때.
예시: 로그인 세션, UI 필터 상태, 모달 관리.
5. 전역 상태 — Redux
상태 변경 로직이 많고, 추적 가능한 구조가 필요한 대규모 앱에 적합하다. Action → Reducer → Store → Component 구조로 상태 흐름이 명확하다.
// store.js
import { createStore } from "redux";
function counterReducer(state = { count: 0 }, action) {
switch (action.type) {
case "INCREMENT": return { count: state.count + 1 };
default: return state;
}
}
export const store = createStore(counterReducer);
// App.jsx
import { Provider, useDispatch, useSelector } from "react-redux";
function Counter() {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch({ type: "INCREMENT" })}>
Count: {count}
</button>
);
}
언제 사용하나: 상태의 변경 이력을 추적해야 하거나, 전역적으로 관리할 액션이 많을 때.
예시: 인증 흐름, 쇼핑카트, 복잡한 폼 관리.
6. 서버 상태 — React Query
서버에서 가져온 데이터를 캐싱하고, 자동으로 최신 상태를 유지한다. 비동기 데이터 관리의 표준 도구.
import { useQuery } from "@tanstack/react-query";
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then(res => res.json()),
});
if (isLoading) return <p>로딩 중...</p>;
if (error) return <p>에러 발생!</p>;
return (
<ul>
{data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
언제 사용하나: API 호출, 데이터 캐싱, 에러 및 로딩 상태 관리가 필요할 때.
예시: 사용자 목록, 게시글 목록, 실시간 데이터 동기화.
7. 서버 상태 — SWR
React Query와 유사하지만, 훨씬 가벼운 데이터 패칭 훅. “Stale-While-Revalidate” 전략으로 최신 데이터를 비동기적으로 갱신한다.
import useSWR from "swr";
const fetcher = url => fetch(url).then(res => res.json());
function Profile() {
const { data, error, isLoading } = useSWR("/api/user", fetcher);
if (isLoading) return <p>로딩 중...</p>;
if (error) return <p>에러 발생!</p>;
return <p>안녕하세요, {data.name}님!</p>;
}
언제 사용하나: 단순 GET API 호출에 캐싱과 리밸리데이션을 적용할 때.
예시: 사용자 정보, 알림 목록, 대시보드 데이터.
6. 상태와 렌더링의 관계
상태(state)는 결국 props를 통해 자식에게 전달되고, props가 바뀌면 컴포넌트가 리렌더링된다. 이 과정에서 불필요한 렌더링을 줄이기 위해 다음과 같은 리액트 메모이제이션 기법을 사용한다.
- React.memo()
- useMemo()
- useCallback()
- PureComponent
이들은 모두 “값이 바뀌지 않으면 연산 또는 렌더링을 건너뛰는” 최적화 도구이다. 하지만 내부적으로 얕은 비교(shallow compare) 를 수행하므로, props가 많거나 객체가 중첩된 경우에는 비교 자체가 오버헤드가 될 수 있다.
7. 요약 및 마무리
| 포인트 | 설명 |
| 상태의 본질 | UI를 다시 그리게 만드는 데이터 |
| 상태의 위치 | 필요한 모든 컴포넌트의 공통 조상에 둔다 |
| Container vs Presentational | 로직과 UI의 관심사를 분리한다 |
| 상태가 복잡할 때 | Context, Redux, React Query 등으로 분리 관리한다 |
| 성능 최적화 | React.memo, useMemo, useCallback은 얕은 비교 기반으로 동작한다 |
React에서 상태는 단순한 변수 이상의 의미를 가진다. 상태를 어디에 두느냐, 어떻게 분리하느냐, 그리고 언제 최적화하느냐에 따라 애플리케이션의 성능과 유지보수성이 결정된다.
“상태는 적게, 명확하게, 필요한 곳에만 둔다.”
'Frontend > React' 카테고리의 다른 글
| React Context API vs Redux: 언제, 무엇을 써야할까? (0) | 2025.10.27 |
|---|---|
| useEffect vs useLayoutEffect (0) | 2025.10.27 |
| React에서 비동기 처리 최적화하기 (0) | 2025.10.25 |
| useRef 자세히 알아보기 (0) | 2025.10.24 |
| useEffect의 cleanup 함수 알아보기 (0) | 2025.10.24 |