iBetter Books
수정

Ch 05. API 연동과 응답 타이핑

현실의 앱은 데이터를 서버에서 가져옵니다. TypeScript로 API를 연동할 때 가장 중요한 작업은 "서버가 무엇을 돌려주는지"를 타입으로 정의하는 것입니다. 이 타입이 정확하면 응답 데이터를 다루는 모든 코드가 타입 안전해집니다.

이 챕터에서는 JSONPlaceholder API에서 Todo 데이터를 불러오고, 로딩과 에러 상태를 타입으로 표현하는 방법을 다룹니다.

API 응답 타입 정의

JSONPlaceholder는 테스트용 무료 API입니다. https://jsonplaceholder.typicode.com/todos 엔드포인트는 다음과 같은 데이터를 반환합니다.

[  {    "userId": 1,    "id": 1,    "title": "delectus aut autem",    "completed": false  }]

우리가 정의한 Todo 타입과 필드 이름이 다릅니다. API 응답 타입과 앱 내부 타입을 분리합니다.

// 수정: src/types/todo.tsexport type TodoStatus = 'all' | 'active' | 'completed';export interface Todo {  id: number;  text: string;  completed: boolean;  createdAt: Date;}export interface TodoState {  todos: Todo[];  filter: TodoStatus;}export type TodoAction =  | { type: 'ADD_TODO'; payload: string }  | { type: 'TOGGLE_TODO'; payload: number }  | { type: 'DELETE_TODO'; payload: number }  | { type: 'SET_FILTER'; payload: TodoStatus }  | { type: 'SET_TODOS'; payload: Todo[] };// API 응답 타입 (JSONPlaceholder)export interface ApiTodo {  userId: number;  id: number;  title: string;  completed: boolean;}// API 응답을 내부 Todo로 변환export function apiTodoToTodo(apiTodo: ApiTodo): Todo {  return {    id: apiTodo.id,    text: apiTodo.title,    completed: apiTodo.completed,    createdAt: new Date(),  };}

API 타입과 내부 타입을 분리하면 좋은 점이 있습니다. API 스펙이 바뀌어도 변환 함수(apiTodoToTodo)만 수정하면 됩니다. 앱 나머지 코드는 영향을 받지 않습니다.

제네릭 fetch 래퍼

fetch의 반환 타입은 Promise<Response>입니다. Response.json()Promise<any>를 반환합니다. 이 any를 그대로 두면 타입 안전성이 사라집니다.

제네릭 래퍼 함수를 만들어 응답 타입을 명시합니다.

// 새 파일: src/api/client.tsexport class ApiError extends Error {  constructor(    public readonly status: number,    message: string  ) {    super(message);    this.name = 'ApiError';  }}export async function apiFetch<T>(url: string): Promise<T> {  const response = await fetch(url);  if (!response.ok) {    throw new ApiError(      response.status,      `HTTP ${response.status}: ${response.statusText}`    );  }  const data: unknown = await response.json();  return data as T;}

await response.json()의 결과를 unknown으로 받고 T로 단언합니다. 이 단언은 any보다 안전합니다. 호출하는 쪽에서 T를 명시하면, 그 이후로는 타입 안전하게 사용할 수 있습니다.

완벽하게 안전하려면 Zod 같은 런타임 검증 라이브러리를 써야 합니다. 하지만 신뢰할 수 있는 API에서는 이 패턴으로 충분합니다.

로딩/에러 상태 타이핑

API 호출에는 세 가지 상태가 있습니다. 로딩 중, 성공, 실패입니다. 이 세 상태를 하나의 타입으로 표현합니다.

// 새 파일: src/types/async.tsexport type AsyncState<T> =  | { status: 'idle' }  | { status: 'loading' }  | { status: 'success'; data: T }  | { status: 'error'; error: Error };

판별 유니언을 사용했습니다. status로 상태를 구분하면 각 상태에서 올바른 속성만 접근할 수 있습니다.

// 파일: 예시 (AsyncState 사용 패턴)declare const state: AsyncState<Todo[]>;if (state.status === 'success') {  // state.data는 Todo[] 타입  console.log(state.data.length);}if (state.status === 'error') {  // state.error는 Error 타입  console.log(state.error.message);}// state.data에 직접 접근하면 타입 에러// console.log(state.data);  // ❌ 에러

status를 좁히지 않고 data에 접근하면 컴파일 에러가 납니다. 로딩 중이거나 에러 상태일 때 데이터에 접근하는 실수를 방지합니다.

API 호출 함수 만들기

// 새 파일: src/api/todos.tsimport { apiFetch } from './client';import type { ApiTodo } from '../types/todo';const BASE_URL = 'https://jsonplaceholder.typicode.com';export async function fetchTodos(limit = 10): Promise<ApiTodo[]> {  return apiFetch<ApiTodo[]>(`${BASE_URL}/todos?_limit=${limit}`);}

apiFetch<ApiTodo[]>로 반환 타입을 명시했습니다. 이 함수를 호출하면 Promise<ApiTodo[]>가 반환됩니다.

Context에 API 연동 추가

초기 데이터를 API에서 불러와 Context에 저장합니다.

// 수정: src/context/TodoContext.tsx
import {
  createContext,
  useContext,
  useReducer,
  useEffect,
  useState,
  type ReactNode,
  type Dispatch,
} from 'react';
import type { TodoState, TodoAction } from '../types/todo';
import type { AsyncState } from '../types/async';
import { todoReducer, initialState } from './todoReducer';
import { fetchTodos } from '../api/todos';
import { apiTodoToTodo } from '../types/todo';

interface TodoContextValue {
  state: TodoState;
  dispatch: Dispatch<TodoAction>;
  fetchState: AsyncState<void>;
}

const TodoContext = createContext<TodoContextValue | undefined>(undefined);

export function TodoProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  const [fetchState, setFetchState] = useState<AsyncState<void>>({
    status: 'idle',
  });

  useEffect(() => {
    let cancelled = false;

    async function loadInitialTodos() {
      setFetchState({ status: 'loading' });
      try {
        const apiTodos = await fetchTodos(10);
        if (!cancelled) {
          const todos = apiTodos.map(apiTodoToTodo);
          dispatch({ type: 'SET_TODOS', payload: todos });
          setFetchState({ status: 'success', data: undefined });
        }
      } catch (err) {
        if (!cancelled) {
          const error = err instanceof Error ? err : new Error(String(err));
          setFetchState({ status: 'error', error });
        }
      }
    }

    loadInitialTodos();

    return () => {
      cancelled = true;
    };
  }, []);

  return (
    <TodoContext.Provider value={{ state, dispatch, fetchState }}>
      {children}
    </TodoContext.Provider>
  );
}

export function useTodoContext(): TodoContextValue {
  const context = useContext(TodoContext);
  if (context === undefined) {
    throw new Error('useTodoContext는 TodoProvider 안에서만 사용할 수 있습니다.');
  }
  return context;
}

cancelled 변수는 컴포넌트가 언마운트된 후 비동기 작업이 완료되어 상태를 업데이트하는 것을 막습니다. useEffect의 cleanup 함수에서 cancelled = true로 설정합니다.

err instanceof Error ? err : new Error(String(err)) 패턴은 catch에서 잡힌 값이 반드시 Error가 아닐 수 있기 때문입니다. TypeScript에서 catch의 errunknown 타입입니다.

로딩/에러 상태 표시

AppContent에서 fetchState를 활용해 로딩과 에러를 표시합니다.

// 수정: src/App.tsx
import { TodoProvider } from './context/TodoContext';
import { useTodoContext } from './context/TodoContext';
import TodoForm from './components/TodoForm';
import TodoFilter from './components/TodoFilter';
import TodoList from './components/TodoList';
import './App.css';

function AppContent() {
  const { fetchState } = useTodoContext();

  return (
    <div className="app">
      <h1>할 일 앱</h1>
      <TodoForm />
      <TodoFilter />
      {fetchState.status === 'loading' && (
        <p className="loading-message">할 일을 불러오는 중입니다...</p>
      )}
      {fetchState.status === 'error' && (
        <p className="error-message">
          데이터를 불러오지 못했습니다. {fetchState.error.message}
        </p>
      )}
      <TodoList />
    </div>
  );
}

function App() {
  return (
    <TodoProvider>
      <AppContent />
    </TodoProvider>
  );
}

export default App;

fetchState.status === 'error' 블록 안에서 fetchState.error.message에 접근합니다. 이 블록 밖에서 fetchState.error에 접근하면 타입 에러가 납니다. 판별 유니언 덕분에 조건문이 곧 타입 좁히기가 됩니다.

정리

이 챕터에서 만든 것과 배운 개념을 정리합니다.

  • ApiTodoTodo를 분리해 API 스펙 변경의 영향을 격리
  • apiFetch<T>: 제네릭 fetch 래퍼로 응답 타입 명시
  • AsyncState<T>: 로딩/성공/에러 상태를 판별 유니언으로 표현
  • useEffect 클린업으로 언마운트 후 상태 업데이트 방지
  • catch의 errunknown 타입임을 처리하는 패턴

다음 챕터에서는 반복되는 로직을 커스텀 Hook으로 분리합니다.