레고처럼 재사용 가능한 컴포넌트 추구하기

Universe·2023년 4월 18일
0

서론

가끔 뜬금없이 생각하는거지만 복사&붙혀넣기를 도입한 사람은
노벨 프로그래밍 상 비슷한거라도 줘야 한다고 생각한다.
나는 당연히 이러한 기능이 컴퓨터가 처음 생겼을 때부터 존재했을거라고 생각했지만,
사실 그렇게 오래된 기능이 아니다.
테슬러 라는 사람이 처음 고안했다고 하는데
이 개발자분의 모토가 "사용자 친화적일 때 만인의 도구가된다" 라고 한다.
선한 영향력의 귀감이라고 할 수 있겠다.

계기

실전 프로젝트에서 추가 구현사항이 생겼다.
지금은 선생님, 학부모의 가입만 받고있지만
원장선생님의 가입도 받을 수 있도록 로그인 페이지를 수정해달라는 것이다.
전에 사용하던 Teacher.jsx 컴포넌트는 아래와 같다.

import { useForm } from "react-hook-form";
import { useMutation } from "@tanstack/react-query";
import { useLocation, useNavigate } from "react-router-dom";
import StyledInfo from "./styled";
import StyledLogin from "../../styled";
import { SignAPI } from "../../../../api/SignAPI";
import Buttons from "../../../../components/Buttons";
import ProfileImageUploader from "../../../../components/ProfileImageUploader";
import { useProfileImageUploader } from "../../../../hooks/useProfileImageUploader";
import { REGEXP } from "../../../../helpers/regexp";
import session from "../../../../utils/session";
import AlertModal from "../../../../components/Modals/AlertModal";
import useModal from "../../../../hooks/useModal";
import AbsenceDatePicker from "../../../ChildManage/AbsenceDatePicker";
import formatPhoneNumber from "../../../../utils/formatPhoneNumber";

const Teacher = () => {
  const location = useLocation();
  const navigate = useNavigate();
  const { openModal } = useModal();
  const { name, profileImageUrl } = session.get("user");

  const { selectedFile, isCancelled } =
    useProfileImageUploader(profileImageUrl);

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitSuccessful },
  } = useForm();

  const { mutate } = useMutation(SignAPI.signup, {
    onSuccess: (res) => {
      if (res.data.StatusCode === 200) {
        session.set("user", res.data.data);
        navigate("/signup/success");
      }
    },
    onError: (error) => {
      openModal({ contents: <AlertModal /> });
    },
  });

  const onSubmit = (data) => {
    const formData = new FormData();
    formData.append("name", data.name);
    formData.append("phoneNumber", data.phoneNumber);
    formData.append("birthday", data.birthday);
    formData.append("isCancelled", isCancelled);
    selectedFile && formData.append("profileImage", selectedFile);
    formData.append("email", data.email ?? null);
    formData.append("resolution", data.resolution ?? null);

    const role = location.pathname.split("/")[2];
    mutate({ role: role, info: formData });
  };

  return (
    <StyledInfo.Container>
      <StyledLogin.Title>선생님! 정보를 입력해주세요</StyledLogin.Title>
      <StyledInfo.Form onSubmit={handleSubmit(onSubmit)}>
        <StyledInfo.Wrapper>
          <ProfileImageUploader id="Teacher" prev={profileImageUrl} />
          <StyledInfo.Box>
            <StyledInfo.ContentsWrapper>
              <StyledLogin.Label htmlFor="name" isEssential>
                이름
              </StyledLogin.Label>
              <StyledLogin.Input
                placeholder="홍길동"
                type="text"
                {...register("name", {
                  required: "이름을 입력해주세요.",
                  pattern: {
                    value: REGEXP.name,
                    message:
                      "이름을 정확하게 입력해주세요. 한글 또는 영문 2~15자 이내만 가능합니다.",
                  },
                })}
                id="name"
                defaultValue={name ?? ""}
                valid={errors.name}
                size={4}
              />
              {!isSubmitSuccessful && errors.name && (
                <StyledInfo.ErrorMessage>
                  {errors.name.message}
                </StyledInfo.ErrorMessage>
              )}
            </StyledInfo.ContentsWrapper>
            <StyledInfo.ContentsWrapper>
              <StyledLogin.Label htmlFor="phoneNumber" isEssential>
                연락처
              </StyledLogin.Label>
              <StyledLogin.Input
                placeholder="010-0000-0000"
                type="text"
                {...register("phoneNumber", {
                  required: "연락처를 입력해주세요",
                  pattern: {
                    value: REGEXP.phone,
                    message:
                      "전화번호를 정확하게 입력해 주세요. (ex: 010-000-0000 or 02-000-0000)",
                  },
                })}
                id="phoneNumber"
                onInput={(e) => formatPhoneNumber(e)}
                valid={errors.phoneNumber}
                size={12}
              />
              {!isSubmitSuccessful && errors.phoneNumber && (
                <StyledInfo.ErrorMessage>
                  {errors.phoneNumber.message}
                </StyledInfo.ErrorMessage>
              )}
            </StyledInfo.ContentsWrapper>
            <StyledInfo.ContentsWrapper>
              <StyledLogin.Label htmlFor="birthday" isEssential>
                생년월일
              </StyledLogin.Label>
              <StyledLogin.Input
                type="date"
                {...register("birthday", {
                  required: "생년월일을 입력해주세요",
                })}
                id="birthday"
                valid={errors.birthday}
                size={12}
              />
              {!isSubmitSuccessful && errors.birthday && (
                <StyledInfo.ErrorMessage>
                  {errors.birthday.message}
                </StyledInfo.ErrorMessage>
              )}
            </StyledInfo.ContentsWrapper>
            <StyledInfo.ContentsWrapper>
              <StyledLogin.Label htmlFor="email">메일주소</StyledLogin.Label>
              <StyledLogin.Input
                placeholder="kindergrew@gmail.com"
                type="text"
                {...register("email", {
                  pattern: {
                    value: REGEXP.email,
                    message: "유효한 이메일 주소를 입력해 주세요",
                  },
                })}
                id="email"
                valid={errors.email}
                size={20}
              />
              {!isSubmitSuccessful && errors.email && (
                <StyledInfo.ErrorMessage>
                  {errors.email.message}
                </StyledInfo.ErrorMessage>
              )}
            </StyledInfo.ContentsWrapper>
            <StyledInfo.ContentsWrapper>
              <StyledLogin.Label htmlFor="resolution">한마디</StyledLogin.Label>
              <StyledLogin.Input
                type="text"
                maxLength="28"
                {...register("resolution", {
                  maxLength: {
                    value: 28,
                    message: "28자 이내로 작성해주세요",
                  },
                })}
                id="resolution"
                valid={errors.resolution}
                size={35}
              />
              {!isSubmitSuccessful && errors.resolution && (
                <StyledInfo.ErrorMessage>
                  {errors.resolution.message}
                </StyledInfo.ErrorMessage>
              )}
            </StyledInfo.ContentsWrapper>
          </StyledInfo.Box>
        </StyledInfo.Wrapper>
        <StyledInfo.SubmitBtnWrapper>
          <Buttons.Filter colorTypes="primary" type="submit">
            작성완료
          </Buttons.Filter>
        </StyledInfo.SubmitBtnWrapper>
      </StyledInfo.Form>
    </StyledInfo.Container>
  );
};

export default Teacher;

React-hook-form과 React-Query 를 사용한 단순한 Validation Form 이다.
전체적인 리팩토링 이전의 코드라서 감안하고 본다고 해도
재사용성, 그러니까 확장 가능한 코드라고는 전혀 생각할 수 없다.
가입 권한이 선생님, 학부모 단 두개였으므로 확장 가능성을 고려하지 않았고
React-hook-form 자체가 성능과 기능은 훌륭하지만
코드의 가독성이 그렇게 좋지 않은 라이브러리라서 크게 문제되지 않는다고 판단했다.

그러나 새로운 권한을 가진 유저를 가입시켜야 된다는 요구사항이 생겼고,
새로운 페이지마저 저런식으로 코드를 짤 수도 있었다.
그러나(이제는 그럴 일이 없겠지만) 또 추가적으로 새로운 권한을 가진 유저가 생겨서
복사 붙혀넣기와 함께 낑낑대면서 코드를 수정하고 있을 모습을 상상하니 정신이 아득해졌다.
그래서 전체적인 리펙토링과 함께 재사용이 가능한 컴포넌트로 만들어보았다.

방향성

  1. 반복되는 코드가 많으니 하나의 InputField 컴포넌트를 만들어서 재사용하자.
import React from "react";
import StyledLogin from "../../styled";
import StyledUser from "../styled";

const InputField = ({
  label,
  id,
  isEssential,
  placeholder,
  type,
  registerOptions,
  defaultValue,
  valid,
  size,
  onInput,
  errors,
  isSubmitSuccessful,
}) => {
  return (
    <StyledUser.ContentsWrapper>
      <StyledLogin.Label htmlFor={id} isEssential={isEssential}>
        {label}
      </StyledLogin.Label>
      <StyledLogin.Input
        placeholder={placeholder}
        type={type}
        {...registerOptions}
        id={id}
        defaultValue={defaultValue}
        valid={valid}
        size={size}
        onInput={onInput}
      />
      {!isSubmitSuccessful && errors && (
        <StyledUser.ErrorMessage>{errors.message}</StyledUser.ErrorMessage>
      )}
    </StyledUser.ContentsWrapper>
  );
};

export default InputField;

...

      <InputField
                label="이름"
                id="name"
                isEssential
                placeholder="홍길동"
                type="text"
                registerOptions={{
                  ...register("name", {
                    required: "이름을 입력해주세요.",
                    pattern: {
                      value: REGEXP.name,
                      message:
                        "이름을 정확하게 입력해주세요. 한글 또는 영문 2~15자 이내만 가능합니다.",
                    },
                  }),
                }}
                defaultValue={name ?? ""}
                valid={errors.name}
                size={4}
                errors={errors.name}
                isSubmitSuccessful={isSubmitSuccessful}
              />

썩 좋은모습은 아니지만, 필요한 모든 정보를 props 로 받아서 렌더링.
중복되는 옵션이 있지만 React-hook-form 을 사용하기 위해서는 반드시 필요하다.




2. 유저의 가입을 받을 때 어떤 권한이어도 반드시 필요한 Field가 있으니 아예 Fields 폴더로 관리하자.

import { REGEXP } from "../../../../helpers/regexp";
import InputField from "./InputField";

const NameInputField = ({
  register,
  defaultValue,
  errors,
  isSubmitSuccessful,
}) => {
  return (
    <InputField
      label="이름"
      id="name"
      isEssential
      placeholder="홍길동"
      type="text"
      registerOptions={{
        ...register("name", {
          required: "이름을 입력해주세요.",
          pattern: {
            value: REGEXP.name,
            message:
              "이름을 정확하게 입력해주세요. 한글 또는 영문 2~15자 이내만 가능합니다.",
          },
        }),
      }}
      defaultValue={defaultValue ?? ""}
      valid={errors.name}
      size={4}
      errors={errors.name}
      isSubmitSuccessful={isSubmitSuccessful}
    />
  );
};

export default NameInputField;
  1. 수정된 전체 코드
import { useForm } from "react-hook-form";
import { useMutation } from "@tanstack/react-query";
import { useLocation, useNavigate } from "react-router-dom";
import StyledUser from "./styled";
import StyledLogin from "../styled";
import SignAPI from "../../../api/SignAPI";
import session from "../../../utils/session";
import formatPhoneNumber from "../../../utils/formatPhoneNumber";
import Buttons from "../../../components/Buttons";
import ProfileImageUploader from "../../../components/ProfileImageUploader";
import AlertModal from "../../../components/Modals/AlertModal";
import useModal from "../../../hooks/useModal";
import { useProfileImageUploader } from "../../../hooks/useProfileImageUploader";
import {
  NameInputField,
  PhoneNumberInputField,
  BirthInputField,
  EmailInputField,
  ResolutionsInputField,
} from "./InputFields";

const Teacher = () => {
  const location = useLocation();
  const navigate = useNavigate();
  const { name, profileImageUrl } = session.get("user");
  const { openModal } = useModal();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitSuccessful },
  } = useForm();

  const { selectedFile, isCancelled } = useProfileImageUploader(
    "Teacher",
    profileImageUrl
  );

  const { mutate } = useMutation(SignAPI.signup, {
    onSuccess: (res) => {
      if (res.data.StatusCode === 200) {
        session.set("user", res.data.data);
        navigate("/signup/success");
      }
    },
    onError: (error) => {
      openModal({
        contents: (
          <AlertModal
            title="회원가입에 실패하였습니다"
            message="연락처가 중복이거나 잘못된 생년월일일 수 있습니다. 확인하고 다시 요청해주세요."
          />
        ),
      });
    },
  });

  const onSubmit = (data) => {
    const formData = new FormData();
    formData.append("name", data.name);
    formData.append("phoneNumber", data.phoneNumber);
    formData.append("birthday", data.birthday);
    formData.append("isCancelled", isCancelled);
    selectedFile && formData.append("profileImage", selectedFile);
    formData.append("email", data.email ?? null);
    formData.append("resolution", data.resolution ?? null);

    const role = location.pathname.split("/")[2];

    mutate({ role: role, info: formData });
  };

  return (
    <StyledUser.Container>
      <StyledLogin.Title>선생님! 정보를 입력해주세요</StyledLogin.Title>
      <StyledUser.Form onSubmit={handleSubmit(onSubmit)}>
        <StyledUser.Wrapper>
          <ProfileImageUploader id="Teacher" prev={profileImageUrl} />
          <StyledUser.Box>
            <NameInputField
              register={register}
              errors={errors}
              defaultValue={name}
              isSubmitSuccessful={isSubmitSuccessful}
            />

            <PhoneNumberInputField
              register={register}
              errors={errors}
              onInput={(e) => formatPhoneNumber(e)}
              isSubmitSuccessful={isSubmitSuccessful}
            />

            <BirthInputField
              register={register}
              errors={errors}
              isSubmitSuccessful={isSubmitSuccessful}
            />

            <EmailInputField
              register={register}
              errors={errors}
              isSubmitSuccessful={isSubmitSuccessful}
            />

            <ResolutionsInputField
              register={register}
              errors={errors}
              isSubmitSuccessful={isSubmitSuccessful}
            />
          </StyledUser.Box>
        </StyledUser.Wrapper>
        <StyledUser.SubmitBtnWrapper>
          <Buttons.Filter colorTypes="primary" type="submit">
            작성완료
          </Buttons.Filter>
        </StyledUser.SubmitBtnWrapper>
      </StyledUser.Form>
    </StyledUser.Container>
  );
};

export default Teacher;

추가적으로는 Submit 로직을 제외하고 useQuery 의 쓰임새가 비슷하므로
이를 커스텀 훅으로 만들어 관리하면 좋을 것 같다.




결론

리팩토링을 통해서 InputFiled 들을 하나의 디렉토리로 관리하게 되면서
전체적인 디렉토리 구조도 리펙토링 했다.

이전

이후

각 InputField를 기능단위로 관리하면서 코드의 중복을 줄일 수 있었고,
가입 권한이 추가되더라도 마치 레고를 조립하듯 쉽게 하나의 페이지를 만들 수 있으므로
유지 보수 측면에서도 효과적이고, InputField 를 의미있는 이름으로 분리하여
코드의 가독성도 늘어났다고 볼 수 있다. 아주 성공적인 리팩토링이 아닐 수 없다.

추가적으로

이번 프로젝트에서 가장 많이 깨달은 부분이라고 생각한다.
애초에 처음부터 모든 것을 다 설계하고 작업을 하는건 불가능하다.
당장에 오늘 저녁메뉴도 알 수 없는데, 사용자가 어떤 부분에서 더 필요함을 느낄지
개발자는 알 수 없는것이다. 아마 기획자라고 해서 다르지 않을 것이다.
따라서 확장 가능성을 염두하고 코드를 짜야한다는 것.
"이런 저런 기능이 추가됐는데 어떻게 구현해야하지?" 를 고민할게 아니라
"이런 저런 기능이 추가될지도 모르니까 이런식으로 구현해야지" 를 먼저 생각해보는 것이 좋겠다.

profile
Always, we are friend 🧡

0개의 댓글