← 전체 글로 돌아가기

API

TypeScript discriminated union으로 API 응답 안전하게 다루기

API 응답의 성공/실패를 정확하게 타입하면, 런타임 에러를 많이 줄일 수 있다.

API를 다룰 때 가장 흔한 실수는 응답의 구조를 정확하게 타입하지 않는 것이다. 성공 응답과 실패 응답을 구분하지 않거나, 데이터가 있을 수도, 없을 수도 있는 상황을 제대로 처리하지 않으면 나중에 런타임 에러가 터진다.

문제: 불안전한 API 응답

// 나쁜 예
interface ApiResponse {
  status: 'success' | 'error';
  data?: any;
  error?: string;
}

const response = await fetch('/api/user');
const result: ApiResponse = await response.json();

// 문제: status가 'error'인데 data를 쓸 수 있음
if (result.status === 'success') {
  console.log(result.data?.name); // data가 있다고 보장 안 됨
}

이런 코드는 타입 체크를 우회한다. TypeScript가 있어도 무의미하다.

해결책: Discriminated Union

Discriminated union을 쓰면, 태그 필드에 따라 다른 구조를 강제할 수 있다.

// 성공 응답
interface SuccessResponse {
  status: 'success';
  data: {
    id: number;
    name: string;
    email: string;
  };
}

// 실패 응답
interface ErrorResponse {
  status: 'error';
  error: {
    code: string;
    message: string;
  };
}

type ApiResponse = SuccessResponse | ErrorResponse;

이제 TypeScript가 강제한다:

const response = await fetch('/api/user');
const result: ApiResponse = await response.json();

// status에 따라 다른 필드만 접근 가능
if (result.status === 'success') {
  // result.data 사용 가능 (타입 확정)
  console.log(result.data.name);

  // result.error는 접근 불가 (컴파일 에러)
  // console.log(result.error); // Error!
} else {
  // result.error 사용 가능
  console.log(result.error.message);

  // result.data는 접근 불가
  // console.log(result.data); // Error!
}

실제 API 설계 예시

페이지네이션, 로딩, 에러 상태를 모두 포함하는 복잡한 응답:

interface Pagination {
  page: number;
  pageSize: number;
  total: number;
}

interface SuccessList {
  status: 'success';
  data: Array<{ id: number; title: string }>;
  pagination: Pagination;
}

interface EmptyList {
  status: 'empty';
  data: [];
  pagination: Pagination;
}

interface ErrorList {
  status: 'error';
  error: {
    code: 'FORBIDDEN' | 'NOT_FOUND' | 'SERVER_ERROR';
    message: string;
  };
}

type ListResponse = SuccessList | EmptyList | ErrorList;

// 사용
const response = await fetch('/api/items?page=1');
const result: ListResponse = await response.json();

switch (result.status) {
  case 'success':
    console.log(`총 ${result.pagination.total}개 중 ${result.data.length}개`);
    break;
  case 'empty':
    console.log('검색 결과 없음');
    break;
  case 'error':
    if (result.error.code === 'FORBIDDEN') {
      console.log('권한 없음');
    }
    break;
}

네트워크 요청 래퍼 만들기

API 호출을 재사용하려면, 래퍼 함수를 만드는 게 좋다.

async function apiCall<T>(url: string): Promise<T> {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

// 사용
const result = await apiCall<ApiResponse>('/api/user');
// result의 타입이 ApiResponse로 추론됨

실전: React에서 사용하기

const UserProfile = () => {
  const [response, setResponse] = useState<ApiResponse | null>(null);

  useEffect(() => {
    (async () => {
      const result = await fetch('/api/user').then(r => r.json());
      setResponse(result as ApiResponse);
    })();
  }, []);

  if (!response) return <div>로딩...</div>;

  // 이제 response의 타입이 정확히 추론됨
  if (response.status === 'success') {
    return <div>{response.data.name}</div>;
  } else {
    return <div>에러: {response.error.message}</div>;
  }
};

API 문서화와 함께

API 문서를 만들 때, 응답 구조를 discriminated union으로 정의하면 개발자들이 훨씬 쉽게 이해한다.

/**
 * GET /api/user
 *
 * @returns {SuccessResponse} status가 'success'면 user data 포함
 * @returns {ErrorResponse} status가 'error'면 error details 포함
 */

정리

Discriminated union은 API 응답을 안전하게 다루는 가장 좋은 방법이다. 타입 체크 때문에 런타임 에러가 훨씬 줄어들고, 코드도 읽기 쉬워진다. 특히 여러 개발자가 같은 API를 쓸 때, 이 패턴은 버그를 미리 잡을 수 있게 해준다. API를 설계할 때부터 discriminated union을 고려하면, 구현도 테스트도 훨씬 편해진다.