2023년 01월 05일
8

API 아키텍처

프론트엔드
KKingmo

Changmo Oh

@KKingmo

전체 글 보기

프론트엔드 애플리케이션의 규모가 커질수록 API 호출은 단순한 데이터 가져오기가 아닌, 하나의 견고한 시스템으로 관리되어야 한다. 단순히 기능이 동작하는 단계를 넘어, 실패에 유연하고, 타입이 안전하며, 디버깅이 쉬운 계층을 설계해야 유지보수 비용을 낮출 수 있다.

대규모 트래픽과 복잡한 도메인을 다루는 환경에서 사용하는 패턴을 기반으로, API 레이어를 다루는 전략을 정리한다.

1. API 클라이언트의 순수성 확보 (Decoupling)

API 클라이언트(axios 인스턴스)가 UI 로직과 강하게 결합되어 있다면, API 레이어의 단위 테스트를 어렵게 만들고, Next.js와 같은 서버 사이드 환경이나 다른 프로젝트에서의 재사용을 불가능하게 한다.

API 클라이언트는 오직 요청, 응답, 그리고 예외 발생(Throwing)에만 집중해야 한다. UI 피드백이나 라우팅 처리는 이를 사용하는 소비(Consumer) 계층에서 처리하거나, 의존성을 주입받는 형태로 설계해야 한다.

Axios 인스턴스 구조

import Axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { env } from '@/config/env'
 
// 1. UI와 비즈니스 로직 분리
// 에러 처리를 위한 콜백을 외부에서 주입받을 수 있도록 설계한다.
// 실제 프로덕션에서는 Store나 Event Emitter를 연결하여 사용한다.
let onUnauthorized: () => void
 
export const setupAPIClient = (unauthorizedCallback: () => void) => {
  onUnauthorized = unauthorizedCallback
}
 
// 2. 명시적인 헤더 처리
function authRequestInterceptor(config: InternalAxiosRequestConfig) {
  config.headers.Accept = 'application/json'
  return config
}
 
export const api = Axios.create({
  baseURL: env.API_URL,
  withCredentials: true,
  timeout: 10000, // 프로덕션 환경에서 타임아웃 설정은 필수다.
})
 
api.interceptors.request.use(authRequestInterceptor)
api.interceptors.response.use(
  (response) => response.data,
  async (error: AxiosError) => {
    // 3. 글로벌 에러 핸들링 로직
    if (error.response?.status === 401) {
      // 순환 참조를 피하기 위해 주입된 콜백 사용
      if (onUnauthorized) {
        onUnauthorized()
      }
    }
  }
)
 

Tip
window.location.href를 인터셉터 안에서 직접 사용하는 것은 지양해야 한다. SPA의 라우팅 컨텍스트를 잃어버려 사용자 경험을 해친다. 대신 인증 상태를 관리하는 Store나 Context에서 401 이벤트를 감지하여 라우터(Maps)를 통해 이동시키는 것이 바람직하다.

2. 쿼리 키 팩토리 (Query Key Factory) 도입

React Query(TanStack Query)를 사용할 때 쿼리 키를 문자열 배열로 하드코딩하는 것은 안티 패턴이다. 이는 오타로 인한 캐싱 오류를 유발하고, 특정 도메인의 쿼리를 일괄 무효화(invalidateQueries) 할 때 추적을 어렵게 만든다.

쿼리 키 팩토리 패턴을 사용하여 키 관리를 중앙화해야 한다.

// @/lib/query-keys.ts
 
// 도메인별로 키를 구조적으로 관리한다.
export const discussionKeys = {
  all: ['discussions'] as const,
  lists: () => [...discussionKeys.all, 'list'] as const,
  list: (filters: string) => [...discussionKeys.lists(), { filters }] as const,
  details: () => [...discussionKeys.all, 'detail'] as const,
  detail: (id: string) => [...discussionKeys.details(), id] as const,
}
 

3. Zod를 활용한 런타임 타입 안전성 (Runtime Validation)

TypeScript는 컴파일 타임에만 작동한다. API 응답이 예상한 타입과 다르게 내려올 경우, 런타임 에러가 발생하여 앱이 멈출 수 있다. Zod와 같은 스키마 유효성 검사 라이브러리를 사용하여 입력(Input)과 출력(Output)의 무결성을 경계(Edge)에서 검증해야 한다.

통합된 API Hook 패턴

단순 데이터 페칭을 넘어, 스키마 검증과 캐시 무효화 전략, UI 피드백이 포함된 훅을 작성한다.

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '@/lib/api-client'
import { discussionKeys } from '@/lib/query-keys' // 위에서 정의한 팩토리
import { useToast } from '@/hooks/use-toast' // UI 의존성은 훅 레벨에서 주입
 
// 1. Zod Schema: Input 뿐만 아니라 Response 스키마도 정의하여 검증한다.
export const createDiscussionSchema = z.object({
  title: z.string().min(1, '제목은 필수입니다.'),
  body: z.string().min(10, '본문은 10자 이상이어야 합니다.'),
})
 
// 타입 추론을 통해 코드 중복을 제거한다.
export type CreateDiscussionInput = z.infer<typeof createDiscussionSchema>
export type DiscussionResponse = { id: string; title: string; body: string }
 
// 2. Fetcher 함수: 순수한 비동기 함수
const createDiscussionFn = async (
  data: CreateDiscussionInput
): Promise<DiscussionResponse> => {
  return api.post('/discussions', data)
}
 
// 3. Custom Hook: 비즈니스 로직과 UI 피드백의 결합
export const useCreateDiscussion = () => {
  const queryClient = useQueryClient()
  const { toast } = useToast()
 
  return useMutation({
    mutationFn: createDiscussionFn,
 
    onSuccess: () => {
      // 팩토리를 사용하여 안전하게 키 무효화
      queryClient.invalidateQueries({ queryKey: discussionKeys.lists() })
 
      toast({
        title: '성공',
        description: '게시글이 등록되었습니다.',
        variant: 'success',
      })
    },
 
    onError: (error: any) => {
      toast({
        title: '오류 발생',
        description:
          error.response?.data?.message || '잠시 후 다시 시도해주세요.',
        variant: 'destructive',
      })
    },
  })
}
 
 

요약

유지보수 가능한 코드는 추상화의 레벨책임의 분리에 있다.

  • API Client: UI 의존성을 제거하고 순수한 통신 모듈로 유지한다.
  • Query Keys: 팩토리 패턴을 사용해 하드코딩과 휴먼 에러를 방지한다.
  • Custom Hooks: 캐시 무효화, 토스트 메시지 등 비즈니스 로직을 캡슐화한다.
  • Validation: TypeScript를 맹신하지 말고 런타임 데이터를 검증한다.

이 구조를 도입하면 팀의 규모가 커지고 기능이 복잡해져도, 프론트엔드 데이터 레이어의 복잡도를 효과적으로 제어할 수 있다.