올리브영 테크블로그 포스팅 새 배송지 추가 form 개발하기
Frontend

새 배송지 추가 form 개발하기

React에서 form 영역을 어떻게 개발했는지 공유합니다.

2023.09.18

안녕하세요. 올리브영에서 눈에 보이는 것들을 담당하고 있는 꾸옹입니다😺

📢 지난 6월 올리브영 온라인몰의 메인인 홈도 신규 아키텍처 기반의 서비스로 전환되었다는 것을 기억하며 읽어주세요! 이 글에서는 배송지 버튼부터 배송지 목록 및 새 배송지 추가 화면 개발 과정의 일부를 소개합니다.

1 mainImage

홈의 가장 상단에 위치한 오늘드림 배송지 버튼을 통해 배송지를 선택하고 선택된 주소지 기반으로 상품의 오늘드림 가능 여부를 확인할 수 있습니다. 하지만…! 최근 셔터가 오픈하면서 하단 메뉴 탭바 구성이 바뀌었고 햄버거 아이콘 버튼(드로우 메뉴 버튼)이 오늘드림 버튼 자리로 입주했습니다. 1 1 menu

😢 새로운 버전의 서비스에서는 이미 사라져 버렸지만, 기억 속에서 아주 잊히기 전 열심히 만들었던 새 배송지 추가 화면에 관해 이야기해 보려고 합니다.

어떻게 만들어볼까?

배송지 목록 하단의 “새 배송지 추가” 버튼을 누르면 다음과 같은 새 배송지 추가 화면이 나옵니다. 2 addressModal

새 배송지 추가 화면은 고객이 새로운 배송지를 등록하기 위해 기본 정보를 입력하고 최종적으로 서버에 정보를 제출하는 form 영역입니다. 화면에는 단순히 문자를 입력받는 부분도 있고 공동현관 출입방법 항목과 같은 복합적인 UI나 외부 지도 API를 통해 받은 주소 정보로 채우는 것도 있습니다. 새 배송지를 추가하려는 고객은 이 화면에 어떤 입력이나 선택을 하든 마지막 “확인” 버튼을 눌렀을 때만큼은 입력한 모든 정보가 잘 전달되기만을 바라고 있을 겁니다. 그러면 여기서 제 할 일은 무엇일까요?

“확인” 버튼이 눌린 순간 꼭 입력되어야 할 것은 입력되었는지 정보의 형식은 올바른지 등을 체크하고 잘못된 입력값이 있다면 수정하도록 알리기도 해야 합니다. 모두 잘 입력되었다면 정보들을 빠짐없이 싹 정리해서 API를 호출하며 마무리 짓는 것입니다. 저는 이 과정을 어떻게 하면 복잡하지 않고 안정적으로 잘 만들 수 있을까 고민했고 (결론을 먼저 공개하면) “React Hook Form”이라는 라이브러리와 함께 이 form 영역을 개발했습니다.

그렇다면 이 결론에 도달하게 된 배경을 저의 의식의 흐름대로 몇 가지 실험을 통해 공유해 보겠습니다!

실험👩‍🔬

실험을 위해 아주 간단하고 못생긴 추가 화면을 하나 만들어 보았습니다. 현재 올영 신규 서비스의 프론트엔드 기술 스택인 Next.js로 프로젝트를 구성하였습니다. (JavaScript와 Tailwind CSS를 사용했다는 점이 다르지만 실험에 영향을 주는 부분은 아니기 때문에 코드상에서는 무시하겠습니다.)

  • 참고) 앞으로 나올 예시 코드는 중요하거나 설명에 필요한 부분들만 남겼습니다.
3 testPage

이번 실험에서 사용할 화면입니다. 배송지 명에서부터 확인 버튼까지를 하나의 컴포넌트로 묶어 <Form>이라고 정의했고 코드는 다음과 같습니다.

const Form = () => {
  return (
    <form>
                <FormTextField label={"배송지 명"} />
                <FormTextField label={"받으실 분"} />
                <FormTextField label={"휴대폰 번호"} />
                <AddressField />
                <EntranceRadio />
                <label>
                    <input type="checkbox" /> 공동현관 출입방법 저장 동의
                </label>
                <button>확인</button>
          </form>
  );
};

그럼, 이 화면에 고객이 입력한 정보를 잘 관리해서 서버까지 제출할 수 있을지 이제부터 여러 가지 방면으로 고민하며 실험해 보겠습니다.

[실험 1] 각 영역의 데이터를 상태로 관리

먼저 입력받고자 하는 데이터를 <Form> 컴포넌트 내부의 상태로 정의해 봅니다. useState로 정의하고 input에서 change 이벤트가 발생할 때마다 상태를 업데이트하며 최신 입력한 정보로 유지합니다.

/* <Form> 컴포넌트 */
import { useState } from "react";
import FormTextField from "./FormTextField";

const FormWithState = () => {
    const [addressName, setAddressName] = useState(""); // 배송지 명
    const [receiver, setReceiver] = useState(""); // 받으실 분

    return (
      <form>
                      <FormTextField
                          label={"배송지 명"}
                          onChange={(e) => {
                            setAddressName(e.target.value); // 입력이 들어올 때마다 상태 업데이트
                          }}
                      />
                      ..............
                </form>
    );
};

form의 onSubmit에서는 데이터 유효성 체크 로직도 추가해 보고 최종적으로 데이터도 잘 가져오고 있는지 확인해 봅니다.

<form
      onSubmit={(e) => {
          e.preventDefault(); // form의 기본 동작으로 페이지가 넘어가는 것을 방지함
          if (addressName === "") {
              alert("배송지를 입력해주세요");
              return;
          }

          const data = {
              addressName,
              receiver,
          };
          console.log("등록하기 ", data);
      }}
>

이렇게 해도 form 제출까지 별문제는 없지만 React Developer Tools를 활용해서 성능적인 면도 살펴보겠습니다. 개발자 도구를 통해 컴포넌트 렌더링이 발생할 때 하이라이트를 주도록 해보았습니다. 입력할 때마다 컴포넌트의 상태 변화를 감지했기 때문에 재렌더링이 발생하며 전체적으로 하이라이트가 표시됩니다.

4 renderHighlight

간단하게 필드 몇 개의 상태만 정의해 보았는데 나머지 영역의 상태 관리까지 추가한다면 복잡도가 많이 올라갈 거 같고 불필요한 재랜더링 문제도 마음에 걸립니다.

다음 실험으로 넘어가 보겠습니다.

[실험 2] Context API

실험 1은 각 영역에 대해 useState로 데이터 상태를 정의하고 입력이 들어올 때마다 상태를 업데이트해 주기 위해 change 이벤트 핸들러를 달아주었습니다. 만약 입력 필드의 컴포넌트가 복잡하고 깊어진다면 계속 props로 전달해 줘야 합니다. 이런 불편을 해소하기 위해 Context를 사용해 보겠습니다.

실험 1과 같이 context로 관리할 데이터는 최상위인 <Form> 컴포넌트에서 정의했고 컴포넌트를 Provider로 감싸줍니다. 그럼 Provider로 감싸진 자식 컴포넌트에서 props가 아닌 context를 통해 공통의 값을 가져와 사용할 수 있습니다.

/* <Form> 컴포넌트 */
import { useState } from "react";
import FormTextFieldContext from "@/components/Context/FormTextFieldContext";
import { AddressContext } from "@/context/AddressContext";

const FormWithContext = () => {
  const [addressData, setAddressData] = useState({
    addressName: "",
    receiver: "",
    phoneNumber: "",
  });

 return (
    <AddressContext.Provider
      value={{
        value: addressData,
        setValue: (id, value) => {
          setAddressData((prev) => ({ ...prev, [id]: value }));
        },
      }}
    >
      <form
                      onSubmit={(e) => {
                        e.preventDefault();
                        console.log("등록하기 ", addressData);
                      }}
              >
                      <FormTextFieldContext label={"배송지 명"} id="addressName" />
                      ..............
                </form>
    </AddressContext.Provider>
  );
};
/* 입력 필드 컴포넌트 */
const FormTextFieldContext = ({ label, id }) => {
  const addressContext = useContext(AddressContext); // context 가져옴
  return (
    <label>
                {label}
                <input
                        type="text"
                        onChange={(e) => {
                                const text = e.target.value;
                                addressContext.setValue(id, text); // context의 공통 set 함수를 사용
                        }}
                />
            </label>
  );
};

이렇게 context로부터 change 이벤트 핸들러를 가져와 최상위에 위치한 <Form> 컴포넌트의 데이터를 업데이트해 줍니다. 실험 1에 비해 props로 직접 전달해주지 않아도 된다는 점은 좋아졌지만 <Form> 컴포넌트 내부에서 여전히 상태로 관리하고 있기 때문에 입력이 들어올 때마다 직접 업데이트도 해주어야 하는 것은 물론 실험 1번과 마찬가지로 재랜더링이 발생합니다.

[실험 3] useRef

1, 2의 실험에서는 <Form> 컴포넌트 내부에서 전체 필드의 데이터를 상태로 유지하며 입력받을 때마다 상태를 업데이트해 주었습니다. 이에 따라 입력받을 때마다 화면이 다시 그려지는 아쉬움이 있었는데 불필요한 렌더링을 줄여볼 수 있을까요?

React 공식 문서에서는 상태가 변하며 렌더링이 필요가 없는 상황에서는 ref를 사용하도록 제안하고 있고 ref로 DOM 원소 자체도 직접 가리킬 수 있기 때문에 이러한 form을 만들 때 적합할 거 같다는 생각이 듭니다.

/* <Form> 컴포넌트 */
import { useRef } from "react";
import FormTextFieldRef from "@/components/Ref/FormTextFieldRef";

const FormWithRef = () => {
  const addressNameRef = useRef("");

  return (
    <form
                onSubmit={(e) => {
                    e.preventDefault();
                    const addressName = addressNameRef.current.value;

                    if (addressName === "") {
                      addressNameRef.current.focus(); // 문제가 되는 부분에 포커스를 줄 수 있음
                      return;
                    }

                    const data = {
                      addressName,
                    };
                    console.log("등록하기 ", data);
              }}
          >
                <FormTextFieldRef label={"배송지 명"} ref={addressNameRef} /> // ref를 그대로 넘겨줌
                ..............
          </form>
  );
};
const FormTextFieldRef = forwardRef(({ label }, ref) => {
  return (
    <label>
                  {label}
                  {/* ref를 그대로 넘겨줌 */}
                  <input ref={ref} type="text" />
          </label>
  );
});

최상단 <Form> 컴포넌트에서 ref를 정의하고 실제 입력 필드의 input까지 그대로 넘겨주면 직접 DOM 원소에 접근하여 입력값을 가져올 수 있습니다. 실험 1, 2의 change 이벤트 핸들링을 해주지 않아도 되고 무엇보다 입력할 때마다 상태가 변하지 않기 때문에 재렌더링이 발생하지 않습니다.

그리고 ref를 사용함으로 focus를 직접 주는 것도 가능해지면서 꼭 입력해야 하는 필드인데 값이 없거나 형식에 맞지 않은 입력이 들어온 영역으로 포커스를 줌으로 사용자에게 알림을 줄 수 있습니다. State나 Context로 관리하는 상황에서도 이 기능을 위해 ref를 사용해야 했을 겁니다.

이렇게 보면 ref를 사용하는 건 꽤 괜찮은 선택인 것 같습니다. 하지만 form의 입력 영역 개수가 많아지고 컴포넌트가 복잡해진다면 ref 정의, 데이터 유효성 체크 등을 챙겨야 하는데 개발 복잡도가 올라가게 될 거 같습니다.

지금까지 한 이런 고민 저만 하는 거 아닐 거 같다는 생각이 스치면서 라이브러리를 찾아보게 되었습니다.

[실험 4] React Hook Form 사용해보기

마지막 실험입니다! 위 실험들은 React Hook Form까지 오기 위한 밑 작업이었습니다.

React Hook Form을 사용한 예시를 먼저 보여드리겠습니다. Hook으로 제공되어 적용하기 어렵지 않았습니다. 기본적인 사용법은 register라는 함수로 입력 영역을 key 값으로 등록하고 해당 영역의 입력 규칙들도 함께 등록할 수 있습니다. 등록하고 나면 각 영역의 데이터를 개발자가 직접 관리하지 않아도 되고 유효성 체크도 간편하게 됩니다.

/* <Form> 컴포넌트 */
import { useForm } from "react-hook-form";
import FormTextFieldHookForm from "@/components/HookForm/FormTextFieldHookForm";

const FormWithHookForm = () => {
  const { register, handleSubmit } = useForm();
  const onSubmit = handleSubmit(
    (data) => console.log("등록하기", data),
    (data) => {
      console.log("입력 오류", data);
    }
  );
  return (
    <form onSubmit={onSubmit}>
                <FormTextFieldHookForm
                  label={"배송지 명"}
                  {...register("ADDRESS_NAME", {
                        required: "배송지 명을 입력하세요",
                        maxLength: 10,
                  })}
                />
                .................
          </form>
  );
};

아래 코드는 입력 필드 예시인데 실험 3에서 보았던 코드처럼 ref를 전달합니다. React Hook Form도 내부적으로 ref를 사용한다는 것이겠죠? React 개발자 도구로 렌더링 하이라이트를 확인해 보면 입력할 때마다 하이라이트 되는 부분이 없습니다. 그래서인지 라이브러리 공식 홈에서는 불필요한 렌더링도 없애고 성능적으로도 좋다고 자신 있게 말하고 있습니다.

/* 입력 필드 컴포넌트 */
const FormTextFieldHookForm = forwardRef(({ label, ...rest }, ref) => {
  return (
    <label>
                  {label}
                  <input ref={ref} type="text" {...rest} />
          </label>
  );
});

데이터 유효성 체크 예시도 보여드리겠습니다. 예를 들어 휴대폰 번호 영역에 입력될 수 있는 값의 형식을 pattern으로 정의해 둘 수 있습니다.

<FormTextFieldHookForm
      label={"휴대폰 번호"}
      {...register("PHONENUMBER", {
            required: "휴대폰 번호를 입력하세요",
            maxLength: 13,
            pattern: {
              value: /010-([0-9]{4})-([0-9]{4})/g,
              message: "휴대폰 번호를 정확하게 입력해주세요",
            },
  })}
/>

만약 입력값에 휴대폰 번호 형식이 아닌 이상한 값이 들어왔다면 handleSubmit의 에러 처리 함수로 등록된 규칙과 메시지를 그대로 알려줍니다.

{
  PHONENUMBER: {
    message: "휴대폰 번호를 정확하게 입력해주세요",
    type: "pattern",
    ref: input.border-2
  }
}

실제 개발할 때는 register 함수만 사용하지는 않았습니다. 하나의 입력 영역이 단순히 <input> 으로 되어있는 것이 아니라 여러 컴포넌트의 조합으로 되어있기 때문에 register 함수의 반환 값들을 자식 컴포넌트의 props로 input이 있는 곳까지 계속 넘겨줘야 했습니다. 그런데 심지어 잘 넘겼다고 생각했는데 input이 제대로 등록이 안 되어 입력값을 받아오지 못하는 등 이런저런 삽질을 하기도 했는데 이때 Context API 기반의 FormProvider를 사용하여 부모로부터 먼 input도 간편하게 등록할 수 있었습니다. 이렇게 다양한 form의 형태도 지원할 수 있게 제공하고 있어 라이브러리를 사용하는데 큰 어려움은 없었습니다.

마무리

지금까지 가장 기본적이고 간단한 예시를 기반으로 보여드렸습니다. 저의 결론이 정답이 아니며 React Hook Form이 아닌 다른 방법으로도 form을 잘 만들 수 있습니다. 하지만 이 라이브러리를 사용하면서 복잡하지 않게 데이터를 잘 관리할 수 있었으며 개발 만족도도 높았기 때문에 추천합니다.

혹시 누군가 form 영역을 개발할 때 고민되는 부분을 해결하고 이 방법을 한번 써보자! 라는 결론을 내리는 데 도움이 되었으면 좋겠습니다.

진짜 마지막

🧪 [번외 실험] Formik

Formik은 React form 개발 라이브러리로 검색했을 때 React Hook form 라이브러리와 함께 높은 순위로 추천하는 라이브러리입니다. 이것도 한번 사용해 보겠습니다. Formik은 아예 Form 컴포넌트를 제공하고 있습니다. 공식 문서의 기본 예제를 따라 해 보았는데 간단한 form을 구성하기에는 나쁘지 않을 거 같습니다.

import { Field, Form, Formik } from "formik";
const FormWithFormikComponent = () => {
  return (
    <Formik
                  initialValues={{ addressName: "" }}
                  onSubmit={(values, actions) => {
                    console.log("등록하기", values);
                  }}
          >
                  <Form>
                          <label htmlFor="addressName">배송지 명</label>
                          <Field id="addressName" name="addressName" className={"border-2"} />
                  </Form>
        </Formik>
  );
};

그리고 hook도 제공하고 있는 데 살짝 사용해보겠습니다. Hook이 Formik 자체 컴포넌트를 사용할 때보다 개발의 자유도가 높아질 거 같습니다.

/* Form 컴포넌트 */
import { useFormik } from "formik";
const FormWithFormik = () => {
  const formik = useFormik({
    initialValues: {
      addressName: "",
    },
    onSubmit: (values) => {
      console.log("등록하기 ", values);
    },
  });

  return (
    <form onSubmit={formik.handleSubmit}>
                <label>
                  배송지 명
                  <input
                          id="addressName"
                          name="addressName"
                          type="text"
                          onChange={formik.handleChange}
                          value={formik.values.addressName}
                  />
                </label>
              ...............
          </form>
  );
};

Formik도 기본 사용법 정도만 살펴보았는데 React Hook Form과 가장 크게 다르다고 생각되는 부분은 상태 관리 부분입니다. Formik은 각 영역의 상태를 정의하고 change 이벤트로 상태도 업데이트해 주는 것으로 보입니다. 실험 1, 2번과 같이 개발자 도구로 살펴보면 입력할 때마다 렌더링 하이라이트도 되고 있습니다. form의 복잡도와 활용을 어떻게 하느냐에 다르겠지만 불필요한 렌더링이 발생할 수 있습니다.

참고 문서

FrontEnd
올리브영 테크 블로그 작성 새 배송지 추가 form 개발하기
😺
꾸옹 |
Front-end Engineer
눈에 보이는 것들을 담당하고 있습니다~!