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변수에 반영합니다.
실습을 준비하는 위치
앞 장에서 준비한 App Router 프로젝트를 사용합니다. page.tsx와 LikeButton.tsx는 src/app/state-basic 안에, 두 번째 실습의 page.tsx와 UserProfile.tsx는 src/app/state-props 안에 따로 만듭니다. 서버 page가 이벤트를 직접 처리하는 것이 아니라, 그 page가 렌더링한 클라이언트 컴포넌트의 버튼·입력이 상태를 바꿉니다. 이미 개발 서버를 열었다면 같은 3000번 포트의 개발 서버에서 두 경로를 확인합니다.
첫 사용법의 MyComponent는 상태 선언만 보여 주는 발췌이고 화면을 반환하는 완성 페이지가 아닙니다. 다음 파일별 코드를 따라갈 때와 구분하세요.
useState 같은 훅은 컴포넌트 함수의 최상위에서 같은 순서로 호출합니다. 버튼의 이벤트 함수 안이나 조건문 안에서 새 상태를 선언하는 용도가 아닙니다. "use client"는 클라이언트 기능을 사용하는 모듈 경계이며, 최초 화면의 HTML이 서버에서 미리 렌더링되지 않는다는 뜻은 아닙니다. 클릭 뒤 로컬 상태가 바뀐다고 서버 page의 함수가 매번 다시 실행되는 것도 아닙니다.
간단한 좋아요 버튼을 만들어 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 상태가 성공적으로 업데이트되고 컴포넌트가 리렌더링되었기 때문입니다.
예시
처음 화면은 isLiked=false, likes=0입니다. 첫 클릭의 handleClick은 이 렌더의 false를 읽습니다. setIsLiked의 updater는 true를 예약하고, setLikes의 updater는 같은 클릭에서 읽은 isLiked=false에 따라 이전 개수 0에 1을 더합니다. 다음 렌더는 true·1을 표시합니다. 그 렌더 뒤에 두 번째 클릭을 하면 true를 읽으므로 false·0이 됩니다.
setIsLiked를 호출했다고 현재 실행 중인 handleClick의 isLiked가 곧바로 true로 바뀌는 것은 아닙니다. React의 상태 업데이트 규칙은 현재 렌더의 값과 다음 렌더에서 사용할 값을 구분합니다. 이 예시는 렌더를 사이에 둔 사용자 클릭 한 번씩의 흐름입니다. 여러 동작을 한 이벤트 안에서 합치려면 두 상태의 관계도 함께 설계해야 합니다.
같은 이벤트 안에서 두 번 토글하면 무엇이 달라질까요?
초기 렌더의 handleClick을 하나의 이벤트 함수 안에서 두 번 호출하면, 두 호출 모두 같은 isLiked=false를 읽습니다. setIsLiked의 두 updater는 false → true → false를 만들지만, setLikes의 두 updater는 각각 1을 더해 0 → 1 → 2를 만듭니다. 따라서 false·2가 될 수 있습니다. 이는 렌더 사이에 실제 사용자가 두 번 클릭한 앞 사례와 다른 조건입니다. updater를 썼더라도 다른 상태의 현재 렌더 값을 함께 읽으면 그 관계를 자동으로 보장하지 않습니다.
두 값을 한 상태로 묶는 확장에서는 이전 객체만으로 다음 객체를 계산할 수 있습니다. 아래는 그 계산만 분리한 순수 함수입니다.
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를 읽도록 함께 바꿉니다. 기존 두 상태 코드에 이 함수만 추가한다고 적용되지는 않습니다. 같은 초기값에서 두 updater가 순서대로 계산하면 false·0 → true·1 → false·0이 됩니다. updater 안에서는 알림이나 서버 저장을 실행하지 않고 다음 값만 계산합니다. React의 업데이트 큐 설명과 연결해 봅니다.
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의 초기값 규칙을 구분해 확인합니다.
입력의 주인과 초기화 시점을 정하기
value={currentName}과 onChange를 함께 쓰는 입력은 React 상태가 값을 결정하는 제어 입력입니다. onChange에서 문자열 상태를 갱신하지 않으면 화면은 이전 값으로 되돌아갑니다. userName은 부모의 값, currentName은 편집 중인 초안이므로 둘을 무조건 동기화하면 새 prop이 도착했을 때 작성 중인 초안을 덮어쓸 수도 있습니다.
다른 사용자를 선택할 때 편집을 새로 시작하려면 그 사용자를 식별하는 key로 컴포넌트를 새로 마운트하는 방법이 있습니다. 같은 사용자의 최신 데이터가 온 경우에는 초안을 유지할지, 취소할 때만 최신 prop을 읽을지 별도로 정합니다. 매 렌더마다 임의의 key를 바꾸면 입력 상태가 계속 초기화되므로 사용자를 나타내는 안정적인 값을 사용해야 합니다. 상태 보존과 초기화를 기준으로 의도를 정하세요.
객체·배열을 상태로 확장할 때도 원본을 직접 수정하지 않습니다. 예를 들어 profile.name = '새 이름'; setProfile(profile)은 같은 객체 참조를 다시 전달하므로 React가 갱신을 건너뛸 수 있습니다. setProfile(previous => ({ ...previous, name: '새 이름' }))처럼 다음 객체를 만들어 전달합니다.
사용자 이름 예제의 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의 종료 결과를 서버 저장과 구분합니다. |
저장 뒤 취소가 어디로 돌아가는지 예상하기
새로 마운트된 원 예제에서 김넥스트를 이다온으로 편집해 저장한 뒤, 다시 이름 수정을 열어 박초록을 입력하고 취소합니다. 부모의 userName은 바꾸지 않았고 서버 저장도 추가하지 않았습니다. 마지막 화면은 어떤 이름이고, 이다온을 저장했다는 알림은 무엇을 보장할까요? 같은 컴포넌트를 유지한 채 부모가 새 userName을 주는 경우와 페이지 전체 새로고침도 구분해 보세요.
prop과 현재 편집값을 나누는 해설
저장 직후 currentName은 이다온이고 userName은 김넥스트입니다. 다음 입력은 currentName을 박초록으로 바꾸지만, 취소는 setCurrentName(userName)을 실행하므로 화면은 김넥스트로 돌아갑니다. 이 코드에는 마지막 저장값을 따로 보관하거나 서버에 저장하는 단계가 없습니다. 알림은 handleSave가 현재 값을 읽고 편집을 닫았다는 화면 동작을 나타냅니다.
이미 마운트된 UserProfile에 새 prop을 전달해도 useState의 초기화가 반복되지는 않습니다. 그러나 이후 취소를 누르면 그때의 userName을 읽습니다. 페이지 전체 새로고침으로 새로 마운트하면 원 page의 김넥스트에서 다시 시작합니다. 실제 저장 기능을 만들 때는 서버의 확정값·편집 중 초안·취소 기준을 따로 정해야 합니다.
상태 관리의 한계점과 다음 단계
useState와 props를 이용한 상태 관리는 간단한 컴포넌트 트리에서는 효과적입니다.
하지만 애플리케이션의 규모가 커지고 컴포넌트 트리가 깊어지며, 여러 컴포넌트가 동일한 상태를 공유해야 하는 상황이 발생하면 다음과 같은 문제에 직면할 수 있습니다.
- Prop Drilling (프롭스 드릴링): 상태를 여러 단계의 자식 컴포넌트로 계속해서
props로 전달해야 하는 번거로움. - 상태 공유의 복잡성: 서로 다른 위치에 있는 컴포넌트들이 동일한 상태를 효율적으로 공유하기 어려움.
- 성능 문제: 불필요한 리렌더링 발생 가능성.
이러한 문제들을 해결하기 위해 React는 Context API와 같은 내장 도구를 제공하며, 더 복잡한 시나리오에서는 Redux Toolkit, Zustand, Jotai 같은 클라이언트 상태 도구와 TanStack Query 같은 서버 상태 도구를 목적에 맞게 선택합니다.
Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할 분담을 고려하여, 어떤 상태를 어디서 관리할지 신중하게 결정하는 것이 중요합니다.
서버 컴포넌트는 서버 데이터 요청과 렌더링을, 클라이언트 컴포넌트는 이벤트·브라우저 API·지속되는 UI 상태를 담당합니다. 서버에서 렌더링하는 내용도 요청과 데이터에 따라 달라질 수 있습니다.
공유할 상태는 원본부터 나눕니다. 탐색 가능한 필터는 URL에, 입력 중인 값은 클라이언트 상태에, 저장된 사용자 정보는 서버 데이터에 속할 수 있습니다. 서버 액션은 상태 저장소가 아니라 데이터를 변경하는 실행 경로입니다.
연습: 같은 값과 같은 컴포넌트를 구별하기
- 최초 상태가
isLiked=false, likes=0입니다. 원래handleClick()을 같은 이벤트 안에서 두 번 실행한 결과와, 위 순수 함수를 하나의 상태 updater로 두 번 적용한 결과를 각각 계산하세요. 중간 렌더는 없다고 합니다. - 이미 마운트된
UserProfile의 초안은'이다온'입니다. 동일한 컴포넌트와 key를 유지한 채 부모 prop이'김넥스트'에서'윤서울'로 바뀌었습니다. 화면과 그 뒤 취소 버튼의 결과는 무엇인가요? 서버 저장과 자동 초안 동기화 코드는 추가하지 않았습니다. - 다른 사용자 ID로 안정적인 key가 바뀌어
UserProfile이 새로 마운트됩니다. 새 prop이'한가람'이면 초안은 무엇인가요? 키를 바꾸는 것만으로 서버에 이름이 저장되는지도 설명하세요.
업데이트 큐·초안·다시 마운트 풀이
- 원래 코드는 두 호출 모두 같은 렌더의 false를 읽어
isLiked=false, likes=2가 됩니다. 한 상태의 순수 updater는 각 단계의 이전 객체를 받아false·0 → true·1 → false·0으로 계산합니다. 별도 사용자 클릭 사이에 렌더가 있는 사례와 혼동하지 않습니다. - 새 prop만으로 useState 초기화가 반복되지는 않아 화면의 초안은
'이다온'을 유지합니다. 이후 취소는 그 렌더의userName='윤서울'을 읽어 초안을 그 값으로 바꿉니다. 부모 prop이나 서버 데이터를 직접 수정하는 동작은 아닙니다. - 새 컴포넌트가 초기값을 읽으므로
'한가람'입니다. key 변경은 클라이언트 상태의 생명주기를 정할 뿐 서버 저장 요청을 만들지 않습니다.