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

yongkini ·2024년 12월 11일

Functional Programming

목록 보기
17/21

회원가입 폼을 만들면서 FP 스타일로 코딩해보기

-> 지난번에 OOP, FP를 비교해보면서 셀프 과제로 각각의 코딩 방식으로 유효성 검사가 섞은 회원가입 폼을 만들어보기로 했었는데 아직 완성은 아니지만, 함수형 프로그래밍 + react hook 방식을 섞어서 1차 완성을 해봤다(아직 태그 부분은 미적용)

아직은 이게 왜 좋은지, 그리고 사실 hook에 옮겨놓은건 아직 재사용을 할 수 있을정도인지는 모르겠다. 일단 중간 기록을 남겨두고, 나중에 완성을 한 뒤에 비교하기 위해 기록해본다.

모노레포 pacakges/utils에 있는 validation.ts 파일

: 여기는 다른 프로젝트에서도 가져다 쓸 수 있게 만들어 놓는 곳이다. 확실히 FP로 해놓으면 여러가지 단일 기능의 함수들이 그냥 모여있는 느낌이라.. 이게 사이즈가 커지면 어떻게 관리하는지는 아직은 모르겠다..

import { curry, Either, pipe } from "../functional";
import {
  REGEX_WITHOUT_CHARACTERS,
  NICKNAME_CHARACTER_ERROR_MESSAGE,
  NICKNAME_LENGTH_ERROR_MESSAGE,
  NICKNAME_WHITESPACE_ERROR_MESSAGE,
  REGEX_CHECK_BLANK,
  REGEX_EMAIL,
  EMAIL_FORMAT_ERROR_MESSAGE,
  REGEX_ALPHABET_UPPER_CASE,
  PASSWORD_FORMAT_ERROR_MESSAGE,
  REGEX_ALPHABET_LOWER_CASE,
  REGEX_SPECIFIC_CHARACTERS,
  PASSWORD_LENGTH_ERROR_MESSAGE,
  TAG_CHARACTER_ERROR_MESSAGE,
  REGEX_TAG,
  INTRODUCTION_LENGTH_ERROR_MESSAGE,
} from "../constants/validation";

// 자기소개 검사: 공백 제외 15자 이상
export const isValidIntroduction = (intro: string): boolean => {
  const trimmedLength = intro.replace(/\s/g, "").length;
  return trimmedLength >= 15;
};

export const replaceAllBlack = (value: string) => value.replace(/\s/g, "");

export const checkStringLength = curry(
  (from: number, to: number, message: string, value: string) =>
    value.trim().length >= from && value.length <= to
      ? Either.right(value)
      : Either.left(message)
);

export const checkSpeicalCharacter = curry(
  (regex: RegExp, message: string, value: string) =>
    regex.test(value) ? Either.right(value) : Either.left(message)
);

export const checkNicknameLength = checkStringLength(
  3,
  15,
  NICKNAME_LENGTH_ERROR_MESSAGE
);

export const checkNicknameCharacter = checkSpeicalCharacter(
  REGEX_WITHOUT_CHARACTERS,
  NICKNAME_CHARACTER_ERROR_MESSAGE
);

export const checkNicknameWhiteSpace = checkSpeicalCharacter(
  REGEX_CHECK_BLANK,
  NICKNAME_WHITESPACE_ERROR_MESSAGE
);

export const checkValidEmail = checkSpeicalCharacter(
  REGEX_EMAIL,
  EMAIL_FORMAT_ERROR_MESSAGE
);

export const checkPasswordHasUpperCase = checkSpeicalCharacter(
  REGEX_ALPHABET_UPPER_CASE,
  PASSWORD_FORMAT_ERROR_MESSAGE
);

export const checkPasswordHasLowerCase = checkSpeicalCharacter(
  REGEX_ALPHABET_LOWER_CASE,
  PASSWORD_FORMAT_ERROR_MESSAGE
);

export const checkPasswordHasSpecificCharacters = checkSpeicalCharacter(
  REGEX_SPECIFIC_CHARACTERS,
  PASSWORD_FORMAT_ERROR_MESSAGE
);

export const checkPasswordLength = checkStringLength(
  10,
  20,
  PASSWORD_LENGTH_ERROR_MESSAGE
);

export const checkTagCharacter = checkSpeicalCharacter(
  REGEX_TAG,
  TAG_CHARACTER_ERROR_MESSAGE
);

export const checkIntroductionLength = pipe(
  replaceAllBlack,
  checkStringLength(
    15,
    Number.MAX_SAFE_INTEGER,
    INTRODUCTION_LENGTH_ERROR_MESSAGE
  )
);

실제 프로젝트에서 packages의 내용을 가져와서 만든 hook

import {
  alt,
  curry,
  Either,
  Left,
  pipe,
  Right,
  tap,
  isValid as checkIsValid,
  checkValidEmail,
  checkNicknameLength,
  checkNicknameCharacter,
  checkNicknameWhiteSpace,
  checkPasswordLength,
  checkPasswordHasUpperCase,
  checkPasswordHasLowerCase,
  checkPasswordHasSpecificCharacters,
  checkIntroductionLength,
} from "@myorg/utils";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FormErrors } from "src/types/validator";

export const useValidation = () => {
  const [errors, setErrors] = useState<FormErrors>({
    email: undefined,
    nickname: undefined,
    password: undefined,
    introduction: undefined,
  });

  const createFieldValidator = useCallback(
    (
      field: keyof FormErrors,
      setErrors: React.Dispatch<React.SetStateAction<FormErrors>>
    ) => {
      const setFieldError = curry((type: string, message: string) => {
        setErrors((prev) => ({ ...prev, [type]: message }));
      });

      const setFieldErrorMessage = setFieldError(field);
      const resetFieldErrorMessage = () => setFieldErrorMessage("");

      const checkAndSetErrorMessage = curry(
        (validators: Array<(value: string) => Either>, value: string) =>
          (
            validators.reduce(
              (acc, validator) => acc.chain(validator),
              Either.right(value) as Right
            ) as Right | Left
          ).orElse(setFieldErrorMessage)
      );

      const createCheckField = (validators: Array<(value: string) => Either>) =>
        pipe(
          tap(resetFieldErrorMessage),
          alt(
            checkIsValid,
            checkAndSetErrorMessage(validators),
            resetFieldErrorMessage
          )
        );

      return { setFieldErrorMessage, resetFieldErrorMessage, createCheckField };
    },
    []
  );

  const emailValidator = useMemo(
    () => createFieldValidator("email", setErrors),
    [createFieldValidator]
  );

  const nicknameValidator = useMemo(
    () => createFieldValidator("nickname", setErrors),
    [createFieldValidator]
  );

  const passwordValidator = useMemo(
    () => createFieldValidator("password", setErrors),
    [createFieldValidator]
  );

  const introductionValidator = useMemo(
    () => createFieldValidator("introduction", setErrors),
    [createFieldValidator]
  );

  const checkEmail = useMemo(
    () => emailValidator.createCheckField([checkValidEmail]),
    [emailValidator]
  );

  const checkNickname = useMemo(
    () =>
      nicknameValidator.createCheckField([
        checkNicknameLength,
        checkNicknameCharacter,
        checkNicknameWhiteSpace,
      ]),
    [nicknameValidator]
  );

  const checkPassword = useMemo(
    () =>
      passwordValidator.createCheckField([
        checkPasswordLength,
        checkPasswordHasUpperCase,
        checkPasswordHasLowerCase,
        checkPasswordHasSpecificCharacters,
      ]),
    [passwordValidator]
  );

  const checkIntroduction = useMemo(
    () => introductionValidator.createCheckField([checkIntroductionLength]),
    [introductionValidator]
  );

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

  useEffect(() => {
    console.log(errors);
  }, [errors]);

  return {
    checkPassword,
    checkNickname,
    checkEmail,
    checkIntroduction,
    errors,
    isFormValid,
    setErrors,
  };
};

그리고 실제 이를 사용하는 SignUp.tsx

import { useState } from "react";
import styled from "@emotion/styled";
import { useSkipFirstRender } from "@myorg/ui";
import { useValidation } from "@hooks/useValidation";

interface FormData {
  email: string;
  nickname: string;
  password: string;
  tags: string[];
  introduction: string;
}

const Container = styled.div`
  width: 450px;
  margin: 2rem auto;
  padding: 2rem;
  box-sizing: border-box;
  background: #1a1a1a;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
  color: #fff;
`;

const Title = styled.h2`
  font-size: 1.5rem;
  font-weight: bold;
  text-align: center;
  margin-bottom: 2rem;
  color: #fff;
`;

const FormGroup = styled.div`
  margin-bottom: 1.5rem;
  box-sizing: border-box;
`;

const Label = styled.label`
  display: block;
  margin-bottom: 0.5rem;
  font-weight: 500;
  color: #fff;
`;

const Input = styled.input<{ hasError?: boolean }>`
  width: 100%;
  height: 40px;
  box-sizing: border-box;
  background: #2a2a2a;
  border: 1px solid ${(props) => (props.hasError ? "#ff4444" : "#3a3a3a")};
  border-radius: 4px;
  font-size: 1rem;
  color: #fff;

  &::placeholder {
    color: #666;
  }

  &:focus {
    outline: none;
    border-color: ${(props) => (props.hasError ? "#ff4444" : "#4a4a4a")};
  }
`;

const TextArea = styled.textarea<{ hasError?: boolean }>`
  box-sizing: border-box;
  width: 100%;
  background: #2a2a2a;
  border: 1px solid ${(props) => (props.hasError ? "#ff4444" : "#3a3a3a")};
  border-radius: 4px;
  font-size: 1rem;
  color: #fff;
  min-height: 120px;
  resize: vertical;

  &::placeholder {
    color: #666;
  }

  &:focus {
    outline: none;
    border-color: ${(props) => (props.hasError ? "#ff4444" : "#4a4a4a")};
  }
`;

const ErrorMessage = styled.p`
  color: #ff4444;
  font-size: 0.875rem;
  margin-top: 0.5rem;
`;

const TagInputContainer = styled.div`
  display: flex;
  gap: 0.5rem;
`;

const TagButton = styled.button`
  padding: 0.5rem 1rem;
  box-sizing: border-box;
  width: 100px;
  height: 40px;
  background: #333;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;

  &:hover {
    background: #444;
  }
`;

const TagList = styled.div`
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.5rem;
`;

const TagItem = styled.div`
  display: flex;
  align-items: center;
  gap: 0.25rem;
  padding: 0.25rem 0.5rem;
  background: #2a2a2a;
  border: 1px solid #3a3a3a;
  border-radius: 4px;
  color: #fff;
`;

const TagRemoveButton = styled.button`
  background: none;
  border: none;
  color: #ff4444;
  cursor: pointer;
  padding: 0 0.25rem;
  font-size: 1.2rem;
  line-height: 1;

  &:hover {
    color: #ff0000;
  }
`;

const SubmitButton = styled.button<{ disabled: boolean }>`
  width: 100%;
  padding: 0.75rem;
  background: ${(props) => (props.disabled ? "#222" : "#333")};
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 1rem;
  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};

  &:hover {
    background: ${(props) => (props.disabled ? "#222" : "#444")};
  }
`;

export default function SignUp() {
  const [formData, setFormData] = useState<FormData>({
    email: "",
    nickname: "",
    password: "",
    tags: [],
    introduction: "",
  });

  const [tagInput, setTagInput] = useState("");
  const {
    checkPassword,
    checkNickname,
    checkEmail,
    errors,
    isFormValid,
    setErrors,
    checkIntroduction,
  } = useValidation();

  useSkipFirstRender(() => {
    checkEmail(formData.email);
  }, [formData.email, checkEmail]);

  useSkipFirstRender(() => {
    checkNickname(formData.nickname);
  }, [formData.nickname, checkNickname]);

  useSkipFirstRender(() => {
    checkPassword(formData.password);
  }, [formData.password, checkPassword]);

  useSkipFirstRender(() => {
    checkIntroduction(formData.introduction);
  }, [formData.introduction, checkIntroduction]);

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setFormData((prev) => ({
      ...prev,
      [name]: value,
    }));
  };

  const handleTagSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // if (!checkTagCharacter(tagInput)) {
    //   setErrors((prev) => ({
    //     ...prev,
    //     tag: "태그는 3-10자의 영문, 숫자, 한글만 사용 가능합니다",
    //   }));
    //   return;
    // }

    if (formData.tags.includes(tagInput)) {
      setErrors((prev) => ({
        ...prev,
        tag: "이미 존재하는 태그입니다",
      }));
      return;
    }

    setFormData((prev) => ({
      ...prev,
      tags: [...prev.tags, tagInput],
    }));
    setTagInput("");
    setErrors((prev) => ({ ...prev, tag: undefined }));
  };

  const removeTag = (tagToRemove: string) => {
    setFormData((prev) => ({
      ...prev,
      tags: prev.tags.filter((tag) => tag !== tagToRemove),
    }));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (isFormValid) {
      console.log("Form submitted:", formData);
    }
  };

  return (
    <Container>
      <Title>회원가입</Title>
      <form onSubmit={handleSubmit} noValidate>
        <FormGroup>
          <Label>이메일</Label>
          <Input
            type="email"
            name="email"
            data-testid="email-input"
            hasError={!!errors.email}
            value={formData.email}
            onChange={handleChange}
          />
          {errors.email && (
            <ErrorMessage data-testid="email-error">
              {errors.email}
            </ErrorMessage>
          )}
        </FormGroup>

        <FormGroup>
          <Label>닉네임</Label>
          <Input
            type="text"
            name="nickname"
            data-testid="nickname-input"
            hasError={!!errors.nickname}
            value={formData.nickname}
            onChange={handleChange}
          />
          {errors.nickname && (
            <ErrorMessage data-testid="nickname-error">
              {errors.nickname}
            </ErrorMessage>
          )}
        </FormGroup>

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

        <FormGroup>
          <Label>태그</Label>
          <TagInputContainer>
            <Input
              type="text"
              value={tagInput}
              data-testid="tag-input"
              hasError={!!errors.tag}
              onChange={(e) => setTagInput(e.target.value)}
              placeholder="태그 입력"
            />
            <TagButton
              type="button"
              data-testid="tag-submit"
              onClick={handleTagSubmit}
            >
              추가
            </TagButton>
          </TagInputContainer>
          {errors.tag && (
            <ErrorMessage data-testid="tag-error">{errors.tag}</ErrorMessage>
          )}
          <TagList>
            {formData.tags.map((tag, index) => (
              <TagItem key={index} data-testid={`tag-item-${index}`}>
                <span>{tag}</span>
                <TagRemoveButton
                  data-testid={`tag-remove-${index}`}
                  type="button"
                  onClick={() => removeTag(tag)}
                >
                  ×
                </TagRemoveButton>
              </TagItem>
            ))}
          </TagList>
        </FormGroup>

        <FormGroup>
          <Label>자기소개</Label>
          <TextArea
            name="introduction"
            data-testid="introduction-input"
            hasError={!!errors.introduction}
            value={formData.introduction}
            onChange={handleChange}
          />
          {errors.introduction && (
            <ErrorMessage data-testid="introduction-error">
              {errors.introduction}
            </ErrorMessage>
          )}
        </FormGroup>

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

컴포넌트는 일단 분리하지 않고 하나에 만들었다. 일단 오늘은 여기까지 진행해보고, FP로 하는게 왜 좋은지, 개선점은 없는지 오히려 가독성이 안좋지는 않나 등등을 알아보고 다시 포스팅해보고자 한다. 재사용성 좋게 리팩토링도 해보자.

profile
Web3.0에 관심이 많은 FE 개발자입니다. VPA와 캔들 차트 분석을 기반으로 정량적 트레이딩 시스템을 직접 개발하여 암호화폐를 트레이딩하고 있습니다.

0개의 댓글