이 컴포넌트는 제어되어야 하는가
“이 컴포넌트의 상태는 누가 소유하는가
그 경계는 무엇을 기준으로 정해지는가”
들어가며 — 나눈 컴포넌트를 어떻게 연결할 것인가
컴포넌트를 나눴다.
각 조각은 자신의 책임 안에서 완결되어 있다.
그런데 새로운 질문이 생긴다.
// 이전 문서에서 분리한 구조
const ProductPage = ({ productId }) => {
return (
<div>
<ProductDetail productId={productId} />
<PurchaseOptions variants={product.variants} onAddToCart={handleAddToCart} />
<ProductReviews productId={productId} />
</div>
);
};
PurchaseOptions는 사용자가 옵션을 선택하고 수량을 정하는 컴포넌트다.
이 컴포넌트가 선택된 옵션을 자기 안에서 관리하면 되는가?
아니면 ProductPage가 알아야 하는가?
만약 ProductDetail에도 선택된 옵션을 표시해야 한다면?
PurchaseOptions 안에 있는 상태를 밖에서 어떻게 알 수 있는가?
// PurchaseOptions가 자체적으로 상태를 관리한다면
const PurchaseOptions = ({ variants, onAddToCart }) => {
const [selectedVariant, setSelectedVariant] = useState(null);
const [quantity, setQuantity] = useState(1);
// ...
};
// ProductDetail은 선택된 옵션을 알 수 없다
const ProductDetail = ({ product }) => {
// selectedVariant가 여기 없다
// 어떤 옵션이 선택되었는지 표시할 수 없다
};
이 상황에서 자연스럽게
이런 질문이 떠오른다.
“이 컴포넌트는 제어되어야 하는가?”
이 문서는
컴포넌트를 제어할지 말지 결정할 때
어떤 기준으로 판단하는지를 다룬다.
제어와 비제어
본격적인 선택지를 보기 전에
“제어”가 무엇을 의미하는지 짚어본다.
// 비제어: 컴포넌트가 자신의 상태를 소유한다
const Dropdown = ({ items, onSelect }) => {
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(null);
const handleSelect = (item) => {
setSelected(item);
setIsOpen(false);
onSelect?.(item); // 부모에게 알려주기만 한다
};
return (/* ... */);
};
// 사용하는 쪽
<Dropdown items={options} onSelect={handleOptionChange} />
// 제어: 부모가 상태를 소유하고, 컴포넌트는 전달받는다
const Dropdown = ({ items, isOpen, selected, onOpenChange, onSelect }) => {
return (/* ... */);
};
// 사용하는 쪽
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(null);
<Dropdown
isOpen={isOpen}
selected={selected}
onOpenChange={setIsOpen}
onSelect={setSelected}
items={options}
/>
비제어 컴포넌트는 자기 안에서 동작이 완결된다.
제어 컴포넌트는 부모가 상태를 소유하고, 컴포넌트는 그것을 반영한다.
이 차이는 단순히 “props가 많은가 적은가”가 아니다.
상태의 소유권이 어디에 있는가의 문제다.
선택지들
비제어 — 컴포넌트가 자신의 상태를 관리한다
const DatePicker = ({ onSelect }) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState(null);
const [viewMonth, setViewMonth] = useState(new Date());
const handleDateClick = (date) => {
setSelectedDate(date);
setIsOpen(false);
onSelect?.(date);
};
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{selectedDate ? format(selectedDate) : '날짜 선택'}
</button>
{isOpen && (
<Calendar
month={viewMonth}
onMonthChange={setViewMonth}
onDateClick={handleDateClick}
selected={selectedDate}
/>
)}
</div>
);
};
// 사용하는 쪽
const OrderForm = () => {
const handleDateSelect = (date) => {
console.log('선택된 날짜:', date);
};
return <DatePicker onSelect={handleDateSelect} />;
};
사용하는 쪽의 코드가 단순하다.
DatePicker를 놓고 결과만 받으면 된다.
열림/닫힘, 월 이동, 선택 상태 —
이런 내부 동작은 DatePicker가 알아서 처리한다.
언제?
- 컴포넌트의 동작이 자체적으로 완결될 때
- 부모가 중간 상태(열림/닫힘, 월 이동 등)를 알 필요가 없을 때
- 사용하는 쪽을 단순하게 유지하고 싶을 때
왜?
- 사용하는 쪽의 코드가 간결하다
- 컴포넌트 내부 동작이 캡슐화된다
- 상태 관리 로직이 한곳에 모여 있어 이해하기 쉽다
비용
- 부모가 컴포넌트의 상태를 알거나 바꿀 수 없다
- 외부에서 “3월로 이동해라”, “이 날짜를 선택해라” 같은 제어가 어렵다
- 여러 컴포넌트 간에 상태를 동기화하기 어렵다
제어 — 부모가 상태를 소유한다
const DatePicker = ({
isOpen,
selectedDate,
viewMonth,
onOpenChange,
onDateSelect,
onMonthChange,
}) => {
return (
<div>
<button onClick={() => onOpenChange(!isOpen)}>
{selectedDate ? format(selectedDate) : '날짜 선택'}
</button>
{isOpen && (
<Calendar
month={viewMonth}
onMonthChange={onMonthChange}
onDateClick={onDateSelect}
selected={selectedDate}
/>
)}
</div>
);
};
// 사용하는 쪽
const BookingForm = () => {
const [isOpen, setIsOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState(null);
const [viewMonth, setViewMonth] = useState(new Date());
// 부모가 동작을 제어할 수 있다
const handleDateSelect = (date) => {
if (isWeekend(date)) return; // 주말은 선택 불가
setSelectedDate(date);
setIsOpen(false);
};
return (
<div>
<DatePicker
isOpen={isOpen}
selectedDate={selectedDate}
viewMonth={viewMonth}
onOpenChange={setIsOpen}
onDateSelect={handleDateSelect}
onMonthChange={setViewMonth}
/>
{selectedDate && <p>선택: {format(selectedDate)}</p>}
</div>
);
};
사용하는 쪽이 모든 동작을 결정한다.
주말 선택을 막거나, 특정 날짜로 이동하거나,
다른 컴포넌트와 선택 상태를 공유할 수 있다.
언제?
- 부모가 컴포넌트의 동작을 제어해야 할 때
- 여러 컴포넌트가 같은 상태를 공유할 때
- 선택에 대한 유효성 검사나 조건이 있을 때
왜?
- 동작을 사용하는 쪽에서 완전히 제어할 수 있다
- 상태가 부모에 있으므로 다른 컴포넌트와 공유가 쉽다
- 예측 가능하다 — 전달한 값이 그대로 반영된다
비용
- 사용하는 쪽의 코드가 길어진다
- 상태와 핸들러를 모두 직접 관리해야 한다
- 단순한 사용에도 많은 설정이 필요하다
혼합 — 일부만 제어한다
const DatePicker = ({ selectedDate, onDateSelect, onOpenChange }) => {
// 열림/닫힘은 내부에서 관리
const [isOpen, setIsOpen] = useState(false);
// 월 이동도 내부에서 관리
const [viewMonth, setViewMonth] = useState(new Date());
// 선택된 날짜는 부모가 소유
const handleDateClick = (date) => {
onDateSelect(date);
setIsOpen(false);
onOpenChange?.(false);
};
const handleToggle = () => {
setIsOpen(!isOpen);
onOpenChange?.(!isOpen);
};
return (
<div>
<button onClick={handleToggle}>
{selectedDate ? format(selectedDate) : '날짜 선택'}
</button>
{isOpen && (
<Calendar
month={viewMonth}
onMonthChange={setViewMonth}
onDateClick={handleDateClick}
selected={selectedDate}
/>
)}
</div>
);
};
// 사용하는 쪽
const BookingForm = () => {
const [selectedDate, setSelectedDate] = useState(null);
return (
<div>
<DatePicker
selectedDate={selectedDate}
onDateSelect={setSelectedDate}
/>
{selectedDate && <p>선택: {format(selectedDate)}</p>}
</div>
);
};
핵심 상태(선택된 날짜)는 부모가 소유하고,
부수적인 상태(열림/닫힘, 월 이동)는 컴포넌트가 관리한다.
언제?
- 핵심 값은 부모가 알아야 하지만, 모든 상태를 제어할 필요는 없을 때
- UI 동작(열림/닫힘, 스크롤 위치 등)은 컴포넌트 내부 관심사일 때
- 단순한 사용과 유연한 제어를 동시에 지원하고 싶을 때
왜?
- 핵심 상태는 부모가 제어하면서도 사용이 간결하다
- UI 동작은 컴포넌트가 알아서 처리한다
- 대부분의 사용처에서 적은 props로 충분하다
비용
- 어떤 상태가 제어되고 어떤 상태가 내부인지 명확해야 한다
- API 설계가 복잡해질 수 있다
- 제어 범위를 나중에 바꾸면 인터페이스가 변경된다
판단의 질문들
컴포넌트를 제어할지 결정할 때
던져볼 수 있는 질문들이 있다.
이 상태를 부모가 알아야 하는가?
// 부모가 알아야 하는 상태
const ProductPage = () => {
// selectedVariant를 ProductDetail에도, PurchaseSection에도 전달해야 한다
// → 부모가 알아야 한다 → 제어
const [selectedVariant, setSelectedVariant] = useState(null);
return (
<>
<ProductDetail
product={product}
highlightedVariant={selectedVariant}
/>
<VariantSelector
variants={product.variants}
selected={selectedVariant}
onSelect={setSelectedVariant}
/>
</>
);
};
// 부모가 알 필요 없는 상태
const SearchResults = () => {
return (
<div>
{/* Tooltip의 열림/닫힘을 부모가 알 필요 없다 */}
<ResultItem result={result}>
<Tooltip content="상세 정보">
<InfoIcon />
</Tooltip>
</ResultItem>
</div>
);
};
Tooltip이 열려 있는지 닫혀 있는지는
SearchResults가 알 필요가 없다.
Tooltip은 비제어로 충분하다.
하지만 selectedVariant는
여러 컴포넌트가 함께 사용한다.
부모가 소유하는 것이 자연스럽다.
이 상태가 형제 컴포넌트에 영향을 주는가?
// 형제에게 영향을 주는 상태
const FilterableList = () => {
// filter가 바뀌면 ResultList도 바뀌어야 한다
// → 형제 간 공유 → 부모가 소유 → FilterPanel은 제어
const [filter, setFilter] = useState({ category: 'all', sort: 'recent' });
return (
<div>
<FilterPanel filter={filter} onChange={setFilter} />
<ResultList filter={filter} />
</div>
);
};
// 형제에게 영향을 주지 않는 상태
const Dashboard = () => {
return (
<div>
{/* 각 차트의 확대/축소 상태는 서로 영향을 주지 않는다 */}
<SalesChart data={salesData} />
<TrafficChart data={trafficData} />
<ConversionChart data={conversionData} />
</div>
);
};
SalesChart가 확대되어도
TrafficChart에는 영향이 없다.
각 차트는 자신의 확대 상태를 스스로 관리하면 된다.
하지만 FilterPanel의 필터가 바뀌면
ResultList도 바뀌어야 한다.
이 상태는 부모에 있는 것이 자연스럽고,
FilterPanel은 제어되는 것이 자연스럽다.
사용하는 쪽에서 동작을 바꿔야 하는가?
// 동작을 바꿔야 하는 경우
const BookingForm = () => {
const [date, setDate] = useState(null);
const handleDateSelect = (newDate) => {
// 과거 날짜는 선택 불가
if (isBefore(newDate, new Date())) return;
// 이미 예약된 날짜는 선택 불가
if (bookedDates.includes(newDate)) return;
setDate(newDate);
};
// DatePicker가 제어되어야 이런 로직이 가능하다
return <DatePicker selected={date} onSelect={handleDateSelect} />;
};
// 동작을 바꿀 필요 없는 경우
const ProfilePage = () => {
return (
<div>
{/* 아코디언의 열림/닫힘에 특별한 조건이 없다 */}
<Accordion title="기본 정보">
<BasicInfo user={user} />
</Accordion>
<Accordion title="활동 내역">
<ActivityLog userId={user.id} />
</Accordion>
</div>
);
};
아코디언을 열고 닫는 데
특별한 조건이 없다면
비제어로 충분하다.
하지만 DatePicker에서 특정 날짜를 거부해야 한다면
선택 동작을 부모가 제어하는 것이 자연스럽다.
이 컴포넌트의 사용처마다 동작이 다른가?
// 사용처마다 동작이 다른 경우
// A 페이지: 단일 선택
<TagSelector selected={selectedTag} onSelect={setSelectedTag} />
// B 페이지: 다중 선택
<TagSelector selected={selectedTags} onSelect={setSelectedTags} />
// C 페이지: 선택 + 새 태그 생성
<TagSelector
selected={selectedTags}
onSelect={setSelectedTags}
onCreateTag={handleCreate}
/>
사용처마다 동작이 달라야 한다면
컴포넌트는 동작을 결정하지 않고
사용하는 쪽에 맡기는 것이 자연스럽다.
// 사용처에 관계없이 동작이 같은 경우
// 어디에서 쓰든 같은 방식으로 동작한다
<PasswordStrengthMeter password={password} />
<LoadingSpinner />
<Avatar src={user.avatarUrl} name={user.name} />
모든 곳에서 같은 방식으로 동작하는 컴포넌트는
내부에서 동작을 결정해도 문제가 없다.
결정의 흐름
몇 가지 상황에서
어떤 질문을 던지게 되는지 살펴본다.
모달 컴포넌트
// 상황: 삭제 확인 모달
const UserManagement = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [targetUser, setTargetUser] = useState(null);
const handleDeleteClick = (user) => {
setTargetUser(user);
setShowDeleteModal(true);
};
const handleConfirm = async () => {
await deleteUser(targetUser.id);
setShowDeleteModal(false);
setTargetUser(null);
};
return (
<div>
<UserList users={users} onDeleteClick={handleDeleteClick} />
<DeleteConfirmModal
isOpen={showDeleteModal}
userName={targetUser?.name}
onConfirm={handleConfirm}
onClose={() => setShowDeleteModal(false)}
/>
</div>
);
};
판단 과정
-
부모가 이 상태를 알아야 하는가?
→ 부모가 “삭제 버튼을 눌렀을 때” 모달을 열어야 한다.
→ 삭제 완료 후 모달을 닫아야 한다.
부모가 열림/닫힘을 알아야 한다. -
동작을 바꿔야 하는가?
→ API 호출이 성공해야 닫히는 등,
닫히는 조건을 부모가 결정한다.
동작을 제어해야 한다.
결론: 제어 컴포넌트
모달은 대부분 제어된다.
열리는 시점과 닫히는 시점을
부모가 결정해야 하는 경우가 많기 때문이다.
아코디언 컴포넌트
// 상황: FAQ 페이지의 아코디언
const FAQPage = () => {
return (
<div>
<Accordion title="배송은 얼마나 걸리나요?">
<p>보통 2-3일 소요됩니다.</p>
</Accordion>
<Accordion title="반품은 어떻게 하나요?">
<p>마이페이지에서 신청 가능합니다.</p>
</Accordion>
<Accordion title="결제 수단은 무엇이 있나요?">
<p>신용카드, 계좌이체, 간편결제를 지원합니다.</p>
</Accordion>
</div>
);
};
판단 과정
-
부모가 열림/닫힘 상태를 알아야 하는가?
→ FAQ 페이지는 어떤 항목이 열려 있는지 알 필요 없다.
알 필요 없다. -
형제에게 영향을 주는가?
→ 하나를 열어도 다른 것에 영향이 없다.
영향 없다.
결론: 비제어 컴포넌트
const Accordion = ({ title, children }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
};
만약 “하나만 열리게” 해야 한다면?
// 하나만 열리게 하려면 부모가 상태를 소유해야 한다
const FAQPage = () => {
const [openIndex, setOpenIndex] = useState(null);
return (
<div>
{faqs.map((faq, index) => (
<Accordion
key={index}
title={faq.question}
isOpen={openIndex === index}
onToggle={() => setOpenIndex(openIndex === index ? null : index)}
>
<p>{faq.answer}</p>
</Accordion>
))}
</div>
);
};
같은 컴포넌트가
요구사항에 따라 제어될 수도, 비제어일 수도 있다.
“하나만 열리게”라는 요구사항이 생기면
형제 간 상태 조율이 필요해지고,
자연스럽게 제어 컴포넌트로 전환된다.
검색 입력 컴포넌트
// 상황: 검색 기능이 있는 페이지
const SearchPage = () => {
// 검색어를 URL과 동기화해야 한다
// 검색어가 바뀌면 결과 목록도 바뀌어야 한다
};
판단 과정
-
부모가 이 상태를 알아야 하는가?
→ 검색어가 바뀌면 결과를 다시 가져와야 한다.
→ URL에도 반영해야 한다.
부모가 알아야 한다. -
형제에 영향을 주는가?
→ 검색어 → 결과 목록, URL 모두 영향.
영향을 준다.
결론: 제어 컴포넌트
const SearchPage = () => {
const [query, setQuery] = useSearchParams('q');
const results = useSearchResults(query);
return (
<div>
<SearchInput value={query} onChange={setQuery} />
<ResultList results={results} />
</div>
);
};
검색어는 URL, 결과 목록, 검색 입력 세 곳에서 사용된다.
이 상태를 SearchInput이 소유하면
나머지 두 곳이 접근할 수 없다.
탭 컴포넌트
// 상황 A: 단순한 탭
const SettingsPage = () => {
return (
<Tabs>
<Tab label="일반"><GeneralSettings /></Tab>
<Tab label="알림"><NotificationSettings /></Tab>
</Tabs>
);
};
판단: 비제어로 충분
어떤 탭이 선택되어 있는지
SettingsPage가 알 필요가 없다.
// 상황 B: URL과 동기화되는 탭
const DashboardPage = () => {
const [activeTab, setActiveTab] = useSearchParams('tab');
return (
<Tabs activeTab={activeTab} onTabChange={setActiveTab}>
<Tab id="overview" label="개요"><Overview /></Tab>
<Tab id="analytics" label="분석"><Analytics /></Tab>
</Tabs>
);
};
판단: 제어 필요
탭이 URL과 동기화되어야 하므로
부모가 상태를 소유하는 것이 자연스럽다.
같은 Tabs 컴포넌트가
사용처에 따라 제어될 수도, 비제어일 수도 있다.
제어의 비용
제어는 유연하지만 무겁다
// 비제어: 세 줄로 충분
<Dropdown items={options} onSelect={handleSelect} />
// 제어: 상태, 핸들러, props가 모두 필요
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(null);
<Dropdown
isOpen={isOpen}
selected={selected}
onOpenChange={setIsOpen}
onSelect={setSelected}
items={options}
/>
제어가 항상 좋은 것은 아니다.
유연함에는 비용이 따른다.
필요하지 않은 제어는
사용하는 쪽의 코드를 복잡하게 만들 뿐이다.
나중에 제어가 필요해지면?
비제어로 시작했는데
나중에 부모가 상태를 알아야 하는 상황이 생길 수 있다.
이때 선택지가 두 가지 있다.
1. 제어 컴포넌트로 전환한다
기존 비제어 사용처를 모두 변경한다.
단순하지만, 사용처가 많으면 비용이 크다.
2. 비제어 기본값 + 선택적 제어를 지원한다
const Accordion = ({
isOpen: controlledIsOpen,
onToggle,
defaultOpen = false,
title,
children,
}) => {
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
const isControlled = controlledIsOpen !== undefined;
const isOpen = isControlled ? controlledIsOpen : internalIsOpen;
const handleToggle = () => {
if (isControlled) {
onToggle?.();
} else {
setInternalIsOpen(prev => !prev);
}
};
return (
<div>
<button onClick={handleToggle}>{title}</button>
{isOpen && <div>{children}</div>}
</div>
);
};
기존 비제어 사용처를 깨뜨리지 않으면서
제어가 필요한 곳에서는 제어할 수 있다.
하지만 이 패턴에도 비용이 있다.
컴포넌트 내부가 복잡해지고,
“이 컴포넌트가 지금 제어되고 있는가?”를
런타임에 판단해야 한다.
어느 쪽이 적합한지는
사용처의 수와 변경 비용에 따라 다르다.
정리하며 — 제어는 소유권의 문제다
컴포넌트를 제어할지 결정하는 것은
“상태의 소유권을 누가 가질 것인가”를 정하는 것이다.
선택지별 특성
| 선택지 | 언제 | 비용 |
|---|---|---|
| 비제어 | 동작이 자체 완결될 때 | 외부에서 제어 불가 |
| 제어 | 부모가 상태를 알거나 제어해야 할 때 | 사용하는 쪽이 복잡해짐 |
| 혼합 | 핵심 값만 제어, 나머지는 내부 | API 경계 설계가 필요함 |
판단의 출발점이 될 수 있는 규칙들
절대적인 규칙은 아니지만,
반복적으로 유효한 패턴들이 있다.
1. 부모가 알 필요 없는 상태는 컴포넌트가 소유한다
Tooltip의 열림/닫힘, 내부 애니메이션 상태,
스크롤 위치 같은 것은
대부분 컴포넌트 내부 관심사다.
2. 형제 간에 공유되는 상태는 부모가 소유한다
한 컴포넌트의 상태가
형제 컴포넌트에 영향을 줘야 한다면
그 상태는 부모에 있는 것이 자연스럽다.
자연스럽게 제어 컴포넌트가 된다.
3. 동작에 조건이 필요하면 제어를 고려한다
“이 날짜는 선택 불가”, “이미 열린 탭은 닫을 수 없음” 같은
조건이 있다면
사용하는 쪽이 동작을 제어하는 것이 자연스럽다.
4. 확신이 없으면 비제어로 시작할 수 있다
비제어에서 제어로 전환하는 것은
설계 변경이지만 불가능하지는 않다.
처음부터 모든 것을 제어하면
사용하는 쪽이 불필요하게 복잡해진다.
컴포넌트를 제어한다는 것은
유연함을 얻는 것이지만
동시에 책임을 가져오는 것이다.
그 책임이 사용하는 쪽에 있어야 할 때
제어는 의미가 있다.