[FEAT] 공통 API 에러 응답 스키마 구조화#82
Conversation
- zod 패키지를 추가했습니다
- 성공 응답 봉투({status, message, data})와 실패 응답 봉투({timestamp, status, errorCode, message, path})를 검증하는 zod 스키마를 추가했습니다
- axios 에러를 앱 전역에서 다루기 쉬운 ApiError로 정규화하는 parseApiError를 추가했습니다
- axios 응답 인터셉터에서 parseApiError를 호출하도록 연결했습니다
- query-client.ts를 api/client 폴더로 이동했습니다 - QueryProvider.tsx의 import 경로를 수정했습니다
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Walkthroughaxios 초기화 코드를 ChangesAPI 에러 표준화 구조화
Estimated code review effort: 2 (Simple) | ~12 minutes Sequence Diagram(s)sequenceDiagram
participant Component
participant instance
participant parseApiError
participant apiErrorSchema
Component->>instance: API 요청
instance-->>Component: 성공 응답 반환
instance->>parseApiError: 오류 응답 전달
parseApiError->>apiErrorSchema: safeParse(error.response.data)
apiErrorSchema-->>parseApiError: 파싱 결과
parseApiError-->>instance: ApiError 반환
instance-->>Component: Promise.reject(ApiError)
Possibly related PRs
Suggested labels: Suggested reviewers: zod 스키마로 에러도 깔끔하게 정리했으니, 이제 런타임 에러도 예의 바르게 들어오겠군요. 👏 참고로 zod의 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Timo Performance ReportBundle Size — timo-web
Lighthouse — timo-web
Image Optimization — timo-web
측정 커밋: |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/timo-web/api/client/axios.ts`:
- Around line 5-7: The axios instance created in axios.ts is missing a default
timeout, so requests can hang indefinitely before parseApiError ever runs.
Update the axios.create configuration in instance to include a sensible timeout
value at the instance level, alongside baseURL, so all API calls fail fast on
stalled network responses.
In `@apps/timo-web/api/error/api-error.ts`:
- Around line 21-40: parseApiError currently drops useful axios metadata when
error.response?.data fails apiErrorSchema validation, causing status/path
information to be lost. Update parseApiError to preserve axios-derived fields
from the axios.isAxiosError branch by falling back to error.response.status and
error.config?.url (or equivalent request URL) when building the ApiError, while
still using the schema-parsed body when available. Keep the UNKNOWN_ERROR
fallback only for non-axios cases, and ensure ApiError construction in
parseApiError always carries the best available status/path context.
In `@apps/timo-web/api/schema/response.ts`:
- Line 3: `apiResponseSchema`에서 사용 중인 `z.ZodTypeAny`가 Zod 4에서 deprecated 되었으므로,
제네릭 타입 제약을 `z.ZodType`으로 바꿔 호환성을 맞춰주세요. 이 변경은 `apiResponseSchema` 시그니처에만 적용하면
되며, 관련 타입 사용처가 있다면 함께 정리해 `z.ZodTypeAny` 참조가 남지 않도록 확인하세요.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 395befab-2f58-418d-a537-f15f0ec6e97a
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (7)
apps/timo-web/api/axios.tsapps/timo-web/api/client/axios.tsapps/timo-web/api/client/query-client.tsapps/timo-web/api/error/api-error.tsapps/timo-web/api/schema/response.tsapps/timo-web/package.jsonapps/timo-web/providers/QueryProvider.tsx
💤 Files with no reviewable changes (1)
- apps/timo-web/api/axios.ts
| export const instance = axios.create({ | ||
| baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, | ||
| }); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
axios 인스턴스에 timeout이 설정되어 있지 않아요.
응답이 지연되거나 네트워크가 멈추면 요청이 무한정 대기하게 되고, 이 상태에서는 parseApiError가 실행될 기회조차 없어 사용자에게 로딩 상태만 남게 됩니다. 인스턴스 레벨에 기본 timeout을 설정해두면 이런 행잉을 방지할 수 있어요.
🛡️ 제안 수정
export const instance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
+ timeout: 10000,
});작은 설정 한 줄로 사용자 경험이 확 달라질 수 있는 포인트라 놓치지 않았으면 해요!
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const instance = axios.create({ | |
| baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, | |
| }); | |
| export const instance = axios.create({ | |
| baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, | |
| timeout: 10000, | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/timo-web/api/client/axios.ts` around lines 5 - 7, The axios instance
created in axios.ts is missing a default timeout, so requests can hang
indefinitely before parseApiError ever runs. Update the axios.create
configuration in instance to include a sensible timeout value at the instance
level, alongside baseURL, so all API calls fail fast on stalled network
responses.
| export function parseApiError(error: unknown): ApiError { | ||
| if (axios.isAxiosError(error)) { | ||
| const parsed = apiErrorSchema.safeParse(error.response?.data); | ||
|
|
||
| if (parsed.success) { | ||
| return new ApiError(parsed.data); | ||
| } | ||
| } | ||
|
|
||
| return new ApiError({ | ||
| timestamp: new Date().toISOString(), | ||
| status: 0, | ||
| errorCode: "UNKNOWN_ERROR", | ||
| message: | ||
| error instanceof Error | ||
| ? error.message | ||
| : "알 수 없는 오류가 발생했습니다.", | ||
| path: "", | ||
| }); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
axios 에러인데 body가 스키마와 안 맞으면 실제 status/path 정보가 유실돼요.
error.response?.data가 apiErrorSchema를 통과하지 못하면(예: 게이트웨이 500 HTML 응답, 백엔드가 다른 포맷으로 에러를 내려주는 경우) 바로 UNKNOWN_ERROR + status: 0 분기로 빠지는데, 이때 axios 에러 객체에는 이미 error.response.status, error.config?.url 같은 유용한 정보가 있음에도 버려지고 있습니다. 향후 401 리다이렉트 같은 상태 코드 기반 처리를 붙일 때(axios.ts의 TODO) 이 정보 유실이 바로 발목을 잡을 수 있어요.
🐛 제안 수정
export function parseApiError(error: unknown): ApiError {
if (axios.isAxiosError(error)) {
const parsed = apiErrorSchema.safeParse(error.response?.data);
if (parsed.success) {
return new ApiError(parsed.data);
}
+
+ return new ApiError({
+ timestamp: new Date().toISOString(),
+ status: error.response?.status ?? 0,
+ errorCode: "UNKNOWN_ERROR",
+ message: error.message,
+ path: error.config?.url ?? "",
+ });
}
return new ApiError({
timestamp: new Date().toISOString(),
status: 0,
errorCode: "UNKNOWN_ERROR",
message:
error instanceof Error
? error.message
: "알 수 없는 오류가 발생했습니다.",
path: "",
});
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function parseApiError(error: unknown): ApiError { | |
| if (axios.isAxiosError(error)) { | |
| const parsed = apiErrorSchema.safeParse(error.response?.data); | |
| if (parsed.success) { | |
| return new ApiError(parsed.data); | |
| } | |
| } | |
| return new ApiError({ | |
| timestamp: new Date().toISOString(), | |
| status: 0, | |
| errorCode: "UNKNOWN_ERROR", | |
| message: | |
| error instanceof Error | |
| ? error.message | |
| : "알 수 없는 오류가 발생했습니다.", | |
| path: "", | |
| }); | |
| } | |
| export function parseApiError(error: unknown): ApiError { | |
| if (axios.isAxiosError(error)) { | |
| const parsed = apiErrorSchema.safeParse(error.response?.data); | |
| if (parsed.success) { | |
| return new ApiError(parsed.data); | |
| } | |
| return new ApiError({ | |
| timestamp: new Date().toISOString(), | |
| status: error.response?.status ?? 0, | |
| errorCode: "UNKNOWN_ERROR", | |
| message: error.message, | |
| path: error.config?.url ?? "", | |
| }); | |
| } | |
| return new ApiError({ | |
| timestamp: new Date().toISOString(), | |
| status: 0, | |
| errorCode: "UNKNOWN_ERROR", | |
| message: | |
| error instanceof Error | |
| ? error.message | |
| : "알 수 없는 오류가 발생했습니다.", | |
| path: "", | |
| }); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/timo-web/api/error/api-error.ts` around lines 21 - 40, parseApiError
currently drops useful axios metadata when error.response?.data fails
apiErrorSchema validation, causing status/path information to be lost. Update
parseApiError to preserve axios-derived fields from the axios.isAxiosError
branch by falling back to error.response.status and error.config?.url (or
equivalent request URL) when building the ApiError, while still using the
schema-parsed body when available. Keep the UNKNOWN_ERROR fallback only for
non-axios cases, and ensure ApiError construction in parseApiError always
carries the best available status/path context.
| @@ -0,0 +1,19 @@ | |||
| import { z } from "zod"; | |||
|
|
|||
| export function apiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) { | |||
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
z.ZodTypeAny는 Zod 4에서 deprecated 되었습니다.
Zod 공식 마이그레이션 가이드에 따르면 z.ZodTypeAny는 제거되었고 z.ZodType을 대신 사용하도록 권장하고 있습니다. (참고: https://zod.dev/v4/changelog)
♻️ 제안 수정
-export function apiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
+export function apiResponseSchema<T extends z.ZodType>(dataSchema: T) {지금 당장 동작에는 문제 없지만, 향후 zod 메이저 업그레이드 시 제거될 수 있는 API라 미리 정리해두면 좋을 것 같아요. 작은 수고로 미래의 골칫거리를 예방하는 셈이죠! 😄
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function apiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) { | |
| export function apiResponseSchema<T extends z.ZodType>(dataSchema: T) { |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/timo-web/api/schema/response.ts` at line 3, `apiResponseSchema`에서 사용 중인
`z.ZodTypeAny`가 Zod 4에서 deprecated 되었으므로, 제네릭 타입 제약을 `z.ZodType`으로 바꿔 호환성을
맞춰주세요. 이 변경은 `apiResponseSchema` 시그니처에만 적용하면 되며, 관련 타입 사용처가 있다면 함께 정리해
`z.ZodTypeAny` 참조가 남지 않도록 확인하세요.
ISSUE 🔗
close #81
What is this PR? 🔍
API 성공/실패 응답 봉투를 zod로 검증하고, axios 에러를 앱 전역에서 일관되게 다룰 수 있는 형태로 정규화하는 공통 스키마를 추가했습니다. 관련 파일들은 역할별로 client/schema/error 폴더로 구조화했습니다.
배경
apps/timo-web/api/axios.ts의 응답 인터셉터에는 에러 처리가 TODO로 비어 있었고, 서버가 내려주는 에러 응답의 형태를 검증하거나 타입으로 다루는 공통 장치가 없었습니다.error.response.data가 실제로 어떤 모양인지 런타임에 보장되지 않고, 에러 메시지·코드를 꺼내 쓰는 방식이 호출부마다 달라질 위험이 있었습니다.ApiError라는 일관된 타입으로 정규화했습니다.공통 응답/에러 스키마 (
api/schema/response.ts){status, message, data})와 실패 응답 봉투({timestamp, status, errorCode, message, path})를 검증하는 zod 스키마를 추가했습니다.@ControllerAdvice스타일의 에러 포맷)를 내려주기 때문에, 두 형태를 각각 별도로 정의해야 타입과 런타임 검증이 실제 계약과 어긋나지 않습니다.apiResponseSchema(dataSchema)는 제네릭 팩토리 함수로, 각 도메인 쿼리에서 실제 데이터 zod 스키마를 넘기면{status, message, data: dataSchema}형태의 스키마를 반환합니다.apiErrorSchema는 필드가 고정된 에러 봉투 스키마입니다. 이 파일은 순수하게 "데이터가 어떤 모양이어야 하는가"만 선언하고, 실제로 검증을 수행하거나 값을 변환하는 로직은 포함하지 않습니다.errorCode는 아직 실제 백엔드 에러 코드 목록이 확정되지 않아z.string()으로 느슨하게 검증했고, 목록이 확정되면 도메인별z.enum([...])으로 좁히는 것을 다음 작업으로 남겼습니다.API 에러 정규화 (
api/error/api-error.ts)ApiError클래스와, 이를 만들어주는parseApiError함수를 추가했습니다.schema/response.ts는 "모양이 맞는지"만 검증할 뿐 실제로 에러를 처리하는 로직은 아니므로, 그 검증 결과를 소비해서 런타임에 실제로 throw/catch되는 객체로 바꿔주는 계층이 별도로 필요했습니다. 스키마와 정규화 로직을 한 파일에 두면 "무엇이 유효한 모양인가"와 "그 모양을 어떻게 처리하는가"가 뒤섞여 재사용성이 떨어집니다.parseApiError(error)는axios.isAxiosError(error)로 axios 에러인지 먼저 확인하고, 맞으면error.response?.data를apiErrorSchema.safeParse로 검증합니다. 검증에 성공하면 그 데이터로ApiError인스턴스를 만들고, 실패하면(네트워크 단절 등 서버가 정형 에러 바디를 못 준 경우)errorCode: "UNKNOWN_ERROR"인 폴백ApiError를 반환합니다.ApiError는status,errorCode,path,timestamp를 읽기 전용 필드로 노출해서, 호출부에서error instanceof ApiError로 타입을 좁힌 뒤error.errorCode로 도메인별 분기(예:"USER_409"→ 이미 탈퇴 처리된 계정 안내)를 하거나error.message를 그대로 UI에 노출할 수 있습니다.parseApiError는 axios 응답 인터셉터에서만 호출되도록 두었고, 개별 쿼리 파일에서 직접 호출할 일은 없도록 했습니다. 401 등 status 기반의 사이드 이펙트(리다이렉트 등)는 인증 방식이 아직 확정되지 않아 이번 PR 범위에서 제외했고, 인터셉터에 TODO로 남겨뒀습니다.api 폴더 구조화
api/최상위에 평면적으로 있던axios.ts,query-client.ts를 역할별로client/,schema/,error/하위 폴더로 옮겼습니다.api/client/에는 axios 인스턴스(axios.ts)와 TanStackQueryClient설정(query-client.ts)을,api/schema/에는 zod 스키마(response.ts)를,api/error/에는 에러 정규화 로직(api-error.ts)을 두었습니다. 모든 내부 import는@/api/...절대경로로 통일했고,providers/QueryProvider.tsx의 import 경로도 함께 수정했습니다. 배럴(index.ts)을 한 차례 추가했다가, 참조하는 곳이 없어 다시 제거했습니다.To Reviewers
apiErrorSchema의errorCode를 지금은z.string()으로만 느슨하게 검증했습니다. 실제 에러 코드 목록이 백엔드에서 확정되면 도메인별로z.enum으로 좁히는 게 나을지 같이 봐주시면 좋겠습니다. 또한 401 등 인증 관련 상태 코드에 따른 사이드 이펙트(리다이렉트 등)는 인증 방식이 아직 정해지지 않아 이번 PR에서 의도적으로 제외했습니다.Screenshot 📷
Test Checklist ✔
pnpm --filter timo-web exec tsc --noEmit통과pnpm --filter timo-web lint통과parseApiError동작 확인 — 후속 작업 (백엔드 API 연동 시)