React 상태 관리 기초
클라이언트 컴포넌트에서 useState로 변화하는 값을 관리하고 props의 단방향 데이터 흐름으로 UI를 갱신합니다.
웹 애플리케이션은 사용자와의 상호작용을 통해 동적으로 변화하는 정보를 다룹니다.
이러한 변화하는 정보를 관리하는 것이 바로 상태 관리(State Management)이며, React 애플리케이션 개발의 핵심 개념 중 하나입니다.
Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 구분이 생기면서, 상태 관리 전략 또한 이분화해 이해하는 것이 중요해졌습니다.
이 절에서는 React의 가장 기본적인 상태 관리 방법인 useState 훅과 컴포넌트 간 데이터 흐름을 위한 props 전달에 대해 알아보고, 이것이 클라이언트 컴포넌트에서 어떻게 활용되는지 살펴보겠습니다.
서버 컴포넌트는 상태를 직접 가질 수 없으므로, 이 절의 내용은 주로 클라이언트 컴포넌트에 국한됩니다.
상태는 컴포넌트가 소유하고 이벤트가 setter를 호출하면 React가 다시 렌더링해 새 값을 자식에게 전달합니다.
- 입력사용자 이벤트
클릭, 입력, 응답처럼 화면을 바꿔야 하는 사건이 발생합니다.
- 갱신setState
setter에 다음 값을 전달해 상태 변경을 예약하며 기존 값을 직접 바꾸지 않습니다.
- 계산Re-render
React가 새 state를 읽어 컴포넌트 함수와 UI 트리를 다시 계산합니다.
- 전달props
부모가 계산한 값과 콜백을 자식 컴포넌트에 한 방향으로 내려보냅니다.
- 출력화면 갱신
전 결과와 달라진 DOM만 반영되고 다음 이벤트를 기다립니다.
상태(State)란 무엇인가요?
데이터의 출처와 변경 주체를 나누면 상태 위치와 실행 경계가 함께 정리된다.
- serverDB · API 원본
fetch·cache로 초기 데이터와 최신성 관리
- propsInitial value
client leaf가 시작할 plain data 전달
- clientInput · toggle
브라우저 행동으로만 바뀌는 지역 상태
- mutationServer 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을 임포트하여 사용합니다.
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 상태가 성공적으로 업데이트되고 컴포넌트가 리렌더링되었기 때문입니다.
이벤트가 업데이트 큐에 다음 값을 넣으면 React가 새 snapshot으로 UI를 다시 계산한다.
- CLICKRendered → Event
현재 isLiked snapshot을 읽어 다음 값 계산
- QUEUEEvent → Scheduled
setState 호출이 업데이트를 큐에 예약
- RENDERScheduled → Calculated
새 state로 컴포넌트 함수를 다시 실행
- COMMITCalculated → 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 클라이언트 컴포넌트로 전달합니다.
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로 접속합니다.
이름 수정 버튼을 클릭하여 이름을 변경하고 저장 버튼을 눌러보세요.
이름이 변경되고, 페이지를 새로고침하면 다시 김넥스트로 돌아오는 것을 확인할 수 있습니다.
이는 초기 userName prop이 다시 전달되어 useState의 currentName 상태를 초기화하기 때문입니다.
상태 관리의 한계점과 다음 단계
useState와 props를 이용한 상태 관리는 간단한 컴포넌트 트리에서는 효과적입니다.
하지만 애플리케이션의 규모가 커지고 컴포넌트 트리가 깊어지며, 여러 컴포넌트가 동일한 상태를 공유해야 하는 상황이 발생하면 다음과 같은 문제에 직면할 수 있습니다.
- Prop Drilling (프롭스 드릴링): 상태를 여러 단계의 자식 컴포넌트로 계속해서
props로 전달해야 하는 번거로움. - 상태 공유의 복잡성: 서로 다른 위치에 있는 컴포넌트들이 동일한 상태를 효율적으로 공유하기 어려움.
- 성능 문제: 불필요한 리렌더링 발생 가능성.
이러한 문제들을 해결하기 위해 React는 Context API와 같은 내장 도구를 제공하며, 더 복잡한 시나리오에서는 Redux Toolkit, Zustand, Jotai 같은 클라이언트 상태 도구와 TanStack Query 같은 서버 상태 도구를 목적에 맞게 선택합니다.
Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할 분담을 고려하여, 어떤 상태를 어디서 관리할지 신중하게 결정하는 것이 중요합니다.
서버 컴포넌트는 초기 데이터 페칭과 정적 콘텐츠 렌더링에 집중하고, 클라이언트 컴포넌트만이 사용자 상호작용 및 동적 상태 관리를 담당해야 합니다.
모든 값을 전역 store로 보내지 말고 서버·지역·공유 UI 상태를 차례로 구분한다.
- DB·CMSServer state
async 컴포넌트와 cache에서 원본 관리
- 한 컴포넌트Local state
입력·열림·선택 탭을 useState로 보관
- 한 subtreeContext
Provider 아래에서만 쓰는 테마·사용자 표시
- 여러 영역Client store
자주 바뀌는 공유 UI가 실제로 있을 때 선택
다음 단계로 넘어가기 전에는 상태가 URL, 서버 데이터, 클라이언트 메모리, 서버 액션 중 어디에 속하는지 먼저 분류해 두면 구조가 단단해집니다.
전역 store부터 고르지 말고 값의 출처·공유 범위·새로고침 후 복원 여부를 차례로 확인한다.
- DB·API 원본Server data
fetch cache와 revalidate로 최신성을 관리
- 공유·뒤로가기URL
검색어·필터·페이지 번호를 주소로 표현
- 부모가 소유Props
서버 결과를 필요한 client leaf까지 전달
- 한 영역 임시값Local state
모달·탭·입력 draft를 컴포넌트 가까이에 둠
- 여러 client leafContext / store
다른 작은 원본이 없을 때 마지막으로 선택
이 다이어그램은 React 상태 관리 기초를 Next.js 구조 안에서 어디에 두고 어떤 컴포넌트 경계에서 다룰지 정리합니다.
Next.js에서는 URL, 서버 원본, 브라우저 상호작용이 서로 다른 수명과 동기화 규칙을 가진다.
- 1공유·탐색?
링크 복사, 새로고침, 뒤로 가기에 의미가 있는가?
- 2서버 권위?
DB·API가 원본이고 사용자 간 일관성이 필요한가?
- 3임시 상호작용?
현재 컴포넌트의 입력·열림·선택에만 필요한가?
- 4URL · searchParams
검색, 필터, 정렬, 페이지를 직렬화하고 읽은 값은 검증한다.
- 5Server data
fetch 결과와 mutation을 캐시·재검증 정책으로 관리한다.
마지막으로 useState와 props를 화면 데이터 흐름에서 어떻게 구분해야 하는지 정리합니다.
useState와 props의 역할을 구분하고 상태 관리가 커질 때 다음 단계로 넘어가는 기준을 정리합니다.
- 지역 상태
useState 입력값, 열림 여부, 선택 탭처럼 한 컴포넌트 주변에서 바뀌는 값에 적합합니다.
- 하향식 전달
props 상위 컴포넌트가 가진 데이터를 자식 UI에 명확하게 전달합니다.
- 공유 상태 신호
확장 멀리 떨어진 컴포넌트가 같은 값을 자주 쓰면 Context나 상태 라이브러리를 검토합니다.
- 상태 변경·조회 주체
구분 질문 상태를 아무 곳에나 두면 데이터 흐름이 흐려지므로 값을 변경하는 주체와 표시하는 위치를 분리해서 생각합니다.
- 상태 기준
까움 한 컴포넌트와 바로 아래 자식만 쓰면 useState와 props로 충분합니다. 공유 여러 화면이 같은 값을 읽으면 상위로 올리거나 Context를 씁니다. 서버 데이터 API 결과 캐시와 동기화가 필요하면 데이터 라이브러리를 검토합니다.