API 계층 설계와 에러 핸들링


API 통신 계층을 설계하고, 에러 핸들링을 통한 서비스의 안정성을 높였던 과정에 대해 공유해보려 합니다.

초기 프로젝트의 컴포넌트들은 각자 데이터를 불러오고, 로딩 상태를 관리하며, try/catch로 에러를 처리하는 등 너무 많은 책임을 지고 있었어요.

이 문제를 해결하기 위해 먼저 반복적인 데이터 페칭 로직을 Fetcher라는 중앙화된 모듈로 추상화했고, 이것이 전체 에러 핸들링 전략의 핵심 기반이 되었어요.

💡 이 글은 Svelte 5와 Tanstack Query (v5)를 사용하는 환경을 기반으로 작성했으며, 흩어져 있던 API 호출 코드를 어떻게 체계적으로 개선하고, 이를 Tanstack Query와 결합하여 예측 가능하고 유지보수하기 쉬운 에러 핸들링 패턴을 완성했는지에 대한 기록입니다.



문제 상황

코드 중복이나 낮은 유지보수성 같은 겉으로 보이는 현상보다, 서버와 통신하는 방법에 대한 통일된 규칙, 즉 아키텍처가 없어서 여러 구조적인 문제가 발생하고 있었어요.


1. 책임질게 많은 컴포넌트

컴포넌트의 핵심 역할은 UI를 선언적으로 렌더링하는 것이에요. 하지만 초기 코드는 하나의 컴포넌트가 너무 많은 역할을 하고 있었어요.

<script>
  let data = $state(null);
  let error = $state(null);
  let loading = $state(true);

  $effect(() => {
    async function fetchData() {
      try {
        const res = await fetch('/api/data');
        if (!res.ok) throw new Error('Failed to fetch');
        data = await res.json();
      } catch (e) {
        error = e;
      } finally {
        loading = false;
      }
    }

    fetchData();
  });
</script>

{#if loading}
  <Loading />
{:else if error}
  <ErrorView {error} />
{:else}
  <DataComponent {data} />
{/if}

폼(Form) 제출과 같이 사용자의 입력과 유효성 검사가 포함되면 문제는 더욱 복잡해져요.

<script lang="ts">
  let form = $state<LicenseCreateForm>({
    name: '',
    projectId: '',
    companyId: '',
    customerId: '',
    ...
  });

  let errors = $state({ name: '', projectId: '', api: '' });
  let isSubmitting = $state(false);

  async function handleSubmit() {
    // 1. 유효성 검사 로직
    let isValid = true;
    errors = { name: '', projectId: '', api: '' }; // 에러 초기화

    if (!form.name) {
      errors.name = '라이선스 이름은 필수 항목입니다.';
      isValid = false;
    }
    ...
    if (!isValid) return;

    // 2. 제출 및 API 통신 로직
    isSubmitting = true;
    try {
      const res = await fetch('/api/licenses', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form),
      });

      if (!res.ok) {
        // API가 실패 응답을 JSON으로 반환한다고 가정
        const errorData = await res.json();
        throw new Error(errorData.message || '라이선스 등록에 실패했습니다.');
      }
      // 성공 처리 로직
      toast.success('등록이 완료되었습니다.');
    } catch (e) {
      // 3. API 에러 처리 로직
      errors.api = (e as Error).message;
    } finally {
      isSubmitting = false;
    }
  }
</script>

<form onsubmit={handleSubmit}>
  <div>
    <label for="name">라이선스 이름</label>
    <input id="name" type="text" bind:value={form.name} />
    {#if errors.name}<p class="error">{errors.name}</p>{/if}
  </div>

  <!-- 다른 폼 필드들 (companyId, customerId) 생략 -->

  <button type="submit" disabled={isSubmitting}>
    {isSubmitting ? '등록 중...' : '등록하기'}
  </button>

  {#if errors.api}<p class="error api-error">{errors.api}</p>{/if}
</form>

이는 명백한 단일 책임 원칙(SRP) 위반인데요. 컴포넌트는 UI 렌더링뿐만 아니라 UI 상태 관리, 유효성 검증, 네트워크 통신, 에러 처리의 역할까지 하며 본질보다 비동기 통신과 상태 관리를 위한 보일러플레이트 코드가 더 많은 공간을 차지하는 현상이 발생했어요.



2.예측 불가능한 에러

가장 심각한 문제는 통신 실패를 다루는 일관된 전략이 없었고, 에러는 다양한 형태로 발생 한다는 점이었어요.

  • 네트워크 오류: fetch 자체가 실패하여 예외를 던지는 경우.
  • 서버 에러 (5xx): 서버는 응답했지만, 내용이 비어있거나 HTML 오류 페이지인 경우.
  • 클라이언트 에러 (4xx): 서버가 의도적으로 실패를 알리는 JSON 응답을 보내는 경우.

각 컴포넌트는 이 모든 시나리오를 개별적으로 try/catch 안에서 처리해야 했어요. 이는 에러 객체의 형식이 제각각이고, 처리 방식도 달라 전역적인 대응도 불가능했어요.

예를 들어, 401 Unauthorized 에러가 발생했을 때 모든 컴포넌트에서 일관되게 로그인 페이지로 리디렉션하는 로직을 추가하는 것은 거의 불가능에 가까웠고, 실패 상황을 예측 불가능한 것은 서비스 안정성에 치명적이었어요.



3. 경직된 아키텍처

모든 컴포넌트가 API 통신 로직을 직접 소유하면서, 애플리케이션 전체가 API 서버의 구현 세부사항에 강하게 결합 되어있었어요.

  • API 기본 주소 (https://api.example.com)
  • 인증 토큰을 담는 헤더 형식 (Authorization: Bearer ...)

공통 관심사 중 하나라도 변경되면, 관련된 모든 컴포넌트를 찾아 일일이 수정해야 했고, 이런 구조는 사소한 변경이 애플리케이션 전반에 걸쳐 예측 불가능한 사이드 이펙트를 일으키는 아키텍처를 의미해요. 변화에 유연하게 대응할 수 없고, 새로운 기능을 추가하는 비용이 증가하는 구조적 문제도 있죠.


이러한 문제 정의들을 바탕으로, 단순히 코드를 줄이는 것을 넘어 역할과 책임을 명확히 분리하고, 예측 가능한 실패를 만들며, 변화에 유연한 아키텍처를 구축하는 것을 목표로 삼고, Fetcher라는 모듈을 설계했어요.



해결 방안 1: HTTPError 기반의 Fether 모듈 설계

반복되는 로직을 추상화하고 API 호출에 일관성을 부여하기 위해 Fetcher라는 공통 모듈을 설계했고, 그 안에는 baseFetcher라는 함수가 있어요.


HttpError: 예측 가능한 실패 형태

먼저, 모든 에러를 표준화하기 위해 커스텀 에러 클래스를 정의했어요. 이 클래스는 에러 처리 시스템의 기반이 되죠.

// lib/api/http.error.ts
export class HttpError extends Error {
  readonly status: number;
  readonly code?: string;
  readonly data: unknown;

  constructor({
    message,
    status,
    code,
    data = null,
  }: {
    message: string;
    status: number;
    code?: string;
    data?: unknown;
  }) {
    super(message);
    this.name = 'HttpError';
    this.status = status;
    this.code = code;
    this.data = data;
  }
}

baseFetcher: 모든 요청의 기반

API 통신의 모든 복잡성을 캡슐화하여, 사용하는 다른 부분들이 api 통신의 세부 구현을 알 필요가 없도록 만드는 것이 목표예요.

// lib/api/fetcher.ts
async function baseFetcher<T>(
  method: string,
  endpoint: string,
  payload?: unknown
): Promise<T> {
  const { hasToken, sessionToken } = getSessionInfo();
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
  };

  // 1. 인증 헤더 자동 추가
  if (hasToken) {
    headers['Authorization'] = `Bearer ${sessionToken}`;
  }

  const options: RequestInit = {
    method,
    headers,
  };

  // 2. GET이 아닌 메서드에 payload(body) 추가
  if (method !== 'GET' && payload !== undefined) {
    options.body = JSON.stringify(payload);
  }

  try {
    const response = await fetch(`${API_BASE}/${endpoint}`, options);

    // 3. 일관된 에러 객체 생성
    if (!response.ok) {
      const errorData = await response.json().catch(() => null); // body가 없는 에러 응답 대비
      throw new HttpError(
        errorData?.message || '서버 응답이 올바르지 않습니다.',
        response.status,
        errorData
      );
    }
    return await response.json();
  } catch (error) {
    // 4. 네트워크 에러 등 fetch 실패 처리
    if (error instanceof HttpError) {
      throw error; // 이미 처리된 HttpError는 다시 throw
    }
    // 그 외 모든 에러를 HttpError로 감싸서 일관성 유지
    throw new HttpError(
      (error as Error)?.message || '네트워크 오류 또는 예기치 못한 에러 발생',
      -1, // 네트워크 에러는 status 코드를 -1로 지정
      error
    );
  }
}

baseFetcher 함수는 다음과 같은 의도로 작성했어요.

  • 타입 안정성 보장: 제네릭 <T>를 통해 반환 데이터 타입을 강제하여 컴포넌트까지 타입이 안전하게 전파돼요.
  • 공통 설정 중앙화: 인증 헤더, Content-Type 등 설정을 한 곳에서 제어해 유지보수를 간편하게 만들어요.
  • 에러 정규화: 서버 오류, JSON 파싱 실패, 네트워크 장애까지 모두 HttpError로 감싸 통일된 에러 형태로 전파해요.

Fetcher 객체: 직관적인 API 제공

baseFetcher는 method 문자열을 직접 받는 등 사용성이 떨어졌어요. Fetcher 객체는 파사드 패턴을 적용하여, 복잡한 baseFetcher를 숨기고 더 단순하고 의도가 명확한 인터페이스를 제공해요.

💡 파사드 패턴(Facade Pattern)
복잡한 서브시스템에 대한 단순화된 인터페이스를 제공하는 디자인 패턴이에요. 건물의 정면(Facade)이 내부의 복잡한 구조는 숨기고 깔끔한 입구만 보여주듯이, Fetcher 객체는 baseFetcher의 복잡한 구현을 숨기고 get, post 같은 단순한 메서드만 밖으로 노출하는 역할을 해요.

// lib/api/fetcher.ts (이어서)

export const Fetcher = {
  get: <T>(endpoint: string): Promise<T> => baseFetcher<T>('GET', endpoint),
  post: <T>(endpoint: string, payload: unknown): Promise<T> =>
    baseFetcher<T>('POST', endpoint, payload),
  put: <T>(endpoint: string, payload: unknown): Promise<T> =>
    baseFetcher<T>('PUT', endpoint, payload),
  delete: <T>(endpoint: string, payload?: unknown): Promise<T> =>
    baseFetcher<T>('DELETE', endpoint, payload),
};

이제 모든 API 호출은 Fetcher.get('/...')과 같이, SDK를 사용하듯 API를 호출할 수 있어요.


왜 라이브러리 대신 fetch를 선택했을까?

axios 같은 라이브러리 대신 브라우저 네이티브 fetch API를 직접 사용한 데에는 몇 가지 이유가 있어요.

  • 의존성 최소화: fetch는 모든 현대 브라우저에 내장되어 있어요. 별도의 라이브러리를 추가하지 않음으로써 최종 번들 사이즈를 줄이고, 외부 패키지의 업데이트나 잠재적인 보안 문제로부터 자유로워질 수 있어요. 이는 프로젝트의 경량성과 장기적인 유지보수성에 기여한다 판단했어요.

  • 맞춤형 제어권 확보: fetch는 저수준 API이므로, 요청과 응답의 모든 과정을 세밀하게 제어할 수 있어요. HttpError 정규화, 타임아웃, 재시도 로직 등 팀만의 규칙을 정해 라이브러리의 기본 동작에 맞추는 것이 아니라, 처음부터 원하는 대로 정확하게 구현할 수 있었어요. 이 완전한 제어권이 가장 큰 장점이라 느꼈어요.

  • 웹 표준과의 호환성: fetch는 Request, Response, Headers와 같은 웹 표준 객체를 사용해요. 이는 Service Worker나 Cache API 등 다른 브라우저 API와 자연스럽게 연동되어, 애플리케이션의 기능을 확장할 때 일관되고 표준적인 방식을 유지할 수 있게 해줘요.

이러한 이유로, 약간의 초기 설정이 더 필요하더라도 장기적인 관점에서 더 유연하고 안정적인 아키텍처를 구축하기 위해 네이티브 fetch를 선택했어요.



Tanstack Query와 Fetcher의 시너지

잘 설계된 Fetcher는 Tanstack Query와 결합될 때 명확한 관심사 분리를 해요.

계층책임 (Concern)예시 코드
Component무엇을(What) 보여줄 것인가?
(UI 렌더링, 사용자 상호작용)
{#if $query.isPending}
Tanstack Query언제(When), 왜(Why) 통신할 것인가?
(캐싱, 재시도, 상태 관리)
createQuery({ queryKey: ..., queryFn: ... })
Fetcher어떻게(How) 통신할 것인가?
(HTTP 명세, 인증, 에러 정규화)
Fetcher.get('/users')

// hooks/useDataQuery.ts
import { createQuery } from '@tanstack/svelte-query';
import { Fetcher } from '$lib/api/fetcher';

export const useDataQuery = () => {
  return createQuery({
    queryKey: ['data'],
    // queryFn을 별도의 fetch 함수로 분리했지만,
    // 이 예제에서는 Fetcher.get이 어떻게 사용되는지 명확히 보이기 위해 인라인으로 작성했어요.
    queryFn: async () => {
      const response = await Fetcher.get<ApiResponse>(ENDPOINT.DATA);
      return response.result;
    },
  });
};

이 구조 덕분에 queryFnFetcher를 호출하는 역할에만 집중하고, 각 계층은 자신의 책임에만 온전히 집중할 수 있어요.


<script lang="ts">
  import { useDataQuery } from './hooks/useDataQuery';

  const dataQuery = useDataQuery();
</script>

{#if $dataQuery.isPending}
  <Loading />
{:else if $dataQuery.isError}
  <ErrorFallback error={$dataQuery.error} />
{:else if $dataQuery.isSuccess}
  <DataComponent data={$dataQuery.data} />
{/if}

컴포넌트는 Tanstack Query가 제공하는 상태 플래그(isPending, isError 등)를 사용하여 선언적으로 UI를 렌더링 하고, {#if} 분기문은 존재하지만, 그 내부 로직은 Fetcher가 없던 때보다 확실히 간결해졌어요.



해결 방안 2: 중앙화된 에러 처리 시스템 구축

Fetcher를 통해 모든 에러를 HttpError로 표준화했고, 이제 이 에러를 해석하고 정책에 따라 처리하는 중앙 시스템을 구축하여 에러 핸들링 했어요.


1. 에러 코드 정의(errorCode.ts)

가장 먼저, 애플리케이션에서 발생할 수 있는 모든 에러를 한 곳에 정의 하고, requireLogin과 같은 정책 플래그를 사용하여 처리 방식을 결정했어요.

// lib/errorUtil/errorCode.ts

/**
 * 에러 코드와 메시지를 중앙에서 관리하는 객체입니다.
 */
export const ERROR_DEFINITIONS = {
  // 기본 에러
  DEFAULT: {
    code: 'ERROR',
    message: '알 수 없는 오류가 발생했습니다.',
  },

  // 클라이언트 네트워크 에러 코드
  FETCH_ERROR: {
    code: 'FETCH_ERROR',
    message: '서버가 응답하지 않습니다. 네트워크 상태를 확인해주세요.',
  },
  TIMEOUT: {
    code: 'TIMEOUT',
    message: '요청 시간이 초과되었습니다.',
  },

  // HTTP 상태 코드 및 서버 정의 코드
  400: { code: '400', message: '잘못된 요청입니다.' },
  401: { code: '401', message: '인증이 필요합니다.', requireLogin: true },
  4011: { code: '4011', message: '인증이 만료되었습니다.', requireLogin: true },
  403: { code: '403', message: '권한이 없습니다.' },
  404: { code: '404', message: '요청하신 리소스를 찾을 수 없습니다.' },
  500: { code: '500', message: '서버 오류가 발생했습니다.' },
} as const;

export type ErrorCodeKey = keyof typeof ERROR_DEFINITIONS;

export type StandardizedError = (typeof ERROR_DEFINITIONS)[ErrorCodeKey] & {
  originalError: unknown;
};

2. 에러 데이터 파서 (parseError.ts)

다음으로, HttpError 객체를 받은 에러 코드를 참조하여 표준화된 에러 데이터를 반환하는 parser를 만들었어요.

서비스 전반에서 동일한 에러 타입을 받을 수 있도록, 모든 에러 정의를 ERROR_DEFINITIONS에 중앙화했고, 이 덕분에 타입 안정성을 유지하면서, 새로운 에러를 추가할 때는 정의 객체만 수정하면 돼요.

  • 우선순위: HttpError.status (HTTP 상태 코드) → HttpError.code (클라이언트 정의 코드)
// lib/errorUtil/parseError.ts
import { HttpError } from './http.error';
import {
  ERROR_DEFINITIONS,
  ErrorCodeKey,
  StandardizedError,
} from './errorCode';

/**
 * 런타임 타입 가드: key가 에러 정의에 존재하는지 확인
 */
function isErrorCodeKey(value: unknown): value is ErrorCodeKey {
  return value !== null && value !== undefined && value in ERROR_DEFINITIONS;
}

/**
 * 에러를 표준화된 에러 정보로 변환
 */
export const parseError = (error: unknown): StandardizedError => {
  if (!(error instanceof HttpError)) {
    return {
      ...ERROR_DEFINITIONS.DEFAULT,
      originalError: error,
    };
  }

  const { status, code, data } = error;

  let resolvedKey: ErrorCodeKey = 'DEFAULT';

  if (isErrorCodeKey(status)) {
    resolvedKey = status;
  } else if (isErrorCodeKey(code)) {
    resolvedKey = code;
  }

  const definition = ERROR_DEFINITIONS[resolvedKey];

  return {
    ...definition,
    message:
      typeof (data as any)?.message === 'string'
        ? (data as any).message
        : definition.message,
    originalError: error,
  };
};
  • 401 응답이 오면 → status 기반 매핑
  • status가 정의 안 돼 있으면 → code 기반 매핑
  • 둘 다 없으면 DEFAULT

3. 모든 것을 연결하는 QueryClient

// lib/queryClient.ts

/**
 * 쿼리(GET) 실패 시 호출될 전역 핸들러.
 * 인증/권한 에러(401, 403) 발생 시 로그인 페이지로 리디렉션하는 '플로우 제어'를 담당합니다.
 */
const handleGlobalQueryError = (error: unknown) => {
  const standardizedError = parseError(error);

  if (standardizedError.requireLogin) {
    // '로그인 필요' 정책이 있는 에러만 여기서 처리
    // 안내 팝업 렌더링 및 세션 토큰 제거, 로그인 페이지로 redirect

    popup.open();
    goto('/login');
  }

  // 그 외 404, 500 등 'localError'는 global에서 처리 하지 않고,
  // 에러가 '컴포넌트'로 전파되어 지역적으로 처리해요.
};

/**
 * 뮤테이션(POST, PUT, DELETE) 실패 시 호출될 핸들러.
 * 사용자의 액션에 대한 '피드백 에러'로, 모든 에러를 Toast 메시지로 보여줍니다.
 */
const handleMutationFeedbackError = (error: unknown) => {
  const standardizedError = parseError(error);

  // 그 외 모든 뮤테이션 에러는 사용자에게 Toast로 피드백
  toast.error(standardizedError.message);
};

export const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: handleGlobalQueryError,
  }),

  mutationCache: new MutationCache({
    onError: handleMutationFeedbackError,
  }),
});

  • Global Errors: queryCache.onError에 연결된 handleGlobalQueryError가 담당해요. requireLogin 정책이 있는 401 (Unauthorized), 403 (Forbidden) 에러가 발생하면, 사용자에게 팝업을 통해 알리고 로그인 페이지로 리디렉션하는 등 전역적인 흐름을 제어해요.
popup

  • Local Errors: 404 (Not Found), 500 (Server Error)등 requireLogin 정책이 없는 쿼리 에러는 handleGlobalQueryError에서 아무런 동작도 하지 않아요. 에러는 Svelte Query의 기본 동작에 따라 컴포넌트의 $query.error로 전달되어, 컴포넌트가 재시도 버튼을 보여주는 등 지역적인 UI를 처리해요.


  • Mutation Errors: mutationCache.onError에 연결된 handleMutationFeedbackError가 담당해요. 사용자의 액션(생성/수정/삭제)에 대한 결과이므로, Toast 메시지처럼 작업 흐름을 방해하지 않는 피드백을 제공해요.
toast



마무리하며

이번 작업을 통해 배운 핵심은, 견고한 추상화 계층(Fetcher)이 애플리케이션 전체의 안정성과 유지보수성을 결정하는 기반이 될 수 있다는 것이었고, 처음에는 단순히 반복 코드를 줄이려는 목적으로 시작했지만 결과적으로는 다음 내용들을 배웠어요.

  • 명확한 역할 분담: Fetcher는 통신, Tanstack Query는 상태 관리, 컴포넌트는 UI 렌더링이라는 각자의 책임에만 집중하게 되었어요.

  • 예측 가능한 에러 처리: 모든 에러가 HttpError라는 단일 형태로 표준화되어, 전역 및 지역 에러 처리가 매우 단순해졌어요.

  • 향상된 개발 경험: 개발자는 더 이상 인증 헤더나 에러 포맷 같은 세부 사항을 신경 쓸 필요 없이 Fetcher.get / Fetcher.post 등만 호출하면 되었어요.

때로는 프레임워크의 최신 기능만 쫓기보다, 이처럼 기본에 충실한 설계로도 안정적이고 확장성 있는 코드를 만든다는 것을 다시 한번 체감할 수 있었어요. 이번에 설계한 구조를 바탕으로, 앞으로도 더 나은 개발 경험과 안정적인 서비스를 위해 지속적으로 발전시켜 나갈 예정이에요.