개발새발 로그

React - 회원가입 기능을 구현하면서 본문

React

React - 회원가입 기능을 구현하면서

이즈흐 2024. 7. 27. 23:58

📖들어가며..

24년 1월 18일 블로그 포스팅을 시작으로 회원가입 기능에 대해서 계속 리팩토링을 고민해왔었다.

처음에는 각각의 Form 데이터들을 하나의 컴포넌트로 만들어서 재사용성이 낮은 기능으로 개발했었다.

이후 팀원들의 코드리뷰와 피드백으로 useForm이라는 커스텀 훅을 만들었고, 중복되는 로직들을 개선했다.

리팩토링을 통한 개선

 


 

24년 3월 29일 블로그 포스팅에서는 useForm 훅 리팩토링을 시도했었다.

useForm이라는 커스텀 훅 개발에 자신감을 느끼고, 개발과정을 기록했었다.

기록하면서 "좋지않은 부분이 있는 것 같은데..?"라는 생각이 들었었다.

그래서 추가로 리팩토링을 시도하게 됐다.

기록하면서 발견한 문제

그래서 해당 문제를 의존성을 알리는 객체 dependencies를 받을 수 있게끔해서 해결했다.

간단하게 말하면

"비밀번호확인비밀번호가 타이핑될 때마다 유효성검사를 실시 해야해!"

와 같은 경우면 위 이미지에서처럼 dependencies에 객체를 포함해주는 것이다.

 

그리고 useForm내부에서는 dependencies가 있는 데이터는

dependencies에 포함된 key값의 유효성검사도 실시하게 하는 것이다.

 

이렇게되면 더욱 재사용성이 높아지게 된다고 판단했다.

 


하지만 여전히 useForm 훅에서 문제점이 존재했다.

24년 7월 13일 블로그 포스팅에서 비제어 컴포넌트와 제어 컴포넌트를 공부하던 도중에 
"제어 컴포넌트 방식인 useForm을 비제어 방식으로 바꿔야할까?" 라는 고민을 시작으로 다시 리팩토링을 시도하게 됐다.

 

먼저 useForm에서 존재했던 문제는 바로

"Form 데이터가 하나의 useState로 정의된 객체이기 때문에 객체 안의 데이터가 변경되면 모두 리렌더링 되는 문제였다." 

쉽게말해서 이메일, 사용자 이름, 비밀번호, 비밀번호확인 등과 같은 데이터가 하나의 객체안에 들어가있고,

이메일 데이터만 변경되어도 state가 변한 것이기 때문에 리-렌더링이 된다.

그러면 해당 객체를 받는 하위 컴포넌트 또한 리-렌더링이 되는 것이다.

 

이를 간단하게 해결한다고 했을 때 객체안의 데이터를 명확하게 해서 props로 내리고

하위 컴포넌트는 memo를 통한 고차 컴포넌트로 만들면 props 변경으로 인한 리-렌더링을 방지할 수 있다.

이런식으로 객체안의 데이터를 특정해서 내린다.

그러면 상위 컴포넌트가 상태 변경으로 리-렌더링되어도 해당 데이터가 정말 변했을 때만 리-렌더링 될 것이다.

 

그럼 해결된것인가?

아니다.

 useForm에는 위 방법을 사용했더라도 모든 하위 컴포넌트가 불필요하게 리-렌더링되고 있었다.

그래서 "어차피 모든 유효성검사가 완료됨을 알기 위해서는 각 데이터의 타이핑마다 리-렌더링이 필요하다."

라는 결론을 내렸었고, useForm 리팩토링을 끝내기로 했었다.

 

근데 위에서 말했듯 제어, 비제어 컴포넌트 패턴을 공부하면서 useForm 기능이 다시 생각났고,

"정말 하위 컴포넌트들의 불필요한 리-렌더링이 필요한걸까?" 라는 생각을 다시 하게 되었다.

 

그래서 해당 문제를 해결했고, 이를 정리해보려고 오늘 포스팅을 시작하게 된 것이다.

정리를 해보면서 또 다른 문제는 없는지 생각해보려고 한다.

 

 

 


 

🤔무엇이 리-렌더링을 일으키는가?

일단 왜 리-렌더링이 일어나는지를 생각해보았다.

내가 예상한 바로는

1. 객체로 관리했지만 하위 컴포넌트에 내려줄 때 데이터를 특정해서 내려주었다.

2. 하위 컴포넌트는 memo 기능을 통해 props가 변경될 때만 리-렌더링되게 해주었다.

 

이렇게 했다면 상위에서 객체안의 하나의 데이터가 변해 리-렌더링이 되었다고 하더라도

하위 컴포넌트들은 리-렌더링이 되지 않아야하는 것 아닌가? 라는 생각이 들었다.

 

이 생각을 멈추지 않고 계속 이어가면서 문제를 찾아갔다.

 

 


 

 

 

😣함수를 props로 내려줄 때는 항상 신경쓰자!

이것이 바로 첫 번째 문제였다.

이제와서 생각해보면 왜 생각하지 못했을까? 라는 생각이 든다.. 

함수를 props로 내려주고 있다.

지금 onChange 함수를 props로 전달하고 있다.

그러면 당연히 함수를 메모이제이션해줘야

상위에서 리-렌더링이 일어나도 하위 컴포넌트는 함수가 변하지 않았다는 걸 인지하고,

리-렌더링을 안할 것이다.

 


 

 

😣useCallback으로 감싸서 메모이제이션했는데도 해결이 되지 않는다?

해당 문제를 발견하고 간단하게 useCallback으로 함수를 감싸주었지만 

여전히 하위 컴포넌트들은 리-렌더링을 일으키고 있었다.

 

무엇이 문제일까? 계속 고민하던 와중에 아래와 같은 문제를 지나치고 있었다.

나는 되도록이면 의존성 배열에 나타나는 경고들을 해결하기 위해 의존성 데이터들을 일단 다 넣어주는 편이다.

이 부분을 보다가 의존성 배열안의 데이터가 계속 변하고 있는 것이 아닐까? 라는 당연히 해야하는 생각을 뒤늦게 해버렸다.

 

아까 말했듯이 dependenciesvalidate, validationStatus는 모두 객체다.

useForm을 사용할 때 사용자가 정의해주는 객체들

객체는 또 당연하게도 리-렌더링이 되면 참조되는 주소값이 바뀌니까 의존성 배열은 변경됐다 판단하고,

리-렌더링을 일으킬 것이다.

 

그러면 해당 부분을 어떻게 해결해야할까?

나는 useRef를 생각했다.

 

기존에는 받아오는 데이터를 useState로 관리했었다.

받아온 객체를 useState로 관리

그런데 지금에 와서 생각해보면 왜 굳이 useState로 관리했을까? 라는 생각이 들었다.

어차피 실제 form데이터가 타이핑 되어 변경됐을 때 리-렌더링이 필요한 것이니까

fields 데이터만 useState로 관리하면 됐었다.

 

그래서 아래와 같이 바꿔주었다.

렌더링과 상관없는 데이터들은 useRef로 변경

 

이렇게 정리하면서 문제점을 인지하고 깨달을 수 있었다.

내가 개발한 코드는 무조건 옳다 라는 생각을 가져선 안된다고 인지하고 있지만 

항상 까먹고 있는 것 같다..

 

 


 

 

😣뭔가 복잡한 useForm 사용방법...

아까 useForm 사용방법을 다시보자

사용할 field 데이터를 정의하고 초기값은 또 따로 정의해야한다.

field 데이터를 정의하고, 또 initialValidationStatus를 정의해야한다.

둘의 key값은 동일한데 굳이 이렇게 두개를 따로 보내줘야하는 것일까? 라는 생각이 들었다.

이 방식에서 뭔가 불편함을 느꼈고, 아래와 같이 변경하기로 결정했다.

useForm 내부에서는 이를 위해서 각 데이터에서 value와 isValid를 추출하는 과정이 필요했다.

왜냐하면 데이터를 사용할 때 이 { fileds.email.value } 와 같은 방식이면 뭔가 사용하기 불편하기 때문이다.

 

타입 정의나 기존에 사용하고 있던 Ref 데이터도 조금씩 바꿔주었다.

이후 전체코드에서 확인하면 된다!

 


😣이 코드는 문제가 있어!

아래 이미지에서 보이는 코드에는 두 가지 문제가 있다.

문제가 무엇일까요?

바로 set함수 내부에서 set함수를 호출하고 있는 문제였다.

해당 부분을 전혀 인지하지 못하고, 사용하고 있었고, 코드를 꼼꼼하게 살펴보던 도중에 발견하게 되었다.

이 부분이 원래는 ESLint에서 경고를 알리는 것으로 알고 있는데 나의 ESLint 설정을 다시한번 확인하게되는 계기가 되기도 했다.

 

그래서 아래와 같이 분리해주었다.

useEffect로 fields가 변하면 수행하도록 한다.

 

 

🤔+ 왜 set함수를 중첩해서 사용하면 안될까?

setState 함수를 중첩해서 사용하는 것은 React에서 권장되지 않는 패턴이다.

일반적으로 React의 상태 업데이트는 비동기적으로 처리되므로,

중첩된 상태 업데이트는 예상치 못한 결과를 초래할 수 있다고 한다.

 

쉽게 정리하면

1. React의 상태 업데이트는 비동기적으로 처리되는데, set함수를 호출하더라도 상태가 즉시 변경되지 않고, 다음 렌더링 사이클 까지 기다려야 된다.

이로 인해 set 함수가 중첩되면 이전 상태 값을 기반으로 한 업데이트가 예상치 못한 결과를 발생시킬수 있는 것이다.

 

2. 상태 업데이트가 중첩되면, React는 각 상태 업데이트에 대해 렌더링을 트리거할 수 있다.

이로 인해 불필요한 렌더링이 발생할 수 있으며, 성능 저하가 일어날 수 있다.


 

🤔명령적인 코드!?

 

위 코드 부분이 명령적으로 느껴졌다.

좀 더 선언적인 프로그래밍을 해야한다..!

 

 


 

 

😣추가적인 문제점 - 중복확인 기능이 있는 이메일 데이터

위와 같은 과정으로 리팩토링을 했지만 이상한 문제가 생겼다.

다른 데이터들은 렌더링 최적화가 되었는데 이메일 데이터만 계속 똑같이 리-렌더링이 발생하고 있었다.

 

왜 그런지 다시 데이터를 확인해봤다.

이메일은 특이하게 DuplicateButton 컴포넌트를 props로 받는다.

컴포넌트를 props로 받고있는데

여기서 또 당연하게 생각해야하는 부분에서 실수가 생긴 것이었다.

당연히 컴포넌트는 함수니까 메모이제이션을 해줘야 했다.

 

부끄럽지만 해당 부분 또한 아래와 같이 메모이제이션을 수행해주었다.

useMemo를 이용해 메모이제이션

이를 통해서 이메일 데이터도 렌더링 최적화가 가능해졌다.

 

 

 


 

✨실행결과를 비교해보자.

(좌) 렌더링 최적화가 전혀 되지 않는 상황, (우) 렌더링 최적화 결과

 

 


✍️전체 코드

useForm 커스텀 

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

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

interface FieldState {
  value: string;
  isValid: boolean;
}

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

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

interface UseFormParams {
  initialValues: FormFields;
  validation: FieldValidators;
  dependencies?: { [key: string]: string[] };
}

const useForm = ({
  initialValues,
  validation,
  dependencies = {},
}: UseFormParams) => {
  const [fields, setFields] = useState<FormFields>(initialValues);
  const validateFuncRef = useRef<FieldValidators>(validation);
  const dependenciesRef = useRef<{ [key: string]: string[] }>(dependencies);
  const [isFormComplete, setIsFormComplete] = useState(false);

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

        const fieldsToValidate = [
          fieldName,
          ...(dependenciesRef.current[fieldName] || []),
        ];

        fieldsToValidate.forEach(fieldToValidate => {
          const validationResult = validateFuncRef.current[fieldToValidate](
            updatedFields[fieldToValidate].value,
            updatedFields,
          );

          updatedFields[fieldToValidate].isValid = validationResult;
        });

        return updatedFields;
      });
    },
    [],
  );

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

  useEffect(() => {
    const validationStatus: FieldsValidationStatus = Object.entries(
      fields,
    ).reduce((acc: FieldsValidationStatus, [key, { isValid }]) => {
      acc[key] = isValid;
      return acc;
    }, {});

    const allFieldsValid = Object.values(validationStatus).every(
      value => value,
    );
    setIsFormComplete(allFieldsValid);
  }, [fields]);

  return {
    fields: Object.fromEntries(
      Object.entries(fields).map(([key, { value }]) => [key, value]),
    ),
    validationStatus: Object.fromEntries(
      Object.entries(fields).map(([key, { isValid }]) => [key, isValid]),
    ),
    isFormComplete,
    handleFieldChange,
  };
};

export default useForm;

FormField 컴포넌트

import { memo, ReactNode } from 'react';

import Group from '~/common/components/Group';
import Input from '~/common/components/Input';
import Text from '~/common/components/Text';

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 = memo(
  ({
    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;

✨회원가입 페이지 - useForm과 FormField컴포넌트를 사용

import { useEffect, useMemo } from 'react';

import Button from '~/common/components/Button';
import Group from '~/common/components/Group';
import Text from '~/common/components/Text';
import { useUserListQuery } from '~/common/hooks/queries/useUserList';
import useForm from '~/common/hooks/useForm';
import { ERROR } from '~/constants/message';
import {
  isValidEmail,
  isValidPassword,
  isValidUsername,
} from '~/utils/isValid';

import useEmailDuplicate from '../../hooks/useEmailDuplicate';
import FormField from '../FormField';
import DuplicateButton from './DuplicateButton';

interface RegisterFormProps {
  mutation: { isPending: boolean };
  onRegisterCompleted: (isValid: boolean) => void;
  handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}

const RegisterForm = ({
  mutation,
  onRegisterCompleted,
  handleSubmit,
}: RegisterFormProps) => {
  const { data: userList } = useUserListQuery();

  const {
    emailDuplicateCheckMessage,
    setIsEmailDuplicate,
    setEmailDuplicateCheckMessage,
    isEmailDuplicate,
    checkDuplicateEmail,
  } = useEmailDuplicate({ userList });

  const { fields, validationStatus, isFormComplete, handleFieldChange } =
    useForm({
      initialValues: {
        email: { value: '', isValid: false },
        password: { value: '', isValid: false },
        username: { value: '', isValid: false },
        confirmPassword: { value: '', isValid: false },
      },
      validation: {
        email: value => isValidEmail(value),
        password: value => isValidPassword(value),
        username: value => isValidUsername(value),
        confirmPassword: (value, values) => value === values.password.value,
      },
      dependencies: {
        password: ['confirmPassword'],
      },
    });

  const MemoizedDuplicateButton = useMemo(
    () => (
      <DuplicateButton
        checkDuplicateEmail={checkDuplicateEmail}
        email={fields.email}
        isEmailDuplicate={isEmailDuplicate}
        validationStatusEmail={validationStatus.email}
      />
    ),
    [
      checkDuplicateEmail,
      fields.email,
      isEmailDuplicate,
      validationStatus.email,
    ],
  );

  useEffect(() => {
    setIsEmailDuplicate(true);
    setEmailDuplicateCheckMessage('');
  }, [fields.email, setEmailDuplicateCheckMessage, setIsEmailDuplicate]);

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

  return (
    <form onSubmit={handleSubmit} className="pb-[100px]">
      <Group direction="columns" spacing="md" grow={true}>
        <div>
          <FormField
            type="email"
            name="email"
            label="이메일"
            placeholder="이메일을 입력해주세요."
            onChange={handleFieldChange}
            value={fields.email}
            isValid={validationStatus.email}
            right={MemoizedDuplicateButton}
          />
          {emailDuplicateCheckMessage && (
            <Text
              className={
                isEmailDuplicate ? 'text-sm text-error' : 'text-sm text-success'
              }
            >
              {emailDuplicateCheckMessage}
            </Text>
          )}
        </div>

        <FormField
          type="text"
          name="username"
          label="이름"
          placeholder="이름을 입력해주세요."
          onChange={handleFieldChange}
          value={fields.username}
          isValid={validationStatus.username}
          errorMessage={ERROR.NAME_INVALID}
        />
        <FormField
          type="password"
          name="password"
          label="비밀번호"
          placeholder="비밀번호를 입력해주세요."
          onChange={handleFieldChange}
          value={fields.password}
          isValid={validationStatus.password}
          errorMessage={ERROR.PASSWORD_INVAILD}
        />
        <FormField
          type="password"
          name="confirmPassword"
          label="비밀번호 확인"
          placeholder="비밀번호를 다시 한번 입력해주세요."
          onChange={handleFieldChange}
          value={fields.confirmPassword}
          isValid={validationStatus.confirmPassword}
          errorMessage={ERROR.PASSWORD_NOT_MATCH}
        />
        <div className="sticky w-full p">
          <Button
            loading={mutation.isPending}
            fullwidth={true}
            disabled={mutation.isPending || isEmailDuplicate || !isFormComplete}
          >
            회원가입
          </Button>
        </div>
      </Group>
    </form>
  );
};

export default RegisterForm;

 

 


📘마치며..

이렇게 useForm 리팩토링을 끝마쳤다.

사실 리팩토링이라기에는 기존에 있던 나의 실수가 가장 컸다...ㅋㅋ

그냥 리-렌더링은 어쩔 수 없이 필요한 거야 라고 결론을 내렸던 것이 가장 큰 문제였다.

이를 계기로 더 나은 판단을 하도록 깊이 생각하는 습관을 길러야겠다.

 

그리고 정리해보면서 느꼈던 문제점이라면 

"회원가입 기능을 사용하려면 useForm과 FormField를 모두 사용해주어야 하는 불편함" 이었다.

 

쉽게 말해서 useForm을 사용하면 그에 대한 View로 FormField 컴포넌트가 필요했다.

사실 데이터 자체가 그렇게 의존되는 것은 아니지만 

어차피 두 기능을 사용할 것이라면 뭔가 한번에 정의되고 사용되는 것이 편하지 않을까? 라는 생각이 들었다.

 

이 부분은 또 나중에 리팩토링을 시도할 때 다시 도전해보려고 한다.

이를 위해서 계속 리액트에서 자주 사용되는 패턴들을 공부하고,

다른 사람들은 이렇게 많이 사용되는 기능들을 어떻게 개발하는지 찾아보고 있다.

 

아무튼 이 과정을 통해서 항상 마음깊이 있었던 문제를 해소했다.

정말 기분이 좋았다.

사소하지만 이런 개선을 했다는 것이 뿌듯하다.

 

728x90
반응형
LIST