최근 토스에서 작성한 Frontend Fundamentals 문서를 봤고, 아직 React로 실무 경험이 없기에 참 내 코드 보면 다들 기겁하며 뒤로가기 누르겠구나 싶어서 현재 진행중이던 프로젝트의 리팩토링을 결심하게 되었습니다.
전 주변에 리액트를 가르쳐 줄 사수도 없고 뭣도 없으니, AI의 힘도 좀 빌려보겠습니다. 제가 먼저 리팩토링을 거치고 생각을 정리한 후, cursor AI 에게 코드를 수정해 달라 하고, 이에 대해 전부 학습할 생각입니다. AI가 제 선배가 되는거에요. (제가 AI보다 똑똑할리가 없잖아요?)
리팩토링 한 것 중, 오늘은 로그인 모달을 보겠습니다.
"use client";
// Library
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import nookies, { setCookie } from "nookies";
import { useCallback, useState } from "react";
// Hook
import { useInput } from "@/hooks/useInput";
// Component
import CButton from "../common/CButton";
import { emailico, pwico } from "../common/CIcons";
import CInput from "../common/CInput";
import CSpinner from "../common/CSpinner";
// Interface
import { IError } from "@/interfaces/commonIFC";
// Util
import { cancelBgFixed } from "@/utils/utils";
// API
import { signinApi } from "@/apis/userApi";
interface ILoginModal {
setModalOpen: (flag: boolean) => void;
}
export default function LoginModal({ setModalOpen }: ILoginModal) {
const [emailErr, setEmailErr] = useState(false);
const [pwErr, setPwErr] = useState(false);
const [emailErrMsg, setEmailErrMsg] = useState("이메일을 입력해주세요.");
const [pwErrMsg, setPwErrMsg] = useState("비밀번호를 입력해주세요.");
const [err, setErr] = useState(false);
const [errMsg, setErrMsg] = useState("이메일 또는 비밀번호를 확인해주세요.");
const router = useRouter();
const queryClient = useQueryClient();
const email = useInput("");
const password = useInput("");
const signInMutation = useMutation({
mutationFn: signinApi,
onMutate: (variable) => {
console.log("onMutate", variable);
},
onError: (error: IError, variable, context) => {
console.error("signinErr:::", error);
let { msg } = error.response.data;
if (msg === "이메일 또는 비밀번호를 확인해주세요.") {
setEmailErr(false);
setPwErr(false);
setErr(true);
setErrMsg(msg);
} else {
setEmailErr(false);
setPwErr(false);
alert("알 수 없는 이유로 로그인에 실패했습니다.");
}
},
onSuccess: (data, variables, context) => {
console.log("signinSuccess", data, variables, context);
if (data.success) {
setModalOpen(false);
setCookie(null, "token", data.token, {
maxAge: 30 * 24 * 60 * 60,
path: "/",
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
});
console.log(nookies.get(), " : on Success Query Nookies");
queryClient.invalidateQueries({ queryKey: ["user"] });
router.push("/");
}
},
onSettled: () => {
cancelBgFixed();
console.log("signinEnd");
},
});
const validation = useCallback((email: string, pw: string) => {
let email_regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/i;
let errFlag = false;
if (email === "") {
setEmailErr(true);
setEmailErrMsg("이메일을 입력해주세요.");
errFlag = true;
} else if (!email_regex.test(email)) {
setEmailErr(true);
setEmailErrMsg("이메일 형식을 확인해주세요.");
errFlag = true;
}
if (pw === "") {
setPwErr(true);
setPwErrMsg("비밀번호를 입력해주세요.");
errFlag = true;
}
return errFlag;
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement> | React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setEmailErr(false);
setPwErr(false);
let emailVal = email.value;
let pwVal = password.value;
if (validation(emailVal, pwVal)) return;
let payload = {
email: emailVal,
password: pwVal,
};
signInMutation.mutate(payload);
},
[email, password, signInMutation, validation]
);
return (
<div className="w-screen h-screen fixed top-0 left-0 bg-gray-500 flex flex-col justify-center bg-opacity-40 overflow-hidden z-50">
{signInMutation.isPending && <CSpinner />}
<div className="relative w-[480px] h-fit py-20 bg-white shadow-xl items-center mx-auto my-0 rounded-xl flex">
<div className="w-full h-full px-12 flex justify-center flex-col">
<div className="mb-12 text-2xl font-bold">Log In To REVIEWERS</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<div className="mb-2 font-medium text-sm">E-Mail</div>
<CInput {...email} type="email" placeholder="이메일을 입력해주세요." isErr={emailErr} errMsg={emailErrMsg}>
{emailico}
</CInput>
</div>
<div>
<div className="mb-2 font-medium text-sm">Password</div>
<CInput {...password} type="password" placeholder="비밀번호를 입력해주세요." isErr={pwErr} errMsg={pwErrMsg}>
{pwico}
</CInput>
</div>
{err && <div className="text-[#ea002c] text-[0.625vw] pl-[0.4167vw] -mt-[0.8vh]">이메일 혹은 비밀번호를 확인해주세요.</div>}
<CButton title="SIGN IN" onClick={handleSubmit} type="submit" />
</form>
<div className="text-center mt-8 text-sm text-gray-400">
Not a Member?{" "}
<Link
href="/register"
onClick={() => {
setModalOpen(false);
cancelBgFixed();
}}
>
<span className="text-blue-500">Sign Up</span>
</Link>
</div>
<div className="text-center mt-2">
<Link
href="/findpw"
onClick={() => {
setModalOpen(false);
cancelBgFixed();
}}
>
<span className="text-sm text-gray-400 hover:underline cursor-pointer">Forgot your password?</span>
</Link>
</div>
</div>
<div
className={`absolute -right-12 -top-12 w-10 h-10 rounded-full bg-white shadow-xl flex justify-center items-center cursor-pointer hover:-top-[52px] transition-all`}
onClick={() => {
setModalOpen(false);
cancelBgFixed();
}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="3" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</div>
</div>
);
}
지금봐도 참 대단합니다. 심지어 이 프로젝트는 프론트엔드 3년차 두명이서 하는 프로젝트인데 둘다 SI 경력에 리액트도 안써봤다보니... 이런 코드가 나왔습니다.
"use client";
// Library
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import nookies, { setCookie } from "nookies";
import { useCallback, useState } from "react";
// Hook
import { useInput } from "@/hooks/useInput";
// Component
import CButton from "../common/CButton";
import { emailico, pwico } from "../common/CIcons";
import CInput from "../common/CInput";
import CSpinner from "../common/CSpinner";
// Interface
import { IError } from "@/interfaces/commonIFC";
// Util
import { cancelBgFixed } from "@/utils/utils";
// API
import { signinApi } from "@/apis/userApi";
interface ILoginModal {
setModalOpen: (flag: boolean) => void;
}
export default function LoginModal({ setModalOpen }: ILoginModal) {
const email = useInput("");
const password = useInput("");
const [err, setErr] = useState(false);
const [errMsg, setErrMsg] = useState("이메일 또는 비밀번호를 확인해주세요.");
const router = useRouter();
const queryClient = useQueryClient();
const signInMutation = useMutation({
mutationFn: signinApi,
onMutate: (variable) => {
console.log("onMutate", variable);
},
onError: (error: IError) => {
console.error("signinErr:::", error);
let { msg } = error.response.data;
if (msg === "이메일 또는 비밀번호를 확인해주세요.") {
setErr(true);
setErrMsg(msg);
} else {
setErr(false);
alert("알 수 없는 이유로 로그인에 실패했습니다.");
}
},
onSuccess: (data, variables, context) => {
console.log("signinSuccess", data, variables, context);
if (data.success) {
setModalOpen(false);
setCookie(null, "token", data.token, {
maxAge: 30 * 24 * 60 * 60,
path: "/",
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
});
console.log(nookies.get(), " : on Success Query Nookies");
queryClient.invalidateQueries({ queryKey: ["user"] });
router.push("/");
}
},
onSettled: () => {
cancelBgFixed();
console.log("signinEnd");
},
});
const validation = useCallback((email: string, pw: string) => {
let email_regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/i;
if (email === "") {
setErr(true);
setErrMsg("이메일을 입력해주세요.");
return true;
} else if (!email_regex.test(email)) {
setErr(true);
setErrMsg("이메일 형식을 확인해주세요.");
return true;
}
if (pw === "") {
setErr(true);
setErrMsg("비밀번호를 입력해주세요.");
return true;
}
return false;
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement> | React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setErr(false);
if (validation(email.value, password.value)) return;
let payload = {
email: email.value,
password: password.value,
};
signInMutation.mutate(payload);
},
[email, password, signInMutation, validation]
);
return (
<div className={styles.background}>
{signInMutation.isPending && <CSpinner />}
<div className={styles.container}>
<div className={styles.wrapper}>
<div className={styles.header}>Log In To REVIEWERS</div>
<form onSubmit={handleSubmit} className={styles.form}>
<Input valueAndOnChange={email} label="E-Mail" type="email" placeholder="이메일을 입력해주세요." />
<Input valueAndOnChange={password} label="Password" type="password" placeholder="비밀번호를 입력해주세요." />
{err && <div className={styles.errMsg}>{errMsg}</div>}
<CButton title="SIGN IN" type="submit" onClick={handleSubmit} />
</form>
<Footer setModalOpen={setModalOpen} />
</div>
<CloseBtn setModalOpen={setModalOpen} />
</div>
</div>
);
}
const Input = ({
label,
type,
placeholder,
valueAndOnChange,
}: {
label: string;
type: string;
placeholder: string;
valueAndOnChange: {
value: any;
onChange: (e: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLTextAreaElement>) => void;
};
}) => {
return (
<div>
<div className={styles.label}>{label}</div>
<CInput {...valueAndOnChange} type={type} placeholder={placeholder}>
{type === "email" ? emailico : pwico}
</CInput>
</div>
);
};
const Footer = ({ setModalOpen }: ILoginModal) => {
return (
<div>
<div className={styles.signupCon}>
Not a Member?
<Link
href="/register"
onClick={() => {
setModalOpen(false);
cancelBgFixed();
}}
>
<span className={styles.signupLink}>Sign Up</span>
</Link>
</div>
<div className={styles.findPwCon}>
<Link
href="/findpw"
onClick={() => {
setModalOpen(false);
cancelBgFixed();
}}
>
<span className={styles.findPwLink}>Forgot your password?</span>
</Link>
</div>
</div>
);
};
const CloseBtn = ({ setModalOpen }: ILoginModal) => {
return (
<div
className={styles.closeBtn}
onClick={() => {
setModalOpen(false);
cancelBgFixed();
}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="3" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
);
};
const styles = {
background: "w-screen h-screen fixed top-0 left-0 bg-gray-500 flex flex-col justify-center bg-opacity-40 overflow-hidden z-50",
container: "relative w-[480px] h-fit py-20 bg-white shadow-xl items-center mx-auto my-0 rounded-xl flex",
wrapper: "w-full h-full px-12 flex justify-center flex-col",
header: "mb-12 text-2xl font-bold",
form: "flex flex-col gap-4",
label: "mb-2 font-medium text-sm",
errMsg: "text-[#ea002c] text-[0.625vw] pl-[0.4167vw] -mt-[0.8vh]",
signupCon: "text-center mt-8 text-sm text-gray-400",
signupLink: "text-blue-500",
findPwCon: "text-center mt-2",
findPwLink: "text-sm text-gray-400 hover:underline cursor-pointer",
closeBtn: "absolute -right-12 -top-12 w-10 h-10 rounded-full bg-white shadow-xl flex justify-center items-center cursor-pointer hover:-top-[52px] transition-all",
};
우선 마크업만 봐도 구조를 알기 쉽게 바꼈습니다. 또한, styles 객체를 두어 더러운 tailwind 코드를 해당 마크업에 맞는 이름으로 바꿔줬습니다.
그리고 닫기 버튼, 모달 Footer, Input 을 따로 컴포넌트화 했습니다. 컴포넌트화도 조금 더 리팩토링이 가능해 보이지만 1차 작업에선 이정도 수준으로 작업하기로 합의하였습니다. (작업할 내용이 너무 많아요..)
저렇게 styles나 컴포넌트로 뺀 것들을 아래로 두면, 위쪽 마크업은 최대한 기획서에 가까워지고, 그렇다면 유지보수에 당연히 용이할 거라고 생각합니다.
"use client";
// Library
import { useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { setCookie } from "nookies";
import { useCallback, useState } from "react";
// Hook
import { useInput } from "@/hooks/useInput";
// Component
import CButton from "../common/CButton";
import { emailico, pwico } from "../common/CIcons";
import CInput from "../common/CInput";
import CSpinner from "../common/CSpinner";
// Interface
import { IError } from "@/interfaces/commonIFC";
// Util
import { cancelBgFixed } from "@/utils/utils";
// API
import { signinApi } from "@/apis/userApi";
// 공통 스타일 상수
const COMMON_STYLES = {
flexCenter: "flex justify-center items-center",
flexCol: "flex flex-col",
text: {
sm: "text-sm",
gray: "text-gray-400",
},
} as const;
interface ILoginModal {
setModalOpen: (flag: boolean) => void;
}
interface InputProps {
label: string;
type: "email" | "password";
placeholder: string;
valueAndOnChange: ReturnType<typeof useInput>;
}
export default function LoginModal({ setModalOpen }: ILoginModal) {
const email = useInput("");
const password = useInput("");
const [error, setError] = useState<{ show: boolean; message: string }>({ show: false, message: "" });
const router = useRouter();
const queryClient = useQueryClient();
const handleClose = useCallback(() => {
setModalOpen(false);
cancelBgFixed();
}, [setModalOpen]);
const signInMutation = useMutation({
mutationFn: signinApi,
onError: (error: IError) => {
const { msg } = error.response.data;
setError({
show: true,
message: msg === "이메일 또는 비밀번호를 확인해주세요." ? msg : "알 수 없는 이유로 로그인에 실패했습니다.",
});
},
onSuccess: (data) => {
if (data.success) {
handleClose();
setCookie(null, "token", data.token, {
maxAge: 30 * 24 * 60 * 60,
path: "/",
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
});
queryClient.invalidateQueries({ queryKey: ["user"] });
router.push("/");
}
},
onSettled: cancelBgFixed,
});
const validateForm = useCallback((email: string, password: string): boolean => {
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/i;
if (!email) {
setError({ show: true, message: "이메일을 입력해주세요." });
return false;
}
if (!emailRegex.test(email)) {
setError({ show: true, message: "이메일 형식을 확인해주세요." });
return false;
}
if (!password) {
setError({ show: true, message: "비밀번호를 입력해주세요." });
return false;
}
return true;
}, []);
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement> | React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setError({ show: false, message: "" });
if (!validateForm(email.value, password.value)) return;
signInMutation.mutate({ email: email.value, password: password.value });
},
[email.value, password.value, signInMutation, validateForm]
);
return (
<div className={styles.background}>
{signInMutation.isPending && <CSpinner />}
<div className={styles.container}>
<LoginForm email={email} password={password} error={error} handleSubmit={handleSubmit} />
<CloseButton onClick={handleClose} />
</div>
</div>
);
}
const LoginForm = ({
email,
password,
error,
handleSubmit,
}: {
email: ReturnType<typeof useInput>;
password: ReturnType<typeof useInput>;
error: { show: boolean; message: string };
handleSubmit: (e: React.FormEvent<HTMLFormElement> | React.MouseEvent<HTMLButtonElement>) => void;
}) => (
<div className={styles.wrapper}>
<div className={styles.header}>Log In To REVIEWERS</div>
<form onSubmit={handleSubmit} className={styles.form}>
<FormInput valueAndOnChange={email} label="E-Mail" type="email" placeholder="이메일을 입력해주세요." />
<FormInput valueAndOnChange={password} label="Password" type="password" placeholder="비밀번호를 입력해주세요." />
{error.show && <div className={styles.errMsg}>{error.message}</div>}
<CButton title="SIGN IN" type="submit" onClick={handleSubmit} />
</form>
<LoginFooter />
</div>
);
const FormInput = ({ label, type, placeholder, valueAndOnChange }: InputProps) => (
<div>
<div className={styles.label}>{label}</div>
<CInput {...valueAndOnChange} type={type} placeholder={placeholder}>
{type === "email" ? emailico : pwico}
</CInput>
</div>
);
const LoginFooter = () => (
<>
<div className={styles.signupCon}>
Not a Member?
<Link href="/register" className={styles.signupLink}>
Sign Up
</Link>
</div>
<div className={styles.findPwCon}>
<Link href="/findpw" className={styles.findPwLink}>
Forgot your password?
</Link>
</div>
</>
);
const CloseButton = ({ onClick }: { onClick: () => void }) => (
<button className={styles.closeBtn} onClick={onClick} type="button">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="3" stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
);
const styles = {
background: `${COMMON_STYLES.flexCenter} w-screen h-screen fixed top-0 left-0 bg-gray-500 bg-opacity-40 overflow-hidden z-50`,
container: "relative w-[480px] h-fit py-20 bg-white shadow-xl items-center mx-auto my-0 rounded-xl flex",
wrapper: `${COMMON_STYLES.flexCol} w-full h-full px-12 justify-center`,
header: "mb-12 text-2xl font-bold",
form: `${COMMON_STYLES.flexCol} gap-4`,
label: "mb-2 font-medium text-sm",
errMsg: "text-[#ea002c] text-[0.625vw] pl-[0.4167vw] -mt-[0.8vh]",
inputError: "border-red-500",
signupCon: `text-center mt-8 ${COMMON_STYLES.text.sm} ${COMMON_STYLES.text.gray}`,
signupLink: "text-blue-500 hover:underline",
findPwCon: "text-center mt-2",
findPwLink: `${COMMON_STYLES.text.sm} ${COMMON_STYLES.text.gray} hover:underline cursor-pointer`,
closeBtn: "absolute -right-12 -top-12 w-10 h-10 rounded-full bg-white shadow-xl flex justify-center items-center cursor-pointer hover:-top-[52px] transition-all",
} as const;
감탄을 금치 못했습니다.
우선 첫번째, tailwindcss를 더 아름답게 사용할 수 있게 되었습니다. 현재는 COMMON_STYLES로 해당 컴포넌트에 묶여있지만 flexCenter, flexCol, text 등 이런 것들은 사실 공통으로써 묶어놓는게 훨씬 편합니다. 코드 리팩토링 작업을 해가면서 공통 스타일 상수도 계속 업데이트 해줘야겠습니다.
두번째, TypeScript입니다. 사실 제가 현재 진행중인 SI 프로젝트는 TypeScript를 쓰지 않아 3년간 한번도 써본적이 없습니다. 혼자 공부도 해보고 사이드 플젝에서도 써보고 했는데 잘 늘지가 않더라고요. 사실 ReturnType도 처음 써봅니다. 문맥상 이해는 쉽게 되는데 이런 상황에서 쓸수있겠구나 란걸 AI에게 배웁니다.
세번째, Memoization 자체를 처음 이해해봅니다. 개념 자체는 알고 있었고, 공부도 했지만 제가 작업했던 프로젝트에서 제 코드가 단점이 보이고, 그걸 고치기 위해 Memoization이 쓰이는걸 보니 이해가 안될 수가 없네요. 만약 아래와 같이 handleClose 함수를 useCallback 없이 썼다면, 컴포넌트 리렌더링 마다 새로운 함수가 생성됐을거고, 불필요한 리렌더링이 발생했을겁니다.
const handleClose = () => {
setModalOpen(false);
cancelBgFixed();
}
네번째는 쓰지 않는 변수 및 함수에 대한 삭제입니다. 최근에도 여자친구와 프로젝트를 진행하며 앞단을 Vercel 에 배포 중 쓰지 않는 변수에 대한 에러로 배포가 되지 않는 문제를 봤고, 그 프로젝트는 수정을 했었습니다.
이 프로젝트가 꽤 오래된 프로젝트다 보니, 최근에 고친 습관이 반영이 안돼있었는데 심지어 제가 리팩토링 했을때도 알아채지 못했습니다. 습관을 계속 고쳐가야겠네요.
다섯번째는 함수명, 변수명 입니다. 물론 네이밍은 제 마음입니다만... 확실히 제가 썼던 네이밍들은 제가 보기에 편한 느낌이 강합니다. 이런 습관들도 고쳐야겠네요. 거기에 추가적으로 당연하게 써왔던 "컴포넌트"라는 개념도 다시 돌아보게 됩니다. 컴포넌트는 서로 독립적인 공간이라는걸 잊게돼서 자꾸 네이밍할때 좀 더 쓰게 된다던지 이런 습관이 생겨있네요.
오늘은 여기까지