React 상태 관리 기초
클라이언트 컴포넌트에서 useState로 변화하는 값을 관리하고 props의 단방향 데이터 흐름으로 UI를 갱신합니다.
웹 애플리케이션은 사용자와의 상호작용을 통해 동적으로 변화하는 정보를 다룹니다.
이러한 변화하는 정보를 관리하는 것이 바로 상태 관리(State Management)이며, React 애플리케이션 개발의 핵심 개념 중 하나입니다.
Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 구분이 생기면서, 상태 관리 전략 또한 이분화해 이해하는 것이 중요해졌습니다.
이 절에서는 React의 가장 기본적인 상태 관리 방법인 useState 훅과 컴포넌트 간 데이터 흐름을 위한 props 전달에 대해 알아보고, 이것이 클라이언트 컴포넌트에서 어떻게 활용되는지 살펴보겠습니다.
서버 컴포넌트에서는 useState로 브라우저 상호작용 상태를 유지하지 않습니다. 서버 데이터는 렌더링마다 달라질 수 있으며, 이 절에서는 클라이언트 컴포넌트의 상태를 다룹니다.
상태(State)란 무엇인가요?
React에서 상태(State)는 컴포넌트의 렌더링 결과에 영향을 미치는 데이터로, 시간이 지남에 따라 변경될 수 있는 값을 의미합니다.
상태 업데이트는 다음 렌더링을 요청합니다. 새 값이 이전 값과 Object.is 기준으로 같으면 React가 렌더링을 건너뛸 수 있습니다.
예를 들어, 카운터 애플리케이션에서 현재 숫자는 상태이고, 체크박스의 체크 여부도 상태입니다.
사용자 입력 필드의 현재 값 또한 상태입니다.
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을 임포트하여 사용합니다.
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" 지시어를 추가하여 클라이언트 컴포넌트로 선언합니다.
"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으로 접속합니다.
좋아요 버튼을 클릭할 때마다 버튼 배경색·텍스트와 좋아요 개수가 바뀝니다. 하트 이모지 자체는 같은 문자입니다.
이는 isLiked와 likes 상태가 성공적으로 업데이트되고 컴포넌트가 리렌더링되었기 때문입니다.
props를 이용한 데이터 흐름
React에서 데이터는 기본적으로 단방향(Uni-directional) 으로 흐릅니다.
즉, 부모 컴포넌트에서 자식 컴포넌트로 props (속성)를 통해 데이터를 전달합니다.
자식 컴포넌트는 전달받은 props를 읽기 전용으로 사용해야 하며, 직접 수정해서는 안 됩니다.
props 사용법
"use client"; // 이 짧은 예제는 하나의 클라이언트 모듈입니다.
// 부모 컴포넌트
function ParentComponent() {
const message = "안녕하세요, React!";
return <ChildComponent text={message} />; // prop 전달
}
// 같은 클라이언트 모듈의 자식 컴포넌트
function ChildComponent({ text }: { text: string }) {
return <p>{text}</p>; // prop 사용
}props로 전달하고 수정하기
사용자 이름을 부모에서 자식으로 전달하고, 자식 컴포넌트에서 이름을 수정할 수 있는 기능을 구현해 봅시다. (실제 수정 로직은 생략하고 상태만 변경합니다.)
src/app/state-props/page.tsx 파일 생성 (서버 컴포넌트):
초기 사용자 이름을 정의하고 UserProfile 클라이언트 컴포넌트로 전달합니다.
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을 받아 자신의 상태로 관리하고 수정하는 기능을 추가합니다.
"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로 접속합니다.
이름 수정 버튼을 클릭하여 이름을 변경하고 저장 버튼을 눌러보세요.
이름이 변경되고, 페이지를 새로고침하면 다시 김넥스트로 돌아오는 것을 확인할 수 있습니다.
페이지 전체 새로고침으로 컴포넌트가 다시 마운트되어 초기값을 읽기 때문입니다. 이미 마운트된 컴포넌트에 새 prop만 전달하는 것은 useState를 자동 초기화하지 않습니다. useState의 초기값 규칙을 구분해 확인합니다.
| 시점 | 예제에서 바뀌는 값 |
|---|---|
| 첫 렌더 | userName과 currentName 모두 김넥스트로 시작합니다. |
| 이름 편집 | 입력한 값으로 currentName만 바뀝니다. userName prop은 그대로입니다. |
| 저장 | 알림을 표시하고 편집을 닫습니다. 바뀐 currentName은 현재 화면에 남습니다. |
| 취소 | currentName을 현재 userName prop 값으로 되돌립니다. |
| 다시 마운트 | 페이지 전체 새로고침으로 새 컴포넌트가 생기면 부모의 초기값으로 시작합니다. |
- 첫 렌더
- 예제에서 바뀌는 값: userName과 currentName 모두 김넥스트로 시작합니다.
- 이름 편집
- 예제에서 바뀌는 값: 입력한 값으로 currentName만 바뀝니다. userName prop은 그대로입니다.
- 저장
- 예제에서 바뀌는 값: 알림을 표시하고 편집을 닫습니다. 바뀐 currentName은 현재 화면에 남습니다.
- 취소
- 예제에서 바뀌는 값: currentName을 현재 userName prop 값으로 되돌립니다.
- 다시 마운트
- 예제에서 바뀌는 값: 페이지 전체 새로고침으로 새 컴포넌트가 생기면 부모의 초기값으로 시작합니다.
김넥스트는 부모가 보낸 값이고, currentName은 입력을 따라 바뀌는 별도 상태입니다. 저장 버튼은 알림만 표시하며 서버에 저장하지 않습니다.
상태 관리의 한계점과 다음 단계
useState와 props를 이용한 상태 관리는 간단한 컴포넌트 트리에서는 효과적입니다.
하지만 애플리케이션의 규모가 커지고 컴포넌트 트리가 깊어지며, 여러 컴포넌트가 동일한 상태를 공유해야 하는 상황이 발생하면 다음과 같은 문제에 직면할 수 있습니다.
- Prop Drilling (프롭스 드릴링): 상태를 여러 단계의 자식 컴포넌트로 계속해서
props로 전달해야 하는 번거로움. - 상태 공유의 복잡성: 서로 다른 위치에 있는 컴포넌트들이 동일한 상태를 효율적으로 공유하기 어려움.
- 성능 문제: 불필요한 리렌더링 발생 가능성.
이러한 문제들을 해결하기 위해 React는 Context API와 같은 내장 도구를 제공하며, 더 복잡한 시나리오에서는 Redux Toolkit, Zustand, Jotai 같은 클라이언트 상태 도구와 TanStack Query 같은 서버 상태 도구를 목적에 맞게 선택합니다.
Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할 분담을 고려하여, 어떤 상태를 어디서 관리할지 신중하게 결정하는 것이 중요합니다.
서버 컴포넌트는 서버 데이터 요청과 렌더링을, 클라이언트 컴포넌트는 이벤트·브라우저 API·지속되는 UI 상태를 담당합니다. 서버에서 렌더링하는 내용도 요청과 데이터에 따라 달라질 수 있습니다.
공유할 상태는 원본부터 나눕니다. 탐색 가능한 필터는 URL에, 입력 중인 값은 클라이언트 상태에, 저장된 사용자 정보는 서버 데이터에 속할 수 있습니다. 서버 액션은 상태 저장소가 아니라 데이터를 변경하는 실행 경로입니다.