본문 바로가기

폼과 제어 컴포넌트

2. 폼과 제어 컴포넌트

React에서 사용자 입력을 다루는 방법을 배워보겠습니다. 제어 컴포넌트와 비제어 컴포넌트의 차이점, 그리고 복잡한 폼을 효율적으로 관리하는 방법을 알아봅시다.

2.1 제어 컴포넌트 vs 비제어 컴포넌트

비제어 컴포넌트 (Uncontrolled Components)

DOM이 폼 데이터를 직접 처리하는 방식입니다:

import { useRef } from 'react';

function UncontrolledForm() {
  const nameRef = useRef();
  const emailRef = useRef();

  const handleSubmit = (event) => {
    event.preventDefault();
    
    const formData = {
      name: nameRef.current.value,
      email: emailRef.current.value
   ;
    
    alert(`이름: ${formData.name}, 이메일: ${formData.email}`);
 ;

  return (
    <div>
      <h3>비제어 컴포넌트 예제</h3>
      
      <form onSubmit={handleSubmit}>
        <div>
          <label>이름:</label>
          <input
            type="text"
            ref={nameRef}
            defaultValue="홍길동"
          />
        </div>

        <div>
          <label>이메일:</label>
          <input
            type="email"
            ref={emailRef}
          />
        </div>

        <button
          type="submit"
        >
          제출
        </button>
      </form>
    </div>
  );
}

제어 컴포넌트 (Controlled Components)

React 상태가 폼 데이터를 제어하는 방식입니다:

import { useState } from 'react';

function ControlledForm() {
  const [formData, setFormData] = useState({
    name: '홍길동',
    email: ''
 );

  const handleInputChange = (event) => {
    const { name, value = event.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
   ));
 ;

  const handleSubmit = (event) => {
    event.preventDefault();
    alert(`이름: ${formData.name}, 이메일: ${formData.email}`);
 ;

  return (
    <div>
      <h3>제어 컴포넌트 예제</h3>
      
      <form onSubmit={handleSubmit}>
        <div>
          <label>이름:</label>
          <input
            type="text"
            name="name"
            value={formData.name}
            onChange={handleInputChange}
          />
        </div>

        <div>
          <label>이메일:</label>
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleInputChange}
          />
        </div>

        <button
          type="submit"
        >
          제출
        </button>
      </form>

      {/* 실시간 데이터 표시 */}
      <div>
        <h4>실시간 데이터:</h4>
        <p>이름: {formData.name}</p>
        <p>이메일: {formData.email}</p>
      </div>
    </div>
  );
}

2.2 다양한 입력 요소 다루기

텍스트 입력, 체크박스, 라디오 버튼, 선택박스

import { useState } from 'react';

function ComprehensiveForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: '',
    age: '',
    gender: '',
    country: '',
    hobbies: [],
    newsletter: false,
    experience: '',
    bio: '',
    website: ''
 );

  const [errors, setErrors] = useState({});

  const countries = ['한국', '미국', '일본', '중국', '독일', '프랑스'];
  const hobbiesList = ['독서', '영화감상', '운동', '요리', '여행', '게임'];

  const handleInputChange = (event) => {
    const { name, value, type, checked = event.target;

    if (type === 'checkbox') {
      if (name === 'hobbies') {
        // 다중 체크박스 처리
        setFormData(prev => ({
          ...prev,
          hobbies: checked
            ? [...prev.hobbies, value]
            : prev.hobbies.filter(hobby => hobby !== value)
       ));
      else {
        // 단일 체크박스 처리
        setFormData(prev => ({
          ...prev,
          [name]: checked
       ));
     
    else {
      setFormData(prev => ({
        ...prev,
        [name]: value
     ));
   

    // 에러 제거
    if (errors[name]) {
      setErrors(prev => ({
        ...prev,
        [name]: ''
     ));
   
 ;

  const validateForm = () => {
    const newErrors = {};

    if (!formData.name.trim()) {
      newErrors.name = '이름은 필수입니다.';
   

    if (!formData.email.trim()) {
      newErrors.email = '이메일은 필수입니다.';
    else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = '올바른 이메일 형식이 아닙니다.';
   

    if (!formData.password) {
      newErrors.password = '비밀번호는 필수입니다.';
    else if (formData.password.length < 6) {
      newErrors.password = '비밀번호는 최소 6자 이상이어야 합니다.';
   

    if (!formData.age) {
      newErrors.age = '나이는 필수입니다.';
    else if (isNaN(formData.age) || formData.age < 1 || formData.age > 120) {
      newErrors.age = '올바른 나이를 입력해주세요.';
   

    if (!formData.gender) {
      newErrors.gender = '성별을 선택해주세요.';
   

    if (!formData.country) {
      newErrors.country = '국가를 선택해주세요.';
   

    return newErrors;
 ;

  const handleSubmit = (event) => {
    event.preventDefault();
    
    const newErrors = validateForm();
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
   

    alert('폼이 성공적으로 제출되었습니다!');
    console.log('제출된 데이터:', formData);
 ;


  return (
    <div>
      <h2>
        종합 회원가입 폼
      </h2>

      <form onSubmit={handleSubmit}>
        {/* 기본 정보 */}
        <fieldset>
          <legend>기본 정보</legend>
          
          <div>
            <label>이름 *</label>
            <input
              type="text"
              name="name"
              value={formData.name}
              onChange={handleInputChange}
              placeholder="이름을 입력하세요"
            />
            {errors.name && <div>{errors.name}</div>}
          </div>

          <div>
            <label>이메일 *</label>
            <input
              type="email"
              name="email"
              value={formData.email}
              onChange={handleInputChange}
              placeholder="example@email.com"
            />
            {errors.email && <div>{errors.email}</div>}
          </div>

          <div>
            <label>비밀번호 *</label>
            <input
              type="password"
              name="password"
              value={formData.password}
              onChange={handleInputChange}
              placeholder="최소 6자 이상"
            />
            {errors.password && <div>{errors.password}</div>}
          </div>

          <div>
            <div>
              <label>나이 *</label>
              <input
                type="number"
                name="age"
                value={formData.age}
                onChange={handleInputChange}
                
                min="1"
                max="120"
                placeholder="나이"
              />
              {errors.age && <div >{errors.age}</div>}
            </div>

            <div>
              <label>국가 *</label>
              <select
                name="country"
                value={formData.country}
                onChange={handleInputChange}
                
              >
                <option value="">국가를 선택하세요</option>
                {countries.map(country => (
                  <option key={country} value={country}>
                    {country}
                  </option>
                ))}
              </select>
              {errors.country && <div >{errors.country}</div>}
            </div>
          </div>
        </fieldset>

        {/* 성별 선택 */}
        <fieldset style={{ 
          border: '1px solid #ddd', 
          borderRadius: '4px', 
          padding: '20px',
          marginBottom: '20px'
       }>
          <legend>성별 *</legend>
          
          <div>
            <label>
              <input
                type="radio"
                name="gender"
                value="male"
                checked={formData.gender === 'male'}
                onChange={handleInputChange}
               
              />
              남성
            </label>
            <label>
              <input
                type="radio"
                name="gender"
                value="female"
                checked={formData.gender === 'female'}
                onChange={handleInputChange}
               
              />
              여성
            </label>
            <label>
              <input
                type="radio"
                name="gender"
                value="other"
                checked={formData.gender === 'other'}
                onChange={handleInputChange}
               
              />
              기타
            </label>
          </div>
          {errors.gender && <div >{errors.gender}</div>}
        </fieldset>

        {/* 취미 선택 */}
        <fieldset style={{ 
          border: '1px solid #ddd', 
          borderRadius: '4px', 
          padding: '20px',
          marginBottom: '20px'
       }>
          <legend>취미 (다중 선택 가능)</legend>
          
          <div style={{ 
            display: 'grid', 
            gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
            gap: '10px' 
         }>
            {hobbiesList.map(hobby => (
              <label
                key={hobby}
               
              >
                <input
                  type="checkbox"
                  name="hobbies"
                  value={hobby}
                  checked={formData.hobbies.includes(hobby)}
                  onChange={handleInputChange}
                 
                />
                {hobby}
              </label>
            ))}
          </div>
        </fieldset>

        {/* 추가 정보 */}
        <fieldset style={{ 
          border: '1px solid #ddd', 
          borderRadius: '4px', 
          padding: '20px',
          marginBottom: '20px'
       }>
          <legend>추가 정보</legend>
          
          <div>
            <label>경력</label>
            <select
              name="experience"
              value={formData.experience}
              onChange={handleInputChange}
              
            >
              <option value="">경력을 선택하세요</option>
              <option value="신입">신입</option>
              <option value="1-3년">1-3년</option>
              <option value="4-7년">4-7년</option>
              <option value="8-15년">8-15년</option>
              <option value="15년 이상">15년 이상</option>
            </select>
          </div>

          <div>
            <label>자기소개</label>
            <textarea
              name="bio"
              value={formData.bio}
              onChange={handleInputChange}
              rows="4"
              
              placeholder="자기소개를 입력하세요..."
            />
          </div>

          <div>
            <label>웹사이트</label>
            <input
              type="url"
              name="website"
              value={formData.website}
              onChange={handleInputChange}
              
              placeholder="https://example.com"
            />
          </div>

          <div>
            <label>
              <input
                type="checkbox"
                name="newsletter"
                checked={formData.newsletter}
                onChange={handleInputChange}
               
              />
              뉴스레터 구독하기
            </label>
          </div>
        </fieldset>

        {/* 제출 버튼 */}
        <div>
          <button
            type="submit"
            style={{
              backgroundColor: '#007bff',
              color: 'white',
              padding: '15px 30px',
              border: 'none',
              borderRadius: '4px',
              fontSize: '16px',
              cursor: 'pointer',
              fontWeight: 'bold'
           }
          >
            회원가입
          </button>
        </div>
      </form>

      {/* 현재 폼 데이터 표시 */}
      <details>
        <summary>
          현재 폼 데이터 보기
        </summary>
        <pre style={{
          backgroundColor: '#f8f9fa',
          padding: '15px',
          borderRadius: '4px',
          fontSize: '12px',
          overflow: 'auto'
       }>
          {JSON.stringify(formData, null, 2)}
        </pre>
      </details>
    </div>
  );
}

2.3 폼 유효성 검사

실시간 유효성 검사

import { useState, useEffect } from 'react';

function ValidationForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    confirmPassword: ''
 );

  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  // 실시간 유효성 검사
  useEffect(() => {
    const newErrors = {};

    // 사용자명 검사
    if (touched.username) {
      if (!formData.username.trim()) {
        newErrors.username = '사용자명은 필수입니다.';
      else if (formData.username.length < 3) {
        newErrors.username = '사용자명은 3자 이상이어야 합니다.';
      else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
        newErrors.username = '영문, 숫자, 언더스코어만 사용 가능합니다.';
     
   

    // 이메일 검사
    if (touched.email) {
      if (!formData.email.trim()) {
        newErrors.email = '이메일은 필수입니다.';
      else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
        newErrors.email = '올바른 이메일 형식이 아닙니다.';
     
   

    // 비밀번호 검사
    if (touched.password) {
      if (!formData.password) {
        newErrors.password = '비밀번호는 필수입니다.';
      else if (formData.password.length < 8) {
        newErrors.password = '비밀번호는 8자 이상이어야 합니다.';
      else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
        newErrors.password = '대문자, 소문자, 숫자를 포함해야 합니다.';
     
   

    // 비밀번호 확인 검사
    if (touched.confirmPassword) {
      if (!formData.confirmPassword) {
        newErrors.confirmPassword = '비밀번호 확인은 필수입니다.';
      else if (formData.password !== formData.confirmPassword) {
        newErrors.confirmPassword = '비밀번호가 일치하지 않습니다.';
     
   

    setErrors(newErrors);
 , [formData, touched]);

  const handleInputChange = (event) => {
    const { name, value = event.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
   ));
 ;

  const handleBlur = (event) => {
    const { name = event.target;
    setTouched(prev => ({
      ...prev,
      [name]: true
   ));
 ;

  const handleSubmit = (event) => {
    event.preventDefault();
    
    // 모든 필드를 touched 상태로 만들기
    setTouched({
      username: true,
      email: true,
      password: true,
      confirmPassword: true
   );

    // 에러가 없으면 제출
    if (Object.keys(errors).length === 0) {
      alert('계정이 성공적으로 생성되었습니다!');
   
 ;

  const getInputStyle = (fieldName) => ({
    width: '100%',
    padding: '12px',
    margin: '5px 0',
    border: `2px solid ${
      touched[fieldName] 
        ? errors[fieldName] ? '#dc3545' : '#28a745'
        : '#ddd'
   `,
    borderRadius: '4px',
    fontSize: '14px',
    outline: 'none'
 );

  const isFormValid = Object.keys(errors).length === 0 && 
                     Object.keys(touched).length === 4;

  return (
    <div style={{
      padding: '20px',
      maxWidth: '500px',
      margin: '0 auto',
      backgroundColor: 'white',
      borderRadius: '8px',
      boxShadow: '0 2px 10px rgba(0,0,0,0.1)'
   }>
      <h2>
        계정 생성
      </h2>

      <form onSubmit={handleSubmit}>
        <div>
          <label>
            사용자명
          </label>
          <input
            type="text"
            name="username"
            value={formData.username}
            onChange={handleInputChange}
            onBlur={handleBlur}
            
            placeholder="사용자명을 입력하세요"
          />
          {touched.username && errors.username && (
            <div>
              ❌ {errors.username}
            </div>
          )}
          {touched.username && !errors.username && formData.username && (
            <div>
              ✅ 사용 가능한 사용자명입니다.
            </div>
          )}
        </div>

        <div>
          <label>
            이메일
          </label>
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleInputChange}
            onBlur={handleBlur}
            
            placeholder="이메일을 입력하세요"
          />
          {touched.email && errors.email && (
            <div>
              ❌ {errors.email}
            </div>
          )}
          {touched.email && !errors.email && formData.email && (
            <div>
              ✅ 유효한 이메일입니다.
            </div>
          )}
        </div>

        <div>
          <label>
            비밀번호
          </label>
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleInputChange}
            onBlur={handleBlur}
            
            placeholder="비밀번호를 입력하세요"
          />
          {touched.password && errors.password && (
            <div>
              ❌ {errors.password}
            </div>
          )}
          {touched.password && !errors.password && formData.password && (
            <div>
              ✅ 강력한 비밀번호입니다.
            </div>
          )}
          
          {/* 비밀번호 강도 표시 */}
          {formData.password && (
            <div>
              <div>비밀번호 강도:</div>
              <div>
                {[1, 2, 3, 4].map(level => (
                  <div
                    key={level}
                    style={{
                      height: '4px',
                      flex: 1,
                      backgroundColor: getPasswordStrength(formData.password) >= level 
                        ? ['#dc3545', '#ffc107', '#fd7e14', '#28a745'][level - 1]
                        : '#e9ecef',
                      borderRadius: '2px'
                   }
                  />
                ))}
              </div>
            </div>
          )}
        </div>

        <div>
          <label>
            비밀번호 확인
          </label>
          <input
            type="password"
            name="confirmPassword"
            value={formData.confirmPassword}
            onChange={handleInputChange}
            onBlur={handleBlur}
            
            placeholder="비밀번호를 다시 입력하세요"
          />
          {touched.confirmPassword && errors.confirmPassword && (
            <div>
              ❌ {errors.confirmPassword}
            </div>
          )}
          {touched.confirmPassword && !errors.confirmPassword && formData.confirmPassword && (
            <div>
              ✅ 비밀번호가 일치합니다.
            </div>
          )}
        </div>

        <button
          type="submit"
          disabled={!isFormValid}
          style={{
            width: '100%',
            padding: '15px',
            backgroundColor: isFormValid ? '#007bff' : '#6c757d',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            fontSize: '16px',
            fontWeight: 'bold',
            cursor: isFormValid ? 'pointer' : 'not-allowed',
            transition: 'background-color 0.3s'
         }
        >
          계정 생성
        </button>
      </form>
    </div>
  );
}

// 비밀번호 강도 계산 함수
function getPasswordStrength(password) {
  let strength = 0;
  if (password.length >= 8) strength++;
  if (/[a-z]/.test(password)) strength++;
  if (/[A-Z]/.test(password)) strength++;
  if (/\d/.test(password)) strength++;
  if (/[^a-zA-Z\d]/.test(password)) strength++;
  return Math.min(strength, 4);
}

2.4 커스텀 훅을 활용한 폼 관리

useForm 훅 구현

import { useState, useCallback } from 'react';

function useForm(initialValues, validationRules = {}) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});

  const validate = useCallback((name, value) => {
    const rule = validationRules[name];
    if (!rule) return '';

    if (typeof rule === 'function') {
      return rule(value, values);
   

    if (rule.required && (!value || value.toString().trim() === '')) {
      return rule.message || `${name}는 필수입니다.`;
   

    if (rule.minLength && value.length < rule.minLength) {
      return rule.message || `${name}는 최소 ${rule.minLength}자 이상이어야 합니다.`;
   

    if (rule.pattern && !rule.pattern.test(value)) {
      return rule.message || `${name} 형식이 올바르지 않습니다.`;
   

    return '';
 , [validationRules, values]);

  const handleChange = useCallback((event) => {
    const { name, value, type, checked = event.target;
    const fieldValue = type === 'checkbox' ? checked : value;

    setValues(prev => ({
      ...prev,
      [name]: fieldValue
   ));

    if (touched[name]) {
      const error = validate(name, fieldValue);
      setErrors(prev => ({
        ...prev,
        [name]: error
     ));
   
 , [validate, touched]);

  const handleBlur = useCallback((event) => {
    const { name, value = event.target;
    
    setTouched(prev => ({
      ...prev,
      [name]: true
   ));

    const error = validate(name, value);
    setErrors(prev => ({
      ...prev,
      [name]: error
   ));
 , [validate]);

  const handleSubmit = useCallback((onSubmit) => (event) => {
    event.preventDefault();

    // 모든 필드 검증
    const newErrors = {};
    const newTouched = {};
    
    Object.keys(values).forEach(name => {
      newTouched[name] = true;
      const error = validate(name, values[name]);
      if (error) newErrors[name] = error;
   );

    setTouched(newTouched);
    setErrors(newErrors);

    if (Object.keys(newErrors).length === 0) {
      onSubmit(values);
   
 , [values, validate]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
 , [initialValues]);

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    handleSubmit,
    reset,
    isValid: Object.keys(errors).length === 0 && Object.keys(touched).length > 0
 ;
}

// useForm 훅 사용 예제
function FormWithCustomHook() {
  const validationRules = {
    name: {
      required: true,
      minLength: 2,
      message: '이름은 최소 2자 이상 입력해주세요.'
   ,
    email: {
      required: true,
      pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      message: '올바른 이메일 주소를 입력해주세요.'
   ,
    age: (value) => {
      if (!value) return '나이는 필수입니다.';
      const num = parseInt(value);
      if (isNaN(num) || num < 1 || num > 120) {
        return '올바른 나이를 입력해주세요. (1-120)';
     
      return '';
   ,
    terms: (value) => {
      return value ? '' : '이용약관에 동의해주세요.';
   
 ;

  const form = useForm(
    {
      name: '',
      email: '',
      age: '',
      terms: false
   ,
    validationRules
  );

  const handleFormSubmit = (data) => {
    alert(`폼 제출 성공!\n${JSON.stringify(data, null, 2)}`);
    form.reset();
 ;

  return (
    <div>
      <h2>커스텀 훅을 사용한 폼</h2>
      
      <form onSubmit={form.handleSubmit(handleFormSubmit)}>
        <div>
          <label>이름</label>
          <input
            type="text"
            name="name"
            value={form.values.name}
            onChange={form.handleChange}
            onBlur={form.handleBlur}
            style={{
              width: '100%',
              padding: '10px',
              border: `1px solid ${
                form.touched.name && form.errors.name ? '#dc3545' : '#ddd'
             `,
              borderRadius: '4px'
           }
          />
          {form.touched.name && form.errors.name && (
            <div>
              {form.errors.name}
            </div>
          )}
        </div>

        <div>
          <label>이메일</label>
          <input
            type="email"
            name="email"
            value={form.values.email}
            onChange={form.handleChange}
            onBlur={form.handleBlur}
            style={{
              width: '100%',
              padding: '10px',
              border: `1px solid ${
                form.touched.email && form.errors.email ? '#dc3545' : '#ddd'
             `,
              borderRadius: '4px'
           }
          />
          {form.touched.email && form.errors.email && (
            <div>
              {form.errors.email}
            </div>
          )}
        </div>

        <div>
          <label>나이</label>
          <input
            type="number"
            name="age"
            value={form.values.age}
            onChange={form.handleChange}
            onBlur={form.handleBlur}
            style={{
              width: '100%',
              padding: '10px',
              border: `1px solid ${
                form.touched.age && form.errors.age ? '#dc3545' : '#ddd'
             `,
              borderRadius: '4px'
           }
          />
          {form.touched.age && form.errors.age && (
            <div>
              {form.errors.age}
            </div>
          )}
        </div>

        <div>
          <label>
            <input
              type="checkbox"
              name="terms"
              checked={form.values.terms}
              onChange={form.handleChange}
              onBlur={form.handleBlur}
             
            />
            이용약관에 동의합니다
          </label>
          {form.touched.terms && form.errors.terms && (
            <div>
              {form.errors.terms}
            </div>
          )}
        </div>

        <div>
          <button
            type="submit"
            disabled={!form.isValid}
            style={{
              flex: 1,
              padding: '12px',
              backgroundColor: form.isValid ? '#007bff' : '#6c757d',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: form.isValid ? 'pointer' : 'not-allowed'
           }
          >
            제출
          </button>
          <button
            type="button"
            onClick={form.reset}
            style={{
              flex: 1,
              padding: '12px',
              backgroundColor: '#6c757d',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer'
           }
          >
            초기화
          </button>
        </div>
      </form>
    </div>
  );
}

export { ComprehensiveForm, ValidationForm, FormWithCustomHook, useForm;

2.5 정리

폼과 제어 컴포넌트의 핵심 개념을 학습했습니다:

  1. 제어 컴포넌트 vs 비제어 컴포넌트의 차이점과 장단점
  2. 다양한 입력 요소들의 상태 관리 방법
  3. 실시간 유효성 검사와 사용자 피드백 제공
  4. 커스텀 훅을 활용한 폼 로직 재사용
  5. 폼 상태 관리와 에러 처리 패턴
  6. 사용자 경험 개선을 위한 UI/UX 고려사항

다음 장에서는 컴포넌트 생명주기와 useEffect에 대해 알아보겠습니다.