React

[React/리액트] ky를 활용한 토큰 인증 시스템 구현하기 - Google OAuth와 Token Refresh

solfa 2025. 2. 13. 17:11

데이터 통신에는 항상 axios만 사용해왔는데 요즘에는 오히려 fetch만 쓰는게 덜 무겁다는 의견도 많이 나오고 axios는 과하다는 이야기가 많아서 이번 프로젝트에서는 fetch 기반의 ky 라이브러리를 사용해서 데이터 통신을 해보고자 한다.


ky란?

https://www.npmjs.com/package/ky

 

ky

Tiny and elegant HTTP client based on the Fetch API. Latest version: 1.7.5, last published: a day ago. Start using ky in your project by running `npm i ky`. There are 693 other projects in the npm registry using ky.

www.npmjs.com

 

kySindre Sorhus가 만든 경량 HTTP 클라이언트 라이브러리로, fetch와 ESM을 기반으로 동작하며 사용성을 개선한 것이 특징이다. 기본적으로 JSON 변환, 타임아웃 처리, 간결한 API 인터페이스를 제공하여 번거로운 설정 없이도 효율적인 네트워크 요청을 수행할 수 있다. ⇒ axios보다 훨씬 가볍다는 소리!

axios vs fetch vs ky 비교

  • axios: 기능이 많지만 번들 사이즈가 큼
  • fetch: 가볍지만 기본적인 기능만 제공
  • ky: fetch의 장점(가벼움)을 가져오면서 편리한 기능들을 제공

ky 사용법

사용법은 다음과 같다.

ky를 설치하려면 다음 명령어를 실행한다.

npm install ky

 

ky는 요청 인터셉터, 재시도(retry) 기능, 헤더 설정 등을 지원하며, 필요에 따라 확장하여 사용할 수 있다.

간단한 요청 예제를 알아가보며 공부해보자.

GET 요청 보내기

import ky from 'ky';

const fetchData = async () => {
  try {
    const data = await ky.get('<https://jsonplaceholder.typicode.com/posts/1>').json();
    console.log(data);
  } catch (error) {
    console.error('Error fetching data:', error);
  }
};

fetchData();

여기서 .json()을 호출하면 response.json()을 자동으로 처리해준다.

POST 요청 보내기

import ky from 'ky';

const postData = async () => {
  try {
    const response = await ky.post('<https://jsonplaceholder.typicode.com/posts>', {
      json: {
        title: 'foo',
        body: 'bar',
        userId: 1,
      },
    }).json();

    console.log(response);
  } catch (error) {
    console.error('Error posting data:', error);
  }
};

postData();
  • json 옵션을 사용하면 자동으로 Content-Type: application/json 헤더가 추가된다.
  • 응답도 JSON 형태로 자동 변환해준다.

기존 axios에서의 응답 처리와 비교해보면

import axios from 'axios';

const fetchData = async () => {
  const response = await axios.get('<https://jsonplaceholder.typicode.com/posts/1>');
  console.log(response.data); // JSON으로 자동 변환된 데이터
};

fetchData();
  • axios는 response.data에 JSON 데이터가 자동으로 변환되어 들어온다.
  • 즉, const data = response.data;를 사용해야 JSON을 추출할 수 있음.

차이점 정리

라이브러리 JSON 변환 방식

ky .json()을 직접 호출해야 JSON 변환됨
axios 응답 객체(response.data)에 JSON 변환된 데이터가 자동으로 들어감

토큰 저장 및 API 인스턴스 관리

보통 인증이 필요한 API를 호출할 때, 토큰을 헤더에 추가해야 한다.

이를 매번 설정하지 않고, ky 인스턴스를 만들어 관리하면 편리하다.

import ky from 'ky';

const api = ky.create({
  prefixUrl: '<https://api.example.com>', // API 기본 URL 설정
  headers: {
    Authorization: `Bearer ${localStorage.getItem('token')}`, // 저장된 토큰 사용
    'Content-Type': 'application/json',
  },
});

이후 인스턴스를 재사용 하기 위해서는 이렇게 하면 된다.

const fetchUserData = async () => {
  try {
    const data = await api.get('user/profile').json();
    console.log(data);
  } catch (error) {
    console.error('Error fetching user data:', error);
  }
};

fetchUserData();

✅ ky.create()를 사용하면

  • API의 기본 URL을 설정할 수 있다.
  • headers를 설정해서 토큰을 자동으로 포함할 수 있다.
  • 필요하면 같은 설정을 공유하는 여러 개의 API 인스턴스를 만들 수도 있다.

요청 인터셉터 (Request Hook)

API 요청을 보낼 때 토큰을 동적으로 설정하거나, 요청을 변형할 때 사용한다.

const api = ky.create({
  prefixUrl: '<https://api.example.com>',
  hooks: {
    beforeRequest: [
      (request) => {
        const token = localStorage.getItem('token');
        if (token) {
          request.headers.set('Authorization', `Bearer ${token}`);
        }
      },
    ],
  },
});

✅ beforeRequest Hook을 사용하면

  • 요청을 보내기 전에 헤더를 수정할 수 있다.
  • 토큰이 있을 때만 Authorization 헤더를 추가할 수도 있다.

응답 인터셉터 (Response Hook)

API 응답을 받은 후, 에러 처리나 응답 변형을 할 때 사용한다.

const api = ky.create({
  prefixUrl: '<https://api.example.com>',
  hooks: {
    afterResponse: [
      async (request, options, response) => {
        if (response.status === 401) {
          console.error('Unauthorized! Redirecting to login...');
          window.location.href = '/login';
        }
      },
    ],
  },
});

✅ afterResponse Hook을 사용하면

  • 응답 코드를 기반으로 자동 처리할 수 있다.
  • 401 (Unauthorized) 발생 시, 자동으로 로그인 페이지로 리디렉트할 수도 있다.

재시도 (Retry) 기능

서버가 일시적으로 응답하지 않을 때, 자동으로 재시도하게 만들 수도 있다.

const api = ky.create({
  prefixUrl: '<https://api.example.com>',
  retry: {
    limit: 3, // 최대 3번 재시도
    methods: ['get', 'post'], // GET과 POST 요청만 재시도
    statusCodes: [408, 500, 502, 503, 504], // 재시도할 HTTP 상태 코드
  },
});

✅ retry 옵션을 사용하면

  • 네트워크 불안정 문제를 자동으로 해결할 수 있다.
  • 특정 HTTP 상태 코드가 발생했을 때만 재시도할 수도 있다.

헤더 설정 (Headers)

API 요청에 추가적인 헤더를 포함하고 싶을 때 사용할 수 있다.

const api = ky.create({
  prefixUrl: '<https://api.example.com>',
  headers: {
    'X-Custom-Header': 'MyCustomValue',
  },
});

요청을 보낼 때마다 X-Custom-Header: MyCustomValue가 자동으로 추가된다.

쿼리 파라미터 쉽게 추가하기

ky는 URL에 자동으로 쿼리 파라미터를 추가하는 기능을 제공한다.

const response = await ky.get('<https://api.example.com/users>', {
  searchParams: {
    page: 1,
    limit: 10,
  },
}).json();

✅ searchParams를 사용하면

정리

기능 방법

기본 GET 요청 ky.get(url).json();
POST 요청 ky.post(url, { json: { ... } }).json();
토큰 저장 & API 관리 ky.create({ headers: { Authorization: 'Bearer ...' } })
요청 인터셉터 beforeRequest: [ (request) => { ... } ]
응답 인터셉터 afterResponse: [ (request, options, response) => { ... } ]
재시도 (Retry) retry: { limit: 3, statusCodes: [...] }
헤더 설정 headers: { 'X-Custom-Header': 'value' }
쿼리 파라미터 추가 searchParams: { key: value }

 

이론을 알았으니 실제로 도입해보자!

 


ky를 사용한 토큰 관리 + 구글 로그인 구현하기

현재 구현하고자 하는 것은 ky를 사용한 토큰 관리 + 구글 로그인 구현이다.

꼭 소셜 로그인이 아니어도 로그인 후 토큰 받아오는 로직은 다른 로그인들도 동일하니까 중간은 생략하고 나머지를 참고하면 좋을 것 같다.

 

구현 흐름은 이렇다.

1. 구글 로그인 → 토큰을 받아옴
2. 토큰을 저장
3. 이후 토큰을 포함하여 api 요청 (인스턴스 생성하기)
4. retry로 토큰 재발급하기

 

일단 기본 Url을 설정하기 위한 인스턴스를 생성한다.

 

1. 기본 인스턴스 생성

import { API_CONFIG } from "@/constants/config";
import ky from "ky";

export const api = ky.create({
  prefixUrl: API_CONFIG.BASE_URL,
});

 

API_CONFIG에는 BASE_URL과 구글 로그인에 필요한 GOOGLE_REDIRECT_URI를 넣어두었다.

export const API_CONFIG = {
  BASE_URL: import.meta.env.VITE_API_BASE_URL,
  GOOGLE_REDIRECT_URI: `${window.location.origin}/oauth/callback/google`,
};

 

2. Google OAuth 페이지로 리다이렉션을 도와주는 authservice를 만들기

import { API_CONFIG } from "@/constants/config";
import { api } from "./api";

export interface GoogleLoginResponse {
  accessToken: string;
  refreshToken: string;
}

export const authService = {
  // ... 코드 생략

  initiateGoogleLogin() {
    const redirectUrl = `${API_CONFIG.BASE_URL}/oauth2/google?redirect_uri=${API_CONFIG.GOOGLE_REDIRECT_URI}`;
    window.location.href = redirectUrl;
  },
};

  • initiateGoogleLogin 함수는 다음과 같은 URL을 생성한다.
  • ${API_CONFIG.BASE_URL}/oauth2/google?redirect_uri=${API_CONFIG.GOOGLE_REDIRECT_URI}
  • 사용자는 이 URL로 리다이렉션되어 Google 로그인 페이지를 보게 된다.

3. Google 인증 후 콜백 처리를 위한 컴포넌트 제작

import { authService } from "@/apis/auth.service.api";
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router";

export const GoogleCallback = () => {
  const navigate = useNavigate();
  const processedRef = useRef(false);

  useEffect(() => {
    const handleGoogleCallback = async () => {
      const searchParams = new URLSearchParams(window.location.search);
      const code = searchParams.get("code");

      if (code && !processedRef.current) {
        processedRef.current = true;

        try {
          const { accessToken, refreshToken } = await authService.googleLogin(
            code
          );
          localStorage.setItem("accessToken", accessToken);
          localStorage.setItem("refreshToken", refreshToken);
          navigate("/");
        } catch (error) {
          console.error("Login failed:", error);
          navigate("/");
        }
      }
    };

    handleGoogleCallback();
  }, [navigate]);

  return <div>로그인 처리중</div>;
};
  • 사용자가 Google에서 로그인을 완료하면, Google은 지정된 콜백 URL로 인증 코드와 함께 리다이렉션한다.
  • GoogleCallback 컴포넌트가 마운트되면 useEffect가 실행된다.
  • URL에서 인증 코드(code 파라미터)를 추출한다.
  • processedRef를 사용하여 콜백이 한 번만 처리되도록 보장한다.
  • ⇒ 소셜 로그인에서 code를 추출해서 서버로 보내는 건 한 번만! 해야 된다. 따라서 stricemode가 켜져있거나 그런 상황에서는 오류가 발생할 수 있기 때문에 이렇게 ref를 사용해서 횟수를 감지하거나 useOnceEffect와 같은 한 번만 실행되는 useEffect를 사용하면 된다. useOnceEffect는 인터넷에 구현 코드가 많으니 찾으면 바로 나온다.

4. 백엔드 인증 처리 (auth.service.api.ts)

import { API_CONFIG } from "@/constants/config";
import { api } from "./api";

export interface GoogleLoginResponse {
  accessToken: string;
  refreshToken: string;
}

export const authService = {
// 아까 위에서 생략됐던 코드!
  async googleLogin(code: string): Promise<GoogleLoginResponse> {
    try {
      const response = await api
        .post("oauth2/login/google", {
          json: {
            authorizationCode: code,
          },
          throwHttpErrors: false,
        })
        .json<GoogleLoginResponse>();

      if (!response) {
        throw new Error("Login failed");
      }

      return response;
    } catch (error) {
      console.error("Google login error:", error);
      throw error;
    }
  },

  initiateGoogleLogin() {
    const redirectUrl = `${API_CONFIG.BASE_URL}/oauth2/google?redirect_uri=${API_CONFIG.GOOGLE_REDIRECT_URI}`;
    window.location.href = redirectUrl;
  },
};

  • 추출된 코드로 authService.googleLogin(code)를 호출한다.
  • 백엔드 API의 oauth2/login/google 엔드포인트로 POST 요청을 보낸다.
  • → 보내면 서버가 토큰을 발급해줌!

5. 인증 완료 및 토큰 저장

import { authService } from "@/apis/auth.service.api";
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router";

export const GoogleCallback = () => {
  const navigate = useNavigate();
  const processedRef = useRef(false);

  useEffect(() => {
    const handleGoogleCallback = async () => {
      const searchParams = new URLSearchParams(window.location.search);
      const code = searchParams.get("code");

      if (code && !processedRef.current) {
        processedRef.current = true;

        try {
          const { accessToken, refreshToken } = await authService.googleLogin(
            code
          );
          localStorage.setItem("accessToken", accessToken);
          localStorage.setItem("refreshToken", refreshToken);
          navigate("/");
        } catch (error) {
          console.error("Login failed:", error);
          navigate("/");
        }
      }
    };

    handleGoogleCallback();
  }, [navigate]);

  return <div>로그인 처리중</div>;
};

  • 백엔드에서 성공적으로 응답을 받으면 accessToken과 refreshToken을 반환받는다.
  • 이 토큰들을 localStorage에 저정하면 구현 끝~

 

이 부분은 기본 소셜 로그인 구현 로직이라 이 외의 로그인을 구현하고 있다면 토큰 받아온 이후부터 밑에 부분을 참고하면 좋을 것 같다.

Retry로 access token 재발급 로직 구현하기

위에서 말했듯이 ky는 beforeRetry 훅을 사용해 API fetch retry시 처리할 동작을 구현하는 것이 가능하다.

구현하고자 하는 상황은 다음과 같다.

1. status code가 401이 아닌 경우 retry를 중지한다.
2. access token 가져오는 것을 2번 이상 실패한다면 토큰 만료인 경우로 판단하여 로그아웃 처리를 한다. (token 삭제 등)
3. 1번과 2번 경우가 아닐 때는 refresh token을 사용해 access token을 가져오고 저장한다.

 

이를 위해 먼저 토큰을 관리하는 service를 만들어보자.

1. tokenService 만들기

export const tokenService = {
  getAccessToken: () => localStorage.getItem("accessToken"),

  getRefreshToken: () => localStorage.getItem("refreshToken"),

  setTokens: (accessToken: string, refreshToken: string) => {
    localStorage.setItem("accessToken", accessToken);
    localStorage.setItem("refreshToken", refreshToken);
  },

  clearTokens: () => {
    localStorage.removeItem("accessToken");
    localStorage.removeItem("refreshToken");
  },

  hasTokens: () => {
    const accessToken = localStorage.getItem("accessToken");
    const refreshToken = localStorage.getItem("refreshToken");
    return !!(accessToken && refreshToken);
  },
};

 

이제 토큰을 관리하는 service를 만들었으니, 토큰 재발급 로직을 구현해보자.

 

2. 토큰을 재발급 하는 로직 만들기

const DEFAULT_API_RETRY_LIMIT = 2;

const handleTokenRefresh: BeforeRetryHook = async ({ error, retryCount }) => {
  const httpError = error as HTTPError;

  if (httpError.response.status !== 401) {
    return ky.stop;
  }

  if (retryCount === DEFAULT_API_RETRY_LIMIT - 1) {
    authService.logout();
    return ky.stop;
  }

  try {
    const refreshToken = tokenService.getRefreshToken();
    if (!refreshToken) {
      throw new Error("refreshToken이 없음");
    }
    await authService.refreshToken(refreshToken);
  } catch (error) {
    console.error("Token refresh 실패, 로그아웃", error);
    authService.logout();
    return ky.stop;
  }
};

 

이렇게 만든 handleTokenRefresh를 ky 인스턴스에 등록하면 된다.

export const api = ky.create({
  prefixUrl: API_CONFIG.BASE_URL,
  retry: {
    limit: DEFAULT_API_RETRY_LIMIT,
  },
  hooks: {
    beforeRequest: [setAuthHeader],
    beforeRetry: [handleTokenRefresh],
  },
});

 

이제 토큰이 만료되었을 때 자동으로 재발급을 시도하고, 실패하면 로그아웃되는 로직이 구현되었다!

이렇게 구현한 auth 로직은 다음과 같은 특징을 가진다.

  • 401 에러 발생시 자동으로 토큰을 재발급한다.
  • 토큰 재발급 실패시 자동으로 로그아웃된다.
  • API 요청시 자동으로 토큰이 헤더에 포함된다.

이제 이 인스턴스를 사용해서 안전하게 API를 호출할 수 있다 ㅎㅎ

로그아웃 구현하기

로그아웃은 간단하다. 토큰을 삭제하고 홈으로 리다이렉트하면 된다.

logout: async () => {
  try {
    await api.post("logout", {
      json: { refreshToken: `${tokenService.getAccessToken()}` },
    });
  } catch (error) {
    console.error("Logout failed:", error);
  } finally {
    tokenService.clearTokens();
    window.location.href = "/";
  }
},

 

이렇게 구현한 auth 로직을 실제로 사용해보자. 네비게이션 바에 로그인/로그아웃 버튼을 추가해보자.

import { authService } from "@/apis/auth.service.api";
import { tokenService } from "@/apis/token.service";
import { StyledContainer, StyledProfileImage } from "./Navigation.style";

const Navigation = () => {
  const handleClick = () => {
    if (tokenService.hasTokens()) {
      authService.logout();
    } else {
      authService.initiateGoogleLogin();
    }
  };

  return (
    <StyledContainer>
      <StyledProfileImage onClick={handleClick}>
        <p>{tokenService.hasTokens() ? "로그인됨" : "로그인필요"}</p>
      </StyledProfileImage>
    </StyledContainer>
  );
};

export default Navigation;

 

토큰이 있으면 "로그인됨"이 표시되고 클릭하면 로그아웃되며, 토큰이 없으면 "로그인필요"가 표시되고 클릭하면 Google OAuth 페이지로 리다이렉트된다.

 

결과 화면

 

구글 로그인과 토큰 저장 로직이 잘 작동하는 것을 볼 수 있다.

 

마무리

ky 괜찮은 것 같다.

재밌다~~ axios랑 비슷해서 넘어오기 부담스럽지 않을 듯 하다.

 

전체 코드는 밑에서 확인할 수 있다.

https://github.com/yourssu/Yourssu-Scouter-Frontend/pull/9

 

feat: 구글 로그인 및 토큰 인증 서비스 구현 by ssolfa · Pull Request #9 · yourssu/Yourssu-Scouter-Frontend

1️⃣ 어떤 작업을 했나요? (Summary) resolved feat: 구글 로그인 구현 #8 2025-02-13.3.06.03.mov API 통신을 위한 ky 인스턴스 구현 ky의 prefixUrl, retry, hooks 옵션을 활용하여 base URL 설정, 재시도 로직,...

github.com

 

728x90