Fitmate 리팩토링 - [회원가입]

최훈오·2024년 6월 9일

핏메이트

목록 보기
1/5

회원가입 폼 react-hook-form 개선

비밀번호 / 비밀번호 확인 유효성 검사

요구사항

요구사항은 다음과 같다.

  1. 비밀번호는 비밀번호 유효성 검사 + 비밀번호 확인과 일치하는지 두가지를 따져야한다.
  2. 비밀번호 확인은 비밀번호 값과 같은지만 따지면 된다.
  3. 비밀번호가 변해도, 비밀번호 확인히 변해도 두 값이 같은지 실시간으로 제어해야 한다.(중요)

react hook form을 구현하다 보면 자연스레 각각 담당하는 하나의 Input에 대해서만 유효성을 검사하는데 다른 Input과 같은지를 검증해야 하는 것이 추가된 것이다.

기존 코드

우선, 기존에 Input을 어떻게 관리하고 사용하고 있는지 알아보자.

import { HtmlHTMLAttributes, PropsWithChildren } from "react"

import styled from "styled-components"

import InputError from "@components/Input/components/InputError"
import InputInput from "@components/Input/components/InputInput"
import InputLabel from "@components/Input/components/InputLabel"
import InputText from "@components/Input/components/InputText"

const InputMain = ({
  children,
  ...props
}: PropsWithChildren<HtmlHTMLAttributes<HTMLDivElement>>) => (
  <InputWrapper {...props}>{children}</InputWrapper>
)

const Input = Object.assign(InputMain, {
  Label: InputLabel,
  Input: InputInput,
  Text: InputText,
  Error: InputError,
})

export default Input

const InputWrapper = styled.div`
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 0.8rem;
`
const InputInput = ({
  props: { $isDirty = false, $isError = false, ...rest },
  variant = "main",
}: InputInputProps) => {
  const variantStyle = VARIANTS[variant]
  const borderStyle = getBorderStyle($isError, $isDirty)
  return (
    <Input
      $isDirty={$isDirty}
      $isError={$isError}
      $variantStyle={variantStyle}
      $borderStyle={borderStyle}
      {...rest}
    />
  )
}

현재 Input 컴포넌트는 수 많은 변하는 값들을 props로 넘기는게 아니라 한눈에 UI구조파악이 쉽도록 합성 컴포넌트로 구현되어있다.

export const SIGNUP_INPUTS = {
  userName: {
    attributes: {
      placeholder: "2글자 이상",
    },
    validate: {
      required: { value: true, message: "이름은 필수 입력입니다." },
      pattern: {
        value: /^[가-힣]{3,8}$/,
        message: "유효하지 않은 이름입니다.",
      },
    },
  },
  birthDate: {
    attributes: {
      placeholder: "YYYY-MM-DD",
      maxLength: 10,
    },
    validate: {
      required: {
        value: true,
        message: "생년월일은 필수 입력입니다.",
      },
      pattern: {
        value: /^\d{4}-\d{2}-\d{2}$/,
        message: "유효하지 않은 생년월일입니다.",
      },
    },
  },
  loginEmail: {
    attributes: {
      placeholder: "이메일을 입력해주세요",
      type: "email",
    },
    validate: {
      required: { value: true, message: "이메일은 필수 입력입니다." },
      pattern: {
        value: /^(.+)@(\S+)$/,
        message: "유효하지 않은 이메일입니다.",
      },
    },
  },
  password: {
    attributes: {
      placeholder: "비밀번호를 입력해주세요",
      type: "password",
    },
    validate: {
      required: { value: true, message: "비밀번호는 필수 입력입니다." },
      pattern: {
        value: /^(?=.*[a-zA-Z])(?=.*[0-9]).{8,25}$/,
        message: "유효하지 않은 비밀번호 입니다.",
      },
    },
  },
  passwordCheck: {
    attributes: {
      placeholder: "비밀번호 확인",
      type: "password",
    },
    validate: {},
  },
}

input에 들어가는 validation 옵션이나 속성들은 따로 상수로 관리하고 있다.

{
  SIGNUP_LIST.map(({ id, name, label, isRequired }) => (
  <Input 
    key={id}>
    {label && (
      <Input.Label
        isRequired={isRequired}
        htmlFor={name}>
        {label}
      </Input.Label>
    )}
    <Input.Input
      props={{
        ...formAdapter({
          register,
          name,
          validate: SIGNUP_INPUTS[name].validate,
        }),
          $isDirty: !!formState.dirtyFields[name],
            $isError: !!formState.errors[name],
              ...SIGNUP_INPUTS[name].attributes,
      }}
      />
    <Input.Error>{formState?.errors[name]?.message}</Input.Error>
  </Input>
))}
interface formAdapterProps {
  register: UseFormRegister<FieldValue<FieldValues>>
  validate: RegisterOptions
  name: string
  $isDirty?: boolean
  $isError?: boolean
  type?: string
  onChange?: (e: ChangeEvent<HTMLInputElement>) => void
}

export const formAdapter = ({
  register,
  validate,
  name,
  ...props
}: formAdapterProps) => {
  return { ...register(name, validate), ...props }
}

그리고, 사용하는 곳과 Input 컴포넌트 사이에 formAdapter를 두어 react hook form에 사용되는 비즈니스 로직(유효성 검사 로직)과 UI를 분리하였다.

현재 상황은 다음과 같다.

비밀번호 확인의 경우 전혀 유효성 검사를 실시하지 않고 있다.

비밀번호 확인 Input에 비밀번호와 비밀번호 확인 일치 로직 추가

...formAdapter({
  register,
  name,
  validate:
  name === "passwordCheck"
  ? {
    validate: (value) => {
      const { password } = getValues()
      return (
        password === value ||
        "비밀번호가 일치하지 않습니다"
      )
    },
  }
  : SIGNUP_INPUTS[name].validate,
}),

먼저 요구사항 2번에 해당하는 비밀번호 확인 유효성 검사 로직을 추가하자.
validation.ts에 저장되어있는 유효성 옵션과 다르게 실시간으로 다른 Input값과 비교해야 하기 때문에 직접 함수를 작성해야 한다. getValues를 통해 실시간으로 비밀번호 확인 값을 가져와 비교하며 return 값을 통해 유효성 검사를 처리하고 에러 메시지 또한 보여준다.

하지만, 당연하게도 비밀번호 확인이 아닌 비밀번호 값을 변경할 경우 두 값을 비교하는 로직이 실행되지 않는다.

그래서, 비밀번호 또한 validate를 따로 추가해줘서 구현하려 했지만 기존 비밀번호 유효성 검사 로직을 덮어써버려서 1번 요구사항이 만족되지 않는 문제가 생긴다.

비밀번호에도 비밀번호와 비밀번호 확인 일치 로직 추가

const passwordValue = watch("password")
const passwordCheckValue = watch("passwordCheck")
  
useEffect(() => {
  if (passwordValue === passwordCheckValue) {
    clearErrors("passwordCheck")
  } else {
    setError("passwordCheck", {
      type: "password-mismatch",
      message: "비밀번호가 일치하지 않습니다",
    })
  }
}, [clearErrors, setError, passwordValue, passwordCheckValue, watch])

블로그를 참고해 useEffect를 통해 해결하였다.
watch를 통해 실시간으로 비밀번호 값과 비밀번호 확인 값을 추적하여 둘의 변경사항이 생기는 경우
1. 값이 같으면 -> 비밀번호 확인 에러 제거
2. 값이 다르면 -> 비밀번호 확인 에러 발생
와 같은 흐름을 따른다.

react hook form에는 직접 에러를 내는 setError도 있고 에러를 지우는 cleanErrors 도 있구나.. 정말 기능이 많은 것 같다.

아무튼 이렇게 하면 1번, 3번 요구사항을 만족시켜 모든 요구사항을 만족시킨다.

참고자료

https://velog.io/@chchaeun/%EA%B3%B5%ED%86%B5-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EB%A5%BC-%EB%A7%8C%EB%93%A4-%EB%95%8C-%EA%B0%80%EC%9E%A5-%EC%A4%91%EC%9A%94%ED%95%9C-%EC%9A%94%EC%86%8C

https://react-hook-form.com/docs/useform

https://velog.io/@h_jinny/React-hook-form-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8-%EC%9D%BC%EC%B9%98-%EC%9C%A0%ED%9A%A8%EC%84%B1%EC%B2%B4%ED%81%AC

2개의 댓글

comment-user-thumbnail
2024년 6월 11일

react hook form으로 validation처리할때 zod라는 라이브러리도 유용하더라구요!

1개의 답글