icon
3장 : React 컴포넌트 심화

폼 다루기 기초

우리는 리스트와 key를 이용하여 여러 데이터를 효율적으로 렌더링하는 방법을 배웠습니다. 이제 사용자로부터 데이터를 입력받는 중요한 UI 요소인 폼(Form) 을 리액트에서 어떻게 다루는지 알아볼 차례입니다.

로그인, 회원가입, 게시물 작성, 검색 등 대부분의 웹 애플리케이션은 사용자로부터 다양한 형태의 입력을 받습니다. 리액트에서는 이러한 폼 요소를 효율적으로 관리하고, 사용자의 입력을 실시간으로 처리하며, 최종적으로 서버로 데이터를 전송하는 과정을 체계적으로 다룰 수 있도록 돕습니다.

이 장에서는 리액트에서 폼을 다루는 핵심 개념인 제어 컴포넌트(Controlled Component) 에 대해 집중적으로 알아볼 것입니다.


제어 컴포넌트란?

HTML에서 <input>, <textarea>, <select>와 같은 폼 요소들은 사용자 입력을 통해 자체적인 '상태(state)'를 가집니다. 예를 들어, input 필드에 텍스트를 입력하면 해당 필드 자체가 그 텍스트 값을 내부적으로 관리합니다. 이러한 폼 요소를 비제어 컴포넌트(Uncontrolled Component) 라고 합니다.

하지만 리액트에서는 폼 요소의 '상태'를 리액트 컴포넌트의 state로 관리하는 방식을 선호합니다. 즉, 사용자가 폼 요소에 값을 입력하면, 그 값이 컴포넌트의 state에 저장되고, 폼 요소는 이 state에 의해 제어됩니다. 이렇게 리액트의 state에 의해 그 값이 제어되는 폼 요소제어 컴포넌트(Controlled Component) 라고 부릅니다.

제어 컴포넌트의 장점

  • 단일 진실 공급원(Single Source of Truth): 폼 요소의 현재 값이 항상 리액트 컴포넌트의 state에 저장되어 있으므로, 데이터의 흐름을 예측하기 쉽고 디버깅이 용이합니다.
  • 실시간 유효성 검사 및 피드백: state가 실시간으로 업데이트되므로, 사용자가 입력하는 즉시 유효성 검사를 수행하고 오류 메시지 등을 제공할 수 있습니다.
  • 쉬운 값 조작: 프로그래밍 방식으로 폼 요소의 값을 변경하거나 초기화하는 것이 쉽습니다.
  • 다양한 사용자 정의 로직 적용: state를 기반으로 복잡한 사용자 정의 로직(예: 입력 포맷팅, 자동 완성)을 적용할 수 있습니다.

input 태그 다루기: 텍스트 입력

가장 기본적인 폼 요소인 <input type="text">를 제어 컴포넌트로 다루는 방법을 살펴보겠습니다.

예제: 사용자 이름 입력 폼

  1. NameForm.js 컴포넌트 생성: src/components 폴더 안에 NameForm.js 파일을 생성합니다.

    src/components/NameForm.js
    // src/components/NameForm.js
    import React, { useState } from 'react';
    
    function NameForm() {
      // (1) input의 현재 값을 저장할 state 선언
      const [name, setName] = useState('');
    
      // (2) input 값이 변경될 때 호출될 이벤트 핸들러
      const handleChange = (event) => {
        // event.target.value: input 필드의 현재 값
        setName(event.target.value); // state 업데이트
        console.log('현재 입력 값:', event.target.value);
      };
    
      // (3) 폼 제출 시 호출될 이벤트 핸들러
      const handleSubmit = (event) => {
        alert('제출된 이름: ' + name); // state에 저장된 이름 출력
        event.preventDefault(); // (4) 폼의 기본 동작(페이지 새로고침) 방지
      };
    
      return (
        <div style={{ border: '1px solid #009688', padding: '20px', margin: '20px', borderRadius: '8px' }}>
          <h2>이름 입력 폼</h2>
          <form onSubmit={handleSubmit}>
            <label>
              이름:
              <input
                type="text"
                value={name} // (5) input의 value를 state 'name'과 연결
                onChange={handleChange} // (6) 값이 변경될 때 handleChange 함수 호출
                style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
              />
            </label>
            <button type="submit" style={{ marginLeft: '10px', padding: '8px 15px', fontSize: '16px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
              제출
            </button>
          </form>
          <p style={{ marginTop: '15px' }}>
            입력 중인 이름: <span style={{ fontWeight: 'bold', color: '#007bff' }}>{name}</span>
          </p>
        </div>
      );
    }
    
    export default NameForm;
    • name state: 입력 필드의 현재 텍스트 값을 저장합니다. 초기값은 빈 문자열('')입니다.
    • handleChange: <input>onChange 이벤트에 연결된 함수입니다. event.target.value를 통해 사용자가 입력한 값을 가져와 setName 함수로 name state를 업데이트합니다. 이 과정이 실시간으로 입력 필드의 값을 state와 동기화하는 핵심입니다.
    • handleSubmit: <form>onSubmit 이벤트에 연결된 함수입니다. 폼이 제출될 때 호출됩니다. event.preventDefault()는 폼 제출 시 페이지가 새로고침되는 브라우저의 기본 동작을 막아줍니다.
    • value={name}: <input> 태그의 value 속성을 name state에 연결했습니다. 이로써 input 필드의 값은 항상 name state에 의해 제어됩니다. 이것이 바로 제어 컴포넌트의 핵심입니다.
  2. App.js에서 NameForm 컴포넌트 사용:

    src/App.js
    // src/App.js
    import React from 'react';
    import './App.css';
    import NameForm from './components/NameForm'; // NameForm 컴포넌트 불러오기
    
    function App() {
      return (
        <div className="App">
          <h1>React 폼 다루기</h1>
          <NameForm />
        </div>
      );
    }
    
    export default App;
  3. 결과 확인: 브라우저에서 입력 필드에 텍스트를 입력해보고, 아래 '입력 중인 이름' 텍스트가 실시간으로 변하는 것을 확인해 보세요. '제출' 버튼을 클릭하면 alert 창에 입력된 이름이 나타나고 페이지는 새로고침되지 않을 것입니다.


textarea 태그 다루기

<textarea> 태그는 여러 줄의 텍스트 입력을 받을 때 사용합니다. input과 거의 동일한 방식으로 다룰 수 있습니다.

예제: 자유 텍스트 입력 폼

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

function EssayForm() {
  const [essay, setEssay] = useState('이곳에 에세이를 작성하세요...'); // 초기값 설정

  const handleChange = (event) => {
    setEssay(event.target.value);
  };

  const handleSubmit = (event) => {
    alert('제출된 에세이:\n' + essay);
    event.preventDefault();
  };

  return (
    <div style={{ border: '1px solid #fd7e14', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>에세이 작성 폼</h2>
      <form onSubmit={handleSubmit}>
        <label>
          에세이:
          <textarea
            value={essay} // state와 연결
            onChange={handleChange} // 변경 시 핸들러 호출
            rows="5" // 높이 지정
            cols="40" // 너비 지정
            style={{ display: 'block', marginTop: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc', width: '90%' }}
          />
        </label>
        <button type="submit" style={{ marginTop: '15px', padding: '8px 15px', fontSize: '16px', backgroundColor: '#fd7e14', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
          에세이 제출
        </button>
      </form>
      <p style={{ marginTop: '15px' }}>
        현재 에세이:<br />
        <span style={{ whiteSpace: 'pre-wrap', color: '#dc3545' }}>{essay}</span>
      </p>
    </div>
  );
}

export default EssayForm;
  • <textarea>는 HTML에서는 자식 노드로 텍스트를 초기화했지만, 리액트에서는 value 속성으로 초기화합니다.
  • whiteSpace: 'pre-wrap' 스타일은 HTML의 <pre> 태그처럼 공백과 줄바꿈을 유지하도록 해줍니다.

select 태그 다루기

HTML의 <select> 태그는 드롭다운 목록을 만듭니다. 리액트에서 <select> 태그도 제어 컴포넌트로 다룰 수 있습니다. HTML에서는 selected 속성을 사용했지만, 리액트에서는 <select> 태그의 value 속성에 선택된 값을 지정합니다.

예제: 과일 선택 폼

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

function FlavorForm() {
  // state의 초기값으로 기본 선택될 option의 value를 지정
  const [selectedFlavor, setSelectedFlavor] = useState('grapefruit');

  const handleChange = (event) => {
    setSelectedFlavor(event.target.value);
  };

  const handleSubmit = (event) => {
    alert('선택한 과일: ' + selectedFlavor);
    event.preventDefault();
  };

  return (
    <div style={{ border: '1px solid #20c997', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>좋아하는 과일 선택</h2>
      <form onSubmit={handleSubmit}>
        <label>
          좋아하는 과일을 선택하세요:
          <select value={selectedFlavor} onChange={handleChange}
                  style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}>
            <option value="grapefruit">자몽</option>
            <option value="lime">라임</option>
            <option value="coconut">코코넛</option>
            <option value="mango">망고</option>
          </select>
        </label>
        <button type="submit" style={{ marginLeft: '10px', padding: '8px 15px', fontSize: '16px', backgroundColor: '#20c997', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
          제출
        </button>
      </form>
      <p style={{ marginTop: '15px' }}>
        선택된 과일: <span style={{ fontWeight: 'bold', color: '#20c997' }}>{selectedFlavor}</span>
      </p>
    </div>
  );
}

export default FlavorForm;
  • <select> 태그 자체의 value 속성에 selectedFlavor state를 연결했습니다.
  • option 태그에는 value 속성만 부여하고 selected 속성은 사용하지 않습니다.

여러 입력 요소에 대한 단일 이벤트 핸들러

여러 개의 입력 필드가 있는 폼을 만들 때, 각 입력 필드마다 별도의 handleChange 함수를 만드는 것은 비효율적입니다. 하나의 이벤트 핸들러 함수로 여러 입력 필드를 처리할 수 있습니다. 이를 위해서는 event.target.name 속성을 활용합니다.

예제: 여러 입력 필드 폼

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

function MultiInputForm() {
  const [formValues, setFormValues] = useState({
    username: '',
    email: '',
    password: ''
  });

  const handleChange = (event) => {
    // (1) event.target에서 name과 value를 비구조화 할당으로 추출
    const { name, value } = event.target;
    setFormValues({
      ...formValues, // (2) 기존 formValues 객체를 복사
      [name]: value // (3) 변경된 input의 name 속성에 해당하는 값만 업데이트
    });
  };

  const handleSubmit = (event) => {
    alert(`제출된 정보:\n사용자 이름: ${formValues.username}\n이메일: ${formValues.email}\n비밀번호: ${formValues.password}`);
    event.preventDefault();
  };

  return (
    <div style={{ border: '1px solid #6f42c1', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>회원가입 폼</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label>
            사용자 이름:
            <input
              type="text"
              name="username" // (4) name 속성을 추가하여 어떤 input인지 식별
              value={formValues.username}
              onChange={handleChange}
              style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
            />
          </label>
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label>
            이메일:
            <input
              type="email"
              name="email" // (4) name 속성
              value={formValues.email}
              onChange={handleChange}
              style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
            />
          </label>
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label>
            비밀번호:
            <input
              type="password"
              name="password" // (4) name 속성
              value={formValues.password}
              onChange={handleChange}
              style={{ marginLeft: '10px', padding: '8px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ccc' }}
            />
          </label>
        </div>
        <button type="submit" style={{ padding: '8px 15px', fontSize: '16px', backgroundColor: '#6f42c1', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
          가입하기
        </button>
      </form>
      <p style={{ marginTop: '15px' }}>
        입력된 사용자 이름: {formValues.username}<br />
        입력된 이메일: {formValues.email}
      </p>
    </div>
  );
}

export default MultiInputForm;
  • formValues state: 여러 입력 필드의 값을 하나의 객체로 관리합니다.
  • handleChange: event.target.name을 이용하여 어떤 입력 필드의 값이 변경되었는지 식별하고, 해당 필드의 값만 formValues 객체에 업데이트합니다. ...formValues는 기존 state를 복사하는 중요한 문법입니다. (객체 state를 업데이트할 때는 불변성(Immutability)을 지켜야 합니다. 이 개념은 나중에 더 자세히 다루겠습니다.)

비제어 컴포넌트 (선택적)

일반적으로 리액트에서는 제어 컴포넌트를 사용하여 폼을 다루는 것이 권장됩니다. 하지만 특정 상황(예: 파일 업로드, 외부 라이브러리 연동)에서는 폼 요소의 값을 직접 DOM에서 가져와야 할 때가 있습니다. 이 경우 비제어 컴포넌트 (Uncontrolled Component) 를 사용하고, ref 속성을 통해 DOM 요소에 직접 접근합니다.

import React, { useRef } from 'react';

function FileInputForm() {
  const fileInputRef = useRef(null); // useRef 훅으로 ref 생성

  const handleSubmit = (event) => {
    event.preventDefault();
    // ref.current를 통해 실제 DOM 요소에 접근하여 값 가져오기
    alert(`선택된 파일: ${fileInputRef.current.files[0].name}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="file" ref={fileInputRef} /> {/* ref 속성 연결 */}
      <button type="submit">파일 제출</button>
    </form>
  );
}
// 실제 파일 업로드는 더 복잡한 서버 통신을 포함합니다.

useRef 훅은 DOM 요소에 직접 접근할 때 사용됩니다. 하지만 이 방법은 폼 데이터 관점에서 보면 비제어 방식이므로, 가능한 한 제어 컴포넌트 방식을 사용하는 것이 리액트의 철학과 더 잘 맞습니다.


"폼 다루기 기초"는 여기까지입니다. 제어 컴포넌트의 개념을 명확히 하고, input, textarea, select와 같은 기본적인 폼 요소들을 useState 훅을 이용하여 어떻게 제어 컴포넌트로 다루는지 상세한 예제를 통해 설명했습니다. 특히 여러 입력 요소를 하나의 핸들러로 처리하는 방법까지 다루어 실용성을 높였습니다.

이제 여러분은 사용자와 상호작용하고 데이터를 입력받는 기본적인 폼을 리액트에서 구축할 수 있게 되었습니다. 다음 장에서는 컴포넌트의 생명 주기와 useEffect 훅을 통해 컴포넌트가 화면에 나타나고 사라지는 시점에 특정 작업을 수행하는 방법에 대해 더 깊이 있게 알아보겠습니다.