컴포넌트를 만들 때 고려해야할 점.

이정수·2025년 1월 16일
0
post-thumbnail

현재 프로젝트에서 로그인과 회원가입기능을 만드는 중이다.

이번 프로젝트에서는 Supabase를 사용해서 로컬로그인과 카카오 로그인을 모두 구현하고자한다.

  • 카카오 로그인은 로그인 버튼만 하나 생성하고 내부 처리는 모두 supabase, kakao login api에 맡기기 때문에 UI를 크게 고민하지 않아도 되었지만
  • 로컬 로그인의 경우 폼 UI를 직접 만들고 유효성 검사를 프론트에서 처리해줘야한다.

  • UI를 보면 회원가입과 로그인 폼의 형식이 매우 비슷하게 생겼음을 알 수 있다

반복을 줄이고 원하는 기능을 모두 구현하기 위해 다음과 같은 요구사항을 정의하였다.

기능 요구 사항

  • Form을 공통 컴포넌트로 만든다.
  • 추후에 유저의 닉네임을 추가로 받을 수 있다. -> Input또한 공통 컴포넌트로 분리한다.
  • 각 Input 별 유효성 검사는 다음과 같이 수행한다.
  1. 이메일 입력시 @를 포함한 이메일 형식을 만족해야 한다.
  2. 비밀번호 입력시 최소8자리~ 최대 12자리로 작성해야 하며, 영소문자와 숫자를 필수적으로 포함해야한다.

공통 컴포넌트를 만들 때 중요한 요소

컴포넌트 설계를 위해 공통컴포넌트는 어떤 조건을 만족해봐야 하는지 알아보았다.
그 결과 두가지의 중요한 요소를 선정하였다.

공통 컴포넌트는
1. 특정도메인 맥락으로 부터 분리되어야 한다.
2. 디자인 변경사항에 유연하게 대응하여야 한다.

1. 특정 도메인 맥락으로부터 분리된 컴포넌트는 어떤 도메인에서든 사용할 수 있는 인터페이스이다.

컴포넌트가 특정 도메인과 결합되어있다면 다른 도메인에서 재사용하기 어렵고, 비즈니스 요구사항이 변경되었을 때, 다른 도메인 로직에 영향을 줄 수 있다.

예를들어 사용자들의 주소 목록을 랜더링하는 컴포넌트가 있을 경우, 이를 '주소목록'의 역할대신 '목록'으로 보다 범용성있게 만들 수 있다.

현재 프로젝트에 적용시켜보자면, '최소8자리~ 최대 12자리로 작성해야 하며, 영소문자와 숫자를 필수적으로 포함' 되어야하는 input 요소가 있다면,아이디, 닉네임을 입력할 때 똑같은 디자인을 가지고 있다고 하더라도 재사용할 수 없다.

그렇다면 '비밀번호는 최소8자리~ 최대 12자리로 작성해야 하며, 영소문자와 숫자를 필수적으로 포함'이라는 로직을 객체로 분리하고 이를 input컴포넌트의 되부에서 주입시켜준다면 어떨까?

기존 코드

  • 가독성을 위해 스타일링 관련된 부분은 제거하였다.
  • 유효성 검사와 랜더링최적화를 위해 react-hook-form을 사용하고 있다.
type SignInForm = {
  email: string;
  password: string;
};

export default function SignIn({
  setView,
}: {
  setView: React.Dispatch<React.SetStateAction<AuthView>>;
}) {
  const onSubmit: SubmitHandler<SignInForm> = data => {
    console.log('제출됨');
  };

  const {
    register,
    handleSubmit,
    formState: {errors},
  } = useForm();

  return (
    <div >
      <h2 > 로그인 </h2>
      <form
        className=
        onSubmit={handleSubmit(onSubmit)}
      >
        <input
          {...register('email', {
            required: '이메일을 입력해주세요',
            pattern: {
              value: /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/,
              message: '올바른 이메일 형식이 아닙니다.',
            },
          })}
          placeholder="이메일을 입력해 주세요."
        />
        {errors.email && typeof errors.email.message === 'string' && (
          <span>
            <em>{errors.email.message}</em>
          </span>
        )}
        <input
          {...register('password', {
            required: '비밀번호를 입력해 주세요',
            minLength: {
              value: 8,
              message: '비밀번호는 최소 8글자 이상이여야 합니다.',
            },
            maxLength: {
              value: 12,
              message: '비밀번호는 12글자를 초과할 수 없습니다.',
            },
            pattern: {
              value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,64}$/,
              message: '영소문자, 숫자가 포함된 8자 이상의 비밀번호를 입력해주세요',
            },
          })}
          placeholder="비밀번호를 입력해 주세요."
        />
        {errors.password && typeof errors.password.message === 'string' && (
          <span>
            <em>{errors.password.message}</em>
          </span>
        )}
        <Button type="submit" label="로그인 하기" />
      </form>
      <div >
        <span >아직 계정이 없습니까?</span>
        <button onClick={() => setView('SIGN_UP')}>
          회원가입 하기
        </button>
      </div>
      <Seperator text="or" width={52} />
      <KakaoLogin />
    </div>
  );
}

1️⃣ input의 유효성 확인 로직을 분리한다.

현재 SignIn컴포넌트의 UI안에는 이메일과 비밀번호의 유효성확인을 위한 로직이 함께 존재한다.
변경가능성이 높은 도메인 맥락인 유효성 확인 로직을 객체로 분리해주자.

  // 1. 도메인 맥락 : 유효성 확인 로직 분리
  const SignInValidation = {
    email: {
      required: '이메일을 입력해주세요',
      pattern: {
        value: /[a-z0-9]+@[a-z]+\.[a-z]{2,3}/,
        message: '올바른 이메일 형식이 아닙니다.',
      },
    },
    password: {
      required: '비밀번호를 입력해 주세요',
      minLength: {
        value: 8,
        message: '비밀번호는 최소 8글자 이상이여야 합니다.',
      },
      maxLength: {
        value: 12,
        message: '비밀번호는 12글자를 초과할 수 없습니다.',
      },
      pattern: {
        value: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{8,64}$/,
        message: '영소문자, 숫자가 포함된 8자 이상의 비밀번호를 입력해주세요',
      },
    },
  };

라이브러리 인터페이스에 변경 가능성이 있으므로 어댑터를 사용하여 연결해주자.

  
// 2. 라이브러리 인터페이스 변경을 고려해 adaptor를 통해 연결
  
type FormAdaptorArgs = {
  register: UseFormRegister<FieldValue<FieldValues>>;
  validator: {[key: string]: RegisterOptions};
  name: keyof SignInFormValues;
};

...

const formAdaptor = ({register, validator, name}: FormAdaptorArgs) => {
    return register(name, validator[name]);
  };

...

<input
    {...formAdaptor({register, validator: SignInValidation, name: 'email'})}
    placeholder="이메일을 입력해 주세요."/>
      
{errors.email && typeof errors.email.message === 'string' && (
     <span>
       <em>{errors.email.message}</em>
     </span>
)}

2️⃣ HTML 속성을 도메인 맥락에서 분리한다.

위 코드에서 input창과 에러를 표시하는 부분을 묶어서 Input 컴포넌트로 만들어 보겠다.

  • 기존의 input의 ui 작업만 완료했을 때는 TextInput이라는 이름을 붙였었는데, 생각해보니 password, email, 날짜, 시간등을 좀더 범용적으로 받을 수 있는 컴포넌트 인것 같아 Input으로 변경하였다.
  • 또한 기존 TextInput에서는 HTML attributes를 하나하나 받아와서 뿌려줬었는데, 요구사항에 따라 속성값들이 달라지면 컴포넌트 뿐 아니라 사용처에서도 모두 수정해야 하는 것이 불편했다. 따라서 props라는 객체 형태로 받아와서 적용하는 방식으로 수정하였다.
  • React Hook Form의 register 함수 역시 InputHTMLAttributes에 해당하는 프로퍼티만 리턴하기 때문에 함께 사용해도 문제될 게 없다.
  • 라벨과 에러는 옵셔널하게 받을 수 있도록 변경하였다.
  • 사용예시는 아래와 같다.
<Input
         props={{
            ...formAdaptor({
              register,
              validator: SignInValidation,
              name: 'email',
            }),
            placeholder: '이메일을 입력해주세요.',
            type: 'email',
          }}
          errors={errors.email}
          label="이메일"
        />
  • 완성된 Input 컴포넌트 ( 정리를 위해 스타일링 부분은 제거 )
type InputProps = {
  props: InputHTMLAttributes<HTMLInputElement>;
  errors?: FieldError;
  label?: string;
};

export default function Input({props, errors, label}: InputProps) {
  return (
    <div >
      {label && <label>{label}</label>}
      <input {...props} />
      {errors && typeof errors.message === 'string' && (
        <span>
          <em>{errors.message}</em>
        </span>
      )}
    </div>
  );
}

폼에서 공통되는 로직 분리하기

현재 로그인 폼과 회원가입 폼에서는 공통적인 로직이 존재한다.

  • 인풋값을 입력받고, 유효성을 확인한다.
  • 검증된 값을 onSubmit에 던진다.

따라서 공통 레이아웃을 가지는 Form 컴포넌트를 만들고,
Input 컴포넌트는 react-hook-form과는 완전히 분리시킬것이다.

Form 컴포넌트는 그러니까,

  • 헤더이름, 버튼이름, 옵셔널한 자식 컴포넌트를 그린다.
  • form에 들어가야하는 inputs배열을 받아서 그린다.
  • form 내부에는 독립적인 useForm을 가지고, 오직 Form 컴포넌트만 라이브러리 의존성을 지닌다.

SignIn

export default function SignIn({
  setView,
}: {
  setView: React.Dispatch<React.SetStateAction<AuthView>>;
}) {
  const onSubmit = data => {
   ...
  };

  const SigninInputs = {
    ...
  };

  return (
    <div className="flex flex-col gap-3 justify-center items-center">
      <Form headerText="로그인" buttonText="로그인하기" onSubmit={onSubmit} inputs={SigninInputs}>
        <div>
          <KakaoLogin />
        </div>
      </Form>
    </div>
  );
}
  • 가장 상위의 SignIn, SignOut에서는
    1) input에 대한 정보 객체를 지니며
    2) 폼이 제출되었을 때 후처리를 담당하게된다.

Form 컴포넌트

export default function Form({children, onSubmit, inputs, buttonText, headerText}: FormProps) {
  const {
    register,
    handleSubmit,
    formState: {errors},
  } = useForm();

  const formAdaptor = ({register, name, input}: FormAdaptorArgs) => {
    const {attributes, validate} = input;
    return {...register(name, validate), ...attributes};
  };

  return (
    <div className="flex flex-col gap-3 justify-center items-center">
      <h2 className="font-semibold text-lg">{headerText} </h2>
      <form
        className="flex flex-col gap-3 justify-center items-center"
        onSubmit={handleSubmit(onSubmit)}
      >
        {Object.entries(inputs).map(([name, input]: [string, InputType]) => (
          <Input
            key={name}
            errorMessage={errors[name]?.message?.toString() || ''}
            props={formAdaptor({register, name, input})}
            label={name}
          />
        ))}

        <Button type="submit" label={buttonText} />
      </form>
      {children}
    </div>
  );
}

정리

이렇게 복잡하던 SignIn 컴포넌트를 SignIn/Form/Input컴포넌트로 분리해보면서 각자 로그인처리를 위한 로직, react-hook-form 라이브러리 의존성, input창 UI관리에 대한 책임을 나눌 수 있었다.

그동안 개발 시간에 쫓겨 구현자체에만 몰두하다 보면 컴포넌트를 분리하지 않고 하나의 컴포넌트에 많은 책임을 몰아넣거나, 혹은 나만 알아볼수 있는 스파게티 코드를 짜기도 한다. 그럴 때마다 항상 나뿐 아니라 남들도 잘 알아볼수 있는 코드, 재사용성 있는 코드를 작성하려 노력한다.

공부하다보니 추상화의 단계에 따라 어떤 차이점이 있는지, 어느정도까지 추상화해야하는지에 대한 궁금점이 들었다.다음에는 확장성있는 UI를 작성하는 방법, 추상화란 무엇인지에 대해 더 알아보고 싶다...!

마지막으로 이번 기회를 통해 도메인 로직이 무엇인지, 왜 분리해야 하는 것이지에 대해 배우고, 적용해보았다. 자료를 찾아보면 원래의 목적에서 벗어나거나 꼬리 질문들이 생기기 마련인데, 오늘은 '폼 리팩토링'이라는 큰 목적 아래서 학습해서 삼천포로 빠지지 않았던 것 같다. 한가지 구현 목표와 요구사항을 정하고 필요한 자료를 학습하며 리팩토링하는 이번 방식이 꽤 효율적이란 생각이 들었다. 다음에도 이런 방식을 적용해봐야겠다..!

📚 참고자료

profile
keep on pushing

0개의 댓글