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
훅을 사용하여 좋아요 상태를 토글해 봅시다.
-
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> ); }
-
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
으로 접속합니다. "좋아요" 버튼을 클릭할 때마다 하트 아이콘의 색깔과 텍스트, 그리고 좋아요 개수가 변하는 것을 확인할 수 있습니다. 이는 isLiked
와 likes
상태가 성공적으로 업데이트되고 컴포넌트가 리렌더링되었기 때문입니다.
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
클라이언트 컴포넌트로 전달합니다.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> ); }
-
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이 다시 전달되어 useState
의 currentName
상태를 초기화하기 때문입니다.
상태 관리의 한계점과 다음 단계
useState
와 props
를 이용한 상태 관리는 간단한 컴포넌트 트리에서는 효과적입니다. 하지만 애플리케이션의 규모가 커지고 컴포넌트 트리가 깊어지며, 여러 컴포넌트가 동일한 상태를 공유해야 하는 상황이 발생하면 다음과 같은 문제에 직면할 수 있습니다.
- Prop Drilling (프롭스 드릴링): 상태를 여러 단계의 자식 컴포넌트로 계속해서
props
로 전달해야 하는 번거로움. - 상태 공유의 복잡성: 서로 다른 위치에 있는 컴포넌트들이 동일한 상태를 효율적으로 공유하기 어려움.
- 성능 문제: 불필요한 리렌더링 발생 가능성.
이러한 문제들을 해결하기 위해 React는 Context API와 같은 내장 도구를 제공하며, 더 복잡한 시나리오를 위해서는 Redux, Zustand, Jotai, Recoil, TanStack Query (React Query) 와 같은 전역 상태 관리 라이브러리를 활용합니다.
Next.js App Router 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 역할 분담을 고려하여, 어떤 상태를 어디서 관리할지 신중하게 결정하는 것이 중요합니다. 서버 컴포넌트는 초기 데이터 페칭과 정적 콘텐츠 렌더링에 집중하고, 클라이언트 컴포넌트만이 사용자 상호작용 및 동적 상태 관리를 담당해야 합니다.