리팩토링 기간 : 2022-08-13 ~ 2022-08-16
import { Input } from "../../common/Input";
import { Button } from "../../common/Button";
import Label from "../../common/Label";
import Spinner from "../../common/Spinner";
import { Box } from "../../common/Box";
import { Text } from "../../common/Text";
import { Box } from "./Box";
import { Button } from "./Button";
import { Input, TextArea } from "./Input";
import Label from "./Label";
import Spinner from "./Spinner";
import { Text } from "./Text";
export { Box, Button, Label, Spinner, Text, Input, TextArea };
import * as el from "./common"
이런식으로import * as el from "./common"
...
{loading && <el.Spinner />}
<el.Box>
<div className="">
{isSignInPage ? (
<el.Text variant="title">Sign up</el.Text>
) : (
<el.Text variant="title">Login</el.Text>
)}
useForm과 useFormValidation 두개의 훅은 로그인과 회원가입을 처리하기 위해 만들어진 함수였지만, 딱 그 2개만 처리할 수 있는 훅이었다.
필터와 모든 value값은 value가 email, password, passwordConfirm일때만 사용 가능했다.
typescript와 객체리터럴의 콜라보...
겨우 2가지 경우인데도 호환이 안되고 재사용이 1도 불가능한 코드였다.
그치만 수업에서 몇가지 중요한 개념들을 배워서 함수를 공통으로 쓸수 있게 바꾸어 보았다.
const { values, errors, handleChange, handleSubmit, isError } = useForm(
login,
validate,
init <--- 추가
);
export const UserLoginInit = { email: "", password: "" };
export const NewUserInit = { email: "", password: "", passwordConfirm: "" };
if ("phoneNumber" in friend)
이렇게 사용할 수 있다.
<form onSubmit={handleSubmit} noValidate>
{isLoginPage && <LoginForm {...{ handleChange, values, errors }} />}
{isSignInPage &&
"passwordConfirm" in values &&
"passwordConfirm" in errors && (
<SignInForm {...{ handleChange, values, errors }} />
)}
export default function validate(
values: UserProps | NewUser,
init: UserProps | NewUser
) {
const errors = Object.assign({}, init);
import React from "react";
type emailType = { email: string };
export const email = (value: emailType, error: emailType) => {
if (value.email === "") {
error.email = "이메일을 입력해주세요";
} else if (!/\S+@\S+\.\S+/.test(value.email)) {
error.email = "이메일 주소의 형태로 입력해주세요";
}
return error.email;
};
...
import React from "react";
type passwordType = { password: string };
export const password = (value: passwordType, error: passwordType) => {
if (!value.password) {
error.password = "비밀번호를 입력해주세요";
} else if (value.password.length < 8) {
error.password = "비밀번호는 최소 8자입니다";
}
return error.password;
};
얘를 useFormValidation에 import 해주면 이렇게 코드가 짧아지고 간결해진다. 그리고 확장성도 증가한다.
values <- 실제 값, init <- 메시지 전송을 위한 키만 들어있는 빈 객체
import React from "react";
import { UserProps, NewUser } from "../types/user";
import * as validate from "./validations";
export default function useFormValidations(
values: UserProps | NewUser,
init: UserProps | NewUser
) {
const errors = Object.assign({}, init);
errors.email = validate.email(values, errors);
errors.password = validate.password(values, errors);
if ("passwordConfirm" in values && "passwordConfirm" in errors) {
errors.passwordConfirm = validate.passwordConfirm(values, errors);
}
return errors;
useEffect(() => {
if (
pathname === "/" &&
Object.values(errors)[0] === "" &&
Object.values(errors)[1] === ""
) {
setIsError(false);
} else if (
Object.values(errors)[0] === "" &&
Object.values(errors)[1] === "" &&
Object.values(errors)[2] === ""
) {
setIsError(false);
} else {
setIsError(true);
}
}, [debouncedKeyword, errors]);
const errorList = Object.entries(errors).filter(
([key, value]) => value === ""
);
useEffect(() => {
if (errorList.length === Object.entries(values).length) {
setIsError(false);
} else {
setIsError(true);
}
}, [debouncedKeyword, errors]);
스토리지는 로컬스토리지일수도 있고 세션스토리지일수도 있는데 이걸 함수로 분리해서 사용하면 나중에 뭐가 바뀌더라도 api폴더에 있는 애만 교체해주면 편하게 사용할 수 있다고 한다.
멘토님이 강의자료에 남겨주신 코드를 토대로 빌드해보았다.
axios.interceptors.response.use(
(res) => {
if (res.data.token) {
Storage.set({ key: "authToken", persist: false })
}
return res;
},
(error) => {
return Promise.reject(error);
}
);
export type storageProps = {
key: string;
persist: boolean;
value?: string | "";
};
export type storageInnerProps = {
set: ({ key, persist, value }: storageProps) => void;
get: ({ key, persist }: storageProps) => string | undefined | null;
};
export const Storage: storageInnerProps = {
set: ({ key, persist, value }: storageProps) => {
if (persist === true) {
sessionStorage.setItem(key, value || "");
}
if (persist === false) {
localStorage.setItem(key, value || "");
}
},
get: ({ key, persist }: storageProps) => {
if (persist === true) {
return sessionStorage.getItem(key);
}
if (persist === false) {
return localStorage.getItem(key);
}
},
};
set을 구현하기 위해 value값도 넣어보았다.
set은 스토리지에 토큰을 집어넣는거니까 void가 되고 get은 토큰을 가져와야하니까 string type을 리턴한다.
persist 라는 boolean값을 true로 하면 세션 스토리지, false로 하면 로컬 스토리지에 들어가게 하고 기존엔 리퀘스트에만 인터셉터를 적용했었는데 리스폰스도 if문으로 해서 토큰을 들어가게 하고 바깥 로직에서는 깔끔하게 지워주었다.
실제 사용
instance.interceptors.request.use(
function (config) {
const token = Storage.get({ key: "token", persist: false });
if (token !== "")
instance.defaults.headers.common.Authorization = token || "";
return config;
},
function (error) {
return Promise.reject(error);
}
);
instance.interceptors.response.use(
(response) => {
if (response.data.token) {
Storage.set({ key: "token", persist: false, value: response.data.token });
}
return response;
},
(error) => {
return Promise.reject(error);
}
);
export type storageInnerProps = {
set: ({ key, persist, value }: storageProps) => void;
get: ({ key, persist }: storageProps) => string | undefined | null;
has: ({ key, persist }: storageProps) => boolean | undefined;
};
export const Storage: storageInnerProps = {
.....
has: ({ key, persist }: storageProps) => {
if (persist === true) {
return !!sessionStorage.getItem(key)?.valueOf();
}
if (persist === false) {
return !!localStorage.getItem(key)?.valueOf();
}
},
};
const token = Storage.has({ key: "token", persist: false });
상단에 계속 사이드바를 띄워서 한번에 볼수 있게 한건 좋았지만 문제는 모바일이었다. 모바일창에 가면 사이드바가 화면의 절반 이상을 차지해서 다른 컨텐츠를 보기가 힘들었다.
게다가 오버플로우가 적용되어 있지 않아 컨텐츠 내용이 길 경우 what to do가 계속 늘어났다.
유니온타입을 사용하자. 타입 한정은 타입 자동완성에 매우 좋다.
제네럴 타입을 이용하면 인자타입과 리턴타입이 일치하지 않아도 된다! 인자가 달라질 수 있는 경우에도 유용하다. 제네릭은 최대한 단순하게 설명하면 “인자로 타입을 받는 함수”
api 받아올때 error의 타입을 error(axios라면 axios error)로 한정해주기 위해 if문을 사용해야 한다. 이런걸 타입가드라고 한다.
타입스크립트의 흐름에 몸을 맡기고 타입스크립트가 타입추론을 할 수 있게끔 잘 이끌어주면서 코딩하면 머리가 덜 아프다.
에러는 숨기는것보다 노출하는 것이 디버깅을 위해 바람직하다.
같은 기능을 하더라도 사람에게 읽히기 쉬운 코드를 짜보자.
같은 기능을 하더라도 디버깅에 공수가 덜 드는 코드를 짜보자.
이건 약간 6번과 상충하는 듯 느껴질 수 있는데, 코드 자체는 여러 곳을 봐야되어서 눈이 조금 복잡하지만, 얘를 고치려고 할때는 로직이 한군데에 집중되어 있어서 결합부만 뜯으면 되는 코드를 얘기한다.
만화에서 종종 실로 뭘 조종하는 빌런이 나오는데 본체나 본체에 연결된 메인 실 하나만 끊으면 공략할 수 있게 만드는 것과 비슷한 이치다.
뷰단에서 이러기는 어렵고 주로 custom hook이나 api 관련 컴포넌트에서 의존성을 제거하고 인위적으로 교체가능한 옵션을 만들어서 <- 이 옵션 컴포넌트만 수정하면 되도록 하는 작업을 말한다.
function login() {
if (isLoginPage && "email" in values) {
UserAPI.loginTodo(values)
.then((res) => {
alert("로그인 성공!");
})
.catch((error) => {
console.log(error);
alert("아이디와 비밀번호를 확인해주세요");
});
}
if (isSignInPage && "passwordConfirm" in values) {
UserAPI.singUpTodo(values).then((res) => {
alert("계정 생성 완료, 자동 로그인 되었습니다!");
});
}
setLoading(true);
setTimeout(() => {
navigate("/todo");
}, 300);
}
const { mutateAsync, isLoading } = useMutation(
(values: UserProps | NewUser) =>
isLoginPage ? UserAPI.loginTodo(values) : UserAPI.singUpTodo(values),
{
onSuccess: () => navigate("/todo"),
onError: (error: AxiosError) => {
if (error !== undefined && error instanceof AxiosError) {
console.log(Object.values(error?.response?.data)[0]);
}
},
}
);
function login() {
mutateAsync(values);
isLoginPage
? alert("로그인 성공!")
: alert("계정이 생성되었습니다, 자동으로 로그인합니다!");
}
뮤테이션을 구조분해할당해서 submit함수랑 isLoading state를 간소화하고 함수 자체도 간결해졌다.
또한 onError를 써서 오류시 백엔드 서버에서 보내주는 메시지를 송출하게 했다.
코드는 같지만 전역상태관리 없이 리액트 쿼리에서 받아오고 있어 훨씬 간단하다.
{isLoading && <el.Spinner />}
const detailToDo = getToDoById();
const { data, isLoading, isError, error } = getToDos();
const { mutateAsync, isLoading, isError, error } = createTodo();
import { QueryErrorResetBoundary } from "@tanstack/react-query";
const List = React.lazy(() => import("../components/toDo/List"));
<QueryErrorResetBoundary>
<React.Suspense fallback={<el.Spinner />}>
<div className="flex p-10">{data?.data && <List {...data} />}</div>
</React.Suspense>
</QueryErrorResetBoundary>
리액트는 가상돔을 이용하는 SPA다. 데이터 변화에 따른 UI의 변경을 위해서는 데이터변경이 아닌 리액트가 지정한 state가 변경되어야 한다.
그런데 리액트의 기본 state는 성능최적화를 위해 Batching을 해버리기 때문에 요청을 여러가지를 한번에 많이 할 경우 여러개 보내지거나 빼먹고 보내질 가능성이 있다는 점이다...!
원래 해야되는거 + 서버데이터랑 동기화하기 위해 해야하는거 하면 state가 너무 많다...
게다가 client State(내가 받아와서 굴리는 데이터)는 로컬이라서 터지지 않지만, Server State(서버에 요청해야만 하는 데이터)는 서버의 상태 = 외부의 상태에 따라서 클라이언트단에 아무 문제가 없더라도 터질 수가 있다.
그래서 두개가 긴밀하게 결합되어 있으면 여러가지 분기처리를 해주어야 해서 코드가 복잡해지고 사이트에 화이트스크린이 자주 뜨는걸 볼수 있다.
하지만 잘 분리 해놓으면 적어도 받아와서 쓰고 있는 데이터는 그대로 쓸수가 있어서 유저가 10000글자 정도 작성하던 블로그 글을 날리는 일이 줄어든다. 클라이언트단에서 로컬/세션스토리지에다가 임시저장 기능을 만들면 더 줄어든다.
그리고 ui = view 컴포넌트가 잘 분리되어 있으면 새 프로젝트를 시작할때 고민거리가 줄어든다. 내 경우에는 404페이지, 헤더, 레이아웃 등 기본적인 애들은 거의 뜯어와서 시작하는 편이다.
Cache : 캐시 = 리액트쿼리가 가지고 있는 자료구조이다. 여기에 서버 데이터를 담아둔다.
Caching : 캐싱, React-Query의 캐싱은 stale과 cachetime의 조합으로 이루어진다.
데이터의 신선도. 전달받은 데이터는 리엑트 쿼리의 자료구조 내용 중 캐시에 저장이 되는데, 이때 이 캐시데이터의 "신선한 상태" 가 언제까지 될지를 말해주는 옵션이다. default는 0으로, 받아오는 즉시 stale하다고 판단하며 캐싱 데이터와 무관하게 계속해서 feching을 진행한다.
클라이언트 데이터는 서버데이터의 어느 한 시간을 스냅샷 찍어서 오는 데이터인데 신선함의 기준을 잡아서 오래되었다고 판단하는게 스테일이다.
언제까지 신선할거라고 보장해주는 유통기간 같은 것, useQuery를 호출할 당시에 옵션으로 staletime을 따로 지정해주지 않으면 캐싱되어 있는 데이터가 계속 신선하지 않다고 여기기 때문에 서버에 계속적인 refetching 요청을 하게된다.
결론 : 캐싱을 잘하려면 옵션을 잘 지정하고 쿼리키를 분리하기!
swr은 캐싱된 낡은 컨텐츠에 대한 확장된 지시를 표현한다.
- 요청을 짧은시간 안에 반복적으로 여러번 보내면 캐싱된 애를 돌려주고
- 정해진 시간 내에 보내면 우선은 캐싱된 애를 돌려준 후 서버에 검토요청을 보내 새로운 데이터를 받아와야할지 평가하고
- 그리고 정해진 시간은 넘겨서 보내면 평범하게 서버에 요청을 해준다.
react-query, swr은 낡은 캐시로부터 빠르게 컨텐츠를 반환하고, 백그라운드에서 요청을 통해 캐싱된 컨텐츠의 재검증을 진행하여 캐싱 레이어에서 최신화된 데이터를 보장할 수 있도록 swr 캐싱 전략을 취하고 있다.
프론트엔드 개발자가 중복요청이나 잘못된 요청, 빈번한 요청 등으로 발생하는 오류와 씨름하는 것을 줄여주는 좋은 애들이다.