profile image

L o a d i n g . . .

현재 하고 있는 프로젝트에서 (사주타로말고 헿) Redux Toolkit의 RTK Query를 사용하여 API 요청들을 처리하고있다.

클라이언트 요구사항으로 인증을 추가해달라는게 있어서 리프레시토큰과 엑세스 토큰을 사용하여 요청들을 보호하도록 코드를 만들었다.

이거슨 다음에 또 RTKQ를 사용해서 인증할 때 참고할 수 있도록 찌는 포스팅

1. 기본 fetchBaseQuery 설정

먼저, RTK Query에서 API 요청을 처리하기 위해 fetchBaseQuery를 사용하여 기본 쿼리를 설정한다.

fetchBaseQuery는 API 요청을 단순화하고, 일관된 설정을 제공하는 데 유용한 도구로써, 해당 설정은 요청 헤더에 인증 토큰을 추가하고, 표준 요청 헤더를 설정하여 요청의 일관성과 보안을 보장하게 도와준다.

//Auth.js

/**
 * accessToken이 있을 경우 인증 헤더를 추가하는 fetch base query
 * @param {Object} headers - 요청 헤더
 * @param {Object} api - getState 함수를 포함한 api 객체
 * @returns {Object} - 인증 토큰이 추가된 헤더
 */
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
// ..그 외의 임포트들


/**
 * 기본 fetch base query를 설정합니다.
 * @param {Object} headers - 요청 헤더
 * @returns {Object} - 수정된 헤더
 */
const baseQuery = fetchBaseQuery({
  baseUrl: "api 주소",
  timeout: 10000,
  prepareHeaders: (headers) => {
    const token = getLocalStorage('엑세스토큰')
    log.info(LOCALES.LOG('baseQueryWithReauth token::'), token)
    if (token) {
      headers.set('authorization', `Bearer ${token}`)
    }
    headers.set('Accept', 'application/json')
    headers.set('Cache-Control', 'no-cache')
    headers.set('Pragma', 'no-cache')
    headers.set('Expires', '0')
    return headers
  },
})

설정 주요 내용

  • 기본 URL 설정: baseUrl 속성을 통해 API의 기본 경로를 설정한다. 모든 요청은 이 기본 경로를 기반으로 하며, 추가적인 경로는 각 요청의 URL에 추가시킨다.
  • 인증 토큰 추가: prepareHeaders 함수를 사용하여 요청 헤더에 인증 토큰을 추가한다.

2. 인증 토큰이 만료되었을 경우 재인증 처리

이 함수는 기본 baseQuery를 확장하여 인증 토큰이 만료되었을 때 자동으로 재인증을 시도하도록 설계된 함수다.

리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받고, 원래의 쿼리를 재시도 하게된다.

//Auth.js

/**
 * 인증 토큰이 만료되었을 경우 자동으로 재인증을 시도하는 fetch base query.
 * @param {Object} args - 요청 인수
 * @param {Object} api - dispatch 및 getState 함수를 포함한 API 객체
 * @param {Object} extraOptions - 쿼리에 대한 추가 옵션
 * @returns {Promise<Object>} - 쿼리 결과
 * @throws {Error} - 인증 오류 발생 시
 */
const baseQueryWithReauth = async (args, api, extraOptions) => {
  try {
    let result = await baseQuery(args, api, extraOptions)
    const status = result?.meta?.response?.status
    const code = result?.data?.code

    // 인증 오류 및 비정상적인 상태 처리
    if (!code && status !== 200) {
      if (result?.error?.originalStatus === 403) {
        throw new Error(`403 ${result?.error?.data}`)
      } else if (result?.error?.originalStatus === 404) {
        throw new Error(`404 ${result?.error?.status}`)
      } else {
        throw new Error(result)
      }
    }

    // accessToken이 만료되었을 경우
    if (code === '특정오류코드-004') {
      const myRefreshToken = getCookie('쿠키')

      // 리프레시 토큰을 사용하여 새로운 액세스 토큰 요청
      const refreshResult = await baseQuery(
        {
          url: '토큰 요청 할 api 주소',
          method: 'POST',
          body: {
            refreshToken: myRefreshToken,
          },
        },
        api,
        extraOptions
      )

      // refreshToken이 유효하여 새 accessToken이 성공적으로 발급 되었을 경우
      if (refreshResult?.data?.code === 'OK') {
        const { accessToken, refreshToken } = refreshResult.data

        setCookie('쿠키', refreshToken, 30)
        setLocalStorage('엑세스토큰', accessToken)

        //원래 쿼리 재시도
        result = await baseQuery(args, api, extraOptions)
        return result
      } else {
        // refreshToken이 만료되었을 경우 유저 로그아웃 시키기
        if (
          refreshResult?.data?.code === '특정오류코드-001' ||
          refreshResult?.data?.code === '특정오류코드-002'
        ) {
          // 리프레쉬 토큰 만료를 사용자가 이해할 수 있도록 프론트 메시지 변경
          throw new Error(
            '보안을 위해 자동으로 로그아웃되었습니다. 다시 로그인해 주세요.'
          )
        } else {
          throw new Error(refreshResult?.data?.message)
        }
      }
    } else {
      return result
    }
  } catch (error) {
    const errorMessage = error?.message ?? JSON.stringify(error)
    log.info('baseQueryWithReauth 오류:', error?.message ?? error)
    openConfirmDialog({
      title: '인증 오류',
      contents: (
        <>
          인증 확인중 오류가 발생했습니다.
          <br />
          잠시 후 다시 시도해주세요.
          <br />
          <br />
          오류내용: {errorMessage}
        </>
      ),
      ok: { action: () => api.dispatch(logoutUser()) },
    })
    return {
      data: {
        code: 'OK',
        message: '인증 오류로 인해 쿼리가 중단되었습니다.',
        error: errorMessage,
      },
    }
  }
}

기본 쿼리 요청

  • baseQuery(args, api, extraOptions)를 호출하여 API 요청을 수행한다. 이 단계에서 기본적인 요청이 처리된다.
  • 여기서 args는 요청 인수, apidispatchgetState를 포함한 API 객체, extraOptions는 추가적인 쿼리 옵션을 나타낸다.

응답 상태 확인

  • result 변수에는 기본 쿼리의 결과가 저장되고 응답 상태 코드와 응답 데이터의 코드를 확인하여, 에러 상태를 처리한다.
    • 상태 코드가 403이면 인증 오류로 간주하여 예외를 발생
    • 상태 코드가 404이면 요청한 리소스가 없다는 오류로 간주하여 예외를 발생
    • 그 외의 상태 코드나 오류가 있을 경우, 일반적인 에러를 발생

인증 토큰 만료 처리

  • 응답 데이터의 code'특정오류코드-004'인 경우, 이는 인증 토큰이 만료되었음을 의미한다. 여기서 특정오류코드는 백엔드와 프론트간에 정해둔 코드이다.
  • 이 경우, getCookie('쿠키')를 호출하여 저장된 리프레시 토큰을 가져오고, 이를 사용하여 새로운 엑세스 토큰을 요청한다.

리프레시 토큰을 사용하여 새로운 엑세스 토큰 요청

  • 새로운 엑세스 토큰을 요청하기 위해, 리프레시토큰을 재발급 할 baseQuery를 호출한다.
  • 요청에 성공하면, 응답에서 새로운 엑세스 토큰과 리프레시 토큰을 가져와 저장 (setCookiesetLocalStorage(만든함수) 사용).
  • 새로운 토큰을 저장한 후, 원래 하려던 요청을 다시 시도한다.

리프레시 토큰 만료 처리

  • 만약 리프레시 토큰을 사용하여 새로운 엑세스 토큰을 발급받지 못하는 경우, 리프레시 토큰이 만료되었거나 유효하지 않다는 것을 의미한다.
  • 이 경우에는 로그아웃을 유도하는 메시지를 사용자에게 보여주고, 로그아웃 처리 작업을 수행하게된다.
  1. 에러 처리:
    • 위의 과정에서 예외가 발생하면, 에러 메시지를 로그에 기록하고, 사용자에게 오류 메시지를 보여주는 대화상자를 표시해준다.
    • 사용자가 로그아웃할 수 있도록 처리하며, 에러 상태에 대한 기본 응답을 반환한다. (에러 기본 응답을 반환하지 않을경우 RTK의 썽크에서 error 객체를 찾을 수 없다고 오류가 발생)

3. createApi에서 사용하는 예시

이 섹션에서는 baseQueryWithReauth를 활용하여 RTK Query의 API slice를 생성하고, 인증을 포함한 API 요청을 설정하는 예시이다.

import { createApi } from '@reduxjs/toolkit/query/react'
import { baseQueryWithReauth } from '../auth'

const url = 'example'

/**
 * exampleApi는 다양한 API 요청을 처리하는 RTK Query의 API slice입니다.
 * 이 API는 데이터를 조회하고, 세부 사항을 가져오며, 생성 및 수정하는 기능을 제공합니다.
 * 
 * @typedef {Object} GetExamplesResponse
 * @property {Array} examples - 예시 데이터 목록
 * 
 * @typedef {Object} GetDetailExampleResponse
 * @property {Object} detail - 상세 예시 데이터
 * 
 * @typedef {Object} CreateOrUpdateExampleResponse
 * @property {string} message - 생성 또는 수정 결과 메시지
 */

const exampleApi = createApi({
  reducerPath: 'exampleApi',
  baseQuery: baseQueryWithReauth,
  endpoints: (builder) => ({
    /**
     * 예시 데이터 목록을 가져오는 쿼리
     * 
     * @param {Object} [data] - 쿼리 파라미터
     * @returns {Object} - 쿼리 결과
     * @returns {GetExamplesResponse} - 예시 데이터 목록
     */
    getExamples: builder.query({
      keepUnusedDataFor: 1, // 캐시데이터 유지 제어
      query: (data) => ({
        url: `${url}?request=${data ? encodeURIComponent(data) : ''}`,
      }),
    }),

    /**
     * 특정 예시의 상세 정보를 가져오는 쿼리
     * 
     * @param {number|string} activeIndex - 상세 정보를 가져올 예시의 ID
     * @returns {Object} - 쿼리 결과
     * @returns {GetDetailExampleResponse} - 상세 예시 데이터
     */
    getDetailExample: builder.query({
      keepUnusedDataFor: 1,
      query: (activeIndex) => ({
        url: `${url}/example/${activeIndex}`,
      }),
    }),

    /**
     * 예시 데이터를 수정하는 뮤테이션
     * 
     * @param {Object} body - 수정할 예시 데이터
     * @returns {Object} - 수정 결과
     * @returns {CreateOrUpdateExampleResponse} - 수정 결과 메시지
     */
    updateExample: builder.mutation({
      query: (body) => ({
        url: `${url}/${body.id}`,
        method: 'PATCH', 
        body,
      }),
    }),

    /**
     * 예시 데이터를 새로 생성하는 뮤테이션
     * 
     * @param {Object} body - 생성할 예시 데이터
     * @returns {Object} - 생성 결과
     * @returns {CreateOrUpdateExampleResponse} - 생성 결과 메시지
     */
    createExample: builder.mutation({
      query: (body) => ({
        url: `${url}`,
        method: 'POST', 
        body,
      }),
    }),
  }),
})

export const {
  useGetExamplesQuery,
  useGetDetailExampleQuery,
  useCreateExampleMutation,
  useUpdateExampleMutation,
} = exampleApi

export { exampleApi }

설명

  • baseQueryWithReauth: 이 설정은 기본 쿼리 처리 함수인 baseQueryWithReauth를 사용하여 API 요청을 수행한다. 해당 함수는 인증 토큰을 자동으로 관리하고, 토큰이 만료된 경우 재인증을 시도하게된다. (2. 인증 토큰이 만료되었을 경우 재인증 처리 참고)
  • keepUnusedDataFor: 이 옵션은 데이터 캐시의 유지 시간을 제어한다. 위 예제에서는 1초 동안 캐시된 데이터를 유지하게 되어있다. 데이터가 1초 동안 사용되지 않으면, 캐시는 자동으로 삭제된다. 이 옵션은 데이터의 최신성을 보장하고, 서버에 불필요한 요청을 줄이는 데 유용한 옵션이다.
  • 쿼리와 뮤테이션: getExamples, getDetailExample, updateExample, createExample 엔드포인트는 각각 데이터 조회, 상세 정보 가져오기, 데이터 수정, 데이터 생성 작업을 수행한다. 이들 엔드포인트는 API 호출 시 필요한 URL과 HTTP 메서드, 요청 본문을 설정한다. GET요청의 경우 쿼리, 이 외 다른 요청메서드는 뮤테이션을 사용한다.

위 설정들을 통해, API 요청은 baseQueryWithReauth를 통해 인증을 포함하여 자동으로 처리되며, RTK Query의 createApi를 사용하여 API 호출을 효율적으로 관리할 수 있다.

 

 


import React, { useState } from 'react';
import { useGetExamplesQuery, useGetDetailExampleQuery, useCreateExampleMutation, useUpdateExampleMutation } from './exampleApi';

const ExampleComponent = () => {
  // 예시 데이터 목록 조회
  const { data: examples, error: getExamplesError, isLoading: isLoadingExamples } = useGetExamplesQuery();
  
  // 특정 예시의 상세 정보 조회
  const { data: detail, error: getDetailError, isLoading: isLoadingDetail } = useGetDetailExampleQuery(1); // 예시 ID는 1로 설정
  
  // 예시 데이터 생성
  const [createExample, { error: createError, isLoading: isCreating }] = useCreateExampleMutation();
  
  // 예시 데이터 수정
  const [updateExample, { error: updateError, isLoading: isUpdating }] = useUpdateExampleMutation();
  
  // 예시 데이터 생성 핸들러
  const handleCreateExample = async () => {
    try {
      await createExample({ name: 'New Example', description: 'This is a new example.' }).unwrap();
      alert('Example created successfully!');
    } catch (error) {
      alert('Failed to create example: ' + error.message);
    }
  };
  
  // 예시 데이터 수정 핸들러
  const handleUpdateExample = async () => {
    try {
      await updateExample({ id: 1, name: 'Updated Example', description: 'This example has been updated.' }).unwrap();
      alert('Example updated successfully!');
    } catch (error) {
      alert('Failed to update example: ' + error.message);
    }
  };
  
  if (isLoadingExamples || isLoadingDetail) return <p>Loading...</p>;
  if (getExamplesError) return <p>Error loading examples: {getExamplesError.message}</p>;
  if (getDetailError) return <p>Error loading detail: {getDetailError.message}</p>;
  
  return (
    <div>
      <h1>Examples</h1>
      <ul>
        {examples?.examples.map((example) => (
          <li key={example.id}>{example.name}</li>
        ))}
      </ul>

      <h2>Detail of Example ID 1</h2>
      <p>Name: {detail?.detail.name}</p>
      <p>Description: {detail?.detail.description}</p>

      <button onClick={handleCreateExample} disabled={isCreating}>Create Example</button>
      <button onClick={handleUpdateExample} disabled={isUpdating}>Update Example</button>

      {(createError || updateError) && <p>Error: {createError?.message || updateError?.message}</p>}
    </div>
  );
};

export default ExampleComponent;

 

리액트 컴포넌트에서는 위처럼 사용할 수 있다. (GPT한테 예시 만들어달라구 했당 ~.~) ㅋㅋ

반응형
복사했습니다!