30일차 - RefreshToken, Under/Over Fetching

류연찬·2023년 5월 10일
0

Codecamp FE07

목록 보기
30/39

RefreshToken

AccessToken 은 사용자의 로그인 정보를 담고 있는 JWT토큰 데이터 입니다.

이전에 학습했던 로그인 인증/인가 과정을 다시 한 번 살펴볼까요?

AccessToken 데이터는 일정 시간 동안만 사용할 수 있도록 만료 기한이 정해져 있습니다.

만료 기한이 지나고 사용자가 로그인 정보가 필요한 페이지에 접근하려고 하면 백엔드에서 미리 지정해둔 경로로 redirect 되거나 에러가 뜨게 됩니다.

그렇기 때문에 AccessToken의 만료 기한이 지나면 새로운 AccessToken을 받아와야 합니다.

이러한 과정에 사용되는 토큰을 RefreshToken 이라고 합니다.

예를 들어볼까요?

사용자가 올바른 이메일과 비밀번호를 입력해서 로그인하면 백엔드에서 AccessToken과 RefreshToken을 받아오게 됩니다.

이 때 AccessToken1~2시간 정도의 짧은 만료 기한을 가지고 있고, RefreshToken2주~1개월 정도의 긴 만료 기한을 가지고 있습니다.

그렇다면 이 RefreshToken을 어디에 담아서 받아올까요?

우리는 앞서 세 가지 브라우저 저장소에 대해 배웠습니다.

로컬 스토리지, 세션 스토리지, 쿠키가 그것인데요.

로컬/세션 스토리지의 경우 보안에 취약하기 때문에 토큰을 취급할 때에는 사용하지 않고 쿠키에 RefreshToken을 담아서 받아오게 됩니다.

💡 쿠키의 secure / httpOnly 옵션

쿠키라고 해서 매우 안전한 것은 아닙니다.
하지만 로컬/세션 스토리지와는 다르게 secure, httpOnly 등의 옵션을 설정할 수 있습니다.

httpOnly : 브라우저에서 Javascript를 이용해 쿠키에 접근할 수 없고, 통신으로만 해당 데이터를 주고받을 수 있습니다.

secure : https 통신 시에만 해당 쿠키를 받아올 수 있습니다.

여기까지 생각하다 보면 자연스럽게 이런 의문이 생깁니다.

왜 번거롭게 AccessToken과 RefreshToken 두 가지 종류의 토큰을 받아오는 거지?

이러한 상황을 떠올려봅시다.

AccessToken 을 1시간 동안만 사용할 수 있다고 가정했을 때 1시간 동안은 AccessToken을 인가 시에 사용할 수 있습니다.

하지만 1시간 1분이 되자마자 이전에 발급 받은 AccessToken은 시간 만료로 인해 사용할 수 없는 토큰이 되어버립니다.

그럼, 새로운 AccessToken을 발급 받기 위해 다시 로그인을 진행해야 하는데 1시간마다 로그인을 새로 해야 한다면 사용자 입장에서는 엄청난 불편함을 느끼게 되겠죠?

이런 불편함을 해결하기 위해서, AccessToken을 발급할 때 RefreshToken이 함께 발급되는 것입니다.

발급된 AccessToken의 유효 기간이 지나 만료 되는 시점에서 RefreshToken을 통해서 로그인 과정 없이 새로운 AccessToken을 받아올 수 있게 됩니다.

단, RefreshToken 역시 만료 기간이 있기 때문에 RefreshToken 의 만료 기간이 지나게 된다면 이때는 다시 로그인을 진행해 새로운 RefreshToken 을 가져오는 과정이 필요합니다.

💡 RefreshToken을 이용해 AccessToken을 새로 발급받는 과정

1. AccessToken 만료 후 인가 요청
2. 해당 오류를 포착해서 인가 에러인지 체크
3. RefreshToken으로 AccessToken 재발급 요청
4. 발급 받은 AccessToken을 state에 재저장
5. 방금 실패했던(error) API를 재요청


마이크로 서비스 아키텍쳐

마이크로 서비스 아키텍쳐 (Micro Service Architecture / MSA) 란 백엔드의 서비스를 작은 단위로 쪼개 서로 다른 컴퓨터에 담는 서비스 구조를 뜻합니다.

일반적으로 인증/인가와 관련된 API를 담아 놓은 인증 서비스,
컨텐츠를 처리하는 것과 관련된 API를 담아 놓은 리소스 서비스
로 나뉘며, 리소스 서비스도 각각의 API의 용도에 따라 더 잘게 쪼개는 것이 가능합니다.

현대 웹의 백엔드 서비스마이크로 서비스 아키텍쳐로 이루어져 있는 경우가 많습니다.

👍 MSA의 장점
1. 각각의 서비스를 필요에 따라 다른 언어나 구조로 만들 수 있습니다.
2. 백엔드 서비스가 다운되더라도 문제가 발생한 일부 마이크로 서비스만 다운될 뿐 서비스 전체가 접속 불가능해지는 사태는 일어나지 않습니다.

👎 MSA의 단점
1. 서비스의 구조가 복잡해집니다.


RefreshToken 실습

1. RefreshToken 확인

가장 먼저, 로그인 API 요청시 RefreshToken이 정상적으로 들어오는지 확인해봅시다.

로그인 후 네트워크 탭에서 loginUser API 요청을 열어보면

표시된 위치에서 Cookies 탭 을 찾을 수 있습니다.

Cookies 탭을 클릭해서 내용을 확인해볼까요?

네트워크-Cookies 탭에 refreshToken이 들어와 있는 것을 볼 수 있습니다.

이제 애플리케이션-Cookies 탭에도 제대로 들어와 있는지 확인해볼까요?

무언가 이상합니다.

브라우저의 Cookies 탭에서는 RefreshToken을 찾을 수 없습니다.

이것은 RefreshToken에 Secure 옵션 이 적용되어 있기 때문입니다.

💡 Secure 옵션이 적용된 쿠키는 https 통신 시에만 받아올 수 있습니다.

우리가 사용하는 실습용 api는 https로도 배포가 되어있습니다.

Apollo Setting의 GraphQL 설정을 변경해볼까요?

uploadLink의 uri 경로를 http 에서 https 바꾸고,

민감한 정보 포함을 승인한다는 뜻의 credentials: “include” 옵션을 추가해주시면 됩니다.

const uploadLink = createUploadLink({
  uri: "https://backend07.codebootcamp.co.kr/graphql",
  headers: { Authorization: `Bearer ${accessToken}` },
  credentials: "include",
});

새로고침 후 다시 로그인을 해 보면

애플리케이션-Cookies 탭에 RefreshToken 이 정상적으로 들어와 있는 것을 확인할 수 있습니다.



이제 RefreshToken을 활용해서 AccessToken을 재발급 받는 작업을 진행해볼까요?

Apollo-client에서 제공하는 onError 라는 기능을 사용할 것입니다.

import { onError } from '@apollo/client/link/error';
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
    // 1. 에러를 캐치
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        // 2. 해당 에러가 토큰 만료 에러인지 체크(UNAUTHENTICATED)
        if (err.extensions.code === "UNAUTHENTICATED") {
          // 3. refreshToken으로 accessToken을 재발급 받기
        }
      }
    }
  });

여기서 문제가 발생합니다.

refreshToken을 사용하기 위해서는 graphQL 요청을 보내야 하는데, errorLink를 생성하는 코드는 ApolloProvider 바깥에 있기 때문에 useQuery나 useApolloClient등을 이용해 graphQL 요청을 보낼 수가 없습니다.

이러한 문제를 해결하기 위해서 graphql-request라는 라이브러리를 사용해 볼 것입니다.

graphql-request 공식 문서를 참고해 다음과 같이 error를 캐치한 뒤 accessToken을 재발급 받는 코드를 적어줍니다.

import { GraphQLClient } from 'graphql-request';
const RESTORE_ACCESS_TOKEN = gql`
  mutation restoreAccessToken {
    restoreAccessToken {
      accessToken
    }
  }
`;
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
  // 1. 에러를 캐치
  if (graphQLErrors) {
    for (const err of graphQLErrors) {
      // 2. 해당 에러가 토큰 만료 에러인지 체크(UNAUTHENTICATED)
      if (err.extensions.code === "UNAUTHENTICATED") {
        // 3. refreshToken으로 accessToken을 재발급 받기
        const graphqlClient = new GraphQLClient(
          "https://backend07.codebootcamp.co.kr/graphql",
          { credentials: "include" }
        );
        const result = await graphqlClient.request(RESTORE_ACCESS_TOKEN);
        // RESTORE_ACCESS_TOKEM이라는 gql을 요청한 뒤 반환되는 결과값을 result에 담는다.
        const newAccessToken = result.restoreAccessToken.accessToken;
        // 4. 재발급 받은 accessToken 저장하기
        setAccessToken(newAccessToken);
        // 5. 재발급 받은 accessToken으로 방금 실패한 쿼리 재요청하기
        operation.setContext({
          headers: {
            ...operation.getContext().headers,
            Authorization: `Bearer ${newAccessToken}`,
          },
        }); // 설정 변경 (accessToken만! 바꿔치기)
        return forward(operation);
      }
    }
  }
});

완성하고 보니 코드가 너무 복잡해져서 가독성이 떨어집니다.

실행하기 전에 먼저 코드를 간추려서 정리하는 작업을 진행해볼까요?

3. onError, operation 등 파일 분리

libraries 폴더로 이동해서 getAccessToken.ts 라는 이름의 파일을 신규 생성합니다.

그리고 graphql-request를 이용하여 accessToken을 재발급 받는 코드를 별도 함수로 분리하여 입력해 줍니다.

// getAccessToken.ts 내용

import { gql } from "@apollo/client";
import { GraphQLClient } from "graphql-request";

const RESTORE_ACCESS_TOKEN = gql`
  mutation restoreAccessToken {
    restoreAccessToken {
      accessToken
    }
  }
`;

export const getAccessToken = async() => {
  try {
    const graphqlClient = new GraphQLClient(
      "https://backend07.codebootcamp.co.kr/graphql",
      {
        credentials: "include",
      }
    );
    const result = await graphqlClient.request(RESTORE_ACCESS_TOKEN);
    const newAccessToken = result.restoreAccessToken.accessToken;

    return newAccessToken;
  } catch (error) {
    console.log(error.message);
  }
}

그리고 ApolloSetting 컴포넌트도 다음과 같이 수정해주시면,

refreshToken을 이용해 accessToken을 재발급 받을 준비가 다 된 것입니다.

// src/components/commons/apollo/index.tsx

const errorLink = onError(({ graphQLErrors, operation, forward }) => {
  // 1. 에러를 캐치
  if (graphQLErrors) {
    for (const err of graphQLErrors) {
      // 2. 해당 에러가 토큰 만료 에러인지 체크(UNAUTHENTICATED)
      if (err.extensions.code === "UNAUTHENTICATED") {
        // 3. refreshToken으로 accessToken을 재발급 받기
        getAccessToken().then((newAccessToken) => {
          // 4. 재발급 받은 accessToken 저장하기
          setAccessToken(newAccessToken);

          // 5. 재발급 받은 accessToken으로 방금 실패한 쿼리 재요청하기
          operation.setContext({
            headers: {
              ...operation.getContext().headers,
              Authorization: `Bearer ${newAccessToken}`,
            },
          }); // 설정 변경 (accessToken만 바꿔치기)
          return forward(operation);
        });
      }
    }
  }
});

4. refreshToken 확인

실습용 API 중, 만료 시간이 5초로 짧게 설정되어있는 테스트용 로그인 API가 있습니다.

테스트용 API인 loginUserExample로 로그인 요청을 보낸 뒤, refreshToken을 통해 accessToken이 정상적으로 갱신 되고 있는지 확인해봅시다.

먼저 로그인 요청을 보냅니다.

그리고 로그인 요청으로 발급 받은 AccessToken의 만료 기한이 지난 뒤,

뒤로 가기 버튼을 이용해 로그인 페이지로 나갔다가 다시 로그인 성공 페이지로 돌아와 봅시다.

네트워크 탭에서 API 요청 순서를 확인해볼까요?

토큰 만료로 API 요청 실패 > restoreToken 요청 > AccessToken 재발급 받은 뒤 API 재요청 순으로 작업이 실행된 것을 네트워크 탭에서 확인하실 수 있습니다.

GraphQL의 실체

graphql-request 라이브러리를 사용해 restoreToken API를 요청하는 코드를 보면

Axios를 이용한 Rest API 요청 형태와 유사합니다.

이것은 GraphQL도 사실은 Rest-API의 일종이기 때문입니다.

이게 무슨 말인지 자세히 한 번 알아볼까요?

먼저 axios를 활용한 Rest API 게시글 조회, 등록 방법을 살펴봅시다.

// 게시글 조회
axios.get("API 주소")

// 게시글 등록
axios.post("API 주소", { 데이터 })
// 게시글 조회
axios.get("https://koreanjson.com/posts/1")

// 게시글 등록
axios.post(
	"https://koreanjson.com/boards",
	{ writer: "철수", title: "제목!!", contents: "내용!!" }
)

일반적인 Rest API는 필요한 기능 별로 서로 다른 endpoint를 가지고 있습니다.

하지만, 이렇게 되면 서비스의 규모가 커질수록 endpoint의 수가 많아지게 됩니다.

그래서 어떠한 사람들이

graphql이라는 하나의 endpoint에 모든 API를 통합하는 방식을 고안해냈습니다.

axios.post("[API 주소]/graphql", { 
	aaa: "GraphQL 요청"
})

💡 GraphQL의 query와 mutation 모두 post 방식으로 데이터가 요청됩니다.
query 요청을 보낼 때에도 post 방식으로 데이터가 들어간다는 부분을 주의해주세요!

이러한 방식의 GraphQL API는 다음과 같은 Rest-API의 두 가지 단점을 해결했습니다.

언더 패칭(Under Fetching)

💡 언더 패칭은 필요한 데이터보다 적은 양을 가져온다(Fetching)는 의미입니다.

createBoardfetchBoard 두 가지 요청에 대한 응답 데이터가 필요하다고 가정해봅시다.

일반적인 Rest API의 경우, createBoard 요청을 보낸 뒤 fetchBoard 요청을 따로 보내주어야 합니다.

하지만 graphql이라는 하나의 endpoint를 이용할 경우, 다음과 같이 한 번의 요청만 보내면 됩니다.

axios.post("[API 주소]/graphql", { 
	aaa: "
		createBoard(){
			// 요청하는 내용
		}
		fetchBoard(){
			// 요청하는 내용
		}
	"
})

오버 패칭(Over Fetching)

💡 오버 패칭은 필요하지 않은 데이터까지 가져온다(Fetching)는 의미입니다.

GraphQL 방식으로 데이터를 요청할 경우, 응답 데이터 중 필요한 데이터만 골라서 받아오는 것이 가능합니다.

Postman으로 GraphQL 요청 실습

우리는 지금까지 GraphQL도 Rest API의 한 종류라는 이야기를 했습니다.

그렇다면 Rest API와 동일한 방식으로 GraphQL을 요청하는 것도 가능하겠죠?

Postman을 이용해서 GraphQL 요청을 한 번 보내봅시다.

Postman을 실행한 뒤 아래 이미지와 같이 endpoint를 입력하고, 표시된 부분에 체크해줍니다.

이렇게 하면 JSON 형태로 데이터를 전송할 수 있습니다.

{
	"query": "mutation createBoard { createBoard(createBoardInput: { writer: \"철수\", password: \"1234\", title: \"제목\", contents: \"내용\" }){ _id, writer } }"
}

💡 요청에 오류가 없는데도 그에 대한 응답이 제대로 들어오지 않을 경우, HeadersContent-Type : application/json 을 추가해주세요.

Postman에서 했던 것과 마찬가지로, Next.js에서도 Rest-API 형태로 GraphQL 요청을 보내는 것이 가능합니다.

axios.post("http://backend05.codebootcamp.co.kr/graphql", {
  query: `
 	 mutation createBoard {
    	createBoard (
      		createBoardInput: {
      		writer: "철수",
      		password: "1234",
      		title: "제목",
      		contents: "내용"
    	}){
      		_id,
      		writer
    	}
  	}
  `,
});

0개의 댓글