본문 바로가기

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

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

9장에서는 웹 애플리케이션에서 사용자의 입력을 받는 데 꼭 필요한 폼(Form) 관리를 다룹니다. input, textarea, select 같은 폼 요소는 사용자에게서 데이터를 받는 기본 수단이며, React는 이 요소들을 관리하는 두 가지 주요 방식으로 제어 컴포넌트(Controlled Components)와 비제어 컴포넌트(Uncontrolled Components)를 제공합니다. 이 글에서는 두 방식의 개념과 특징, 그리고 각각을 언제 고르면 좋은지 정리합니다.

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


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

HTML의 폼 요소는 사용자의 입력을 받아 서버로 보내는 역할을 합니다. 전통적인 HTML에서는 폼 요소가 스스로 내부 상태를 관리하고, submit 이벤트가 일어나면 서버로 데이터를 전송합니다.

하지만 React는 UI가 애플리케이션의 상태에 따라 바뀌는 선언적(declarative) 방식을 지향합니다. 폼 요소도 마찬가지로, React 컴포넌트의 상태를 진실의 원천(source of truth)으로 삼아 폼 요소의 값을 제어하는 것이 일반적입니다.


제어 컴포넌트

제어 컴포넌트(Controlled Components)는 React 컴포넌트의 state가 폼 요소의 값을 완전히 제어하는 방식입니다. 폼 요소의 value 속성은 React의 상태가 정하고, 사용자의 입력은 onChange 이벤트 처리 함수가 상태를 갱신하는 방식으로 반영됩니다.

이 방식에서는 폼 요소의 현재 값이 언제나 컴포넌트의 state로 정해집니다. 모든 입력의 변화가 상태를 거쳐 흐르므로 데이터의 흐름을 예측하기 쉽고 디버깅도 쉽습니다. onChange에서 곧바로 상태를 갱신하므로 입력하는 도중에 유효성 검사를 하고 피드백을 줄 수 있으며, 입력값을 특정 형식으로 바꾸거나 조건에 따라 입력을 막는 복잡한 로직도 구현하기 쉽습니다.

한 입력은 생명주기 내내 제어나 비제어 가운데 한 방식을 유지해야 합니다. 제어하는 text 입력의 state는 undefined나 null이 아니라 빈 문자열처럼 언제나 유효한 value로 초기화하고, value나 checked를 넘길 때는 값을 동기적으로 갱신하는 onChange나 의도적인 readOnly를 함께 둡니다.

구현은 세 단계입니다. 먼저 useState로 폼 요소의 값을 저장할 상태를 선언하고, 폼 요소의 value 속성을 그 상태 변수에 연결합니다. 그다음 onChange 속성에 이벤트 처리 함수를 넘기고, 그 함수 안에서 event.target.value로 현재 입력값을 읽어 상태를 갱신합니다. 다음 컴포넌트는 JSX가 들어 있으므로 .jsx로 저장합니다.

src/components/ControlledForm.jsx
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.jsx에 이 컴포넌트를 넣어 시험해 보세요. 입력할 때마다 setName과 setEmail이 state를 바꾸고 다음 렌더가 그 값을 value로 다시 넘기며, 제출하면 alert에 세 값이 보이고, alert를 닫으면 세 setter가 값을 빈 문자열과 빈 문자열, 'good'으로 되돌립니다.

더 깊이: onChange에서 값을 바꾸거나 늦게 반영하면

제어 입력에서 onChange는 DOM의 새 값을 그대로, 그리고 그 자리에서 state에 넣어야 합니다. React 문서의 input은 setFirstName(e.target.value.toUpperCase())처럼 e.target.value와 다른 값으로 바꾸는 코드와, setTimeout 안에서 늦게 갱신하는 코드를 키를 누를 때마다 커서가 맨 앞으로 튀는 버그의 원인으로 듭니다. 둘 다 고칠 방법은 같아서, e.target.value를 동기적으로 state에 넣는 것입니다. 대문자 변환처럼 값을 다듬어야 한다면, 입력하는 동안에는 원래 값을 두고 제출하거나 따로 보여 줄 때 변환하는 방법을 먼저 검토합니다.

같은 문서는 이렇게 고쳐도 커서가 튄다면, 입력이 키를 누를 때마다 DOM에서 빠졌다가 다시 들어가는지 의심하라고 합니다. 입력이나 그 부모가 렌더마다 다른 key를 받거나, 컴포넌트 함수 안에 다른 컴포넌트를 정의하면 React가 그 부분을 매번 다른 트리로 보고 새로 만들기 때문입니다. 이때는 state를 고치기보다 key를 만드는 방식과 컴포넌트를 정의한 위치를 먼저 확인합니다.


비제어 컴포넌트

비제어 컴포넌트(Uncontrolled Components)는 전통적인 HTML 폼처럼 폼 요소가 자신의 내부 상태를 스스로 관리하는 방식입니다. React 컴포넌트의 상태가 폼 요소의 값을 직접 제어하지 않으며, 대신 폼을 제출할 때 ref로 DOM에서 직접 값을 가져옵니다.

이 방식에서는 DOM이 진실의 원천입니다. 간단한 폼이나 폼 요소가 많지 않을 때 비교적 적은 코드로 구현할 수 있고, 브라우저 위젯이나 에디터처럼 DOM이 값을 가지는 외부 도구와 연결할 때 유용할 수 있습니다. 브라우저의 기본 검증은 쓸 수 있지만, 입력하는 도중의 값을 React UI에 반영하려면 이벤트나 별도의 상태를 따로 연결해야 합니다.

구현은 이렇게 합니다. 먼저 useRef로 폼 요소에 접근할 ref를 만듭니다. 처음 렌더하는 동안에는 ref.current가 null이고 커밋 뒤에 DOM 노드가 연결되며, 노드가 분리되거나 컴포넌트가 사라지면 다시 null이 됩니다. 따라서 렌더 중에는 읽거나 쓰지 않고, 커밋 뒤의 이벤트 처리 함수나 Effect에서 null일 수 있음을 확인하고 씁니다. 그다음 폼 요소의 ref 속성에 만든 ref 객체를 넘기고, 제출(onSubmit) 처리 함수에서 ref.current.value로 현재 값을 읽습니다.

src/components/UncontrolledForm.jsx
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.jsx에 이 컴포넌트를 넣어 시험해 보세요. defaultValue는 컴포넌트가 처음 렌더링될 때만 적용되는 초기값입니다. 그 뒤의 사용자 입력은 DOM이 보관하며, ref는 현재 DOM 값에 접근할 때 씁니다.

제출할 때는 new FormData(event.currentTarget)로 폼의 제출 대상 컨트롤을 한꺼번에 모을 수도 있습니다. 이때는 컨트롤에 name이 있어야 하며, disabled 컨트롤과 선택되지 않은 checkbox는 빠지고, 같은 name이 여러 항목이 될 수 있어 getAll()이 필요할 수 있으며, 파일 입력의 값은 File입니다. 인자를 하나만 넘기면 제출 버튼의 name과 value도 빠지므로, 어느 버튼으로 제출했는지가 데이터에 필요하다면 실제 SubmitEvent.submitter를 두 번째 인자로 넘깁니다.

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

Vite 프로젝트에서는 이 글의 세 예제를 각각 ControlledForm.jsx, UncontrolledForm.jsx, MultiInputControlledForm.jsx로 저장합니다. App.jsx에서는 처음에 한 폼만 가져와 렌더하고 하나씩 바꿔 끼웁니다. 세 폼의 input id는 서로 달라 함께 놓아도 label 연결은 깨지지 않지만, 제어 폼과 비제어 폼은 같은 문구의 alert를 띄워 어느 폼의 결과인지 헷갈리기 쉬우므로 한 번에 한 폼만 놓고 새로고침한 처음 상태에서 확인합니다.

이름에 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의 차이와 이어집니다.

더 깊이: React 19의 폼 Action과 비제어 입력의 자동 초기화

React 19부터는 <form action={submitAction}>처럼 action에 함수를 넘길 수 있고, 이 Action이 성공하면 React가 폼 안의 비제어 입력을 자동으로 초기화합니다(React 19 발표 글). 직접 초기화해야 할 때는 react-dom의 requestFormReset을 부릅니다. 이 초기화는 비제어 입력을 대상으로 하므로, value를 state로 넘기는 제어 입력은 Action이 끝나도 state를 직접 바꾸지 않는 한 그대로 남습니다.

세부 동작은 버전에 따라 조금씩 달라지기도 합니다. React의 변경 기록을 보면 19.3.0에서 자동으로 폼을 초기화할 때도 onReset이 실행되도록 고쳤고, submit 이벤트에 제출 버튼을 가리키는 submitter를 담도록 바꿨습니다. 그래서 onReset으로 초기화를 추적하거나 어느 버튼으로 제출했는지 읽는 코드를 쓴다면, 프로젝트에 설치된 React의 버전을 함께 확인합니다.


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

특징제어 컴포넌트 (Controlled Components)비제어 컴포넌트 (Uncontrolled Components)
진실의 원천React 컴포넌트의 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가 항상 가벼운 것도 아닙니다. 화면이 현재 입력값을 사용하는 시점과 검증·제출 책임을 기준으로 한 입력의 소유자를 고정합니다.

위 그림은 제어와 비제어의 차이가 입력값을 누가 가지고 언제 읽는지에 있다는 점을 보여 줍니다. 입력하는 도중의 값으로 UI를 계산한다면 React state를, 제출하는 순간에만 읽는다면 DOM과 FormData, ref를 먼저 검토합니다. 왼쪽의 Controlled 칸에서는 React state가 현재 값을 가지고, value나 checked를 state에서 넘기며 onChange에서 다음 값을 계산해 state를 바꾸고, 새 state로 입력과 오류, 제출 가능 여부를 다시 렌더합니다. 오른쪽의 Uncontrolled 칸에서는 DOM이 현재 값을 가지고, defaultValue나 defaultChecked로 초기값만 넘기며, 사용자가 입력한 뒤의 현재 값은 DOM에 남아 있다가 제출할 때 FormData나 ref로 읽습니다.

가운데 표는 폼의 요구에 따라 값을 가질 쪽을 고르는 기준입니다. 입력 중의 검증과 파생 UI가 필요하면 제어 방식으로 렌더마다 최신 state로 오류와 버튼 상태를 계산하고, 제출하는 순간에만 값을 모으면 DOM 값을 FormData나 ref로 한 번 읽습니다. 파일 선택은 브라우저가 파일 값과 FileList를 관리하므로 제어하는 text 값처럼 다루지 않고, 값과 오류, 방문 여부가 커지는 큰 폼은 폼 라이브러리에 등록과 검증을 맡기되 그 도구가 안에서 어떤 방식을 쓰는지 확인합니다. 필드마다 요구가 다르면 방식을 섞을 수 있지만 한 입력의 방식은 고정합니다.

그림 아래의 두 칸은 흔한 실수를 정리합니다. 제어 방식에서 value만 주고 onChange나 readOnly를 빼면 편집할 수 없고, undefined에서 문자열로 바뀌면 비제어에서 제어로 전환됩니다. 비제어 방식의 defaultValue는 초기값일 뿐이라 나중에 바꿔도 이미 입력 중인 DOM의 현재 값을 제어하지 않으며, 초기화는 폼의 reset()이나 의도적인 다시 마운트로 합니다. 그림의 마지막 설명처럼 제어 방식이 언제나 낫거나 비제어 방식이 언제나 가벼운 것이 아니라, 화면이 현재 입력값을 쓰는 시점과 검증·제출을 맡는 곳을 기준으로 한 입력의 주인을 정합니다.

정리하면, 입력하는 도중의 현재 값으로 React UI를 계산해야 한다면 제어 컴포넌트가 자연스럽고, 제출하는 순간에만 값이 필요하거나 파일 입력처럼 브라우저가 값을 관리한다면 비제어 방식이 더 직접적일 수 있습니다. 필드가 많아 값과 오류, 방문 여부, 제출 상태의 규칙이 커지면 리듀서나 폼 라이브러리도 검토합니다. 라이브러리는 내부적으로 제어나 비제어 등록 방식을 쓸 수 있으므로, 별도의 셋째 값 원천으로 단정하지 말고 그 도구가 값을 어떻게 다루는지 확인합니다. 한 폼 안에서 필드마다 방식을 다르게 고를 수는 있지만, 같은 입력을 렌더 도중에 제어와 비제어 사이에서 바꾸면 안 됩니다.


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

제어 방식으로 여러 입력 필드를 관리할 때 필드마다 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로 전달한다필드별 값의 표현과 소유 계약을 유지한다

위 그림은 여러 입력을 하나의 상태 객체와 공통 처리 함수로 묶을 때, 입력 이벤트에서 한 필드의 새 렌더까지 값이 지나가는 길을 보여 줍니다. 공통 처리 함수는 이벤트의 name과 type을 읽고, checkbox만 checked를, 나머지는 value를 골라 문자열이나 boolean인 nextValue를 만듭니다. 그다음 이전 객체를 펼친 뒤 [name] 키 하나만 바꾼 새 객체로 state를 갱신하고, 다음 렌더가 그 필드의 value나 checked를 다시 넘깁니다. 그림의 캡션이 밝히듯 number의 value는 문자열이고, file은 이 제어 처리 함수가 다루지 않습니다.

그림 아래의 표는 입력의 종류마다 이벤트에서 읽을 값과 state의 표현, 렌더 속성을 정리합니다. text와 select는 state와 렌더 모두 문자열이고, number는 입력하는 동안 문자열을 유지하다가 검증이나 제출 단계에서 숫자로 바꾸며, checkbox는 value가 아니라 선택 여부인 checked를 읽어 다시 넘기고, file은 files를 제출할 때 DOM에서 읽는 비제어 입력으로 둡니다. 마지막 두 칸은 이전 state를 펼친 새 객체로 다른 필드를 보존한다는 점과, 잘못된 name은 새 키를 만들고 모든 입력에서 value만 읽으면 checkbox의 의미가 깨진다는 점을 짚습니다. 공통 처리 함수가 단순하게 유지되려면 name과 입력 종류, state의 표현, 렌더 속성을 한 세트로 맞춰야 합니다.

src/components/MultiInputControlledForm.jsx
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의 키와 같고 입력 종류별 값의 표현이 코드와 맞아야 합니다. text와 number, select는 입력하는 동안 value 문자열을 유지하고 checkbox는 checked boolean을 씁니다. number는 빈 값과 편집 중의 표현을 그대로 두었다가 검증이나 제출 단계에서 바꾸며, 파일 입력은 이 공통 제어 경로에 넣지 않습니다. 처리 함수가 event.target 대신 event.currentTarget을 읽는 것은, 처리 함수를 등록한 바로 그 입력 요소를 가리키기 위해서입니다.


정리

  • 제어 컴포넌트는 React state가 입력값을 가지며, value와 onChange로 입력 중의 검증과 형식 변환, 조건부 UI를 계산하기 쉽습니다.
  • 비제어 컴포넌트는 DOM이 입력값을 가지며, defaultValue로 초기값만 주고 제출할 때 ref나 FormData로 읽습니다.
  • 제어하는 text 입력은 빈 문자열처럼 언제나 유효한 값으로 초기화하고, 한 입력의 방식은 생명주기 내내 바꾸지 않습니다.
  • 비제어 폼의 reset()은 빈 값이 아니라 원래의 defaultValue로 되돌립니다.
  • 여러 입력은 하나의 상태 객체와 name 기반의 공통 처리 함수로 묶되, checkbox는 checked를 읽고 number는 제출 단계에서 숫자로 바꿉니다.

연습 문제

1. 여러 필드의 값과 타입

MultiInputControlledForm을 새로 마운트해 이름(firstName)에 Mina, 나이(age)에 0을 입력하고 뉴스레터(newsletter)를 체크한 뒤, 다른 필드는 건드리지 않고 제출 버튼을 누릅니다. 콘솔 객체에서 세 값과 그 타입을 적고, lastName과 gender가 왜 그대로 남는지 설명해 보세요. 나이를 다시 비우면 숫자 0과 어떻게 구별할까요? name이 실수로 first_name인 입력을 같은 처리 함수에 연결하면 기존 firstName이 바뀔까요?

해설 보기

firstName은 문자열 'Mina', age는 문자열 '0', newsletter는 boolean true입니다. input의 type이 number여도 이 코드가 읽는 value는 문자열입니다. 이전 객체를 펼친 뒤 계산된 키 하나만 바꾸므로 lastName은 빈 문자열, gender는 'male'로 남으며, 제출은 값을 보여 줄 뿐 폼을 초기화하지 않습니다.

나이를 비우면 빈 문자열이 됩니다. Number('')도 0이 되므로, 제출 단계에서 빈 값인지 먼저 검사한 뒤 숫자로 바꿔야 입력하지 않은 것과 0을 구분할 수 있으며, 어떤 범위의 숫자를 허용할지는 업무 규칙으로 정합니다. first_name을 보내면 처리 함수는 새 키 first_name을 더하고 firstName은 그대로 둡니다. 계산된 속성 이름 자체가 잘못된 이름을 거부하지는 않으므로, name을 state의 키와 맞추거나 허용하는 키인지 검사하는 코드를 더해야 합니다.

2. 비제어 폼의 초기화

UncontrolledForm에서 이름을 지워 빈칸으로 만든 뒤 제출했습니다. alert에 보이는 이름과, alert를 닫은 뒤 입력란에 보이는 이름은 각각 무엇일까요? 같은 조작을 ControlledForm에서 하면 어떻게 될까요?

해설 보기

UncontrolledForm의 alert에는 제출하는 순간 DOM이 가진 빈 문자열이 보이고, alert를 닫으면 reset()이 defaultValue를 되살리므로 입력란에는 다시 “홍길동”이 보입니다.

ControlledForm은 처음부터 이름이 빈 문자열이므로 alert에도 빈 이름이 보이고, alert를 닫은 뒤 setName('')이 실행되어 입력란도 계속 비어 있습니다. 두 폼이 초기화하는 기준이 각각 defaultValue와 state의 초기값이기 때문입니다.

3. value만 넘긴 입력

ControlledForm의 이름 입력에서 onChange를 지우고 value={name}만 남기면, 사용자가 키보드로 입력할 때 어떤 일이 생길까요? 개발 모드의 콘솔에는 무엇이 보일까요?

해설 보기

state가 바뀌지 않으므로 React가 렌더할 때마다 입력에 빈 문자열을 다시 넘겨, 키를 눌러도 입력란이 바뀌지 않는 읽기 전용처럼 동작합니다. 개발 모드에서는 onChange 없이 value를 넘겨 읽기 전용 필드가 된다는 경고가 콘솔에 나타나며, 편집할 수 있어야 한다면 defaultValue를, 그렇지 않다면 onChange나 readOnly를 두라고 안내합니다.

다음 글 복합 폼 상태 관리에서는 필드가 많아지고 값과 오류, 제출 상태가 함께 바뀌는 폼의 상태를 관리하는 방법을 알아봅니다.