제어 컴포넌트 vs 비제어 컴포넌트 — 폼을 다루는 두 가지 철학
input에 글자를 입력할 때, 그 값을 "누가" 관리해야 할까요 — React인가, 브라우저 DOM인가?
HTML form 요소(input, textarea, select)는 자체적으로 상태를 관리합니다. React에서는 이 상태를 React가 제어할 것인지, DOM에게 맡길 것인지 선택할 수 있습니다. 이 선택이 제어 컴포넌트와 비제어 컴포넌트의 핵심 차이입니다.
제어 컴포넌트 (Controlled Component)
React state가 "진실의 원천(Single Source of Truth)"이 되는 방식입니다.
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
// email과 password state를 직접 사용
login(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email} // React state가 값을 결정
onChange={(e) => setEmail(e.target.value)} // 변경을 React에 알림
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">로그인</button>
</form>
);
}
동작 원리
- 사용자가 키를 입력합니다
onChange이벤트가 발생합니다- 핸들러가
setState를 호출합니다 - React가 새 state로 리렌더링합니다
- input의
value가 새 state 값으로 업데이트됩니다
모든 입력이 React를 거치기 때문에 입력 과정을 완전히 제어 할 수 있습니다.
제어 컴포넌트로 할 수 있는 것들
function PhoneInput() {
const [phone, setPhone] = useState('');
const handleChange = (e) => {
// 숫자만 허용
const onlyNumbers = e.target.value.replace(/[^0-9]/g, '');
// 자동 포맷팅: 010-1234-5678
const formatted = onlyNumbers.replace(
/(\d{3})(\d{4})(\d{4})/,
'$1-$2-$3'
);
setPhone(formatted);
};
return <input value={phone} onChange={handleChange} />;
}
- **입력 포맷팅 **: 전화번호, 카드 번호 등 자동 포맷
- ** 실시간 검증 **: 매 키 입력마다 유효성 검사
- ** 조건부 동작 **: 특정 값에 따라 다른 필드 표시/숨기기
- ** 입력 제한 **: 특정 문자 차단, 최대 길이 제한
비제어 컴포넌트 (Uncontrolled Component)
DOM 자체가 상태를 관리하고, 필요할 때 ref로 값을 읽는 방식입니다.
function LoginForm() {
const emailRef = useRef(null);
const passwordRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
// 제출 시점에 DOM에서 값을 읽는다
const email = emailRef.current.value;
const password = passwordRef.current.value;
login(email, password);
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
ref={emailRef}
defaultValue="" // 초기값만 설정, 이후 DOM이 관리
/>
<input
type="password"
ref={passwordRef}
defaultValue=""
/>
<button type="submit">로그인</button>
</form>
);
}
value vs defaultValue
// 제어 컴포넌트 — value 사용
<input value={state} onChange={handler} />
// 비제어 컴포넌트 — defaultValue 사용
<input defaultValue="초기값" ref={inputRef} />
value를 설정하면 React가 값을 제어합니다. onChange 없이 value만 설정하면 input이 읽기 전용이 됩니다.
defaultValue는 최초 렌더링 시 초기값만 설정하고, 이후 변경은 DOM이 자체적으로 처리합니다.
각 방식의 장단점
제어 컴포넌트
** 장점:**
- 매 입력을 React가 알고 있어 실시간 검증, 포맷팅이 가능합니다
- 여러 컴포넌트 간 값 동기화가 쉽습니다
- 전체 폼 상태를 한 곳에서 관리할 수 있습니다
** 단점:**
- 매 키 입력마다 리렌더링이 발생합니다
- 보일러플레이트 코드가 많습니다 (각 필드마다 state + handler)
- 필드가 많은 폼에서 성능 이슈가 있을 수 있습니다
비제어 컴포넌트
** 장점:**
- 키 입력 시 리렌더링이 없어 성능이 좋습니다
- 코드가 간결합니다
- 서드파티 DOM 라이브러리와의 통합이 쉽습니다
** 단점:**
- 실시간 검증이 어렵습니다
- 값 동기화가 복잡합니다
- 폼 상태를 추적하기 어렵습니다
성능 관점에서의 비교
제어 컴포넌트의 리렌더 문제
function BigForm() {
const [form, setForm] = useState({
name: '',
email: '',
address: '',
// ... 20개 필드
});
const handleChange = (field) => (e) => {
setForm((prev) => ({ ...prev, [field]: e.target.value }));
};
// 한 필드 입력 → 전체 폼 리렌더링
return (
<form>
<input value={form.name} onChange={handleChange('name')} />
<input value={form.email} onChange={handleChange('email')} />
{/* 20개 필드 모두 리렌더링 */}
</form>
);
}
최적화 방법
// 방법 1: 각 필드를 별도 컴포넌트로 분리
const FormField = React.memo(({ value, onChange, label }) => (
<label>
{label}
<input value={value} onChange={onChange} />
</label>
));
// 방법 2: 각 필드에 독립적인 state
function BigForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
// 각 필드의 변경이 다른 필드에 영향을 주지 않음
}
하지만 필드가 20개 이상이면 비제어 컴포넌트나 React Hook Form 같은 라이브러리가 더 실용적입니다.
file input — 항상 비제어
function FileUpload() {
const fileRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const file = fileRef.current.files[0];
uploadFile(file);
};
return (
<form onSubmit={handleSubmit}>
{/* file input은 항상 비제어 — 보안상 value 설정 불가 */}
<input type="file" ref={fileRef} />
<button type="submit">업로드</button>
</form>
);
}
보안상의 이유로 브라우저는 JavaScript로 file input의 value를 설정할 수 없습니다. 따라서 file input은 항상 비제어 컴포넌트입니다.
select와 textarea
select
// 제어
<select value={selected} onChange={(e) => setSelected(e.target.value)}>
<option value="apple">사과</option>
<option value="banana">바나나</option>
</select>
// 비제어
<select defaultValue="apple" ref={selectRef}>
<option value="apple">사과</option>
<option value="banana">바나나</option>
</select>
textarea
// 제어 — HTML과 다르게 value prop을 사용
<textarea value={text} onChange={(e) => setText(e.target.value)} />
// 비제어
<textarea defaultValue="초기 텍스트" ref={textRef} />
React에서 textarea는 value prop을 사용합니다. HTML의 <textarea>내용</textarea> 방식과 다릅니다.
선택 기준 정리
| 기능 | 제어 | 비제어 |
|---|---|---|
| 실시간 입력 검증 | O | X |
| 입력 포맷팅 | O | X |
| 조건부 필드 표시 | O | 어려움 |
| 폼 제출 시 값 읽기 | O | O |
| 성능 (대규모 폼) | 최적화 필요 | 좋음 |
| 코드 간결성 | 보일러플레이트 많음 | 간결 |
| file input | X | O |
이럴 때 제어 컴포넌트
- 실시간 검증이 필요한 폼 (회원가입 등)
- 입력 값 포맷팅 (전화번호, 카드번호)
- 필드 간 의존성이 있는 폼 (A 선택에 따라 B 옵션 변경)
- 폼 상태를 외부(Redux 등)에서 관리해야 할 때
이럴 때 비제어 컴포넌트
- 단순한 폼 (검색 바, 간단한 입력)
- 파일 업로드
- 서드파티 DOM 라이브러리와 통합
- 성능이 중요한 대규모 폼 (React Hook Form 등의 라이브러리 기반)
정리
제어 컴포넌트와 비제어 컴포넌트는 "누가 상태를 관리하느냐"의 차이입니다.
- **제어 컴포넌트 **: React state가 진실의 원천. 완전한 제어가 가능하지만 리렌더링 비용이 있습니다
- ** 비제어 컴포넌트 **: DOM이 자체적으로 상태 관리. 성능이 좋지만 실시간 제어가 어렵습니다
- 대부분의 경우 ** 제어 컴포넌트가 기본 선택 **이지만, 대규모 폼에서는 비제어 기반 라이브러리가 유리합니다
- file input은 보안상 항상 비제어입니다
주의할 점
제어와 비제어를 혼용하면 예측 불가능한 동작
하나의 input에 value(제어)와 defaultValue(비제어)를 동시에 설정하면 경고가 발생합니다. 한 폼 안에서도 각 필드의 제어 방식을 일관되게 유지해야 합니다.
file input은 보안상 항상 비제어
<input type="file">은 브라우저 보안 정책으로 프로그래밍으로 값을 설정할 수 없습니다. 항상 비제어 방식(ref)으로 다루어야 합니다.
어떤 방식이 "정답"이 아니라, 폼의 요구사항에 맞는 방식을 선택하는 것이 중요합니다.