Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b1c3e3d
chore: swagger-typescript-api 패키지 설치 (#17)
odukong Jan 5, 2026
2b26a45
chore: cross-var, doten-cli 패키지 설치 (#17)
odukong Jan 5, 2026
beff28e
chore: swagger-typescript-api 실행 커멘드 추가 (#17)
odukong Jan 5, 2026
041ae11
chore: swagger-typescript-api 템플릿 일부 추출 및 부분 수정 (#17)
odukong Jan 5, 2026
a5b7c96
chore: swagger-typescript-api 커스텀 템플릿 적용 및 script 수정 (#17)
odukong Jan 5, 2026
0a14386
fix: 템플릿 파일(ejs) eslint, prettier 검사 제외 (#17)
odukong Jan 5, 2026
a6a4e48
chore: axios 패키지 설치 (#17)
odukong Jan 5, 2026
e58c220
fix: request함수가 Promise타입을 반환하도록 수정 (#17)
odukong Jan 5, 2026
3ee6729
feat: axiosInstance 공통 로직 구현 (#17)
odukong Jan 5, 2026
f0d0595
feat: 에러 핸들링 로직(error-handler.ts) 구현 (#17)
odukong Jan 5, 2026
ec2d168
fix: axios-instance 파일명 컨벤션대로 수정 (#17)
odukong Jan 5, 2026
c5776c8
fix: accessToken이 존재하지 않는다면 null 반환 (#17)
odukong Jan 8, 2026
64c41f7
refactor: 서버정의에러 검사에 대한 유연한 기준 적용 (#17)
odukong Jan 8, 2026
ceb4899
refactor: authorization 첫 글자 대문자로 수정 (#17)
odukong Jan 8, 2026
e3e6396
Merge remote-tracking branch 'origin/dev' into init/#17/api-initial-s…
odukong Jan 8, 2026
afa8493
fix: swager-ts-api 옵션 수정 (#17)
odukong Jan 9, 2026
df79bed
fix: 서버 에러 타입 형식으로 수정 (#17)
odukong Jan 9, 2026
b3e2e08
rename: 에러 타입(error-response)을 명시적으로 지정 (#17)
odukong Jan 9, 2026
4615c18
rename: 서버정의에러 판별 함수 리네이밍 (#17)
odukong Jan 9, 2026
e250385
Merge remote-tracking branch 'origin/dev' into init/#17/api-initial-s…
odukong Jan 9, 2026
b68840f
update: query-client option 및 공통 에러 핸들러 수정 (#17)
odukong Jan 9, 2026
cd057ed
fix: 화살표 함수 변경 및 provider 진입점 통일 (#17)
odukong Jan 9, 2026
ab7973d
update: 토큰 접근 로직 libs/auth/token-storage로 수정 (#17)
odukong Jan 9, 2026
7edd120
comment: 주석 수정 (#17)
odukong Jan 10, 2026
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
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@ package-lock.json
# OS
.DS_Store
Thumbs.db

# swagger-typescript-api templates
src/shared/api/generate/templates/**
*.ejs

README.md
11 changes: 10 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@ import eslintConfigPrettier from "eslint-config-prettier";
import react from "eslint-plugin-react";

export default defineConfig([
{ ignores: ["dist", "node_modules", "build", "*.config.js", "*.config.ts"] },
{
ignores: [
"dist",
"node_modules",
"build",
"*.config.js",
"*.config.ts",
"**/*.ejs",
],
},

importPlugin.flatConfigs.recommended,
importPlugin.flatConfigs.typescript,
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"preview": "vite preview",
"prepare": "husky",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"gen:swagger-ts-api": "dotenv -f .env cross-var -- swagger-typescript-api generate -p %SWAGGER_URL% -o ./src/shared/api/generate -n http-client.ts --extract-request-body --extract-response-body --axios -t ./src/shared/api/generate/templates --module-name-index 2"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": [
Expand All @@ -22,6 +23,7 @@
]
},
"dependencies": {
"axios": "^1.13.2",
"@tanstack/react-query": "^5.90.16",
"@vanilla-extract/css": "^1.18.0",
"@vanilla-extract/vite-plugin": "^5.1.4",
Expand All @@ -44,6 +46,8 @@
"@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0",
"@vitejs/plugin-react-swc": "^4.2.2",
"cross-var": "^1.1.0",
"dotenv-cli": "^11.0.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
Expand All @@ -57,7 +61,6 @@
"lint-staged": "^16.2.7",
"storybook": "^10.1.11",
"prettier": "3.7.4",
"storybook": "^10.1.11",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
Expand Down
1,386 changes: 1,292 additions & 94 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

7 changes: 1 addition & 6 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import { RouterProvider } from "react-router-dom";

import ThemeProvider from "./providers";
import { router } from "./routes/app-router";

const App = () => {
return (
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
);
return <RouterProvider router={router} />;
};

export default App;
2 changes: 1 addition & 1 deletion src/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import App from "@app/App";
import AppProviders from "@app/providers";
import { AppProviders } from "@app/providers";

import "@app/styles/global.css";

Expand Down
1 change: 0 additions & 1 deletion src/app/providers/index.ts

This file was deleted.

25 changes: 20 additions & 5 deletions src/app/providers/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import type { PropsWithChildren } from "react";
import { QueryProvider } from "./query-provider";
import ThemeProvider from "./theme-provider";

import QueryProvider from "./query-provider";
import type { ReactNode } from "react";

export default function AppProviders({ children }: PropsWithChildren) {
return <QueryProvider>{children}</QueryProvider>;
}
export const AppProviders = ({
theme,
className,
children,
}: {
children: ReactNode;
theme?: string;
className?: string;
}) => {
return (
<QueryProvider>
<ThemeProvider theme={theme} className={className}>
{children}
</ThemeProvider>
</QueryProvider>
);
};
9 changes: 5 additions & 4 deletions src/app/providers/query-provider.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 화살표 함수로 변경 부탁드립니다 !! 🙇‍♀️

Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type { PropsWithChildren } from "react";
import { Suspense, lazy } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { Suspense, lazy } from "react";

import { queryClient } from "@/shared/api";

import type { PropsWithChildren } from "react";

const ReactQueryDevtools = import.meta.env.DEV
? lazy(async () => {
const mod = await import("@tanstack/react-query-devtools");
return { default: mod.ReactQueryDevtools };
})
: null;

export default function QueryProvider({ children }: PropsWithChildren) {
export const QueryProvider = ({ children }: PropsWithChildren) => {
return (
<QueryClientProvider client={queryClient}>
{children}
Expand All @@ -23,4 +24,4 @@ export default function QueryProvider({ children }: PropsWithChildren) {
) : null}
</QueryClientProvider>
);
}
};
2 changes: 1 addition & 1 deletion src/app/store/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { useAuthStore } from '@shared/model/auth';
export { useAuthStore } from "@shared/model/auth";
12 changes: 6 additions & 6 deletions src/pages/bookmark/bookmark-page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const BookmarkPage = () => {
return (
<div>
<h1>Welcome to the Bookmark Page</h1>
</div>
);
return (
<div>
<h1>Welcome to the Bookmark Page</h1>
</div>
);
};

export { BookmarkPage };
export { BookmarkPage };
2 changes: 1 addition & 1 deletion src/pages/experience-matching/experience-matching-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ const ExperienceMatchingPage = () => {
);
};

export { ExperienceMatchingPage };
export { ExperienceMatchingPage };
12 changes: 6 additions & 6 deletions src/pages/login/login-page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const LoginPage = () => {
return (
<div>
<h1>Welcome to the Login Page</h1>
</div>
);
return (
<div>
<h1>Welcome to the Login Page</h1>
</div>
);
};

export { LoginPage };
export { LoginPage };
12 changes: 6 additions & 6 deletions src/pages/my-page/my-page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const MyPage = () => {
return (
<div>
<h1>Welcome to the My Page</h1>
</div>
);
return (
<div>
<h1>Welcome to the My Page</h1>
</div>
);
};

export { MyPage };
export { MyPage };
12 changes: 6 additions & 6 deletions src/pages/onboarding/onboarding-page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const OnboardingPage = () => {
return (
<div>
<h1>Welcome to the Onboarding Page</h1>
</div>
);
return (
<div>
<h1>Welcome to the Onboarding Page</h1>
</div>
);
};

export { OnboardingPage };
export { OnboardingPage };
12 changes: 6 additions & 6 deletions src/pages/register/register-page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const RegisterPage = () => {
return (
<div>
<h1>Welcome to the Register Page</h1>
</div>
);
return (
<div>
<h1>Welcome to the Register Page</h1>
</div>
);
};

export { RegisterPage };
export { RegisterPage };
62 changes: 62 additions & 0 deletions src/shared/api/axios-instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import axios from "axios";

const SERVER_URL = import.meta.env.VITE_API_URL;

// TODO: 현재는 axios로 instance를 생성하지만, http-client.ts Api 클래스로 instance를 생성하도록 수정할 예정입니다.
export const api = axios.create({
baseURL: `${SERVER_URL}`,
headers: {
"Content-Type": "application/json",
},
});

api.interceptors.request.use(
async (config) => {
// TODO: 액세스 토큰 가져오는 로직은 utils/token로 대체 예정
const accessToken = localStorage.getItem("access_token") || null;
if (accessToken) {
config.headers["Authorization"] = `Bearer ${accessToken}`;
} else {
// TODO: 로그인이 필요하다는 Toast Message
console.error("액세스 토큰이 존재하지 않습니다");
window.location.replace("/login");
throw new Error("액세스 토큰이 존재하지 않습니다");
}
return config;
},
(error) => Promise.reject(error)
);

api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;

if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// TODO: refreshToken 관련은 차후 순위로 개발할 예정
// 액세스 토큰 재발급 API
const { data } = await axios.post("토큰 재발급 path", null, {
withCredentials: true, // (토큰을 cookie에 넣었을 경우 사용됨)
});

// 새로 발급 받은 액세스 토큰 저장
const newAccessToken = data.accessToken;
localStorage.setItem("access_token", newAccessToken);

originalRequest.headers["Authorization"] = `Bearer ${newAccessToken}`;
return api(originalRequest); // 이전 요청 재시도
} catch (refreshError) {
// TODO: 재발급 실패시, 로그인 화면으로 이동
console.error("리프레쉬 토큰 요청에 실패했습니다.", error);
localStorage.removeItem("access_token"); // TODO: 토큰, 유저 정보 모두 삭제
window.location.replace("/login");

return Promise.reject(refreshError);
}
}

return Promise.reject(error);
}
);
55 changes: 55 additions & 0 deletions src/shared/api/error-handler.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 코드에서 type=="query"일 때에 대한 처리가 별도로 되어 있지 않은데, 혹시 이 부분에 대한 설명이 comment로 남겨주신

useQuery와 useMutation 요청에 대해 분기 처리하여 query(조회)는 에러 페이지(Suspense)로 이동하고, mutation(수정)은 토스트만 띄우도록 로직을 분리했습니다. (임시 처리로 추후 에러 처리에 대해서는 변경될 수 있습니다.) 단, useSuspenseQuery는 자체적으로 에러를 ErrorBoundary로 넘기게 됩니다. 결과적으로 useQuery 에러 처리 과정은 다르지만 에러 처리 결과는 동일합니다. 서버 정의 에러를 판별하는 isAxiosErrorCustomError 함수명이 의미가 좀 더 명확했으면 좋겠다는 의견이 있어 isValidCustomError로 네이밍을 변경하였습니다.

이 부분일까요? 여러 번 읽어보았는데 "query"일 때 에러 페이지로 이동하는 흐름이 어떤 식으로 이루어지는지 잘 이해가 되지 않아서 호옥시 조금 더 자세히 설명해주실 수 있을지 여쭤봅니다. !! 😅

Copy link
Collaborator Author

@odukong odukong Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleApiError에서는 query, mutation에 대한 에러가 분기되어 처리된다는 것을 명시적으로 알려주기 위해
query 동작 시 에러가 발생하였을 때도, handleApiError에 "query" 인자를 넘겨주고 있긴 하지만,
defaultOptions에서 throwOnError 옵션을 이미 true로 설정하여 query에서 에러가 발생하였을 땐,
query 자체 동작으로 ErrorBoundary로 에러를 던져 처리할 수 있는 흐름이 됩니다!

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
     // 생략 // 
      throwOnError: true,  // throwOnError 옵션을 true 설정 
    },
    mutations: {
      throwOnError: false,
    },
  },

  queryCache: new QueryCache({
    onError: (error: unknown) => {
      handleApiError(error, "query");
    },
  }),

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하~ 정확히 이해했습니다! mutation error의 경우는 사용자 경험 개선을 위해 alert 메시지로 처리해주기로 하였기 때문에,throwOnError 옵션을 false로 설정하고 handleApiError에서 처리를 해주는 방식인 거군요! 친절한 설명 감사합니다 👼👼

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { isAxiosError } from "axios";

import { ERROR_MESSAGE } from "@/shared/api/types/error-response";

import type {
ApiErrorResponse,
ApiErrorCode,
RequiredWith,
} from "@/shared/api/types/error-response";
import type { AxiosError } from "axios";

/**
* 서버 정의 에러(Business Error) 타입 가드
* - HTTP 상태 코드는 정상이지만, 비즈니스 로직상 실패한 경우를 식별합니다.
* - 응답 본문(data)에 'prefix' 필드가 있는지 확인합니다. (추후 TODO)
*/
Comment on lines 12 to 16
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석에 “HTTP 상태 코드는 정상이지만 비즈니스 로직상 실패한 경우”라고 적혀 있는데, 현재 구현은 status와 무관하게 prefix가 있으면 커스텀 에러로 분류되는 구조라서 설명과 실제 동작이 살짝 다른 것 같아서요!

지금 당장 기능에는 영향 없지만, 나중에 다른 사람이 호옥시 코드를 읽을 때 “200 OK인데 body로 내려오는 에러만 처리하나?”라고 오해할 수 있어서 주석을 “서버가 정의한 error payload 식별” 쪽으로 수정하거나 실제로 status 조건을 추가하면 더 좋을 것 같습니다 😽

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

호오 그렇네요!!!!!! 세심막내의 코리다..d=(^o^)=b
이미 받아온 error객체에 대해서 판별하고 있는 구조이기 때문에 status 조건은 추가하기보다, 말씀해주신 코멘트대로 주석 수정해서 반영해두었습니다 .ᐟ

export const isValidCustomError = (
error: unknown
): error is RequiredWith<AxiosError<ApiErrorResponse>, "response"> => {
return (
isAxiosError(error) &&
!!error.response &&
!!error.response.data &&
typeof error.response.data.prefix === "string"
);
};

// 요청 종류 구분 (UI 피드백 방식을 결정하기 위함)
type ErrorType = "query" | "mutation";

/**
* API 공통 에러 핸들러
* @param error 발생한 에러 객체
* @param type 요청 종류 ('query': 조회 -> 에러페이지 / 'mutation': 수정 -> 토스트)
*/
export const handleApiError = (error: unknown, type: ErrorType) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type 파라미터를 query/mutation으로 설정해주셨는데, mutation을 사용하는 post/put/delete를 모두 아우를 수 있어 기존 회의에서 말씀주셨던 get/post보다 좋은 선택인 것 같습니다! 😙

if (isValidCustomError(error)) {
const { prefix } = error.response.data;
const message =
ERROR_MESSAGE[prefix as ApiErrorCode] ??
"알 수 없는 에러가 발생했습니다.";

console.error(message);
if (type == "mutation") {
// TODO: toast 메시지 handleApiError 내부처리 or message return 후 컴포넌트에서 처리
return message;
}
} else if (isAxiosError(error)) {
console.error("서버/네트워크 오류입니다");
// TODO: 에러 페이지 필요
} else {
console.error("알 수 없는 에러 발생");
// TODO: 에러 페이지 필요
}
};
Loading