icon
8장 : 상태 관리 및 폼 처리

React 상태 관리 기초

웹 애플리케이션은 사용자와의 상호작용을 통해 동적으로 변화하는 정보를 다룹니다. 이러한 '변화하는 정보'를 관리하는 것이 바로 상태 관리(State Management) 이며, React 애플리케이션 개발의 핵심 개념 중 하나입니다. Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 구분이 생기면서, 상태 관리 전략 또한 이분화하여 이해하는 것이 중요해졌습니다.

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


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

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 훅을 사용하여 좋아요 상태를 토글해 봅시다.

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

    src/app/state-basic/page.tsx
    // 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>
      );
    }
  2. src/app/state-basic/LikeButton.tsx 파일 생성 (클라이언트 컴포넌트): 파일 상단에 "use client" 지시어를 추가하여 클라이언트 컴포넌트로 선언합니다.

    src/app/state-basic/LikeButton.tsx
    // 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 상태가 성공적으로 업데이트되고 컴포넌트가 리렌더링되었기 때문입니다.


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로 전달하고 수정하기

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

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

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

    src/app/state-props/UserProfile.tsx
    // 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, Zustand, Jotai, Recoil, TanStack Query (React Query) 와 같은 전역 상태 관리 라이브러리를 활용합니다.

Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할 분담을 고려하여, 어떤 상태를 어디서 관리할지 신중하게 결정하는 것이 중요합니다. 서버 컴포넌트는 초기 데이터 페칭과 정적 콘텐츠 렌더링에 집중하고, 클라이언트 컴포넌트만이 사용자 상호작용 및 동적 상태 관리를 담당해야 합니다.