지난 시간까지 우리는 리액트의 핵심 빌딩 블록을 모두 살펴봤습니다. 컴포넌트, props, state, 이벤트, 조건부/리스트 렌더링까지 다 다뤘죠. 이번 글부터는 좀 더 실전적인 패턴으로 들어갑니다. 첫 번째 주제는 거의 모든 앱에 등장하는 폼(form) 처리입니다.
입력 요소를 다루는 두 가지 방식
리액트에서 입력 요소를 다루는 방식은 크게 두 가지로 나뉩니다.
- Controlled Component (제어 컴포넌트) — 입력 값의 진실의 원천을 리액트 state에 두고, 화면의 입력은 그 state를 따라가게 하는 방식
- Uncontrolled Component (비제어 컴포넌트) — 입력 값을 DOM 자체에 맡기고, 필요할 때
ref로 꺼내 쓰는 방식
리액트에서는 Controlled Component가 정석이고, 우리도 거기에 집중합니다. 비제어 방식은 후반부 ref를 다룰 때 다시 짚을 기회가 있습니다.
Controlled Component란
이미 #6, #8에서 사용했던 패턴입니다. value와 onChange를 한 쌍으로 묶어주는 게 핵심입니다.
import { useState } from 'react';
function SimpleInput() {
const [text, setText] = useState('');
return (
<div>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<p>입력값: {text}</p>
</div>
);
}
export default SimpleInput;흐름은 다음과 같습니다.
- 사용자가 키를 입력 → 브라우저가
change이벤트 발생 onChange핸들러가e.target.value로 새 값을 꺼내setText로 state에 반영- state가 바뀌면 컴포넌트가 다시 렌더링됨
- 새로 렌더링될 때
<input value={text}>가 갱신된 값을 화면에 표시
겉으로 보면 그냥 입력하는 대로 글자가 보이는 평범한 입력창인데, 내부적으로는 state를 거쳐서 다시 화면에 그려지는 한 사이클이 매번 돌고 있는 거예요. 이게 controlled component입니다.
왜 이렇게 번거롭게?
"브라우저가 알아서 입력값을 기억할 텐데 굳이 state로 받아서 다시 그려야 하나?"라고 생각할 수 있습니다. 맞습니다 — 그냥 두면 브라우저가 알아서 값을 보관해줍니다(이게 uncontrolled). 그런데 controlled로 두면 얻는 게 많습니다.
- 실시간 가공/검증이 쉬워진다 (입력 길이 제한, 자동 대문자화, 형식 검증 등)
- 두 입력 필드가 연동되도록 만들 수 있다 (한쪽 변경 시 다른 쪽 자동 갱신)
- state로 입력값을 다른 컴포넌트와 공유할 수 있다 (다음 #11에서 다룰 패턴)
- 제출 버튼 활성화 조건을 입력 상태로 즉시 표현할 수 있다 (이미 #7에서 봤죠)
대부분의 폼 시나리오에서 controlled가 더 직관적이고 강력합니다.
textarea
textarea도 input처럼 value/onChange를 사용합니다. HTML에서는 <textarea>여기 텍스트</textarea>처럼 자식으로 값을 넣었지만, 리액트에서는 value 속성으로 다룹니다.
import { useState } from 'react';
function MemoForm() {
const [memo, setMemo] = useState('');
return (
<textarea
value={memo}
onChange={(e) => setMemo(e.target.value)}
rows={5}
/>
);
}
export default MemoForm;select
드롭다운(<select>) 역시 동일한 패턴입니다. 선택된 옵션의 value가 e.target.value로 들어옵니다.
import { useState } from 'react';
function CategoryPicker() {
const [category, setCategory] = useState('frontend');
return (
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="frontend">프론트엔드</option>
<option value="backend">백엔드</option>
<option value="devops">데브옵스</option>
</select>
);
}
export default CategoryPicker;각 <option>의 value와 state 값이 매칭됩니다. 초기 state('frontend')가 곧 처음에 선택된 옵션이 됩니다.
checkbox
체크박스는 값이 문자열이 아니라 **체크 여부(boolean)**입니다. 그래서 value 대신 **checked**를, e.target.value 대신 **e.target.checked**를 사용합니다.
import { useState } from 'react';
function AgreeCheckbox() {
const [agreed, setAgreed] = useState(false);
return (
<label>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
약관에 동의합니다
</label>
);
}
export default AgreeCheckbox;radio
라디오 버튼은 여러 개 중 하나를 선택하는 그룹입니다. 같은 name으로 묶고, checked는 state === 해당 옵션 값으로 표현합니다.
import { useState } from 'react';
function PaymentRadio() {
const [payment, setPayment] = useState('card');
return (
<div>
<label>
<input
type="radio"
name="payment"
value="card"
checked={payment === 'card'}
onChange={(e) => setPayment(e.target.value)}
/>
카드
</label>
<label style={{ marginLeft: '12px' }}>
<input
type="radio"
name="payment"
value="bank"
checked={payment === 'bank'}
onChange={(e) => setPayment(e.target.value)}
/>
계좌이체
</label>
</div>
);
}
export default PaymentRadio;여러 필드를 한 객체로 관리하기
폼 필드가 많아지면 useState를 여러 번 쓰는 게 번거로워집니다. 한 객체에 묶어두는 패턴도 자주 보입니다.
import { useState } from 'react';
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
password: '',
agreed: false,
});
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
return (
<form>
<input name="name" value={form.name} onChange={handleChange} placeholder="이름" />
<input name="email" value={form.email} onChange={handleChange} placeholder="이메일" />
<input name="password" type="password" value={form.password} onChange={handleChange} placeholder="비밀번호" />
<label>
<input name="agreed" type="checkbox" checked={form.agreed} onChange={handleChange} />
약관 동의
</label>
</form>
);
}
export default SignupForm;핵심 트릭 두 가지입니다.
- 각 입력 요소에
name속성을 줘서 어떤 필드인지 구분 setForm(prev => ({ ...prev, [name]: ... }))로 객체의 일부만 갱신 (#5에서 배운 패턴)
핸들러 하나로 여러 필드를 처리할 수 있어 코드가 깔끔합니다. 다만 필드별 검증 로직 같은 게 복잡해지면 다시 useState를 분리하는 쪽이 나아질 수도 있습니다. 정해진 답은 없고 상황에 맞게 선택하시면 됩니다.
폼 제출 처리
#6에서 다룬 내용 복습입니다. <form>의 onSubmit을 사용하고, e.preventDefault()로 페이지 새로고침을 막는 것이 정석입니다.
function handleSubmit(e) {
e.preventDefault();
if (!form.agreed) {
alert('약관에 동의해주세요.');
return;
}
console.log('가입 정보:', form);
// 실제로는 여기서 서버에 전송
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
<button type="submit">가입</button>
</form>
);<button type="submit">을 누르거나 입력창에서 엔터를 치면 폼이 제출됩니다. **버튼의 기본 type은 submit**이므로 굳이 명시하지 않아도 폼 안에서는 제출 버튼으로 동작하지만, 헷갈리지 않게 명시하는 습관을 들이는 편이 좋습니다.
입력값 정제하기
controlled의 강점 중 하나는 입력값을 자유롭게 가공할 수 있다는 것입니다. set 직전에 손을 대면 됩니다.
<input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
/><input
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
/><input
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, 100))}
/>화면에 보이는 값과 state 값이 항상 일치하므로, 사용자가 보는 즉시 결과가 반영됩니다.
직접 해보기
가입 폼을 만들어봅니다. 여러 종류의 입력 요소를 한 화면에 모은 종합 예제입니다.
src/SignupForm.jsx:
import { useState } from 'react';
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
age: '',
gender: 'female',
interests: {
frontend: false,
backend: false,
design: false,
},
bio: '',
agreed: false,
});
const [submitted, setSubmitted] = useState(null);
function handleChange(e) {
const { name, type, value, checked } = e.target;
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
}
function handleInterestChange(e) {
const { name, checked } = e.target;
setForm(prev => ({
...prev,
interests: { ...prev.interests, [name]: checked },
}));
}
function handleSubmit(e) {
e.preventDefault();
if (!form.agreed) {
alert('약관에 동의해주세요.');
return;
}
setSubmitted(form);
}
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px', maxWidth: '400px' }}>
<h2>회원 가입</h2>
<form onSubmit={handleSubmit}>
<div>
<label>이름: </label>
<input name="name" value={form.name} onChange={handleChange} />
</div>
<div>
<label>이메일: </label>
<input name="email" type="email" value={form.email} onChange={handleChange} />
</div>
<div>
<label>나이: </label>
<input
name="age"
type="text"
value={form.age}
onChange={(e) => setForm(prev => ({
...prev,
age: e.target.value.replace(/\D/g, ''),
}))}
/>
</div>
<div>
<label>성별: </label>
<label>
<input type="radio" name="gender" value="female" checked={form.gender === 'female'} onChange={handleChange} />
여성
</label>
<label style={{ marginLeft: '8px' }}>
<input type="radio" name="gender" value="male" checked={form.gender === 'male'} onChange={handleChange} />
남성
</label>
</div>
<div>
<label>관심 분야: </label>
<label>
<input type="checkbox" name="frontend" checked={form.interests.frontend} onChange={handleInterestChange} />
프론트엔드
</label>
<label style={{ marginLeft: '8px' }}>
<input type="checkbox" name="backend" checked={form.interests.backend} onChange={handleInterestChange} />
백엔드
</label>
<label style={{ marginLeft: '8px' }}>
<input type="checkbox" name="design" checked={form.interests.design} onChange={handleInterestChange} />
디자인
</label>
</div>
<div>
<label>자기소개: </label>
<textarea name="bio" value={form.bio} onChange={handleChange} rows={3} />
</div>
<div>
<label>
<input type="checkbox" name="agreed" checked={form.agreed} onChange={handleChange} />
약관에 동의합니다
</label>
</div>
<button type="submit" disabled={!form.agreed} style={{ marginTop: '8px' }}>
가입
</button>
</form>
{submitted && (
<pre style={{ marginTop: '16px', background: '#f4f4f4', padding: '8px' }}>
{JSON.stringify(submitted, null, 2)}
</pre>
)}
</div>
);
}
export default SignupForm;src/App.jsx에 연결합니다.
import SignupForm from './SignupForm';
function App() {
return <SignupForm />;
}
export default App;여러 종류의 입력 요소가 모두 controlled로 동작합니다. 나이 입력은 자동으로 숫자만 받고, 약관에 동의해야만 제출 버튼이 활성화됩니다. 제출하면 입력 결과가 JSON으로 화면에 출력됩니다.
실무에서 더 큰 폼을 다룰 때는 React Hook Form이나 Formik 같은 폼 라이브러리를 쓰는 경우가 많습니다. 검증, 에러 메시지, 성능 최적화 등을 미리 구현해두고 있어서 큰 폼에서는 코드량을 크게 줄여줍니다. 다만 기초 강좌인 이 시리즈에서는 라이브러리 없이 리액트의 기본 기능만 사용합니다 — 라이브러리도 결국 controlled 패턴 위에서 동작하는 것이라, 기본을 익혀두면 어떤 라이브러리도 빠르게 익힐 수 있습니다.
마무리
이번 글에서는 폼을 다루는 정석인 controlled component 패턴을 살펴봤습니다. 핵심은:
value/onChange한 쌍으로 입력을 state와 묶는다- 체크박스는
checked/e.target.checked, 라디오는name+value+checked={state===값}패턴 - 여러 필드는 객체 하나로 묶고
name속성으로 구분하는 패턴이 흔하다 - 제출은
<form onSubmit>+e.preventDefault() - controlled의 장점은 실시간 가공/검증/연동
지금까지 우리가 만든 컴포넌트들은 모두 자기 안에서 모든 일이 시작되고 끝났습니다. 그런데 실제 앱에서는 외부 세계와 상호작용이 필요합니다. 서버에서 데이터를 가져오고, 타이머를 설정하고, 브라우저 API를 사용하는 등의 작업이죠. 다음 글인 "리액트 기초 강좌 #10 useEffect"에서는 이런 side effect를 처리하는 표준 도구인 useEffect 훅을 배워보도록 하겠습니다.