React 상태 관리 기초
클라이언트 컴포넌트에서 useState로 변화하는 값을 관리하고 props의 단방향 데이터 흐름으로 UI를 갱신합니다.
웹 애플리케이션은 사용자와의 상호작용에 따라 바뀌는 정보를 다룹니다. 이렇게 바뀌는 정보를 관리하는 것이 상태 관리(State Management)이며, React 애플리케이션 개발의 핵심 개념 가운데 하나입니다. Next.js App Router에서는 서버 컴포넌트와 클라이언트 컴포넌트가 나뉘면서, 상태 관리 전략도 두 갈래로 나누어 이해하는 것이 중요해졌습니다.
8장에서는 클라이언트 컴포넌트의 상태 관리에서 시작해 서버 액션, 폼 제출과 데이터 처리, 상태 관리 도구까지 차례로 다룹니다. 이 글에서는 React의 가장 기본적인 상태 관리 방법인 useState 훅과, 컴포넌트 사이의 데이터 흐름을 위한 props 전달을 알아보고 이것을 클라이언트 컴포넌트에서 어떻게 쓰는지 살펴봅니다. 서버 컴포넌트에서는 useState로 브라우저 상호작용의 상태를 유지하지 않습니다. 서버 데이터는 렌더링할 때마다 달라질 수 있으며, 이 글에서 다루는 것은 클라이언트 컴포넌트의 상태입니다.
상태(State)란 무엇인가요?
React에서 상태(state)는 컴포넌트의 렌더링 결과에 영향을 주는 데이터로, 시간이 지나면서 바뀔 수 있는 값을 뜻합니다. 카운터 애플리케이션의 현재 숫자, 체크박스의 체크 여부, 사용자 입력 필드의 현재 값이 모두 상태입니다. 상태를 업데이트하면 다음 렌더링을 요청하며, 새 값이 이전 값과 Object.is 기준으로 같으면 React가 렌더링을 건너뛸 수 있습니다.
useState 훅을 사용한 상태 관리
React 함수 컴포넌트에서 상태를 관리하는 가장 기본적인 방법은 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 변수에 업데이트된 값이 들어갑니다.
위 코드의 MyComponent는 상태 선언만 보여 주는 발췌이며 화면을 돌려주는 완성된 페이지가 아니므로, 이어지는 파일별 코드와 구분합니다. useState 같은 훅은 컴포넌트 함수의 최상위에서 매번 같은 순서로 호출하며, 버튼의 이벤트 함수나 조건문 안에서 새 상태를 선언하는 용도가 아닙니다.
실습은 앞 장에서 준비한 App Router 프로젝트를 씁니다. page.tsx와 LikeButton.tsx는 src/app/state-basic 안에, 두 번째 실습의 page.tsx와 UserProfile.tsx는 src/app/state-props 안에 따로 만듭니다. 이미 개발 서버를 열었다면 같은 3000번 포트의 개발 서버에서 두 경로를 확인합니다. 서버 페이지가 이벤트를 직접 처리하는 것이 아니라, 그 페이지가 렌더링한 클라이언트 컴포넌트의 버튼과 입력이 상태를 바꿉니다. "use client"는 클라이언트 기능을 쓰는 모듈을 표시할 뿐 첫 화면의 HTML이 서버에서 미리 렌더링되지 않는다는 뜻이 아니며, 클릭 뒤 로컬 상태가 바뀐다고 서버 페이지의 함수가 매번 다시 실행되는 것도 아닙니다.
실습: 좋아요 버튼 구현
간단한 좋아요 버튼을 만들어 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>
);
}페이지는 버튼을 배치하기만 하고 상태를 갖지 않으므로 서버 컴포넌트로 둡니다. 맨 위의 console.log는 이 페이지가 서버에서 렌더링될 때 터미널에 찍히며, 브라우저 콘솔에는 나타나지 않습니다. 다음으로 src/app/state-basic/LikeButton.tsx를 만들고, 파일 맨 위에 "use client" 지시어를 추가해 클라이언트 컴포넌트로 선언합니다. 버튼은 좋아요 여부(isLiked)와 개수(likes)를 각각의 useState로 관리하며, 클릭할 때 두 상태를 함께 바꿉니다.
"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 상태가 업데이트되고 컴포넌트가 다시 렌더링되었기 때문입니다.
처음 화면은 isLiked=false, likes=0입니다. 첫 클릭의 handleClick은 이 렌더링의 false를 읽습니다. setIsLiked의 업데이트 함수(updater)는 true를 예약하고, setLikes의 업데이트 함수는 같은 클릭에서 읽은 isLiked=false에 따라 이전 개수 0에 1을 더합니다. 그래서 다음 렌더링은 true와 1을 표시합니다. 그 렌더링 뒤에 두 번째로 클릭하면 true를 읽으므로 false와 0이 됩니다.
setIsLiked를 호출했다고 지금 실행 중인 handleClick의 isLiked가 곧바로 true로 바뀌지는 않습니다. React의 상태 업데이트 규칙은 현재 렌더링의 값과 다음 렌더링에서 쓸 값을 구분합니다. 이 예시는 렌더링을 사이에 두고 사용자가 한 번씩 클릭하는 흐름이며, 여러 동작을 한 이벤트 안에서 합치려면 두 상태의 관계도 함께 설계해야 합니다.
예를 들어 첫 렌더링의 handleClick을 하나의 이벤트 함수 안에서 두 번 호출하면, 두 호출 모두 같은 isLiked=false를 읽습니다. setIsLiked의 두 업데이트 함수는 false에서 true, 다시 false를 만들지만, setLikes의 두 업데이트 함수는 각각 1을 더해 0에서 1, 다시 2를 만듭니다. 그래서 false와 2가 될 수 있습니다. 업데이트 함수를 썼더라도 다른 상태의 현재 렌더링 값을 함께 읽으면 두 값의 관계가 저절로 맞지는 않습니다.
두 값을 하나의 상태로 묶으면 이전 객체만으로 다음 객체를 계산할 수 있습니다. 다음은 그 계산만 분리한 순수 함수입니다.
export interface Reaction {
isLiked: boolean;
likes: number;
}
export function toggleReaction(previous: Reaction): Reaction {
return {
isLiked: !previous.isLiked,
likes: previous.likes + (previous.isLiked ? -1 : 1),
};
}이 방식을 적용하려면 상태 선언을 useState({ isLiked: false, likes: 0 }) 하나로 바꾸고, 이벤트에서는 setReaction(toggleReaction)을 쓰며, 화면도 reaction.isLiked와 reaction.likes를 읽도록 함께 바꿉니다. 기존의 두 상태 코드에 이 함수만 추가한다고 적용되지는 않습니다. 같은 초기값에서 두 업데이트 함수가 차례로 계산하면 false·0에서 true·1, 다시 false·0이 됩니다. 업데이트 함수 안에서는 알림이나 서버 저장을 실행하지 않고 다음 값만 계산합니다. 자세한 동작은 React의 업데이트 큐 설명에서 확인할 수 있습니다.
props를 이용한 데이터 흐름
React에서 데이터는 기본적으로 단방향(uni-directional)으로 흐릅니다. 즉 부모 컴포넌트가 자식 컴포넌트에 props(속성)로 데이터를 넘기며, 자식 컴포넌트는 받은 props를 읽기 전용으로 써야 하고 직접 고쳐서는 안 됩니다. 기본 사용법은 다음과 같습니다.
"use client"; // 이 짧은 예제는 하나의 클라이언트 모듈입니다.
// 부모 컴포넌트
function ParentComponent() {
const message = "안녕하세요, React!";
return <ChildComponent text={message} />; // prop 전달
}
// 같은 클라이언트 모듈의 자식 컴포넌트
function ChildComponent({ text }: { text: string }) {
return <p>{text}</p>; // prop 사용
}이 예제에서 text의 값은 부모가 정한 message이므로 자식은 언제나 같은 문장을 그립니다. 자식이 화면에서 이 값을 바꿔 보여 주려면 text를 직접 고치지 않고, 다음 실습처럼 받은 값을 자기 상태의 초기값으로 삼은 뒤 그 상태를 바꿉니다.
실습: 사용자 이름을 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의 초기값 규칙에서 확인할 수 있습니다.
사용자 이름 예제의 prop과 편집값
서버 prop으로 currentName을 초기화하고 onChange는 draft만 바꾸며 cancel은 prop으로 되돌립니다. save도 alert·편집 닫기일 뿐 서버 저장이 아니므로 지역 draft의 실제 변화를 단계로 보여 줄 수 있습니다.
NEXT · CH8-1
이름 편집은 currentName만 바꿉니다
StatePropsPage의 userName="김넥스트"를 UserProfile의 useState 초기값으로 사용합니다. prop과 입력 중인 상태를 각 동작에서 구분합니다.
| 시점 | 예제에서 바뀌는 값 |
|---|---|
| 첫 렌더 | userName과 currentName 모두 김넥스트로 시작합니다. |
| 이름 편집 | 입력한 값으로 currentName만 바뀝니다. userName prop은 그대로입니다. |
| 저장 | 알림을 표시하고 편집을 닫습니다. 바뀐 currentName은 현재 화면에 남습니다. |
| 취소 | currentName을 현재 userName prop 값으로 되돌립니다. |
| 다시 마운트 | 페이지 전체 새로고침으로 새 컴포넌트가 생기면 부모의 초기값으로 시작합니다. |
- 첫 렌더
- 예제에서 바뀌는 값: userName과 currentName 모두 김넥스트로 시작합니다.
- 이름 편집
- 예제에서 바뀌는 값: 입력한 값으로 currentName만 바뀝니다. userName prop은 그대로입니다.
- 저장
- 예제에서 바뀌는 값: 알림을 표시하고 편집을 닫습니다. 바뀐 currentName은 현재 화면에 남습니다.
- 취소
- 예제에서 바뀌는 값: currentName을 현재 userName prop 값으로 되돌립니다.
- 다시 마운트
- 예제에서 바뀌는 값: 페이지 전체 새로고침으로 새 컴포넌트가 생기면 부모의 초기값으로 시작합니다.
저장은 alert와 편집 닫기이며 서버에 저장하지 않습니다. 취소는 현재 userName prop으로 되돌립니다. 이미 마운트된 상태에서 새 prop을 받는 것만으로 useState가 초기화되지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 화살표는 값 전달이나 상태 갱신의 관계입니다. 서버 page가 userName prop을 전달하고, 처음 마운트의 초기화 또는 취소 클릭에서 currentName에 그 값을 사용합니다. 새 prop을 받기만 하면 이미 마운트된 상태가 자동 초기화되지는 않습니다. 편집 UI의 onChange는 로컬 currentName만 바꾸고 그 값을 화면이 읽습니다. 저장 버튼은 원 코드대로 alert와 편집 닫기이며 서버에 값을 쓰는 연결은 없습니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| prop으로 첫 지역 상태 서버 page의 userName으로 첫 마운트 시 currentName을 초기화합니다. | 원본: userName prop · 지역: currentName · 새 prop 자동 reset: 없음 | 편집할 지역 값이 처음 설정됩니다. |
| draft 변경 onChange는 지역 currentName만 바꾸고 화면이 그 값을 읽습니다. | 변경: currentName · 서버 값: 미변경 · 화면: 지역 draft | 편집 입력에 맞춰 지역 화면만 바뀝니다. |
| 취소 또는 지역 저장 UI cancel은 prop 값으로 되돌리고 save는 alert·편집 닫기입니다. | cancel: prop으로 복원 · save: alert·닫기 · 서버 쓰기: 없음 | 편집 UI의 종료 결과를 서버 저장과 구분합니다. |
사용자 이름 예제의 prop과 편집값
서버 prop으로 currentName을 초기화하고 onChange는 draft만 바꾸며 cancel은 prop으로 되돌립니다. save도 alert·편집 닫기일 뿐 서버 저장이 아니므로 지역 draft의 실제 변화를 단계로 보여 줄 수 있습니다.
NEXT · CH8-1
이름 편집은 currentName만 바꿉니다
StatePropsPage의 userName="김넥스트"를 UserProfile의 useState 초기값으로 사용합니다. prop과 입력 중인 상태를 각 동작에서 구분합니다.
| 시점 | 예제에서 바뀌는 값 |
|---|---|
| 첫 렌더 | userName과 currentName 모두 김넥스트로 시작합니다. |
| 이름 편집 | 입력한 값으로 currentName만 바뀝니다. userName prop은 그대로입니다. |
| 저장 | 알림을 표시하고 편집을 닫습니다. 바뀐 currentName은 현재 화면에 남습니다. |
| 취소 | currentName을 현재 userName prop 값으로 되돌립니다. |
| 다시 마운트 | 페이지 전체 새로고침으로 새 컴포넌트가 생기면 부모의 초기값으로 시작합니다. |
- 첫 렌더
- 예제에서 바뀌는 값: userName과 currentName 모두 김넥스트로 시작합니다.
- 이름 편집
- 예제에서 바뀌는 값: 입력한 값으로 currentName만 바뀝니다. userName prop은 그대로입니다.
- 저장
- 예제에서 바뀌는 값: 알림을 표시하고 편집을 닫습니다. 바뀐 currentName은 현재 화면에 남습니다.
- 취소
- 예제에서 바뀌는 값: currentName을 현재 userName prop 값으로 되돌립니다.
- 다시 마운트
- 예제에서 바뀌는 값: 페이지 전체 새로고침으로 새 컴포넌트가 생기면 부모의 초기값으로 시작합니다.
저장은 alert와 편집 닫기이며 서버에 저장하지 않습니다. 취소는 현재 userName prop으로 되돌립니다. 이미 마운트된 상태에서 새 prop을 받는 것만으로 useState가 초기화되지는 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 화살표는 값 전달이나 상태 갱신의 관계입니다. 서버 page가 userName prop을 전달하고, 처음 마운트의 초기화 또는 취소 클릭에서 currentName에 그 값을 사용합니다. 새 prop을 받기만 하면 이미 마운트된 상태가 자동 초기화되지는 않습니다. 편집 UI의 onChange는 로컬 currentName만 바꾸고 그 값을 화면이 읽습니다. 저장 버튼은 원 코드대로 alert와 편집 닫기이며 서버에 값을 쓰는 연결은 없습니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| prop으로 첫 지역 상태 서버 page의 userName으로 첫 마운트 시 currentName을 초기화합니다. | 원본: userName prop · 지역: currentName · 새 prop 자동 reset: 없음 | 편집할 지역 값이 처음 설정됩니다. |
| draft 변경 onChange는 지역 currentName만 바꾸고 화면이 그 값을 읽습니다. | 변경: currentName · 서버 값: 미변경 · 화면: 지역 draft | 편집 입력에 맞춰 지역 화면만 바뀝니다. |
| 취소 또는 지역 저장 UI cancel은 prop 값으로 되돌리고 save는 alert·편집 닫기입니다. | cancel: prop으로 복원 · save: alert·닫기 · 서버 쓰기: 없음 | 편집 UI의 종료 결과를 서버 저장과 구분합니다. |
위 그림은 사용자 이름 예제에서 prop과 편집 중인 값이 각 동작에서 어떻게 나뉘는지 보여 줍니다. 서버 페이지가 userName="김넥스트"를 읽기 전용 prop으로 넘기면, UserProfile은 처음 마운트될 때 그 값으로 로컬 상태 currentName을 초기화합니다. 이름을 편집하면 입력의 onChange가 로컬 currentName만 바꾸고 화면이 그 값을 읽으며, userName prop은 그대로입니다. 저장은 원래 코드대로 알림을 띄우고 편집을 닫을 뿐 서버에 값을 쓰지 않으므로, 바뀐 currentName이 현재 화면에 남습니다. 취소는 currentName을 현재의 userName prop 값으로 되돌립니다. 그림의 단계도 prop으로 첫 지역 상태를 만들고, 초안을 바꾸고, 취소하거나 지역 저장 UI를 쓰는 순서입니다.
그림의 아래 설명처럼 이미 마운트된 상태에서 새 prop을 받는 것만으로는 useState가 초기화되지 않으며, 페이지 전체를 새로고침해 새 컴포넌트가 생겨야 부모의 초기값으로 다시 시작합니다.
입력의 주인과 초기화 시점 정하기
value={currentName}과 onChange를 함께 쓰는 입력은 React 상태가 값을 정하는 제어 입력(controlled input)입니다. onChange에서 문자열 상태를 갱신하지 않으면 화면은 이전 값으로 되돌아갑니다. userName은 부모의 값이고 currentName은 편집 중인 초안이므로, 둘을 무조건 동기화하면 새 prop이 도착했을 때 작성 중인 초안을 덮어쓸 수 있습니다.
다른 사용자를 고를 때 편집을 새로 시작하려면, 그 사용자를 식별하는 key로 컴포넌트를 새로 마운트하는 방법이 있습니다. 같은 사용자의 최신 데이터가 온 경우에는 초안을 유지할지, 취소할 때만 최신 prop을 읽을지 따로 정합니다. 렌더링할 때마다 임의의 key를 바꾸면 입력 상태가 계속 초기화되므로, 사용자를 나타내는 안정적인 값을 써야 합니다. 어떤 의도로 상태를 남기거나 초기화할지는 상태 보존과 초기화를 기준으로 정합니다.
객체나 배열을 상태로 쓸 때도 원본을 직접 고치지 않습니다. 예를 들어 profile.name = '새 이름'; setProfile(profile)은 같은 객체 참조를 다시 넘기므로 React가 갱신을 건너뛸 수 있습니다. setProfile(previous => ({ ...previous, name: '새 이름' }))처럼 다음 객체를 새로 만들어 넘깁니다.
상태 관리의 한계점과 다음 단계
useState와 props를 이용한 상태 관리는 간단한 컴포넌트 트리에서는 효과적입니다. 하지만 애플리케이션이 커지고 컴포넌트 트리가 깊어지며 여러 컴포넌트가 같은 상태를 함께 써야 하는 상황이 생기면 몇 가지 문제를 만납니다. 상태를 여러 단계의 자식 컴포넌트로 계속 props로 넘겨야 하는 프롭 드릴링(prop drilling)이 번거로워지고, 서로 다른 위치의 컴포넌트가 같은 상태를 효율적으로 함께 쓰기 어려우며, 불필요한 다시 렌더링이 생길 수 있습니다.
이런 문제를 해결하려고 React는 Context API 같은 내장 도구를 제공하며, 더 복잡한 상황에서는 Redux Toolkit, Zustand, Jotai 같은 클라이언트 상태 도구와 TanStack Query 같은 서버 상태 도구를 목적에 맞게 고릅니다.
Next.js App Router에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할 분담을 생각해 어떤 상태를 어디서 관리할지 신중하게 정해야 합니다. 서버 컴포넌트는 서버에서의 데이터 페칭과 렌더링을 맡고, 클라이언트 컴포넌트는 이벤트, 브라우저 API, 계속 유지되는 UI 상태를 맡습니다. 서버에서 렌더링하는 내용도 요청과 데이터에 따라 달라질 수 있습니다.
함께 쓸 상태는 그 값의 원본이 어디인지부터 나눕니다. 탐색할 수 있는 필터는 URL에, 입력 중인 값은 클라이언트 상태에, 저장된 사용자 정보는 서버 데이터에 속할 수 있습니다. 다음 글에서 다룰 서버 액션은 상태 저장소가 아니라 데이터를 바꾸는 실행 경로입니다.
정리
- 상태는 렌더링 결과에 영향을 주며 시간이 지나면서 바뀌는 값이고, 클라이언트 컴포넌트에서
useState로 관리합니다. - 업데이트 함수를 불러도 지금 실행 중인 렌더링의 값은 바뀌지 않으며, 새 값은 다음 렌더링에서 읽습니다.
- 서로 관계가 있는 두 값은 하나의 상태로 묶고, 이전 값만으로 다음 값을 계산하는 업데이트 함수를 쓰면 같은 이벤트 안에서도 관계가 유지됩니다.
- props는 부모에서 자식으로 흐르는 읽기 전용 값이며,
useState의 초기값으로 받은 prop은 이후에 바뀌어도 상태를 다시 초기화하지 않습니다. - 편집을 새로 시작해야 할 때는 대상을 식별하는 안정적인
key로 컴포넌트를 다시 마운트하며, 객체와 배열 상태는 새 객체를 만들어 넘깁니다.
연습 문제
1. 저장 뒤 취소가 어디로 돌아가는지 예상하기
새로 마운트된 예제에서 "김넥스트"를 "이다온"으로 편집해 저장한 뒤, 다시 "이름 수정"을 열어 "박초록"을 입력하고 취소합니다. 부모의 userName은 바꾸지 않았고 서버 저장도 추가하지 않았습니다. 마지막 화면은 어떤 이름이고, "이다온"을 저장했다는 알림은 무엇을 보장할까요? 같은 컴포넌트를 유지한 채 부모가 새 userName을 주는 경우와 페이지 전체를 새로고침하는 경우도 구분해 보세요.
해설 보기
저장 직후 currentName은 "이다온"이고 userName은 "김넥스트"입니다. 다음 입력은 currentName을 "박초록"으로 바꾸지만, 취소는 setCurrentName(userName)을 실행하므로 화면은 "김넥스트"로 돌아갑니다. 이 코드에는 마지막으로 저장한 값을 따로 보관하거나 서버에 저장하는 단계가 없으며, 알림은 handleSave가 현재 값을 읽고 편집을 닫았다는 화면 동작을 나타낼 뿐입니다.
이미 마운트된 UserProfile에 새 prop을 넘겨도 useState의 초기화가 다시 일어나지는 않지만, 그 뒤에 취소를 누르면 그때의 userName을 읽습니다. 페이지 전체를 새로고침해 새로 마운트하면 페이지의 "김넥스트"에서 다시 시작합니다. 실제 저장 기능을 만들 때는 서버의 확정값, 편집 중인 초안, 취소할 때 돌아갈 값을 따로 정해야 합니다.
2. 같은 이벤트 안에서 두 번 적용하기
처음 상태가 isLiked=false, likes=0입니다. 원래의 handleClick()을 같은 이벤트 안에서 두 번 실행한 결과와, toggleReaction을 하나의 상태 업데이트 함수로 두 번 적용한 결과를 각각 계산해 보세요. 중간에 렌더링은 없다고 가정합니다.
해설 보기
원래 코드는 두 호출 모두 같은 렌더링의 false를 읽으므로 isLiked=false, likes=2가 됩니다. 하나의 상태에 쓴 순수 업데이트 함수는 단계마다 이전 객체를 받아, false·0에서 true·1, 다시 false·0으로 계산합니다. 사용자가 따로 두 번 클릭해 그 사이에 렌더링이 있는 경우와 혼동하지 않도록 합니다.
3. 같은 컴포넌트에 새 prop이 올 때
이미 마운트된 UserProfile의 초안이 "이다온"입니다. 같은 컴포넌트와 key를 유지한 채 부모의 prop이 "김넥스트"에서 "윤서울"로 바뀌었습니다. 화면에는 무엇이 보이고, 그 뒤에 취소 버튼을 누르면 어떻게 될까요? 서버 저장이나 초안을 자동으로 동기화하는 코드는 추가하지 않았습니다.
해설 보기
새 prop만으로는 useState의 초기화가 다시 일어나지 않으므로, 화면의 초안은 "이다온"을 유지합니다. 그 뒤 취소를 누르면 그 렌더링의 userName인 "윤서울"을 읽어 초안을 그 값으로 바꿉니다. 이 동작은 부모의 prop이나 서버 데이터를 직접 고치는 것이 아닙니다.
4. key가 바뀌어 다시 마운트될 때
다른 사용자 ID로 안정적인 key가 바뀌어 UserProfile이 새로 마운트됩니다. 새 prop이 "한가람"이라면 초안은 무엇일까요? key를 바꾸는 것만으로 서버에 이름이 저장되는지도 설명해 보세요.
해설 보기
새 컴포넌트가 초기값을 읽으므로 초안은 "한가람"입니다. key를 바꾸는 것은 클라이언트 상태가 언제 새로 시작하는지를 정할 뿐이며, 서버 저장 요청을 만들지 않습니다.
다음 글 서버 액션에서는 클라이언트에서 서버의 데이터를 바꾸는 실행 경로인 서버 액션을 알아봅니다.