본문으로 건너뛰기

안동민 개발노트

본문 시작

React 상태 관리 기초

클라이언트 컴포넌트에서 useState로 변화하는 값을 관리하고 props의 단방향 데이터 흐름으로 UI를 갱신합니다.

웹 애플리케이션은 사용자와의 상호작용을 통해 동적으로 변화하는 정보를 다룹니다.

이러한 변화하는 정보를 관리하는 것이 바로 상태 관리(State Management)이며, React 애플리케이션 개발의 핵심 개념 중 하나입니다.

Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 구분이 생기면서, 상태 관리 전략 또한 이분화해 이해하는 것이 중요해졌습니다.

이 절에서는 React의 가장 기본적인 상태 관리 방법인 useState과 컴포넌트 간 데이터 흐름을 위한 props 전달에 대해 알아보고, 이것이 클라이언트 컴포넌트에서 어떻게 활용되는지 살펴보겠습니다.

서버 컴포넌트는 상태를 직접 가질 수 없으므로, 이 절의 내용은 주로 클라이언트 컴포넌트에 국한됩니다.

React 상태가 화면에 반영되는 흐름

상태는 컴포넌트가 소유하고 이벤트가 setter를 호출하면 React가 다시 렌더링해 새 값을 자식에게 전달합니다.

  1. 입력
    사용자 이벤트

    클릭, 입력, 응답처럼 화면을 바꿔야 하는 사건이 발생합니다.

  2. 갱신
    setState

    setter에 다음 값을 전달해 상태 변경을 예약하며 기존 값을 직접 바꾸지 않습니다.

  3. 계산
    Re-render

    React가 새 state를 읽어 컴포넌트 함수와 UI 트리를 다시 계산합니다.

  4. 전달
    props

    부모가 계산한 값과 콜백을 자식 컴포넌트에 한 방향으로 내려보냅니다.

  5. 출력
    화면 갱신

    전 결과와 달라진 DOM만 반영되고 다음 이벤트를 기다립니다.


상태(State)란 무엇인가요?

Next 상태는 서버 원본에서 client 상호작용으로 내려온다

데이터의 출처와 변경 주체를 나누면 상태 위치와 실행 경계가 함께 정리된다.

  1. server
    DB · API 원본

    fetch·cache로 초기 데이터와 최신성 관리

  2. props
    Initial value

    client leaf가 시작할 plain data 전달

  3. client
    Input · toggle

    브라우저 행동으로만 바뀌는 지역 상태

  4. mutation
    Server Action

    저장 결과와 캐시 정합성을 서버에서 확정

React에서 상태(State)는 컴포넌트의 렌더링 결과에 영향을 미치는 데이터로, 시간이 지남에 따라 변경될 수 있는 값을 의미합니다.

상태가 변경되면 React는 해당 컴포넌트를 자동으로 리렌더링하여 변경된 UI를 화면에 반영합니다.

예를 들어, 카운터 애플리케이션에서 현재 숫자는 상태이고, 체크박스의 체크 여부도 상태입니다.

사용자 입력 필드의 현재 값 또한 상태입니다.


useState 훅을 사용한 상태 관리

React 함수 컴포넌트에서 상태를 관리하는 가장 기본적인 방법은 useState을 사용하는 것입니다.

useState는 클라이언트 컴포넌트에서만 사용 가능합니다.

useState의 기본 사용법
import React, { useState } from 'react';

function MyComponent() {
  // useState 호출:
  // - count: 현재 상태 값
  // - setCount: 상태를 업데이트하는 함수
  // - 0: 상태의 초기값
  const [count, setCount] = useState(0);

  // ... 컴포넌트 로직
}
  • useState는 배열을 반환합니다.
    • 첫 번째 요소 (count): 현재 상태 값을 가리킵니다.
    • 두 번째 요소 (setCount): 상태를 업데이트하는 함수입니다. 이 함수를 호출할 때 새 상태 값을 인자로 전달합니다.
  • useState()의 인자는 상태의 초기값입니다. 초기값은 첫 렌더링 시에만 사용됩니다.
  • setCount와 같은 상태 업데이트 함수를 호출하면, React는 컴포넌트를 다시 렌더링하고 업데이트된 상태 값을 count 변수에 반영합니다.
실습: 좋아요 버튼 구현

간단한 좋아요 버튼을 만들어 useState 훅을 사용하여 좋아요 상태를 토글해 봅시다.

src/app/state-basic/page.tsx 파일 생성 (서버 컴포넌트): 이 파일은 서버 컴포넌트로, 그 안에서 클라이언트 컴포넌트인 LikeButton을 임포트하여 사용합니다.

src/app/state-basic/page.tsx
import LikeButton from './LikeButton'; // 클라이언트 컴포넌트 임포트

export default function StateBasicPage() {
  console.log('StateBasicPage (Server Component) rendering...');
  return (
    <div style={{ padding: '20px', maxWidth: '600px', margin: '20px auto', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h1 style={{ textAlign: 'center', color: '#333' }}>React 상태 관리 기초</h1>
      <p style={{ textAlign: 'center', marginBottom: '30px', color: '#666' }}>
        아래 좋아요 버튼은 클라이언트 컴포넌트에서 상태를 관리합니다.
      </p>
      <div style={{ display: 'flex', justifyContent: 'center' }}>
        <LikeButton />
      </div>
    </div>
  );
}

src/app/state-basic/LikeButton.tsx 파일 생성 (클라이언트 컴포넌트): 파일 상단에 "use client" 지시어를 추가하여 클라이언트 컴포넌트로 선언합니다.

src/app/state-basic/LikeButton.tsx
"use client"; // 클라이언트 컴포넌트임을 명시

import React, { useState } from 'react';

export default function LikeButton() {
  // 좋아요 여부를 상태로 관리 (초기값: false)
  const [isLiked, setIsLiked] = useState(false);
  // 좋아요 개수를 상태로 관리 (초기값: 0)
  const [likes, setLikes] = useState(0);

  const handleClick = () => {
    // 이전 상태 값(prevIsLiked)을 사용하여 상태 업데이트
    setIsLiked(prevIsLiked => !prevIsLiked);
    // 좋아요 개수도 함께 업데이트
    setLikes(prevLikes => (isLiked ? prevLikes - 1 : prevLikes + 1));
  };

  return (
    <button
      onClick={handleClick}
      style={{
        padding: '12px 25px',
        fontSize: '1.2em',
        backgroundColor: isLiked ? '#ff4d4f' : '#f0f2f5',
        color: isLiked ? 'white' : '#595959',
        border: `2px solid ${isLiked ? '#ff4d4f' : '#d9d9d9'}`,
        borderRadius: '25px',
        cursor: 'pointer',
        display: 'flex',
        alignItems: 'center',
        gap: '10px',
        boxShadow: '0 4px 8px rgba(0,0,0,0.1)',
        transition: 'all 0.3s ease',
      }}
    >
      ❤️
      <span>{likes} {isLiked ? '좋아요 취소' : '좋아요'}</span>
    </button>
  );
}

실습 확인: 개발 서버(npm run dev)를 실행한 후, http://localhost:3000/state-basic으로 접속합니다.

좋아요 버튼을 클릭할 때마다 하트 아이콘의 색깔과 텍스트, 그리고 좋아요 개수가 변하는 것을 확인할 수 있습니다.

이는 isLikedlikes 상태가 성공적으로 업데이트되고 컴포넌트가 리렌더링되었기 때문입니다.

setState는 DOM 변경이 아니라 다음 렌더를 예약한다

이벤트가 업데이트 큐에 다음 값을 넣으면 React가 새 snapshot으로 UI를 다시 계산한다.

  1. CLICK
    Rendered → Event

    현재 isLiked snapshot을 읽어 다음 값 계산

  2. QUEUE
    Event → Scheduled

    setState 호출이 업데이트를 큐에 예약

  3. RENDER
    Scheduled → Calculated

    새 state로 컴포넌트 함수를 다시 실행

  4. COMMIT
    Calculated → Visible

    버튼 문구와 좋아요 수의 필요한 DOM만 반영


props를 이용한 데이터 흐름

React에서 데이터는 기본적으로 단방향(Uni-directional) 으로 흐릅니다.

즉, 부모 컴포넌트에서 자식 컴포넌트로 props (속성)를 통해 데이터를 전달합니다.

자식 컴포넌트는 전달받은 props를 읽기 전용으로 사용해야 하며, 직접 수정해서는 안 됩니다.

props 사용법
// 부모 컴포넌트 (서버 또는 클라이언트 컴포넌트)
function ParentComponent() {
  const message = "안녕하세요, React!";
  return <ChildComponent text={message} />; // prop 전달
}

// 자식 컴포넌트 (클라이언트 컴포넌트)
"use client";
function ChildComponent({ text }: { text: string }) {
  return <p>{text}</p>; // prop 사용
}
실습: 사용자 이름을 props로 전달하고 수정하기

사용자 이름을 부모에서 자식으로 전달하고, 자식 컴포넌트에서 이름을 수정할 수 있는 기능을 구현해 봅시다. (실제 수정 로직은 생략하고 상태만 변경합니다.)

src/app/state-props/page.tsx 파일 생성 (서버 컴포넌트): 초기 사용자 이름을 정의하고 UserProfile 클라이언트 컴포넌트로 전달합니다.

src/app/state-props/page.tsx
import UserProfile from './UserProfile'; // 클라이언트 컴포넌트 임포트

export default function StatePropsPage() {
  const initialUserName = "김넥스트"; // 서버 컴포넌트에서 정의한 초기값
  console.log('StatePropsPage (Server Component) rendering...');

  return (
    <div style={{ padding: '20px', maxWidth: '700px', margin: '20px auto', border: '1px solid #ddd', borderRadius: '8px' }}>
      <h1 style={{ textAlign: 'center', color: '#444' }}>`props`를 이용한 데이터 흐름</h1>
      <p style={{ textAlign: 'center', marginBottom: '30px', color: '#777' }}>
        부모 (서버)에서 전달된 사용자 이름을 자식 (클라이언트)에서 관리하고 수정합니다.
      </p>
      <UserProfile userName={initialUserName} /> {/* userName prop으로 전달 */}
    </div>
  );
}

src/app/state-props/UserProfile.tsx 파일 생성 (클라이언트 컴포넌트): userName prop을 받아 자신의 상태로 관리하고 수정하는 기능을 추가합니다.

src/app/state-props/UserProfile.tsx
"use client"; // 클라이언트 컴포넌트임을 명시

import React, { useState } from 'react';

interface UserProfileProps {
  userName: string; // 부모로부터 전달받을 prop의 타입 정의
}

export default function UserProfile({ userName }: UserProfileProps) {
  // prop으로 받은 userName을 초기값으로 사용하여 컴포넌트 내부 상태를 생성
  const [currentName, setCurrentName] = useState(userName);
  const [isEditing, setIsEditing] = useState(false);

  const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setCurrentName(e.target.value);
  };

  const handleSave = () => {
    // 실제로는 여기서 API 호출 등으로 백엔드에 업데이트를 요청
    alert(`이름 저장: ${currentName}`);
    setIsEditing(false);
  };

  return (
    <div style={{ border: '2px dashed #6c757d', padding: '20px', borderRadius: '10px', backgroundColor: '#f8f9fa' }}>
      <h2 style={{ color: '#007bff' }}>사용자 프로필 (클라이언트 컴포넌트)</h2>
      {isEditing ? (
        <div>
          <label htmlFor="nameInput" style={{ display: 'block', marginBottom: '5px' }}>이름 편집:</label>
          <input
            id="nameInput"
            type="text"
            value={currentName}
            onChange={handleNameChange}
            style={{ width: 'calc(100% - 20px)', padding: '10px', marginBottom: '15px', borderRadius: '5px', border: '1px solid #ddd' }}
          />
          <button onClick={handleSave} style={{ padding: '10px 20px', marginRight: '10px', backgroundColor: '#28a745', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>저장</button>
          <button
            onClick={() => {
              setCurrentName(userName);
              setIsEditing(false);
            }}
            style={{
              padding: '10px 20px',
              backgroundColor: '#dc3545',
              color: 'white',
              border: 'none',
              borderRadius: '5px',
              cursor: 'pointer',
            }}
          >
            취소
          </button>
        </div>
      ) : (
        <div>
          <p style={{ fontSize: '1.1em' }}>현재 이름: <strong style={{ color: '#343a40' }}>{currentName}</strong></p>
          <button onClick={() => setIsEditing(true)} style={{ padding: '10px 20px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>이름 수정</button>
        </div>
      )}
    </div>
  );
}

실습 확인: http://localhost:3000/state-props로 접속합니다.

이름 수정 버튼을 클릭하여 이름을 변경하고 저장 버튼을 눌러보세요.

이름이 변경되고, 페이지를 새로고침하면 다시 김넥스트로 돌아오는 것을 확인할 수 있습니다.

이는 초기 userName prop이 다시 전달되어 useStatecurrentName 상태를 초기화하기 때문입니다.


상태 관리의 한계점과 다음 단계

useStateprops를 이용한 상태 관리는 간단한 컴포넌트 트리에서는 효과적입니다.

하지만 애플리케이션의 규모가 커지고 컴포넌트 트리가 깊어지며, 여러 컴포넌트가 동일한 상태를 공유해야 하는 상황이 발생하면 다음과 같은 문제에 직면할 수 있습니다.

  • Prop Drilling (프롭스 드릴링): 상태를 여러 단계의 자식 컴포넌트로 계속해서 props로 전달해야 하는 번거로움.
  • 상태 공유의 복잡성: 서로 다른 위치에 있는 컴포넌트들이 동일한 상태를 효율적으로 공유하기 어려움.
  • 성능 문제: 불필요한 리렌더링 발생 가능성.

이러한 문제들을 해결하기 위해 React는 Context API와 같은 내장 도구를 제공하며, 더 복잡한 시나리오에서는 Redux Toolkit, Zustand, Jotai 같은 클라이언트 상태 도구와 TanStack Query 같은 서버 상태 도구를 목적에 맞게 선택합니다.

Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할 분담을 고려하여, 어떤 상태를 어디서 관리할지 신중하게 결정하는 것이 중요합니다.

서버 컴포넌트는 초기 데이터 페칭과 정적 콘텐츠 렌더링에 집중하고, 클라이언트 컴포넌트만이 사용자 상호작용 및 동적 상태 관리를 담당해야 합니다.

상태는 출처와 공유 범위가 가장 좁은 곳에 둔다

모든 값을 전역 store로 보내지 말고 서버·지역·공유 UI 상태를 차례로 구분한다.

  1. DB·CMS
    Server state

    async 컴포넌트와 cache에서 원본 관리

  2. 한 컴포넌트
    Local state

    입력·열림·선택 탭을 useState로 보관

  3. 한 subtree
    Context

    Provider 아래에서만 쓰는 테마·사용자 표시

  4. 여러 영역
    Client store

    자주 바뀌는 공유 UI가 실제로 있을 때 선택

다음 단계로 넘어가기 전에는 상태가 URL, 서버 데이터, 클라이언트 메모리, 서버 액션 중 어디에 속하는지 먼저 분류해 두면 구조가 단단해집니다.

상태는 가장 좁고 복원 가능한 원본에 둔다

전역 store부터 고르지 말고 값의 출처·공유 범위·새로고침 후 복원 여부를 차례로 확인한다.

  1. DB·API 원본
    Server data

    fetch cache와 revalidate로 최신성을 관리

  2. 공유·뒤로가기
    URL

    검색어·필터·페이지 번호를 주소로 표현

  3. 부모가 소유
    Props

    서버 결과를 필요한 client leaf까지 전달

  4. 한 영역 임시값
    Local state

    모달·탭·입력 draft를 컴포넌트 가까이에 둠

  5. 여러 client leaf
    Context / store

    다른 작은 원본이 없을 때 마지막으로 선택

이 다이어그램은 React 상태 관리 기초를 Next.js 구조 안에서 어디에 두고 어떤 컴포넌트 경계에서 다룰지 정리합니다.

Next.js 상태의 올바른 주소

Next.js에서는 URL, 서버 원본, 브라우저 상호작용이 서로 다른 수명과 동기화 규칙을 가진다.

  1. 1
    공유·탐색?

    링크 복사, 새로고침, 뒤로 가기에 의미가 있는가?

  2. 2
    서버 권위?

    DB·API가 원본이고 사용자 간 일관성이 필요한가?

  3. 3
    임시 상호작용?

    현재 컴포넌트의 입력·열림·선택에만 필요한가?

  4. 4
    URL · searchParams

    검색, 필터, 정렬, 페이지를 직렬화하고 읽은 값은 검증한다.

  5. 5
    Server data

    fetch 결과와 mutation을 캐시·재검증 정책으로 관리한다.

마지막으로 useState와 props를 화면 데이터 흐름에서 어떻게 구분해야 하는지 정리합니다.

state와 props 흐름

useState와 props의 역할을 구분하고 상태 관리가 커질 때 다음 단계로 넘어가는 기준을 정리합니다.

  1. 지역 상태

    useState 입력값, 열림 여부, 선택 탭처럼 한 컴포넌트 주변에서 바뀌는 값에 적합합니다.

  2. 하향식 전달

    props 상위 컴포넌트가 가진 데이터를 자식 UI에 명확하게 전달합니다.

  3. 공유 상태 신호

    확장 멀리 떨어진 컴포넌트가 같은 값을 자주 쓰면 Context나 상태 라이브러리를 검토합니다.

  4. 상태 변경·조회 주체

    구분 질문 상태를 아무 곳에나 두면 데이터 흐름이 흐려지므로 값을 변경하는 주체와 표시하는 위치를 분리해서 생각합니다.

  5. 상태 기준

    까움 한 컴포넌트와 바로 아래 자식만 쓰면 useState와 props로 충분합니다. 공유 여러 화면이 같은 값을 읽으면 상위로 올리거나 Context를 씁니다. 서버 데이터 API 결과 캐시와 동기화가 필요하면 데이터 라이브러리를 검토합니다.