원티드 프리온보딩 프론트엔드 인턴십 회고

프리온보딩 과정의 회고를 다룹니다.

2023-04-12

개요

원티드 프리온보딩 프론트엔드 과정 그리고 저의 회고를 다룹니다.

과정 소개 및 사전 과제

과정 소개

제가 참여한 과정은 2월 20일부터 3월 17일까지 총 4주간 진행된 2월 프론트엔드 과정입니다. 과정은 주마다 2회의 세션 강의와 1개의 기업 과제를 팀 단위로 수행하는 방식으로 진행되었습니다. 팀의 경우 총 8명으로 구성되었는데, 저는 팀의 팀장을 담당하였습니다.

기업 과제는 팀이 구성되고 1/N로 팀원별 역할을 분담하여 해결하는 것이 아닌, 팀원 한 명이 100%를 담당하여 그중 Best Practice를 도출하는 방식이었습니다. 이 방식에 대해 들었을 때, 처음 접해보는 동료 학습 방식이라 어떻게 진행해야 할지 갈피를 못 잡았습니다. 또 기능을 구현하고 과제를 해결하기에도 시간이 촉박한데 거기서 최고의 결과물을 뽑아야 된다는 것에 압박을 느꼈습니다. 시간이라는 것이 절대적인 것이 아니며, 설상가상으로 병행하던 부트캠프에서도 프로젝트가 진행되는 시점과 맞물려 시간에 대한 압박을 더 심하게 받았습니다.

그래도 긍정적인 마음을 갖고 이왕 하는 거 둘 다 제대로 해보자는 각오를 다졌습니다.

뿐만 아니라 프리온보딩 프론트엔드 챌린지 3월까지 같이 듣느라 제법 바빴던 기억이 있습니다. 😱

사전 과제

사전 과제의 경우 인증 기능이 있는 투두 앱을 구현하는 것으로 총 10개의 요구 사항을 해결하는 과제였습니다.

그중 기억에 남는 요구 사항들은 다음과 같습니다.

1. 회원가입, 로그인 페이지의 이메일과 비밀번호의 유효성 검사

유효성 검사의 조건은 이메일의 경우 @포함, 비밀번호의 경우 8자 이상인 아주 심플한 조건이었습니다. 하지만, 막상 유효성 검증을 하려고 하니 제법 막막했던 기억이 있습니다. 우선 정규식을 사용하는 것을 고민하였습니다. 평소 정규식과 매우 소원한 사이여서 이 기회에 좀 친해질까 하는 마음을 갖긴 했지만, 심플한 만큼 정규식은 피하기로 하였습니다.

제가 생각한 검사 방법은 우선 이메일의 경우 String 객체의 프로토타입인 includes() 메서드를 사용하는 것과 비밀번호의 경우 역시 String 객체의 속성인 length를 통한 유효성 검사였습니다. 또 포커스가 되었을 때 에러 메시지가 렌더링 될 수 있도록 focus 상태를 사용하였습니다.

const [user, setUser] = useState({
  email: "",
  password: "",
});
 
// ...
 
{
  !user.email.includes("@") && focus.email ? (
    <ErrorMessage>@를 포함한 이메일을 입력하세요.</ErrorMessage>
  ) : null;
}
 
// ...
 
{
  user.password.length < 8 && focus.password ? (
    <ErrorMessage>8자 이상의 비밀번호를 입력하세요.</ErrorMessage>
  ) : null;
}

이렇게 유효성 검사를 하게 되면 정규식을 사용하지 않고도 user 정보에 따라 유효성 검증을 진행할 수 있었습니다.

2. JWT 여부에 따른 유저 처리

JWT, 친해졌다 싶으면 저 멀리 가버리는 그런 녀석입니다. 과제를 진행하며 토큰에 한 발짝 가까워진 느낌을 받았습니다. 토큰 관련하여 처리해야 할 로직은 두 가지였습니다.

  • 로그인 성공 시 로컬 스토리지에 토큰 저장

    로그인 성공 시 로컬 스토리지에 어떻게 토큰을 저장하는 게 좋을까를 생각하다, 토큰의 유무에 따른 리다이렉트 처리도 필요하므로 Context APIuseReducer 활용하는 게 좋겠다는 생각이 들었습니다.

    AuthProvider.jsx
    import { createContext, useMemo, useReducer } from "react";
     
    export const authContext = createContext();
     
    const reducer = (state, action) => {
      switch (action.type) {
        case AUTH_ACTION.SET_TOKEN:
          localStorage.setItem("accessToken", action.token);
          return { ...state, token: action.token };
        case AUTH_ACTION.RESET_TOKEN:
          localStorage.removeItem("accessToken");
          return { ...state, token: "" };
        default:
          return state;
      }
    };
     
    const AuthProvider = ({ children }) => {
      const [state, dispatch] = useReducer(reducer, initialState);
     
      const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
      return (
        <authContext.Provider value={value}>{children}</authContext.Provider>
      );
    };
    export default AuthProvider;

    로그인 · 로그아웃 시 토큰을 로컬에 저장하도록 reducer를 생성하였고, context를 통해 useReducer로 생성된 statedispatch를 넘겨줍니다. 위 방식을 통해 Props Drilling을 피할 수 있게 되었습니다.

  • 토큰 유무에 따른 리다이렉트 처리

    토큰이 필요한 페이지의 경우 useContext훅으로 토큰을 가져옵니다. 리다이렉트는 useEffect훅을 사용하여 다음과 같이 처리를 하였습니다.

    Todo/index.jsx
    const {
      state: { token },
      dispatch: authDispatch,
    } = useContext(authContext);
     
    // ...
     
    useEffect(() => {
      if (!token) {
        navigate("/signin");
      }
    }, [token]);
  • axios interceptors를 활용한 유저 인증 대부분의 api 엔드 포인트에서 토큰을 통한 유저 인증이 필요하였습니다. 매번 api 호출을 할 때 토큰을 넣어주는 중복을 피하기 위해 axiosinterceptors를 활용하여 토큰이 있다면, 토큰을 Authorization에 넣어주는 처리를 하였습니다.

    client.interceptors.request.use((config) => {
      const accessToken = localStorage.getItem("accessToken");
     
      if (accessToken !== null) {
        config.headers.Authorization = `Bearer ${accessToken}`;
      }
      return config;
    });

그 외의 요구사항들은 CRUD 기능을 구현하는 것이었습니다. 과제를 제출하고 2월 17일 인턴십 합격 문자를 받게 되었습니다!

레포지토리에서 사전 과제 코드를 확인하실 수 있습니다! 😄

1주 차

1주 차의 과제는 사전 과제였던 투두 앱의 Best Practice를 선정하는 것이었습니다. 8명의 결과물에서 최고의 결과물을 도출하기 위해 저희 팀이 선택한 방식은 이미 존재하는 8명의 결과물 중 최고를 뽑아 그 결과물을 개선하는 방식이었습니다.

이때 제 결과물이 우선 선정되어 저의 투두 앱을 개선하기로 하였습니다. 팀원들과 회의를 통해 개선점을 찾게 되었고, 개선점은 다음과 같습니다.

1. 기존 useEffect로 처리하던 리다이렉트를 Router로 처리

사실 사전 과제를 수행할 때 PrivateRouterPublicRouter를 만들어 인증 여부에 따른 Router 처리를 할까 고민을 하였는데, 프로젝트 규모가 작아 이펙트로 처리를 하였습니다. 하지만, 이펙트로 처리할 경우 인증이 필요한 페이지에 로직을 추가해야 하여 컴포넌트의 가독성을 해칠 수 있으며, 컴포넌트가 마운트 된 후 이펙트를 처리하므로 사용자가 화면 깜빡임(flicker) 현상을 경험할 수 있으니, UX 적으로 친화적이지 않습니다. 이와 같은 이유로 Router를 활용해 리다이렉트를 처리하기로 하였습니다.

Router.jsx
// ...
 
<Route
  path="/"
  exact
  element={token ? <Navigate to="/todo" replace /> : <SignIn />}
/>

useContext로 가져온 토큰을 여부에 따라 Navigate 컴포넌트로 리다이렉트 처리해 줍니다. 위와 같이 처리하면 페이지가 로드 되기 전에 리다이렉트 처리를 합니다.

2. interceptors를 활용한 에러 처리

응답에 관한 interceptors를 추가하여, 만약 에러가 발생하면 전역적으로 alert를 표시하도록 하는 로직을 추가하였습니다. interceptors를 사전 과제를 진행하며 처음 접하였는데, 요청과 응답을 가로채 필요한 로직을 편리하게 추가할 수 있다는 것을 알게 되었습니다.

아래의 코드와 같이 응답이 실패하여 에러가 발생했을 시, 전역으로 alert를 띄워 에러 메세지를 보여줄 수 있게 구현하였습니다. 개별적으로 에러를 처리하지 않아도 되는 이점이 있습니다.

instance.js
axiosInstance.interceptors.response.use(
  (response) => response,
  (error) => {
    const { response } = error;
    if (response.status === 401) return Promise.reject(error);
    if (response) alert(response.data.message);
    return Promise.reject(error);
  }
);

항상 성공할 경우만 생각하며 개발을 진행했었는데, 다른 팀원들의 코드를 읽으며 제 스스로 예외 처리하는 것에 많이 부족하다는 것을 느꼈습니다.

팀원들과 논의를 하며 몇 가지 의문 사항이 생겼었는데, 다른 과정의 강사님께 조언을 얻어 의문을 해결하였습니다. 그 의문점은 다음과 같았습니다.

  1. 투두 앱에서 useReducer를 사용하는 것이 오버 스펙인가
  • 대다수의 팀원들이 간단한 앱인데 useReducer를 사용하는 것은 오히려 복잡성을 높인다고 하였는데, useReducer를 활용해 구현한 저로서는 오히려 로직을 분리 시킬 수 있어서 가독성이 높아졌다고 생각하였습니다.
    → 강사님의 역시 useReducer잘 활용했다는 답변을 주셨습니다.
  1. Context API 내부에서 서버 상태와 클라이언트 상태 동시에 관리 vs 분리
  • 저와 마찬가지로 Context API를 활용하여 투두 앱을 구현한 팀원이 있었는데, 서버 상태와 클라이언트 상태를 모두 처리했다는 차이가 있었습니다. 저는 한 곳에서 모든 것을 관리하는 것이 너무 많은 로직이 존재하고, 가독성이 떨어진다고 생각하여 클라이언트 상태만 관리하자는 의견을 표출하였는데, 두 경우의 명확한 차이를 알지 못하는 상황이었습니다.
    → 서버의 로직을 처리한다고 어떤 성능에는 차이가 없지만, 로직이 너무 많아지면 가독성을 해칠 수 있으므로 이 부분을 해결하면 둘 중 어느 것을 사용해도 된다는 답변을 받았습니다.
  1. 비동기 요청이 성공하였을 시 상태 변경
  • 위의 질문의 예시는 다음과 같습니다.

    // 기존 상태를 복사하여 변경 하는 방식
    const res = await createTodo(todoTitle);
    if (res.status === 201) {
      dispatch({ type: TODO_ACTION_TYPE.POST, todo: res.data });
      setTodoTitle("");
    }
    case TODO_ACTION_TYPE.POST:
      return [...state, action.todo];
     
     
    // GET 요청후 응답 데이터로 상태 변경 하는 방식
    const handleUpdate = (id, todo, isCompleted) => {
    updateTodo(id, todo, isCompleted).then(() => {
     getTodos().then((response) => setTodos(response.data))
     });
    }

    즉 기존의 상태를 전개 연산자를 활용해 복사 후 변경하느냐, GET 요청을 통해 받은 새로운 데이터로 변경하느냐의 차이입니다.
    → 서버에서 받아온 데이터를 활용해 상태를 변경하는 것이 훨씬 안전하다는 답변을 받았는데, 그 이유는 서버에 요청이 들어가지 않았을 경우도 생각을 해야 하기 때문이라고 답변을 주셨습니다.

정말 눈 깜짝할 새에 1주 차가 지나가게 되었습니다.

1주 차의 결과물은 이곳에서 확인하실 수 있습니다!

2주 차

2주 차는 첫 기업 과제가 진행되는 주였습니다. 기업의 과제는 주어진 데이터를 활용해 여행 상품 리스트를 조회하고 장바구니에 저장할 수 있는 사이트를 구현하는 것이었습니다. 첫 기업 과제인 만큼 과제가 발표되기 전까지 긴장의 연속이었습니다. 처음 과제를 접했을 때, 가장 걱정스러웠던 것은 바로 Chakra-UI였습니다. 기능 구현도 해야 하는데, 한 번도 사용해 보지 않은 라이브러리로 스타일링을 진행해야 한다는 것에 지레 겁을 먹었습니다.

지난 과제를 진행하며, 수행 방식에 문제점이 있음을 저뿐 아니라 다른 팀원들도 알고 있었기에 새로운 방식으로 동료 학습을 진행하기로 하였습니다. 저희 조가 도입한 방식은 깃허브의 issue였습니다. 각 요구 사항을 issue로 작성한 뒤 커밋과 연동하여, PR을 생성했을 때 추적을 쉽게 할 수 있도록 하였습니다. 또 슬랙과 깃허브 레포지토리를 연동하여, PR이 생성될 시 바로 확인할 수 있게 설정을 해두었습니다.

이 방식은 4주 차까지 지속되었습니다! ✨

과제의 경우 React, TypeScript, React-Query, Redux-Toolkit 등을 활용해 진행하였습니다.

과제의 기능 구현을 하면서 가장 어려웠던 부분은 바로 필터링이었습니다. 필터링의 요구 사항은 여행 상품 리스트의 가격과 공간의 필터링 기능을 구현하는 것으로 개별 · 다중 필터링 모두 가능하도록 구현하는 것이었습니다. 개별과 다중의 개념이 명확하지 않아 팀원들과 논의를 했던 것이 생각납니다. 논의 결과 가격과 공간을 동시에 필터링하는 것으로 결론을 내렸습니다.

제가 필터링을 구현한 방식은 다음과 같습니다.

Main.tsx
// ...
const [[minPrice, maxPrice], setPrice] = useState<number[]>([
  MIN_VALUE,
  MAX_VALUE,
]);
const [spaceCategory, setSpaceCategory] = useState<string[]>([]);
 
const filteredProducts = products?.filter(
  (product) =>
    [...spaceCategory].includes(product.spaceCategory) &&
    product.price >= minPrice &&
    product.price <= maxPrice
);

React-Query를 통해 전체 데이터를 가져온 후, 데이터를 Array의 메서드인 filter를 활용해 필터링합니다. 이때 다중 필터링을 구현해야 하므로 공간인 spaceCategoryArray의 메서드인 includes를 사용해 현재 배열에 공간이 있는지 확인합니다. 가격 값인 minPrice, maxPrice는 비교 연산자를 사용해 가격을 비교합니다. 이렇게 필터링 된 데이터를 map 메서드를 활용해 화면에 렌더링 해줍니다.

위와 같은 방식으로 구현한 필터링은 몇 가지 문제가 있었습니다.

  1. 새로고침 시 필터링이 유지되지 않는 점
  • state를 활용해 필터링을 하고 있기에 새로고침 시 필터링이 풀려 버리는 상황이 발생하였습니다. 이렇게 필터링이 유지되지 않는 것은 사용자 관점에서 친화적이지 않습니다. 이와 같은 문제를 해결하기 위해선 Query string을 활용한 필터링을 구현해야 합니다. React-router-domuseSearchParams를 활용하면 쉽게 구현할 수 있습니다. 사실 React-router-dom을 많이 사용하면서도 전혀 몰랐던 훅이기에 새로운 지식을 얻을 수 있어서 과정을 통해 알 수 있어 좋았습니다.

    import { useSearchParams } from "react-router-dom";
     
    const [searchParams, setSearchParams] = useSearchParams();
    const spaceCategory = searchParams.get("spaceCategory");
    const price = searchParams.get("price");
     
    // ex
    const handleSpace = (e: React.ChangeEvent<HTMLInputElement>) =>
      setSearchParams({
        spaceCategory: e.target.value,
      });

    다음과 같이 setSearchParams를 사용하면 URL에 자동으로 ?spaceCategory와 같이 Query가 추가됩니다. 즉, 새로고침 시에도 URL은 고정이므로 필터링이 풀리지 않게 됩니다.

  1. 선언형 코드가 아닌 명령형 코드가 존재한다는 점
  • 선택된 장소를 배열에 넣기 위해 다음과 같은 로직을 사용했습니다.

    useEffect(() => {
      const idArr: string[] = [];
      SPACE_VALUE.map((el) => idArr.push(el));
      setSpaces(idArr);
    }, []);
     
    const handleAllCheck = (checked: boolean) => {
      if (checked) {
        const idArr: string[] = [];
        SPACE_VALUE.map((el) => idArr.push(el));
        setSpaces(idArr);
      } else setSpaces([]);
    };

    위의 로직들은 명령형 코드입니다. 명령형 코드는 어떤 일을 어떤 식으로 할 것인가에 대한 코드입니다. 위의 코드를 다음과 같이 코드를 작성하면 React의 철학과 부합한 선언형 코드로 바꿀 수 있습니다.

    useEffect(() => {
      setSpaces([...SPACE_VALUE]);
    }, []);
     
    const handleAllCheck = (checked: boolean) => {
      if (checked) {
        setSpaces([...SPACE_VALUE]);
      } else setSpaces([]);
    };

    명령형과 선언형 코드라는 것을 사실 접해 본 적이 없기에, 이 부분에 있어서 공부를 해야겠다는 생각이 들었습니다.

    1. 삼항연산자와 if 문 구분하기

    또 다른 문제점은 바로 삼항연산자와 if 문을 구분하지 못한 코드들이었습니다.

    const handleAllCheck = (checked: boolean) => {
      if (checked) {
        setSpaces([...SPACE_VALUE]);
      } else setSpaces([]);
    };

    위의 코드를 다음과 같이 리팩토링할 수 있습니다.

    const handleAllCheck = (checked: boolean) => {
      setSpaces(checked ? [...SPACE_VALUE] : []);
    };

    내가 지금 필요한 것이 조건에 따른 코드들인지 아니면 자체인지를 생각할 수 있는 계기가 되었습니다.

막상 처음에 걱정했던 Chakra-UI는 사용하면서 정말 쉽게 UI를 만들 수 있어서 즐거운 경험으로 개발을 하였습니다.

2주 차의 결과물은 이곳에서 확인하실 수 있습니다!

3주 차

3주 차의 과제는 주어진 데이터로 시계열 차트를 만드는 과제였습니다. 시계열 차트 📊 정말 저에게 생소한 용어였습니다. 사실 차트라는 것 자체가 제게 생소하였습니다. 용어 자체가 생소하니 과제가 너무나 어렵게만 느껴졌습니다. 낙담할 시간은 없으니 시계열 차트가 무엇인지부터 찾아보았습니다. 시계열 차트란 시간 경과에 따른 활동 그래프입니다. 용어 정리를 하고 기능 구현을 들어가게 되었습니다. 즉 시간 순으로 그래프를 만드는 것이 3주 차 과제의 주제였습니다.

이번 과제는 지난 과제들과 다르게, 기술 스택이 정해진 것이 아닌 세 가지의(recharts, chart.js, apexCharts) 차트 라이브러리 중 팀원들이 선택하여 개발하는 방식을 채택하였습니다. 저는 가장 React스러운 라이브러리인 recharts를 사용하여 기능 개발을 하였습니다. 차트라는 것을 구현하는 것 자체도 처음이니 라이브러리 역시 처음 사용해 보는 것이었습니다. 처음이라는 것에 2주 차와 마찬가지로 지레 겁을 먹었던 것 같습니다. 하지만 이런 걱정들과 달리 recharts를 사용하며 느낀 것은 바로 사용법이 정말 간편하다는 것과 공식 문서가 매우 친절하다는 것이었습니다.

기능을 구현하며 까다로웠던 것들이 있었는데 객체인 데이터를 배열 데이터로 정제하는 것과 특정 데이터 구역 클릭 시 하이라이트를 처리하는 것이었습니다.

  1. 객체 데이터를 배열로 정제하기

    recharts를 활용해 차트를 만들기 위해선 배열 데이터가 요구됩니다. 하지만, 제공받은 데이터의 타입이 객체였습니다. 객체인 데이터를 보고 당황했던 기억이 있습니다. 배열로 만들기 위해 우선 시도한 방법은 다음과 같습니다.

    useChartData.ts
    // ...
     
    const chartArray: IChart[] = [];
     
    for (const key in data) {
      chartArray.push({
        time: new Date(key).toLocaleTimeString().replace("오후 ", ""),
        value_time: key,
        ...data[key],
      });
    }

    위와 같이 코드를 작성하니 역시나 명령형으로 코드를 작성했다는 사실을 알게 되었습니다.

    위의 코드를 선언형 코드로 수정하기 위해 다음과 같이 리팩토링하였습니다.

    useChartData.ts
    // ...
     
    const arrData: IChart[] = Object.entries(data).map(([time, item]) => ({
      time: new Date(time).toLocaleTimeString(),
      ...item,
    }));

    우선 Object.entries 메서드를 사용해 객체를 [key, value] 형태의 배열로 만들어 줍니다. 이렇게 만들어진 배열을 Array 객체의 map()메서드를 사용해 새로운 배열로 만들어 줍니다. 이때 시계열 차트를 만들기 위해서 시간이 필요하니 새로운 key를 추가하여 반환하도록 하였습니다. 선언형 코드를 통해 기존의 방식보다 훨씬 간결하고 더욱 가독성이 좋은 코드를 작성할 수 있게 되었습니다.

  2. 특정 데이터 구역 클릭 시 데이터 구역 하이라이트 처리

    바 차트의 경우 <Cell /> 컴포넌트를 활용하여 어렵지 않게 하이라이트 처리를 할 수 있었는데, 문제는 에어리어 차트의 하이라이트 처리였습니다. 하이라이트 기능을 아무리 구현하려고 하여도 방법이 없는 것 같았습니다. 이때 저는, 한 가지 번뜩이는 생각이 들었는데 바로 에어리어 차트를 hover시 생기는 들을 활용해 구역 하이라이트를 처리하는 것이었습니다.

    CustomTooltip.tsx
    const CustomToolTip = ({ active, payload, setDot, category }: Props) => {
      useEffect(() => {
        if (payload && payload.length) setDot(payload[0].payload.id);
      }, [payload]);
      //...
    };

    우선 점들의 상태를 저장하기 위해 툴팁 컴포넌트에 setDot을 prop으로 내려줍니다. payload 값이 바뀔 때마다, payloadid(지역) 값을 상태로 저장합니다. 이렇게 되면, 마우스가 hover 되었을 시 id 값을 저장할 수 있게 됩니다. 즉 클릭 시에도 마우스 hover에 따른 값을 상태로 가질 수 있게 됩니다.

    Chart.tsx
    // ...
    const district = searchParams.get("district") as string;
    const handleClickDistrict = (district: string) => {
      setSearchParams({ category, district });
    };
     
    <Area
      type="monotone"
      dataKey="value_area"
      fill="#82ca9d"
      stroke="#82ca9d"
      yAxisId="left"
      onClick={() => {
        handleClickDistrict(dot);
      }}
      dot={
        <CustomizedDot
          cx={0}
          cy={0}
          stroke="#86d3a4"
          district={district}
          payload={{ id: "", time: "", value_area: 0, value_bar: 0 }}
        />
      }
    />;

    dot 상태 값에 따라 구역을 설정할 수 있는 함수를 onClick 이벤트로 달아줍니다. 에어리어 차트가 클릭되면, district<CustomizedDot> 컴포넌트의 payload prop과 비교를 하여 점들을 렌더링 하게 됩니다. 또한 2주 차에 알게 된, useSearchParams 훅을 사용해 새로고침 시에도 하이라이트가 풀리지 않도록 구현하였습니다.

    CustomizedDot.tsx
    const CustomizedDot = ({ cx, cy, stroke, payload, district }: Props) => {
      if (payload.id === district) {
        return (
          <svg x={cx - 3} y={cy - 3} fill="white">
            <g transform="translate(4 4)">
              <circle r="4" fill={stroke} />
              <circle r="2" fill={stroke} />
            </g>
          </svg>
        );
      }
     
      return null;
    };

3주 차를 진행하며, 팀원들과 회의에서 화두에 오른 주제는 커스텀 훅에 대한 정의였습니다. 저는 객체로 받아온 데이터를 배열로 바꾸고 상태로 set 해주는 useChartData라는 훅을 만들었는데, 다른 팀원이 이 함수는 훅이 아닌 util 함수라는 의견을 내었습니다. 그 이유는 다음과 같습니다.

  • "훅은 useState처럼 data와 그 data를 컨트롤할 수 있는 함수를 return 해야 한다. 이 함수는 data만 return 하고 그 data를 변경할 수는 없으니 훅이 아니다."

훅이라고 생각하는 저와 다른 팀원들의 의견은 다음과 같았습니다.

  • "useStateuseEffect 훅을 사용했기 때문에 훅이 맞다."
useChartData.ts
import { useEffect, useState } from "react";
import { getChartData } from "../api";
import type { IChart, IResponse } from "../types/chart";
 
const useChartData = () => {
  const [chartData, setChartData] = useState<IChart[]>([]);
  useEffect(() => {
    const getChart = async () => {
      const data: IResponse = await getChartData();
 
      const arrData: IChart[] = Object.entries(data).map(([time, item]) => ({
        time: new Date(time).toLocaleTimeString(),
 
        ...item,
      }));
 
      setChartData(arrData);
    };
 
    getChart();
  }, []);
 
  const chartDistrict = [...new Set(chartData.map((data) => data.id))].sort();
 
  return { data: chartData, chartDistrict };
};
 
export default useChartData;

토론을 하며 이렇다 할 결과가 도출되지 않아 세션을 진행하는 멘토님께 조언을 구했습니다.

"커스텀 훅과 일반 함수를 구분하는 기준은 바로 함수 안에서 기존의 훅을 호출하는가에 따른다"라는 답변을 주셨습니다. 사실 커스텀 훅과 일반 함수의 경계가 모호하였는데, 커스텀 훅이라는 개념이 좀 더 명확해지는 계기가 될 수 있었습니다. 동료 학습이 없었다면, 이런 인사이트를 얻기는 쉽지 않았을 것이라는 생각이 들었습니다.

위와 같은 구현으로 3주 차의 Best Practice로 선정되어 과제를 제출하게 되었습니다. ✨ 3주 차의 결과물은 이곳에서 확인하실 수 있습니다!

4주 차

드디어 대망의 4주 차입니다. 4주 차의 기업 과제는 주어진 데이터를 이용해 주문 목록 페이지를 구현하는 것이었습니다. 거기에 더불어 컴포넌트에 대한 테스트 코드를 작성해야 하는 과제였습니다. 주 별로 과제를 수행하며 항상 처음접하는 것들에 당황을 했었는데, 테스트 코드라니! 이번 과제는 막막함까지 느끼게 되었습니다. 우선 다른 요구 사항을 진행한 뒤, 테스트를 진행하기로 마음을 먹었습니다.

요구 사항 중 가장 까다로웠던 것은 바로 정렬과 필터링이었습니다. 주문번호, 거래일 & 거래시간을 내림 · 오름차순으로 정렬해야 했으며, 주문처리에 따른 필터링 그리고 고객 이름 검색 기능을 구현해야 했습니다. 이번 과제 역시 상태를 이용하기보단 useSearchParams를 활용해 UX 적으로 좀 더 친화적인 구현을 하였습니다. 하지만 여러 조건으로 정렬과 필터링을 하다 보니 조건문이 너무 많아진 코드가 되었습니다.

Home.tsx
// ...
 
if (order === "desc") {
  transactions = [...transactions].sort((a, b) => b.id - a.id);
} else if (order === "asce") {
  transactions = [...transactions].sort((a, b) => a.id - b.id);
}
 
if (time === "desc") {
  transactions = [...transactions].sort(
    (a, b) =>
      new Date(b.transaction_time).getTime() -
      new Date(a.transaction_time).getTime()
  );
} else if (time === "asce") {
  transactions = [...transactions].sort(
    (a, b) =>
      new Date(a.transaction_time).getTime() -
      new Date(b.transaction_time).getTime()
  );
} else transactions;
 
if (status === "true") {
  transactions = [...transactions].filter(
    (transaction) => transaction.status === true
  );
} else if (status === "false") {
  transactions = [...transactions].filter(
    (transaction) => transaction.status === false
  );
} else transactions;
 
if (name === value) {
  transactions = [...transactions].filter((transaction) =>
    trimValue(transaction.customer_name).includes(trimValue(name))
  );
}

한눈에 봐도 가독성은 물론 유지 보수하기도 어려운 코드라는 것을 알 수 있습니다. 항상 새로운 배열을 만들어 내는 문제점도 있으며 각 조건문이 독립적이어서 순서에 상관없이 조건을 처리할 수 있는 문제를 야기할 수도 있습니다. 저는 과정이 끝나고, 가독성을 향상시키기 위해 if-else문을 switch문으로 리팩토링하기로 하였습니다.

Home.tsx
// ...
 
switch (status) {
  case "true":
    transactions = transactions.filter(
      (transaction) => transaction.status === true
    );
    break;
  case "false":
    transactions = transactions.filter(
      (transaction) => transaction.status === false
    );
    break;
}
 
if (name === value) {
  transactions = transactions.filter((transaction) =>
    trimValue(transaction.customer_name).includes(trimValue(name))
  );
}
 
switch (time) {
  case "desc":
    transactions = transactions.sort(
      (a, b) =>
        new Date(b.transaction_time).getTime() -
        new Date(a.transaction_time).getTime()
    );
    break;
  case "asce":
    transactions = transactions.sort(
      (a, b) =>
        new Date(a.transaction_time).getTime() -
        new Date(b.transaction_time).getTime()
    );
    break;
}
 
switch (order) {
  case "desc":
    transactions = transactions.sort((a, b) => b.id - a.id);
    break;
  case "asce":
    transactions = transactions.sort((a, b) => a.id - b.id);
    break;
}

막상 switch문으로 리팩토링하고 나니 if-else문보다는 확실히 가독성이 좋아진 것 같았지만, 코드를 재사용하기 어렵고 로직이 너무 지저분한 느낌을 받았습니다. 그래서 저는 함수로 리팩토링을 시도하였습니다.

filterTransactions.ts
const filterTransactions = (
  transactions: ITransaction[],
  options: IOptions
): ITransaction[] => {
  const { status, name, time, order, value } = options;
 
  const filterByStatus = (
    transactions: ITransaction[],
    filteredStatus: boolean
  ): ITransaction[] =>
    transactions.filter((transaction) => transaction.status === filteredStatus);
 
  const filterByName = (
    transactions: ITransaction[],
    name: string
  ): ITransaction[] =>
    transactions.filter((transaction) =>
      trimValue(transaction.customer_name).includes(trimValue(name))
    );
 
  const sortByTime = (
    transactions: ITransaction[],
    time: string
  ): ITransaction[] =>
    transactions.sort((a, b) => {
      const timeA = new Date(a.transaction_time).getTime();
      const timeB = new Date(b.transaction_time).getTime();
      return time === "desc" ? timeB - timeA : timeA - timeB;
    });
 
  const sortByOrder = (
    transactions: ITransaction[],
    order: string
  ): ITransaction[] =>
    transactions.sort((a, b) => (order === "desc" ? b.id - a.id : a.id - b.id));
 
  if (status !== "default") {
    const filteredStatus = status === "true";
    transactions = filterByStatus(transactions, filteredStatus);
  }
 
  if (name === value) {
    transactions = filterByName(transactions, name);
  }
 
  if (time !== "default") {
    transactions = sortByTime(transactions, time);
  }
 
  if (order !== "default") {
    transactions = sortByOrder(transactions, order);
  }
 
  return transactions;
};

우선 각각의 조건에 따른 함수들을 만들어 줍니다. 그리고 if문을 통해 조건에 부합한 함수를 실행하여 주문 목록 데이터를 반환하도록 하였습니다. 이런 식으로 함수를 만들어 분리하니 사용하는 컴포넌트에서 로직이 줄어들어 훨씬 가독성을 높일 수 있었습니다. 또한 필터링이 필요한 다른 컴포넌트에서도 재사용이 쉬워졌습니다.

요구 사항에 관한 기능을 구현하며, 생각보다 시간이 많이 들어 테스트 코드를 작성하지 못하여 아쉬운 부분이 있습니다. 테스트 코드를 작성하는 법을 추후에 꼭 공부하고 싶습니다.

4주 차의 결과물은 이곳에서 확인하실 수 있습니다!

과정에 관한 회고 ✨

과정을 처음 시작하며 내가 해낼 수 있을 까하는 걱정과 괜히 두 가지 과정을 병행해서 이도 저도 아니게 되면 어쩌지 하는 생각들이 지배적이었습니다. 하지만, 과정이 종료되고 "걱정은 역시 백해무익한 것이다"라는 생각이 절로 들었습니다. 걱정한 것들이 무색할 정도로 많은 것을 얻었고 저와 비슷한 동료들이 이렇게나 많으며 열심히 하고 있다는 사실에 자극을 받았습니다. 특히 팀원들의 코드를 읽으며, 같은 기능 구현에도 서로 다르게 작성할 수 있다는 것을 알게 되었습니다. 코드에도 사람처럼 개성이 있다는 점이 정말 흥미로웠고 그런 팀원들의 코드를 통해 시야를 넓힐 수 있었습니다. 또 팀원들과 논의와 리뷰를 통해 소통하는 능력을 좀 더 향상시킬 수 있었습니다.

하지만, 아쉬운 점 역시 존재합니다. 가장 큰 아쉬움은 팀원들과 소통이 더 활발하지 못했던 것입니다. 물론 회의를 주도적으로 진행하며 최대한 소통하려고 노력은 했지만, 불참하는 인원도 발생하고 참여는 해도 한마디도 하지 않는 팀원들이 있었는데, 이런 상황을 현명하게 해결하지 못한 것 같습니다. 앞으로 개발적인 능력도 중요하지만, 소통하는 것에도 소홀히 하지 말아야겠습니다.

과정을 통해 몰랐던 새로운 지식과 인사이트들을 많이 얻게 되었는데, 새로운 것을 알게 된다는 점은 저에게는 항상 즐거운 일이고 원동력이 되는 것 같습니다. 또 모른 다는 것을 알 수 있는 무지의 지를 많이 경험할 수 있었습니다.
열심히 과정을 수행한 제 자신에게 고생했다고 말하고 싶습니다. 이것을 발판 삼아 더 열심히 앞으로 질주하도록 하겠습니다. 같이 과정을 수행한 팀원들에게도 감사했다고 전하고 싶습니다.

긴 글 읽어주셔서 감사합니다! 😊