이벤트 처리 — SyntheticEvent와 폼 다루기
SyntheticEvent 시스템
React는 브라우저의 네이티브 이벤트를 SyntheticEvent로 감쌉니다. SyntheticEvent는 W3C 사양을 기반으로 모든 브라우저에서 동일하게 동작하며, 네이티브 이벤트와 동일한 인터페이스를 제공합니다.
function ClickExample() {
function handleClick(event) {
// SyntheticEvent 객체
console.log(event.type); // 'click'
console.log(event.target); // 클릭된 DOM 요소
console.log(event.currentTarget); // 핸들러가 바인딩된 요소
console.log(event.nativeEvent); // 브라우저 네이티브 이벤트
event.preventDefault(); // 기본 동작 방지
event.stopPropagation(); // 이벤트 전파 중단
}
return <button onClick={handleClick}>클릭</button>;
}
React 17 이후 이벤트 위임 변경
React 16까지는 모든 이벤트를 document에 위임했습니다. React 17부터는 React 루트 컨테이너에 위임합니다. 이로 인해 여러 React 앱을 한 페이지에 마운트해도 이벤트 충돌이 없습니다.
이벤트 핸들러 패턴
인라인 핸들러
function App() {
return (
<button onClick={() => console.log('클릭됨')}>
클릭
</button>
);
}
별도 함수로 정의
function App() {
function handleClick() {
console.log('클릭됨');
}
return <button onClick={handleClick}>클릭</button>;
// ✅ onClick={handleClick} — 함수 참조 전달
// ❌ onClick={handleClick()} — 즉시 실행됨 (함수 호출)
}
매개변수 전달
function ProductList({ products }) {
function handleDelete(id, event) {
event.stopPropagation();
console.log(`상품 ${id} 삭제`);
}
return (
<ul>
{products.map(product => (
<li key={product.id} onClick={() => console.log('항목 클릭')}>
{product.name}
<button onClick={(e) => handleDelete(product.id, e)}>삭제</button>
</li>
))}
</ul>
);
}
주요 이벤트 목록
function EventExamples() {
return (
<div
// 마우스 이벤트
onClick={() => {}}
onDoubleClick={() => {}}
onMouseEnter={() => {}}
onMouseLeave={() => {}}
onMouseMove={(e) => console.log(e.clientX, e.clientY)}
// 드래그 이벤트
onDragStart={() => {}}
onDrop={() => {}}
>
<input
// 폼 이벤트
onChange={(e) => console.log(e.target.value)}
onFocus={() => {}}
onBlur={() => {}}
onKeyDown={(e) => {}}
onKeyUp={(e) => {}}
/>
<form
onSubmit={(e) => e.preventDefault()}
>
</form>
</div>
);
}
제어 컴포넌트 (Controlled Component)
React state가 폼의 "진실의 원천(single source of truth)"이 되는 패턴입니다.
import { useState } from 'react';
function LoginForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
});
const [errors, setErrors] = useState({});
function handleChange(e) {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// 입력 시 해당 필드 에러 초기화
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
}
function validate() {
const newErrors = {};
if (!formData.email.includes('@')) {
newErrors.email = '유효한 이메일을 입력하세요.';
}
if (formData.password.length < 8) {
newErrors.password = '비밀번호는 8자 이상이어야 합니다.';
}
return newErrors;
}
function handleSubmit(e) {
e.preventDefault();
const newErrors = validate();
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
console.log('로그인 시도:', formData.email);
// API 호출...
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">이메일</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="password">비밀번호</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
/>
{errors.password && <span className="error">{errors.password}</span>}
</div>
<button type="submit">로그인</button>
</form>
);
}
비제어 컴포넌트 (Uncontrolled Component)
DOM이 폼 데이터를 관리하고, React는 필요할 때만 값을 읽습니다.
import { useRef } from 'react';
function FileUploadForm() {
const fileRef = useRef(null);
const titleRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
const file = fileRef.current.files[0];
const title = titleRef.current.value;
console.log('파일:', file.name, '제목:', title);
}
return (
<form onSubmit={handleSubmit}>
<input ref={titleRef} type="text" placeholder="파일 설명" />
<input ref={fileRef} type="file" accept="image/*" />
<button type="submit">업로드</button>
</form>
);
}
제어 vs 비제어 비교
| 특성 | 제어 컴포넌트 | 비제어 컴포넌트 |
|---|---|---|
| 데이터 위치 | React state | DOM |
| 값 즉시 접근 | ✅ 언제든지 | ❌ 이벤트 시에만 |
| 실시간 검증 | ✅ 쉬움 | ❌ 복잡 |
| 파일 입력 | ❌ 불가 | ✅ 필수 |
| 성능 | 렌더링 발생 | 렌더링 없음 |
키보드 이벤트
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
function handleKeyDown(e) {
switch (e.key) {
case 'Enter':
onSearch(query);
break;
case 'Escape':
setQuery('');
break;
case 'ArrowUp':
e.preventDefault();
// 이전 검색어로 이동
break;
}
}
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="검색 (Enter로 검색, Esc로 지우기)"
/>
);
}
이벤트 버블링 제어
function Modal({ onClose, children }) {
return (
<div
className="backdrop"
onClick={onClose} // 배경 클릭 시 닫기
>
<div
className="modal-content"
onClick={e => e.stopPropagation()} // 모달 내용 클릭 시 버블링 중단
>
{children}
</div>
</div>
);
}
실전 예제: 다단계 폼
import { useState } from 'react';
const STEPS = ['기본 정보', '주소', '결제 정보'];
function MultiStepForm() {
const [step, setStep] = useState(0);
const [formData, setFormData] = useState({
name: '', email: '', phone: '',
address: '', city: '', zipCode: '',
cardNumber: '', cardExpiry: '',
});
function handleChange(e) {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
}
function handleNext(e) {
e.preventDefault();
setStep(prev => Math.min(prev + 1, STEPS.length - 1));
}
function handlePrev() {
setStep(prev => Math.max(prev - 1, 0));
}
function handleSubmit(e) {
e.preventDefault();
console.log('최종 제출:', formData);
}
return (
<div className="multistep-form">
{/* 진행 표시 */}
<div className="steps">
{STEPS.map((label, i) => (
<span key={i} className={i <= step ? 'active' : ''}>
{i + 1}. {label}
</span>
))}
</div>
<form onSubmit={step === STEPS.length - 1 ? handleSubmit : handleNext}>
{/* Step 0: 기본 정보 */}
{step === 0 && (
<div>
<h2>기본 정보</h2>
<input name="name" value={formData.name} onChange={handleChange}
placeholder="이름" required />
<input name="email" type="email" value={formData.email} onChange={handleChange}
placeholder="이메일" required />
<input name="phone" value={formData.phone} onChange={handleChange}
placeholder="전화번호" />
</div>
)}
{/* Step 1: 주소 */}
{step === 1 && (
<div>
<h2>배송 주소</h2>
<input name="address" value={formData.address} onChange={handleChange}
placeholder="도로명 주소" required />
<input name="city" value={formData.city} onChange={handleChange}
placeholder="도시" required />
<input name="zipCode" value={formData.zipCode} onChange={handleChange}
placeholder="우편번호" />
</div>
)}
{/* Step 2: 결제 */}
{step === 2 && (
<div>
<h2>결제 정보</h2>
<input name="cardNumber" value={formData.cardNumber} onChange={handleChange}
placeholder="카드 번호" required maxLength={19} />
<input name="cardExpiry" value={formData.cardExpiry} onChange={handleChange}
placeholder="MM/YY" required maxLength={5} />
</div>
)}
<div className="form-actions">
{step > 0 && (
<button type="button" onClick={handlePrev}>이전</button>
)}
<button type="submit">
{step === STEPS.length - 1 ? '주문 완료' : '다음'}
</button>
</div>
</form>
</div>
);
}
고수 팁
1. React Hook Form 도입
실전에서는 react-hook-form 또는 formik을 사용하면 제어 컴포넌트의 성능 문제와 검증 로직을 모두 해결할 수 있습니다.
import { useForm } from 'react-hook-form';
function RegisterForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: '이메일은 필수입니다',
pattern: { value: /\S+@\S+/, message: '유효한 이메일 형식이 아닙니다' },
})}
/>
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">등록</button>
</form>
);
}
2. 이벤트 핸들러 디바운싱
import { useCallback } from 'react';
function useDebounce(fn, delay) {
const timerRef = useRef(null);
return useCallback((...args) => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => fn(...args), delay);
}, [fn, delay]);
}
function SearchInput({ onSearch }) {
const debouncedSearch = useDebounce(onSearch, 300);
return (
<input
onChange={e => debouncedSearch(e.target.value)}
placeholder="입력 후 300ms 뒤에 검색"
/>
);
}