API 관리
서버 API 호출은 두 패키지가 협력해 동작합니다.
| 패키지 | 역할 |
|---|---|
@howmuchhome-web/api | API 엔드포인트 정의, 에러 클래스 |
@howmuchhome-web/utils-react-query | @tanstack/query 기반 데이터 페칭 훅, 서버 유틸리티 |
클라이언트 — 쿼리
데이터를 읽어올 때 useAPIQuery를 사용합니다.
Suspense 기반으로 동작하므로 상위에 <Suspense>가 필요합니다.
'use client';
import {VoteAPI} from '@howmuchhome-web/api';
import {useAPIQuery} from '@howmuchhome-web/utils-react-query';
function VoteDetail({voteId}: {voteId: string}) {
const {data: vote} = useAPIQuery(VoteAPI.getVoteDetail, {voteId});
return <div>{vote.title}</div>;
}쿼리 옵션을 재정의하려면 세 번째 인자에 전달합니다.
const {data} = useAPIQuery(VoteAPI.getVoteDetail, {voteId}, {
staleTime: 1000 * 60 * 5,
});서버에서 prefetch하지 않고 클라이언트에서만 데이터를 가져오는 경우, clientOnly: true를 명시합니다.
실제 동작 차이는 없지만, 서버 prefetch가 없어도 의도된 것임을 표현합니다.
이 옵션 없이 캐시에 데이터가 없으면 콘솔에 경고가 출력됩니다.
// 첫 화면에 보이지 않는 데이터 (탭 전환, 드로어 내부 등)
const {data} = useAPIQuery(CafeAPI.getPostList, {cafeId}, {clientOnly: true});useAPIInfiniteQuery에서도 동일하게 사용합니다.
클라이언트 — 뮤테이션
데이터를 변경할 때 useAPIMutation을 사용합니다.
'use client';
import {VoteAPI, ApiServerError} from '@howmuchhome-web/api';
import {useAPIMutation} from '@howmuchhome-web/utils-react-query';
function VoteForm({voteId}: {voteId: string}) {
const mutation = useAPIMutation(VoteAPI.voteSubmission);
const handleSubmit = async () => {
try {
await mutation.mutateAsync({voteId, answers});
router.push('/complete');
} catch (e) {
if (ApiServerError.isApiServerError(e)) {
if (e.isErrorOfType('ALREADY_VOTED')) {
alreadyVotedAlert.open();
return;
}
}
throw e;
}
};
}클라이언트 — 무한 스크롤
페이지네이션이 있는 목록은 useAPIInfiniteQuery를 사용합니다.
'use client';
import {CafeAPI} from '@howmuchhome-web/api';
import {useAPIInfiniteQuery} from '@howmuchhome-web/utils-react-query';
function PostList({unionId}: {unionId: string}) {
const {data, fetchNextPage, hasNextPage} = useAPIInfiniteQuery(
CafeAPI.getPostList,
{unionId},
);
return (
<>
{data.pages.flatMap(page => page.data).map(post => (
<PostItem key={post.id} post={post} />
))}
{hasNextPage && <button onClick={() => fetchNextPage()}>더 보기</button>}
</>
);
}서버 — 프리페치와 Hydration
서버 컴포넌트(RSC)에서 fetchAPIQuery로 데이터를 미리 받아두면, 클라이언트 컴포넌트가 처음 렌더링될 때 로딩 없이 즉시 데이터를 사용할 수 있습니다.
이 흐름을 prefetch → Hydration 패턴이라고 합니다:
- RSC에서
fetchAPIQuery로 API를 호출해 서버 queryClient에 데이터를 저장 <Hydration>이 queryClient 상태를 직렬화해 클라이언트로 전달- 클라이언트의
useAPIQuery는 초기 렌더링 시 네트워크 요청 없이 캐시된 데이터를 바로 사용
// app/vote/[voteId]/page.tsx (서버 컴포넌트)
import {VoteAPI} from '@howmuchhome-web/api';
import {fetchAPIQuery, Hydration} from '@howmuchhome-web/utils-react-query/server';
export default async function VotePage({params}: {params: Promise<{voteId: string}>}) {
// fetchAPIQuery는 fetch한 데이터를 반환합니다. RSC에서 직접 사용할 수도 있습니다.
const {voteId} = await params;
const vote = await fetchAPIQuery(VoteAPI.getVoteDetail, {voteId});
return (
<Hydration>
<VoteDetail voteId={voteId} />
</Hydration>
);
}
fetchAPIQuery를 사용하는 서버 컴포넌트는 반드시<Hydration>으로 감싸야 합니다. 감싸지 않으면 서버에서 fetch한 데이터가 클라이언트로 전달되지 않습니다.
클라이언트 컴포넌트에서는 평소처럼 useAPIQuery를 사용하면 초기 데이터가 자동으로 채워집니다.
// vote-detail.tsx (클라이언트 컴포넌트)
'use client';
function VoteDetail({voteId}: {voteId: string}) {
// 서버에서 prefetch된 데이터를 바로 사용 — 로딩 없음
const {data: vote} = useAPIQuery(VoteAPI.getVoteDetail, {voteId});
return <div>{vote.title}</div>;
}서버 — 병렬 호출
여러 API를 순차적으로 호출하면 앞선 호출이 끝나야 다음 호출이 시작되는 waterfall 현상이 발생합니다.
서로 의존성이 없는 API는 Promise.all로 병렬 호출해 전체 응답 시간을 단축합니다.
// app/home/page.tsx (서버 컴포넌트)
import {UserAPI, ListingAPI} from '@howmuchhome-web/api';
import {fetchAPIQuery, Hydration} from '@howmuchhome-web/utils-react-query/server';
export default async function HomePage() {
// 순차 호출 (느림) — 피해야 할 패턴
// await fetchAPIQuery(UserAPI.getMe, {});
// await fetchAPIQuery(ListingAPI.getMyListingList, {});
// 병렬 호출 (빠름) — 권장 패턴
await Promise.all([
fetchAPIQuery(UserAPI.getMe, {}),
fetchAPIQuery(ListingAPI.getMyListingList, {}),
]);
return (
<Hydration>
<HomeContent />
</Hydration>
);
}클라이언트에서도 waterfall이 발생할 수 있습니다
서버 prefetch 없이 useAPIQuery만 쓰는 경우에도 컴포넌트 중첩 구조에 따라 waterfall이 발생합니다.
A 컴포넌트가 데이터를 기다리는 동안 B 컴포넌트는 렌더링 자체가 시작되지 않기 때문입니다.
// 문제: A의 쿼리가 끝난 후에야 B가 렌더링되고, B의 쿼리가 시작됨
function A() {
const {data} = useAPIQuery(SomeAPI.getA, {});
return <B />;
}
function B() {
const {data} = useAPIQuery(SomeAPI.getB, {}); // A 쿼리 완료 후에야 실행됨
return <div>{data.title}</div>;
}이를 해결하려면 서버에서 미리 병렬 prefetch해두거나, 클라이언트 전용 페이지라면 상위 컴포넌트에서 두 쿼리를 동시에 시작해야 합니다.
// 해결: 루트 RSC에서 두 API를 병렬 prefetch
export default async function Page() {
await Promise.all([
fetchAPIQuery(SomeAPI.getA, {}),
fetchAPIQuery(SomeAPI.getB, {}),
]);
return (
<Hydration>
<A /> {/* A, B 모두 초기 데이터가 이미 채워져 있음 */}
</Hydration>
);
}동적 배열 기반 병렬 호출
앞선 API 결과가 배열인 경우, 그 배열을 매핑해 다시 병렬 호출할 수 있습니다.
// 첫 단계: 목록 병렬 fetch
const [chatList] = await Promise.all([
fetchAPIQuery(UserAPI.getMyChatList),
fetchAPIQuery(UserAPI.getMe),
// ...
]);
// 두 번째 단계: 목록 결과를 기반으로 동적 병렬 fetch
const chatProfiles = await Promise.all(
chatList.map(chat =>
fetchAPIQuery(UserAPI.getMyProfile, {chatUrl: chat.chat.url}),
),
);병렬 호출이 불가능한 경우
다음 상황에서는 순차 호출이 불가피합니다:
- 앞선 API 결과에 의존하는 경우 — 예: 첫 번째 API로 ID를 얻은 후 해당 ID로 다른 API 호출
- 특정 조건에서만 호출해야 하는 경우 — 예: “진행 중인 매물”이 있을 때만 호출하는 API
이런 경우엔 순차 호출을 사용하되, 독립적인 나머지 API들은 병렬로 묶습니다.
서버 — layout에서 프리페치
page.tsx뿐 아니라 layout.tsx에서도 fetchAPIQuery를 호출할 수 있습니다.
layout 범위의 모든 페이지에서 공통으로 필요한 데이터를 미리 받아둘 때 사용합니다.
// app/(with-bottom-navigation)/layout.tsx
import {UserAPI} from '@howmuchhome-web/api';
import {fetchAPIQuery, Hydration} from '@howmuchhome-web/utils-react-query/server';
export default async function Layout({children}: {children: React.ReactNode}) {
await fetchAPIQuery(UserAPI.getMyChatList);
return (
<Hydration>
{children}
</Hydration>
);
}layout에서 prefetch한 데이터도 동일하게 클라이언트의 useAPIQuery에서 바로 사용됩니다.
layout에서 에러 처리
동적 라우트 layout에서는 try-catch로 API 에러를 받아 Next.js의 forbidden() / notFound()로 연결합니다.
// app/[chatUrl]/layout.tsx
export default async function CommunityLayout({params}: Props) {
const {chatUrl} = await params;
try {
const chat = await fetchAPIQuery(ChatAPI.getChatByUrl, {chatUrl});
const cafeId = chat.cafe.id;
await Promise.all([
fetchAPIQuery(ChatAPI.getChatParticipantsList, {chatUrl}),
fetchAPIQuery(CafeAPI.getCafeActiveNotice, {cafeId}),
]);
return <Hydration>{children}</Hydration>;
} catch (e) {
if (ApiServerError.is403Error(e)) forbidden();
if (ApiServerError.is404Error(e)) notFound();
throw e;
}
}서버 — RSC 간 데이터 공유
RSC 트리 내에서 같은 API를 여러 번 호출해도 네트워크 요청이 중복되지 않습니다.
Next.js는 하나의 렌더링 사이클 내에서 동일한 fetchAPIQuery 호출 결과를 자동으로 캐시합니다.
// home/page.tsx (루트 RSC) — API를 미리 병렬 호출
export default async function HomePage() {
await Promise.all([
fetchAPIQuery(UserAPI.getMyChatList, {}),
fetchAPIQuery(ListingAPI.getMyListingList, {}),
]);
return <HomeMyListings />;
}
// home/my-listings.tsx (자식 RSC)
export default async function HomeMyListings() {
// 이미 캐시된 결과를 사용 — 추가 네트워크 요청 없음
const chatList = await fetchAPIQuery(UserAPI.getMyChatList, {});
const listings = await fetchAPIQuery(ListingAPI.getMyListingList, {});
// ...
}이 덕분에 자식 RSC는 props로 데이터를 받지 않아도 되고, props drilling을 피하면서도 병렬 fetch의 이점을 그대로 누릴 수 있습니다.
자식 RSC에서 독립적인 fetchAPIQuery + Hydration
자식 RSC도 자신만의 fetchAPIQuery와 <Hydration>을 가질 수 있습니다.
페이지를 독립적인 섹션 단위로 분리할 때 유용합니다.
// home/page.tsx
export default async function HomePage() {
await Promise.all([
fetchAPIQuery(UserAPI.getMe),
fetchAPIQuery(ListingAPI.getMyListingList),
]);
return (
<Hydration>
<HomeHeader />
<HomeMyListings /> {/* 자체 fetch를 가진 자식 RSC */}
</Hydration>
);
}
// home/_sections/HomeMyListings.tsx
export default async function HomeMyListings() {
// 부모가 이미 호출했으므로 캐시된 결과 사용 (추가 네트워크 요청 없음)
const listings = await fetchAPIQuery(ListingAPI.getMyListingList);
// 이 섹션에서 추가로 필요한 데이터를 더 fetch할 수도 있음
const chatList = await fetchAPIQuery(UserAPI.getMyChatList);
return (
<Hydration> {/* 이 섹션의 클라이언트 컴포넌트에 데이터 전달 */}
<MyListingsClient />
</Hydration>
);
}서버 — 서버 액션
서버 액션에서는 API 메서드를 직접 호출합니다.
ApiServerError는 클라이언트로 그대로 전달되지 않으므로 반드시 직렬화가 필요합니다.
자세한 패턴은 컨벤션 — 서버 액션 에러 직렬화를 참고하세요.
'use server';
import {AuthAPI, UserAPI, ApiServerError} from '@howmuchhome-web/api';
export async function verifyAccessAction(props: Props) {
try {
const token = await AuthAPI.verifyAccess(props);
const status = await UserAPI.getMyOnboardingStatus();
return {token, status};
} catch (e) {
if (ApiServerError.isApiServerError(e)) {
throw e.serializeForServerAction(); // 직렬화 필수
}
throw e;
}
}API 모듈 목록
| 모듈 | 설명 |
|---|---|
AuthAPI | 인증, 본인인증 |
UserAPI | 사용자 프로필, 인증서 |
ListingAPI | 매물 등록 및 관리 |
VoteAPI | 총회 투표 |
SurveyAPI | 설문 |
CafeAPI | 커뮤니티 게시판 |
ChatAPI | 채팅 |
AssemblyAPI | 총회 관리 |
QuizAPI | 퀴즈 |
AssignmentAPI | 과제 |
DashboardAPI | 대시보드 |
OwnerGroupAPI | 오너 그룹 |
PrincipalOwnerAPI | 임원 |
RegionAPI | 지역 |
UnionAPI | 단지 정보 |
더 보기
- 컨벤션 — 에러 처리, 캐시 무효화, 새 엔드포인트 추가
- API 레퍼런스 — 데코레이터, 에러 클래스 스펙
- utils-react-query 레퍼런스 — 훅 및 서버 유틸리티 스펙