
프론트엔드 개발 시 서버와 통신하기 위해 API를 통해 상호작용 하는 과정은 필수적이라고도 할 수 있다.
특히, 여러 개발자가 공동으로 작업하는 프로젝트에서 통일된 타입 정의가 없다면, API마다 응답 데이터 구조가 일정하지 않거나 명확하지 않은 타입으로 인해 불편을 겪을 가능성이 높아지게 된다.
따라서 TypeScript를 활용하는 이점을 최대한 살려내기 위해 프로젝트 전체에서 사용하는 공통 응답 타입을 새로 도입해보고, 에러와 관련된 부분도 한 곳에서 처리하도록 바꿔본 과정을 담아 보았다.
아래와 같은 기본 구조를 사용해, 다양한 데이터 타입에 대해 일관되게 응답 형식을 가질 수 있다.
해당 인터페이스는 직접 구축한 서버 API에서 내려주는 응답 형식에 맞게 작성하였다.
export interface ApiResponse<T> {
data: T; // 데이터 내용
message?: string; // 응답 메시지
status: number; // 응답 상태 코드
pagination?: Pagination; // 페이지네이션 정보
}
interface ApiResponse<T> 부분에서 제네릭 개념이 사용되었다.
여기 사용된 "T" 키워드의 경우, 제네릭 프로그래밍에서 관례적으로 사용되는 키워드로 '타입 매개변수(Type parameter)'를 나타낸다.
해당 부분은 실제 사용 시점에 구체적인 타입으로 대체되는 일종의 플레이스홀더 역할을 한다.
참고로, "T" 대신 다른 알파벳을 사용할 수도 있기는 한데 기왕이면 많은 개발자들의 빠른 이해를 돕도록 널리 사용되는 관례를 따르는 게 좋을 것 같다.
API 응답의 구조는 대체로 비슷하며, 거의 대부분 실제 데이터(data 필드)의 타입만 각 API 엔드포인트마다 달라지곤 한다.
여기에 제네릭을 사용하여 하나의 인터페이스로 여러 종류의 API 응답을 표현하기 위해 사용한다.
→ 다양한 데이터 타입에 대해 같은 인터페이스를 재사용 가능
각 API 호출마다 data 필드의 타입이 다를텐데, ApiResponse 인터페이스를 재사용하면 각 API 응답 타입마다 별도로 인터페이스를 정의하지 않고도 편리하게 타입 안정성을 유지할 수 있다.
// 메시지 목록 조회
const messageRes: ApiResponse<Message[]> = await findMessages(...);
// 채팅방 목록 조회
const chatRes: ApiResponse<Chat[]> = await findChats(...);
// 사용자 정보 조회
const userRes: ApiResponse<User> = await getUserInfo(...);
interface MessageApiResponse {
data: Message[];
message?: string;
status: number;
pagination?: Pagination;
}
interface ChatApiResponse {
data: Chat[];
message?: string;
status: number;
pagination?: Pagination;
}
// ... 각 API마다 유사한 구조의 인터페이스 반복 정의해야 하는 불편함
여기에선 페이지네이션 관련 파라미터들을 객체로 따로 묶어 하나의 타입을 정의하였다. 이렇게 하면 파라미터를 관리하는 것이 용이해지고, 나중에 쉽게 확장할 수도 있게 된다.
또한 URL을 생성하는 로직을 별도의 함수로 분리해 코드 재사용성을 높일 수 있다. 이제 API 요청 시 해당 함수에 파라미터 객체만 넘기기만 하면 요청 URL이 만들어지며, 파라미터 종류에 따라 일일이 URL을 작성해주지 않아도 된다.
export interface Pagination {
page: number;
limit: number;
totalItems: number;
totalPage: number;
hasMorePages: boolean;
}
export interface PaginationParams {
page?: number;
limit?: number;
}
function createUrlParams(params: Record<string, any>): string {
return Object.entries(params)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
}
Record<string, any> 부분에서 유틸리티 타입인 Record가 사용되었다.
Record<K, T>은 키가 K 타입이고 값이 T 타입인 객체를 나타낸다.
위의 경우 키는 string 타입이고, 값은 any 타입에 해당한다.
이렇게 동적인 키-값 쌍을 가진 객체를 표현할 때 유용한 타입이다.
키는 문자열이지만 값은 어느 타입이든 될 수 있으므로, 다양한 타입의 값(문자열, 숫자, 불리언 등)을 전달할 수 있어 URL 파라미터와 같이 키와 값이 미리 정해지지 않은 경우에 적합하다.
→ 키-값 쌍의 구조를 가질 때와 같이, 객체 형태를 정의할 때 특히 유용
{ [key: string]: any }: Record로 작성하는 것 보다는 간결성이 떨어진다.Object type: 너무 광범위하여, 타입 안정성이 떨어진다.이제 위에서 정의한 인터페이스와 유틸 함수에 의해, API를 호출하는 함수를 아래와 같이 작성할 수 있다.
import instance from "@/api/axios-instance";
export const findMessages = async (
chatId: string,
pageParams: PageParams = {}
): Promise<ApiResponse<BaseMessage[]>> => {
try {
const params = createUrlParams({ ...pageParams, chatId });
const response: ApiResponse<BaseMessage[]> = await instance.get(`/messagesd?${params}`);
return response;
} catch (error) {
if (isAxiosError(error)) {
const axiosError = error as AxiosError<ApiResponse<any>>;
if (axiosError.response) {
return axiosError.response.data;
}
}
throw error;
}
};
async const fetchMessages = async () => {
try {
// 데이터 페치
// chatId, page 는 상태에서 가져옴
const response = await findMessages(chatId, { page });
const { data: messages, pagination } = response;
// 불러온 데이터로 화면 정보 업데이트
// ...
} catch (error) {
// 에러 처리...
}
}
그런데 매번 API 호출 함수를 정의할 때마다, 이렇게 매번 try-catch 문을 사용하는 게 반복적이고 번잡해 보일 수 있다.
이를 개선할 수 있는 방법을 아래에서 알아보도록 하자.
개별 함수나 컴포넌트에서 공통 로직(에러 처리, 응답 데이터 전처리, 로깅 등..)을 반복해서 작성하는 일은 번거롭기도 하고 그만큼 시간이 소요되는 작업이기도 할 뿐더러, 무엇보다도 반복으로 인한 실수 가능성이나 수정의 어려움을 불러 일으킬 문제가 있다.
이를 해소하기 위해 Axios에서 제공하는 Interceptor 기능을 활용해, 응답이 catch 블록에서 처리되기 전에 가로채어 전역적인 에러 처리를 해보자.
다음은 인터셉터를 사용해 API 호출 시에 성공(onFulfilled)과 실패(onRejected)에 대한 케이스를 포괄적으로 처리하는 코드이다. 필요에 따라 상세 에러 메시지나 로깅 로직을 추가할 수 있다.
// src/api/axios-instance.ts
// 인스턴스 생성
const instance = axios.create({
baseURL: baseUrl,
timeout: 3000
});
instance.interceptors.response.use(
(response: AxiosResponse) => {
// onFulfilled: API 호출 성공 시, 응답으로 2xx 범위 내의 상태 코드를 받았을 때
return response.data;
},
async (error: AxiosError<ApiResponse<any>>) => {
// onRejected: API 호출 실패 시, 응답으로 2xx 범위를 벗어사는 모든 상태 코드를 받았을 때
if (error.response) {
// API 에러 처리
console.log("API Error: ", error.response.status, error.message);
} else if (error.request) {
// 요청은 보냈지만 응답은 받지 못함
console.log("No response received: ", error.request);
} else {
// 요청 설정 중 오류 발생
console.log("Error setting up request: ", error.message);
}
return Promise.reject(error);
}
);
export default instance;
에러 처리 코드를 인터셉트 쪽에서 처리하도록 분리하여, 이쪽에서는 try-catch 문을 제거해 주었다.
import instance from "@/api/axios-instance";
export const findMessages = async (
chatId: string,
pageParams: PageParams = {}
): Promise<ApiResponse<BaseMessage[]>> => {
const params = createUrlParams({ ...pageParams, chatId });
const response: ApiResponse<BaseMessage[]> = await instance.get(`/messages?${params}`);
return response;
};
만약 나중에 컴포넌트 쪽에서 데이터를 불러올 때, 다른 방식의 에러 처리가 필요한 경우 catch 블록에서 이를 구현할 수 있다. 이전에 인터셉터에서 Promise.reject를 통해 에러를 던지기 때문에, 에러를 처리한 후에도 이를 호출한 곳에 해당 에러가 전파되어 가능한 동작이다.
import useErrorToast from "@/hooks/useErrorToast";
const [errorMessage, setErrorMessage] = useState<any>(null);
useErrorToast(errorMessage); // 🆕 toast를 보여 주는 커스텀 훅
async const fetchMessages = async () => {
try {
const response = await findMessages(chatId, { page });
const { data: messages, pagination } = response;
// ...
} catch (error) {
// 🆕 에러 메시지를 토스트 메시지로 보여주고자 하는 경우
setErrorMessage(error);
}
}
이렇게 공통적인 에러는 인터셉터에서 처리하면서도 특정한 상황에는 더 세밀하게 추가적인 에러 처리를 할 수 있는 유연성을 제공할 수 있다.
해당 접근 방식은 코드를 한층 더 깔끔하게 만들고 유지보수하기 좋도록 만들어 준다. (변경이 필요하면 이 곳에서만 수정)
가끔은 서버에서 받은 데이터를 그대로 사용하는 게 아니라, 프론트엔드 단에서 약간 수정해야 할 필요가 생기곤 한다.
TypeScript에서 제공하는 몇 가지 내장 타입을 활용하면 타입 안정성을 유지하면서도, 응답 데이터를 좀 더 효율적으로 만들거나 UI에 필요한 내용만 맞춰 변형시켜 사용할 수 있다.
기존 타입을 변형하거나 재사용 할 수 있게 돕는 타입들이다. 더 간결하고 유지보수 하기 쉬운 코드를 작성할 수 있도록 한다.
모든 프로퍼티를 선택적으로 만든다. 특정 필드만을 업데이트할 때 유용하다.
interface User {
id: string;
name: string;
email: string;
age?: number;
}
const updateUser = (userId: string; userData: Partial<User>) => { ... }
// 일부 필드만 업데이트 가능
updateUser('1', { name: 'New name" });
필요한 프로퍼티만을 선택할 때 사용한다.
type UserBasicInfo = Pick<User, 'id' | 'name'>;
// 컴파일 타임에 TS에 의해 실제로는 아래와 같이 처리됨
// interface UserBasicInfo {
// id: string;
// name: string;
// }
이번엔 특정 프로퍼티만을 제외할 때 사용한다.
type UserWithoutName = Omit<User, 'name'>;
// 컴파일 타임에 TS에 의해 실제로는 아래와 같이 처리됨
// interface UserWithoutName {
// id: string;
// email: string;
// age?: number;
// }
타입의 프로퍼티를 변환하거나, 특정 규칙을 적용하여 새로운 타입을 만드는 타입들이다. 이전에 살펴보았던 Record 타입도 이 부분에 속한다. 타입 변환을 효율적으로 수행하는 데 도움을 준다.
모든 프로퍼티를 읽기 전용으로 만든다.
type ReadonlyUser = Readonly<User>;
// 다음과 같음
// {
// readonly id: number
// readonly name: string;
// readonly email: string;
// readonly age?: number;
// }
// 아래 코드는 오류를 발생시킴
const user: ReadonlyUser = {
id: 1,
name: 'Name',
email: 'myname@example.com',
};
user.name = 'New name'; // Error
모든 프로퍼티를 필수로 만든다.
type RequiredUser = Required<User>;
// 다음과 같음
// {
// id: number
// name: string;
// email: string;
// age: number;
// }
// 아래 코드는 오류를 발생시킴
const user: RequiredUser = {
id: 1,
name: 'Name',
email: 'myname@example.com'
}; // Error
이러한 다양한 타입을 활용해 API 응답 데이터와 같이 복잡하고 가변적인 구조의 데이터를 다룸으로써, 더욱 안정적이고 유지보수 하기 좋은 코드를 만들어 볼 수 있다.