본문 바로가기

문제 해결하기 - FE

react-hook-form 컴포넌트 의존성 관리하기

회사에서 하고 있던 작업에서 회원가입 관련 내용이 있었습니다. 
react-hook-form을 이용해서 만들었습니다. 만들고 나서 흥미로운 블로그 글을 읽었고, 그 내용을 바탕으로 저의 코드를 고쳐봤습니다. 

항상 코드를 개발하면서, 재사용 가능하도록 만들어야지, 유지보수를 쉽게 할 수 있도록 설계해야지 하면서 그러기 위한 특별한 무엇가가 없었습니다. 사실 방법을 몰랐다해도 될정도 였습니다. 그래서 이글에서는 어떻게 컴포넌트를 만들면 좋을지에 대해 고민했던 내용을 담아봤습니다. 


"의존성"

이번 개발을 하면서 "의존성"이라는 키워드에 집중했습니다. 여러 글을 보면서 항상 중요하게 나오는 키워드인데요. 
"의존성"은 말 그대로 다른것에 의존한다 뜻입니다. 그렇다면 좋은 컴포넌트를 만들기 위해서는 "의존성"을 어떻게 설계하면 좋을까요?

"의존성"이 적을 수 록 쉽게 말해 다른것에 의존하지 않을 수 록 좋은 컴포넌트를 만들 수 있다고합니다. 의존 관계가 컴포넌트와 컴포넌트 일 수 도 있고, 데이터와 컴포넌트, 유저 액션과 컴포넌트 일 수 도 있지만 뭐가 됬든 다른것에 의존하지 않을 수 록 잘 설계된 컴포넌트라고 할 수 있습니다. 

 


"의존성"이 적은 컴포넌트의 장점

1. 컴포넌트의 가독성이 좋아진다. 

말그대로 컴포넌트를 볼 때, 쉽게 이해할 수 있고, 쉽게 수정할 수 있습니다. 다른것에 의존하지 않고 오직 자신의 역할만 정의하기 때문에 

코드의 구현부를 빠르게 파악할 수 있고, 코드를 수정할떄도 코드의 변경이 다른것에 영향을 미치지 않기 때문에 수정에도 용의합니다. 

 

2. 컴포넌트의 재사용성이 높아진다.

당연한 말이지만, 컴포넌트의 의존성이 낮을 수 록 사용하기가 쉽기 때문에 재사용할 가능성도 높아집니다. 이로 인해 생산성이 증가하고, 

코드의 중복이 줄어들면서 보일러플레이트도 줄 일 수 가 있습니다. 

 

 

3. 테스트 코드 작성하기가 쉬워진다.

의존성이 적은 컴포넌트는 다른 컴포넌트에 영향을 받지 않기 때문에 해당 컴포넌트만을 테스트할 수 있습니다. 그렇기 때문에 테스트 코드 작성에도 쉽게 작성할 수 있습니다. 

 


요구사항 정리하기 

회원가입 & 가입 상세 정보 확인 할때 필요한 요구사항들을 정리하고, 그것을 바탕으로 비즈니스 로직과 컴포넌트를 분류해야합니다. 

  • 회원가입시 필요한 정보는, 아이디, 비밀번호, 비밀번호 확인, 직급, 부서, 성명, 이메일, 상태이다.
  • 직급과 부서는 select-box로 구현되어야 하며, 클릭 시 클릭한 값이 화면에 보여야한다.
  • select-box에서는 hover 시 해당 item의 배경색을 변경한다.
  • 상태는 radio-box로 구현되어야 하며, "활성화" / "비활성화"로 나뉜다. 
  • 각각의 항목들은 필수값 입력에 대한 유효성 검사가 존재한다.
  • 아이디는 유효성 조건으로 "영문자/숫자가 포함된 6~20자리이내의 아이디 입력" 이다. 
  • 비밀번호에 대한 유효성 조건은 "문자/숫자/특수문자가 포함된 8 ~ 20 자이내의 비밀번호" 이다.
  • 이메일에 대한 유효성 조건은 이메일 형식에 맞는지에 대한 유효성이 존재한다. 
  • 값은 ReadOnly mode가 존재하고 상위로 부터 받은 데이터를 받아서 보여주는 형식이다. 
  • ...

 

컴포넌트 역할 분리 & 비즈니스 로직

 

우선 간단한것은 회원가입을 완료하고, 상세정보를 확인할 때 데이터를 어떻게 받아와서 처리할것인지에 대한 비즈니스 로직을 정리해봤습니다. 

 

1. 비즈니스 로직 

  • 회원가입 정보에 대한 데이터는 id, password, name, email, department, position 정보를 가진다.
  • 모든 항복은 isReadOnly(boolean) 항목을 가진다. 

2. form 태그 

  • react-hook-form을 이용한 form태그를 생성한다. 
  • 데이터 관리를 위해 context-API 방식인 useFormContext() 방식을 사용한다.
  • props로는  data, isReadOnly, onSubmit을 받는다. 

 

3. Form Inpout 태그 

  • props로 받는 정보는 Input에 대한 기본 props를 extends 받고  label, name, layout을 추가적으로 받는다.
  • useFormContext()를 이용해서  register를 등록한다. 

4. Form SelectBox 태그 

  • headlessui/react를 이용해서 기본 틀을 잡는다. 
  • props로는 list, type, width, disabled를 받는다. 
  • useFormContext()와 selectHandler를 이용해서 값의 변화가 있을때, setValue를 통한 값변화를 일으킨다. 

 

 

 

Context API를 통해 상태를 공유한다면? 

리액트에서 Context API를 사용하여 컴포넌트 간의 상태 공유는 일반적인 패턴 중 하나입니다. 특히 useFormContext()와 같은 API를 사용함으로써, Form 태그와 그 안의 Input/Select-box와 같은 컴포넌트들이 상태를 공유하고 서로 데이터를 주고받는 구조를 만들 수 있습니다. 이런 상황에서는 컴포넌트들이 밀접하게 연결되어 함께 동작하게 되며, 이를 효과적으로 관리하기 위한 디자인 패턴으로 Compound-Components 패턴이 제안됩니다.

 

Compound-Components 패턴은 두 개 이상의 컴포넌트가 특정 작업을 위해 함께 작동할 때 유용합니다. 이 패턴을 사용하면 부모 컴포넌트가 자식 컴포넌트와 더 쉽게 통신할 수 있으며, 로직과 UI를 명확히 분리할 수 있습니다. 이로 인해 컴포넌트의 재사용성, 유연성, 그리고 가독성이 향상되며, 컴포넌트 간의 명확한 관계 설정이 가능해집니다.

 

Form 컴포넌트의 경우, 입력 값을 받는 컴포넌트가 반드시 필요합니다. 이때 Compound-Components 패턴을 적용하면 컴포넌트 간의 관계를 더 명확히 할 수 있으며, 이는 가독성과 재사용성의 측면에서도 이점을 가져옵니다. 이처럼 Compound-Components 패턴은 컴포넌트들이 서로 긴밀히 연관되어 있고 상태 공유가 필수적인 상황에서 매우 유용한 디자인 패턴이 됩니다.

 


비즈니스 로직 - data Type 

export type AdminFormType = {
  id: string;
  password: string;
  name: string;
  email: string;
  department: string;
  position: string;
};

 

앞서 비즈니스 로직에서 정의했듯이, 다음과 같은 타입을 가지고 데이터를 주고 받을 겁니다. 

 

Form Components - Properties

interface HookFormProps<T> {
  children: React.ReactNode;
  onSubmit?: (data: T) => Promise<any>;
  className?: string;
  schema?: z.ZodType<T>; // schema는 필수로 변경
  defaultValues?: Partial<T>;
}

 

 

제너릭을 사용한 이유? -  제너릭을 사용한 이유는 다양한 타입의 데이터를 받기 위함입니다. 이것도 "의존성"을 고려한 설정이라고 보면됩니다. 어떤 데이터 타입이 오든 유연하게 받을 수 있도록 T(제너릭)타입을 사용했고, onSubmit에 매개변수와 defaultValues의 타입값을 동일하게 가져가고 있습니다. 

 

  • children - Form태그 내부에 자식 컴포넌트들을 사용하기 위해 사용됩니다.
  • onSumbit - 서버로 보낼 데이터를 다루는 함수 입니다. 매개변수로 FormData를 받을 예정입니다.
  • className - Form 태그의 style을 다룹니다. ( tailwindcss) 
  • schema -  유효성을 다룹니다. (zod 라이브러리 사용) 
  • defaultValues - form태그의 초기값을 다룹니다. 

 

수정사항 과정

기존에는 데이터를 받는 타입을 정해놨었습니다. 그렇게 하다보니까 결국 그 타입에 대해서만 의존성을 고려하게 되는 문제가 있다는걸 발견했습니다. 그래서 제너릭을 이용해서 타입을 유연하게 받을 수 있도록 수정했습니다. 

onSubmit 또한 의존성을 줄이기 위해 props로 받았습니다. 각각의 form마다 submit 시 호출하는 API가 다르게 될거라고 생각했습니다. 그렇게 때문에 의존성을 줄이기 위해서는 submit을 props로 받아야 했고, 다음과 같이 적용했습니다. 

 

react-hook-form을 사용하다가 유효성을 쉽게할 수 있는 방법을 발견했습니다. zod 라이브러리를 사용해서 schema를 작성해두고, 

react-hook-form에서 제공하는 resolver를 사용하면 zod에서 작성한 shema를 적용할 수 있었습니다. 이렇게 함으로써 한곳에서 유효성관련 내용을 다처리할 수 있게 되었고, 각각 컴포넌트 사용시에 적용되는 보일러 프레이트도 사라지게 되었습니다. 

import useLoading from '@/components/login/hooks/useLoading';
import React from 'react';
import {
  DefaultValues,
  FormProvider,
  SubmitHandler,
  useForm,
} from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Input from './input';
import SelectBox from './selectbox';

interface HookFormProps<T> {
  children: React.ReactNode;
  onSubmit?: (data: T) => Promise<any>;
  className?: string;
  schema?: z.ZodType<T>; // schema는 필수로 변경
  defaultValues?: Partial<T>;
}

const HookForm = <T extends {}>({
  children,
  onSubmit,
  className,
  schema,
  defaultValues,
}: HookFormProps<T>) => {
  const { startLoading, endLoading } = useLoading();

  const resolver = schema ? zodResolver(schema) : undefined;

  const methods = useForm<T>({
    resolver,
    defaultValues: defaultValues as DefaultValues<T>,
  });

  const submit: SubmitHandler<T> = async (data) => {
    try {
      startLoading();
      console.log('data', data);
      if (onSubmit) await onSubmit(data);
    } catch (error: any) {
      endLoading();
      throw Error(error.message);
    } finally {
      endLoading();
    }
  };

  return (
    <FormProvider {...methods}>
      <form className={className} onSubmit={methods.handleSubmit(submit)}>
        {children}
      </form>
    </FormProvider>
  );
};

HookForm.Input = Input;
HookForm.SelectBox = SelectBox;

export default HookForm;

 

 

 

SelectBox - headlessui/react 

export type SelectListType = {
  list: SelectType[];
  type: 'box_shadow' | 'border';
  width?: string;
  disabled?: boolean;
};
export type SelectType = {
  item: string;
  key: string;
};
interface SelectBoxProps extends SelectListType {
  name: string;
}

 

Selectbox는 편의성을 위해서 headlessui/react를 사용했습니다. 이 라이브러리는 기능은 모두 구현이 되어있고, css만 변경해서 사용할 수 있도록 도와주는 편리한 라이브러리 입니다. 저희가 직접 구현해서 사용하면  좋겠지만 시간이 부족했고, 빠른 구현을 위해 사용하게 되었습니다.  SelectBox는 기본적으로 4가지의 속성값을 받도록 설정했습니다.

 

  • list : select box options에 적용될 값들이고, 객체형식으로 item,key 값을 가지고 있습니다.
  • type : css Varient를 적용해서 2가지 타입의 select-box를 구현했습니다.
  • width : select-box의 넓이를 정합니다.
  • disabled: 변경가능 여부를 나타냅니다.

 

그리고 추가로 "name" 속성을 추가했는데, 이건 react-hook-form을 사용할 때 필요한 항목이기 때문에 기존 type을 extends 받도록 

interface를 추가해서 적용했습니다. 

 

 

적용시 고려했어야 했던점 

이름은 select-box이지만 사실 여기서의 ListBox는 그냥 Button이다. headlessui/react github이나 브라우저 검사창에서 확인할 수 있는데, 그렇기에 register를 등록하면 내용이 바뀌지 않는다. register는 input, textArea와 같은 값이 바뀌는 태그에 한해서 변경이 되는데 button은 이에 해당하지 않기 때문이다. props로 값을 value, onChange를 받고 있다. 그래서 useState로 값 변경에 따른 상태관리를 해주어야 한다.  그래서 나는 react-hook-form에 데이터를 등록하기 위해서 useFormContext()에서 제공하는 setValue를 통해 값을 넣기로 했고, selectHandler가 호출될때마다, setValue를 통해 값을 등록했다. 나와같은 방법으로 select-box를 구현한다면 다음과 같은 issue를 조심해야 된다. 

/* eslint-disable @typescript-eslint/no-shadow */

'use client';

import React, { Fragment, useState } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { IoMdCheckmark } from '@react-icons/all-files/io/IoMdCheckmark';
import { useFormContext } from 'react-hook-form';

import { SelectListType } from '@/types/select';

interface SelectBoxProps extends SelectListType {
  name: string;
}

export default function SelectBox(props: SelectBoxProps) {
  const { setValue } = useFormContext();

  const { list, type = 'box_shadow', width, name, disabled } = props;

  const [selected, setSelected] = useState(list[0].item);

  const selectHandler = (value: string) => {
    setSelected(value);
    setValue(name, value);
  };

  const SelectBoxVariant = {
    box_shadow:
      'relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm',
    border:
      'relative w-full cursor-default border border-gray-200 py-2 pl-3 pr-10 text-left bg-white focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm',
  };

  const SelectOptionVariant = {
    box_shadow:
      'absolute mt-1 z-10 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm',
    border:
      'absolute mt-1 z-10 max-h-60 w-full overflow-auto rounded-md bg-white border border-gray-200 text-base ring-1 ring-black/5 focus:outline-none sm:text-sm',
  };

  return (
    <div className={`${width} max-w-md`}>
      <Listbox disabled={disabled} value={selected} onChange={selectHandler}>
        <div className="relative mt-1">
          <Listbox.Button className={SelectBoxVariant[type]}>
            <span className="block truncate">{selected}</span>
            <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2" />
          </Listbox.Button>
          <Transition
            as={Fragment}
            leave="transition ease-in duration-100"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <Listbox.Options className={SelectOptionVariant[type]}>
              {list &&
                list.map((value) => (
                  <Listbox.Option
                    key={value.key}
                    className={({ active }) =>
                      `relative cursor-default select-none py-2 pl-10 pr-4 ${
                        active ? 'bg-amber-100 text-amber-900' : 'text-gray-900'
                      }`
                    }
                    value={value.item}
                  >
                    {({ selected }) => (
                      <>
                        <span
                          className={`block truncate ${
                            selected ? 'font-medium' : 'font-normal'
                          }`}
                        >
                          {value.item}
                        </span>
                        {selected ? (
                          <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-amber-600">
                            <IoMdCheckmark
                              className="h-5 w-5"
                              aria-hidden="true"
                            />
                          </span>
                        ) : null}
                      </>
                    )}
                  </Listbox.Option>
                ))}
            </Listbox.Options>
          </Transition>
        </div>
      </Listbox>
    </div>
  );
}

 

 

FormInput - properies

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  name: string;
  layout?: string;
}

 

inpu 태그는 input 태그에서 적용할 수 있는 기본값들은 extends를 통해서 가져왔고, 추가적으로 필요한 내용만 정의했습니다. 

여기서 name 속성값은 React.InputHTMLAttributes 안에 있는 속성값인데 다시 적은 이유는 필수값으로 적용하기 위해서였스빈다. 

react-hook-form을 적용하기 위해서는 name 속성값은 필수여야하기 때문입니다.

 

  • label : input의 앞에 text 값입니다.
  • name : react-hook-form에 등록될 명입니다.
  • layout : input 태그를 감싸고 있는 div태그의 style입니다. 

 

 

Schema  작성하기

schema를 통해서 유효성을 검증할 수 있도록 만들었습니다. zod 라이브러리를 사용했고 zod 라이브러리에 대한 사용법은 github에서 확인할 수 있었습니다. 아니면 dev 페이지도 있으니 참고 바랍니다. 

https://github.com/colinhacks/zod

 

GitHub - colinhacks/zod: TypeScript-first schema validation with static type inference

TypeScript-first schema validation with static type inference - colinhacks/zod

github.com

https://zod.dev/

 

GitHub - colinhacks/zod: TypeScript-first schema validation with static type inference

TypeScript-first schema validation with static type inference - colinhacks/zod

github.com

import { z } from 'zod';

const REQUIRED_ID_MESSAGE = '아이디는 필수값입니다.';
const REQUIRED_PW_MESSAGE = '비밀번호는 필수값입니다.';
const REQUIRED_EMAIL_MESSAGE = '이메일 형식에 맞지 않습니다.';

export const schema = z.object({
  id: z.string().min(1, { message: REQUIRED_ID_MESSAGE }),
  password: z.string().min(1, { message: REQUIRED_PW_MESSAGE }),
  email: z.string().email({ message: REQUIRED_EMAIL_MESSAGE }),
});

 

 

 

 

마치며..

항상 재사용성, 가독성을 고려하면서 개발을 했다고 생각을 했었다. 근데 방법을 몰랐던건지 잘못된거에 익숙했던건지, 더 나은 방법에 대해서 생각하지 못했던거같다. 의존성을 고려한 개발할때 개발하려고한 컴포넌트에 대한 비즈니스 로직과 컴포넌트 정의를 먼저 작성하고 하면  더 쉽게 개발할 수 있을거같다. 항상 무언가를 개발할 때 기획에서 힘을 가장 많이 쓴다는거에도 조금 공감이 된다.

로직을 잘만들고 개발을 하는게 진짜중요한거같다. 

 

 

 

 

 

 

 

레퍼런스

https://velog.io/@moreso/designing-complex-components-flexibly

 

복잡한 컴포넌트 유연하게 설계하기

프론트엔드 개발자의 일을 하다보면 복잡한 요구사항의 컴포넌트를 제작할 일이 있습니다. 복잡한 컴포넌트라는게 무엇인지 생각을 해본다면 서버에서 내려준 데이터를 UI로 표현하는게 복잡

velog.io

https://medium.com/@junep/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EB%A5%BC-%EB%B6%84%EB%A6%AC%ED%95%98%EB%8A%94-%EA%B8%B0%EC%A4%80%EA%B3%BC-%EB%B0%A9%EB%B2%95-e7cf16bb157a

 

프론트엔드 아키텍처: 컴포넌트를 분리하는 기준과 방법

컴포넌트를 언제 분리해야 하고 어떻게 분리해야 하는지 살펴봅니다.

medium.com