From ebf00aaa05d9932ba03254a57bd2e9d4b8cb2950 Mon Sep 17 00:00:00 2001 From: sungwonnoh Date: Sat, 25 Oct 2025 16:40:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20api=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=8A=B8=20=EB=B6=84=EA=B8=B0=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(setting)/index.tsx | 10 ++- app/apis/auth.ts | 8 +-- app/apis/client.ts | 133 ++++++++++++++++++++++++++++++++---- app/services/authService.ts | 9 ++- 4 files changed, 142 insertions(+), 18 deletions(-) diff --git a/app/(setting)/index.tsx b/app/(setting)/index.tsx index a1f5c0f..a60b958 100644 --- a/app/(setting)/index.tsx +++ b/app/(setting)/index.tsx @@ -2,9 +2,17 @@ import SettinglView from "@/components/view/SettingView"; import { Entypo } from "@expo/vector-icons"; import { useRouter } from "expo-router"; import { Pressable, Text } from "react-native"; +import { authService } from "../services/authService"; export default function SettingScreen() { const router = useRouter(); + const handleLogout = async () => { + try { + await authService.logout(); + } catch (error) { + console.error("설정 화면에서 로그아웃 처리 중 에러:", error); + } + }; return ( router.push("/(auth)/login")} + onPress={handleLogout} className="flex-row justify-between border-b border-[#B6B6B6] w-full py-6 px-4 active:bg-[#f0f0f0] active:opacity-80" > 로그아웃 diff --git a/app/apis/auth.ts b/app/apis/auth.ts index 9fb0929..5d38770 100644 --- a/app/apis/auth.ts +++ b/app/apis/auth.ts @@ -8,9 +8,6 @@ export function memberLoginByBody(body: LoginBody) { idToken: body.idToken, }; - console.log("🔍 [API] 로그인 요청 시작:", payload); - console.log("🔍 [API] Base URL:", api.defaults.baseURL); - return api .post("/api/v1/member/login", payload, { headers: { @@ -37,7 +34,10 @@ export function memberLoginByBody(body: LoginBody) { throw error; }); } - +export function memberLogout() { + //로그아웃 + return api.post("/api/v1/member/phone/logout"); +} export function getMemberData() { //본인 정보 조회 return api.get("/api/v1/member"); diff --git a/app/apis/client.ts b/app/apis/client.ts index 3cda0a6..c49285c 100644 --- a/app/apis/client.ts +++ b/app/apis/client.ts @@ -1,28 +1,30 @@ import { - clearTokens /*, getRefreshToken, setTokens*/, + clearTokens, getAccessToken, + getRefreshToken, + setTokens, } from "@/app/services/tokenStore"; import axios from "axios"; +import { router } from "expo-router"; const baseURL = process.env.EXPO_PUBLIC_API_BASE_URL; const api = axios.create({ baseURL, - timeout: 60000, // 타임아웃 60초로 증가 + timeout: 60000, headers: { - //"Content-Type": "application/json", Accept: "application/json", }, }); -// 요청 인터셉터: Bearer 토큰 자동 첨부 +// 요청 인터셉터 (기존과 동일) api.interceptors.request.use(async (config) => { const token = await getAccessToken(); const absoluteUrl = config.baseURL ? new URL(config.url ?? "", config.baseURL).toString() : (config.url ?? ""); if (config.data instanceof FormData) { - delete (config.headers as any)?.["Content-Type"]; // axios가 자동 세팅 + delete (config.headers as any)?.["Content-Type"]; } if (__DEV__) { console.log( @@ -40,7 +42,30 @@ api.interceptors.request.use(async (config) => { return config; }); -// 응답 인터셉터: 401 처리 (필요 시 리프레시 로직 확장 가능) +let isRefreshing = false; +let failedQueue: any[] = []; + +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + +const forceLogout = async () => { + await clearTokens(); + try { + router.replace("/(auth)/login"); + } catch (e) { + console.error("로그인 화면 이동 실패", e); + } +}; + +//응답 인터셉터 api.interceptors.response.use( (res) => { if (__DEV__) { @@ -57,6 +82,7 @@ api.interceptors.response.use( return res; }, async (error) => { + const originalRequest = error.config; const status = error?.response?.status; if (__DEV__) { @@ -71,14 +97,97 @@ api.interceptors.response.use( ); } - // 리프레시 토큰 플로우를 쓰려면 여기서 갱신 후 재시도 로직 추가 + // 401 에러 처리 if (status === 401) { - await clearTokens(); - try { - const { router } = await import("expo-router"); - setTimeout(() => router.replace("/(auth)/login"), 0); - } catch {} + const errorCode = error.response?.data?.error; + + // 재시도 요청이 또 401이면 무한 루프 방지 (기존과 동일) + if (originalRequest._retry) { + console.error( + "토큰 재발급 후 재시도했으나 여전히 401입니다. 로그아웃합니다." + ); + await forceLogout(); + return Promise.reject(error); + } + + switch (errorCode) { + case "TOKEN_EXPIRED": + console.log("토큰 만료. 재발급을 시도합니다."); + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ + resolve: (token: string) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(api(originalRequest)); + }, + reject: (err: any) => { + reject(err); + }, + }); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const refreshToken = await getRefreshToken(); + if (!refreshToken) { + throw new Error("리프레시 토큰이 없습니다."); + } + const { data } = await api.post("/api/v1/member/refresh", null, { + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + }); + const newAccessToken = data.accessToken; + + if (!newAccessToken) { + throw new Error("서버가 새 액세스 토큰을 반환하지 않았습니다."); + } + + await setTokens({ + accessToken: newAccessToken, + refreshToken: refreshToken, // 기존 토큰 유지 + }); + + // api 인스턴스 및 현재 요청 헤더 업데이트 + api.defaults.headers.common["Authorization"] = + `Bearer ${newAccessToken}`; + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + processQueue(null, newAccessToken); // 큐 처리 + return api(originalRequest); // 원래 요청 재시도 + } catch (refreshError) { + console.error("토큰 재발급 실패", refreshError); + processQueue(refreshError, null); + await forceLogout(); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + + // 즉시 로그아웃 처리 (기존과 동일) + case "TOKEN_BLACKLISTED": + case "MISSING_TOKEN": + case "REFRESH_TOKEN_EXPIRED": + case "INVALID_REFRESH_TOKEN": + console.warn(`[${errorCode}] - 즉시 로그아웃합니다.`); + await forceLogout(); + return Promise.reject(error); + + // 그 외 401 (기존과 동일) + default: + console.warn( + `처리되지 않은 401 에러 (${errorCode || "N/A"}). 로그아웃합니다.` + ); + await forceLogout(); + return Promise.reject(error); + } } + + // 401이 아닌 다른 에러 (기존과 동일) return Promise.reject(error); } ); diff --git a/app/services/authService.ts b/app/services/authService.ts index 0e1d109..559abba 100644 --- a/app/services/authService.ts +++ b/app/services/authService.ts @@ -1,4 +1,5 @@ -import { memberLoginByBody } from "@/app/apis/auth"; +import { memberLoginByBody, memberLogout } from "@/app/apis/auth"; +import { router } from "expo-router"; import { LoginResponse } from "../apis/auth.type"; import { clearTokens, setTokens } from "./tokenStore"; @@ -19,6 +20,12 @@ export const authService = { }, async logout() { + try { + await memberLogout(); + } catch (error) { + console.error("로그아웃 중 에러 발생:", error); + } await clearTokens(); + router.replace("/(auth)/login"); }, }; From ed05b295b4177265d2ee31b23353b5bf6e9661c4 Mon Sep 17 00:00:00 2001 From: sungwonnoh Date: Sat, 25 Oct 2025 16:50:29 +0900 Subject: [PATCH 2/4] =?UTF-8?q?modify:=20failedQueue=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/apis/client.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/apis/client.ts b/app/apis/client.ts index c49285c..8ef3c2a 100644 --- a/app/apis/client.ts +++ b/app/apis/client.ts @@ -41,16 +41,21 @@ api.interceptors.request.use(async (config) => { } return config; }); - +interface FailedRequest { + resolve: (token: string) => void; + reject: (error: any) => void; +} let isRefreshing = false; -let failedQueue: any[] = []; +let failedQueue: FailedRequest[] = []; -const processQueue = (error: any, token: string | null = null) => { +const processQueue = (error: any | null, token: string | null) => { failedQueue.forEach((prom) => { if (error) { prom.reject(error); - } else { + } else if (token) { prom.resolve(token); + } else { + prom.reject(new Error("토큰 재발급에 성공했으나 토큰이 없습니다.")); } }); failedQueue = []; From 44ba1775e1c93581ed2d0870a37b1660077cf906 Mon Sep 17 00:00:00 2001 From: sungwonnoh Date: Sat, 25 Oct 2025 17:05:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=8B=A4=ED=81=AC=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 5 +---- app/_layout.tsx | 10 ++-------- constants/Colors.ts | 2 +- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index b599c47..44103fc 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -5,16 +5,13 @@ import { HapticTab } from "@/components/HapticTab"; import { IconSymbol } from "@/components/ui/IconSymbol"; import TabBarBackground from "@/components/ui/TabBarBackground"; import { Colors } from "@/constants/Colors"; -import { useColorScheme } from "@/hooks/useColorScheme"; import { FontAwesome, MaterialCommunityIcons } from "@expo/vector-icons"; export default function TabLayout() { - const colorScheme = useColorScheme(); - return ( + Date: Sun, 2 Nov 2025 13:40:50 +0900 Subject: [PATCH 4/4] =?UTF-8?q?modify:=20refreshtoken=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/caches/deviceStreaming.xml | 88 ++++++++++++++++++++++++++++++++ app/apis/client.ts | 34 ++++++++---- app/services/authService.ts | 5 +- app/services/tokenStore.ts | 15 +++++- 4 files changed, 128 insertions(+), 14 deletions(-) diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml index 366a9d1..20b5c6a 100644 --- a/.idea/caches/deviceStreaming.xml +++ b/.idea/caches/deviceStreaming.xml @@ -124,6 +124,17 @@