graphql을 활용해서 프로젝트에 accessToken과 refreshToken을 어떻게 적용하였는지 작성해 보도록 하겠다.
일단 어떤식으로 적용할지에 대한 플로우는 다음과 같다.
여기서 주의깊게 봐야 할 단계는 3번과 4번이다.
액세스 토큰 만료 시 리프레쉬 토큰요청과
그 이후 로직을 어떠한 방식으로 처리하였는지 작성하겠다.
현재 ApolloClient 초기 세팅 값은 다음과 같다.
import {
ApolloClient,
InMemoryCache,
createHttpLink,
ApolloLink,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
const httpLink = createHttpLink({
// eslint-disable-next-line
uri: `${process.env.SERVER_URI}/graphql`,
});
const authLink = setContext((_, { headers }: { headers: Headers }) => {
if (typeof window === "undefined") return;
const accessToken = localStorage.getItem(
process.env.ACCESS_TOKEN_KEY as string
);
return {
headers: {
...headers,
"x-jwt": accessToken ? accessToken : "",
},
};
});
const client = new ApolloClient({
link: ApolloLink.from([authLink, httpLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: "no-cache",
errorPolicy: "ignore",
},
query: {
fetchPolicy: "no-cache",
errorPolicy: "all",
},
},
});
export default client;
이 설정값들은 기본적으로 useApolloClient를 통해 요청을 보낼때 항상 실행이 된다고 볼 수 있다.
위에서 언급한 3번과 4번과정을 실현시킬려면 요청과 응답사이에 처리할수 있는 미들웨어 역할을 하는 로직이 필요하다.
axios.interceptors 처럼 말이다.
apolloClient에서는 onError라는 top level에서 에러를 가로채 중간 작업을 할 수있도록 제공하는 기능이 있다.
import {
ApolloClient,
InMemoryCache,
createHttpLink,
ApolloLink,
Observable,
} from "@apollo/client";
import { onError } from "@apollo/client/link/error";
import { setContext } from "@apollo/client/link/context";
import { getRestoreAuthToken } from "@libs/getRestoreAuthToken";
import { setAuthToken } from "./utils";
import { logout } from "@libs/logout";
const httpLink = createHttpLink({
// eslint-disable-next-line
uri: `${process.env.SERVER_URI}/graphql`,
});
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions.code === "UNAUTHENTICATED") {
void getRestoreAuthToken()
.then((response) => {
const newAccessToken = response?.token;
const newRefreshToken = response?.refreshToken;
if (!newAccessToken || !newRefreshToken) return;
operation.setContext({
headers: {
...operation.getContext().headers, // 기존 헤더는 그대로 가져오기
"x-jwt": newAccessToken, // accessToken만 바꿔치기
},
});
// 로컬 스토리지 토큰 값 갱신
setAuthToken(newAccessToken, newRefreshToken);
// 변경된 operation 재요청하기
forward(operation);
})
.catch(async (err) => {
console.log(`err`, err);
// refreshToken이 만료이기에 로그아웃 진행
await logout();
});
}
}
}
});
const authLink = setContext((_, { headers }: { headers: Headers }) => {
if (typeof window === "undefined") return;
const accessToken = localStorage.getItem(
process.env.ACCESS_TOKEN_KEY as string
);
return {
headers: {
...headers,
"x-jwt": accessToken ? accessToken : "",
},
};
});
const client = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, httpLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: "no-cache",
errorPolicy: "ignore",
},
query: {
fetchPolicy: "no-cache",
errorPolicy: "all",
},
},
});
export default client;
위에 코드를 설명하자면
err.extensions.code가 "UNAUTHENTICATED" 즉
accessToken이 만료된 상태를 말한다.
그렇기에 getRestoreAuthToken을 호출하여 refreshToken 으로 accessToken과 refreshToken을 갱신하는 작업을 한후 갱신된 토큰으로 기존에 거절되었던 요청을 다시 재요청 하는 것이다.
한마디로
1. 유저정보 요청
2. UNAUTHENTICATED 에러
3. 리프레쉬 토큰으로 토큰 갱신
4. 유저정보 재요청
이런식의 흐름을 갖게된다.
위에서 재요청을 담당하는 함수는 forward(operation)이다.
하지만 이상하게도 위에 코드에서 forward(operation)은 작동하지 않는다.
그 이유를 생각해보니 getRestoreAuthToken()이 비동기 작업을 진행하기에 그 결과를 기다리지 않고 onError 함수가 끝나버려서 그런거지 않나 싶다.
그래서 코드를 다음과 같이 수정하였다.
import {
ApolloClient,
InMemoryCache,
createHttpLink,
ApolloLink,
Observable,
} from "@apollo/client";
import { onError } from "@apollo/client/link/error";
import { setContext } from "@apollo/client/link/context";
import { getRestoreAuthToken } from "@libs/getRestoreAuthToken";
import { setAuthToken } from "./utils";
import { logout } from "@libs/logout";
const httpLink = createHttpLink({
// eslint-disable-next-line
uri: `${process.env.SERVER_URI}/graphql`,
});
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
if (err.extensions.code === "UNAUTHENTICATED") {
return new Observable((observer) => {
void getRestoreAuthToken()
.then((response) => {
const newAccessToken = response?.token;
const newRefreshToken = response?.refreshToken;
if (!newAccessToken || !newRefreshToken) return;
operation.setContext({
headers: {
...operation.getContext().headers, // 기존 헤더는 그대로 가져오기
"x-jwt": newAccessToken, // accessToken만 바꿔치기
},
});
// 로컬 스토리지 토큰 값 갱신
setAuthToken(newAccessToken, newRefreshToken);
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer),
};
// 변경된 operation 재요청하기
forward(operation).subscribe(subscriber);
})
.catch(async (err) => {
console.log(`err`, err);
// refreshToken이 만료이기에 로그아웃 진행
await logout();
});
});
}
}
}
});
const authLink = setContext((_, { headers }: { headers: Headers }) => {
if (typeof window === "undefined") return;
const accessToken = localStorage.getItem(
process.env.ACCESS_TOKEN_KEY as string
);
return {
headers: {
...headers,
"x-jwt": accessToken ? accessToken : "",
},
};
});
const client = new ApolloClient({
link: ApolloLink.from([authLink, errorLink, httpLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: "no-cache",
errorPolicy: "ignore",
},
query: {
fetchPolicy: "no-cache",
errorPolicy: "all",
},
},
});
export default client;
Observable 함수를 리턴해 줌으로써 비동기 작업을 처리하고 그 결과를 옵저버에게 전달하게 하였더니 정상적으로 재호출을 진행하였다.