리액트 폼 다루기: Controlled Component와 Uncontrolled Component 가이드
들어가며
리액트로 개발을 하다 보면 폼(Form)을 다루는 일은 피할 수 없습니다. 사용자 입력을 받고, 그 데이터를 처리하는 과정은 대부분의 웹 애플리케이션에서 핵심적인 부분이죠. 하지만 리액트에서 폼을 다루는 방식에는 크게 두 가지 패턴이 있습니다.
Controlled Component(제어 컴포넌트)와 Uncontrolled Component(비제어 컴포넌트)입니다.
이 글에서는 두 패턴의 차이점을 명확히 이해하고, 언제 어떤 방식을 선택해야 하는지 알아보겠습니다.
Controlled Component와 Uncontrolled Component란?
리액트에서 폼 요소를 다루는 방식의 핵심적인 차이는 "누가 데이터를 관리하는가?"에 있습니다.
Controlled Component (제어 컴포넌트)
Controlled Component는 리액트의 상태(state)가 폼 데이터의 "단일 출처(Single Source of Truth)"가 되는 방식입니다. 즉, 폼에 입력되는 모든 데이터는 리액트 컴포넌트의 상태에 의해 제어됩니다.
핵심 특징:
- 리액트의 상태(state)가 입력 요소의 값을 제어
- 입력 값이 변경될 때마다 리액트 상태도 업데이트
- 사용자 입력이 즉시 유효성 검사 등의 로직에 반영 가능
Uncontrolled Component (비제어 컴포넌트)
Uncontrolled Component는 폼 데이터가 DOM 자체에 의해 처리되는 방식입니다. 리액트가 아닌 DOM이 데이터를 관리하며, 필요할 때만 DOM에서 데이터를 가져옵니다.
핵심 특징:
- DOM이 폼 데이터를 관리
ref
를 사용하여 필요할 때 DOM에서 값을 가져옴 (또는 이벤트 핸들러 등)- 폼 제출 시점에만 값을 확인하는 경우 유용
이제 각 방식의 구현 방법과 사용 사례를 자세히 살펴보겠습니다.
Controlled Component 구현하기
Controlled Component는 리액트의 상태(state)를 통해 폼 요소의 값을 관리합니다. 다음은 간단한 입력 필드를 Controlled Component로 구현한 예제입니다:
import React, { useState } from 'react';
function ControlledForm() {
// 상태를 통해 입력 값 관리
const [inputValue, setInputValue] = useState('');
// 입력 값이 변경될 때마다 상태 업데이트
const handleChange = (event) => {
setInputValue(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
console.log('제출된 값:', inputValue);
// 폼 처리 로직...
};
return (
<form onSubmit={handleSubmit}>
<label>
이름:
<input
type="text"
value={inputValue} // 상태를 input의 value로 설정
onChange={handleChange} // 변경 시 상태 업데이트
/>
</label>
<button type="submit">제출</button>
{/* 현재 입력 값을 즉시 활용할 수 있음 */}
<p>현재 입력 값: {inputValue}</p>
</form>
);
}
여기서 주목할 점은:
useState
훅을 사용하여 입력 값을 상태로 관리합니다.input
요소의value
속성에 상태 값을 연결합니다.onChange
이벤트 핸들러에서 입력 값이 변경될 때마다 상태를 업데이트합니다.- 현재 입력 값을 즉시 다른 UI 요소에서 사용할 수 있습니다.
이 방식의 장점은 입력 값을 즉시 유효성 검사하거나 특정 형식으로 변환할 수 있다는 것입니다.
Uncontrolled Component 구현하기
Uncontrolled Component는 DOM이 폼 데이터를 관리하고, ref
를 사용하여 필요할 때 값을 가져옵니다:
import React, { useRef } from 'react';
function UncontrolledForm() {
// ref를 사용하여 DOM 요소에 접근
const inputRef = useRef();
const handleSubmit = (event) => {
event.preventDefault();
// 폼 제출 시점에 ref를 통해 값 가져오기
const inputValue = inputRef.current.value;
console.log('제출된 값:', inputValue);
// 폼 처리 로직...
};
return (
<form onSubmit={handleSubmit}>
<label>
이름:
<input
type="text"
defaultValue="" // 초기값 설정 (선택 사항)
ref={inputRef} // ref 연결
/>
</label>
<button type="submit">제출</button>
{/* 현재 입력 값을 즉시 보여줄 수 없음 */}
</form>
);
}
여기서 주목할 점은:
useRef
훅을 사용하여 DOM 요소에 접근합니다.input
요소에ref
를 연결합니다.onChange
핸들러가 없으며, 리액트 상태와 연결되지 않습니다.- 폼 제출 시점에만
inputRef.current.value
를 통해 값을 가져옵니다. - 선택적으로
defaultValue
를 사용하여 초기값을 설정할 수 있습니다.
폼 요소별 제어/비제어 컴포넌트 예제
다양한 폼 요소에 대한 제어/비제어 컴포넌트 구현을 비교해보겠습니다.
체크박스
Controlled 체크박스:
import React, { useState } from 'react';
function ControlledCheckbox() {
const [isChecked, setIsChecked] = useState(false);
return (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)}
/>
동의합니다
{isChecked && <p>동의해주셔서 감사합니다!</p>}
</label>
);
}
Uncontrolled 체크박스:
import React, { useRef } from 'react';
function UncontrolledCheckbox() {
const checkboxRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
console.log('체크 상태:', checkboxRef.current.checked);
};
return (
<form onSubmit={handleSubmit}>
<label>
<input
type="checkbox"
ref={checkboxRef}
defaultChecked={false}
/>
동의합니다
</label>
<button type="submit">확인</button>
</form>
);
}
선택 목록 (Select)
Controlled Select:
import React, { useState } from 'react';
function ControlledSelect() {
const [selectedOption, setSelectedOption] = useState('option1');
return (
<div>
<select
value={selectedOption}
onChange={(e) => setSelectedOption(e.target.value)}
>
<option value="option1">옵션 1</option>
<option value="option2">옵션 2</option>
<option value="option3">옵션 3</option>
</select>
<p>선택된 옵션: {selectedOption}</p>
</div>
);
}
Uncontrolled Select:
import React, { useRef } from 'react';
function UncontrolledSelect() {
const selectRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
console.log('선택된 옵션:', selectRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<select ref={selectRef} defaultValue="option1">
<option value="option1">옵션 1</option>
<option value="option2">옵션 2</option>
<option value="option3">옵션 3</option>
</select>
<button type="submit">확인</button>
</form>
);
}
복잡한 폼 다루기: 여러 입력 필드
Controlled Component로 여러 입력 필드 다루기
여러 입력 필드를 포함한 폼을 다룰 때 Controlled Component의 장점이 더 분명하게 드러납니다:
import React, { useState } from 'react';
function ControlledForm() {
// 객체로 여러 입력 필드의 값 관리
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
});
// 모든 입력 필드에 대해 단일 핸들러 사용
const handleChange = (event) => {
const { name, value } = event.target;
setFormData({
...formData, // 기존 데이터 유지
[name]: value, // 변경된 필드만 업데이트
});
};
// 입력 값의 유효성 검사
const isEmailValid = formData.email.includes('@');
const isPasswordValid = formData.password.length >= 8;
const handleSubmit = (event) => {
event.preventDefault();
// 모든 유효성 검사 통과 시에만 제출
if (isEmailValid && isPasswordValid) {
console.log('제출된 데이터:', formData);
// API 호출 등 추가 로직...
} else {
alert('입력 값을 확인해주세요.');
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>
사용자명:
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
/>
</label>
</div>
<div>
<label>
이메일:
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
</label>
{formData.email && !isEmailValid && (
<p style={{ color: 'red' }}>유효한 이메일 주소를 입력하세요.</p>
)}
</div>
<div>
<label>
비밀번호:
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
/>
</label>
{formData.password && !isPasswordValid && (
<p style={{ color: 'red' }}>비밀번호는 8자 이상이어야 합니다.</p>
)}
</div>
<button
type="submit"
disabled={!isEmailValid || !isPasswordValid}
>
가입하기
</button>
</form>
);
}
이 예제에서는:
- 모든 입력 필드의 값을 하나의 객체로 관리합니다.
- 객체 구조 분해와 계산된 프로퍼티 이름을 사용하여 모든 입력 필드에 대해 단일 핸들러를 구현합니다.
- 입력 값을 기반으로 실시간 유효성 검사를 수행합니다.
- 조건부 렌더링을 통해 유효성 검사 결과를 즉시 사용자에게 보여줍니다.
- 유효하지 않은 데이터가 있을 경우 제출 버튼을 비활성화합니다.
Controlled Component vs Uncontrolled Component: 언제 무엇을 선택해야 할까?
두 방식 모두 장단점이 있습니다. 상황에 따라 적절한 방식을 선택하는 것이 중요합니다.
Controlled Component가 적합한 경우
- 실시간 유효성 검사가 필요한 경우
- 입력 값에 따라 동적으로 UI를 변경해야 하는 경우
- 입력 값을 특정 형식으로 변환해야 하는 경우 (예: 자동 포맷팅)
- 폼 제출 전에 값을 조작해야 하는 경우
- 여러 입력 필드가 서로 의존하는 경우
Uncontrolled Component가 적합한 경우
- 폼이 단순하고 유효성 검사가 간단한 경우
- 초기 구현이 빠르고 간단해야 하는 경우
- 제출 시점에만 값이 필요한 경우
- 파일 업로드와 같이 DOM에서만 관리되는 요소
- 리액트 외부 라이브러리와 통합해야 하는 경우
실무에서의 고려사항
성능 측면
Controlled Component는 입력 값이 변경될 때마다 리렌더링이 발생합니다. 대부분의 경우 이는 문제가 되지 않지만, 매우 복잡한 폼이나 성능에 민감한 상황에서는 고려해야 할 사항입니다.
- 컴포넌트 분리:
- 큰 폼을 더 작은 컴포넌트로 분리하여 상태 변경 시 전체가 아닌 관련 부분만 리렌더링되도록 구조화
- 메모이제이션 활용:
- React.memo로 컴포넌트 리렌더링 최적화
- useMemo로 계산 비용이 큰 값 캐싱
- useCallback으로 이벤트 핸들러 함수 메모이제이션
- 디바운싱/스로틀링:
- 입력 값이 빠르게 변경될 때 상태 업데이트 빈도 제한
- lodash의 debounce 또는 throttle 함수 활용
- 실시간 피드백이 필요하지 않은 경우에 특히 유용
- 불변성 관리 최적화:
- 불필요한 객체 생성 최소화
- 중첩된 객체 상태보다 평면적인 상태 구조 사용
- 폼 라이브러리 사용:
- React Hook Form, Formik 등은 이미 내부적으로 최적화되어 있음
- 특히 React Hook Form은 불필요한 리렌더링 최소화에 중점
타입스크립트와의 통합
타입스크립트를 사용할 때 Controlled Component는 타입 안전성 측면에서 이점이 있습니다:
import React, { useState, ChangeEvent, FormEvent } from 'react';
// 폼 데이터 타입 정의
interface FormData {
username: string;
email: string;
password: string;
}
function TypedControlledForm() {
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
password: '',
});
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setFormData({
...formData,
[name]: value,
});
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
{/* 입력 필드들... */}
</form>
);
}
폼 상태 관리 라이브러리 활용
복잡한 폼을 다룰 때는 Formik, React Hook Form과 같은 라이브러리를 고려해볼 수 있습니다:
import { useForm } from 'react-hook-form';
function HookFormExample() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("username", { required: "이름을 입력하세요" })}
/>
{errors.username && <p>{errors.username.message}</p>}
<input
{...register("email", {
required: "이메일을 입력하세요",
pattern: {
value: /\S+@\S+\.\S+/,
message: "유효한 이메일 주소를 입력하세요"
}
})}
/>
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">제출</button>
</form>
);
}
이러한 라이브러리는 내부적으로 Controlled Component와 Uncontrolled Component의 장점을 모두 활용하여 더 효율적인 폼 처리를 제공합니다.
자주 발생하는 문제와 해결 방법
Controlled Component에서 발생할 수 있는 문제
문제: 입력 필드가 리액트 상태와 연결되었지만 onChange
핸들러가 없는 경우
// 문제가 있는 코드
<input value={name} /> // onChange 핸들러 없음
해결 방법: 항상 value
와 함께 onChange
핸들러를 제공하거나, 읽기 전용으로 만들기
// 수정된 코드
<input value={name} onChange={handleChange} />
// 또는 읽기 전용
<input value={name} readOnly />
Uncontrolled Component에서 발생할 수 있는 문제
문제: ref
를 사용하여 DOM에 접근하려 했지만 컴포넌트가 마운트되지 않은 경우
// 문제가 있는 코드
const inputRef = useRef();
// 컴포넌트 렌더링 전
console.log(inputRef.current.value); // 오류 발생
해결 방법: ref
가 할당되었는지 항상 확인
// 수정된 코드
if (inputRef.current) {
console.log(inputRef.current.value);
}
결론
Controlled Component와 Uncontrolled Component는 리액트에서 폼을 다루는 두 가지 기본 패턴입니다. 각각 장단점이 있으며, 상황에 맞게 적절한 방식을 선택하는 것이 중요합니다.
- Controlled Component는 리액트 상태가 진리의 원천이 되어 더 예측 가능하고 유연한 폼 처리를 제공합니다.
- Uncontrolled Component는 DOM이 데이터를 관리하며, 간단한 폼에서 구현이 더 쉽고 때로는 더 성능이 좋을 수 있습니다.
실제 프로젝트에서는 두 가지 접근 방식을 적절히 조합하여 사용하는 것이 일반적입니다. 작은 폼이나 간단한 상호작용에는 Uncontrolled Component를, 복잡한 유효성 검사나 동적 UI가 필요한 경우에는 Controlled Component를 사용하는 것이 좋습니다.
리액트 애플리케이션에서 폼을 다루는 방식을 선택할 때는 항상 사용자 경험, 개발 생산성, 코드 유지보수성을 함께 고려하세요. 가장 좋은 접근 방식은 프로젝트의 특성과 요구사항에 따라 달라질 수 있습니다.
참고 자료
이 글이 리액트에서 폼을 다루는 다양한 방법을 이해하는 데 도움이 되었기를 바랍니다. 더 궁금한 점이 있으시면 댓글로 남겨주세요!
'React' 카테고리의 다른 글
<React Recoil> - 리액트 상태관리 리코일 / atoms,hooks (0) | 2023.09.26 |
---|---|
리액트(React) - useState 내부 구현과 동작 원리(feat : 클로저) (0) | 2023.09.25 |
react-query(리액트쿼리)란? (1) | 2023.05.19 |
리액트(react) - JSX(JavaScript XML) 란 .feat(symbol) (0) | 2023.04.26 |
React(리액트) 하위 컴포넌트에서 데이터(data) 받아서 상위 컴포넌트로 전달. (0) | 2022.05.17 |