제어 컴포넌트와 비제어 컴포넌트
폼 값을 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로 저장합니다.
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로 현재 값을 읽습니다.
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)으로 고른 뒤 제출한다고 가정합니다. 브라우저의 이메일 형식 검사도 통과하는 입력입니다.
| 시점 | ControlledForm | UncontrolledForm |
|---|---|---|
| 첫 화면 | 빈 이름, 빈 이메일, good | 홍길동, [email protected], good |
| 사용자가 세 값을 바꿈 | state 갱신 뒤 입력에 Mina, [email protected], bad 전달 | 현재 DOM에 같은 값 보관 |
| 제출 처리 함수가 읽는 값 | 현재 렌더의 name, email, feedback | 세 ref의 current.value |
| alert를 닫은 뒤의 초기화 | 세 setter로 빈 값, 빈 값, good | form.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 컴포넌트의 state | DOM 자체 |
| 구현 방식 | value 속성과 onChange 이벤트 처리 함수 | ref 속성과 defaultValue(초기값) |
| 데이터 흐름 | 이벤트 → state → value나 checked로 다시 렌더 | DOM이 보관하고 제출할 때 FormData나 ref로 읽음 |
| 유효성 검사 | 입력 중의 React UI와 파생 상태를 연결하기 쉬움 | 브라우저의 기본 검증이나 제출할 때의 검증에 자연스러움 |
| 복잡성 | 입력 필드마다 상태와 처리 함수가 필요해 코드가 늘어남 | 간단한 폼에서는 코드가 적음 |
| 쓰는 때 | 입력 중의 값으로 검증, 형식 변환, 조건부 UI를 계산할 때 | 제출 시점의 수집, 파일 입력, DOM 등록형 도구를 쓸 때 |
React · Form ownership
입력값의 소유자와 읽기 시점으로 폼 방식 고르기
제어와 비제어의 차이는 입력값을 누가 소유하고 언제 읽는가입니다. 입력 중 값으로 UI를 계산하면 React state를, 제출 순간만 읽으면 DOM·FormData·ref를 우선 검토합니다.
React state가 현재 값을 소유한다
value또는checked를 state에서 전달onChange에서 다음 값을 계산해 state 갱신새 state로 입력, 오류, 제출 가능 UI를 다시 렌더
입력 중 포매팅, 조건부 UI, 동기 검증처럼 현재 값이 렌더에 필요할 때 적합합니다.
DOM이 현재 값을 소유한다
defaultValue·defaultChecked로 초기값만 전달사용자 입력 뒤 현재 값은 DOM에 유지
제출 시
FormData나 ref로 필요한 값을 읽음
제출 시점 수집, 네이티브 폼, 파일 입력, uncontrolled 등록형 라이브러리에 자연스럽습니다.
| 요구 | 우선 선택 | 이유와 경계 |
|---|---|---|
| 입력 중 검증·파생 UI | Controlled | 렌더마다 최신 state로 오류와 버튼 상태를 계산 |
| 제출 순간 값 수집 | Uncontrolled | DOM 값을 FormData 또는 ref로 한 번 읽음 |
| 파일 선택 | Uncontrolled | 브라우저가 파일 값과 FileList를 관리 |
| 큰 폼의 값·오류·방문 상태 | Form library | 등록·검증·오류 연결을 맡기고 도구 내부의 controlled/uncontrolled 계약 확인 |
| 필드별 요구가 다름 | Intentional hybrid | 필드 단위 소유권은 섞을 수 있지만 한 입력의 모드는 고정 |
Controlled
입력 중 검증·포매팅·제출 가능 여부를 state에서 계산합니다.
Uncontrolled
제출 순간 DOM 값을 FormData나 ref로 읽습니다.
브라우저 소유
파일 값은 controlled text value처럼 다루지 않습니다.
일관된 library 모델
값·오류·방문·제출 상태가 커지면 전용 도구를 검토하고 내부의 controlled/uncontrolled 연결 계약을 확인합니다.
필드마다 의도적으로
필드별 방식은 다를 수 있지만 같은 입력을 중간에 전환하지 않습니다.
모드를 중간에 바꾸지 않는다
value만 주고onChange나readOnly를 빼면 편집할 수 없음undefined에서 문자열로 바꾸면 uncontrolled→controlled 전환
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 객체/다음 제어 값을 실제 필드 사례로 추적할 가치가 있다.
React · Multi-field controlled form
여러 입력을 name·값 표현·새 객체로 연결하기
공통 핸들러는 name을 state key 계약으로 사용하고 입력 종류별 값을 먼저 정규화합니다. 이전 객체를 펼친 뒤 계산된 key 하나만 바꾸면 다른 필드를 잃지 않습니다.
DOM 이벤트에서 다음 controlled render까지
입력의
name과type을 읽는다name은formData의 key와 정확히 일치해야 합니다.value또는checked를 정규화한다text·number·select의 문자열과 checkbox의 boolean을 구분합니다.
functional update로 이전 state를 기준 삼는다
이전 객체를 펼치고
[name]key만nextValue로 바꿉니다.해당 필드의
value·checked를 다시 전달한다React state와 화면 값이 한 방향으로 동기화됩니다.
| 입력 | 이벤트에서 읽기 | 권장 state 값 | 렌더 속성 |
|---|---|---|---|
| text · select | value | 문자열 | value |
| number | value | 입력 중 문자열, 경계에서 숫자 변환 | value |
| checkbox | checked | boolean | checked |
| file | files | 제출 시 DOM에서 읽기 | uncontrolled |
value 문자열
state와 렌더 모두 문자열을 사용합니다.
입력 문자열을 유지
빈 값과 편집 중 표현을 보존하고 검증·제출 경계에서 숫자로 바꿉니다.
checked boolean
value가 아니라 선택 여부를 읽고 다시 전달합니다.
DOM에서 files 읽기
파일 선택은 controlled value로 만들지 않습니다.
기존 필드를 보존한다
이전 state를 펼친 새 객체로 교체해 다른 필드를 보존하고 한 key만 갱신합니다.
이름과 타입을 숨기지 않는다
잘못된 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 객체/다음 제어 값을 실제 필드 사례로 추적할 가치가 있다.
React · Multi-field controlled form
여러 입력을 name·값 표현·새 객체로 연결하기
공통 핸들러는 name을 state key 계약으로 사용하고 입력 종류별 값을 먼저 정규화합니다. 이전 객체를 펼친 뒤 계산된 key 하나만 바꾸면 다른 필드를 잃지 않습니다.
DOM 이벤트에서 다음 controlled render까지
입력의
name과type을 읽는다name은formData의 key와 정확히 일치해야 합니다.value또는checked를 정규화한다text·number·select의 문자열과 checkbox의 boolean을 구분합니다.
functional update로 이전 state를 기준 삼는다
이전 객체를 펼치고
[name]key만nextValue로 바꿉니다.해당 필드의
value·checked를 다시 전달한다React state와 화면 값이 한 방향으로 동기화됩니다.
| 입력 | 이벤트에서 읽기 | 권장 state 값 | 렌더 속성 |
|---|---|---|---|
| text · select | value | 문자열 | value |
| number | value | 입력 중 문자열, 경계에서 숫자 변환 | value |
| checkbox | checked | boolean | checked |
| file | files | 제출 시 DOM에서 읽기 | uncontrolled |
value 문자열
state와 렌더 모두 문자열을 사용합니다.
입력 문자열을 유지
빈 값과 편집 중 표현을 보존하고 검증·제출 경계에서 숫자로 바꿉니다.
checked boolean
value가 아니라 선택 여부를 읽고 다시 전달합니다.
DOM에서 files 읽기
파일 선택은 controlled value로 만들지 않습니다.
기존 필드를 보존한다
이전 state를 펼친 새 객체로 교체해 다른 필드를 보존하고 한 key만 갱신합니다.
이름과 타입을 숨기지 않는다
잘못된 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의 표현, 렌더 속성을 한 세트로 맞춰야 합니다.
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를 두라고 안내합니다.
다음 글 복합 폼 상태 관리에서는 필드가 많아지고 값과 오류, 제출 상태가 함께 바뀌는 폼의 상태를 관리하는 방법을 알아봅니다.