가끔 뜬금없이 생각하는거지만 복사&붙혀넣기를 도입한 사람은
노벨 프로그래밍 상 비슷한거라도 줘야 한다고 생각한다.
나는 당연히 이러한 기능이 컴퓨터가 처음 생겼을 때부터 존재했을거라고 생각했지만,
사실 그렇게 오래된 기능이 아니다.
테슬러 라는 사람이 처음 고안했다고 하는데
이 개발자분의 모토가 "사용자 친화적일 때 만인의 도구가된다" 라고 한다.
선한 영향력의 귀감이라고 할 수 있겠다.
실전 프로젝트에서 추가 구현사항이 생겼다.
지금은 선생님, 학부모의 가입만 받고있지만
원장선생님의 가입도 받을 수 있도록 로그인 페이지를 수정해달라는 것이다.
전에 사용하던 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 자체가 성능과 기능은 훌륭하지만
코드의 가독성이 그렇게 좋지 않은 라이브러리라서 크게 문제되지 않는다고 판단했다.
그러나 새로운 권한을 가진 유저를 가입시켜야 된다는 요구사항이 생겼고,
새로운 페이지마저 저런식으로 코드를 짤 수도 있었다.
그러나(이제는 그럴 일이 없겠지만) 또 추가적으로 새로운 권한을 가진 유저가 생겨서
복사 붙혀넣기와 함께 낑낑대면서 코드를 수정하고 있을 모습을 상상하니 정신이 아득해졌다.
그래서 전체적인 리펙토링과 함께 재사용이 가능한 컴포넌트로 만들어보았다.
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;
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 를 의미있는 이름으로 분리하여
코드의 가독성도 늘어났다고 볼 수 있다. 아주 성공적인 리팩토링이 아닐 수 없다.
이번 프로젝트에서 가장 많이 깨달은 부분이라고 생각한다.
애초에 처음부터 모든 것을 다 설계하고 작업을 하는건 불가능하다.
당장에 오늘 저녁메뉴도 알 수 없는데, 사용자가 어떤 부분에서 더 필요함을 느낄지
개발자는 알 수 없는것이다. 아마 기획자라고 해서 다르지 않을 것이다.
따라서 확장 가능성을 염두하고 코드를 짜야한다는 것.
"이런 저런 기능이 추가됐는데 어떻게 구현해야하지?" 를 고민할게 아니라
"이런 저런 기능이 추가될지도 모르니까 이런식으로 구현해야지" 를 먼저 생각해보는 것이 좋겠다.