본문 바로가기

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

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

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

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

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

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


이 절은 첫 React 앱의 파일 구성과 이벤트 핸들러·state 갱신을 전제로 합니다. 폼에서는 화면에 보이는 값, 제출 순간 읽은 값, 서버가 저장한 값을 구별해야 합니다. 아래 세 예제는 콘솔과 alert까지 확인하는 브라우저 실습이며 실제 서버에 저장하지 않습니다. preventDefault()는 브라우저의 기본 제출을 막을 뿐 API 요청을 대신 보내 주지 않습니다.

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

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

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

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

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


제어 컴포넌트

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

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

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

한 입력은 생명주기 동안 제어 또는 비제어 방식 중 하나를 유지해야 합니다. 제어 text 입력의 state는 undefined나 null이 아니라 빈 문자열처럼 항상 유효한 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.current가 null이고 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="[email protected]"
            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를 두 번째 인자로 전달합니다.


같은 입력을 두 방식으로 따라가기

Vite 프로젝트에서는 원 코드 제목의 예제 세 개를 각각 JSX 파일로 저장해 실행하세요. 원문의 파일 제목은 보존했으며, 복사본은 ControlledForm.jsx, UncontrolledForm.jsx, MultiInputControlledForm.jsx처럼 구분할 수 있습니다. App.jsx에서 먼저 한 폼만 import하고 렌더합니다. 서로 다른 폼을 동시에 배치하면 반복되는 input id가 충돌할 수 있으므로 초기 비교 실습에서는 하나씩 교체합니다.

예시

이름 Mina, 이메일 [email protected], 피드백 bad를 입력한 뒤 제출한다고 가정합니다. 브라우저의 이메일 형식 검사도 통과하는 입력입니다.

관찰 시점ControlledFormUncontrolledForm
첫 화면빈 이름·빈 이메일·good홍길동·[email protected]·good
사용자가 세 값을 바꿈state 갱신 뒤 입력에 Mina·[email protected]·bad 전달현재 DOM에 같은 값 보관
제출 핸들러가 읽는 값현재 렌더의 name·email·feedback세 ref의 current.value
alert를 닫은 뒤 초기화세 setter로 빈 값·빈 값·goodform.reset()으로 원 defaultValue 복원

제출 시 읽은 Mina와 초기화 뒤 보여 주는 홍길동은 다른 시점의 값입니다. 비제어 폼의 reset을 “항상 빈 문자열로 만들기”로 해석하면 결과를 잘못 예상하게 됩니다. 제어 폼에서는 DOM만 reset해도 다음 React 렌더가 state 값을 다시 전달하므로 상태의 초기화 정책을 함께 정합니다. 이 계약은 React input 공식 안내의 value·defaultValue·checked 구분과 연결됩니다.

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

특징제어 컴포넌트 (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등록·검증·오류 연결을 맡기고 도구 내부의 controlled/uncontrolled 계약 확인
필드별 요구가 다름Intentional hybrid필드 단위 소유권은 섞을 수 있지만 한 입력의 모드는 고정
Live UI

Controlled

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

Submit only

Uncontrolled

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

File input

브라우저 소유

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

Large form

일관된 library 모델

값·오류·방문·제출 상태가 커지면 전용 도구를 검토하고 내부의 controlled/uncontrolled 연결 계약을 확인합니다.

Hybrid

필드마다 의도적으로

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

Controlled traps

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

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

default는 초기값이다

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

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

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

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

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


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

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

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

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

React 여러 입력의 공통 변경 계약 — 상태 추적

공통 handler가 checkbox.checked 또는 value 문자열을 골라 새 객체의 key 하나를 갱신한다. 현재 DOM/선택값/이전-new 객체/다음 제어 값을 실제 필드 사례로 추적할 가치가 있다.

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

React · Multi-field controlled form

여러 입력을 name·값 표현·새 객체로 연결하기

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

입력 이벤트에서 한 필드의 새 렌더까지동일한 공통 핸들러가 이벤트 name과 type을 읽고 checkbox만 checked, 나머지는 value를 선택해 이전 객체의 한 필드만 새 객체로 바꾼 뒤 입력에 다시 전달한다. number의 value는 문자열이고 file은 이 controlled 핸들러 범위 밖이다.현재 DOM 입력name · type값 표현 선택checkbox는 checked선택한 nextValue문자열 또는 boolean이전 객체를 펼침[name] 한 key 갱신다음 React 렌더value 또는 checked
입력 이벤트에서 한 필드의 새 렌더까지동일한 공통 핸들러가 이벤트 name과 type을 읽고 checkbox만 checked, 나머지는 value를 선택해 이전 객체의 한 필드만 새 객체로 바꾼 뒤 입력에 다시 전달한다. number의 value는 문자열이고 file은 이 controlled 핸들러 범위 밖이다.현재 DOM 입력name · type값 표현 선택checkbox는 checked선택한 nextValue문자열 또는 boolean이전 객체를 펼침[name] 한 key 갱신다음 React 렌더value 또는 checked
Change pipeline

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

  1. 입력의 name과 type을 읽는다

    name은 formData의 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 표현, 렌더 속성을 한 세트로 검토합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명공통 핸들러는 name을 state key 계약으로 사용하고 입력 종류별 값을 먼저 정규화합니다. 이전 객체를 펼친 뒤 계산된 key 하나만 바꾸면 다른 필드를 잃지 않습니다.
원도표 설명입력의 name과 type을 읽는다
원도표 설명name은 formData의 key와 정확히 일치해야 합니다.
원도표 조건·주석입력의 name과 type을 읽는다. name은 formData의 key와 정확히 일치해야 합니다.
원도표 설명value 또는 checked를 정규화한다
원도표 조건·주석value 또는 checked를 정규화한다. text·number·select의 문자열과 checkbox의 boolean을 구분합니다.
원도표 설명이전 객체를 펼치고 [name] key만 nextValue로 바꿉니다.
원도표 조건·주석functional update로 이전 state를 기준 삼는다. 이전 객체를 펼치고 [name] key만 nextValue로 바꿉니다.
원도표 설명해당 필드의 value·checked를 다시 전달한다
원도표 조건·주석해당 필드의 value·checked를 다시 전달한다. React state와 화면 값이 한 방향으로 동기화됩니다.
원도표 표 행입력 | 이벤트에서 읽기 | 권장 state 값 | 렌더 속성
원도표 표 행text · select | value | 문자열 | value
원도표 표 행number | value | 입력 중 문자열, 경계에서 숫자 변환 | value
원도표 표 행checkbox | checked | boolean | checked
원도표 표 행file | files | 제출 시 DOM에서 읽기 | uncontrolled
원도표 설명value가 아니라 선택 여부를 읽고 다시 전달합니다.
원도표 설명잘못된 name은 새 key를 만듭니다. 모든 입력에서 value만 읽으면 checkbox 의미가 깨지고, number 문자열은 검증·제출 경계에서 변환해야 합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
DOM 필드checkbox: boolean / 다른 value: 문자열name과 type을 읽고 checkbox는 checked를 고른다number의 value는 자동으로 number가 되지 않으며 file은 범위 밖이다
순수한 key 교체다른 key: 보존 / 객체: 새 참조updater가 이전 객체를 펼치고 [name]만 바꾼다현재 formData 객체를 직접 변이하지 않는다
다음 제어 값state: 다음 필드 값 / DOM: 같은 제어 값새 state의 해당 필드를 value 또는 checked로 전달한다필드별 값의 표현과 소유 계약을 유지한다

React 여러 입력의 공통 변경 계약 — 상태 추적

공통 handler가 checkbox.checked 또는 value 문자열을 골라 새 객체의 key 하나를 갱신한다. 현재 DOM/선택값/이전-new 객체/다음 제어 값을 실제 필드 사례로 추적할 가치가 있다.

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

React · Multi-field controlled form

여러 입력을 name·값 표현·새 객체로 연결하기

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

입력 이벤트에서 한 필드의 새 렌더까지동일한 공통 핸들러가 이벤트 name과 type을 읽고 checkbox만 checked, 나머지는 value를 선택해 이전 객체의 한 필드만 새 객체로 바꾼 뒤 입력에 다시 전달한다. number의 value는 문자열이고 file은 이 controlled 핸들러 범위 밖이다.현재 DOM 입력name · type값 표현 선택checkbox는 checked선택한 nextValue문자열 또는 boolean이전 객체를 펼침[name] 한 key 갱신다음 React 렌더value 또는 checked
입력 이벤트에서 한 필드의 새 렌더까지동일한 공통 핸들러가 이벤트 name과 type을 읽고 checkbox만 checked, 나머지는 value를 선택해 이전 객체의 한 필드만 새 객체로 바꾼 뒤 입력에 다시 전달한다. number의 value는 문자열이고 file은 이 controlled 핸들러 범위 밖이다.현재 DOM 입력name · type값 표현 선택checkbox는 checked선택한 nextValue문자열 또는 boolean이전 객체를 펼침[name] 한 key 갱신다음 React 렌더value 또는 checked
Change pipeline

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

  1. 입력의 name과 type을 읽는다

    name은 formData의 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 표현, 렌더 속성을 한 세트로 검토합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명공통 핸들러는 name을 state key 계약으로 사용하고 입력 종류별 값을 먼저 정규화합니다. 이전 객체를 펼친 뒤 계산된 key 하나만 바꾸면 다른 필드를 잃지 않습니다.
원도표 설명입력의 name과 type을 읽는다
원도표 설명name은 formData의 key와 정확히 일치해야 합니다.
원도표 조건·주석입력의 name과 type을 읽는다. name은 formData의 key와 정확히 일치해야 합니다.
원도표 설명value 또는 checked를 정규화한다
원도표 조건·주석value 또는 checked를 정규화한다. text·number·select의 문자열과 checkbox의 boolean을 구분합니다.
원도표 설명이전 객체를 펼치고 [name] key만 nextValue로 바꿉니다.
원도표 조건·주석functional update로 이전 state를 기준 삼는다. 이전 객체를 펼치고 [name] key만 nextValue로 바꿉니다.
원도표 설명해당 필드의 value·checked를 다시 전달한다
원도표 조건·주석해당 필드의 value·checked를 다시 전달한다. React state와 화면 값이 한 방향으로 동기화됩니다.
원도표 표 행입력 | 이벤트에서 읽기 | 권장 state 값 | 렌더 속성
원도표 표 행text · select | value | 문자열 | value
원도표 표 행number | value | 입력 중 문자열, 경계에서 숫자 변환 | value
원도표 표 행checkbox | checked | boolean | checked
원도표 표 행file | files | 제출 시 DOM에서 읽기 | uncontrolled
원도표 설명value가 아니라 선택 여부를 읽고 다시 전달합니다.
원도표 설명잘못된 name은 새 key를 만듭니다. 모든 입력에서 value만 읽으면 checkbox 의미가 깨지고, number 문자열은 검증·제출 경계에서 변환해야 합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
DOM 필드checkbox: boolean / 다른 value: 문자열name과 type을 읽고 checkbox는 checked를 고른다number의 value는 자동으로 number가 되지 않으며 file은 범위 밖이다
순수한 key 교체다른 key: 보존 / 객체: 새 참조updater가 이전 객체를 펼치고 [name]만 바꾼다현재 formData 객체를 직접 변이하지 않는다
다음 제어 값state: 다음 필드 값 / DOM: 같은 제어 값새 state의 해당 필드를 value 또는 checked로 전달한다필드별 값의 표현과 소유 계약을 유지한다
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과 입력 종류별 값 계약으로 공통 핸들러를 구성했습니다.

여러 필드의 값과 타입을 혼자 추적하기

MultiInputControlledForm을 새로 마운트해 firstName을 Mina, age를 0으로 입력하고 newsletter를 체크합니다. 다른 필드에는 손대지 않고 Submit을 누릅니다. 콘솔 객체에서 세 값과 타입을 적고, lastName·gender가 남는 이유를 설명하세요. age를 다시 비우면 숫자 0과 어떻게 구별하나요? name이 실수로 first_name인 입력을 같은 핸들러에 연결하면 기존 firstName은 바뀔까요?

값 소유권과 한 필드 병합의 풀이

firstName은 문자열 Mina, age는 문자열 '0', newsletter는 boolean true입니다. input type이 number여도 이 코드가 읽는 value는 문자열입니다. 이전 객체를 펼친 다음 계산된 key 하나만 바꾸므로 lastName은 빈 문자열, gender는 male로 남습니다. 제출은 값을 표시할 뿐 이 폼을 리셋하지 않습니다.

age를 비우면 빈 문자열입니다. Number('')도 0이 되므로 제출 경계에서 빈 값 검사를 먼저 하고 숫자로 변환해야 입력하지 않음과 0을 구분할 수 있습니다. 어떤 숫자 범위를 허용할지는 업무 규칙으로 정합니다. first_name을 보내면 원 핸들러는 새 key를 추가하고 firstName은 그대로 둡니다. name을 state key와 맞추는 계약 또는 허용 key 검사를 추가해야 하며, computed property 자체가 잘못된 이름을 거부하는 것은 아닙니다.