Skip to content
Merged

. #28

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions 401-fix-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# 401 ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ฐ ๋กœ๊ทธ์ธ ๋ผ์šฐํŒ… ์ˆ˜์ • ํ”Œ๋žœ

## Context

์ฃผ๋‹ˆ์–ด๊ฐ€ ์ž‘์„ฑํ•œ ์ฝ”๋“œ์—์„œ 401 ๋ฐœ์ƒ ์‹œ `/login` ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๋กœ์ง์— ์—ฌ๋Ÿฌ ๋ฌธ์ œ๊ฐ€ ์žˆ์Œ.
ํ•ต์‹ฌ ์›์ธ์€ ์„ธ ๊ฐ€์ง€:
โ‘  `instance.ts`์˜ 401 ์ฒ˜๋ฆฌ ๋กœ์ง ์ž์ฒด ๋ฒ„๊ทธ
โ‘ก interceptor์—์„œ `window.location.href` ์‚ฌ์šฉ์œผ๋กœ SPA ๊ตฌ์กฐ ํŒŒ๊ดด
โ‘ข `QueryClient`๋ฅผ Singleton์œผ๋กœ ์“ฐ์ง€ ์•Š์•„ ์„ค์ •๊ณผ ์บ์‹œ๊ฐ€ ๋ ˆ์ด์•„์›ƒ๋งˆ๋‹ค ๋ถ„๋ฆฌ๋จ

---

## ๋ฌธ์ œ ๋ชฉ๋ก

### ๐Ÿ”ด P0 โ€” `instance.ts` 401 ์ฒ˜๋ฆฌ ๋กœ์ง ๋ฒ„๊ทธ 4๊ฐœ

**ํŒŒ์ผ**: `src/api/instance.ts`

**๋ฒ„๊ทธ 1** โ€” `isRefreshRequest || alreadyRetried`์ธ ๊ฒฝ์šฐ ๊ทธ๋ƒฅ 401 response๋ฅผ return

React Query๋Š” ์ด๊ฑธ ์„ฑ๊ณต์œผ๋กœ ์ธ์‹ํ•˜๊ณ , ์‚ฌ์šฉ์ž๋Š” ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜์ง€ ์•Š์€ ์ฑ„ ์—๋Ÿฌ ์ƒํƒœ์— ๋ฐฉ์น˜๋จ.

```ts
if (isRefreshRequest || alreadyRetried) {
return response; // โ† ํ† ํฐ ์ œ๊ฑฐ + ๋กœ๊ทธ์ธ ์ด๋™์ด ์—†์Œ
}
```

**๋ฒ„๊ทธ 2** โ€” `newAccessToken`์ด ์—†์–ด๋„ ๊ทธ๋ƒฅ 401 response๋ฅผ return

```ts
if (!newAccessToken) {
return response; // โ† ๋™์ผํ•˜๊ฒŒ ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ˆ„๋ฝ
}
```

**๋ฒ„๊ทธ 3** โ€” refreshToken ์—†์„ ๋•Œ๋Š” `/login`, catch์—์„œ๋Š” `/login?error=true` โ†’ ๋ถˆ์ผ์น˜
๋‘ ๊ณณ ๋ชจ๋‘ `/login?error=true`๋กœ ํ†ต์ผ ํ•„์š”

**๋ฒ„๊ทธ 4** โ€” `console.log("a")`, `console.log("b")`, `console.log("c")` ๋ฏธ์ œ๊ฑฐ

---

### ๐Ÿ”ด P1 โ€” `window.location.href` ์‚ฌ์šฉ (SPA ๊ตฌ์กฐ ํŒŒ๊ดด)

**ํŒŒ์ผ**: `src/api/instance.ts`, `src/api/axios.ts`

๋‘ ํŒŒ์ผ ๋ชจ๋‘ `window.location.href = "/login?error=true"` ์‚ฌ์šฉ.
์ด๋Š” **hard reload**๋ฅผ ๋ฐœ์ƒ์‹œ์ผœ React ์ƒํƒœ, QueryClient ์บ์‹œ, ๋„ค๋น„๊ฒŒ์ด์…˜ ์Šคํƒ์„ ์ „๋ถ€ ์ดˆ๊ธฐํ™”ํ•จ.

**์›์ธ**: interceptor๋Š” React ์ปดํฌ๋„ŒํŠธ ๋ฐ–์ด๋ผ `useNavigate()` ์‚ฌ์šฉ ๋ถˆ๊ฐ€.

**ํ•ด๊ฒฐ**: `BrowserRouter` โ†’ `createBrowserRouter` ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜

- ๋ผ์šฐํ„ฐ ๊ฐ์ฒด๋ฅผ ๋ชจ๋“ˆ๋กœ ๋ถ„๋ฆฌ export
- interceptor์—์„œ `router.navigate('/login?error=true', { replace: true })` ํ˜ธ์ถœ
- SPA ๋ฐฉ์‹์œผ๋กœ ์ฒ˜๋ฆฌ๋˜์–ด hard reload ์—†์Œ

```ts
// src/router.ts
import { createBrowserRouter } from 'react-router-dom';

export const router = createBrowserRouter([...]);

// src/api/instance.ts
import { router } from '../router';
// ...
router.navigate('/login?error=true', { replace: true });
```

---

### ๐ŸŸก P2 โ€” QueryClient Singleton ๋ฏธ์ ์šฉ

**ํŒŒ์ผ**: `src/App.tsx` (line 11), `src/layouts/FullWidthLayout.tsx` (line 8)

๋‘ ํŒŒ์ผ ๋ชจ๋‘ `new QueryClient()`๋กœ ์ƒˆ ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑ.

- `src/api/queryClient.ts`์— ์ •์˜ํ•œ `retry: 1`, `staleTime: 30s` ์„ค์ •์ด ์•„์˜ˆ ์ ์šฉ๋˜์ง€ ์•Š์Œ
- `App` ๋ ˆ์ด์•„์›ƒ๊ณผ `FullWidthLayout` ์‚ฌ์ด์— ์บ์‹œ ๊ณต์œ  ์•ˆ๋จ

```ts
// ์ˆ˜์ • ์ „ (๊ฐ ํŒŒ์ผ์—์„œ ๋”ฐ๋กœ ์ƒ์„ฑ)
const queryClient = new QueryClient();

// ์ˆ˜์ • ํ›„
import { queryClient } from '../api/queryClient';
```

---

### ๐ŸŸก P3 โ€” ProtectedRoute ๋ถ€์žฌ

**ํŒŒ์ผ**: `src/routes.tsx`

`/`, `/searchpage`, `/writepage` ๋“ฑ ์ธ์ฆ์ด ํ•„์š”ํ•œ ํŽ˜์ด์ง€์— ํ† ํฐ ์ฒดํฌ๊ฐ€ ์—†์Œ.
ํ† ํฐ ์—†์ด ์ง์ ‘ ์ ‘๊ทผํ•ด๋„ `/login`์œผ๋กœ ์ด๋™ํ•˜์ง€ ์•Š๊ณ  API ํ˜ธ์ถœ๋งŒ ์‹คํŒจํ•จ.

**ํ•ด๊ฒฐ**: `ProtectedRoute` ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€

```tsx
// src/components/ProtectedRoute.tsx
const ProtectedRoute = () => {
const token = localStorage.getItem("accessToken");
if (!token) return <Navigate to="/login" replace />;
return <Outlet />;
};
```

---

## ์ˆ˜์ • ์ˆœ์„œ

| ์ˆœ์„œ | ํŒŒ์ผ | ์ž‘์—… |
|------|------|------|
| 1 | `src/router.ts` | ์‹ ๊ทœ ์ƒ์„ฑ โ€” ๋ผ์šฐํŠธ ๋ฐฐ์—ด + `createBrowserRouter` export |
| 2 | `src/main.tsx` | `BrowserRouter` + `<Router />` โ†’ `RouterProvider` |
| 3 | `src/api/instance.ts` | ๋ฒ„๊ทธ 4๊ฐœ ์ˆ˜์ • + `router.navigate()` ์ ์šฉ |
| 4 | `src/api/axios.ts` | `window.location.href` โ†’ `router.navigate()` ์ ์šฉ |
| 5 | `src/App.tsx` | QueryClient singleton ์ ์šฉ |
| 6 | `src/layouts/FullWidthLayout.tsx` | QueryClient singleton ์ ์šฉ |
| 7 | `src/components/ProtectedRoute.tsx` | ์‹ ๊ทœ ์ƒ์„ฑ โ€” ์ธ์ฆ ๊ฐ€๋“œ |
| 8 | `src/router.ts` | ๋ณดํ˜ธ ๋Œ€์ƒ ๋ผ์šฐํŠธ์— ProtectedRoute ์ ์šฉ |

---

## ์ˆ˜์ • ํ›„ 401 ์ฒ˜๋ฆฌ ํ๋ฆ„

```
API ์š”์ฒญ โ†’ 401 ์‘๋‹ต
โ”œโ”€ refreshToken ์—†์Œ
โ”‚ โ†’ ํ† ํฐ ์ œ๊ฑฐ โ†’ router.navigate('/login?error=true', { replace: true })
โ”œโ”€ refresh ์š”์ฒญ์ด๊ฑฐ๋‚˜ ์ด๋ฏธ ์žฌ์‹œ๋„
โ”‚ โ†’ ํ† ํฐ ์ œ๊ฑฐ โ†’ router.navigate('/login?error=true', { replace: true })
โ””โ”€ refreshToken ์žˆ์Œ โ†’ getFreshAccessToken()
โ”œโ”€ ์„ฑ๊ณต โ†’ ์ƒˆ ํ† ํฐ์œผ๋กœ ์›๋ž˜ ์š”์ฒญ ์žฌ์‹œ๋„
โ””โ”€ ์‹คํŒจ (newAccessToken ์—†์Œ or throw)
โ†’ ํ† ํฐ ์ œ๊ฑฐ โ†’ router.navigate('/login?error=true', { replace: true })
```

๋ชจ๋“  ๋ถ„๊ธฐ์—์„œ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ์ด๋™ ๋ณด์žฅ, hard reload ์—†์Œ.

---

## ๊ฒ€์ฆ

1. accessToken ์‚ญ์ œ ํ›„ API ํ˜ธ์ถœ โ†’ `/login?error=true`๋กœ SPA navigate (์ƒˆ๋กœ๊ณ ์นจ ์—†์Œ) ํ™•์ธ
2. accessToken ๋งŒ๋ฃŒ, refreshToken ์œ ํšจ โ†’ ์ž๋™ refresh ํ›„ ์› ์š”์ฒญ ์žฌ์‹œ๋„ ํ™•์ธ
3. ๋‘ ํ† ํฐ ๋ชจ๋‘ ๋งŒ๋ฃŒ โ†’ `/login?error=true` ์ด๋™ ํ™•์ธ
4. ํ† ํฐ ์—†์ด `/` ์ง์ ‘ ์ ‘๊ทผ โ†’ `/login` redirect ํ™•์ธ
5. `FeedDetailPage` โ†” `HomePage` ์บ์‹œ ๊ณต์œ  ํ™•์ธ (QueryClient singleton)
4 changes: 1 addition & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import { QueryClientProvider } from "@tanstack/react-query";

import { ThemeProvider } from "styled-components";
import { theme } from "./styles/theme";
import { QueryClient } from "@tanstack/react-query";
import { Outlet } from "react-router-dom";
import GlobalStyle from "./styles/GlobalStyle";

const queryClient = new QueryClient();
import { queryClient } from "./api/queryClient";
function App() {
return (
<QueryClientProvider client={queryClient}>
Expand Down
7 changes: 4 additions & 3 deletions src/api/axios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { refreshAccessToken } from "./auth";
import { router } from "../router";

const instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
Expand Down Expand Up @@ -44,6 +45,8 @@ instance.interceptors.response.use(

const refreshToken = localStorage.getItem("refreshToken");
if (!refreshToken) {
localStorage.removeItem("accessToken");
router.navigate("/login?error=true", { replace: true });
return Promise.reject(error);
}

Expand Down Expand Up @@ -79,9 +82,7 @@ instance.interceptors.response.use(
onRefreshed(undefined);
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
if (window.location.pathname !== "/login") {
window.location.href = "/login?error=true";
}
router.navigate("/login?error=true", { replace: true });
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
Expand Down
24 changes: 16 additions & 8 deletions src/api/instance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ky from "ky";
import { refreshAccessToken } from "./auth";
import { router } from "../router";

let refreshPromise: Promise<string | undefined> | null = null;

Expand All @@ -13,6 +14,12 @@ const getFreshAccessToken = async () => {
return refreshPromise;
};

const redirectToLogin = () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
router.navigate("/login?error=true", { replace: true });
};

const api = ky.create({
prefixUrl: "/api",
hooks: {
Expand All @@ -34,14 +41,21 @@ const api = ky.create({
const alreadyRetried = request.headers.get("x-refresh-retry") === "1";
const hasRefreshToken = !!localStorage.getItem("refreshToken");

if (isRefreshRequest || alreadyRetried || !hasRefreshToken) {
if (!hasRefreshToken) {
redirectToLogin();
return response;
}

if (isRefreshRequest || alreadyRetried) {
redirectToLogin();
return response;
}

try {
const newAccessToken = await getFreshAccessToken();

if (!newAccessToken) {
redirectToLogin();
return response;
}

Expand All @@ -54,13 +68,7 @@ const api = ky.create({
headers,
});
} catch {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");

if (window.location.pathname !== "/login") {
window.location.href = "/login?error=true";
}

redirectToLogin();
return response;
}
},
Expand Down
13 changes: 13 additions & 0 deletions src/components/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Navigate, Outlet } from "react-router-dom";

const ProtectedRoute = () => {
const token = localStorage.getItem("accessToken");

if (!token) {
return <Navigate to="/login" replace />;
}

return <Outlet />;
};

export default ProtectedRoute;
5 changes: 2 additions & 3 deletions src/layouts/FullWidthLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import styled from "styled-components";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "styled-components";
import { theme } from "../styles/theme";
import GlobalStyle from "../styles/GlobalStyle";
import { Outlet } from "react-router-dom";

const queryClient = new QueryClient();
import { queryClient } from "../api/queryClient";

const FullWidthLayout = () => {
return (
Expand Down
9 changes: 3 additions & 6 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./styles/index.css";
// import App from "./App.tsx";
import Router from "./routes.tsx";
import { BrowserRouter } from "react-router-dom";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";

createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<Router />
</BrowserRouter>
<RouterProvider router={router} />
</StrictMode>,
);
45 changes: 45 additions & 0 deletions src/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createBrowserRouter } from "react-router-dom";
import App from "./App";
import HomePage from "./pages/HomePage";
import LandingPage01 from "./pages/LandingPage01";
import LandingPage02 from "./pages/LandingPage02";
import SearchPage from "./pages/SearchPage";
import Test from "./pages/Test";
import ErrorPage from "./pages/ErrorPage";
import WritePage from "./pages/WritePage";
import OAuthRedirect from "./pages/OAuthRedirect";
import FeedDetailPage from "./pages/FeedDetailPage";
import FullWidthLayout from "./layouts/FullWidthLayout";
import ProtectedRoute from "./components/ProtectedRoute";

export const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{
element: <ProtectedRoute />,
children: [
{ index: true, element: <HomePage /> },
{ path: "searchpage", element: <SearchPage /> },
{ path: "writepage", element: <WritePage /> },
],
},
{ path: "login", element: <LandingPage01 /> },
{ path: "landing1", element: <LandingPage01 /> },
{ path: "landing2", element: <LandingPage02 /> },
{ path: "oauth2/redirect", element: <OAuthRedirect /> },
{ path: "test", element: <Test /> },
{ path: "errorpage", element: <ErrorPage /> },
],
},
{
element: <FullWidthLayout />,
children: [
{
element: <ProtectedRoute />,
children: [{ path: "feed/:id", element: <FeedDetailPage /> }],
},
],
},
]);
Loading