Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/#511 프론트의 토큰 만료 검증로직을 삭제하고 Axios를 도입한다. #554

Merged
merged 24 commits into from
Dec 19, 2023
Merged
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3ec2b5f
config: axios 설치
Creative-Lee Nov 10, 2023
895be60
feat: axios 인스턴스 및 인터셉터 구현
Creative-Lee Nov 12, 2023
69605c1
refactor: 엑세스 토큰 refresh remote 함수 분리
Creative-Lee Nov 12, 2023
89bdb5f
refactor: 분리되지 않은 type, remote 함수 분리
Creative-Lee Nov 12, 2023
e10bfbe
refactor: remote 함수의 fetcher 의존성 제거 및 인스턴스 적용
Creative-Lee Nov 13, 2023
f2c79ee
refactor: 로그인 remote 함수 분리
Creative-Lee Nov 13, 2023
3ecd234
feat: 메인 페이지 장르별 fetch msw 구현
Creative-Lee Nov 13, 2023
db04ff0
chore: 불필요한 파일 제거
Creative-Lee Nov 13, 2023
a2bbcee
chore: remote 함수 중복 래핑 hook 삭제 및 코드 이동
Creative-Lee Nov 13, 2023
c287cfe
refactor: remote 함수 query parameter 처리 방식 통일
Creative-Lee Nov 13, 2023
d06887a
chore: import 방식 변경
Creative-Lee Nov 13, 2023
a4a83df
chore: auth 관련 remote함수 auth/하위로 이동
Creative-Lee Nov 13, 2023
4f858e9
fix: refresh 요청 API 명세에 맞게 로직 수정
Creative-Lee Nov 13, 2023
09cf9f3
refactor: 최종 만료시 로그인 페이지 리다이렉트 처리
Creative-Lee Nov 14, 2023
e6bfcfe
refactor: 타입 분리
Creative-Lee Nov 14, 2023
744fbff
refactor: 인터셉터 refresh 중복 요청 방지 기능 추가
Creative-Lee Nov 14, 2023
2166a72
refactor: 에러응답 타입 분리
Creative-Lee Nov 14, 2023
8e0a22f
chore: fetcher 및 토큰 만료 검증 파일 제거
Creative-Lee Nov 14, 2023
0fbcfde
refactor: 함수 네이밍 개선
Creative-Lee Nov 15, 2023
6755702
chore: 주석 수정
Creative-Lee Nov 15, 2023
0f87b75
refactor: promise 변수 null 초기화 코드 위치 이동
Creative-Lee Nov 28, 2023
dbf9655
style: promise 변수 라인 변경
Creative-Lee Nov 28, 2023
500cae1
refactor: config type 변경 및 린트 주석 제거
Creative-Lee Dec 9, 2023
ea5e89b
Merge branch 'main' into refactor/#511
Creative-Lee Dec 19, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 35 additions & 11 deletions frontend/src/shared/remotes/axios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/* eslint-disable prefer-const */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 prefer-consterror가 아니라 warning인데 disable한 이유가 있으신가요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

몰랐네요 ㅋㅋㅋㅋ 수정할게요~

import axios from 'axios';
import { postRefreshAccessToken } from '@/features/auth/remotes/auth';
import type { AccessTokenRes } from '@/features/auth/types/auth.type';
import type { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';

const { BASE_URL } = process.env;
Expand All @@ -12,8 +14,10 @@ const defaultConfig: AxiosRequestConfig = {
withCredentials: true,
};

export const clientBasic = axios.create(defaultConfig);
export const client = axios.create(defaultConfig);
const clientBasic = axios.create(defaultConfig);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클라이언트 베이식은 인터셉터를 달지 않은 인스턴스 입니다.
리프레쉬 remote 함수에만 사용됩니다.

리프레쉬 요청 함수에도 인터셉터를 부착한 인스턴스를 사용하는 경우,
응답 에러 인터셉터가 무한 호출됩니다.
이 경우 정상적으로 에러를 전파하기 위해 필요합니다.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 제가 이해한 게 맞나요? clientBasic 은 토큰 재발급 용도로만 사용되는 axios 인스턴스. 현재 인증 에러 시에 토큰을 재발급 받는 로직이 인터셉터로 주입하고 있지만, 토큰 재발급 요청 에러 시에는 재발급 요청을 반복해서 받을 필요가 없음. 그래서 토큰 재발급 요청에만 다른 인스턴스를 사용한다는 것.

Copy link
Collaborator Author

@Creative-Lee Creative-Lee Nov 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토큰 재발급 요청 에러 시에는 재발급 요청을 반복해서 받을 필요가 없음.
이부분 만 다시 설명하자면, 정확히는

에러 인터셉터가 달려있는 인스턴스로 리패치 요청 + 이 요청에서 에러가 발생하면 다시 인터셉터에 걸림 + 또 요청
위 무한 굴레를 쫌 쉽게 처리하려고 만들었어요!

(다른방향으로 해결방법을 생각해보면, 카운터 변수를 하나 만들어서 리패치 요청의 횟수를 제한해도 되겠네용)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금도 괜찮고, 다른 방향으로 제시한 것도 괜찮네요! 역시 도우밥!

const client = axios.create(defaultConfig);

let reissuePromise: Promise<AccessTokenRes> | null = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reissue 인터셉터 따로 모듈 분리하는 게 좋을 것 같아요. reissue에만 사용되는 변수인데 핵심 로직과 떨어져 있어서 계속 앞뒤로 왔다갔다하면서 읽게 되네요!

Copy link
Collaborator Author

@Creative-Lee Creative-Lee Nov 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reissue에만 사용되는 변수인데 핵심 로직과 떨어져 있어서 계속 앞뒤로 왔다갔다하면서 읽게 되네요!

좋습니당~ 공감하는 부분이라 사용처와 가깝게 let 변수의 line을 변경했어용!(반영 완)

💬 다만, 인터셉터는 인스턴스에 부착하는 로직인 만큼 한 파일에 모아두는게 더 좋다고 생각하는데, 어떻게 생각하세용?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그냥 제 생각을 말할게요! ( 이렇게 해달라는 것은 아닙니다! 도밥이 생각하고 정리하면 좋을 것 같아요! )
우선 axios 인스턴스에 인터셉터를 주입하는 인터페이스를 보면 인자로 함수를 받잖아요. 이것만 봐도 요청 전후의 작업을 "독립적"으로 코드를 짤 수밖에 없도록 하는 느낌이 들거든요! "독립적"이라는 것은 별도로 분리해서 모듈화하거나 단위 테스트하거나 등등 장점(결국 관심사 분리를 통한 장점일 것 같네요)이 많잖아요. 그렇기 때문에 저는 분리하는 게 좋겠다는 생각이 들었어요!

별개로 만약 도밥이하나의 역할이라 결정 지었다면, reissuePromise 변수를 쓰는 함수 근처에 선언하는 게 더 좋을 것 같아요! 그래야위아래로 왔다갔다 안 하고 좋을 것 같습니다 👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 좋은 접근법이네요.
인터페이스를 보고 생각하는 법 배워갑니다~! 야무진 인사이트 감사합니다.


// 요청 인터셉터
const setToken = (config: InternalAxiosRequestConfig) => {
Expand All @@ -26,27 +30,47 @@ const setToken = (config: InternalAxiosRequestConfig) => {
return config;
};

// 응답 인터셉터
const refreshAccessTokenOnAuthError = async (error: AxiosError) => {
// 응답 에러 인터셉터
const reissueOnExpiredTokenError = async (error: AxiosError) => {
const originalRequest = error.config;
const isAuthError = error.response?.status === 401;
const hasAuthorization = !!originalRequest?.headers.Authorization;

if (error.response?.status === 401 && originalRequest?.headers.Authorization) {
if (isAuthError && hasAuthorization) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

401 에러이면서, 토큰을 가지고 있었을 때, 즉 사용자의 토큰이 만료되었을 때를 특정합니다.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

401 에러 발생 시, 토큰을 가지고 있는 상황이라면 '토큰이 만료되었을 가능성'이 있다. 그러니 헤더에 토큰값이 있다면 재발급 요청을 보내라는 것이군요! 👍

try {
const { accessToken } = await postRefreshAccessToken();
const { accessToken } = await (reissuePromise ??= reissue());
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프라미스의 특성을 이용한 로직입니다.
한 페이지에서 auth 정보가 필요한 api를 2개 이상 호출하는데 토큰이 만료된 경우, reissue api는 1회만 호출해야하고 나머지 요청들 모두 순차적으로 재시도 해야합니다.

위 로직으로 조금이라도 먼저 호출된 api가 최초 1회만 reissue 요청을 보내고 뒤이어 실행되는 다수의 api는 해당 프라미스의 이행을 기다리게 됩니다.
이후 micro task queue에 inqueue한 순서대로 자기 자신을 재시도합니다.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reissue는 한번만 하는 로직을 만들기 어려웠을텐데 고생 많으셨네요! 하나의 프로미스로 관리한다는 점 인상 깊었습니다. 👍

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오호 예제를 만들어보니 이해가 되었네요.

let promise = null;

const reissue = () => {
  return new Promise((resolve) =>
    setTimeout(() => {
      console.log('promise');
      resolve('accessToken');
    }, 5000)
  );
};

const onExpired = async () => {
  const accessToken = await (promise ??= reissue());
  console.log(accessToken);
};

onExpired();
onExpired();
onExpired();
onExpired();

// reissue promise는 한 번만 이행되지만
// console.log(accessToken)은 여러번 실행되어서
// promise
// accessToken *4


localStorage.setItem('userToken', accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;

return client(originalRequest);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이부분 설명해주실 수 있나요? 본 요청을 다시 보내는 로직인가요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞아용. axios는
client.get, client.post 형태의 인터페이스로 요청을 보낼 수도 있고
client(config객체) 형태의 인터페이스로도 요청을 보낼 수 있어용

기존 config객체에 토큰 받아온걸 set하고, 다시 요청 보내는 로직입니당
위 promise 사용처 설명처럼 순서대로 재요청해용~

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와!우! 신기하네요! 정말 좋군요 axios~

} catch {
window.alert('세션이 만료되었습니다. 다시 로그인 해주세요');
window.location.href = '/login';
} catch (error) {
return Promise.reject(error);
}
}

return Promise.reject(error);
};

const reissue = async () => {
try {
const response = await postRefreshAccessToken();
const { accessToken } = response;

localStorage.setItem('userToken', accessToken);
return response;
} catch (error) {
window.alert('세션이 만료되었습니다. 다시 로그인 해주세요');
localStorage.removeItem('userToken');
window.location.href = '/login';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reissue에서 에러를 throw하고 리액트 단에서 라우팅해야할 것 같아요! 이거는 예외처리 (에러 바운더리)하면서 고려해보죠! 그리고 alert 부분도 로직과 ui가 결합되어 있는데 이것도 예외처리하면서 풀어낼 수 있을 것 같아요.

Copy link
Collaborator Author

@Creative-Lee Creative-Lee Nov 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞아용. 저도 에러바운더리에서 해결하는 방법도 있다고 생각했어용.
실제로 깔끔하게 적용까지 하고 싶었는데... 에러 처리는 원래 코난하고 같이하기로 했던 부분이라, 에러바운더리 하나 만드는것도 조금 고민되더라고요. 그래서 기존 코드에서 에바 없이 해결했던 방법으로 그대로 구현해두었습니당.
(서비스 터지지만 않게요 ㅋㅋ)

에바 얼른 합시당 ! 으아아아아아아악!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅎㅎ 에바 고우고우


throw error;
} finally {
reissuePromise = null;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 reissuePromise를 응답 에러 인터셉터에서만 관리하는 것이 어떠한가요? 두 함수 모두 해당 변수를 조작할 필요는 없을 것 같다는 생각이 드네요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

논리적으로
리프레시 완료 후 null할당 vs 토큰 갈아끼고 재시도 전 null 할당의 차이였는데
로직 상 문제가 없네요ㅎㅎ 구현 당시에 착각했었나 봅니다 하하
아주 좋은 의견 감사용 반영했습니당 👍

}
};

clientBasic.interceptors.request.use(setToken);
client.interceptors.request.use(setToken);
client.interceptors.response.use((response) => response, refreshAccessTokenOnAuthError);
client.interceptors.response.use((response) => response, reissueOnExpiredTokenError);

export { clientBasic, client };