Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
6 changes: 5 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ package-lock.json

# OS
.DS_Store
Thumbs.db
Thumbs.db

# swagger-typescript-api templates
src/shared/api/generate/templates/**
*.ejs
11 changes: 10 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,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: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"gen:swagger-ts-api": "dotenv -e .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 --type-suffix DTO --axios -t ./src/shared/api/generate/templates"
},
"dependencies": {
"axios": "^1.13.2",
"eslint-import-resolver-typescript": "^4.4.4",
"react": "^19.2.0",
"react-dom": "^19.2.0"
Expand All @@ -22,6 +24,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 @@ -31,6 +35,7 @@
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "3.7.4",
"swagger-typescript-api": "^13.2.16",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
Expand Down
1,839 changes: 1,778 additions & 61 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

60 changes: 60 additions & 0 deletions src/shared/api/axios-instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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") || "temp";
Copy link
Collaborator

@u-zzn u-zzn Jan 7, 2026

Choose a reason for hiding this comment

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

토큰을 interceptor에서 주입하는 흐름 너무 좋은 것 같습니다 👍 다만 이전까지 합의되었던 내용은 토큰을 shared/lib/auth/token.ts(storage 유틸)에서만 다루는 방향인걸로 기억해서, 추후 실제 적용 단계에서는 여기의 localStorage.getItem을 tokenStorage 조회로 통일하면 팀 컨벤션과 더 잘 맞을 것 같습니다 ☺️ 그리고 이렇게 맞춰두면 저장소(session/local) 정책이 변경되더라도 token 유틸 한 군데만 수정하면 되어서, 유지보수나 확장 측면에서도 이점이 있을 것 같아요!
추후 컨벤션이 완전히 확정되면 더 적절한 방식으로 통일해보면 좋을 것 같아요 :)

추가로 지금은 임시 코드라 "temp"가 들어간 것 같은데 이렇게 되면 기본값 때문에 토큰이 없어도 Bearer temp가 붙어서 401을 유발할 수도 있을 것 같아서 실제 서비스 구현 시점에는 기본값을 제거하여 null/undefined로 “토큰 없음 케이스”를 확실하게 처리하면 좋을 것 같습니다 :D

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

헉.. 제가 임시로 설정하고 올리기 전에 다른 값으로 대체한다는게 그대로 올려버렸네요.. 감사합니다ㅠ

스토리지 토큰에 접근하는 로직은 zustand PR이 머지되지 않았기도 했고 아직 axios instance를 사용하는 구현단계는 아니라 로그인 이슈 처리하면서 함께 shared/lib/auth/token.ts의 token 조회 유틸함수로 대체하도록 하겠습니다!

if (accessToken) {
config.headers["authorization"] = `Bearer ${accessToken}`;
Copy link
Contributor

@hummingbbird hummingbbird Jan 7, 2026

Choose a reason for hiding this comment

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

header 키값의 경우 보통 "Authorization"와 같이 첫글자로 대문자로 사용하는 게 조금 더 통용적인 방법이라고 해서 수정해도 좋을 거 같아요 😃

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

새롭게 배워갑니다(^///^) header 키 값은 대문자로 사용... 메모...

} 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);
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);
}
);
40 changes: 40 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,40 @@
import { isAxiosError } from "axios";

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

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

// 서버 정의 에러 타입 가드
// 서버가 보낸 에러가 맞는지 확인하는 함수
export const isAxiosErrorWithCustomCode = (
error: unknown
): error is RequiredWith<AxiosError<ApiErrorResponse>, "response"> => {
return (
isAxiosError(error) &&
!!error.response &&
!!error.response.data &&
typeof error.response.data.code === "string" &&
error.response.data.code in ERROR_MESSAGE
);
};
Copy link
Collaborator

Choose a reason for hiding this comment

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

RequiredWith<..., "response">로 response 존재를 보장한 뒤 타입가드로 좁혀가는 접근이 좋은 것 같습니다!! 덕분에 아래 분기 로직에서 optional chaining을 줄이고 타입 안정성을 확보할 수 있어서, 팀원들이 공통 에러 처리 코드를 쓰기 쉬워질 것 같습니다 :)
한 가지 확인하고 싶은 점이 있는데, 현재 “서버 정의 에러” 판단 기준이 code in ERROR_MESSAGE까지 포함되어 있어서, 서버가 새로운 에러 코드를 추가했는데 프론트 매핑이 아직 없으면 서버 정의 에러가 일반 axios 에러로 분류될 수도 있을 것 같아요. 이게 의도한 처리인지, 아니면 code가 string이면 서버 정의 에러로 보고 메시지는 fallback을 두는 쪽이 나은지 팀 컨벤션을 정리해보면 좋을 것 같습니다 ☺️

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

우선 제가 생각한 에러 처리 흐름에 대해 말씀드려보자면,
[1. 서버 정의 에러][2. 그 외 에러(ErrorBoundary 처리)] 로 크게 나누고, [1. 서버 정의 에러] 안에서 다시 POST(toast 노출)와 GET(Fallback UI 노출)을 분리(이건 아직 고민중...)하는 흐름으로 우선은 생각해두었습니다.

말씀하신 대로 판단 기준이 프론트 단에서 매핑해둔 ERROR_MESSAGEcode가 존재하는지와 같은 엄격한 기준을 설정하다보면, 신규 에러 코드가 발생했을 때 [1. 서버 정의 에러] 로 분류되지 못하고 [2. 그 외 에러] 로 인식해서, toast메시지가 떠야하는데 에러 페이지가 뜰 가능성도 있을 것 같아요!

아직 서버에서 명확한 에러코드가 정의된 상태는 아니기 때문에, 우선 초반에는 안정적인 개발을 위해서는 유진님이 말씀해주신 code가 string으로 존재하면 서버 에러로 간주하고 방식으로 수정하는 게 적합할 것 같네요🥰
해당 내용 반영하도록 하겠습니다!


// 공통 에러 핸들러
export const handleApiError = (error: unknown) => {
if (isAxiosErrorWithCustomCode(error)) {
const { code } = error.response.data;
const message = ERROR_MESSAGE[code as ApiErrorCode];
console.error(message);
return message;
} else if (isAxiosError(error)) {
console.error("서버/네트워크 오류입니다");
// TODO: 에러 페이지 필요
} else {
console.error("알 수 없는 에러 발생");
// TODO: 에러 페이지 필요
}
};
69 changes: 69 additions & 0 deletions src/shared/api/generate/templates/api.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<%
const { apiConfig, routes, utils, config } = it;
const { info, servers, externalDocs } = apiConfig;
const { _, require, formatDescription } = utils;

const server = (servers && servers[0]) || { url: "" };

const descriptionLines = _.compact([
`@title ${info.title || "No title"}`,
info.version && `@version ${info.version}`,
info.license && `@license ${_.compact([
info.license.name,
info.license.url && `(${info.license.url})`,
]).join(" ")}`,
info.termsOfService && `@termsOfService ${info.termsOfService}`,
server.url && `@baseUrl // .env 파일을 참조해주세요`,
externalDocs.url && `@externalDocs ${externalDocs.url}`,
info.contact && `@contact ${_.compact([
info.contact.name,
info.contact.email && `<${info.contact.email}>`,
info.contact.url && `(${info.contact.url})`,
]).join(" ")}`,
info.description && " ",
info.description && _.replace(formatDescription(info.description), /\n/g, "\n * "),
]);

%>

<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %>

<% if (descriptionLines.length) { %>
/**
<% descriptionLines.forEach((descriptionLine) => { %>
* <%~ descriptionLine %>

<% }) %>
*/
<% } %>
export class <%~ config.apiClassName %><SecurityDataType extends unknown><% if (!config.singleHttpClient) { %> extends HttpClient<SecurityDataType> <% } %> {

<% if(config.singleHttpClient) { %>
http: HttpClient<SecurityDataType>;

constructor (http: HttpClient<SecurityDataType>) {
this.http = http;
}
<% } %>


<% if (routes.outOfModule) { %>
<% for (const route of routes.outOfModule) { %>

<%~ includeFile('./procedure-call.ejs', { ...it, route }) %>

<% } %>
<% } %>

<% if (routes.combined) { %>
<% for (const { routes: combinedRoutes = [], moduleName } of routes.combined) { %>
<%~ moduleName %> = {
<% for (const route of combinedRoutes) { %>

<%~ includeFile('./procedure-call.ejs', { ...it, route }) %>

<% } %>
}
<% } %>
<% } %>
}
143 changes: 143 additions & 0 deletions src/shared/api/generate/templates/http-client.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<%
const { apiConfig, generateResponses, config } = it;
%>

import type { AxiosInstance, AxiosRequestConfig, HeadersDefaults, ResponseType } from "axios";
import axios from "axios";

export type QueryParamsType = Record<string | number, any>;

export interface FullRequestParams extends Omit<AxiosRequestConfig, "data" | "params" | "url" | "responseType"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseType;
/** request body */
body?: unknown;
}

export type RequestParams = Omit<FullRequestParams, "body" | "method" | "query" | "path">;

export interface ApiConfig<SecurityDataType = unknown> extends Omit<AxiosRequestConfig, "data" | "cancelToken"> {
securityWorker?: (securityData: SecurityDataType | null) => Promise<AxiosRequestConfig | void> | AxiosRequestConfig | void;
secure?: boolean;
format?: ResponseType;
}

export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}

export class HttpClient<SecurityDataType = unknown> {
public instance: AxiosInstance;
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private secure?: boolean;
private format?: ResponseType;

constructor({ securityWorker, secure, format, ...axiosConfig }: ApiConfig<SecurityDataType> = {}) {
this.instance = axios.create({ ...axiosConfig, baseURL: axiosConfig.baseURL || import.meta.env.VITE_API_URL })
Copy link
Collaborator

Choose a reason for hiding this comment

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

생성 코드에 .env가 그대로 노출되는 문제 신경써서 해결하신 점 굳굳입니다 👍

this.secure = secure;
this.format = format;
this.securityWorker = securityWorker;
}

public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data
}

protected mergeRequestParams(params1: AxiosRequestConfig, params2?: AxiosRequestConfig): AxiosRequestConfig {
const method = params1.method || (params2 && params2.method)

return {
...this.instance.defaults,
...params1,
...(params2 || {}),
headers: {
...((method && this.instance.defaults.headers[method.toLowerCase() as keyof HeadersDefaults]) || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}

protected stringifyFormItem(formItem: unknown) {
if (typeof formItem === "object" && formItem !== null) {
return JSON.stringify(formItem);
} else {
return `${formItem}`;
}
}

protected createFormData(input: Record<string, unknown>): FormData {
if (input instanceof FormData) {
return input;
}
return Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
const propertyContent: any[] = (property instanceof Array) ? property : [property]

for (const formItem of propertyContent) {
const isFileType = formItem instanceof Blob || formItem instanceof File;
formData.append(
key,
isFileType ? formItem : this.stringifyFormItem(formItem)
);
}

return formData;
}, new FormData());
}

public request = async <T = any, _E = any>({
secure,
path,
type,
query,
format,
body,
...params
<% if (config.unwrapResponseData) { %>
}: FullRequestParams): Promise<T> => {
<% } else { %>
}: FullRequestParams): Promise<T> => {
<% } %>
const secureParams = ((typeof secure === 'boolean' ? secure : this.secure) && this.securityWorker && (await this.securityWorker(this.securityData))) || {};
const requestParams = this.mergeRequestParams(params, secureParams);
const responseFormat = (format || this.format) || undefined;

if (type === ContentType.FormData && body && body !== null && typeof body === "object") {
body = this.createFormData(body as Record<string, unknown>);
}

if (type === ContentType.Text && body && body !== null && typeof body !== "string") {
body = JSON.stringify(body);
}

return this.instance.request({
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type ? { "Content-Type": type } : {}),
},
params: query,
responseType: responseFormat,
data: body,
url: path,
<% if (config.unwrapResponseData) { %>
}).then(response => response.data);
<% } else { %>
});
<% } %>
};
}
20 changes: 20 additions & 0 deletions src/shared/api/types/response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface ApiErrorResponse {
status: number;
code: ApiErrorCode;
message: string;
}

// 서버 정의 에러 (임시)
export type ApiErrorCode =
| "INVALID_ACCESS_TOKEN_VALUE"
| "EXPIRED_ACCESS_TOKEN";

export const ERROR_MESSAGE: Record<ApiErrorCode, string> = {
INVALID_ACCESS_TOKEN_VALUE: "액세스 토큰의 값이 올바르지 않습니다.",
EXPIRED_ACCESS_TOKEN: "액세스 토큰이 만료되었습니다. 재발급 받아주세요.",
};

// 옵셔널 타입을 필수로 지정
export type RequiredWith<T, K extends keyof T> = T & {
[P in K]-?: T[P];
};