개발새발 로그

React - 회원가입, 로그인 Form을 좀 더 편하게 쓸 수 없을까? 본문

React

React - 회원가입, 로그인 Form을 좀 더 편하게 쓸 수 없을까?

이즈흐 2024. 3. 29. 19:57

회원가입 페이지와 로그인 페이지를 만들면서 다양한 경험을 했다.

form데이터를 관리하면서 중복되는 문제와

해당 form데이터 마다 검증, 에러 메시지, 또한 의존되는 특정 form 데이터 등...

이런 문제들을 어떻게 풀어갔는지 과정으로 설명해보려고 한다.

 

 

 

중복되는 Input 데이터와 Validation

먼저 발견된 문제점은 이메일, 사용자 이름, 비밀번호를 입력하는 input이 동일한 기능과 validation을 갖고 있다는 점이었다.

이 점을 염두해두고 개발을 시작했지만

이메일, 사용자 이름, 비밀번호는 각각 다른 validation을 갖고 있고, onChange될 때마다 검증을 해야했으며

이메일은 검증 후에 중복확인 API완료라는 조건이 있었고,

비밀번호는 password와 confirmPassword 값을 서로 비교해야하는 과정이 필요했다.

그래서 처음에는 아래와 같은 파일 구조로 회원가입을 만들게 되었다

간단하게 설명하면 각 Input들을 컴포넌트로 나누고 각 컴포넌트 마다의 Validation이나 onChange 기능을 수행했다.

이 방식으로 하게 되니까 기능은 되었지만 너무 많은 파일을 생성하고 있었고, 중복이 되고있다는 느낌이 들었다,

 

팀원 분과 얘기하면서 이 부분이 좋지 않은 것을 느꼈다.

아래 코드리뷰를 받아 보면서 문제를 확실하게 인식했고, 이를 풀어보고자 했다.

https://github.com/prgrms-fe-devcourse/FEDC5_Owhat_Byunghyun/pull/93

 

feat: 회원가입 페이지 구현 by oridori2705 · Pull Request #93 · prgrms-fe-devcourse/FEDC5_Owhat_Byunghyun

🌍 이슈 번호 close #77 ✅ 작업 내용 회원가입 요청 기능 구현 이메일, 사용자 이름, 비밀번호에 각각 validation 구현 이메일 중복확인을 위한 validation 구현 이메일, 사용자 이름, 비밀번호가 모두

github.com

 

 

일단 이메일, 비밀번호, 닉네임 등을 입력받고 그 값을 저장하는 State와 그에 해당하는 각각의 Validation을 관리해야했다.
그래서 먼저 생각했던 것이 state를 객체로 보관하는 것이었다.

처음에는 객체로 보관하면 어떤 하나의 값이 바뀌었을 때 모두 렌더링 되어서 좋지 않잖아? 라고 생각했다.

그래도 객체로 선택한 이유는

  • 비밀번호와 비밀번호 확인 값 같은 경우에는 서로 다른 state지만 서로 같은지를 확인해야했다.
  • 그리고 모든 state의 Validation이 통과가 됐음을 처리하기에 객체가 편리했다.

 -> 하지만 하나의 타이핑마다 불필요한 부분이 렌더링이 되는 부분은 이후 리팩토링을 꼭 해야겠다고 판단했다.

 

그래서 먼저 객체로 만들어서 모든 input의 state들을 한번에 관리했다.

 

 

🛠️input값과 Validation 관리하기

interface FormValues {
  [key: string]: string;
}

const [values, setValues] = useState<FormValues>(initialValues);

이렇게 인덱스 시그니처를 사용해서 객체가 여러 key값을 가질 수 있도록 했다.

 

interface FormIsValid {
  [key: string]: boolean;
}

const [isValid, setIsValid] = useState<FormIsValid>(isValidinitialValues);

그리고 Validation 또한 인덱스 시그니처를 통해 여러 key값을 가지도록 했다.

 

 

🛠️onChange로 타이핑 마다 Validation 검사하기

const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
    const { name, value } = e.target;

    const result = validate[name](value);

    setValues(prevValues => ({ ...prevValues, [name]: value }));
    setIsValid(prevIsValid => ({ ...prevIsValid, [name]: result }));

    // confirmPassword 필드에 대한 유효성 검사 함수가 등록되어 있다면 실행 - 재사용성 감소의 원인
    if (name === 'password' && validate['confirmPassword']) {
      const confirmPasswordResult = value === values.confirmPassword;
      setIsValid(prevIsValid => ({
        ...prevIsValid,
        confirmPassword: confirmPasswordResult,
      }));
    }
  };

회원가입이나 로그인을 할 때 타이핑을 하면 현재 값을 검사하고, 옳지 않으면 메시지를 띄워야했다.

 

그래서 onChage마다 객체 값을 바꿔주고, Validation을 실행하게 했다.

여기서 문제는 비밀번호비밀번호 확인 state 였다.

 

비밀번호Validation을 수행하지만 

비밀번호비밀번호 확인이 같은지의 Validation도 수행해야했다.

비밀번호 state는 2개의 Validation이 필요했다.

 

또한 비밀번호비밀번호 확인이 같은지 검사했다 하더라도

사용자가 비밀번호 값이나 비밀번호 확인 값을 지우거나 바꾸면 Validation이 작동되게 해야했다.

 

그래서 위처럼 if문으로 바뀌는 state값이 "password" 이거나 Validation이 "confirmPassword"라면 
비밀번호와 비밀번호 확인의 필요한 Validation을 한번 더 수행하게 했다.

 

이 부분이 뭔가 재사용성을 감소시키는 것 같았다.

 -> 이 부분이 두 번째로 리팩토링 해야하는 부분이라고 생각이 들었다.\

 

결과적으로 모든 validation과 form데이터를 통합해서 관리하고 검증해주는 useForm을 커스텀 훅을 만들었다.

useForm 커스텀 훅

import { ChangeEvent, useEffect, useState } from 'react';

interface FormValues {
  [key: string]: string;
}

interface FormIsValid {
  [key: string]: boolean;
}

interface FormValidate {
  [key: string]: (value: string) => boolean;
}

interface useFormParams {
  initialValues: FormValues;
  isValidinitialValues: FormIsValid;
  validate: FormValidate;
}

const useForm = ({
  initialValues,
  isValidinitialValues,
  validate,
}: useFormParams) => {
  const [values, setValues] = useState<FormValues>(initialValues);
  const [isValid, setIsValid] = useState<FormIsValid>(isValidinitialValues);
  const [isCompleted, setIsCompleted] = useState(false);

  const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
    const { name, value } = e.target;

    const result = validate[name](value);

    setValues(prevValues => ({ ...prevValues, [name]: value }));
    setIsValid(prevIsValid => ({ ...prevIsValid, [name]: result }));

    // confirmPassword 필드에 대한 유효성 검사 함수가 등록되어 있다면 실행 - 재사용성 감소의 원인
    if (name === 'password' && validate['confirmPassword']) {
      const confirmPasswordResult = value === values.confirmPassword;
      setIsValid(prevIsValid => ({
        ...prevIsValid,
        confirmPassword: confirmPasswordResult,
      }));
    }
  };

  const areAllValid = (obj: FormIsValid): boolean => {
    return Object.values(obj).every(value => value);
  };

  useEffect(() => {
    const result = areAllValid(isValid);
    setIsCompleted(result);
  }, [isValid]);

  return {
    values,
    isValid,
    isCompleted,
    handleChange,
  };
};

export default useForm;

사용 방법

  const { values, isValid, isCompleted, handleChange } = useForm({
    initialValues: {
      email: '',
      password: '',
      username: '',
      confirmPassword: '',
    },
    isValidinitialValues: {
      email: false,
      password: false,
      username: false,
      confirmPassword: false,
    },
    validate: {
      email: value => isValidEmail(value),
      password: value => isValidPassword(value),
      username: value => isValidUsername(value),
      confirmPassword: value =>
        isValidPasswordMatch({
          value,
          newPassword: values.password,
        }),
    },
  });

 

 

🛠️하지만 중복확인 Validation이 있어..!

그렇지만 남은 문제는 이메일을 입력해서 Validation이 성공하면 활성화되는 중복확인 과정이 있었다.

즉 검증에 성공한 이메일 값을 API 요청에 보내서 같은 이메일이 있는지 확인하는 것이다.

 

여기서는 필요한 조건들이 있었다.

1. 이메일 검증이 성공해야 중복확인 버튼이 활성화 된다.

2. 중복확인이 성공하면 중복확인 버튼은 비활성화 되어야 한다.

2. 중복확인이 성공하고, 이메일 값이 바뀐다면 다시 중복확인검증을 수행해야한다.

3. 만약 이메일 값이 바뀔 때 검증이 실패하면 1번부터 다시 수행한다.

 

이메일은 이처럼 검증중복확인이 필요했다.

 

그래서 따로 Email의 중복 검증을 하는 훅을 만들었고,

중복에러 메시지(EmailCheckMessage) 검증에러 메시지를 따로 관리해야 했다.

 

그리고 중복확인 버튼의 활성화/비활성화의 조건이 복잡했다.

1. 이메일 검증이 성공했을 때 활성화/ 검증 실패시 비활성화

2. 이메일 검증 성공 후 email 값이 변경되면 다시 활성화

 

이 부분을 어떻게 해야할까? 고민됐다.

그냥 "검증이 성공하면 useEffect에서 의존성을 이용해 활성화 시키면 되잖아!" 했는데

검증 성공하고 이메일 값을 변경할 수 있는 상황이 있었다.

이걸 UI에서 중복확인에 성공하면 input을 못 바꾸게 잠금처리할 수 있었지만 그러지는 않았다.

 

일단은 아래와 같이 email 값이 바뀔 때마다 

중복 확인 버튼 활성화 요청(true)을 하고, 중복 에러메시지를 초기화 시킨다.

그리고 중복확인 버튼에 disabled에 검증과 중복확인버튼 상태를 계속해서 확하게 했다.

 

어쩌면 불필요한 set함수 호출이었다.

검증이 실패일때도 setIsEmailDuplicate(true)가 계속 요청하게 하는 것이었다.

하지만 리액트 자체에서 set함수를 요청해도 값이 바뀌지않는다면 커밋 페이즈 단계에서 최적화가 되기 때문에 괜찮다고 생각했다.

 

결과적으로 아래와 같은 코드가 완성되었다.

  const {
    emailDuplicateCheckMessage,
    setIsEmailDuplicate,
    setEmailDuplicateCheckMessage,
    isEmailDuplicate,
    checkDuplicateEmail,
  } = useEmailDuplicate({ userList }); // 중복관련 커스텀 훅
  
  //useForm 사용
  const { values, isValid, isCompleted, handleChange } = useForm({
    initialValues: {
      email: '',
      password: '',
      username: '',
      confirmPassword: '',
    },
    isValidInitialValues: {
      email: false,
      password: false,
      username: false,
      confirmPassword: false,
    },
    validate: {
      email: value => isValidEmail(value),
      password: value => isValidPassword(value),
      username: value => isValidUsername(value),
      confirmPassword: value =>
        isValidPasswordMatch({
          value,
          newPassword: values.password,
        }),
    },
  });
  
  // ...


useEffect(() => {
    setIsEmailDuplicate(true); // email 값이 바뀔 때마다 중복확인 버튼 활성화 요청
    setEmailCheckMessage(''); // email 값이 바뀔 때 마다 email 중복 체크 메시지 초기화
  }, [values.email, setEmailCheckMessage, setIsEmailDuplicate]);

  useEffect(() => {
    onRegisterCompleted(isCompleted && !isEmailDuplicate);
  }, [isCompleted, isEmailDuplicate, onRegisterCompleted]);

  const DuplicateButton = () => (
    <Button
      onClick={() => checkDuplicateEmail(values.email)}
      type="button"
      styleType="ghost"
      className="absolute right-0 top-0 z-10 translate-y-[7%] text-sm"
      disabled={!isValid.email || (isValid.email && !isEmailDuplicate)}
    > {/*이메일이 검증안됐거나 이메일이 검증됐고, 중복버튼 활성화가 false면*/}
      중복 확인
    </Button>
  );

 

🛠️그럼 이제 중복되는 Input들을 줄여보자!

위에서 input의 값과 validation을 통합해서 관리했다

그러면 이제 그 값들로 운용되는 Input 컴포넌트를 만들어서 한번에 관리하면 된다.

interface FormFieldProps {
  type: string;
  name: string;
  label: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
  value: string;
  isValid: boolean;
  errorMessage?: string;
  placeholder: string;
  right?: ReactNode;
}

const FormField = ({
  type,
  name,
  label,
  onChange,
  value,
  isValid,
  errorMessage,
  placeholder,
  right,
}: FormFieldProps) => {
  return (
    <Group direction="columns" spacing="sm" grow={true} className="relative">
      <label htmlFor={name}>
        <Text size="small" elementType="span">
          {label}
        </Text>
      </label>
      <div className="relative w-full">
        <Input
          type={type}
          name={name}
          onChange={onChange}
          placeholder={placeholder}
          value={value}
          className="h-11 w-full pr-10"
        />
        {right}
        {value && !isValid && (
          <Text className="text-sm text-error">
            {errorMessage || `올바른 ${label}을(를) 입력하세요.`}
          </Text>
        )}
      </div>
    </Group>
  );
};

export default FormField;

 

하나의  <input>태그와 에러 메시지를 출력할 <p>테그로 각 input을 표현하게 했다.

이를 통해서 이전에 이메일,비밀번호,사용자 이름으로 중복되도록 나눴던 컴포넌트들이 하나의 컴포넌트로 운용가능해졌다.

아래와 같이사용한다.

  <FormField
      type="text"
      name="username"
      label="이름"
      placeholder="이름을 입력해주세요."
      onChange={handleChange}
      value={values.username}
      isValid={isValid.username}
      errorMessage={ERROR.NAME_INVALID}
    />

🛠️검증 정규표현식도 만들어보았다!

아래와 같이 이메일, 패스워드, 사용자 이름 정규표현식도 만들어 보았다.

정규표현식을 써서 간단하게 풀어낼 수 있었다.

이메일 & 패스워드 & 사용자 이름 정규표현식

export const isValidEmail = (value: string) =>
  /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/.test(value);

export const isValidPassword = (value: string) =>
  value.length >= 8 &&
  /[a-zA-Z]/.test(value) &&
  /\d/.test(value) &&
  /[!@#$%^&*(),.?":{}|<>]/.test(value);

export const isValidUsername = (value: string) =>
  value.length >= 3 && value.length <= 8 && /^[가-힣a-zA-Z0-9]+$/.test(value);

export const isValidPasswordMatch = ({
  value,
  newPassword,
}: {
  value: string;
  newPassword: string;
}) => {
  return newPassword === value;
};

 

 

😊결과

결과적으로 아래와 같이 파일 수 자체가 줄었다.

측정했을 때 453줄의 코드를 줄일 수 있었다!

처음에는 각각 검증도 다르고, 의존되는 상태들도 있고, 에러메시지도 다르게 해야되는데 따로 컴포넌트로 하는 방법밖에 없지 않나? 했었는데 집중해서 생각하니 해결 법은 있었다.

 

리액트를 더 잘 알게 된다면 지금 useForm을 더 최적화할 수 있는 방법이 있지 않을까? 생각한다.

 

 

 

🛠️리팩토링을 해보자!

 현재 두 가지의 문제를 발견했다.

1. 함수명과 변수명이 자신의 역할을 충분히 설명 못하는 네이밍으로 되어있는 문제

2. confirmPassword로 인해 추가된 조건 문제

 

1번 문제는 위 코드에서도 보이듯이 다른 개발자가 봤을 때 전혀 예상할 수 없는 함수명과 변수명으로 되어있다.

해당 문제를 발견했고, 변수명과 함수명을 변경해야겠다고 생각했다.

 

2번 문제는 계속해서 좋지 않다고 생각한 문제였다.

아래 코드를 다시 보자

 

  const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
    const { name, value } = e.target;

    const result = validate[name](value);

    setValues(prevValues => ({ ...prevValues, [name]: value }));
    setIsValid(prevIsValid => ({ ...prevIsValid, [name]: result }));

    // confirmPassword 필드에 대한 유효성 검사 함수가 등록되어 있다면 실행 - 재사용성 감소의 원인
    if (name === 'password' && validate['confirmPassword']) {
      const confirmPasswordResult = value === values.confirmPassword;
      setIsValid(prevIsValid => ({
        ...prevIsValid,
        confirmPassword: confirmPasswordResult,
      }));
    }
  };

비밀번호 확인을 위해서 "confirmPassword" 라는 검증이 있고, "password"가 onChange 될 때 따로 검사해야 되는 부분이다.

"password"가 바뀌면 "confrimPassword"의 검증도 다시 수행해야한다.

근데 로직을 잘보면 불필요한 검사와 set함수를 한번 더 수행하고 있다.

 

이 부분이 useForm을 만들면서 좋지않다고 생각한 부분이었다.

어떻게하면 통합할 수 있을까 생각하다가 아래와 같이 바꾸면 어떨까 생각해냈다.

 

먼저 validate로 받아오는 함수들의 인수 타입을 하나 더 추가해줬다.

interface FieldValidators {
  [key: string]: (value: string, fields: FormFields) => boolean;
}

fields를 추가해줘서 전체 필드 데이터를 인수로 보내줄 수 있도록 했다.

그리고 전체 데이터를 통해서 각각 form 데이터 모두가 전체 데이터와 검증을 수행하도록 했다.

즉 모든 form 데이터가 타이핑 될 때 마다 모든 검증을 수행하게 한 것이다.

 const handleFieldChange = (e: ChangeEvent<HTMLInputElement>): void => {
    const { name, value } = e.target;
    validateField(name, value);
  };

  const validateField = (fieldName: string, fieldValue: string): void => {
    setFields(prevFields => {
      const updatedFields = { ...prevFields, [fieldName]: fieldValue };
      const updatedValidationStatus = { ...validationStatus };
      for (const key in validate) {
        updatedValidationStatus[key] = validate[key](
          updatedFields[key],
          updatedFields,
        );
      }

      setValidationStatus(updatedValidationStatus);
      return updatedFields;
    });
  };

작동을 잘 되겠지만 치명적인 문제가 있는 코드다

만약  form데이터가 아주 많다면 그 코드의 검증을 모두 수행해야하고,

검증이 복잡한 코드라면 결과적으로 성능저하가 일어날 수 있다.

 

"password"라는 특정 키워드를 if문으로 넣어서 처리하는 것을 해결했지만 좋지 않은 코드가 됐다.

그래서 이렇게 특정 키워드를 쓰지 않되 가능한 방법을 생각해봤다.

 

의존성이 있는 form 데이터들은 useForm을 사용할 때 사용자가 미리 알려주면 되지 않을까?

무슨 소리냐면 useForm을 사용할 때 form데이터와 validation을 설정하는데 이 때 의존성있는 데이터를 미리 useForm에 알려줘서 검증을 수행하게 하는 것이다.

쉽게 말하면 "password"가 바뀌면 "confirmpassword"의 검증도 다시 수행해야 하는 것을 알리는 것이다.

 

코드로 보자

const { fields, validationStatus, isFormComplete, handleFieldChange } =
    useForm({
      initialValues: {
        email: '',
        password: '',
        username: '',
        confirmPassword: '',
      },
      initialValidationStatus: {
        email: false,
        password: false,
        username: false,
        confirmPassword: false,
      },
      validate: {
        email: value => isValidEmail(value),
        password: value => isValidPassword(value),
        username: value => isValidUsername(value),
        confirmPassword: (value, values) => value === values.password,
      },
      dependencies: { // 의존성을 알리는 객체
        password: ['confirmPassword'],
      },
    });

위 처럼 dependencies라는  객체를 넘겨줄 수 있도록 해서

만약 password가 바뀔 때 같이 검증을 수행해야하는 confirmPassword를 useForm에 알려주는 것이다.

 

그래서 useForm에서는 이를 확인하고, 지정한 key값인 password가 바뀔 때 confrimPassword의 검증도 수행하는 것이다.

useForm내부에서 수행해야하는 코드를 보자

  const validateField = (fieldName: string, fieldValue: string): void => {
    setFields(prevFields => {
      const updatedFields = { ...prevFields, [fieldName]: fieldValue };

      let fieldsToValidate = [fieldName]; //form데이터 자신이 수행해야하는 validation을 먼저 삽입
      if (dependencies[fieldName]) {
        fieldsToValidate = fieldsToValidate.concat(dependencies[fieldName]);
      } //이후 의존성이 있으면 배열을 합쳐줌

      const updatedValidationStatus = { ...validationStatus };
      fieldsToValidate.forEach(fieldToValidate => { //위에서 합쳐진 validation을 모두 수행
        updatedValidationStatus[fieldToValidate] = validate[fieldToValidate](
          updatedFields[fieldToValidate],
          updatedFields,
        );
      });

      setValidationStatus(updatedValidationStatus);

      return updatedFields;
    });
  };

 

이렇게 만들면 password는 2개의 검증을 수행할 수 있고, 이외의 form데이터는 1개의 검증만 수행하게 돼서 위에서 성능저하 문제를 해결했다.

그리고 의존성 자체를 배열로 했는데 이후 만약 의존성이 2개이상이 될 수 있으므로 배열로 해줬다. (재사용성)

 

최종 코드를 보자

import { ChangeEvent, useEffect, useState } from 'react';

interface FormFields {
  [key: string]: string;
}

interface FieldsValidationStatus {
  [key: string]: boolean;
}

interface FieldValidators {
  [key: string]: (value: string, fields: FormFields) => boolean;
}
interface UseFormParams {
  initialValues: FormFields;
  initialValidationStatus: FieldsValidationStatus;
  validate: FieldValidators;
  dependencies?: { [key: string]: string[] };
}

const useForm = ({
  initialValues,
  initialValidationStatus,
  validate,
  dependencies = {},
}: UseFormParams) => {
  const [fields, setFields] = useState<FormFields>(initialValues);
  const [validationStatus, setValidationStatus] =
    useState<FieldsValidationStatus>(initialValidationStatus);
  const [isFormComplete, setIsFormComplete] = useState(false);

  const handleFieldChange = (e: ChangeEvent<HTMLInputElement>): void => {
    const { name, value } = e.target;
    validateField(name, value);
  };

  const validateField = (fieldName: string, fieldValue: string): void => {
    setFields(prevFields => {
      const updatedFields = { ...prevFields, [fieldName]: fieldValue };

      let fieldsToValidate = [fieldName];
      if (dependencies[fieldName]) {
        fieldsToValidate = fieldsToValidate.concat(dependencies[fieldName]);
      }

      const updatedValidationStatus = { ...validationStatus };
      fieldsToValidate.forEach(fieldToValidate => {
        updatedValidationStatus[fieldToValidate] = validate[fieldToValidate](
          updatedFields[fieldToValidate],
          updatedFields,
        );
      });

      setValidationStatus(updatedValidationStatus);

      return updatedFields;
    });
  };

  const allFieldsValid = (status: FieldsValidationStatus): boolean => {
    return Object.values(status).every(value => value);
  };

  useEffect(() => {
    const validationResult = allFieldsValid(validationStatus);
    setIsFormComplete(validationResult);
  }, [validationStatus]);

  return {
    fields,
    validationStatus,
    isFormComplete,
    handleFieldChange,
  };
};

export default useForm;

 

이렇게 되면 "password"와 "confirmPassword"라는 키워드를 useForm사용자가 꼭 지켜서 사용하지 않아도 된다.

이 부분이 항상 마음에 걸린 부분이었다.

사용자가 자유롭게 Key 네임을 정하고, 의존성을 넣음으로써 useForm은 더 재사용성이 높아졌다.

 

비밀번호 데이터를 변경 시 비밀번호 확인 데이터의 검증도 수행되는 모습

728x90
반응형
LIST