최근 진행했던 짤을 공유하고 자유롭게 사용할 수 있는 "짤뮤니티" 프로젝트에서 맡았던 OAuth 2.0 로그인 기능에 대해 작성해보려 한다.
우선 나는 잘 모르는 서비스에서 회원 가입을 요구할 때, 대부분 거부감이 들었었다. 내 개인 정보를 잘 모르는 서비스에 제공해야 한다는 것이 썩 내키지 않았기 때문. 그렇다면 다른 사용자들도 나처럼 거부감이 들지는 않을까? 또 회원가입을 해야 하는 프로세스 자체가 귀찮지는 않을까?라는 고민에서 시작되어 자체 회원가입 및 로그인 없이 Oauth 2.0 서비스를 사용하기로 결정하였고, 구글, 카카오, 네이버 총 3개의 서비스를 이용하여 로그인을 진행했다.
우선 OAuth의 프로세스를 알아보기 전, OAuth에서 쓰이는 구성 요소 용어에 대해 알아보자.
OAuth 구성 요소
✅ Resource Owner: 웹 서비스를 이용하려는 유저, 자원(개인 정보)을 소유하는 자, 사용자
✅ Client: 자사 또는 개인이 만든 애플리케이션 서버
✅ Authorization Server: 권한을 부여해 주는 서버
- - 사용자는 이 서버로 ID, PW를 넘겨 Authorization Code를 발급받을 수 있고 Client는 이 서버로 Authorization Code를 넘겨 Token을 발급 받을 수 있다.
✅ Resource Server: 사용자의 개인정보를 가지고 있는 애플리케이션(카카오, 구글, 네이버)등 회사의 서버
- Client는 Token을 이 서버로 넘겨 개인정보를 응답받을 수 있음.
✅ AccessToken: 자원에 대한 접근 권한을 Resource Owner가 인가하였음을 나타내는 자격증명
✅ RefreshToken: Client는 Authorization Server로부터 AccessToken과 RefreshToken을 함께 부여받음. AccessToken은 보안상 만료기간이 짧기 때문에 얼마 지나지 않아 만료되면 사용자는 로그인을 다시 시도해야 함. 하지만 RefreshToken이 있다면 AccessToken이 만료될 때 RefreshToken을 통해 AccessToken을 재발급받아 재 로그인 할 필요 없게 해 줌.
위 용어들을 토대로 Oauth 로그인 프로세스는 아래와 같다.
사용자가 우리 서비스(Client)에 구글, 카카오, 네이버 버튼을 클릭해 로그인을 요청하면, Client는 Oauth 프로세스 시작을 위해 사용자의 브라우저를 Authorization Server로 보낸다.
클라이언트는 이때 Authourization Server가 제공하는 Authorization URL에 responseType, ClientID, redirectURI, scope를 쿼리스트링에 포함시켜 전송한다.
이후 로그인 페이지를 제공받아 ID/PW를 Authorization Server에 제공하고 인증에 성공했다면, Redirect URI로 Authorization Code를 발급받게 된다. 이때 Redirect URI에 Authorization Code를 포함하여 사용자를 리다이렉션 시키게 된다.
Client는 Authorization Server에 Authorization Code를 전달하고, Access Tokend을 응답받게 되며, Client는 AccessToken을 저장하고 이후 Resource Server에서 Resource Owner의 리소스에 접근하기 위해 AccessToken을 사용할 수 있다.
이때 Authorization Code와 AccessToken 교환은 Authorization Code, Client ID, Client Secret을 요청 형식에 맞추어 token 엔드포인트에 전달해야 한다.
위 과정을 마치고 나면 로그인이 성공되고 이후 사용자는 서비스(Client)에게 Resource Server의 리소스가 필요한 기능을 요청하고 Client는 AccessToken을 통해 제한된 리소스에 접근하여 사용자에게 자사 서비스를 제공할 수 있다.
BE와 FE의 역할 분리
위 내용까지가 Oauth에 대한 전반적인 프로세스 내용이었고, 이제부터 고민해야 할 것은 BE와 FE의 역할 분리이다.
하지만 이번 짤뮤니티 프로젝트에서는 BE 측에서 이미 이전 프로젝트에 사용했던 코드를 재사용하여 거의 모든 과정을 구현을 해놓으셨어서, FE가 할 일이 명확하게 분리가 되어있었다.
그렇다면 나(FE)는 무슨 역할을 맡았을까? 이해를 돕기 위해 아래 그림을 보자
위 그림의 빨간색 박스 부분이 BE 팀원분께서 맡으신 역할이다. 그렇다면 FE는 구글, 네이버, 카카오 로그인 버튼을 클릭하여 로그인 페이지를 제공받고, ID/FW를 다시 제공해 준 뒤 AccessToken과 RefreshToken을 Authroization Server로부터 발급받아 브라우저 스토리지에 저장만 하면 되는 것이었다.
로그인 부분
우선 위 프로세스 중 1번에 해당하는 기능을 구현하였다. 구글, 카카오, 네이버 로그인 버튼을 생성하고 그 버튼을 클릭하면 백엔드가 필요한 파라미터 값을 집어넣고 로그인 페이지를 제공해 주었다.
그 후, 서버는 유저가 입력한 ID/PW가 유효하다면 서버 내에서 인가 코드를 발급받고, 발급받은 인가 코드로 토큰을 요청하여 관련 토큰을 제공받은 뒤 우리 서비스로 리다이렉트 시킴과 동시에 AccessToken, RefreshToken을 쿼리스트링에 함께 제공해 주었다. 이후 우리는 TanStack Router를 이용하여 라우팅을 관리하였고, 이를 통해 리다이렉트 된 페이지에서 AccessToken, RefreshToken을 쿼리스트링으로 받아와 로컬 스토리지에 저장하였다.
여기까지는 순조롭게 진행이 되었지만 토큰을 검사하는 부분에서 문제가 생겼다.
위 그림을 보면(요청 관련 로직들은 모두 Axios Interceptor를 사용했다)
- 액세스 토큰을 실어 태그 요청 API를 사용하여 서버에 태그 관련 데이터를 요청하지만, 액세스 토큰이 만료되어 서버에서 401 에러를 내려준다
- errror response 로직에서 401 에러를 받았으니 이전 요청(config)의 헤더에 RefreshToken을 넣어 서버로 재요청
- 리프레시 토큰이 유효하다면 서버로부터 새로운 AccessToken, RefreshToken을 받아온다
- 정상작동 하였으니 정상작동한 response에서 다시 이전 요청(config)의 헤더에 new AccessToken을 넣어 서버로 재요청
- 액세스 토큰이 유효하다면 정상 데이터를 내려줌
이러한 로직인데 내 생각에는 이러한 루트가 조금 선언적이지 않다고 느껴졌다. 이전 요청을 다시 재탕하여 헤더 부분에 리프레시 토큰을 넣어 다시 요청을 보낸다는 게 가독성 측면에서 좋지 않을 것 같다는 생각이 들었다. 그래서 아래와 같이 간단한 변경이 일어나게끔 요청을 했다.
기존 요청 헤더에 리프레시 토큰 값을 집어넣는 게 아닌, 리이슈 API를 생성하여 조금 더 가독성 있게 선언적으로 관리하는 게 좋지 않겠냐는 의견을 냈었고 백엔드 분께서 흔쾌히 수락하셔서 위처럼 진행하기로 했다.
하지만 이 마저도 네트워크 호출 횟수를 한 번이라도 줄이고 싶어 프런트에서도 토큰 만료 검사를 진행하겠다고 요청을 드렸고 백엔드 분께서 수락하셔서 다음과 같이 진행을 하기로 결론이 났다.
자체적으로 토큰 만료 검사 util 함수를 만들고 그 함수를 사용해 토큰 만료 여부를 체크하여 총 네트워크 호출 횟수를 3번 -> 2번으로 줄일 수 있었다. Axios Interceptor 내에서도 코드를 작성하는 데 있어 조금 더 가독성 있고 선언적으로 관리할 수 있었던 것 같다.
좌측에는 isExpiredToken 유틸 함수를 통해 토큰 만료를 검사하고, 리프레시 토큰이 만료되었다면 로컬 스토리지에 저장했던 토큰들을 삭제 후 홈으로 이동시켜 주고, 액세스 토큰이 만료되었다면 리이슈 API를 사용해 액세스 토큰 및 리프레시 토큰을 받아오고 받아온 토큰을 토대로 헤더의 AccessToken 값을 새 걸로 갈아끼워주며 요청을 보내게 된다.
우측의 response 부분에는 리이슈를 위한 로직이며 리이슈 API 요청이 성공했을 시 두 토큰을 return 시켜준다. 이어서 혹시 모를 서버의 리프레시 토큰값 유효성 검증이 실패했을 경우 로컬의 두 토큰 값을 제거하여 홈으로 이동시켜 재로그인을 시켜주는 로직이다.
이전 코드는 증발되어 어디로 사라졌는지 모르겠지만, 이전에 요청했던 요청 헤더에 다시 리프레시 토큰을 넣어주고, 새 토큰을 헤더로 받아온 뒤 이전 요청의 헤더에 받아온 새 토큰을 다시 넣어 요청을 보낸다는 복잡한 로직보다 리이슈 api를 하나 생성하고, 프론트 자체적으로 토큰 유효성 검사를 진행하니 네트워크 호출 횟수도 줄일 수 있었으며 코드를 보다 가독성 있게 짤 수 있었던 것 같다. 아래는 토큰 유효성을 검증하는 util 함수이다. 파라미터의 타입을 유니온 타입으로 "AccessToken | RefreshToken"으로 강제하여 타입을 좁혀 주었으면 더 좋았을 것 같다는 생각이 든다.
하지만 이로써 끝이 아니다. 아직 에러 처리 부분에 대한 미숙한 부분이 남아있고 이 에러처리를 보다 선언적으로, 보다 가독성 있게 효율적으로 짜기 위해 고민중이다.
끝으로 axios Interceptor 전체 코드를 남기며 에러 코드를 보다 효율적으로 관리하고 가독성있게 작성하게 되면 다시 이 글을 수정하러 와야겠다. ㅎㅎ
import axios, { AxiosInstance, AxiosRequestConfig, Method } from "axios";
import * as Sentry from "@sentry/react";
import { getLocalStorage, removeLocalStorage, setLocalStorage } from "@/utils/localStorage";
import { isExpiredToken } from "@/utils/tokenManagement";
import { patchLogOut, postReissueToken } from "./auth";
import { ACCESS_TOKEN, REFRESH_TOKEN } from "@/constants/auth";
const HTTP_METHODS = {
GET: "get",
POST: "post",
PATCH: "patch",
PUT: "put",
DELETE: "delete",
} as const;
const axiosInstance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_BASE_URL,
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
axiosInstance.interceptors.request.use(async (config) => {
const accessToken = getLocalStorage(ACCESS_TOKEN);
const refreshToken = getLocalStorage(REFRESH_TOKEN);
config.headers.Authorization = accessToken && `Bearer ${accessToken}`;
if (refreshToken && isExpiredToken(refreshToken)) {
await patchLogOut();
removeLocalStorage(ACCESS_TOKEN);
removeLocalStorage(REFRESH_TOKEN);
window.location.href = "/";
return config;
}
if (accessToken && isExpiredToken(accessToken)) {
const { accessTokenResponse, refreshTokenResponse } = await postReissueToken();
setLocalStorage(ACCESS_TOKEN, accessTokenResponse);
setLocalStorage(REFRESH_TOKEN, refreshTokenResponse);
config.headers.Authorization = `Bearer ${accessTokenResponse}`;
}
return config;
});
axiosInstance.interceptors.response.use(
(response) => {
const accessToken = response.headers.authorization;
const refreshToken = response.headers["authorization-refresh"];
if (accessToken && refreshToken) return { accessToken, refreshToken };
return response.data;
},
async (error) => {
Sentry.captureException(error);
if (!axios.isAxiosError(error)) return;
if (
error.response?.status === 401 &&
error.response?.data.message === "refresh token이 유효하지 않습니다."
) {
removeLocalStorage(ACCESS_TOKEN);
removeLocalStorage(REFRESH_TOKEN);
window.location.href = "/";
}
return Promise.reject(error);
},
);
const createApiMethod =
(_axiosInstance: AxiosInstance, methodType: Method) =>
<T>(config: AxiosRequestConfig): Promise<T> => {
return _axiosInstance({
...config,
method: methodType,
});
};
const http = {
get: createApiMethod(axiosInstance, HTTP_METHODS.GET),
post: createApiMethod(axiosInstance, HTTP_METHODS.POST),
patch: createApiMethod(axiosInstance, HTTP_METHODS.PATCH),
put: createApiMethod(axiosInstance, HTTP_METHODS.PUT),
delete: createApiMethod(axiosInstance, HTTP_METHODS.DELETE),
};
export default http;
출처
https://developers.kakao.com/docs/latest/ko/kakaologin/common
'React' 카테고리의 다른 글
렌더링을 빨리 빨리!! by 번들 사이즈 최적화 (2) | 2024.04.25 |
---|---|
프론트엔드 성능 최적화(Image 압축 및 확장자 변경 자동화) (0) | 2024.04.13 |
프론트엔드 성능 최적화(Font) (0) | 2024.04.12 |
human error를 줄여 동료 개발자의 경험을 개선해보자! with console.log() (0) | 2024.04.08 |
공통 컴포넌트에 유연성을 확장 시켜보자! by 합성 컴포넌트 패턴 (1) | 2024.03.31 |
같은 실수를 반복하지 않고, 한 번 학습한 내용을 오래 기억하기 위해 개발하면서 겪었던 트러블 슈팅과 학습한 내용을 정리하고 기록합니다 🧑💻