상태(stateful) & 비상태(stateless) 컴포넌트

2025. 10. 25. 15:12·Frontend/React

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
'Frontend/React' 카테고리의 다른 글
  • React Context API vs Redux: 언제, 무엇을 써야할까?
  • useEffect vs useLayoutEffect
  • React에서 비동기 처리 최적화하기
  • useRef 자세히 알아보기
JTB
JTB
웹/앱 개발 정보를 공유하고 있습니다.
  • JTB
    JTechBlog
    JTB
  • 전체
    오늘
    어제
    • All About Programming;)
      • Computer Science
        • Terminology and Concepts
        • Network
        • Operating System
        • Database
        • Data Structure
        • Web Development
      • Frontend
        • Javascript Essentials
        • Perfomance Optimization
        • JS Patterns
        • React
        • Next.js
        • Flutter
        • Testing
      • Backend
        • Node.js
      • DevOps
        • Docker & Kubernetes
      • Coding Test
        • LeetCode
        • Programmers
      • Tech Books & Lectures
        • Javascript_Modern JS Deep d..
        • Network_IT 엔지니어를 위한 네트워크 입문
      • Projects
        • PolyLingo_2025
        • Build Your Body_2024
        • JStargram_2021
        • Covid19 Tracker_2021
        • JPortfolio_2021
      • BootCamp_Codestates
        • TIL
        • TILookCloser
        • Pre Tech Blog
        • IM Tech Blog
        • Daily Issues and DeBugging
        • First Project
        • Final Project
        • Sprint Review
        • Good to Know
        • Socrative Review
        • HTML &amp; CSS
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 글쓰기
    • 관리
  • 공지사항

  • 인기 글

  • 태그

    need a database
    DOM
    leetcode
    VoiceJournal
    딥다이브
    Javascript Essentials
    CPU scheduling algorithm
    프론트엔드 성능 최적화 가이드
    Time complexity and Space complexity
    indie hacker
    polylingo
    Network
    스코프
    How memory manage data
    모던 자바스크립트 Deep Dive
    Binary Tree BFS
    Data Structure
    mobile app
    structure of os
    TCP/IP
    Shared resources
    Threads and Multithreading
    database
    자바스크립트 딥다이브
    js pattern
    커리어
    이벤트
    testing
    Operating System
    자바스크립트
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
JTB
상태(stateful) & 비상태(stateless) 컴포넌트
상단으로

티스토리툴바