본문으로 건너뛰기
Advertisement

이벤트 처리 — 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 stateDOM
값 즉시 접근✅ 언제든지❌ 이벤트 시에만
실시간 검증✅ 쉬움❌ 복잡
파일 입력❌ 불가✅ 필수
성능렌더링 발생렌더링 없음

키보드 이벤트

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 뒤에 검색"
/>
);
}
Advertisement