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의 err는 unknown 타입입니다.
로딩/에러 상태 표시
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에 접근하면 타입 에러가 납니다. 판별 유니언 덕분에 조건문이 곧 타입 좁히기가 됩니다.
정리
이 챕터에서 만든 것과 배운 개념을 정리합니다.
ApiTodo와Todo를 분리해 API 스펙 변경의 영향을 격리apiFetch<T>: 제네릭 fetch 래퍼로 응답 타입 명시AsyncState<T>: 로딩/성공/에러 상태를 판별 유니언으로 표현useEffect클린업으로 언마운트 후 상태 업데이트 방지- catch의
err는unknown타입임을 처리하는 패턴
다음 챕터에서는 반복되는 로직을 커스텀 Hook으로 분리합니다.