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을 고려하면, 구현도 테스트도 훨씬 편해진다.