Skip to Content
APIAPI 관리

API 관리

서버 API 호출은 두 패키지가 협력해 동작합니다.

패키지역할
@howmuchhome-web/apiAPI 엔드포인트 정의, 에러 클래스
@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 패턴이라고 합니다:

  1. RSC에서 fetchAPIQuery로 API를 호출해 서버 queryClient에 데이터를 저장
  2. <Hydration>이 queryClient 상태를 직렬화해 클라이언트로 전달
  3. 클라이언트의 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단지 정보

더 보기

Last updated on