원티드 프리온보딩 챌린지 1주차 과제 1/2

HR.lee·2022년 8월 10일
0

원티드

목록 보기
2/9

1주차 1번째 수업 과제

  • 모든 학습에는 Output이 남아야 한다
  • 작은 부분부터 차근차근
  • 개선 이유 (Before & After 적어보기)
  • 과제 진행 기간 : 2022 - 08 - 08 ~ 2022 - 08 - 12

사전과제 리팩토링과 Before & After 작성

1. 타입은 devDependencies에만 깔기

  • 타입스크립트는 자바스크립트를 위한 타입검사기에 가깝다.

dependencies와 devDependencies

  • 타입을 검사한다는 기능은 개발 시에 필요하며, 배포시에는 굳이 필요가 없다.

  • 따라서 개발환경에서만 실행되는 devDependencies에 깔면 성능최적화를 기대할 수 있다.

  • create-react-app의 타입스크립트 기본 템플릿을 사용할 경우 그냥 디펜던시에 타입스크립트 관련 모듈들이 깔아지는데 이것도 같이 옮겨놓으면 좋다.

  • 또한 @types가 붙은 모듈들은 라이브러리가 아닌, 타입스크립트용으로 해당 라이브러리의 타입지정파일만을 모듈화 한것으로 역시 devDependencies에 깔아서 개발환경에서만 쓰면 좋다.

    "@types/jest": "^27.0.1",
    "@types/node": "^16.7.13",
    "@types/react": "^18.0.0",
    "@types/react-dom": "^18.0.0",
    "types": "^0.1.1",
    "typescript": "^4.4.2"

2. React-query를 custom Hook으로 모듈화하기

before

  • api에 axios 요청 정도만 분리되어 있고 나머지는 form에서 정의하여 사용했다.
  • Mutation의 경우 return값이 없어야 실행되는데 api에서 이미 지정해서 데이터만 받아오고 있었기에 작동하지 않았다.
export const getToDos = () =>
  instance.get<ToDoList>(`/todos`).then((response) => response.data);

export const getToDoById = (id: String) =>
  instance.get<ToDoList>(`/todos/${id}`).then((response) => response.data);

export const updateToDo = async (data: NewToDo, id: string) => {
  const { data: response } = await instance.put(`/todos/${id}`, data);
  return response.data;
};

export const deleteToDo = async (id: string) => {
  const response = await instance.delete(`/todos/${id}`);
  return response.data;
};

export const loginTodo = async (data: UserProps) => {
  const { data: response } = await instance.post(`/users/login`, data);
  return response.data;
  • 모듈을 사용하지 못해서 코드가 지저분하고 여기저기 흩어져 있었다.
const deleteMutation = useMutation(
    (id: string) =>
      instance.delete(`/todos/${id}`, {
        headers: { Authorization: token },
      }),
    {
      onSuccess: () => {
        queryClient.invalidateQueries(["todos"]);
      },
    }
  );

after

  • httpRequest.ts에서 api들을 객체형으로 만들어 한데 묶고 response 받아오는 부분을 없애 순수한 함수로 만들어주었음.
export const ToDosAPI = {
  getToDos: () => instance.get<ToDoList>(`/todos`),
  getToDoById: (id: String) => instance.get<ToDoDetail>(`/todos/${id}`),
  createToDo: (create: NewToDo) => instance.post(`todos`, create),
  updateToDo: async (update: NewToDo, id: string) =>
    await instance.put(`/todos/${id}`, update),
  deleteToDo: async (id: string) => await instance.delete(`/todos/${id}`),
};
  • api 폴더에 query.ts를 추가하고 toDos 관련 훅들을 정리하여 집어넣음
export function getToDos() {
  return useQuery(["todos"], () =>
    ToDosAPI.getToDos().then((response) => response.data)
  );
}

export function getToDoById() {
  const queryClient = useQueryClient();
  return useMutation((id: string) => ToDosAPI.getToDoById(id), {
    onSuccess: () => queryClient.invalidateQueries(["todo"]),
  });
}

export function createTodo() {
  const queryClient = useQueryClient();
  return useMutation((create: NewToDo) => ToDosAPI.createToDo(create), {
    onSuccess: () => queryClient.invalidateQueries(["todos"]),
  });
}

export function updateToDo(id: string) {
  const queryClient = useQueryClient();
  return useMutation((update: NewToDo) => ToDosAPI.updateToDo(update, id), {
    onSuccess: () => queryClient.invalidateQueries(["todos"]),
  });
}

export function deleteToDo(id: string) {
  const queryClient = useQueryClient();
  return useMutation(() => ToDosAPI.deleteToDo(id), {
    onSuccess: () => queryClient.invalidateQueries(["todos"]),
  });
}
  • 이렇게 로직들을 하나로 맞추고 react-query 리턴방식에 맞추어 타입변경을 하니 일일이 타입을 지정해줘야 했던 부분들에서 타입추론이 가능해졌다.

3. default Header 대신 interceptor

before

instance.defaults.headers.common.Authorization = token || "";
  • 이렇게 디폴트로 값을 넣어주었는데 첫 로그인이나 첫 회원가입에서 종종 토큰이 제대로 세팅되지 않으며 홈페이지로 넘어가면 오류를 일으켰다.

  • 자바스크립트에서는 토큰이 없을 경우 null값으로 둘 수 있었는데 타입스크립트에서는 null을 허용하지 않아서 "" 빈값을 할당해 주어야 했고, 이 값이 갱신되지 않으면서 생기는 문제였다.

after

instance.interceptors.request.use(function (config) {
  const token = localStorage.getItem("token");
  if (token !== "")
    instance.defaults.headers.common.Authorization = token || "";

  return config;
});
  • axios의 인터셉터를 사용해서 토큰 가져오기를 인터셉터가 매번 요청하도록 했다.

4. styled import와 기능 분리

before

  • 커스텀 앨리먼트를 사용하고 있었다.
  • 상속기능을 구현하기 위해 init 옵션을 만들어 보았지만 근본적인 한계점이 있었는데, 테일윈드는 클래스 중복시 둘다 적용이 취소된다.
<button className="bg-red-300 bg-green-400"></button>
  • 이런게 안된다.

  • 이는 앨리먼트에 무슨 옵션이 적용되어 있는지 확인하고 작업하거나, 혹은 앨리먼트 자체를 확장가능하게 최소한의 요소만으로 제작해야 한다는 뜻이었다.

  • 편하려고 커스텀을 만들었는데 오히려 더 불편하고 button에 원래 있는 기능들 또한 props로 전달하기 위해 타입지정을 해주어야 했다.

import React from "react";

type ButtonType = {
  children: React.ReactNode | React.ReactNode[];
  className?: string;
  onClick?: () => void;
  disabled?: boolean;
};

const Button = (props: ButtonType) => {
  const init = `bg-blue-200 w-[13.75rem] h-[3.125rem] 
    rounded-md shadow-md font-[1.25rem] text-white font-karla font-semibold
    disabled:bg-gray-200`;

  const { children, className, onClick, disabled } = props;
  const [classNameList, setClassNameList] = React.useState(init);

  React.useEffect(() => {
    if (className) {
      setClassNameList(classNameList.concat(` ${className}`));
    }
  }, []);

  return (
    <button className={classNameList} onClick={onClick} disabled={disabled}>
      {children}
    </button>
  );
};

export default Button;

after

  • 그래서 이번에는 새로운 패키지를 도입해보았다.

  • 저번 프로젝트에서 PPT에 넣을 레퍼런스를 서치하다가 내가 고민하던 문제에 대한 팁이 올려진 기술블로그를 찾았었다.

블로그 : https://fe-developers.kakaoent.com/2022/220303-tailwind-tips/

  • twin.macro라는 라이브러리이다.

  • className 정렬 문제도 해결해주고, styled import 내에서 일반 css나 scss를 같이 사용할 수 있다는 것도 장점이다.

  • 최근 테일윈드 3.0이 업데이트 되어서 아직은 베타버전만 릴리즈되어 있지만 일단은 잘 돌아가고 있다.

해당 이슈 : https://github.com/ben-rogerson/twin.macro/issues/589

  • 기존 사용하던 tailwind-styled-component의 상위호환 라이브러리였기에 관련 디펜던시를 제거하고 twin.macro로 스펙을 통일시켰다.

  • 또한 앨리먼트 자체가 아니라 css만 분리하는 새로운 방식을 도입하였는데, 공식 홈페이지에 예시가 무척 잘 나와있었기에 가져왔다.

import React from "react";
/** @jsxImportSource @emotion/react */
import tw, { css, styled, theme } from "twin.macro";

interface ButtonProps {
  variant?: "primary" | "secondary";
  isSmall?: boolean;
}

export const Button = styled.button(({ variant, isSmall }: ButtonProps) => [
  tw`px-8 py-2 rounded focus:outline-none transform duration-75`,
  tw`hocus:(scale-105 text-yellow-400)`,

  variant === "primary" && tw`bg-black text-white border-black`,

  variant === "secondary" && [
    css`
      box-shadow: 0 0.1em 0 0 rgba(0, 0, 0, 0.25);
    `,
    tw`border-2 border-yellow-600`,
  ],

  isSmall ? tw`text-sm` : tw`text-lg`,

  // The theme import can supply values from your tailwind.config.js
  css`
    color: ${theme`colors.white`};
  `,
]);
  • 이제 이런식으로 편안하게 상속도 할 수 있다.
  • 마크로가 알아서 배열을 돌려서 제일 나중에 들어간 클래스가 적용되도록 해준다.
  const YellowButton = tw(Button)`border-yellow-500 border-4 text-amber-600`;
  • 컴포넌트에서 로직과 뷰를 분리하자 common element 만들기가 훨씬 쉬워졌다.

5. Custom Routes 설정

before

  • route설정은 기본을 쓰고 각 페이지에서 Navigate 메서드를 사용해 리다이렉트 시켜주고 있었다.
{token && <Navigate to="/todo" />}
  • 를 모든 페이지에 넣어두는 식이었다.
  • 문제점 : token과 Navigate를 매 페이지에서 소환해야 하고, 또 최초 로그인시 새로고침을 해야 페이지가 적용되는 문제가 있었다.

after

  • 해결 1 : customRoute를 만들어 Routes를 대체했다.

  • token 유무에 따라 리다이렉트 될 수 있게 useEffect를 걸어주었고, 매번 변화가 있을때 검사를 해야 했기 때문에 함수를 useCallback으로 만들어 거기에 디펜던시를 걸어주고, useEffect에서는 제거해주었다.

const CustomRoutes = () => {
  const token = !!localStorage.getItem("token")?.valueOf();
  const { pathname } = useLocation();
  const navigate = useNavigate();

  const auth = React.useCallback(
    (token: boolean) => {
      if (token === true && ["/", "/signin"].includes(pathname)) {
        return navigate("/todo");
      }
      if (token === false && ["/todo"].includes(pathname)) {
        return navigate("/");
      }
    },
    [navigate, pathname]
  );

  React.useEffect(() => {
    auth(token);
  });

  return (
    <Routes>
      {["/todo", "/todo/:id"].map((path) => {
        return <Route path={path} element={<Home />} key={path} />;
      })}
      {["/", "/signin"].map((path) => {
        return <Route path={path} element={<SignUp />} key={path} />;
      })}
    </Routes>
  );
};

export default CustomRoutes;
  • 해결 2 : react-query에 이니셜스테이트를 줘서 토큰이 없어도 괜찮게 하기
export const ToDoInit = {
  data: {
    data: [
      {
        title: "",
        content: "",
        id: "",
        createdAt: new Date(),
        updatedAt: new Date(),
      },
    ],
  },
};

const { data } = token ? getToDos() : ToDoInit;
  • 기존 쓰던 api 형태가 아니라 hook이었기 때문에 따로 useEffect를 지정해주지 않아 좋다고 생각했지만, 그것때문에 페이지 소환시 다른 어떤 것보다 먼저 실행 => 실패처리 되어 다른 컴포넌트의 로딩을 막고 있었다.

  • 그리고 같은 데이터라는것도 보장받지 못해서 any를 써야했었다.

  • 이렇게 타입을 한정해주자 깔-끔!

  • 그치만 리액트쿼리 이론 강의를 들으면 좀더 좋은 처리방법이 생각날 것도 같다.

6. LoginForm을 직관적으로

before

  • 이런 느낌으로 form.tsx 컴포넌트 안에 로직들이 이리저리 뒤섞여있었다.

  • 훅을 분리하긴 했지만 submit 함수는 inline으로 작성되었었다.

  • recoil로 토큰을 관리하려고 했던 흔적도 남아있었다. 로컬스토리지 자체가 이미 전역이므로 굳이 필요없는 로직이었다.

  const location = useLocation();
  const navigate = useNavigate();
  const loginQuery = useLogin();
  const [tokens, setTokens] = useRecoilState(tokenState);

  const { values, errors, handleChange, handleSubmit, isError } = useForm(
    location.pathname === "/sign"
      ? login
      : location.pathname === "/signin"
      ? SignIn
      : () => alert("알수 없는 오류가 발생했습니다. 다시 시도해주세요"),
    validate
  );

  const token: string = localStorage.getItem("token") || "";

  function login() {
    loginQuery
      .mutateAsync(values)
      .then((res) => {
        localStorage.setItem("token", res.data.token);
        setTokens(res.data.token);
        navigate("/");
      })
      .catch((error) => {
        console.log(error);
        alert("아이디와 비밀번호를 확인해주세요");
      });
    return <Navigate to="/" />;
  }

after

  • 이름을 LoginForm으로 바꾸고 관련 함수들을 떼어내 분리한 후, login시 띄워줄 로딩스피너의 공간도 확보해주었다.
  const navigate = useNavigate();
  const { pathname } = useLocation();
  const [loading, setLoading] = useRecoilState(loadingState);
  const isLoginPage = pathname === "/";
  const isSignInPage = pathname === "/signin";

  React.useEffect(() => {
    setTimeout(() => {
      setLoading(false);
    }, 1000);
  }, [loading]);

  const { values, errors, handleChange, handleSubmit, isError } = useForm(
    login,
    validate
  );

  function login() {
    if (isLoginPage) {
      UserAPI.loginTodo(values)
        .then((res) => {
          localStorage.setItem("token", res.data.token);
        })
        .catch((error) => {
          console.log(error);
          alert("아이디와 비밀번호를 확인해주세요");
        });
    }
    if (isSignInPage) {
      UserAPI.singUpTodo(values).then((res) => {
        localStorage.setItem("token", res.data.token);
        alert("계정 생성 완료, 자동 로그인 되었습니다!");
      });
    }
    setLoading(true);
    setTimeout(() => {
      navigate("/todo");
    }, 300);
  }
  • 이 페이지만 보아도 전체 내용이 한눈에 들어올 수 있게 정리하기!

  • token을 한번에 받아오지 못하고 로그인페이지로 다시갔다가 홈으로 가는 이슈가 있었는데 저렇게 setTimeout을 걸어 약간 뒤로 밀어주니 깔끔하게 작동했다.

7. UI와 반응형 웹

Cumulative Layout Shift

  • 누적 레이아웃 이동(CLS)은 사용자가 예상치 못한 레이아웃 이동을 경험하는 빈도를 수량화한 것이다. 시각적 안정성을 측정할 때 중요한 ux 요소 중 하나이다.

  • label element를 커스텀하면서 label 자체에 기본 높이를 주어 에러메세지가 사라지거나 나타날때에도 CLS가 발생하지 않도록 개선하였다.

  • 다른 페이지들을 모달로 띄우려고 했지만 과제 특성(각 상세페이지 뒤로가기 + 한 페이지 내에서 새로고침 없이 데이터 정합성 유지하기)을 고려했을 때 단일 페이지를 유지하는 것이 가장 깔끔해보였다.

  • 이에 크리에이트 폼과 디테일페이지에 sticky 포지션을 적용해서 유저가 스크롤을 내릴 경우 따라오도록 만들었다.

  • 스크롤을 하면

  • 이렇게 내려간다.

mobile 고려하기

  • 카드에 반응형 전용 버튼(모바일용)을 추가해서 디자인이 깨지지 않게 만들고 가능한 모든 경우에 엔터키로 submit이 가능하게 만들었다.

8. Input logic 개선

  • 물흐르듯 동작 가능한 투두리스트 사이트를 만들기 위해 인풋들의 로직을 개선했다.

1. 타이틀은 인풋으로, 상세설명은 텍스트에리어로

  • 인풋 앨리먼트에서 각각에 대한 css와 타입을 지정해준다음 불러와 사용했다. onChange에서 타입설정하는 부분이 어려웠지만 해냈다!

  • 이렇게 두개를 지정해두면 한개의 함수로 돌려쓸 수 있다.

  const handleChange = (
    event:
      | React.ChangeEvent<HTMLInputElement>
      | React.ChangeEvent<HTMLTextAreaElement>
  ) => {
    event.persist();
    setValues((values) => ({
      ...values,
      [event.target.name]: event.target.value,
    }));
  };
  • 사실 event.persist()는 리액트 17 이상에서는 아무일도 안한다고 하지만 있는게 보기 좋아서 그냥 넣어두었다.

1. Ref로 수정시 오토포커스

  • 버튼 클릭시 p태그를 input 태그로 변화시키는데 이때 타이틀에 자동으로 포커스가 갈 수 있도록 로직을 개선했다.
  const modifyRef = React.useRef<HTMLInputElement>(null);

  React.useEffect(() => {
    if (modify) {
      if (modifyRef.current && focus) {
        modifyRef.current.focus();
        setFocus(false);
      }
    }
  });
  • p태그 / input태그의 모양과 위치를 비슷하게 맞추어 CLS를 최소화했다.

  • 수정모드일 경우 글씨가 살짝 회색으로 변하고 상세설명 칸(텍스트에리어)에 최대한 많은 줄 수를 부여하기 위해 두 칸의 갭이 줄어든다.

  • 탭으로 버튼을 이동해서 선택할 수 있으며, 이를 잘 보여주기 위해 버튼이 포커스될 때 css로 스케일을 주었다.

과제 후기

  • 작업을 기록해서 한다는건 힘들지만 보람있는 일이라는 걸 알았다. 지금까지 쓴 어떤 글보다 길고 자세하다!
  • 모든 학습에는 아웃풋이 남아야 한다는 말에 무척 공감되었다.
  • 마감일(토요일)전에 잘 끝내서 다행이다.

과제 2/2에서 더 해야할 것

  • alert, confirm 창 모달로 빼기
  • 반응형 사이드바 만들기 등
profile
It's an adventure time!

0개의 댓글