input에 글자를 입력할 때, 그 값을 "누가" 관리해야 할까요 — React인가, 브라우저 DOM인가?

HTML form 요소(input, textarea, select)는 자체적으로 상태를 관리합니다. React에서는 이 상태를 React가 제어할 것인지, DOM에게 맡길 것인지 선택할 수 있습니다. 이 선택이 제어 컴포넌트와 비제어 컴포넌트의 핵심 차이입니다.

제어 컴포넌트 (Controlled Component)

React state가 "진실의 원천(Single Source of Truth)"이 되는 방식입니다.

JSX
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>
  );
}

동작 원리

  1. 사용자가 키를 입력합니다
  2. onChange 이벤트가 발생합니다
  3. 핸들러가 setState를 호출합니다
  4. React가 새 state로 리렌더링합니다
  5. input의 value가 새 state 값으로 업데이트됩니다

모든 입력이 React를 거치기 때문에 입력 과정을 완전히 제어 할 수 있습니다.

제어 컴포넌트로 할 수 있는 것들

JSX
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로 값을 읽는 방식입니다.

JSX
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

JSX
// 제어 컴포넌트 — value 사용
<input value={state} onChange={handler} />

// 비제어 컴포넌트 — defaultValue 사용
<input defaultValue="초기값" ref={inputRef} />

value를 설정하면 React가 값을 제어합니다. onChange 없이 value만 설정하면 input이 읽기 전용이 됩니다.

defaultValue는 최초 렌더링 시 초기값만 설정하고, 이후 변경은 DOM이 자체적으로 처리합니다.

각 방식의 장단점

제어 컴포넌트

** 장점:**

  • 매 입력을 React가 알고 있어 실시간 검증, 포맷팅이 가능합니다
  • 여러 컴포넌트 간 값 동기화가 쉽습니다
  • 전체 폼 상태를 한 곳에서 관리할 수 있습니다

** 단점:**

  • 매 키 입력마다 리렌더링이 발생합니다
  • 보일러플레이트 코드가 많습니다 (각 필드마다 state + handler)
  • 필드가 많은 폼에서 성능 이슈가 있을 수 있습니다

비제어 컴포넌트

** 장점:**

  • 키 입력 시 리렌더링이 없어 성능이 좋습니다
  • 코드가 간결합니다
  • 서드파티 DOM 라이브러리와의 통합이 쉽습니다

** 단점:**

  • 실시간 검증이 어렵습니다
  • 값 동기화가 복잡합니다
  • 폼 상태를 추적하기 어렵습니다

성능 관점에서의 비교

제어 컴포넌트의 리렌더 문제

JSX
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>
  );
}

최적화 방법

JSX
// 방법 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 — 항상 비제어

JSX
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

JSX
// 제어
<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

JSX
// 제어 — HTML과 다르게 value prop을 사용
<textarea value={text} onChange={(e) => setText(e.target.value)} />

// 비제어
<textarea defaultValue="초기 텍스트" ref={textRef} />

React에서 textareavalue prop을 사용합니다. HTML의 <textarea>내용</textarea> 방식과 다릅니다.

선택 기준 정리

기능제어비제어
실시간 입력 검증OX
입력 포맷팅OX
조건부 필드 표시O어려움
폼 제출 시 값 읽기OO
성능 (대규모 폼)최적화 필요좋음
코드 간결성보일러플레이트 많음간결
file inputXO

이럴 때 제어 컴포넌트

  • 실시간 검증이 필요한 폼 (회원가입 등)
  • 입력 값 포맷팅 (전화번호, 카드번호)
  • 필드 간 의존성이 있는 폼 (A 선택에 따라 B 옵션 변경)
  • 폼 상태를 외부(Redux 등)에서 관리해야 할 때

이럴 때 비제어 컴포넌트

  • 단순한 폼 (검색 바, 간단한 입력)
  • 파일 업로드
  • 서드파티 DOM 라이브러리와 통합
  • 성능이 중요한 대규모 폼 (React Hook Form 등의 라이브러리 기반)

정리

제어 컴포넌트와 비제어 컴포넌트는 "누가 상태를 관리하느냐"의 차이입니다.

  • **제어 컴포넌트 **: React state가 진실의 원천. 완전한 제어가 가능하지만 리렌더링 비용이 있습니다
  • ** 비제어 컴포넌트 **: DOM이 자체적으로 상태 관리. 성능이 좋지만 실시간 제어가 어렵습니다
  • 대부분의 경우 ** 제어 컴포넌트가 기본 선택 **이지만, 대규모 폼에서는 비제어 기반 라이브러리가 유리합니다
  • file input은 보안상 항상 비제어입니다

주의할 점

제어와 비제어를 혼용하면 예측 불가능한 동작

하나의 input에 value(제어)와 defaultValue(비제어)를 동시에 설정하면 경고가 발생합니다. 한 폼 안에서도 각 필드의 제어 방식을 일관되게 유지해야 합니다.

file input은 보안상 항상 비제어

<input type="file">은 브라우저 보안 정책으로 프로그래밍으로 값을 설정할 수 없습니다. 항상 비제어 방식(ref)으로 다루어야 합니다.

어떤 방식이 "정답"이 아니라, 폼의 요구사항에 맞는 방식을 선택하는 것이 중요합니다.

댓글 로딩 중...