본문으로 건너뛰기

안동민 개발노트

본문 시작

제어 컴포넌트와 비제어 컴포넌트

폼 값을 React 상태에 연결하는 제어 방식과 DOM ref로 읽는 비제어 방식을 비교해 입력 관리 전략을 선택합니다.

웹 애플리케이션에서 사용자 입력을 받는 데 필수적인 폼(Form) 관리를 알아보겠습니다.

사용자 데이터를 입력받는 input, textarea, select 등의 폼 요소는 웹 개발에서 매우 중요합니다.

리액트에서는 폼 요소를 관리하는 두 가지 주요 접근 방식, 제어 컴포넌트(Controlled Components)비제어 컴포넌트(Uncontrolled Components)를 제공합니다.

이 장에서는 두 방식의 개념과 특징, 그리고 각각을 언제 선택하면 좋은지 정리합니다.


폼(Form)의 역할과 리액트에서의 관리

HTML의 폼 요소는 사용자의 입력을 받고 서버로 전송하는 역할을 합니다.

전통적인 HTML에서 폼 데이터는 폼 자체적으로 내부 상태를 관리하며, submit 이벤트 발생 시 서버로 데이터를 전송합니다.

하지만 리액트는 선언적(declarative) 프로그래밍 방식을 지향하며, UI가 애플리케이션의 상태에 따라 변경되도록 합니다.

폼 요소 또한 마찬가지로, 리액트 컴포넌트의 상태를 진실의 원천(source of truth)으로 삼아 폼 요소의 값을 제어하는 것이 일반적입니다.


제어 컴포넌트

제어 컴포넌트 (Controlled Components)는 리액트 컴포넌트의 상태(state)가 폼 요소의 값을 완전히 제어하는 방식을 말합니다.

폼 요소의 value 속성이 리액트 상태에 의해 관리되며, 사용자의 입력은 onChange 이벤트 핸들러를 통해 상태를 업데이트함으로써 이루어집니다.

특징
  • 리액트 상태가 진실의 원천: 폼 요소의 현재 값이 항상 리액트 컴포넌트의 state에 의해 결정됩니다.
  • 예측 가능한 동작: 모든 입력 변화가 명시적으로 상태를 통해 흐르므로, 데이터의 흐름을 예측하기 쉽고 디버깅이 용이합니다.
  • 실시간 유효성 검사: onChange 이벤트에서 즉시 상태를 업데이트하므로, 실시간으로 유효성 검사를 수행하고 사용자에게 피드백을 줄 수 있습니다.
  • 세밀한 제어: 입력값을 특정 형식으로 포매팅하거나, 특정 조건에 따라 입력 자체를 막는 등의 복잡한 로직을 구현하기 용이합니다.

한 입력은 생명주기 동안 제어 또는 비제어 방식 중 하나를 유지해야 합니다. 제어 text 입력의 state는 undefinednull이 아니라 빈 문자열처럼 항상 유효한 value로 초기화하고, value 또는 checked를 전달할 때는 값을 동기적으로 갱신하는 onChange나 의도적인 readOnly도 함께 둡니다.

구현 방법

useState 훅을 사용하여 폼 요소의 값을 저장할 상태를 선언합니다.

폼 요소의 value 속성을 선언한 상태 변수에 바인딩합니다.

폼 요소의 onChange 속성에 이벤트 핸들러 함수를 할당합니다.

이 핸들러 함수 내에서 event.target.value를 통해 현재 입력값을 가져와 상태를 업데이트합니다.

예시
src/components/ControlledForm.js
import { useState } from 'react';

function ControlledForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [feedback, setFeedback] = useState('good'); // select 박스 예시

  const handleSubmit = (event) => {
    event.preventDefault(); // 폼의 기본 제출 동작 방지
    console.log('폼 제출됨 (제어 컴포넌트):', { name, email, feedback });
    alert(`이름: ${name}, 이메일: ${email}, 피드백: ${feedback} 제출 완료!`);
    // 제출 후 상태 초기화
    setName('');
    setEmail('');
    setFeedback('good');
  };

  return (
    <div style={{ maxWidth: '500px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>제어 컴포넌트 예시</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="controlled-name" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이름:</label>
          <input
            type="text"
            id="controlled-name"
            name="name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="controlled-email" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이메일:</label>
          <input
            type="email"
            id="controlled-email"
            name="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="controlled-feedback" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>피드백:</label>
          <select
            id="controlled-feedback"
            name="feedback"
            value={feedback}
            onChange={(e) => setFeedback(e.target.value)}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          >
            <option value="good">좋음</option>
            <option value="neutral">보통</option>
            <option value="bad">나쁨</option>
          </select>
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '12px', fontSize: '1.1em' }}>제출</button>
      </form>
    </div>
  );
}

export default ControlledForm;

App.js에 이 컴포넌트를 추가하여 테스트해 보세요.


비제어 컴포넌트

비제어 컴포넌트 (Uncontrolled Components)는 전통적인 HTML 폼과 유사하게, 폼 요소 자체가 자신의 내부 상태를 관리합니다.

리액트 컴포넌트의 상태가 폼 요소의 값을 직접 제어하지 않습니다.

대신, 폼 제출(submit) 이벤트가 발생했을 때 ref를 사용하여 DOM에서 직접 값을 가져옵니다.

특징
  • DOM이 진실의 원천: 폼 요소의 현재 값이 DOM 자체에 의해 관리됩니다.
  • 간단한 구현: 간단한 폼이나, 폼 요소가 많지 않을 때 비교적 적은 코드로 구현할 수 있습니다.
  • DOM 중심 도구와의 통합: 브라우저 위젯이나 에디터처럼 DOM이 값을 소유하는 외부 도구와 연결할 때 유용할 수 있습니다.
  • React 상태 기반 실시간 피드백에는 추가 연결 필요: 브라우저의 네이티브 검증은 사용할 수 있지만, 입력 중 값을 React UI에 반영하려면 이벤트나 별도 상태 연결이 필요합니다.
구현 방법

useRef 훅을 사용하여 폼 요소에 접근할 ref를 생성합니다.

최초 렌더 중에는 ref.currentnull이고 commit 뒤 DOM 노드가 연결되며, 노드가 분리되거나 컴포넌트가 unmount되면 다시 null이 됩니다. 따라서 render 중에 읽거나 쓰지 않고, commit 뒤의 이벤트 핸들러나 effect에서 nullable 값을 확인해 사용합니다.

폼 요소의 ref 속성에 생성한 ref 객체를 할당합니다.

폼 제출(onSubmit) 핸들러에서 ref.current.value를 통해 폼 요소의 현재 값을 가져옵니다.

예시
src/components/UncontrolledForm.js
import { useRef } from 'react';

function UncontrolledForm() {
  const nameInputRef = useRef(null);
  const emailInputRef = useRef(null);
  const feedbackSelectRef = useRef(null);

  const handleSubmit = (event) => {
    event.preventDefault(); // 폼의 기본 제출 동작 방지

    // 🌟 ref를 통해 DOM에서 직접 값 가져오기
    const name = nameInputRef.current.value;
    const email = emailInputRef.current.value;
    const feedback = feedbackSelectRef.current.value;

    console.log('폼 제출됨 (비제어 컴포넌트):', { name, email, feedback });
    alert(`이름: ${name}, 이메일: ${email}, 피드백: ${feedback} 제출 완료!`);

    // 현재 DOM 값을 각 defaultValue로 되돌림
    event.currentTarget.reset();
  };

  return (
    <div style={{ maxWidth: '500px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>비제어 컴포넌트 예시</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="uncontrolled-name" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이름:</label>
          <input
            type="text"
            id="uncontrolled-name"
            name="name"
            defaultValue="홍길동"
            ref={nameInputRef}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="uncontrolled-email" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이메일:</label>
          <input
            type="email"
            id="uncontrolled-email"
            name="email"
            defaultValue="hong@example.com"
            ref={emailInputRef}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="uncontrolled-feedback" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>피드백:</label>
          <select
            id="uncontrolled-feedback"
            name="feedback"
            defaultValue="good"
            ref={feedbackSelectRef}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          >
            <option value="good">좋음</option>
            <option value="neutral">보통</option>
            <option value="bad">나쁨</option>
          </select>
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '12px', fontSize: '1.1em' }}>제출</button>
      </form>
    </div>
  );
}

export default UncontrolledForm;

App.js에 이 컴포넌트를 추가하여 테스트해 보세요.

defaultValue는 컴포넌트가 처음 렌더링될 때만 적용되는 초기값입니다.

이후 사용자의 입력은 DOM이 보관하며, ref는 현재 DOM 값에 접근할 때 사용합니다. 제출 시에는 new FormData(event.currentTarget)로 성공적인 폼 컨트롤을 모을 수도 있습니다. name이 있어야 하며 disabled 컨트롤과 선택되지 않은 checkbox는 빠지고, 같은 name은 여러 entry가 될 수 있어 getAll()이 필요할 수 있으며 파일 입력 값은 File입니다. 인자를 하나만 쓰면 제출 버튼의 name/value도 빠지므로 버튼별 제출 의도가 데이터에 필요할 때는 실제 SubmitEvent.submitter를 두 번째 인자로 전달합니다.


언제 무엇을 사용해야 하는가?

특징제어 컴포넌트 (Controlled Components)비제어 컴포넌트 (Uncontrolled Components)
진실의 원천리액트 컴포넌트의 stateDOM 자체
구현 방식value 속성 + onChange 이벤트 핸들러ref 속성 + defaultValue (초기값)
데이터 흐름이벤트 → state → value/checked로 다시 렌더DOM이 보관하고 제출 시 FormData 또는 ref로 읽음
유효성 검사입력 중 React UI와 파생 상태를 연결하기 쉬움네이티브 검증 또는 제출 시 검증에 자연스러움
복잡성각 입력 필드마다 상태와 핸들러 필요 (코드량 증가)간단한 폼에선 코드량 적음
활용 시점입력 중 값으로 검증·포매팅·조건부 UI를 계산할 때제출 시점 수집, 파일 입력, DOM 등록형 도구를 쓸 때
React controlled 입력과 DOM uncontrolled 입력의 값 소유자, 초기값, 읽기 시점, 검증과 파생 UI, 파일 입력, 폼 라이브러리 선택 기준과 controlled 전환 함정을 비교하는 결정표

React · Form ownership

제어와 비제어의 차이는 입력값을 누가 소유하고 언제 읽는가입니다. 입력 중 값으로 UI를 계산하면 React state를, 제출 순간만 읽으면 DOM·FormData·ref를 우선 검토합니다.

Controlled

React state가 현재 값을 소유한다

  1. value 또는 checked를 state에서 전달

  2. onChange에서 다음 값을 계산해 state 갱신

  3. 새 state로 입력, 오류, 제출 가능 UI를 다시 렌더

입력 중 포매팅, 조건부 UI, 동기 검증처럼 현재 값이 렌더에 필요할 때 적합합니다.

Uncontrolled

DOM이 현재 값을 소유한다

  1. defaultValue·defaultChecked로 초기값만 전달

  2. 사용자 입력 뒤 현재 값은 DOM에 유지

  3. 제출 시 FormData나 ref로 필요한 값을 읽음

제출 시점 수집, 네이티브 폼, 파일 입력, uncontrolled 등록형 라이브러리에 자연스럽습니다.

폼 요구에서 값 소유자를 고르는 기준
요구우선 선택이유와 경계
입력 중 검증·파생 UIControlled렌더마다 최신 state로 오류와 버튼 상태를 계산
제출 순간 값 수집UncontrolledDOM 값을 FormData 또는 ref로 한 번 읽음
파일 선택Uncontrolled브라우저가 파일 값과 FileList를 관리
큰 폼의 값·오류·방문 상태Form library등록·검증·오류 연결을 일관된 모델에 위임
필드별 요구가 다름Intentional hybrid필드 단위 소유권은 섞을 수 있지만 한 입력의 모드는 고정
Live UI

Controlled

입력 중 검증·포매팅·제출 가능 여부를 state에서 계산합니다.

Submit only

Uncontrolled

제출 순간 DOM 값을 FormData나 ref로 읽습니다.

File input

브라우저 소유

파일 값은 controlled text value처럼 다루지 않습니다.

Large form

일관된 library 모델

값·오류·방문·제출 상태가 커지면 전용 상태 모델을 검토합니다.

Hybrid

필드마다 의도적으로

필드별 방식은 다를 수 있지만 같은 입력을 중간에 전환하지 않습니다.

Controlled traps

모드를 중간에 바꾸지 않는다

  • value만 주고 onChangereadOnly를 빼면 편집할 수 없음
  • undefined에서 문자열로 바꾸면 uncontrolled→controlled 전환
Default contract

default는 초기값이다

  • defaultValue 변경은 이미 입력 중인 DOM 현재 값을 제어하지 않음
  • 초기화는 폼 reset() 또는 명시적인 remount 정책으로 수행

Controlled가 항상 우월한 것도, uncontrolled가 항상 가벼운 것도 아닙니다. 화면이 현재 입력값을 사용하는 시점과 검증·제출 책임을 기준으로 한 입력의 소유자를 고정합니다.

결론적으로, 입력 중 현재 값으로 React UI를 계산해야 하면 제어 컴포넌트가 자연스럽습니다. 제출 순간에만 값이 필요하거나 파일 입력처럼 브라우저가 값을 관리하면 비제어 방식이 더 직접적일 수 있습니다.

필드가 많아 값, 오류, 방문 여부, 제출 상태의 규칙이 커지면 reducer나 폼 라이브러리도 검토합니다. 라이브러리가 내부적으로 controlled 또는 uncontrolled 등록 모델을 사용할 수 있으므로 별도의 제3 값 원천으로 단정하지 않고 그 소유권 계약을 확인합니다.

한 폼 안에서 필드별 방식을 다르게 선택할 수는 있지만, 같은 입력을 렌더 도중 controlled와 uncontrolled 사이에서 전환해서는 안 됩니다.


여러 입력 필드 관리 (제어 컴포넌트 심화)

제어 컴포넌트 방식으로 여러 개의 입력 필드를 관리할 때, 각 필드마다 별도의 useStateonChange 핸들러를 만드는 것은 비효율적입니다.

이럴 때는 하나의 상태 객체를 사용하고 범용적인 onChange 핸들러를 만들 수 있습니다.

아래 다이어그램은 여러 입력 필드를 하나의 상태 객체와 공통 핸들러로 묶을 때 값이 갱신되는 흐름을 정리한 것입니다.

여러 controlled 입력에서 안정적인 name으로 state key를 선택하고 input type에 따라 value 또는 checked를 정규화한 뒤 functional state update로 한 필드만 병합하고 다시 value 또는 checked를 렌더하는 흐름

React · Multi-field controlled form

공통 핸들러는 name을 state key 계약으로 사용하고 입력 종류별 값을 먼저 정규화합니다. 이전 객체를 펼친 뒤 계산된 key 하나만 바꾸면 다른 필드를 잃지 않습니다.

Change pipeline

DOM 이벤트에서 다음 controlled render까지

  1. 입력의 nametype을 읽는다

    nameformData의 key와 정확히 일치해야 합니다.

  2. value 또는 checked를 정규화한다

    text·number·select의 문자열과 checkbox의 boolean을 구분합니다.

  3. functional update로 이전 state를 기준 삼는다

    이전 객체를 펼치고 [name] key만 nextValue로 바꿉니다.

  4. 해당 필드의 value·checked를 다시 전달한다

    React state와 화면 값이 한 방향으로 동기화됩니다.

입력 종류별 읽기와 state 표현
입력이벤트에서 읽기권장 state 값렌더 속성
text · selectvalue문자열value
numbervalue입력 중 문자열, 경계에서 숫자 변환value
checkboxcheckedbooleanchecked
filefiles제출 시 DOM에서 읽기uncontrolled
Text · select

value 문자열

state와 렌더 모두 문자열을 사용합니다.

Number

입력 문자열을 유지

빈 값과 편집 중 표현을 보존하고 검증·제출 경계에서 숫자로 바꿉니다.

Checkbox

checked boolean

value가 아니라 선택 여부를 읽고 다시 전달합니다.

File

DOM에서 files 읽기

파일 선택은 controlled value로 만들지 않습니다.

Invariant

기존 필드를 보존한다

객체를 통째로 교체하지 않고 이전 state를 펼쳐 한 key만 갱신합니다.

Mismatch

이름과 타입을 숨기지 않는다

잘못된 name은 새 key를 만듭니다. 모든 입력에서 value만 읽으면 checkbox 의미가 깨지고, number 문자열은 검증·제출 경계에서 변환해야 합니다.

공통 핸들러의 단순함은 필드 계약이 정확할 때만 유지됩니다. name, 입력 종류, state 표현, 렌더 속성을 한 세트로 검토합니다.

src/components/MultiInputControlledForm.js
import { useState } from 'react';

function MultiInputControlledForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    age: '',
    gender: 'male',
    newsletter: false,
  });

  const handleChange = (event) => {
    const { name, type, value, checked } = event.currentTarget;
    const nextValue = type === 'checkbox' ? checked : value;

    setFormData(prevFormData => ({
      ...prevFormData,
      [name]: nextValue,
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log('여러 입력 필드 폼 제출됨:', formData);
    alert(`제출된 데이터: ${JSON.stringify(formData, null, 2)}`);
  };

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>여러 입력 필드 관리 (제어 컴포넌트)</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="firstName" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이름:</label>
          <input
            type="text"
            id="firstName"
            name="firstName"
            value={formData.firstName}
            onChange={handleChange}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="lastName" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>성:</label>
          <input
            type="text"
            id="lastName"
            name="lastName"
            value={formData.lastName}
            onChange={handleChange}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="age" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>나이:</label>
          <input
            type="number"
            id="age"
            name="age"
            value={formData.age}
            onChange={handleChange}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="gender" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>성별:</label>
          <select
            id="gender"
            name="gender"
            value={formData.gender}
            onChange={handleChange}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          >
            <option value="male">남성</option>
            <option value="female">여성</option>
            <option value="other">기타</option>
          </select>
        </div>
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="newsletter" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
            <input
              type="checkbox"
              id="newsletter"
              name="newsletter"
              checked={formData.newsletter}
              onChange={handleChange}
            />
            뉴스레터 받기
          </label>
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '12px', fontSize: '1.1em' }}>제출</button>
      </form>
    </div>
  );
}

export default MultiInputControlledForm;

이 패턴은 제어 컴포넌트의 중복을 줄이지만, name이 state key와 일치하고 입력 종류별 값 표현이 코드와 맞아야 합니다. text, number, select는 입력 중 value 문자열을 유지하고 checkbox는 checked boolean을 사용합니다. number는 빈 값과 편집 중 표현을 보존한 뒤 검증이나 제출 경계에서 변환합니다. 파일 입력은 이 공통 controlled 값 경로에 넣지 않습니다.


9장 1절 제어 컴포넌트와 비제어 컴포넌트는 여기까지입니다.

이 장에서는 리액트에서 폼을 다루는 두 가지 핵심 방식인 제어 컴포넌트와 비제어 컴포넌트의 개념, 구현 방법, 그리고 각각의 장단점을 비교하여 언제 어떤 방식을 선택해야 하는지 알아보았습니다.

입력 중 React UI가 값을 필요로 하는지, 제출 순간에만 수집하면 되는지를 기준으로 소유권을 선택하고, 여러 controlled 입력에서는 안정적인 name과 입력 종류별 값 계약으로 공통 핸들러를 구성했습니다.