Skip to Content
APIAPI 컨벤션

API 컨벤션

API 사용 시 따르는 에러 처리, 캐시 관리, 새 엔드포인트 추가 패턴입니다. 사용법 개요는 API 관리를, 스펙은 API 레퍼런스utils-react-query 레퍼런스를 참고하세요.

에러 처리

기본 구조

모든 API 에러는 ApiServerError입니다. catch 블록에서 반드시 타입 가드를 먼저 확인하고, 처리하지 않은 에러는 다시 throw합니다.

try { await SomeAPI.someMethod(props); } catch (e) { if (ApiServerError.isApiServerError(e)) { // 처리 } throw e; // 예상치 못한 에러는 다시 throw }

특정 에러 코드 처리

isErrorOfType으로 특정 에러 코드를 타입 안전하게 확인합니다.

if (ApiServerError.isApiServerError(e)) { if (e.isErrorOfType('ALREADY_VOTED')) { alreadyVotedAlert.open(); return; } if (e.isErrorOfType('VOTE_CLOSED')) { voteClosedAlert.open(); return; } throw e; }

HTTP 상태 코드 분기

if (ApiServerError.isApiServerError(e)) { if (e.statusCode === 409) { duplicateAlert.open(); return; } if (e.is404Error()) { router.replace('/not-found'); return; } }

예상 가능한 에러는 SilentErrorCodeList로 Sentry 제외

사용자 입력 실수 등 정상 범위의 에러는 Sentry 전송에서 제외합니다. 엔드포인트 정의 시 @SilentErrorCodeList를 적용합니다.

@POST(props => `v1/vote/${props.voteId}/submission`) @SilentErrorCodeList(['ALREADY_VOTED', 'VOTE_CLOSED']) voteSubmission(props: VoteSubmissionRequest): Promise<VoteSubmissionResponse> { return this.fetch(props); }

서버 컴포넌트 에러 처리

RSC와 layout에서 발생하는 API 에러는 Next.js 라우팅과 연결합니다.

// layout.tsx try { await fetchAPIQuery(ChatAPI.getChatByUrl, {chatUrl}); } catch (e) { if (ApiServerError.is403Error(e)) forbidden(); // Next.js forbidden() if (ApiServerError.is404Error(e)) notFound(); // Next.js notFound() throw e; // 예상치 못한 에러는 다시 throw }

forbidden()notFound()는 각각 app/forbidden.tsx, app/not-found.tsx로 렌더링을 넘깁니다. 클라이언트 에러 처리 패턴은 기본 구조를 참고하세요.

서버 액션 에러 직렬화

서버 액션에서 ApiServerError는 클라이언트로 그대로 전달되지 않습니다. serializeForServerAction()으로 직렬화하고, 클라이언트에서 deserializeForServerAction()으로 복원합니다.

// 서버 액션 'use server'; export async function someAction(props: Props) { try { return await SomeAPI.someMethod(props); } catch (e) { if (ApiServerError.isApiServerError(e)) { throw e.serializeForServerAction(); // 필수 } throw e; } } // 클라이언트 try { await someAction(props); } catch (e) { const error = ApiServerError.deserializeForServerAction(e); if (error?.isErrorOfType('SPECIFIC_ERROR')) { // 처리 } }

캐시 무효화

뮤테이션 성공 후 관련 쿼리 캐시를 무효화할 때 getQueryKey를 사용합니다.

import {getQueryKey} from '@howmuchhome-web/utils-react-query'; import {useQueryClient} from '@tanstack/react-query'; const queryClient = useQueryClient(); const mutation = useAPIMutation(CafeAPI.commentCreate, { onSuccess: () => { // 특정 인자의 쿼리만 무효화 queryClient.invalidateQueries({ queryKey: getQueryKey(CafeAPI.getPostDetail, {postId}), }); }, });

또는 엔드포인트 정의 시 @MutationOptions로 기본 동작을 설정합니다.

@POST(props => `v1/cafe/post/${props.postId}/comment`) @MutationOptions(client => ({ onSuccess: (_, {postId}) => { client.invalidateQueries({ queryKey: getQueryKey(CafeAPI.getPostDetail, {postId}), }); }, })) commentCreate(props: CommentCreateRequest): Promise<CommentCreateResponse> { return this.fetch(props); }

무한 쿼리 캐시 업데이트

무한 스크롤 목록에서 특정 아이템만 업데이트할 때 updateInfiniteQueryData를 사용합니다.

import {updateInfiniteQueryData, getQueryKey} from '@howmuchhome-web/utils-react-query'; // 댓글 좋아요 후 목록 캐시에서 해당 댓글만 업데이트 updateInfiniteQueryData({ queryClient, queryKey: getQueryKey(CafeAPI.getPostList, {unionId}), condition: (item) => item.id === targetPostId, updater: (item) => ({...item, likeCount: item.likeCount + 1}), });

타입 정의 위치

요청/응답 타입 — schema.ts

각 API 모듈의 요청·응답 타입은 해당 모듈 폴더의 schema.ts에 정의합니다.

// packages/api/lib/cafe/schema.ts import {Post} from '@howmuchhome-web/domains'; export type GetPostListRequest = { cafeId: string; categoryId?: string; pageParam?: number; pageSize: number; }; export type GetPostListResponse = { posts: Post[]; // 도메인 타입은 domains 패키지에서 import totalCount: number; };

도메인 타입 — packages/domains

Chat, Cafe, User 같은 도메인 모델 타입은 packages/api에 정의하면 안 됩니다. 반드시 packages/domains/types/에 정의하고 api 패키지에서 import해서 사용합니다.

packages/ ├── domains/ │ └── types/ │ ├── cafe.ts ← Cafe, Post, Author 등 도메인 타입 정의 │ ├── chat.ts ← ChatRoom, ChatMessage 등 │ └── user.ts ← User, ChatProfile 등 └── api/ └── lib/ └── cafe/ └── schema.ts ← 요청/응답 타입만, 도메인 타입은 import해서 사용

잘못된 예시:

// ❌ packages/api/lib/cafe/schema.ts 에서 도메인 타입을 직접 정의 export type Post = { id: string; title: string; // ... };

올바른 예시:

// ✅ packages/domains/types/cafe.ts 에 도메인 타입 정의 export type Post = { id: string; title: string; // ... }; // ✅ packages/api/lib/cafe/schema.ts 에서 import해서 사용 import {Post} from '@howmuchhome-web/domains'; export type GetPostListResponse = { posts: Post[]; };

새 엔드포인트 추가

기본 구조

HowmuchhomeAPI를 상속받고 데코레이터로 HTTP 메서드와 경로를 지정합니다.

import {HowmuchhomeAPI} from '@howmuchhome-web/api/lib'; import {GET, POST, QueryOptions, MutationOptions} from '@howmuchhome-web/api/lib'; class SomeAPI extends HowmuchhomeAPI { // 정적 경로 + 쿼리 옵션 @GET('v1/some/resource') @QueryOptions({staleTime: 1000 * 60 * 5}) getSomeResource(): Promise<SomeResponse> { return this.fetch(); } // 동적 경로 @GET(props => `v1/some/resource/${props.id}`) @QueryOptions(props => ({queryKey: ['some', props.id]})) getSomeResourceById(props: {id: string}): Promise<SomeResponse> { return this.fetch(props); } // 쿼리 파라미터 @GET('v1/some/list', props => ({page: props.page, size: props.size})) @QueryOptions({}) getSomeList(props: {page: number; size: number}): Promise<SomeListResponse> { return this.fetch(props); } // 뮤테이션 @POST('v1/some/resource') @MutationOptions(client => ({ onSuccess: () => client.invalidateQueries({queryKey: getQueryKey(this.getSomeList)}), })) createSomeResource(props: CreateSomeRequest): Promise<CreateSomeResponse> { return this.fetch(props); } }

에러 코드 타입 정의

엔드포인트에서 발생할 수 있는 에러 코드를 타입으로 정의합니다.

type CreateSomeErrorCode = 'ALREADY_EXISTS' | 'INVALID_INPUT'; @POST('v1/some/resource') @SilentErrorCodeList<CreateSomeErrorCode>(['ALREADY_EXISTS']) createSomeResource(props: CreateSomeRequest): Promise<CreateSomeResponse> { return this.fetch(props); }
Last updated on