icon
9장 : 폼 처리와 유효성 검사

제어 컴포넌트와 비제어 컴포넌트

웹 애플리케이션에서 사용자 입력을 받는 데 필수적인 폼(Form) 관리에 대해 알아보겠습니다.

사용자로부터 데이터를 입력받는 input, textarea, select 등의 폼 요소는 웹 개발에서 매우 중요합니다. 리액트에서는 이러한 폼 요소를 관리하는 두 가지 주요 접근 방식이 있는데, 바로 제어 컴포넌트(Controlled Components)비제어 컴포넌트(Uncontrolled Components) 입니다. 이 장에서는 두 가지 방식의 개념과 특징, 그리고 각각을 언제 사용해야 하는지에 대해 자세히 살펴보겠습니다.


폼(Form)의 역할과 리액트에서의 관리

HTML의 폼 요소는 사용자의 입력을 받고 서버로 전송하는 역할을 합니다. 전통적인 HTML에서 폼 데이터는 폼 자체적으로 내부 상태를 관리하며, submit 이벤트 발생 시 서버로 데이터를 전송합니다.

하지만 리액트는 선언적(declarative) 프로그래밍 방식을 지향하며, UI가 애플리케이션의 상태에 따라 변경되도록 합니다. 폼 요소 또한 마찬가지로, 리액트 컴포넌트의 상태를 "진실의 원천(source of truth)"으로 삼아 폼 요소의 값을 제어하는 것이 일반적입니다.


제어 컴포넌트

제어 컴포넌트 (Controlled Components) 는 리액트 컴포넌트의 상태(state)가 폼 요소의 값을 완전히 제어하는 방식을 말합니다. 폼 요소의 value 속성이 리액트 상태에 의해 관리되며, 사용자의 입력은 onChange 이벤트 핸들러를 통해 상태를 업데이트함으로써 이루어집니다.

특징

  • 리액트 상태가 진실의 원천: 폼 요소의 현재 값이 항상 리액트 컴포넌트의 state에 의해 결정됩니다.
  • 예측 가능한 동작: 모든 입력 변화가 명시적으로 상태를 통해 흐르므로, 데이터의 흐름을 예측하기 쉽고 디버깅이 용이합니다.
  • 실시간 유효성 검사: onChange 이벤트에서 즉시 상태를 업데이트하므로, 실시간으로 유효성 검사를 수행하고 사용자에게 피드백을 줄 수 있습니다.
  • 강력한 제어: 입력값을 특정 형식으로 포매팅하거나, 특정 조건에 따라 입력 자체를 막는 등의 복잡한 로직을 구현하기 용이합니다.

구현 방법

  1. useState 훅을 사용하여 폼 요소의 값을 저장할 상태를 선언합니다.
  2. 폼 요소의 value 속성을 선언한 상태 변수에 바인딩합니다.
  3. 폼 요소의 onChange 속성에 이벤트 핸들러 함수를 할당합니다. 이 핸들러 함수 내에서 event.target.value를 통해 현재 입력값을 가져와 상태를 업데이트합니다.

예시

src/components/ControlledForm.js
// src/components/ControlledForm.js
import React, { useState } from 'react';

function ControlledForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [feedback, setFeedback] = useState('good'); // select 박스 예시

  const handleSubmit = (event) => {
    event.preventDefault(); // 폼의 기본 제출 동작 방지
    console.log('폼 제출됨 (제어 컴포넌트):', { name, email, feedback });
    alert(`이름: ${name}, 이메일: ${email}, 피드백: ${feedback} 제출 완료!`);
    // 제출 후 상태 초기화
    setName('');
    setEmail('');
    setFeedback('good');
  };

  return (
    <div style={{ maxWidth: '500px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>제어 컴포넌트 예시</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="name" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이름:</label>
          <input
            type="text"
            id="name"
            value={name} // 🌟 name 상태에 바인딩
            onChange={(e) => setName(e.target.value)} // 🌟 입력 변화 시 name 상태 업데이트
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="email" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이메일:</label>
          <input
            type="email"
            id="email"
            value={email} // 🌟 email 상태에 바인딩
            onChange={(e) => setEmail(e.target.value)} // 🌟 입력 변화 시 email 상태 업데이트
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="feedback" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>피드백:</label>
          <select
            id="feedback"
            value={feedback} // 🌟 feedback 상태에 바인딩
            onChange={(e) => setFeedback(e.target.value)} // 🌟 입력 변화 시 feedback 상태 업데이트
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          >
            <option value="good">좋음</option>
            <option value="neutral">보통</option>
            <option value="bad">나쁨</option>
          </select>
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '12px', fontSize: '1.1em' }}>제출</button>
      </form>
    </div>
  );
}

export default ControlledForm;

App.js에 이 컴포넌트를 추가하여 테스트해 보세요.


비제어 컴포넌트

비제어 컴포넌트 (Uncontrolled Components) 는 전통적인 HTML 폼과 유사하게, 폼 요소 자체가 자신의 내부 상태를 관리합니다. 리액트 컴포넌트의 상태가 폼 요소의 값을 직접 제어하지 않습니다. 대신, 폼 제출(submit) 이벤트가 발생했을 때 ref를 사용하여 DOM에서 직접 값을 가져옵니다.

특징

  • DOM이 진실의 원천: 폼 요소의 현재 값이 DOM 자체에 의해 관리됩니다.
  • 간단한 구현: 간단한 폼이나, 폼 요소가 많지 않을 때 비교적 적은 코드로 구현할 수 있습니다.
  • 외부 라이브러리와의 통합: 리액트가 아닌 다른 라이브러리 (예: 특정 jQuery 플러그인)와 통합할 때 유용할 수 있습니다.
  • 실시간 유효성 검사가 어려움: ref를 통해 값을 가져오는 시점이 주로 폼 제출 시점이므로, 실시간 입력 유효성 검사에는 부적합합니다.

구현 방법

  1. useRef 훅을 사용하여 폼 요소에 접근할 ref를 생성합니다.
  2. 폼 요소의 ref 속성에 생성한 ref 객체를 할당합니다.
  3. 폼 제출(onSubmit) 핸들러에서 ref.current.value를 통해 폼 요소의 현재 값을 가져옵니다.

예시

src/components/UncontrolledForm.js
// src/components/UncontrolledForm.js
import React, { useRef } from 'react';

function UncontrolledForm() {
  const nameInputRef = useRef(null);
  const emailInputRef = useRef(null);
  const feedbackSelectRef = useRef(null);

  const handleSubmit = (event) => {
    event.preventDefault(); // 폼의 기본 제출 동작 방지
    
    // 🌟 ref를 통해 DOM에서 직접 값 가져오기
    const name = nameInputRef.current.value;
    const email = emailInputRef.current.value;
    const feedback = feedbackSelectRef.current.value;

    console.log('폼 제출됨 (비제어 컴포넌트):', { name, email, feedback });
    alert(`이름: ${name}, 이메일: ${email}, 피드백: ${feedback} 제출 완료!`);
    
    // 제출 후 폼 필드 초기화 (선택 사항, ref를 통해 직접 DOM 조작)
    nameInputRef.current.value = '';
    emailInputRef.current.value = '';
    feedbackSelectRef.current.value = 'good';
  };

  return (
    <div style={{ maxWidth: '500px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>비제어 컴포넌트 예시</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="name" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이름:</label>
          <input
            type="text"
            id="name"
            defaultValue="홍길동" // 🌟 초기값 설정 시 defaultValue 사용 (value 아님)
            ref={nameInputRef} // 🌟 ref 바인딩
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="email" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이메일:</label>
          <input
            type="email"
            id="email"
            defaultValue="hong@example.com"
            ref={emailInputRef} // 🌟 ref 바인딩
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="feedback" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>피드백:</label>
          <select
            id="feedback"
            defaultValue="good"
            ref={feedbackSelectRef} // 🌟 ref 바인딩
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          >
            <option value="good">좋음</option>
            <option value="neutral">보통</option>
            <option value="bad">나쁨</option>
          </select>
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '12px', fontSize: '1.1em' }}>제출</button>
      </form>
    </div>
  );
}

export default UncontrolledForm;

App.js에 이 컴포넌트를 추가하여 테스트해 보세요. defaultValue는 컴포넌트가 처음 렌더링될 때만 적용되는 초기값입니다. 이후 사용자의 입력은 ref를 통해 직접 DOM에 반영됩니다.


언제 무엇을 사용해야 하는가?

특징제어 컴포넌트 (Controlled Components)비제어 컴포넌트 (Uncontrolled Components)
진실의 원천리액트 컴포넌트의 stateDOM 자체
구현 방식value 속성 + onChange 이벤트 핸들러ref 속성 + defaultValue (초기값)
데이터 흐름리액트 상태를 통해 단방향으로 흐름 (명확)DOM에서 직접 값을 가져옴 (덜 명확)
유효성 검사실시간 유효성 검사 및 사용자 피드백 용이주로 제출 시점에 검사, 실시간 검사는 복잡
복잡성각 입력 필드마다 상태와 핸들러 필요 (코드량 증가)간단한 폼에선 코드량 적음
활용 시점대부분의 경우 (권장), 복잡한 폼, 실시간 피드백 필요간단한 폼, 외부 라이브러리 통합, 레거시 코드 호환성

결론적으로, 리액트에서는 대부분의 경우 제어 컴포넌트를 사용하는 것이 권장됩니다. 이는 리액트의 핵심 개념인 "상태를 통한 UI 제어"와 일치하며, 폼 데이터의 흐름을 예측 가능하게 하고 디버깅을 용이하게 합니다. 또한, 실시간 유효성 검사나 입력 포매팅 등 복잡한 폼 로직을 구현하는 데 훨씬 유리합니다.

비제어 컴포넌트는 매우 단순한 폼이거나, 기존의 jQuery 플러그인 등 리액트 외부에서 DOM을 직접 조작하는 라이브러리와 통합해야 할 때 고려할 수 있습니다.


여러 입력 필드 관리 (제어 컴포넌트 심화)

제어 컴포넌트 방식으로 여러 개의 입력 필드를 관리할 때, 각 필드마다 별도의 useStateonChange 핸들러를 만드는 것은 비효율적입니다. 이럴 때는 하나의 상태 객체를 사용하고 범용적인 onChange 핸들러를 만들 수 있습니다.

src/components/MultiInputControlledForm.js
// src/components/MultiInputControlledForm.js
import React, { useState } from 'react';

function MultiInputControlledForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    age: '',
    gender: 'male',
  });

  const handleChange = (event) => {
    const { name, value } = event.target; // input의 name 속성과 value를 가져옴
    setFormData(prevFormData => ({
      ...prevFormData, // 기존 formData 복사
      [name]: value,   // 해당 name의 필드만 업데이트
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log('여러 입력 필드 폼 제출됨:', formData);
    alert(`제출된 데이터: ${JSON.stringify(formData, null, 2)}`);
  };

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>여러 입력 필드 관리 (제어 컴포넌트)</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="firstName" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이름:</label>
          <input
            type="text"
            id="firstName"
            name="firstName" // 🌟 name 속성 추가
            value={formData.firstName}
            onChange={handleChange} // 🌟 하나의 핸들러 사용
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="lastName" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>성:</label>
          <input
            type="text"
            id="lastName"
            name="lastName" // 🌟 name 속성 추가
            value={formData.lastName}
            onChange={handleChange} // 🌟 하나의 핸들러 사용
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="age" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>나이:</label>
          <input
            type="number"
            id="age"
            name="age" // 🌟 name 속성 추가
            value={formData.age}
            onChange={handleChange} // 🌟 하나의 핸들러 사용
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '20px' }}>
          <label htmlFor="gender" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>성별:</label>
          <select
            id="gender"
            name="gender" // 🌟 name 속성 추가
            value={formData.gender}
            onChange={handleChange} // 🌟 하나의 핸들러 사용
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          >
            <option value="male">남성</option>
            <option value="female">여성</option>
            <option value="other">기타</option>
          </select>
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '12px', fontSize: '1.1em' }}>제출</button>
      </form>
    </div>
  );
}

export default MultiInputControlledForm;

이 패턴은 제어 컴포넌트 방식을 사용하면서도 코드의 중복을 줄여주는 효율적인 방법입니다.


9장 1절 "제어 컴포넌트와 비제어 컴포넌트"는 여기까지입니다. 이 장에서는 리액트에서 폼을 다루는 두 가지 핵심 방식인 제어 컴포넌트와 비제어 컴포넌트의 개념, 구현 방법, 그리고 각각의 장단점을 비교하여 언제 어떤 방식을 선택해야 하는지 알아보았습니다. 특히, 대부분의 리액트 애플리케이션에서 선호되는 제어 컴포넌트의 중요성을 강조하고, 여러 입력 필드를 효율적으로 관리하는 패턴까지 살펴보았습니다.