백앤드 API 사용 예제
dictionary-app은 axios 공용 인스턴스를 감싼 useApi 훅 위에, apis(요청 함수) → queries/mutations
(react-query 연결) 세 계층을 쌓는다. domain/menu 기능을 예시로 정리한다.
useApi 공통 훅
Section titled “useApi 공통 훅”- 공용 axios 인스턴스와 전역 스피너 상태를 조합해,
useSpinner가 true인 요청에 대해 요청 시작/종료 시점에 스피너 카운터를 자동으로 증감시킨다.hooks/useApi.ts export const useApi = () => {const {axiosInstance} = useAxiosInterceptor()const {increaseSpinner, decreaseSpinner} = useSpinnerStore()const call = useCallback(async <R, P = void>({endpoint,method,body,params,useSpinner = false,signal,}: AxiosParam<RequestDto<P>>): Promise<R> => {try {if (useSpinner) increaseSpinner()const response = await axiosInstance({url: bindUrl(endpoint),method,data: body,params,withCredentials: true,signal,})return response.data.payload as R} finally {if (useSpinner) decreaseSpinner()}},[axiosInstance, decreaseSpinner, increaseSpinner],)return {call, callRaw, callPage, callDownload}} - 네 가지 호출 함수가 각각 다른 형태의 응답을 벗겨서 돌려준다.
call<R, P>—ResponseDto<R>에서payload만R로 반환한다.callRaw<R, P>— axios 응답 객체 전체(status, headers 포함)를 그대로 반환한다.callPage<R, P>—PaginationResponseDto<R>에서{payload, pagination}을 반환한다. 서버사이드 페이지네이션 그리드와 연결하는 방법은 그리드 사용 예제 참고.callDownload<P>—Blob응답과Content-Disposition헤더에서 뽑은 파일명을 함께 반환한다(responseType: 'blob').
DTO 타입 참조
Section titled “DTO 타입 참조”백엔드 Swagger 스펙에서 생성한 타입을 components['schemas']['DtoName'] 형태로 바로 가져다 쓴다(생성 방법은
프런트앤드 개발 환경 설정 참고).
import type {components} from '@/types'
type MenuResponseDto = components['schemas']['MenuResponseDto']apis — 요청 함수
Section titled “apis — 요청 함수”call로 감싸 endpoint/method/body만 넘긴다.useMenuApi처럼 조회만 있는 기능은 간단하다.hooks/apis/useMenuApi.ts export const useMenuApi = () => {const {call} = useApi()const findRoot = useCallback((signal?: AbortSignal) => {return call<MenuResponseDto[]>({endpoint: '/v1/menu/find-root',method: 'get',signal,})},[call],)const findChild = useCallback((parentId: number, signal?: AbortSignal) => {return call<MenuResponseDto[]>({endpoint: `/v1/menu/find-child/${parentId}`,method: 'get',signal,})},[call],)return {findRoot, findChild}}- CRUD 전체가 있는 기능(
domain)은create/update/removeAll까지 포함한다.pages/word/domain/apis/useDomainApi.ts export const useDomainApi = () => {const {call, callPage} = useApi()const create = useCallback((payload: DomainCreateRequestDto, signal?: AbortSignal) => {return call<DomainResponseDto, DomainCreateRequestDto>({endpoint: '/v1/domain',method: 'post',body: {payload},signal,})},[call],)const update = useCallback((id: number, payload: DomainUpdateRequestDto, signal?: AbortSignal) => {return call<DomainResponseDto, DomainUpdateRequestDto>({endpoint: `/v1/domain/${id}`,method: 'put',body: {payload},signal,})},[call],)const removeAll = useCallback((ids: number[], signal?: AbortSignal) => {return call<void, DomainDeleteAllRequestDto>({endpoint: '/v1/domain/delete-all',method: 'delete',body: {payload: {ids}},signal,})},[call],)return {create, update, removeAll, findByPage, findAll}}
queries — 조회 (useQuery)
Section titled “queries — 조회 (useQuery)”apis의 조회 함수를queryFn으로 연결한다. react-query의signal을 그대로 전달해 쿼리 키 변경/언마운트 시 진행 중인 요청이 취소되게 한다.hooks/queries/useMenuRootQuery.ts export const useMenuRootQuery = () => {const {findRoot} = useMenuApi()const {data: menus,isLoading: isMenusLoading,error: menusError,} = useQuery({queryKey: ['menu', 'root'],queryFn: ({signal}) => findRoot(signal),})return {menus, isMenusLoading, menusError}}- react-query의 원본 반환값(
data/isLoading/error)을 그대로 노출하지 않고menus/isMenusLoading/menusError처럼 이름을 붙여 반환한다 — 호출부가 react-query 필드 이름을 몰라도 되고, 다른 쿼리 훅과 같이 쓸 때 이름이 겹치지 않는다. - 페이지 번호/검색 조건처럼 조회 자체에 필요한 상태는 쿼리 훅 내부에서 관리하고,
queryKey에 포함시켜 값이 바뀌면 자동으로 재요청되게 한다(수동 refetch 호출이 필요 없다).pages/word/domain/queries/useDomainListQuery.ts export const useDomainListQuery = () => {const {findByPage} = useDomainApi()const [pageNumber, setPageNumber] = useState(0)const [appliedFilter, setAppliedFilter] = useState<DomainFilter>(DOMAIN_FILTER_INITIAL)const {data, isLoading: isDomainsLoading} = useQuery({queryKey: ['domain', 'list', pageNumber, appliedFilter],queryFn: ({signal}) => findByPage(appliedFilter, pageNumber, PAGE_SIZE, signal),})const handlePageChange = (nextPageNumber: number) => {setPageNumber(nextPageNumber)}const pagination: Pagination = data?.pagination ?? {pageNumber,pageSize: PAGE_SIZE,totalCount: 0,}return {domains: data?.payload ?? [], pagination, isDomainsLoading, handlePageChange}}
mutations — 저장/삭제/다운로드 (useMutation)
Section titled “mutations — 저장/삭제/다운로드 (useMutation)”- 등록/수정처럼 캐시 무효화가 필요한 경우
onSuccess에서queryClient.invalidateQueries를 호출한다.pages/word/domain/mutations/useDomainSaveMutation.ts export const useDomainSaveMutation = () => {const {create} = useDomainApi()const {mutateAsync: saveDomain, isPending: isDomainSaving} = useMutation({mutationFn: (payload: DomainCreateRequestDto) => create(payload),onSuccess: () => {queryClient.invalidateQueries({queryKey: ['domain', 'list']})toaster.success({title: '등록되었습니다', description: '도메인이 등록됐습니다.'})},onError: (error) => {toaster.error({title: '등록 실패', description: getErrorMessage(error)})},})return {saveDomain, isDomainSaving}} - 캐시 무효화가 필요 없는 1회성 동작(엑셀/SQL 다운로드)도 버튼 클릭으로 실행되는 비동기 작업이라는 이유만으로
useMutation을 쓴다 — 로딩 상태(isPending)를 그대로 버튼에 연결할 수 있기 때문이다.hooks/mutations/useSqlExportMutation.ts export const useSqlExportMutation = () => {const {callDownload} = useApi()const {mutateAsync: downloadSqlExport, isPending: isSqlExportDownloading} = useMutation({mutationFn: async ({tableKey, fallbackFileName}: DownloadSqlExportParam) => {const {blob, fileName} = await callDownload({endpoint: `/v1/tools/export-data/${tableKey}`,method: 'get',params: {useDefaultAuditValue: true},})downloadBlob(blob, fileName ?? fallbackFileName ?? `${tableKey}.sql`)},onError: (error) => {toaster.error({title: 'SQL 다운로드 실패', description: getErrorMessage(error)})},})return {downloadSqlExport, isSqlExportDownloading}} queries(조회)와mutations(저장)를 분리하는 이유:useQuery는 컴포넌트가 마운트/의존값 변경 시 자동 실행되지만, 등록/수정/삭제는 사용자 액션(버튼 클릭)으로만 실행돼야 한다 —useMutation은mutateAsync호출 전까지 아무 것도 하지 않고,isPending으로 버튼 로딩 상태를 표현하기 쉽다.