쏟아지는 사용자 입력 관리하기

profile
FE Developer - 기원
September 09, 2023
GPT 요약
Thumbnail

The pouring waves of Japan reinterpreted by Midjourney.

목차

  1. 들어가며
  2. 가장 일반적인 사용자 입력 폼 관리하기
  3. 보다 복잡한 사용자 입력 폼 관리하기
  4. 마치며

들어가며

안녕하세요. 🖐
IT팀 FE Developer 기원입니다.

IT팀에서 개발 중인 전자 차트는 사용자로부터 다양한 입력 폼을 통해 입력값을 제어하고 또 비즈니스 로직에 맞게 적절히 처리하고 있어요.

저 또한, 다양한 입력 폼을 개발하면서 사용자의 입력값을 관리하는 여러 가지 방법을 사용해 봤는데요. 이번 포스팅에서는 최근 유용하게 사용 중인 관리 방법에 대해 소개해 드릴게요.

가장 일반적인 사용자 입력 폼 관리하기

아래는 흔히 볼 수 있는 사용자 정보 입력 폼이에요.

기본_사용자_정보_입력_폼

사용자의 정보를 입력받고 저장하기 버튼을 눌러 입력값을 알맞게 처리하는 정말 기본적인 폼이에요. 위의 요구사항을 리액트에서 안내하고 있는 일반적인 방식으로 처리해 볼게요.

import { type ChangeEvent, type FormEvent, useState } from 'react';

export default function BasicForm() {
  const [name, setName] = useState('');
  const [userId, setUserId] = useState('');
  const [password, setPassword] = useState('');

  function handleNameChange(event: ChangeEvent<HTMLInputElement>) {
    setName(event.target.value);
  }

  function handleUserIdChange(event: ChangeEvent<HTMLInputElement>) {
    setUserId(event.target.value);
  }

  function handlePasswordChange(event: ChangeEvent<HTMLInputElement>) {
    setPassword(event.target.value);
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    console.log({
      name,
      userId,
      password,
    });
  }

  return (
    <form className="basic-form" onSubmit={handleSubmit}>
      <h1>사용자 정보 입력</h1>
      <div className="input-box">
        <label htmlFor="name">이름</label>
        <input
          id="name"
          name="name"
          type="text"
          placeholder="이름을 입력하세요"
          value={name}
          onChange={handleNameChange}
        />
      </div>
      <div className="input-box">
        <label htmlFor="userId">아이디</label>
        <input
          id="userId"
          name="userId"
          type="text"
          placeholder="아이디를 입력하세요"
          value={userId}
          onChange={handleUserIdChange}
        />
      </div>
      <div className="input-box">
        <label htmlFor="password">패스워드</label>
        <input
          id="password"
          name="password"
          type="password"
          placeholder="비밀번호를 입력하세요"
          value={password}
          onChange={handlePasswordChange}
        />
      </div>
      <button type="submit">저장하기</button>
    </form>
  );
}

리액트에서는 사용자의 입력값을 제어 컴포넌트 방식으로 사용하도록 안내하고 있어요. 이는 아이디, 패스워드의 양식을 검증하는 등 사용자의 입력값에 따라, 다른 UI 상태를 보여주기 위함이에요.

비제어 컴포넌트 방식을 사용한다면 입력값은 업데이트 되지만, UI는 업데이트되지 않기 때문이에요.
만약, 우리가 구현하고자 하는 입력 폼의 요구사항이 사용자의 입력값에 따라 UI 상태를 변경할 필요가 없다면 아래와 같이 비제어 컴포넌트 방식으로 입력값을 관리할 수 있어요.

import { type ChangeEvent, type FormEvent, useState, useRef } from 'react';

export default function BasicForm() {
  const nameInputRef = useRef<HTMLInputElement>(null); // 비제어로 변경

  // ...

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const name = nameInputRef.current?.value;

    console.log({
      name,
      userId,
      password,
    });
  }

  return (
    <form className="basic-form" onSubmit={handleSubmit}>
      <h1>사용자 정보 입력</h1>
      <div className="input-box">
        <label htmlFor="name">이름</label>
        <input
          ref={nameInputRef}
          id="name"
          name="name"
          type="text"
          placeholder="이름을 입력하세요"
        />
      </div>
      // ...
      <button type="submit">저장하기</button>
    </form>
  );
}

이처럼 사용자의 입력값을 비제어 컴포넌트 방식으로 관리한다면 불필요한 렌더링을 막을 수 있어요.

보다 복잡한 사용자 입력 폼 관리하기

지금까지 제어와 비제어 방식을 사용하여 기본적인 입력 상태를 관리하는 방법을 알아봤어요.

이제 기획이 변경되어 단일 페이지에서 사용자 정보 입력과 사용자 추가 정보 입력을 함께 받아야 하며, 나이와 성별은 사용자의 입력값에 따라 다른 UI 상태를 보여줘야 한다고 가정해 볼게요.

이제 우리는 아래와 같이 적절한 방식으로 입력 상태를 관리할 수 있어요.

  • 제어
    • 아이디
    • 패스워드
    • 나이
    • 성별
  • 비제어
    • 이름
    • 지역

이제 기존 코드에 몇 가지 상태를 추가하여 간단하게 변경 사항을 처리해 볼게요.

import { type ChangeEvent, type FormEvent, useState, useRef } from 'react';

export default function BasicForm() {
  const nameInputRef = useRef<HTMLInputElement>(null);
  const [userId, setUserId] = useState('');
  const [password, setPassword] = useState('');
  const [age, setAge] = useState(''); // 추가된 상태
  const [gender, setGender] = useState(''); // 추가된 상태
  const regionInputRef = useRef<HTMLInputElement>(null); // 추가된 상태

  function handleUserIdChange(event: ChangeEvent<HTMLInputElement>) {
    setUserId(event.target.value);
  }

  function handlePasswordChange(event: ChangeEvent<HTMLInputElement>) {
    setPassword(event.target.value);
  }

  function handleAgeChange(event: ChangeEvent<HTMLInputElement>) {
    // 추가된 핸들러
    setAge(event.target.value);
  }

  function handleGenderChange(event: ChangeEvent<HTMLInputElement>) {
    // 추가된 핸들러
    setGender(event.target.value);
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const name = nameInputRef.current?.value;
    const region = regionInputRef.current?.value;

    console.log({
      name,
      userId,
      password,
      age,
      gender,
      region,
    });
  }

  return (
    <form className="basic-form" onSubmit={handleSubmit}>
      // ...
      <h1>사용자 추가 정보 입력</h1>
      <div className="input-box">
        <label htmlFor="age">나이</label>
        <input
          id="age"
          name="age"
          type="number"
          placeholder="나이를 입력하세요"
          value={age}
          onChange={handleAgeChange}
        />
      </div>
      <div className="input-box">
        <span>성별</span>
        <div>
          <label htmlFor="male"></label>
          <input
            id="male"
            name="gender"
            type="radio"
            value="male"
            onChange={handleGenderChange}
          />
          <label htmlFor="female"></label>
          <input
            id="female"
            name="gender"
            type="radio"
            value="female"
            onChange={handleGenderChange}
          />
          <label htmlFor="other">기타</label>
          <input
            id="other"
            name="gender"
            type="radio"
            value="other"
            onChange={handleGenderChange}
          />
        </div>
      </div>
      <div className="input-box">
        <label htmlFor="region">지역</label>
        <input
          ref={regionInputRef}
          id="region"
          name="region"
          type="text"
          placeholder="지역을 입력하세요"
        />
      </div>
      <button type="submit">저장하기</button>
    </form>
  );
}

우리는 경험을 통해 기획은 언제든 수정될 수 있음을 알고 있어요. 여기서 또 다른 추가 정보를 받는 기획이 추가된다면 어떻게 될까요?

우리는 기존과 같이 코드에 추가된 몇 가지 상태와 핸들러를 추가하여 관리할 거예요. 그런데, 요구사항을 자세히 살펴보면 사용자 정보 입력과 사용자 추가 정보 입력은 서로 관련이 없어 보여요. 이는 관심사에 따라 서로 다른 컴포넌트로 적절히 분리될 수 있음을 의미해요. 분리하지 않고 하나의 컴포넌트로 관리하는 것은 유지보수에도 좋지 않을 거예요.

이제 관심사에 따라 각 컴포넌트로 분리해 볼게요.

사용자 정보 입력 컴포넌트

import { type ChangeEvent, useState, useRef } from 'react';

export default function UserInfoForm() {
  const nameInputRef = useRef<HTMLInputElement>(null);
  const [userId, setUserId] = useState('');
  const [password, setPassword] = useState('');

  function handleUserIdChange(event: ChangeEvent<HTMLInputElement>) {
    setUserId(event.target.value);
  }

  function handlePasswordChange(event: ChangeEvent<HTMLInputElement>) {
    setPassword(event.target.value);
  }

  return (
    <>
      <h1>사용자 정보 입력</h1>
      <div className="input-box">
        <label htmlFor="name">이름</label>
        <input
          ref={nameInputRef}
          id="name"
          name="name"
          type="text"
          placeholder="이름을 입력하세요"
        />
      </div>
      <div className="input-box">
        <label htmlFor="userId">아이디</label>
        <input
          id="userId"
          name="userId"
          type="text"
          placeholder="아이디를 입력하세요"
          value={userId}
          onChange={handleUserIdChange}
        />
      </div>
      <div className="input-box">
        <label htmlFor="password">패스워드</label>
        <input
          id="password"
          name="password"
          type="password"
          placeholder="비밀번호를 입력하세요"
          value={password}
          onChange={handlePasswordChange}
        />
      </div>
    </>
  );
}

사용자 추가 정보 입력 컴포넌트

import { type ChangeEvent, useState, useRef } from 'react';

export default function UserAddInfoForm() {
  const [age, setAge] = useState('');
  const [gender, setGender] = useState('');
  const regionInputRef = useRef<HTMLInputElement>(null);

  function handleAgeChange(event: ChangeEvent<HTMLInputElement>) {
    setAge(event.target.value);
  }

  function handleGenderChange(event: ChangeEvent<HTMLInputElement>) {
    setGender(event.target.value);
  }

  return (
    <>
      <h1>사용자 추가 정보 입력</h1>
      <div className="input-box">
        <label htmlFor="age">나이</label>
        <input
          id="age"
          name="age"
          type="number"
          placeholder="나이를 입력하세요"
          value={age}
          onChange={handleAgeChange}
        />
      </div>
      <div className="input-box">
        <span>성별</span>
        <div>
          <label htmlFor="male"></label>
          <input
            id="male"
            name="gender"
            type="radio"
            value="male"
            onChange={handleGenderChange}
          />
          <label htmlFor="female"></label>
          <input
            id="female"
            name="gender"
            type="radio"
            value="female"
            onChange={handleGenderChange}
          />
          <label htmlFor="other">기타</label>
          <input
            id="other"
            name="gender"
            type="radio"
            value="other"
            onChange={handleGenderChange}
          />
        </div>
      </div>
      <div className="input-box">
        <label htmlFor="region">지역</label>
        <input
          ref={regionInputRef}
          id="region"
          name="region"
          type="text"
          placeholder="지역을 입력하세요"
        />
      </div>
    </>
  );
}

최종 입력 값 전달 컴포넌트

import { type FormEvent } from 'react';
import UserAddInfoForm from '../Form/UserAddInfoForm';
import UserInfoForm from '../Form/UserInfoForm';

export default function BasicForm() {
  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    // TODO: 사용자 정보와 추가 정보 가져오기
  }

  return (
    <form className="basic-form" onSubmit={handleSubmit}>
      <UserInfoForm />
      <UserAddInfoForm />
      <button type="submit">저장하기</button>
    </form>
  );
}

관심사에 따라 컴포넌트를 적절히 분리했어요. 이를 통해 우리는 이전보다 좀 더 나은 코드를 가지게 되었어요. 그런데, 최종 입력값을 전달하는 부모 컴포넌트에서 저장하기 버튼을 눌렀을 때, 우리는 어떻게 사용자 정보와 추가 정보를 자식 컴포넌트에서 가져와 적절히 처리할 수 있을까요?

리액트는 부모에서 자식, 즉, 위에서 아래로 상태 값을 전달하는 방식을 채택하고 있는데요. 그렇기에 일반적으로 부모 컴포넌트에서 각 상태를 가지고 있고 상태를 변경하는 핸들러를 분리된 자식 컴포넌트에 Props로 넘겨주며 부모 상태를 변경하는 방식을 사용해요.

하지만 이 방식을 사용하면 자식 컴포넌트에서 사용하는 입력값이 추가될 때마다, 부모 컴포넌트에서 자식 컴포넌트의 상태를 추가하고 핸들러를 작성하여 자식에게 넘겨줘야 할 거예요.

즉, 컴포넌트가 분리되었지만, 실제 상태(관심사)는 적절하게 분리되지 않았음을 의미해요. 그렇다면 어떻게 자식 컴포넌트의 값을 부모 컴포넌트에서 알 수 있을까요?

바로 리액트에서 제공하는 forwardRefuseImperativeHandle을 사용하여 해결할 수 있어요. 아래에서 함께 살펴보시죠.

forwardRef와 useImperativeHandle로 자식 컴포넌트 값 사용하기

자식 컴포넌트(값 전달)

// UserInfoForm
import {
  type ChangeEvent,
  useState,
  useRef,
  forwardRef,
  useImperativeHandle,
} from 'react';

const UserInfoForm = forwardRef(function ({}, ref) {
  // ...

  function getUserInfo() {
    const name = nameInputRef.current?.value;

    return {
      name,
      userId,
      password,
    };
  }

  useImperativeHandle(
    ref,
    () => {
      return {
        getUserInfo,
      };
    },
    [userId, password]
  );

  return <>// ...</>;
});

export default UserInfoForm;

// UserAddInfoForm
import {
  type ChangeEvent,
  useState,
  useRef,
  forwardRef,
  useImperativeHandle,
} from 'react';

const UserAddInfoForm = forwardRef(function ({}, ref) {
  // ...

  function getUserAddInfo() {
    const region = regionInputRef.current?.value;

    return {
      age,
      gender,
      region,
    };
  }

  useImperativeHandle(
    ref,
    () => {
      return {
        getUserAddInfo,
      };
    },
    [age, gender]
  );

  return <>// ...</>;
});

export default UserAddInfoForm;

부모 컴포넌트(값 사용)

import { useRef, type FormEvent } from 'react';
import UserAddInfoForm from '../Form/UserAddInfoForm';
import UserInfoForm from '../Form/UserInfoForm';

export default function BasicForm() {
  const userInfoFormRef = useRef(null);
  const userAddInfoFormRef = useRef(null);

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    console.log({
      ...userInfoFormRef.current?.getUserInfo(),
      ...userAddInfoFormRef.current?.getUserAddInfo(),
    });
  }

  return (
    <form className="basic-form" onSubmit={handleSubmit}>
      <UserInfoForm ref={userInfoFormRef} />
      <UserAddInfoForm ref={userAddInfoFormRef} />
      <button type="submit">저장하기</button>
    </form>
  );
}

분리_후_저장하기_결과

저장하기 버튼을 누르면 자식 컴포넌트에서 넘겨준 입력값을 잘 가져오는 것을 볼 수 있어요. 부모 컴포넌트의 코드를 보면 사용자 정보와 추가 정보에 대한 코드는 볼 수 없어요.

이는 부모 컴포넌트에서는 자식 컴포넌트에서 관리하는 상세 정보들을 알 필요가 없고 오로지 저장하는 기능에 관해서만 관심을 가졌음을 의미해요.

타입스크립트를 사용하여 코드를 작성하셨다면 자식 컴포넌트에서 어떤 값을 부모(상위)로 제공하고 있는지 미리 선언하여 제공할 수 있어요.

자식 컴포넌트에서 타입 제공하기

// 자식 컴포넌트
export type UserInfoFormRef = {
  getUserInfo: () => { name: string, userId: string, password: string }
}

const UserInfoForm = forwardRef<UserInfoFormRef>(function({}, ref) {...}

// 부모 컴포넌트
import UserInfoForm, { type UserInfoFormRef } from '../Form/UserInfoForm';

export default function BasicForm() {
  const userInfoFormRef = useRef<UserInfoFormRef>(null);
  // ...
}

자동완성

이처럼 자식 컴포넌트에서 타입을 제공하여 부모 컴포넌트에서 안전하게 개발할 수 있어요. 위와 같이 직접 타입을 제공할 수도 있지만 ElementRef를 통해서도 가능해요.

// 자식 컴포넌트
type UserAddInfoFormRef = {
  getUserAddInfo: () => { age: string, gender: string, region: string }
}

const UserAddInfoForm = forwardRef<UserAddInfoFormRef>(function({}, ref) {...}

// 부모 컴포넌트
import { type ElementRef, useRef } from 'react';
import UserAddInfoForm from '../Form/UserAddInfoForm';

export default function BasicForm() {
  const userAddInfoFormRef = useRef<ElementRef<typeof UserAddInfoForm>>(null);
  // ...
}

공유되는 상태가 존재하는 경우

기획이 변경되어 사용자의 성별을 사용자 정보 입력 타이틀에 함께 보여줘야 해요. 지금까지는 각 컴포넌트의 관심사가 완전히 분리되어 있어 상관이 없었지만, 특정 상태가 공유되어야 하는 경우에는 어떻게 처리할 수 있을까요?

리액트에서는 공유되는 상태가 존재한다면 상태 끌어올리기 방식을 사용하라고 안내하고 있어요. 안내와 같이 상태 끌어올리기 방식으로 구현해 볼게요.

// 사용자 추가 정보 입력 컴포넌트(상태 끌어올리기 대상)
type UserAddInfoFormProps = {
  gender: string;
  onChangeGender: (gender: string) => void;
}

const UserAddInfoForm = forwardRef<UserAddInfoFormRef, UserAddInfoFormProps>(function({ gender, onChangeGender }, ref) {
  const [age, setAge] = useState('');
  // gender 상태 부모로 끌어올리기

  function handleGenderChange(event: ChangeEvent<HTMLInputElement>) {
      onChangeGender(event.target.value); // props로 전달 받은 핸들러로 변경
  }
  // ...
}

// 사용자 정보 입력 컴포넌트(상태 사용 대상)
type UserInfoFormProps = {
  gender: string;
}

const UserInfoForm = forwardRef<UserInfoFormRef, UserInfoFormProps>(function({ gender }, ref) {
  // 전달 받은 상태를 적절히 사용
}

// 부모 컴포넌트
import { type ElementRef, useRef, type FormEvent, useState } from 'react';
import UserAddInfoForm from '../Form/UserAddInfoForm';
import UserInfoForm, { type UserInfoFormRef } from '../Form/UserInfoForm';

export default function BasicForm() {
  const userInfoFormRef = useRef<UserInfoFormRef>(null);
  const userAddInfoFormRef = useRef<ElementRef<typeof UserAddInfoForm>>(null);
  const [gender, setGender] = useState(''); // 상태 끌어올리기

  function onChangeGender(gender: string) {
    setGender(gender)
  }

  // ...

  return (
      <form className="basic-form" onSubmit={handleSubmit}>
        <UserInfoForm ref={userInfoFormRef} gender={gender}/>
        <UserAddInfoForm
          ref={userAddInfoFormRef}
          gender={gender}
          onChangeGender={onChangeGender}
        />
        <button type="submit">저장하기</button>
      </form>
  );
}

이처럼 상태 끌어올리기를 통해 공용으로 관심 있는 상태를 부모 컴포넌트로 위치시키며 분리된 컴포넌트에서 동일한 상태를 볼 수 있어요.

초기화 기능 추가하기

이번에는 사용자의 입력값을 초기화할 수 있는 버튼이 기획에 추가되었어요. 우리는 기존 코드를 활용하여 빠르게 반영해 볼 수 있어요. 바로 반영해 볼게요.

// 사용자 정보 입력 컴포넌트
export type UserInfoFormRef = {
  getUserInfo: () => { name: string, userId: string, password: string }
  resetUserInfo: VoidFunction; // 초기화 함수 타입 선언
}

const UserInfoForm = forwardRef<UserInfoFormRef, UserInfoFormProps>(function({ gender }, ref) {
  function resetUserInfo() { // 초기화 함수 구현
    nameInputRef.current!.value = '';
    setUserId('')
    setPassword('');
  }

  useImperativeHandle(ref, () => {
    return {
      getUserInfo,
      resetUserInfo, // 초기화 함수 전달
    }
  }, [userId, password])
}

// 사용자 추가 정보 입력 컴포넌트
type UserAddInfoFormRef = {
  getUserAddInfo: () => { age: string, gender: string, region: string }
  resetUserAddInfo: VoidFunction // 초기화 함수 타입 선언
}

const UserAddInfoForm = forwardRef<UserAddInfoFormRef, UserAddInfoFormProps>(function({ gender, onChangeGender }, ref) {
  function resetUserAddInfo() { // 초기화 함수 구현
    setAge('');
    onChangeGender('');
    regionInputRef.current!.value = '';
  }

  useImperativeHandle(ref, () => {
    return {
      getUserAddInfo,
      resetUserAddInfo, // 초기화 함수 전달
    }
  }, [age, gender])
}

// 부모 컴포넌트
export default function BasicForm() {
  function reset() { // 전체 초기화 함수 구현
    userInfoFormRef.current?.resetUserInfo();
    userAddInfoFormRef.current?.resetUserAddInfo();
  }

  return (
    <>
     // ...
        <div className='button-box'>
          <button type='reset' onClick={reset}>초기화하기</button> // 초기화 액션 추가
          <button type="submit">저장하기</button>
        </div>
    </>
  )
}

reset

초기화가 잘 동작하는 것을 볼 수 있네요! 이처럼 forwardRefuseImperativeHandle을 사용해서 자식 컴포넌트에서 상위로 전달하고 싶은 것들만 선택적으로 제공할 수 있어요.

지금까지의 코드는 여기에서 확인할 수 있어요.

마치며

지금까지 여러 경우의 사용자 입력을 관리하는 방법에 대해 함께 알아봤는데요. 이번 포스팅에서는 관심사에 맞게 컴포넌트를 적절히 분리할 방법들을 소개해 드렸어요.

웹을 개발하다 보면 사용자 입력을 받는 경우를 흔히 마주할 수 있어요. 다양한 입력값이 하나의 페이지, 팝업 등에서 입력되기도 하고요. 우리는 입력값을 받아 적절한 비즈니스 로직을 녹여내야 하는 역할도 함께 가지고 있어요. 이러한 상황 속에서 관심사에 맞게 컴포넌트를 분리하는 것은 선택이 아닌 필수라고 생각해요.

이번 포스팅에서는 언급되지 않았지만, 입력 상태를 쉽게 관리할 수 있는 라이브러리인 react-hook-form을 마지막으로 소개해드리며 포스팅을 마무리하도록 할게요.

감사합니다👋

profile
안녕하세요 👏
FE Developer 기원입니다.