[훕치치] 관리자 로그인 과정 개선하기

December 26, 2023 (2y ago)

⚙️

이 글은 훕치치 프로젝트에서 관리자용 로그인 기능을 개선하며 정리한 내용이 담겨있습니다.

훕치치 서비스는 일반 학생인 사용자와 운동 대회를 주최 및 관리하는 주최 단체인 관리자 두 역할이 존재합니다. 현재 사용자는 특별한 로그인 없이 서비스를 이용할 수 있고, 관리자의 경우 권한이 있어야만 데이터를 조회/등록/수정/삭제 할 수 있도록 당연한 설계로 이뤄져있습니다.

이 포스트 이전에는 서비스의 규모가 크지 않고, 관리자에게 부여된 액션의 수가 몇 개 없었기 때문에 별다른 조치 없이 정말 간단하게 로그인이 구현돼있었습니다. 하지만 거듭되는 서비스의 발전에 따라 관리자 역할의 범위 또한 커지게 되었고, 이제 다음에 있을 출시 버전엔 관리자가 여러 계정이 생길 수 있다는 확장성이 생겼기 때문에 로그인 기능을 여타 서비스처럼 견고하고 튼튼하게 설계할 필요성이 대두됐습니다. 그래서 오늘은 빈약했던 로그인 API 호출 이후 결과 상태에 따른 후속처리를 보강한 과정을 포스팅하게 되었습니다.

기존의 로그인 프로세스

  1. 먼저 /login 라우트에서 로그인 요청을 보냅니다. 로그인 정보는 useState로 관리하였고 이를 tanstack queryuseMutation()을 wrap한 커스텀 뮤테이션 훅에 넘겨줍니다.
// src/app/login
export default function Login() {
  const [loginData, setLoginData] = useState<AuthPayload>({} as AuthPayload);

  const { mutate, status } = useLoginMutation();

  const { isError: isEmailEmpty } = useValidate(
    loginData.email,
    emailValue => !emailValue,
  );
  const { isError: isPasswordEmpty } = useValidate(
    loginData.password,
    pwValue => !pwValue,
  );
  const isAnyInvaild = isEmailEmpty || isPasswordEmpty;

  const handleInput = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;

    setLoginData(prev => ({ ...prev, [name]: value }));
  };

  const loginSubmitHandler = async (
    event: React.FormEvent<HTMLFormElement>,
  ) => {
    event.preventDefault();

    mutate(loginData);
  };
	return (...)
}
  1. useLoginMutation()이 호출됩니다. 로그인 정보를 서버에 전송한 뒤 성공적으로 액세스 토큰을 받으면, 이를 localStorage와 tanstack query의 queryData에 각각 저장하고 관리자용 Axios 인스턴스 요청 헤더에 달아줍니다.
// src/queries/admin/auth/useLoginMutation
export default function useLoginMutation() {
  const queryClient = useQueryClient();
  const router = useRouter();

  return useMutation({
    mutationKey: [ADMIN_MUTATION_KEY.POST_LOGIN],
    mutationFn: postLogin,
    onSuccess: ({ access }) => {
      localStorage.setItem('token', access);

      adminInstance.interceptors.request.use((config) => {
        config.headers.Authorization = `Bearer ${access}`;

        return config;
      });

      queryClient.setQueryData([ADMIN_QUERY_KEY.AUTHORIZATION], access);

      router.push('/admin/league');
    },
  });
}
  1. 관리자용 Axios 인스턴스에 달려있는 응답 인터셉터 함수에 따라 매번 API를 호출할 때마다 그 응답 결과를 정해진 함수에 통과시킵니다. 400번대 이상의 응답엔 onRejected 함수가 실행되며 에러 상태에 따라 정해진 액션이 실행됩니다.
// src/api/index.ts
const onError = async (message: string) => {
  alert(message);
  return;
};

adminInstance.interceptors.response.use(
  res => res,
  (error: AxiosError) => {
    if (error.code === 'ECONNABORTED') {
      alert('서버에 문제가 발생했습니다.');
    }
    const { message } = error;
    const { method, url } = error.config as AxiosRequestConfig;
    const { status, statusText } = error.response as AxiosResponse;

    console.error(
      `${method?.toUpperCase()} ${url} | Error ${status} ${statusText} | ${message}`,
    );

    switch (
      status // TODO: 각 상태에 적절한 후속액션 달아주기
    ) {
      case 401: {
        try {
          // token
        } catch (e) {}
        onError('로그인이 필요합니다.');
        return (window.location.href = '/login');
      }
      case 403: {
        onError('권한이 필요합니다.');
        break;
      }
      case 404: {
        onError('잘못된 요청입니다.');
        break;
      }
      case 500: {
        onError('서버에 문제가 발생했습니다.');
        break;
      }
      default: {
        onError('알 수 없는 오류입니다.');
        break;
      }
    }

    return Promise.reject(error);
  },
);

에러 잘게 나눠 다루기

이제 각 에러에 대한 후속조치들을 채워줄 차례입니다. 403 에러는 추후 관리자 계정이 여러개가 될 때 보완하고, 현재는 인증과 관련된 401 에러만 다루기로 했습니다.

401 에러 발생에 대응하기 위해선 우선 1. 401 에러가 발생하는 맥락2. 해당 맥락에 따른 대처 방안을 파악해야 합니다. 어떤 경우가 있을까요?

가장 먼저 관리자가 아닌 유저가 url 입력을 통해 직접 라우팅하는 경우가 있습니다.

이 경우에는 인증 토큰이 없을 것이므로 당연히 401 에러가 발생할 것입니다. 저는 권한이 없는 유저를 로그인 페이지로 리다이렉트 시켜주면 될 것 같습니다.

두번째 경우는 관리자 도메인 사용 중에 저장돼있는 토큰이 삭제될 경우입니다.

인증 토큰이 로컬스토리지에 저장돼있기 때문에 고의로 삭제하지 않는 이상 토큰이 따로 사라질 일은 없겠습니다만, 추후 토큰 저장 로직을 쿠키로 변경할 수도 있기 때문에 그 가능성까지 고려했을 때 일관적으로 토큰 재발급 로직을 만들어놓기로 결정했습니다.

그래서 getAccessToken 함수를 작성해 토큰 재발급을 관장하도록 하였습니다. 이 함수를 맨처음 구현할 땐 useLoginMutation()에서 설정해준 queryData로부터 토큰을 가져와야겠다 생각해 아래와 같이 작성했습니다. 그러나 작성하면서도 useQueryClient 훅을 함수 컴포넌트가 아닌 곳에서 사용했기 때문에 Invalid Hook Call 에러가 발생하는건 당연한 것이었습니다.

const getAccessToken = () => {
  // refreshToken의 역할 - 로그인 인증 토큰을 찾아오는 것
  // 1. 로그인 queryData 2. 로컬스토리지 순으로 찾아오기
  const queryClient = useQueryClient();
  const tokenInQuery = queryClient.getQueryData([
    ADMIN_QUERY_KEY.AUTHENTICATION,
  ]);
  const tokenInLocalStorage = LocalStorage.getItem('token');

  if (tokenInQuery) return tokenInQuery;
  else if (tokenInLocalStorage) return tokenInLocalStorage;
  else return null;
};

그래서 queryData에서 인증 토큰을 찾는 로직을 지웠습니다. 생각해보면 queryData가 사라져서 다른 곳에서 찾는 상황인데 queryData에서 토큰을 불러오려는게 바보같은 짓이었습니다.

const getAccessToken = () => {
  // refreshToken의 역할 - 로그인 인증 토큰을 찾아오는 것
  // queryData에서 못찾으니까 다른 곳에서 찾는 상황인데 queryData에 다시 접근하는게 넌센스
  const tokenInLocalStorage = LocalStorage.getItem('token');

  if (tokenInLocalStorage) return tokenInLocalStorage;
  else return null;
};

그렇게 작성한 인증 토큰 함수를 401 에러 케이스에 추가해줬습니다. 인증 토큰을 조회하고, 인증 토큰이 없는 경우는 에러를 발생시켜 catch문에 걸리도록 했습니다. catch문에 걸렸다는 것은 권한이 없다는 뜻이므로 알맞는 메세지를 alert해주고 로그인 페이지로 리다이렉트시켜주었습니다.

adminInstance.interceptors.response.use(
  res => res,
  async (error: AxiosError) => {
    switch (error.response?.status) {
      case 400: {
        onError('400: 잘못된 요청입니다.');
        break;
      }
      case 401: {
        try {
          const existedToken = getAccessToken();
          if (!existedToken) throw new Error('no accessible token');
          return await adminInstance({
            ...error.config,
            headers: { Authorization: `Bearer ${existedToken}` },
          });
        } catch (e) {
          let message =
            e instanceof Error ? e.message : '401: 로그인이 필요합니다.';
          onError(message);
          return (window.location.href = '/login');
        }
      }
	...
)

이렇게 조치해주니 기존 인증 토큰으로 API 호출에 성공했습니다.

그러나 새로운 문제를 발견했습니다. 로그인 이후 새로고침이 일어나기 전까진 문제가 없지만 서비스 사용 중에 새로고침(라우트 이동 혹은 F5)이 발생할 경우 반복적으로 401에러가 한 번씩 발생하는 것이었습니다.

새로고침하면 날아가는 토큰

unintentional API retrial의도하지 않은 API 재호출

분명 로그인하여 인증을 받은 상태인데 새로고침 이후엔 계속 처음 API 호출이 실패하고 재시도해야만 성공했습니다. 크리티컬한 문제가 아니라 생각할 수 있지만, 아무래도 이렇게 불필요하게 서버 자원에 여러번 접근하는 것을 내버려둘 순 없었습니다. 해결하지 못할 영역도 아니었기 떄문에 고쳐보기로 했습니다.

곰곰히 생각해보니 이 문제는 기존에 관리자용 Axios 인스턴스 요청 인터셉터에 헤더 토큰을 달아주는 로직이 있어 문제가 되지 않았는데, 이를 로그인 뮤테이션 함수로 옮기는 바람에 발생한 버그였습니다. 특별한 조건 없이 매번 관리자 서버 API 요청 전 작동되던 헤더 설정이 로그인 뮤테이션 함수가 호출될 때만 작동하도록 변경되었으니 당연히 새로고침 이후엔 헤더 설정이 제대로 되지 않던 것이었습니다.

// src/queries/admin/auth/useLoginMutation
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';

import { postLogin } from '@/api/auth';
import { ADMIN_MUTATION_KEY } from '@/constants/admin/mutationKey';
import LocalStorage from '@/utils/LocalStorage';

export default function useLoginMutation() {
  const router = useRouter();

  return useMutation({
    mutationKey: [ADMIN_MUTATION_KEY.POST_LOGIN],
    mutationFn: postLogin,
    onSuccess: ({ access }) => {
      LocalStorage.setItem('token', access);
      // 헤더 설정 로직 이동, setQueryData 로직 삭제
      router.push('/admin/league');
    },
  });
}

그래서 이 문제는 로그인 뮤테이션으로 옮겨진 요청 인터셉터를 조건없이 모든 관리자 서버 API 호출시 발동되도록 원위치시켰고, 로그인 뮤테이션 성공 이후에는 중복되는 헤더 설정을 없앤 뒤 로컬스토리지에 토큰 저장 및 라우팅 기능만 남겨놓는 것으로 해결할 수 있었습니다.

// src/api/index.ts
adminInstance.interceptors.request.use((config) => {
  const existedToken = getAccessToken();
  config.headers.Authorization = `Bearer ${existedToken}`;

  return config;
});
problem solved해결된 모습

무한 반복되는 API 재호출

이렇게 해결한 뒤, 다음날 무심코 같은 로그인 프로세스를 실행해보고 401(권한 없음) 에러가 무한 반복되는 문제를 마주하게 되었습니다.

infinite 401 error loop401 에러 무한 반복 문제

분명 어딘가에서 조건의 빈약함으로 API 재호출이 멈추지 않고 반복되고 있음을 직감할 수 있었습니다.

아차 싶었습니다. 401 에러가 발생해 API를 재호출하는 로직은 응답 인터셉터 함수 밖에 없었습니다. try catch문을 작성할 때, 재시도 횟수를 제한하는 로직을 추가하려고 생각만 하고 구현하지 않은게 화근이었습니다.

그래서 간단히 retryCounter란 변수를 생성해 컨트롤하기로 했습니다.

let retryCounter = 0;

adminInstance.interceptors.response.use(
  res => {
    // 응답이 성공하면 retryCounter 초기화
    retryCounter = 0;
    return res;
  },
  async (error: AxiosError) => {
    ...
    switch (error.response?.status) {
      ...
      case 401: {
        if (retryCounter < 3) {
          retryCounter++;
          try {
            ...
          } catch (e) {
            ...
          }
        } else {
          onError('재시도 횟수를 초과하였습니다. 로그인 페이지로 이동합니다.');
          return (window.location.href = '/login');
        }
      }
      ...
    }
  }
)

위와 같이 조건문으로 처리하니, API가 무한 호출되지 않고 3번 호출 이후 alert와 함께 로그인 페이지로 리다이렉트되었습니다. 구현할 때 재시도 간격을 몇 초 정도 부여해야할지 고민했는데 현재로선 딱히 의미가 없다 판단해 넣지는 않았습니다.

stopped retrying when retryCounter touches its limit3번 재시도 후 의도대로 멈춘 모습

정리, 개선 및 보완할 점

오늘 포스팅을 통해 로그인 로직에 크게 아래와 같은 기능을 추가하였습니다.

  1. 401 에러 핸들링 로직
  2. 에러 발생 시 재시도 횟수 최대 3회로 제한
  3. 인증 토큰 저장 및 호출 로직 정교화

그리고 개선 및 보완할 점으로는 우선 401 이외의 다른 에러 핸들링이 있습니다. 또한 추후 로그인 방식을 쿠키로 변경할 가능성이 있는데, 서버 개발자와 이야기해 결정할 필요가 있겠습니다.

이렇게 정리하면서 구현하다보니 좀 더 정교하게 설계할 수 있어서 좋았습니다. 의식의 흐름에 맡겨 코딩하다보면 어딘가 누락되는 것들이 생기는데, 글로 정리하며 코딩하니 집중력을 유지하면서 작업할 수 있는 것 같습니다. 다음에도 훕치치 프로젝트 관련 글감이 있으면 들고 오겠습니다. 긴글 읽어주셔서 감사합니다.