thumnail.jpg

클라이언트와 서버가 통신할 때 토큰을 사용할 경우 토큰을 탈취당할 우려가 있기 때문에 보통 API에 접근할 때 사용하는 Access Token과 Access Token을 재발급 받는 Refresh Token으로 구성하는 경우가 많다.

Access Token은 API에 접근할 때 사용하지만 탈취당할 경우 서버에서 접근을 막기가 어렵기 때문에 토큰의 유효기간을 짧게 두고 Refresh Token을 통해 주기적으로 재발급받으며 인증을 처리한다.

하지만 서비스를 이용하는 도중에 Access Token이 만료된다면 요청이 실패하게 된다. 그렇기 때문에 토큰 만료로 인한 에러로 요청이 실패한다면 자동으로 토큰을 재발급 받아 이전에 실패한 요청을 재요청하게 되면 사용자 경험이 향상되게 된다.

내 블로그의 토큰 재발급 과정

내 블로그는 토큰을 쿠키에 보관한다. 서버에서 로그인 시 쿠키에 토큰을 담아 반환해주기 때문에 클라이언트에서는 따로 토큰을 컨트롤 하지 않아도 되도록 구성했다.

apollo.png

내 블로그의 토큰 재발급 과정을 그림으로 그려봤다. 나는 원래 이 과정을 Apollo Client에서 제공하는 useQuery, useMutation을 사용해 커스텀 훅을 만들어 구현을 했다. 그런데 찾아보니 Apollo Client 자체에서 에러 발생 시 이전 요청을 재요청 해주는 기능을 제공하는 것을 알게되었다. (나는 뭘 했던거지...😢)

토큰 재발급 링크 추가하기

일단 내 블로그의 경우에 토큰 만료시 GraphQL Error로 'jwt expired'라는 에러를 반환한다. GraphQL 요청 시 해당 에러가 발생한다면 자동으로 토큰을 재발급 받고 이전에 실패한 요청을 재 요청하는 로직을 작성하려고 한다.

Apollo Client 기본 설정

// src/apollo.ts

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

let apolloClient: ApolloClient<object>;

const httpLink = new HttpLink({
  uri: 'http://localhost:4000/graphql',
  credentials: 'include',
});

apolloClient = new ApolloClient({
	link: httpLink,
  cache: new InMemoryCache(),
});

export default apolloClient;

다음과 같이 Apollo Client를 설정하면 된다. Apollo Client에 대한 자세한 설정은 React와 GraphQL로 채팅 구현하기 - 리액트 환경설정 포스트를 참고하면 좋다.

여기서 apolloClientlet으로 먼저 선언 후 넣어주지 않고 추후에 넣어준 이유는 나중에 토큰 재발급 시 사용해야 하기 때문에 위쪽에 미리 선언해놓았다.

에러 발생 시 토큰 재발급

// src/apollo.ts

import { ApolloClient, InMemoryCache, HttpLink, fromPromise } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

import { REISSUANCE_ACCESS_TOKEN } from '../queries/token';

...

const TOKEN_EXPIRED = 'jwt expired';

const linkOnError = onError(({ graphQLErrors, operation, forward }) => {
  if (apolloClient && graphQLErrors?.[0].message === TOKEN_EXPIRED) {
    const refresh = fromPromise(
      apolloClient
        .mutate({ mutation: REISSUANCE_ACCESS_TOKEN })
        .then(({ data }) => {
          return data.ReissuanceAccessToken.ok;
        }),
    );

    return refresh.filter((result) => result).flatMap(() => forward(operation));
  }
});

...

export default apolloClient;

Apollo Client에서는 GraphQL 통신 시 에러가 발생할 경우 실행할 로직을 onError를 통해 설정할 수 있다. 인자로는 콜백함수를 넣어줘야 하는데 콜백함수의 인자로 graphQLErrors, operation, forward를 인자로 받는다. (네트워크 에러는 따로 networkError로 받을 수 있다.)

  • graphQLErrors: GraphQL 엔드포인트 요청 오류 배열
  • operation: 오류가 발생한 작업
  • forward: 체인의 다음 링크에 대한 참조 (호출하여 인자로 넣어준 요청 실행)

아까 말했듯이 토큰 만료 에러의 경우 GraphQL에러로 'jwt expired'라는 메시지를 반환하도록 설정했기 때문에 graphQLErrors의 메시지가 해당 에러일 경우 토큰 재발급을 요청하면 된다.

그런데 여기서 문제가 발생한다. onError에 인자로 넣어줄 콜백함수는 async function으로 생성할 수 없었다. (에러가 발생함) 그래서 찾아보니 Apollo Client에서 fromPromise라는 메서드를 통해 비동기로 동작하는 작업을 수행할 수 있었다. 이 메서드는 내부적으로 zen-observable라는 모듈의 Observable객체를 통해 Promise작업을 수행하고 그 결과값을 flatMap메서드를 통해 확인 후 forward(operation)으로 이전 요청을 재실행 할 수 있다.

여기서 처음에 map메서드를 사용해봤는데 에러가 떠서 확인해보니 map은 각 항목마다 Observable객체를 하나하나 반환하는데 flatMap은 모든 시퀀스를 병합해 하나로 줄여 반환하는데, 단어 그대로 평평하게(flat)만들어준다. 해당 내용은 RxJS의 mapflatMap을 참고하면 좋다. (자바스크립트 Array의 map, flatMap과는 다르다.)

에러 링크와 기존 링크 병합

기존의 GraphQL 통신을 위한 링크와 에러 처리를 위한 링크가 있는데 Apollo Client를 생성할 때 넣을 수 있는 링크는 하나이다. 그렇기 때문에 링크가 여러개일 경우 링크를 결합시켜줘야 한다.

링크를 결합시키는 방법은 대표적으로 2가지가 있다. (링크를 스위칭하는 경우는 제외함) fromconcat메서드를 사용해 링크를 결합할 수 있는데 from은 결합할 링크가 많은 경우 사용하고 concat의 경우 결합할 링크가 2개일 경우 사용한다.

import { ApolloClient, InMemoryCache, HttpLink, concat, fromPromise } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

import { REISSUANCE_ACCESS_TOKEN } from '@queries/user.queries';

...

apolloClient = new ApolloClient({
	link: concat(linkOnError, httpLink),
  cache: new InMemoryCache(),
});

우리가 작성한 링크는 2개이므로 concat메서드를 통해 좀 전에 구현한 linkOnErrorhttpLink를 결합시켜주면 된다.

기능 구현을 완료하고 Access Token의 유효기간을 5초로 설정해 실제로 요청시 자동으로 토큰을 재발급 받은 뒤, 이전에 실패한 요청을 재요청하는지 확인해봤는데 성공적으로 확인하는 것을 확인할 수 있었다.

결론

Apollo Client에서 효율적인 토큰 재발급을 위해 onError를 사용해 토큰 만료시 자동 재발급 기능을 구현해보고, 실제로 블로그에도 바로 적용을 시켜 보았다. 기존에 작성했던 커스텀 훅보다 훨씬 간결하고 많은 기능을 제공해서 개인적으로 앞으로 많이 활용할 것 같다. 그리고 비동기 작업을 aysnc function 없이도 fromPromise라는 메서드를 통해 적용했는데 이 과정에서 RxJS에 대한 개념이 아직 부족한 것 같아 추가로 공부할 부분도 생긴 것 같다. 😁

본 포스트는 다음 문서를 참고해 작성했습니다.

https://www.apollographql.com/docs/react/api/link/apollo-link-error/

https://www.apollographql.com/docs/link/composition/

https://github.com/zenparsing/zen-observable

https://able.bio/AnasT/apollo-graphql-async-access-token-refresh--470t1c8