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);
}