RefreshToken
이란, 사용자의 로그인 정보를 담고 있는 JWT토큰 데이터
입니다.
이전에 학습했던 로그인 인증/인가 과정을 다시 한 번 봅시다.
AccessToken 데이터는 일정 시간 동안만 사용할 수 있도록 만료 기한이 정해져 있습니다.
만료 기한이 지나고 사용자가 로그인 정보가 필요한 페이지에 접근하려고 하면 백엔드에서 미리 지정해둔 경로로 redirect 되거나 에러가 뜨게 됩니다.
그렇기 때문에 AccessToken의 만료 기한이 지나면 새로운 AccessToken을 받아와야 합니다.
이러한 과정에 사용되는 토큰을 RefreshToken 이라고 합니다.
예를 들어봅시다!
사용자가 올바른 이메일과 비밀번호를 입력해서 로그인하면 백엔드에서 AccessToken과 RefreshToken을 받아오게 됩니다. 이 때 AccessToken
은 1~2시간 정도의 짧은 만료 기한을 가지고 있고, RefreshToken
은 2주~1개월 정도의 긴 만료 기한을 가지고 있습니다.
그렇다면 이 RefreshToken을 어디에 담아서 받아올까요? 우리는 앞서 세 가지 브라우저 저장소에 대해 배웠습니다. 로컬 스토리지, 세션 스토리지, 쿠키가 그것인데요,
로컬/세션 스토리지의 경우 보안에 취약하기 때문에 토큰을 취급할 때에는 사용하지 않고
쿠키에 RefreshToken을 담아서 받아오게 됩니다.
쿠키의 secure / httpOnly 옵션
쿠키라고 해서 매우 안전한 것은 아닙니다.
하지만 로컬/세션 스토리지와는 다르게 secure, httpOnly 등의 옵션을 설정할 수 있습니다.
httpOnly
: 브라우저에서 Javascript를 이용해 쿠키에 접근할 수 없고, 통신으로만 해당 데이터를 주고받을 수 있습니다.
secure
: https 통신 시에만 해당 쿠키를 받아올 수 있습니다.
❓그러면 왜 번거롭게 두가지 종류의 토큰을 받아올까요??
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를 재요청
소셜 로그인 ( OpenAuth 서비스 )
위와 같은 로그인 서비스를 카카오에서 제공을 하고, 네이버에서 제공을 하고, 구글에서 제공을 합니다.
우리는 이런 서비스들을 OAuth 서비스라고 하며, 흔히 소셜 로그인이라고 부릅니다.
마이크로 서비스 아키텍쳐 (Micro Service Architecture / MSA)
란, 백엔드의 서비스를 작은 단위로 쪼개 서로 다른 컴퓨터에 담는 서비스 구조를 뜻합니다.
일반적으로 인증/인가와 관련된 API를 담아 놓은 인증 서비스
, 컨텐츠를 처리하는 것과 관련된 API를 담아 놓은 리소스 서비스
로 나뉘며,
리소스 서비스도 각각의 API의 용도에 따라 더 잘게 쪼개는 것이 가능합니다. 현대 웹의 백엔드 서비스는 마이크로 서비스 아키텍쳐로 이루어져 있는 경우가 많습니다.
MSA의 장점
1. 각각의 서비스를 필요에 따라 다른 언어나 구조로 만들 수 있습니다.
2. 백엔드 서비스가 다운되더라도 문제가 발생한 일부 마이크로 서비스만 다운될 뿐 서비스 전체가 접속 불가능해지는 사태는 일어나지 않습니다.MSA의 단점
1. 서비스의 구조가 복잡해집니다.
우리는 좀 더 정확한 테스트를 위해 버튼을 클릭했을때 한번만 API를 요청하는 방식으로 refreshToken에 대해 알아 볼 것 입니다.
버튼을 클릭해서 fetchUserLoggedIn을 해오는 방식은 axios에서 사용하는 방식입니다.
따라서 우리도 axios와 비슷한 쿼리 방식을 사용해야 합니다.
apollo-client에는 크게 세가지의 쿼리 방식이 있습니다.
useQuery
: 페이지에 접속하면 자동으로 바로 실행되어 data라는 변수에 fetch해온 데이터를 담아주며, 리렌더링 됩니다.useLazyQuery
: useQuery를 원하는 시점에 실행(버튼 클릭시)후 fetch해온 데이터를 data변수에 담아줍니다.useApolloClient
: 원하는 시점에 실행 후 fetch해온 데이터를 원하는 변수에 담을 수 있습니다. 따라서 axios 같은 느낌으로 사용이 가능합니다.
useApolloClient()를 이용해 버튼을 눌렀을 때 fetchUserLoggedIn을 받아와보도록 만들어 줄 것 입니다. (22-02-login-success 폴더를 복사해 와 useApolloClient를 사용할 수 있도록 버튼을 만들어주었습니다.)
import { gql, useApolloClient, useLazyQuery, useQuery } from "@apollo/client";
import { IQuery } from "../../src/commons/types/generated/types";
const FETCH_USER_LOGGED_IN = gql`
query fetchUserLoggedIn {
fetchUserLoggedIn {
email
name
}
}
`;
export default function LoginSuccessPage() {
// 1. 페이지 접속하면 자동으로 data에 받아지고, 리렌더링됨
// const { data } = useQuery<Pick<IQuery, "fetchUserLoggedIn">>(FETCH_USER_LOGGED_IN);
// 2. 버튼 클릭시 직접 실행하면 data에 받아지고, 리렌더링됨
// const [myquery, { data }] = useLazyQuery(FETCH_USER_LOGGED_IN);
// 3. axios와 동일
// const client = useApolloClient();
const client = useApolloClient();
const onClickButton = async () => {
const result = await client.query({
query: FETCH_USER_LOGGED_IN,
});
console.log(result);
};
return (
<button onClick={onClickButton}>클릭하세요</button>
);
}
이렇게 만들어준 뒤에 네트워크 탭을 보면, 페이지에 접속했을때는 요청을 보내지 않다가, 버튼을 클릭했을 때 요청을 보내는 것을 확인할 수 있습니다! 이렇게 useApolloClient를 이용하시면 axios와 같은 방식으로 사용이 가능합니다.
임의로 토큰의 만료시간을 짧게 해둔 API(playground -> loginUserExample )를 사용해서 실습했습니다.
리프레시 토큰을 받아오는 작업은 아폴로세팅을 해주는 부분에서 진행했습니다.
파일 세팅이 끝났다면, 이제 리프레시토큰을 활용해서 어세스토큰을 재발급 받는 작업을 해보겠습니다.
지금 부터는 Apollo-client에서 제공하는 onError
라는 기능을 사용할 것입니다.
// src/apollo/index.tsx 파일
export default function ApolloSetting(props: IApolloSettingProps) {
const [accessToken, setAccessToken] = useRecoilState(accessTokenState);
const [userInfo, setUserInfo] = useRecoilState(userInfoState);
if (!accessToken || !userInfo) return;
setUserInfo(JSON.parse(userInfo));
}, []);
const errorLink = onError(({ graphQLErrors, operation, forward })=>{
// 1-1. 에러를 캐치
// 1-2. 해당에러가 토큰만료 에러인지 체크(UNAUTHENTICATED)
// 2-1. refreshToken으로 accessToken을 재발급 받기
// 2-2. 재발급 받은 accessToken 저장하기
// 3-1. 재발급 받은 accessToken으로 방금 실패한 쿼리정보 수정하기
// 3-2. 재발급 받은 accessToken으로 방금 수정한 쿼리 재요청하기
})
const uploadLink = createUploadLink({
uri: "http://backend08.codebootcamp.co.kr/graphql",
headers: { Authorization: `Bearer ${accessToken}` },
credentials: "include",
});
const client = new ApolloClient({
link: ApolloLink.from([uploadLink]),
cache: APOLLO_CACHE,
connectToDevTools: true,
});
return (
<ApolloProvider client={client}>
{props.children}
</ApolloProvider>
)
}
🚨 uploadLink의 요청을 보낼 때 uri 경로를 http에서 https로
바꾸고, 민감한 정보 포함을 승인한다는 뜻의 credentials: "include"
옵션을 추가해주셔야 합니다.
만일, credentials: "include"
이 없다면 refreshToken을 쿠키에 못담을 뿐만아니라 쿠키에 담겨있는것들도 백엔드로 전송이 되지 않습니다.
❗️ 알아두기
graphQLErrors
: 에러들을 캐치해줍니다.
operation
: 방금전에 실패했던 쿼리가 뭐였는지 알아둡니다.
forward
: 실패했던 쿼리들을 재전송 합니다.
위의 세팅이 끝났다면, 본격적으로 errorLink를 채워나가 보도록 하겠습니다.
// src/apollo/index.tsx 파일의 errorLink부분
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-requset 설치
yarn add graphql-request
graphql-request 공식 문서를 참고해 다음과 같이
error를 캐치한 뒤 accessToken을 재발급 받는 코드를 적어줍니다.
// src/apollo/index.tsx 파일
import { GraphQLClient } from "graphql-request";
export default function ApolloSetting(props: IApolloSettingProps) {
const [accessToken, setAccessToken] = useRecoilState(accessTokenState);
const [userInfo, setUserInfo] = useRecoilState(userInfoState);
const RESTORE_ACCESS_TOKEN = gql`
mutation restoreAccessToken {
restoreAccessToken {
accessToken
}
}
`;
if (!accessToken || !userInfo) return;
setUserInfo(JSON.parse(userInfo));
}, []);
// 리프레시 토큰 만료 에러 캐치 & 발급
const errorLink = onError(({ graphQLErrors, operation, forward })=>{
// 1-1. 에러를 캐치
if(graphQLErrors){
for(const err of graphQLErrors){
// 1-2. 해당 에러가 토큰만료 에러인지 체크(UNAUTHENTICATED)
if (err.extensions.code === "UNAUTHENTICATED") {
// 2-1. refreshToken으로 accessToken을 재발급 받기
const graphqlClient = new GraphQLClient(
"https://backend-practice.codebootcamp.co.kr/graphql",
{ credentials: "include" }
);
const result = await graphqlClient.request(RESTORE_ACCESS_TOKEN);
// RESTORE_ACCESS_TOKEM이라는 gql을 요청한 뒤 반환되는 결과값을 result에 담는다.
const newAccessToken = result.restoreAccessToken.accessToken;
// 2-2. 재발급 받은 accessToken 저장하기
setAccessToken(newAccessToken);
//3-1. 재발급 받은 accessToken으로 방금 실패한 쿼리정보 수정하기
if(typeof newAcessToken !== "string") return
operation.setContext({
headers: {
...operation.getContext().headers,
Authorization: `Bearer ${newAccessToken}`, // accessToken만 새걸로 바꿔치기
},
});
//3-2. 재발급 받은 accessToken으로 방금 수정한 쿼리 재요청하기
forward(operation)
}
}
}
})
const uploadLink = createUploadLink({
uri: "https://backend-practice.codebootcamp.co.kr/graphql",
headers: { Authorization: `Bearer ${accessToken}` },
credentials: "include",
});
const client = new ApolloClient({
link: ApolloLink.from([uploadLink]),
cache: APOLLO_CACHE,
connectToDevTools: true,
});
return (
<ApolloProvider client={client}>
{props.children}
</ApolloProvider>
)
}
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 async function getAccessToken() {
try {
const graphqlClient = new GraphQLClient(
"https://backend-practice.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);
}
}
이렇게 따로 분리해주면, errorLink내부의 로직을 변경해주어야 하겠죠?
분리한 함수롤 import 해주고, 함수의 return 값이 promise이므로, .then
을 이용해서 이후의 코드를 작성할 수 있습니다.
// src/apollo/index.tsx 파일
import getAccessToken from '파일 경로'
export default function ApolloSetting(props: IApolloSettingProps) {
const [accessToken, setAccessToken] = useRecoilState(accessTokenState);
const [userInfo, setUserInfo] = useRecoilState(userInfoState);
const RESTORE_ACCESS_TOKEN = gql`
mutation restoreAccessToken {
restoreAccessToken {
accessToken
}
}
`;
if (!accessToken || !userInfo) return;
setUserInfo(JSON.parse(userInfo));
}, []);
// 리프레시 토큰 만료 에러 캐치 & 발급
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
// 1-1. 에러를 캐치
if (graphQLErrors) {
console.log(graphQLErrors);
for (const err of graphQLErrors) {
// 1-2. 해당 에러가 토큰만료 에러인지 체크(UNAUTHENTICATED)
if (err.extensions.code === "UNAUTHENTICATED") {
// 2-1. refreshToken으로 accessToken을 재발급 받기
return fromPromise(
getAccessToken().then((newAccessToken) => {
// 2-2. 재발급 받은 accessToken 저장하기
setAccessToken(newAccessToken);
// 3-1. 재발급 받은 accessToken으로 방금 실패한 쿼리 재요청하기
operation.setContext({
headers: {
...operation.getContext().headers,
Authorization: `Bearer ${newAccessToken}`, // accessToken만 새걸로 바꿔치기
},
});
})
).flatMap(() => forward(operation)); // 3-2. 변경된 operation 재요청하기!!!
}
}
}
});
const uploadLink = createUploadLink({
uri: "https://backend-practice.codebootcamp.co.kr/graphql",
headers: { Authorization: `Bearer ${accessToken}` },
credentials: "include",
});
const client = new ApolloClient({
link: ApolloLink.from([uploadLink]),
cache: APOLLO_CACHE,
connectToDevTools: true,
});
return (
<ApolloProvider client={client}>
{props.children}
</ApolloProvider>
)
}
refreshToken을 이용해 accessToken을 재발급 받을 준비가 다 되었습니다!!
5초 이후에 만료되는 테스트용 API를 사용해서 로그인 요청을 보냈습니다.
정상적으로 갱신되고 있는지 확인해봅시다. 그리고 로그인 요청으로 발급 받은 AccessToken의 만료 기한이 지난 뒤,
뒤로 가기 버튼을 이용해 로그인 페이지로 나갔다가 다시 로그인 성공 페이지로 돌아와 봅시다. 네트워크 탭에서 API 요청 순서를 확인해볼까요?
토큰 만료로 API 요청 실패 > restoreToken 요청 > AccessToken 재발급 받은 뒤 API 재요청 순으로 작업이 실행된 것을 네트워크 탭에서 확인하실 수 있습니다.
새로고침을 했을시에도 토큰이 유지될 수 있도록 바꿔보도록 하겠습니다. 아래 방법은 withAuth가 아닐시의 방법임
// 3. 프리렌더링 무시 - useEffect 방법
// 토큰을 넣어두는 global state - recoilState
const [accessToken, setAccessToken] = useRecoilState(accessTokenState);
useEffect(() => {
// 1. 기존방식(refreshToken 이전)
// console.log("지금은 브라우저다!!!!!");
// const result = localStorage.getItem("accessToken");
// console.log(result);
// if (result) setAccessToken(result);
// 2. 새로운방식(refreshToken 이후) - 새로고침 이후에도 토큰 유지할 수 있도록
void getAccessToken().then((newAccessToken) => {
setAccessToken(newAccessToken);
});
}, []);
프로미스?? => 비동기 작업을 도와주는 도구입니다.
연속적인 비동기 작업 도와주는 도구입니다.
📚 observable 사용예제
→ 연속적인 페이지 클릭 혹은 연속적인 검색어 변경게시글 목록페이지에서 페이지 요청을 여러번 빠르게 했을경우,
백엔드에서 누른 순서대로 응답을 보내지 않습니다.예를 들어,
3번페이지를 요청했다가 빠르게 5번 페이지를 요청했을 경우 3번 페이지 요청을 취소 후 5번 페이지를 보내줘야 하는데 , 백엔드에서는 3번페이지를 보여주게 됩니다.
이런경우에는 3번 페이지 요청을 취소해야 합니다.
그렇지 않으면 사용자의 불편한 경험을 초해 할 수 있기 때문입니다.하지만, 이런경우는 promise로 처리 하는게 쉽지 않습니다. 이럴 때 observable을 사용하게 됩니다.
🚨 자바스크립트의 메소드와는 다른 flatMap입니다.
여기서 사용하는 flatMap은 apollo-client에서 지원하는 flatMap입니다
.
실습하기에 앞서, 우리는 apollo-client에서 지원하는 observable을 사용해야 합니다.
따라서 apollo-client에서 지원하는 것 들을 설치를 해주도록 하겠습니다.
⚠️ 설치목록
yarn add zen-observable
yarn add @types/zen-observable --dev
yarn add graphql-request
import {from} from 'zen-observable'
export default function (){
const onClickButton = ()=>{
// new promise(()=>{})
// new observable(()=>{})
// from을 hover해보시면 observable이 나옵니다.
from(["","",""]) // fromPromise
.flatMap((el)=> from([`${el} 결과에 qqq 적용`,`${el} 결과에 zzz 적용`]))
.subscribe((el)=>(console.log(el)))
}
return <button onClick={onClickButton}> 클릭! </button>
}
💡 fromPromise란?
→ onError 라는 함수는 return타입으로 Observable타입을 받고 있습니다.
하지만, 우리가 리턴해주는 값은 promise이기 때문에 Observable타입으로 바꿔줄 도구가 필요합니다.
해당 도구 역시 아폴로에서 지원해주고 있으며, 그 도구가 바로 fromPromise 인 것 입니다.
정리 하자면, fromPromise 는 promise타입을 Observable타입으로 바꿔주는 도구 입니다.
즉, from과 비슷하다고 보시면 됩니다.