본문 바로가기

원티드 프리온보딩 챌린지

원티드 프리온보딩 챌린지 1-2회차

이번 세션은 Typescript와 함께 시작했다. 지난 시간에 TypeScript적용을 과제로 내주셨기 때문에, 각자 나름대로 학습한 내용에 대해서 다시한번 리마인드 시켜주고, 여러 팁들을 알려주셨다. 그 다음으로는 제출과제에 대한 피드백이었다. 대부분 비동기 처리에 방법에 대한 코드가 나왔다. 예시코드를 상당히 많이 준비를 하셨었고, 더 좋은 방향에 대한 인사이트를 얻을 수 있었다. 그리고는 과제로 React Query적용이 주어졌다. 오늘은 이 내용에 대해서 내가 적용한 내용을 기록해보려고 한다.

이번회차 핵심 내용.

  • 외부 요소와의 결합이 강한 코드는 좋지 않다. 추상에 의존하자.
    • ‘추상(abstraction)’에 의존하며 ‘구체(concretion)’에는 의존하지 않는 시스템.
  • 제어권 위임을 통해 결합을 느슨하게 하자.
    • 대표적으로 콜백함수가 있다. React에서는 HOC가 예일것같다.

  3시간 40분정도의 짧지 않은 세션이었는데, 핵심내용이 두가지 정도로 밖에 추려지지가 않았다. 지난 세션에서 설명한 클린코드, 선언적 프로그래밍에 관련한 내용이랑 결이 비슷한 부분을 생략했기 때문이다. 제출된 코드에 대한 피드백들을 보면, 함수간의 강한 결합을 방지하고 변화에 더 유연하게 코드를 작성한 방법을 강조하신 것처럼 느껴졌다.

코드 수정 및 세션 내용 적용하기.

1. fetch Api를 Axios로 교체하기.

변경 전 코드(fetch 적용됨.)

// custom-fetch.ts
import axios from 'axios';
import { authToken } from '../common/constants/local-storage';
import { backendBaseUrl } from './endpoints';

export type FetchResponse<T> = { data: T };

const tokenNotValid = 'Token is missing';

interface apiResponse extends Response {
  details: string;
}

const errorMiddleware = async (data: apiResponse) => {
  if (data.details === tokenNotValid) {
    window.alert('다시 로그인을 진행해주세요.');
    window.location.reload();
    return data;
  }
  return data;
};

const apiFetchMiddlewares = async (res: Response) => {
  let data = await res.json();
  data = errorMiddleware(data);
  return data;
};

const apiBackend = axios.create({
  baseURL: backendBaseUrl,
  headers: { Authorization: localStorage.getItem(authToken) || '' },
});

export const apiFetch = {
  get: <T>(url: string): Promise<T> =>
    fetch(url, { 생략 },}).then(apiFetchMiddlewares),

  post: <T>(url: string, body: object): Promise<T> =>
    fetch(url, { 생략 }).then(apiFetchMiddlewares),

  delete: <T>(url: string): Promise<T> =>
    fetch(url, { 생략 }).then(apiFetchMiddlewares),

  put: <T>(url: string, body: object): Promise<T> =>
    fetch(url, { 생략 }).then(apiFetchMiddlewares),
 };

  내가 작성했던 custom-fetch코드이다. React-Query를 처음 적용한 프로젝트였기 때문에, React-Query에 대해서 잘 몰랐다. 유명한 라이브러리인데 통신에 관련된 errorHandling이나 interceptor등을 React-Query에서 지원해 주지 않을까? fetch를 써도 괜찮을 것 같은데? 라고 생각했다. 하지만 React-Query는 철저하게 비동기 통신으로 받아온 데이터를 관리하는 역할만 한다. 그래서 fetch의 단점을 하나도 커버해주지 못한다. 임시로 middleware를 만들어서 interceptor를 구현 해보기 위한 시도가 apiFetchMiddlewares로 복잡하게 남아있다. 복잡하고 완성도가 떨어지는 코드를 작성하고 싶지 않았고, errorHandling, interceptor등의 기능을 사용하기 위해 Axios로 변경하기로 했다.

변경 후 코드(axios 적용됨.)

/* eslint-disable consistent-return */
/* eslint-disable no-param-reassign */
import axios, { AxiosError } from 'axios';
import { authToken } from '../common/constants/local-storage';
import { backendBaseUrl } from './endpoints';

export type FetchResponse<T> = { data: T };

export interface ErrorResponse {
  details: string;
}

const tokenMissingError = 'Token is missing'

const apiBackend = axios.create({
  baseURL: backendBaseUrl,
  headers: {
    Authorization: localStorage.getItem(authToken) || '',
  },
});

apiBackend.interceptors.request.use(
  (config) => {
    if (config.headers) config.headers.Authorization = localStorage.getItem(authToken) || '';
    return config;
  },
  (error) => {
    return Promise.reject(error);
  },
);

apiBackend.interceptors.response.use(
  (res) => {
    return res;
  },
  (error: AxiosError<ErrorResponse>): Promise<never> | undefined => {
    const errorDetail = error.response?.data.details;
    if (errorDetail === tokenMissingError) {
      window.alert('다시 로그인을 진행해 주세요.')
      window.history.pushState(null, "", '/');
      window.location.reload();
      return ;
    }
    return Promise.reject(error);
  },
);

export const apiFetch = {
  get: <T>(url: string): Promise<T> => apiBackend.get(url).then((res) => res.data),
  post: <T>(url: string, body: object): Promise<T> => apiBackend.post(url, body).then((res) => res.data),
  delete: <T>(url: string): Promise<T> => apiBackend.delete(url).then((res) => res.data),
  put: <T>(url: string, body: object): Promise<T> => apiBackend.put(url, body).then((res) => res.data),
};

코드의 변경점을 살펴보자.

  • baseUrl과 authToken을 포함하는 Axios 인스턴스인 apiBackend를 생성했다.
  • fetch가 추상화 되어있던 자리는 apiBackend로 교체되었다.
  • 요청에 토큰을 넣어주는 interceptor가 추가되었다.
  • 토큰 관련된 에러를 핸들링 하는 interceptor가 추가되었다.

  처음부터 fetch가 axios로 변경될 가능성을 염두해 둔 의존성 역전의 원칙이 적용된 코드였기에, 쉽게 수정되었다. 요청api는 apiFetch로 추상화 되어있었기 때문에, 다른 파일의 코드는 한 줄도 건드리지 않고, apiFetch만 수정해서 모든 요청을 fetch에서 axios로 수정할 수 있었다. 추상에 의존하는 시스템으로 작성하면, 이러한 구체에 의존하는 코드들에 대한 수정이 수월해진다.

  토큰 관련된 에러를 처리하는 기능은 interceptor에게 위임을 했다. middleware나 interceptor를 사용하게 되면, 요청에 대한 응답을 중간에서 미리 확인하고, 응답에 관련 에러가 포함된 경우를 처리할 수 있다. 이 방법이 내가 아는 방법중에 가장 직관적이고 명확한 방법이다.

Pull Request생성.

https://github.com/2hakjoon/wanted-pre-onboarding-challenge-fe-1/pull/1

 

Api by 2hakjoon · Pull Request #1 · 2hakjoon/wanted-pre-onboarding-challenge-fe-1

비동기 통신 api 수정.(Fetch -> Axios) 수정 내용 요약. Fetch를 Axios로 교체. 기타 변수 네이밍 수정. 1. 변수 명 및 url수정. 백엔드 서버 url을 포함하는 변수의 이름을 backend -> backendBaseURl 로 수정. api end

github.com

  혼자 개발하다보니, 기능을 브랜치로 나눠서 구현하고 그냥 merge시키기만 했었다. 세션을 들으면서 각 회차에 대한 내용을 정리하다 보니, 단위별로 merge를 관리해야겠다는 생각과 문서화의 필요성을 느꼈다. 세션에 참여하는 분 들의 pull request를 본뒤, 나도 똑같이 해봐야 겠다는 생각을 했다. 그래서 fetch를 axios로 교체하던 과정을 pull request로 만들었고, 다른 사람이 본다고 생각하면서 내용을 작성해 봤다.

  처음 작성해보는 pull request라서 내용이 좀 아쉽다. 앞으로도 적절한 단위로 나눠 pull request를 생성하는법. 문서화를 더 잘하는법은 앞으로 더 고민해봐야겠다.

2. localStorage를 추상화 해보자.

localStorage.setItem('TOKEN', token);

내가 로그인을 하거나, 회원가입을 할때 로컬스토리지에 토큰을 저장하는 방법이다. 프로젝트 시작하고나서 바로 작성한 코드라서 그런지 정말 급하게 막 짠것같은 코드이다. 문제점부터 살펴보자.

 

  • localStorage라는 구체의 의존하는코드이다.
  • 저장될 token의 키가 'TOKEN'이라는 하드코딩 된 문자열이다.

localStorage만 추상화하면 되겠지? 라고 생각했는데 key값까지 하드코딩 해놨을 줄은 상상도 못했다. 도대체 이때는 왜이렇게 짠건지 이해가 안된다.

export const persistStore = {
  get(key: string) {
    return localStorage.getItem(key);
  },
  set(key: string, value: any) {
    return localStorage.setItem(key, value);
  },
  remove(key: string) {
    return localStorage.removeItem(key);
  },
};

localStorage를 추상화 한 persistStore라는 객체를 만들었다. localStorage는 추상화를 시키지 않았기 때문에, fetch를 수정했을때와 다르게 모든 코드를 돌아다니면서 수정을 해야한다. 의존성 역전의 원칙이 정말 중요하다고 느껴지는 순간이었다.

persistStore.set(authTokenKey, token);

추상화를 진행한 후 코드는 이렇게 바뀌었다. 이제 localStorage가 아닌 다른곳에 저장하더라도, 수정하기 용이해졌다.

 

3. Error Handling을 깔끔하게 작성 해보자.

고마워요 Cypress

  위의 내용으로 코드수정을 하고 난 후에 Cypress 테스트를 돌려봤다. 그랬더니 원래는 보이지 않던 문제로 인해서 테스트가 실패했다. 에러를 처음 봤을때는 좀 당황스러웠다. 응? 어째서? 에러 날 부분은 바꾼 기억이 없는걸? 그래서 원인 분석을 시작 해보았다.

중복된 이메일로 회원가입시의 에러

  자동화 테스트에서 잡아준 오류 내용을 확인해 보았다. 네트워크 탭을 열어보니 409코드로 error가 발생했다. 분명 원래는 ErrorBoundary로 위임되던 에러가 아닌데 어쩌다 등장하게 된건지 잘 모르겠다. 마지막 변경점이 Axios였으니 Axios를 조사해 보기로 했다.

Axios 공식 문서 : https://github.com/axios/axios#handling-errors

https://github.com/axios/axios#handling-errors

 

GitHub - axios/axios: Promise based HTTP client for the browser and node.js

Promise based HTTP client for the browser and node.js - GitHub - axios/axios: Promise based HTTP client for the browser and node.js

github.com

  Axios의 공식문서에 나와 있는 내용이다. 기본적으로 200번대 바깥의 status code에 대한 내용은 모두 에러 처리를 하게 되어있고, validateStatus라는 config옵션으로 코드의 범위를 커스텀 할 수도 있다. 다른 블로그들에서도 같은 내용을 보았지만, 정보의 레퍼런스 체크를 위해 github에서도 관련된 내용을 확인하였다.

  원인을 알았으니 해결 하면 되는데, 이때부터 깊은 고민에 빠지고 말았다.

 

에러의 종류를 명확하게 정의해 보자.

https://jbee.io/react/error-declarative-handling-2/

 

클라이언트의 사용자 중심 예외 처리

1편에서는 React 애플리케이션에서 비동기를 선언적으로 다룰 수 있는 컴포넌트를 만들어봤습니다. 이번 포스팅에서는 다뤄야 하는 ‘에러’에 대해 살펴봅니다. 에러를 환경에 따라 성격을 분

jbee.io

  해당 내용은 jbee님의 블로그를 참고해서 구현 했다. 내용이 너무 좋다. 잘 작성된 글이다. jbee님과 동일한 조건이 아니기 때문에, 동일하게 구현하지는 않았지만, 현재의 프로젝트에 맞춰서 해결했다. 문제를 해결하기 위해 해결해야 할 부분에 대해서 명확하게 정의를 해보자.

  1. 서버는 요청에 대한 응답을 2xx부터 4xx까지 state코드로 보내준다.
  2. axios는 기본적으로 2xx이상의 코드는 전부 error로 간주한다.
  3. React-Query 인스턴스를 useErrorBoundary : true로 생성했기 때문에 error가 throw되면 ErrorBoundary로 에러를 위임한다.
  4. ErrorBoundary로 위임하려 의도했던 에러는 장애가 발생한 수준의 500에러였고, 응답의 일종으로 간주했던 에러인 4xx에러도 Axios에 의해 의도치 않게 ErrorBoundary로 위임이 되어버렸다.

문제 해결을 위한 구조.

나름대로 정의해본 에러의 종류. 그리고 위임받을 곳들.

문제를 해결하기 위한 구조를 잡아 보았다. 그리고 문제를 해결하려 보니 나 자신에게 문득 의문이 들었다.

<LoadingAndError errorFallback={<SignUpFormError />} loadingFallback={<SignUpFormLoading />}>
  <SignUpFormTemplate />
</LoadingAndError>

  지난번에 리팩토링한 내용이다. Error와 Loading을 위임하여 SignUpFormTemplate은 성공한 상태만 처리하게 선언적으로 코드를 수정했었다. 하지만 위 문제를 해결하기 위한 방법은 컴포넌트 내부에서 4xx에 해당하는 오류도 분기 처리하는 방법이다. 이 부분에 대해 고민한 내용을 정리해보려고 한다.

 

1.대부분의 4xx에러는 200응답에 비해 중요도가 떨어지지 않는다.

  • 로그인을 할 경우 우리는 로그인에 성공 하거나, 로그인에 실패 하거나 두가지 경우를 갖는다. 로그인에 성공한 경우에는 사용자를 홈 화면으로 리다이렉트 시키는 과정을 통해서 로그인이 되었다는 사실을 명확하게 알게 해준다. 그리고 로그인에 실패한 경우 역시 실패한 내용에 대해 명확하게 사용자에게 고지하고, 다시 로그인을 시도할 수 있도록 해준다. 그렇기 때문에 성공과 실패를 동일한 수준의 중요도로 구현을 하는 방향이 맞다는 생각이 들었다. 반면에 500에러의 경우 개발자나 사용자나 쉽게 접하는 에러가 아니므로, 에러 처리를 위임하고 추상화 하여 로직의 복잡도를 낮추는게 맞는 방향이라고 생각한다.

2. 200응답과 4xx에러를 한곳에서 처리하면서도 선언적이고, 간결하게 작성 할 수 있다.

const { mutate } = useLogin();

const loginRequest = ({ email, password }: LoginParams) => {
    const onSuccess = ({ token }: LoginResponse) => {
        window.alert('로그인이 완료되었습니다.');
        localStorage.setItem('TOKEN', token);
        window.location.reload();
    };

    const onError = ({response}: LoginError) => {
	// 아래의 코드는 수정 예정. 자세한 내용은 아래에서 설명함.
      return response?.data && window.alert(response?.data.details);
    };

    mutate({ email, password }, { onSuccess, onError });
  };
  •   useLogin이라는 커스텀 훅을 통해서 useMuation의 동작을 수행할 수 있다. 훅이 반환해준 muate함수는 parameter를 첫번째 인자로 받고, 두번째로는 config option을 받는다. 두번째 인자로 onSuccess와 onError함수를 콜백으로 넣어주게 되면 각각 요청이 성공했을때, 실패했을때로 나눠서 처리하게 된다.
  •   컴포넌트 안에서 성공과 실패를 분기처리 하더라도 코드가 복잡해지지 않았다고 생각한다. 물론 나는 alert창을 띄우는 동작만 구현했기때문에 그럴지 몰라도, 전체적으로 보면 loginRequest라는 네이밍과 onSuccess, onError라는 함수가 선언적으로 잘 읽힌다고 생각된다.

  지난번에 리팩토링한 내용의 핵심은 “선언적 코드 작성”과 “위임을 통한 코드 복잡도 감소” 라고 생각한다. 그 두가지를 지키면서 작성한 코드라고 생각하고 있다. (다른 사람의 피드백도 듣고 싶다.)

 

문제 해결을 위한 과정.

1. useErrorBoundary 설정하기

https://tkdodo.eu/blog/react-query-error-handling#error-boundaries

 

React Query Error Handling

After covering the sunshine cases of data fetching, it's time to look at situations where things don't go as planned and "Something went wrong..."

tkdodo.eu

문서내에서 찾을 수 있었다. errorBoundary로 위임하는 옵션이 boolean이 아닌 콜백함수 형태로도 선언이 가능했다.

useErrorBoundary?: boolean | ((error: TError, query: Query<TQueryFnData, TError, TQueryData, TQueryKey>) => boolean);

 useErrorBoundary가 동적으로 동작하게 코드를 작성했다.

import { QueryClient } from "@tanstack/react-query";

export interface useErrorBoundaryError {
  response: {
    status: number;
  };
}

const isServerError = (error: useErrorBoundaryError) => {
  // 500에러는 서버 internal error
  // status가 없는 경우는 서버가 죽은경우.
  return error.response?.status >= 500 || !error.response?.status;
};

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 0,
      useErrorBoundary: (error) => isServerError(error as useErrorBoundaryError),
    },
    mutations: {
      useErrorBoundary: (error) => isServerError(error as useErrorBoundaryError),
    },
  },
});

  isServerError함수로 조건을 추상화 해주었다. 내가 처음 정의한 ErrorBoudary에 해당하는 에러는 500에러와, 서버와의 통신이 안되는 경우인데, 서버와 통신이 안될때는 status가 없는것 같다.(그냥 에러만 나옴..) 이렇게 useBoundary는 500에러와 서버와 통신이 안될때만 위임받게 설정이 되었다.

 

2. 응답에 대한 분기 설정

 

변경전 코드(onSuccess에서 성공과 실패를 모두 다루고 있다.)

  const loginAndRefresh = ({ email, password }: LoginParams) => {
    // onSuccess로직에서 로그인에 성공한 경우와 실패한 경우가 공존함. 리팩토링 예정
    const onSuccess = ({ details, token }: LoginResponse) => {
      if (details) {
        window.alert(details);
        return;
      }
      if (token) {
        window.alert('로그인이 완료되었습니다.');
        localStorage.setItem('TOKEN', token);
        window.location.reload();
      }
    };

    mutate({ email, password }, { onSuccess });
  };

내가 기존에 작성했던 코드이다. 서버에서 실패에 대한 응답이 올 경우 details에 실패 내용이 담겨서 온다. 그래서 실패에 대한 처리를 한 뒤에 얼리 리턴의 형식으로 에러에 대한 분기 처리를 했고, details가 없는 경우에는 완료 응답으로 간주하여 로직을 수행하게 했다.. onSuccess 분기 내에서 실패한 경우도 같이 처리하고 있기 때문에 좋은 패턴이 아니다. 세션에서도 이와같은 부분에 피드백을 해주시는걸 보고 리팩토링을 진행하기로 했다.

 

변경후 코드(onSuccess와 onError로 성공과 실패를 분기한다.)

const { mutate } = useLogin();

const loginRequest = ({ email, password }: LoginParams) => {
    const onSuccess = ({ token }: LoginResponse) => {
    };

    const onError = ({response}: LoginError) => {
	// 이부분이 문제.
      return response?.data && window.alert(response?.data.details);
    };

    mutate({ email, password }, { onSuccess, onError });
  };

loginRequest함수를 통해 서버로 mutate요청을 보내면, 응답에 따라 onSuccess, onError가 수행된다.

  •  onSuccess
    • Axios가 200응답을 받을 경우 별도의 에러처리 없이 React-Query에 넘겨줄 것이고, React-Query는 콜백함수를 실행시킬것이다. 성공한 경우에는 큰 문제없이 수행이 된다. 로그인이 성공한 후 수행할 관련 로직을 넣어주면 된다.
  • onError
    • 로그인에 실패한 경우에 4xx에러가 발생하게되면 onError에서 처리를 하게된다. 4xx에러는 온전하게 처리를 하지만 500에러를 처리할때는 onError와 ErrorBoundary가 동시에 동작한다. Axios가 error를 뱉어내어 onError가 수행되고, React-Query는 뱉어진 500에러 코드를 보고 errorBoundary로 넘겨주기 때문이다. 이때 고민이 좀 깊어졌다. 내가 생각한 구조대로 라면, 4xx에러와 500에러는 분기 되어 처리되어야 한다. 만약 수정 전의 코드로 돌아간다 하더라도, ErrorBoundary내부에서 분기를 시켜야 할 것이다. 어느 곳 이던 분기를 시켜야 한다면, 처음에 200응답과 4xx응답의 중요도가 비슷하다고 이야기 했던 기준에 따라 4xx응답을 이곳에서 처리 하는게 맞다고 생각했다. 그래서 4xx응답과 500번응답을 분기 시키기 위해 조건문을 추가로 작성 했다. 4xx응답은 response.data에 해당하는 값이 있기 때문에 의도한 alert동작을 수행할 것이고, 500응답은 해당 값이 없기 때문에 아무 동작도 수행하지 않을 것이다.

문제 해결에 대한 정리.

  useErrorBoundary를 동적으로 변경하며 에러가 발생하더라도, 500번 대의 에러만 선택적으로 ErrorBoundary로 넘기고, 200번과 4xx번 대의 에러는 내부에서 분기 처리를 하는 방법을 고안해 보았다. 지난 리팩토링과는 결과물은 조금 다른 것 같지만, 선언적 코드, 클린 코드에 대한 방향성은 동일하다고 생각한다.

 

마치며.

  오늘의 문제 해결 과정은 테스트 자동화에서 시작되었다. 타이트하게 작성된 테스트 코드가 아님에도, 코드 리팩토링 과정에서 발생한 에러를 잘 잡아주었다. 테스트 자동화를 위해 사용한 시간보다 더 많은 시간을 절약된 것 같아서 기분이 좋다. 다음에도 적극적으로 자동화 테스트를 만들어야겠다.

  이 글을 쓰는데 생각 했던 것 보다 시간이 더 오래 걸렸다. 그동안 비동기 처리를 신경 쓴 적 없었기 때문인 것 같다. 내부에서 분기 처리를 하게 될 때는 지저분해도 어쩔 수 없다고 생각했다. 다른 사람들도 크게 다르다 고는 생각을 안 했었다. 하지만 세션을 통해서 잘하는 사람들이 작성한 코드를 보니 방법은 찾으면 있다는 생각이 들었고, 신경을 쓰면 쓸수록 코드 품질은 좋아진다고 느꼈다.

  나름대로 시간을 투자해서 작성한 나의 코드도 분명 더 개선될 여지가 있음을 확신한다. 나도 오늘의 결과물이 그렇게 만족스럽지는 못하다. 하지만 조금이라도 더 명확한 코드를 작성하기 위해 고민하고, 공을 들이는 과정이 중요한 것 같다. 비록 만족스럽지 못한 결과물이지만, 과거에 내가 작성한 코드보다는 분명히 좋은 코드라고 확신할 수 있다.