React Native 앱에서 토큰 갱신 로직 구현하기: 401 오류 해결

oversleep·2025년 3월 1일
0

troubleshooting

목록 보기
13/19
post-thumbnail

React Native 앱에서 JWT 토큰 기반 인증을 사용할 때, 가장 까다로운 부분 중 하나가 토큰 만료 처리입니다. 특히 액세스 토큰이 만료된 후 리프레시 토큰을 사용해 자동으로 새 토큰을 발급받는 로직은 사용자 경험에 직접적인 영향을 미치는 중요한 부분입니다.

최근 프로젝트에서 토큰 갱신이 제대로 동작하지 않는 문제가 발생했고, 이를 해결하는 과정을 기록합니다.

문제 상황

우리 앱은 JWT 토큰 기반 인증을 사용하며, 액세스 토큰은 1시간 후 만료됩니다. 만료된 액세스 토큰으로 API를 호출하면 서버에서 다음과 같은 응답을 받았습니다:

JWT expired 54085019 milliseconds ago at 2025-02-28T12:55:15.000Z.

이론적으로는 리프레시 토큰을 사용해 /auth/refresh 엔드포인트를 호출하여 새 액세스 토큰을 발급받아야 하지만, 리프레시 요청이 실패하고 다음과 같은 오류가 발생했습니다:

토큰이 존재하지 않거나 형식이 잘못되었습니다.

원인 분석

로그를 분석해본 결과, 두 가지 문제점을 발견했습니다:

  1. 헤더 형식 오류: 리프레시 토큰을 서버에 전송할 때 헤더 이름이 잘못되었거나 형식이 맞지 않았습니다.
  2. 토큰 만료 처리 로직 미흡: 401 Unauthorized 응답을 토큰 만료로 처리하지 않고 있었습니다.

해결 방법

1. 올바른 헤더 형식 사용

스웨거 문서를 통해 /auth/refresh 엔드포인트가 Refresh-Token 헤더를 사용한다는 것을 확인했습니다. 기존에는 Authorization 헤더나 다른 형식을 사용하고 있었습니다.

// 잘못된 방식
const refreshResponse = await refreshAxios.post("/auth/refresh", null, {
  headers: {
    Authorization: `Bearer ${refreshToken}`,
  },
});

// 올바른 방식
const refreshResponse = await refreshAxios.post("/auth/refresh", null, {
  headers: {
    "Refresh-Token": refreshToken,
  },
});

2. 401 응답도 토큰 갱신 대상으로 처리

기존 코드는 500 에러와 JWT expired 메시지가 포함된 경우에만 토큰 갱신을 시도했습니다. 그러나 서버 정책에 따라 401 상태 코드로 응답하는 경우도 있어, 이를 처리하도록 수정했습니다.

if (
  (error.response?.status === 500 &&
    error.response?.data?.message?.includes("JWT expired")) ||
  error.response?.status === 401 // 401 Unauthorized도 처리
) {
  console.log("🔄 토큰 만료 감지: 토큰 갱신 시도...");
  // 토큰 갱신 로직...
}

3. 무한 루프 방지

토큰 갱신이 실패했을 때 계속해서 재시도하는 문제를 방지하기 위해, 재시도 횟수를 제한했습니다.

// 한 번만 재시도
if (originalRequest._retry) {
  console.log("⚠️ 이미 재시도한 요청입니다. 로그아웃 처리합니다.");
  await handleLogout();
  return Promise.reject(error);
}
originalRequest._retry = true;

4. 로그아웃 처리 개선

토큰 갱신이 실패했을 때 로그아웃 처리를 강화하여 모든 관련 데이터를 정리하도록 했습니다.

// 로그아웃 처리 함수로 분리
const handleLogout = async () => {
  console.log("🚪 로그아웃 처리 중...");
  await AsyncStorage.multiRemove([
    "accessToken",
    "refreshToken",
    "isLoggedIn",
    "pushPermissionShown",
    "notificationSettings"
  ]);

  navigationRef.current?.dispatch(
    CommonActions.reset({
      index: 0,
      routes: [{ name: "Login" }],
    })
  );

  Alert.alert("세션 만료", "로그인이 만료되었습니다. 다시 로그인해주세요.");
};

최종 구현 코드

아래는 수정된 전체 axios 인터셉터 코드입니다:

import axios from "axios";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { navigate, navigationRef } from "../navigation/NavigationService";
import { Alert } from "react-native";
import { CommonActions } from "@react-navigation/native";

const axiosInstance = axios.create({
  baseURL: "http://13.125.58.70:8080",
  timeout: 5000,
  headers: {
    "Content-Type": "application/json",
  },
});

const refreshAxios = axios.create({
  baseURL: "http://13.125.58.70:8080",
  timeout: 5000,
  headers: {
    "Content-Type": "application/json",
  },
});

// 모든 요청에 디버깅 추가
axiosInstance.interceptors.request.use(
  async (config) => {
    console.log("🚀 요청 시작:", config.method?.toUpperCase(), config.url);
    console.log("📦 요청 데이터:", config.data);
    console.log("🔧 요청 헤더:", config.headers);

    if (
      config.url === "/auth/login" ||
      config.url === "/auth/signup" ||
      config.url === "/auth/refresh"
    ) {
      return config;
    }

    const token = await AsyncStorage.getItem("accessToken");
    if (!token) {
      console.warn("⚠️ 토큰 없음: 로그인 화면으로 이동합니다.");
      navigate("Login");
    } else {
      console.log("🔑 인증 토큰 사용:", token.substring(0, 15) + "...");
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    console.error("❌ 요청 오류:", error.message);
    return Promise.reject(error);
  }
);

// 모든 응답에 디버깅 추가
axiosInstance.interceptors.response.use(
  (response) => {
    console.log("✅ 응답 성공:", response.status);
    console.log("📄 응답 데이터:", response.data);
    return response;
  },
  async (error) => {
    console.error("❌ 응답 오류:", {
      status: error.response?.status,
      url: error.config?.url,
      method: error.config?.method?.toUpperCase(),
      data: error.response?.data,
      message: error.message,
    });

    const originalRequest = error.config;

    if (
      (error.response?.status === 500 &&
        error.response?.data?.message?.includes("JWT expired")) ||
      error.response?.status === 401 // 401 Unauthorized도 처리
    ) {
      console.log("🔄 토큰 만료 감지: 토큰 갱신 시도...");
      // 한 번만 재시도
      if (originalRequest._retry) {
        console.log("⚠️ 이미 재시도한 요청입니다. 로그아웃 처리합니다.");
        await handleLogout();
        return Promise.reject(error);
      }
      
      originalRequest._retry = true;

      try {
        const refreshToken = await AsyncStorage.getItem("refreshToken");
        if (!refreshToken) {
          console.error("❌ 리프레시 토큰 없음");
          await handleLogout();
          return Promise.reject(error);
        }
        
        console.log("🔑 리프레시 토큰:", refreshToken.substring(0, 10) + "...");

        // 서버 API 명세에 맞게 요청 형식 수정
        // Refresh-Token 헤더 사용 (스웨거 예제에 맞춤)
        const refreshResponse = await refreshAxios.post("/auth/refresh", null, {
          headers: {
            "Refresh-Token": refreshToken,
          },
        });

        console.log("✅ 토큰 갱신 성공:", {
          newTokenLength: refreshResponse.data.accessToken?.length,
          refreshTokenLength: refreshResponse.data.refreshToken?.length,
        });

        const {
          accessToken,
          refreshToken: newRefreshToken,
        } = refreshResponse.data;
        
        // 새 토큰 저장
        await AsyncStorage.multiSet([
          ["accessToken", accessToken],
          ["refreshToken", newRefreshToken],
        ]);
        console.log("💾 새 토큰 저장됨");

        // 원래 요청 재시도
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        console.log("🔄 원래 요청 재시도:", originalRequest.url);
        return axiosInstance(originalRequest);
      } catch (error) {
        console.error("❌ 토큰 갱신 실패:");
        if (axios.isAxiosError(error)) {
          console.error("  - 상태:", error.response?.status);
          console.error("  - 데이터:", error.response?.data);
          console.error("  - 메시지:", error.message);
        } else {
          console.error("  - 일반 오류:", error);
        }

        await handleLogout();
        return Promise.reject(error);
      }
    }

    return Promise.reject(error);
  }
);

// 로그아웃 처리 함수로 분리
const handleLogout = async () => {
  console.log("🚪 로그아웃 처리 중...");
  await AsyncStorage.multiRemove([
    "accessToken",
    "refreshToken",
    "isLoggedIn",
    "pushPermissionShown",
    "notificationSettings"
  ]);

  navigationRef.current?.dispatch(
    CommonActions.reset({
      index: 0,
      routes: [{ name: "Login" }],
    })
  );

  Alert.alert("세션 만료", "로그인이 만료되었습니다. 다시 로그인해주세요.");
};

// 리프레시 인스턴스에도 로깅 추가
refreshAxios.interceptors.request.use(
  (config) => {
    console.log("🔄 리프레시 요청:", config.url);
    console.log("🔧 리프레시 헤더:", config.headers);
    return config;
  },
  (error) => {
    console.error("❌ 리프레시 요청 오류:", error.message);
    return Promise.reject(error);
  }
);

refreshAxios.interceptors.response.use(
  (response) => {
    console.log("✅ 리프레시 응답 성공:", response.status);
    return response;
  },
  (error) => {
    console.error("❌ 리프레시 응답 오류:", {
      status: error.response?.status,
      data: error.response?.data,
      message: error.message,
    });
    return Promise.reject(error);
  }
);

export default axiosInstance;

테스트 방법

토큰 갱신 로직은 액세스 토큰이 만료된 후에만 테스트할 수 있어 불편합니다.
하지만 실제 토큰 만료를 기다리지 않고도 다음과 같은 방법으로 테스트할 수 있습니다:

  1. 디버그 버튼 추가: 특정 화면에 개발 모드에서만 보이는 버튼을 추가하여 토큰을 강제로 만료시키는 방법
  2. 인터셉터 수정: 개발 환경에서는 특정 API 요청에 대해 항상 만료된 토큰을 사용하도록 설정

이러한 방법을 사용하면 1시간 동안 기다릴 필요 없이 토큰 갱신 로직을 즉시 테스트할 수 있습니다.

결론

토큰 갱신 로직은 모바일 앱에서 사용자 인증을 유지하는 중요한 부분입니다. 올바른 헤더 형식을 사용하고, 서버의 다양한 응답 형식을 처리하며, 무한 루프를 방지하는 로직을 구현하는 것이 중요합니다.

또한, 디버깅을 위한 로깅을 풍부하게 추가하면 문제 발생 시 원인을 파악하기 쉽습니다.

이번 경험을 통해 API 통신 시 서버와 클라이언트 간의 정확한 인터페이스 정의의 중요성을 다시 한번 느꼈습니다. 스웨거 같은 API 문서화 도구는 이러한 오해를 줄이는 데 큰 도움이 됩니다.

profile
궁금한 것, 했던 것, 시행착오 그리고 기억하고 싶은 것들을 기록합니다.

0개의 댓글