Skip to content

백엔드 API 사용 예제

dictionary-app은 axios 공용 인스턴스를 감싼 useApi 훅 위에, apis(요청 함수) → queries/mutations (react-query 연결) 세 계층을 쌓는다. domain/menu 기능을 예시로 정리한다.

  1. 공용 axios 인스턴스와 전역 스피너 상태를 조합해, useSpinner가 true인 요청에 대해 요청 시작/종료 시점에 스피너 카운터를 자동으로 증감시킨다.
    hooks/useApi.ts
    7 collapsed lines
    import {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}
    }
  2. 네 가지 호출 함수가 각각 다른 형태의 응답을 벗겨서 돌려준다.
    • 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').

백엔드 Swagger 스펙에서 생성한 타입을 components['schemas']['DtoName'] 형태로 바로 가져다 쓴다(생성 방법은 프런트엔드 개발 환경 설정 참고).

import type {components} from '@/types'
type MenuResponseDto = components['schemas']['MenuResponseDto']
  1. call로 감싸 endpoint/method/body만 넘긴다. useMenuApi처럼 조회만 있는 기능은 간단하다.
    hooks/apis/useMenuApi.ts
    5 collapsed lines
    import {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}
    }
  2. CRUD 전체가 있는 기능(domain)은 create/update/removeAll까지 포함한다.
    pages/word/domain/apis/useDomainApi.ts
    4 collapsed lines
    import {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}
    }
  1. 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}
    }
  2. react-query의 반환값(data/isLoading/error)은 menus/isMenusLoading/menusError처럼 이름을 바꿔 반환한다. 호출부는 react-query 필드 이름을 몰라도 되고, 여러 쿼리 훅을 함께 써도 이름이 겹치지 않는다.
  3. 페이지 번호/검색 조건처럼 조회 자체에 필요한 상태는 쿼리 훅 내부에서 관리하고, queryKey에 포함시켜 값이 바뀌면 자동으로 재요청되게 한다(수동 refetch 호출이 필요 없다).
    pages/word/domain/queries/useDomainListQuery.ts
    8 collapsed lines
    import {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)”
  1. 등록/수정처럼 캐시 무효화가 필요한 경우 onSuccess에서 queryClient.invalidateQueries를 호출한다.
    pages/word/domain/mutations/useDomainSaveMutation.ts
    6 collapsed lines
    import {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}
    }
  2. 캐시를 무효화할 필요가 없는 1회성 동작(엑셀/SQL 다운로드)도 버튼으로 실행하는 비동기 작업이면 useMutation을 쓴다. 로딩 상태(isPending)를 버튼에 바로 연결할 수 있기 때문이다.
    hooks/mutations/useSqlExportMutation.ts
    5 collapsed lines
    import {useMutation} from '@tanstack/react-query'
    import {downloadBlob, getErrorMessage, toaster} from '@/utils'
    import {useApi} from '../useApi'
    type DownloadSqlExportParam = {
    tableKey: string
    fallbackFileName?: 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}
    }
  3. queries(조회)와 mutations(저장)를 나누는 이유: useQuery는 마운트되거나 의존값이 바뀌면 자동으로 실행된다. 반면 등록/수정/삭제는 버튼 클릭 같은 사용자 동작으로만 실행돼야 한다. useMutation은 mutateAsync를 호출하기 전까지 아무것도 하지 않고, isPending으로 버튼 로딩 상태도 쉽게 표현한다.