Skip to content

[FEAT] 공통 API 에러 응답 스키마 구조화#82

Open
kimminna wants to merge 3 commits into
developfrom
feat/web/81-common-api-error-schema
Open

[FEAT] 공통 API 에러 응답 스키마 구조화#82
kimminna wants to merge 3 commits into
developfrom
feat/web/81-common-api-error-schema

Conversation

@kimminna

@kimminna kimminna commented Jul 2, 2026

Copy link
Copy Markdown
Member

ISSUE 🔗

close #81



What is this PR? 🔍

API 성공/실패 응답 봉투를 zod로 검증하고, axios 에러를 앱 전역에서 일관되게 다룰 수 있는 형태로 정규화하는 공통 스키마를 추가했습니다. 관련 파일들은 역할별로 client/schema/error 폴더로 구조화했습니다.

배경

  • 기존 구조: apps/timo-web/api/axios.ts의 응답 인터셉터에는 에러 처리가 TODO로 비어 있었고, 서버가 내려주는 에러 응답의 형태를 검증하거나 타입으로 다루는 공통 장치가 없었습니다.
  • 발생 문제: 각 쿼리·컴포넌트에서 axios 에러를 그대로 받으면 error.response.data가 실제로 어떤 모양인지 런타임에 보장되지 않고, 에러 메시지·코드를 꺼내 쓰는 방식이 호출부마다 달라질 위험이 있었습니다.
  • 해결 방향: 서버 에러 응답의 봉투 구조를 zod 스키마로 명시하고, axios 응답 인터셉터라는 "외부 응답이 들어오는 경계"에서 이를 검증해 ApiError라는 일관된 타입으로 정규화했습니다.

공통 응답/에러 스키마 (api/schema/response.ts)

  • 변경 요약: 성공 응답 봉투({status, message, data})와 실패 응답 봉투({timestamp, status, errorCode, message, path})를 검증하는 zod 스키마를 추가했습니다.
  • 이유: 서버가 성공과 실패에서 서로 다른 응답 구조(Spring @ControllerAdvice 스타일의 에러 포맷)를 내려주기 때문에, 두 형태를 각각 별도로 정의해야 타입과 런타임 검증이 실제 계약과 어긋나지 않습니다.
  • 구현 방식: apiResponseSchema(dataSchema)는 제네릭 팩토리 함수로, 각 도메인 쿼리에서 실제 데이터 zod 스키마를 넘기면 {status, message, data: dataSchema} 형태의 스키마를 반환합니다. apiErrorSchema는 필드가 고정된 에러 봉투 스키마입니다. 이 파일은 순수하게 "데이터가 어떤 모양이어야 하는가"만 선언하고, 실제로 검증을 수행하거나 값을 변환하는 로직은 포함하지 않습니다. errorCode는 아직 실제 백엔드 에러 코드 목록이 확정되지 않아 z.string()으로 느슨하게 검증했고, 목록이 확정되면 도메인별 z.enum([...])으로 좁히는 것을 다음 작업으로 남겼습니다.

API 에러 정규화 (api/error/api-error.ts)

  • 변경 요약: axios 에러를 앱에서 다루기 쉬운 ApiError 클래스와, 이를 만들어주는 parseApiError 함수를 추가했습니다.
  • 이유: schema/response.ts는 "모양이 맞는지"만 검증할 뿐 실제로 에러를 처리하는 로직은 아니므로, 그 검증 결과를 소비해서 런타임에 실제로 throw/catch되는 객체로 바꿔주는 계층이 별도로 필요했습니다. 스키마와 정규화 로직을 한 파일에 두면 "무엇이 유효한 모양인가"와 "그 모양을 어떻게 처리하는가"가 뒤섞여 재사용성이 떨어집니다.
  • 구현 방식: parseApiError(error)axios.isAxiosError(error)로 axios 에러인지 먼저 확인하고, 맞으면 error.response?.dataapiErrorSchema.safeParse로 검증합니다. 검증에 성공하면 그 데이터로 ApiError 인스턴스를 만들고, 실패하면(네트워크 단절 등 서버가 정형 에러 바디를 못 준 경우) errorCode: "UNKNOWN_ERROR"인 폴백 ApiError를 반환합니다. ApiErrorstatus, 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/ 하위 폴더로 옮겼습니다.
  • 이유: 파일이 늘어나면서 "HTTP 클라이언트 설정"과 "응답 검증 스키마", "에러 처리 로직"이 한 폴더에 평면적으로 섞여 있으면 무엇이 무엇을 의존하는지 파악하기 어려워졌습니다.
  • 구현 방식: api/client/에는 axios 인스턴스(axios.ts)와 TanStack QueryClient 설정(query-client.ts)을, api/schema/에는 zod 스키마(response.ts)를, api/error/에는 에러 정규화 로직(api-error.ts)을 두었습니다. 모든 내부 import는 @/api/... 절대경로로 통일했고, providers/QueryProvider.tsx의 import 경로도 함께 수정했습니다. 배럴(index.ts)을 한 차례 추가했다가, 참조하는 곳이 없어 다시 제거했습니다.



To Reviewers

apiErrorSchemaerrorCode를 지금은 z.string()으로만 느슨하게 검증했습니다. 실제 에러 코드 목록이 백엔드에서 확정되면 도메인별로 z.enum으로 좁히는 게 나을지 같이 봐주시면 좋겠습니다. 또한 401 등 인증 관련 상태 코드에 따른 사이드 이펙트(리다이렉트 등)는 인증 방식이 아직 정해지지 않아 이번 PR에서 의도적으로 제외했습니다.

Screenshot 📷



Test Checklist ✔

  • pnpm --filter timo-web exec tsc --noEmit 통과
  • pnpm --filter timo-web lint 통과
  • 실제 백엔드 에러 응답으로 parseApiError 동작 확인 — 후속 작업 (백엔드 API 연동 시)

kimminna added 3 commits July 3, 2026 01:50
- zod 패키지를 추가했습니다
- 성공 응답 봉투({status, message, data})와 실패 응답 봉투({timestamp, status, errorCode, message, path})를 검증하는 zod 스키마를 추가했습니다
- axios 에러를 앱 전역에서 다루기 쉬운 ApiError로 정규화하는 parseApiError를 추가했습니다
- axios 응답 인터셉터에서 parseApiError를 호출하도록 연결했습니다
- query-client.ts를 api/client 폴더로 이동했습니다
- QueryProvider.tsx의 import 경로를 수정했습니다
@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
timo Ready Ready Preview, Comment Jul 2, 2026 4:54pm

@github-actions github-actions Bot requested review from jjangminii and yumin-kim2 July 2, 2026 16:54
@github-actions github-actions Bot added the ⏰ Timo-web Timo 웹 서비스 label Jul 2, 2026
@github-actions github-actions Bot added ✨ Feature 새로운 기능(기능성) 구현 ♦️ 민아 민아상 labels Jul 2, 2026
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Storybook Preview

항목 링크
Storybook 열기
Chromatic 빌드 확인

마지막 업데이트: 2026-07-02 16:55 UTC

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

axios 초기화 코드를 apps/timo-web/api/axios.ts에서 제거하고 apps/timo-web/api/client/axios.ts로 재구성했습니다. zod 기반 성공/에러 응답 스키마(response.ts)와 ApiError/parseApiError(api-error.ts)를 새로 추가하여 axios 응답 인터셉터에서 에러를 정규화합니다. QueryProvider.tsx의 import 경로와 package.jsonzod 의존성이 함께 갱신되었습니다.

Changes

API 에러 표준화 구조화

Layer / File(s) Summary
공통 응답/에러 zod 스키마 정의
apps/timo-web/api/schema/response.ts
성공 응답용 apiResponseSchema와 에러 응답용 apiErrorSchema, ApiErrorResponse 타입을 추가함.
ApiError 클래스 및 parseApiError 유틸리티
apps/timo-web/api/error/api-error.ts
Error를 확장한 ApiError 클래스와, axios 에러 및 비-axios 에러를 표준 ApiError로 변환하는 parseApiError 함수를 추가함.
신규 axios 클라이언트 인스턴스 및 기존 인스턴스 제거
apps/timo-web/api/axios.ts, apps/timo-web/api/client/axios.ts
기존 instance와 응답 인터셉터를 삭제하고, NEXT_PUBLIC_API_BASE_URL 기반 새 instanceclient/axios.ts에 생성해 응답 인터셉터에서 parseApiError로 에러를 정규화해 reject함.
경로 변경 및 의존성 추가
apps/timo-web/providers/QueryProvider.tsx, apps/timo-web/package.json
queryClient import 경로를 @/api/client/query-client로 변경하고, zod를 dependencies에 추가함.

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)
Loading

Possibly related PRs

  • Team-Timo/Timo-client#21: 동일한 axios 초기화 파일(apps/timo-web/api/axios.ts)에서 instance를 추가했던 PR로, 이번 PR에서 그 구조를 제거하고 client/axios.ts로 대체함.

Suggested labels: 🛠️ Setup

Suggested reviewers: jjangminii, yumin-kim2

zod 스키마로 에러도 깔끔하게 정리했으니, 이제 런타임 에러도 예의 바르게 들어오겠군요. 👏 참고로 zod의 safeParse 활용법은 공식 문서(https://zod.dev/?id=safeparse)를 참고하시면 좋습니다.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed 주요 변경점인 공통 API 에러 응답 스키마 구조화를 잘 요약한 제목입니다.
Description check ✅ Passed 설명이 스키마 추가, 에러 정규화, 폴더 구조화 내용과 일치합니다.
Linked Issues check ✅ Passed 연결된 이슈의 핵심 요구사항인 스키마 정의, ApiError/parseApiError, 인터셉터 연결, 폴더 구조화가 반영되었습니다.
Out of Scope Changes check ✅ Passed 의존성 추가와 import 경로 정리 등도 목표 범위에 맞는 변경으로 보이며, 뚜렷한 이탈은 없습니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/web/81-common-api-error-schema

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Timo Performance Report

Bundle Size — timo-web
라우트 크기 First Load JS
/ 0 B 🟡 205.49 kB
/focus 0 B 🟡 205.49 kB
/home 0 B 🟡 205.49 kB
/login 0 B 🟡 205.49 kB
/onboarding 0 B 🟡 205.49 kB
/settings 0 B 🟡 205.49 kB
/settings/account 0 B 🟡 205.49 kB
/settings/policy 0 B 🟡 205.49 kB
/statistics 0 B 🟡 205.49 kB
/today 0 B 🟡 205.49 kB

공유 번들: 205.49 kB
🟢 < 200kB  |  🟡 < 350kB  |  🔴 ≥ 350kB (First Load JS · gzip)

Lighthouse — timo-web

⚠️ Lighthouse 결과를 가져오지 못했습니다.

Image Optimization — timo-web

public/ 디렉토리에 이미지가 없습니다.

측정 커밋: 6caaef4

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 094d028 and fdb6f72.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • apps/timo-web/api/axios.ts
  • apps/timo-web/api/client/axios.ts
  • apps/timo-web/api/client/query-client.ts
  • apps/timo-web/api/error/api-error.ts
  • apps/timo-web/api/schema/response.ts
  • apps/timo-web/package.json
  • apps/timo-web/providers/QueryProvider.tsx
💤 Files with no reviewable changes (1)
  • apps/timo-web/api/axios.ts

Comment on lines +5 to +7
export const instance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 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.

Suggested change
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.

Comment on lines +21 to +40
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: "",
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

axios 에러인데 body가 스키마와 안 맞으면 실제 status/path 정보가 유실돼요.

error.response?.dataapiErrorSchema를 통과하지 못하면(예: 게이트웨이 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.

Suggested change
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 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.

Suggested change
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` 참조가 남지 않도록 확인하세요.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 새로운 기능(기능성) 구현 ⏰ Timo-web Timo 웹 서비스 ♦️ 민아 민아상

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] 공통 API 에러 응답 스키마 구조화

1 participant