[STP] 다양한 FormData의 유효성 검사 모듈화하기

foresec·2024년 6월 1일

Project

목록 보기
4/11

회원가입은 물론 여러가지 형태의 음료 등을 등록해야했던 만큼 다양한 형태의 확장된 FormData를 제출해야했다.

1. 각 컴포넌트에서 무작정 error검증하기

처음에는 정말 아래와 같이 무작정 삼항연산자로 조건을 작성하고 에러 전체를 error 상태에 저장을 했다

const [error, setError] = useState(initialErrorState);

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    ...
    const errors: ErrorState = {
      nickname:
        signUpFormData.nickname.trim() === "" ? "닉네임을 입력해주세요." : "",
      birth: signUpFormData.birth.trim() === "" ? "나이를 입력해주세요." : "",
      gender: signUpFormData.gender.trim() === "" ? "성별을 선택해주세요." : "",
      activityLevel:
        signUpFormData.activityLevel.trim() === ""
          ? "활동 수준을 선택해주세요."
          : "",
      height: signUpFormData.height === "" ? "키를 입력해주세요" : "",
      weight: signUpFormData.height === "" ? "몸무게를 입력해주세요" : "",
    };

    const checkError = Object.values(errors).some((e) => e !== "");

    if (checkError) {
      setError(errors);
      return;
    }

하지만
1. 여러가지 form마다 if문이나 삼항연산자를 쭉 나열하듯 다 작성할 순 없었고,
2. UI상 당장 모든 input에서 발생하는 에러를 다 저장해서 보여줄 필요도 없었다.(상단에서부터 하나만 반환해도 충분)


2. Config작성과 유틸함수로서의 분리

컴포넌트에서의 유효성 검사

유효성 검사를 위한 config를 따로 작성하고, ValidationConfigType또한 따로 작성해준다(추후 확장에 용이)

const signUpValidationConfig: Record<string, ValidationConfigType> = {
  nickname: {
    required: true,
    maxLength: 8,
    emptyMessage: "닉네임을 입력해주세요",
    errorMessage: "닉네임은 최대 8자까지만 입력할 수 있습니다.",
  },
  height: {
    required: true,
    emptyMessage: "키를 입력해주세요.",
  },
  weight: {
    required: true,
    emptyMessage: "몸무게를 입력해주세요.",
  },
  ...
};
export type ErrorStateType = {
  [key: string]: string;
};

export type ValidationConfigType = {
  required: boolean;

  // 조건
  maxLength?: number;
  minValue?: number;

  // 메시지
  errorMessage?: string;
  emptyMessage?: string;
};

form의 유효성 검사를 수행하려는 컴포넌트의 submit로직에 해당 에러상태에 맞는 key에 error를, 그리고 error메시지를 반영하는 로직을 넣어준다

const [error, setError] = useState<ErrorStateType>(initialErrorState);
const [errorMessage, setErrorMessage] = useState("");


const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const checkErrorOrder: string[] = [
      "nickname",
      "height",
      "weight",
      "birth",
      "gender",
      "activityLevel",
    ];
    
  
    // error를 검증한 후
    const formError: ErrorStateType = validateFormData(
      signUpValidationConfig,
      signUpFormData,
      checkErrorOrder
    );

    // error가 존재할 경우 해당 error와 error메시지를 반환
    if (Object.keys(formError).length > 0) {
      const [firstErrorKey, firstError] = Object.entries(formError)[0];
      setError((prev) => ({
        ...prev,
        [firstErrorKey]: firstError,
      }));
      setErrorMessage(firstError);
      return;
    }

    // API 요청
    ...
  };

util 함수

검증config, data, 검증순서를 담은 배열을 받아 순서대로 에러를 하나씩 반환하는 함수

import { ErrorStateType, ValidationConfigType } from "@/types/validationTypes";

export default function validateFormData<T>(
  // 검증config
  validationConfig: Record<string, ValidationConfigType>,
  // data
  formdata: T,
  // 검증의 순서
  checkErrorOrder: string[]
) {
  const errors: ErrorStateType = {};

  // 배열로서 검증 순서를 지킴
  for (const key of checkErrorOrder) {
    const config = validationConfig[key];
    let value: unknown = formdata[key as keyof T];
    if (typeof value === "string") {
      value = value.trim();
    }

    // 바로 return함으로써 오류 하나만 반영(삭제시 여러개 반영 가능)
    if (config.required && value === "") {
      // 1. 비어있는 경우 판단
      errors[key] = config.emptyMessage || "";
      return errors;
    } else if (
      // 2. maxLength판단
      typeof value === "string" &&
      config.maxLength !== undefined &&
      value.length > config.maxLength
    ) {
    
      errors[key] = config.errorMessage || "";
      return errors;
    } else if (
      // 3. minValue 초과 입력 판단
      typeof value === "number" &&
      config.minValue !== undefined &&
      value <= config.minValue
    ) {
      errors[key] = config.errorMessage || "";
      return errors;
    }
  }

  return errors;
}

결과물

상단에서부터 1개씩 해당 input의 error상태(빨간색) 하단에 error메시지를 보여준다


Record

Record<K,T>

기본적으로 객체로서 두가지 타입 인자를 받아 전자는 Key로, T는 value로서의 타입을 담당한다.
즉, 객체의 모든 속성이 같은 타입을 갖도록 만들 수 있어 각 상태의 타입을 명시적으로 정의하는 데 유용하며 type의 일관성을 보장받을 수 있다.

  • 객체의 구조화 면에서 인덱스 시그니처 혹은 Map과 비교되는 듯한데.. 뭔가 새로운 타입을 사용해볼 수 있어서 좋았다.
  • Record는 Typescript에서 사용되는 타입 정의이기때문에 기본적으로 Object면서 컴파일단계에서 타입 체크(오류 사전 방지)가 이루어진다.

Object와 checkErrorOrder?

사실, checkErrorOrder가 들어가는 로직이 없어도 Config 순서대로도 위에서부터 검증이 작동하는 과정을 확인했다.
하지만...Object는 순서가 보장되지 않는 것로 아는데 왜 순서가 보장이 된걸까?

이에 대해 찾아보니 같은 의문을 가졌던 블로그글이 나왔는데

Es2015(es6)기준으로
1. string 타입의 키 값은 넣는 순서를 보장
2. 정수형 키 값은 오름차순으로 정렬되며 순회 시 string형 보다 먼저 접근됨
3. Symbol 타입 또한 넣는 순서 보장

이라고 한다.

그 외 대체가능한 순서 보장 방법으로 다음과 같이 방법이 있는데

  1. Map
  2. Object.keys(), Object.values(), Object.entries() (이것도 ES2015부터)
  3. 배열

구형브라우저에서 이를 보장하지 않는다는 점도 있고, Config파일(너무 길다)이 따로 분리되거나 최상단에 위치하여 순서를 확인하기 힘든 점을 고려했을 때, 위 3가지 선택지 중 배열로서 따로 명시하는것을 선택했다.

이게 무조건 맞는 선택이라고는 할 수 없지만 나름의 이유는 이렇다..

Generic Type

그 외, 제네릭 타입을 이론적으로 배우거나.. 가끔 라이브러리 props나 타입을 찾아들어가보면서 봐왔는데, 이번에 직접 기능을 제작하는데 쓰였다는 점이 굉장히 좋았다.

  • 위 유틸함수에서 여러 종류의 formdata를 받아 유효성을 검사해야 할때 제네릭(formdata: T)을 활용함으로서 각자의 특정한 데이터 타입에 의존하지 않고 사용할 수 있었다.(재사용성과 유연성이 높아짐)

+)사실 React-Hook-Form이라는 라이브러리(비제어 컴포넌트로서 리렌더링을 최소화시킴 등)가 있다는 걸 중간에 알았지만 일단 끝까지 구현해보고 싶어 이렇게 해 보았다. 하지만 에러 유효성 검사를 하며 느낀 렌더링 횟수를 보니 사용을 앞으로 사용안할 이유가 없어보인다...

다음 프로젝트 form 관련 로직이 필요하다면 위 라이브러리를 사용해볼 수 있지 않을까?


https://developer-talk.tistory.com/296
https://velog.io/@hkh9601/JS-객체의-속성-순서

profile
왼쪽 태그보다 시리즈 위주로 구분

0개의 댓글