백엔드 API 사용 예제
dictionary-app은 axios 공용 인스턴스를 감싼 useApi 훅 위에, apis(요청 함수) → queries/mutations
(react-query 연결) 세 계층을 쌓는다. domain/menu 기능을 예시로 정리한다.
useApi 공통 훅
Section titled “useApi 공통 훅”- 공용 axios 인스턴스와 전역 스피너 상태를 조합해,
useSpinner가 true인 요청에 대해 요청 시작/종료 시점에 스피너 카운터를 자동으로 증감시킨다.hooks/useApi.ts 7 collapsed linesimport {useCallback} from 'react'import type {AxiosParam, RequestDto} from '@/types'import {bindUrl} from '@/utils'import {useSpinnerStore} from './stores'import {useAxiosInterceptor} from './useAxiosInterceptor'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 5 collapsed linesimport {useCallback} from 'react'import type {components} from '@/types'import {useApi} from '../useApi'type MenuResponseDto = components['schemas']['MenuResponseDto']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 4 collapsed linesimport {useCallback} from 'react'import type {components} from '@/types'import {useApi} from '@/hooks'type DomainResponseDto = components['schemas']['DomainResponseDto']type DomainCreateRequestDto = components['schemas']['DomainCreateRequestDto']type DomainUpdateRequestDto = components['schemas']['DomainUpdateRequestDto']type DomainDeleteAllRequestDto = components['schemas']['DomainDeleteAllRequestDto']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],)// findByPage(callPage 사용)·findAll은 생략return {create, update, removeAll, findByPage, findAll}}
queries — 조회 (useQuery)
Section titled “queries — 조회 (useQuery)”apis의 조회 함수를queryFn으로 연결한다. react-query의signal을 그대로 전달해 쿼리 키 변경/언마운트 시 진행 중인 요청이 취소되게 한다.hooks/queries/useMenuRootQuery.ts import {useQuery} from '@tanstack/react-query'import {useMenuApi} from '../apis'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 8 collapsed linesimport {useState} from 'react'import {useQuery} from '@tanstack/react-query'import type {Pagination} from '@/types'import {useDomainApi} from '../apis'import type {DomainFilter} from '../models'import {DOMAIN_FILTER_INITIAL, PAGE_SIZE} from '../useDomain'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 6 collapsed linesimport {useMutation} from '@tanstack/react-query'import type {components} from '@/types'import {getErrorMessage, queryClient, toaster} from '@/utils'import {useDomainApi} from '../apis'type DomainCreateRequestDto = components['schemas']['DomainCreateRequestDto']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 5 collapsed linesimport {useMutation} from '@tanstack/react-query'import {downloadBlob, getErrorMessage, toaster} from '@/utils'import {useApi} from '../useApi'type DownloadSqlExportParam = {tableKey: stringfallbackFileName?: string}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으로 버튼 로딩 상태도 쉽게 표현한다.