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

yongkini ·2024년 12월 11일
0

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
완벽함 보다는 최선의 결과를 위해 끊임없이 노력하는 개발자

0개의 댓글