유효성 검사 FP 스타일로 만들기(with hook)

지난번 이후 변경점

: 지난번 포스팅에 이어서 작업을 해보았다. 꽤나 많은 부분이 변화했는데, 크게 변화시킨 부분은

  • 모든 로직을 단일 함수 조각으로 쪼갰고, 그 쪼갠 함수들을 최근에 배운 함수 조합기와 curry, pipe 등 원래 쓰던 함수형 프로그래밍 패턴을 더해서 합성하는 식으로 변경했다.
  • 파라미터로 hook을 가져다 쓸 컴포넌트에서 사용하는 formData state를 내려주도록 했고, formData에 속하는 키값에 맞는 required 여부(필수 데이터인지 아닌지 여부), 유효성 검사 함수들을 배열에 담은 데이터를 주도록 했다.
export const META_DATA = {
  email: {
    required: true,
    validators: [checkValidEmail],
  },
  nickname: {
    validators: [
      checkNicknameLength,
      checkNicknameCharacter,
      checkNicknameWhiteSpace,
    ],
    required: true,
  },
  password: {
    required: true,
    validators: [
      checkPasswordLength,
      checkPasswordHasUpperCase,
      checkPasswordHasLowerCase,
      checkPasswordHasSpecificCharacters,
    ],
  },
  introduction: {
    validators: [checkIntroductionLength],
    required: false,
  },
};

이런식으로 hook을 가져다 쓰는 쪽에서 MetaData를 props로 내려주도록 했다. 이 때, 일단 이 훅은 내가 쓸 것이기 때문에 validators에 들어가는 모든 함수는 Either 모나드를 사용하여 Right, Left를 리턴하도록 했다.

최종 useValidation.ts

import { useCallback, useEffect, useRef, useState } from "react";
import { createFieldValidator } from "../utils/validator";
import {
  ErrorMessage,
  FormData,
  FormErrors,
  MetaData,
  ValidatorRef,
} from "../types/validator";
import { useSkipFirstRender } from "./useSkipFirstRender";
import { alt, identity, isValid, sequence } from "@myorg/utils";
import { curry } from "lodash";

interface UseValidationProps {
  formData: FormData;
  metadata?: MetaData;
}

/* 
** Document ** 
: useValidation은 form 데이터와 metadata를 받아서 form 데이터의 유효성을 검사하는 커스텀 훅입니다.
- metadata는 form 데이터의 필드에 대한 유효성 검사 규칙을 정의합니다.
ex) 
const META_DATA = {
  email: {
    required: true,
    validators: [checkValidEmail],
  },
  nickname: {
    validators: [
      checkNicknameLength,
      checkNicknameCharacter,
      checkNicknameWhiteSpace,
    ],
    required: true,
  },
  password: {
    required: true,
    validators: [
      checkPasswordLength,
      checkPasswordHasUpperCase,
      checkPasswordHasLowerCase,
      checkPasswordHasSpecificCharacters,
    ],
  },
  introduction: {
    validators: [checkIntroductionLength],
    required: false,
  },
};

이 때, 모든 유효성 검사 함수는 Either.right or Either.left를 반환해야 합니다.
- formData는 실제로 이 커스텀 훅을 가져다 쓰는 컴포넌트(예를 들어, Signup or Form 등)에서 관리하는 form 데이터입니다.
ex) 
  const [formData, setFormData] = useState<FormData>({
    email: "",
    nickname: "",
    password: "",
    introduction: "",
  });

이 때, formData의 타입은 packages/ui/src/types/validator.ts에 정의된 FormData 타입에 맞춰서 써야합니다. 
input 태그를 쓰는 value에 한하여 유효성 검사 로직을 만든 것이기 때문에 모든 value는 string이라고 가정합니다.

- 실제 가져다 쓸 때는 

const { errors, isFormValid } = useValidation({
  formData,
  metadata: META_DATA,
});

이런식을로 사용하고, errors에는 formData 각 필드에 대한 유효성 검사 결과가 담겨있고, isFormValid는 전체 form 데이터가 유효한지를 나타냅니다.
따라서 컴포넌트(Input 태그등)에서 사용할 때 이런식으로 활용합니다. 

<FormGroup>
  <Label>{META_DATA.password.required && <span>*</span>}비밀번호</Label>
  <Input
    type="password"
    name="password"
    data-testid="password-input"
    hasError={isValid(errors.password)}
    value={formData.password}
    onChange={handleChange}
  />
  {errors.password && (
    <ErrorMessage data-testid="password-error">
      {errors.password}
    </ErrorMessage>
  )}
</FormGroup>

포인트는 errors.특정_필드_이름에 에러가 있으면 에러 메시지가 할당되고, 문제가 없으면 "" 이 할당된다는 것 입니다. 또한, 초기 아무 값도 입력하지 않았을 때는 
에러가 없는 상태지만, 유효성 검사를 통과한 상태는 아니기 때문에 undefined로 처리 됩니다.

- 마지막으로 submit 버튼을 유효성 검사 clear 여부에 따라 활성, 비활성 처리를 하고자 할 때는 이런식으로 가져다 쓰면 됩니다.

<SubmitButton
  type="submit"
  data-testid="submit-button"
  disabled={!isFormValid}
>
  가입하기
</SubmitButton>

** PS
<Label>{META_DATA.password.required && <span>*</span>}비밀번호</Label>
required 여부를 UI로 표시하고자 할 때는 따로 훅에서 정보를 제공하진 않으며, META_DATA(hook에 파라미터로 제공하는)에서
가져다가 위와 같이 써주시면 됩니다.
*/

export const useValidation = ({ formData, metadata }: UseValidationProps) => {
  const [errors, setErrors] = useState<FormErrors>({});
  const prevFormData = useRef(formData);
  const validatorRef = useRef<ValidatorRef>({});

  const filterActualChangedFields = useCallback(
    (field: string) => formData[field] !== prevFormData.current[field],
    [formData]
  );

  const checkValueIsReset = useCallback(
    (field: string) => !isValid(formData[field]),
    [formData]
  );

  const activateValidator = useCallback(
    (field: string) => {
      validatorRef.current[field](formData[field]);
    },
    [formData]
  );

  const checkValidatorSaved = useCallback(
    (field: string) => validatorRef?.current && validatorRef.current[field],
    []
  );
  const handleSetErrors = useCallback(
    curry((value: ErrorMessage, field: string) => {
      setErrors((prev) => ({
        ...prev,
        [field]: value,
      }));
    }),
    []
  );

  const handleNotRequiredField = handleSetErrors("");
  const handleFirstRenderingField = handleSetErrors(undefined);

  const handleValidate = (formData: FormData) => {
    Object.keys(formData)
      .filter(filterActualChangedFields)
      .forEach(
        alt(
          checkValidatorSaved,
          alt(
            checkValueIsReset,
            alt(isRequired, handleFirstRenderingField, handleNotRequiredField),
            activateValidator
          ),
          identity
        )
      );
  };

  const resetPrevFormData = (formData: FormData) => {
    prevFormData.current = formData;
  };

  useSkipFirstRender(() => {
    sequence(handleValidate, resetPrevFormData)(formData);
  }, [formData, metadata]);

  const hasMetaData = useCallback(
    (metadata: MetaData | undefined) => metadata !== undefined,
    [metadata]
  );

  const hasValidator = useCallback(
    (field: string) =>
      hasMetaData(metadata) && metadata[field] !== undefined ? field : false,
    [metadata]
  );

  const isRequired = useCallback(
    (field: string) =>
      hasMetaData(metadata) && metadata[field].required ? true : false,
    [metadata]
  );

  const initializeErrors = useCallback((formData: FormErrors) => {
    Object.keys(formData).forEach(handleFirstRenderingField);
  }, []);

  const initializeValidator = useCallback(
    (field: string) => {
      validatorRef.current[field] = createFieldValidator(
        field,
        setErrors
      ).createCheckField((metadata as MetaData)[field].validators);
    },
    [metadata]
  );

  const initializeMetadata = useCallback(() => {
    Object.keys(formData).forEach(
      alt(
        hasValidator,
        sequence(
          initializeValidator,
          alt(isRequired, identity, handleNotRequiredField)
        ),
        identity
      )
    );
  }, [formData, initializeValidator, isRequired, handleSetErrors]);

  useEffect(() => {
    initializeErrors(formData);
    initializeMetadata();
  }, []);

  const isValidData = (message: ErrorMessage) => message === "";
  const isFormValid = Object.values(errors).every(isValidData);

  return {
    errors,
    isFormValid,
  };
};

주석으로 대부분의 설명을 끝내놓았다.

아쉬운점 및 개선할 점

  • api를 통해서 유효성 검사하는 로직들이 있는데, 이걸 일단 포함시키지 않았다. 하지만, 이건 선택의 문제라고 생각되는게 어차피 api를 태우는 로직 검사를 가장 나중에 한다고 생각하기 때문에 hook을 통해 먼저 체크를 하고 모두 통과했다면, 그 다음에 api를 통해 하는 유효성 검사를 실행하도록 분리시켜놔도, 즉, hook에서 api 로직은 배제해도 상관은 없을 것 같다라는 판단이다.
  • 현재는 input 태그 value에 대한 유효성 검사만을 커버할 수 있다. 예를 들어, tags: string[] 이런 타입의 데이터는 formData로 넣을 수 없다.
export interface FormData {
  [key: string]: string;
}

하지만, input 태그의 value만을 커버하기 위해 설계한거긴 해서 예외 상황은 아니다. tags와 같이 배열 등의 데이터를 유효성 검사하려면 따로 로직을 분리해서 작성하던지(이게 좀 불편할 것 같다. formData 말고 다른 state를 따로 만들어서 해야하기 때문이다) 하면 되기 때문이다.

이후 계획

  • 개선점을 바로 고치지 않고, 일단은 써보면서 개선해 나아갈 생각이다. 이걸 적용해서 실제로 회원가입 로직 혹은 특정 정보를 입력하는 Form 로직을 구현해보자.
profile
완벽함 보다는 최선의 결과를 위해 끊임없이 노력하는 개발자

0개의 댓글

Powered by GraphCDN, the GraphQL CDN