저번 글과 이어집니다.
저번에는 기본적인 에러처리를 통합시켰었다. 이어서 만들어보자
Intercepter 만들기
인터셉터는 두가지가 있다.
요청 인터셉터는 HTTP 요청이 발생하기 전에 실행된다. 주로 인증토큰 추가나, 공통헤더 설정 같은 곳에 사용한다.
ex) 인증 토큰 추가
client.interceptors.request.use({
onFulfilled: (config) => {
const token = localStorage.getItem('token');
return {
...config,
headers: {
...config.headers,
Authorization: `Bearer ${token}`
}
};
}
});
ex) 공통 헤더 설정
client.interceptors.request.use({
onFulfilled: (config) => ({
...config,
headers: {
...config.headers,
'Accept-Language': 'ko',
'Custom-Header': 'value'
}
})
});
서버로부터 응답을 받은 후 실행된다. 에러 처리, 응답 데이터 변환, 인터셉터 체이닝 같은 동작들을 할 수 있다.
ex) 에러처리
client.interceptors.response.use({
onRejected: async (error) => {
if (error instanceof APIError && error.status === 401) {
const newToken = await refreshToken();
return client.request(error.config);
}
throw error;
}
});
ex)응답 데이터 변환
client.interceptors.response.use({
onFulfilled: (response) => ({
...response,
data: transformData(response.data)
})
});
1단계: 상단에 Intercepter 정의
export const createClient = (baseConfig: Config = {}): Client => {
const requestInterceptors: Interceptor<Config>[] = [];
const responseInterceptors: Interceptor<APIResponse>[] = [];
2단계: 요청 인터셉터 처리 추가
const request = async <T>(config: Config): Promise<T> => {
const finalConfig = await requestInterceptors.reduce(async (promise, interceptor) => {
const conf = await promise;
return interceptor.onFulfilled?.(conf) ?? conf;
}, Promise.resolve(config));
};
3단계: 토큰 처리 로직 추가
const request = async <T>(config: Config): Promise<T> => {
const finalConfig = /* 인터셉터 처리 */;
if (!finalConfig.url?.includes("/auth/refresh-token")) {
const accessToken = await getAccessToken();
finalConfig.headers = {
...finalConfig.headers,
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
};
}
};
4단계: 타임아웃 처리 추가
const request = async <T>(config: Config): Promise<T> => {
const finalConfig = /* 인터셉터 처리 */;
/* 토큰 처리 */
const controller = new AbortController();
const timeoutId = finalConfig.timeout
? setTimeout(() => controller.abort(), finalConfig.timeout)
: undefined;
};
5단계: 실제 요청 로직 추가
const request = async <T>(config: Config): Promise<T> => {
const finalConfig = /* 인터셉터 처리 */;
/* 토큰 처리 */
/* 타임아웃 설정 */
try {
const url = createURL(config.url!, config.params);
const init = createRequestInit({
...finalConfig,
signal: controller.signal,
});
return await fetch(url, init).then(res => handleResponse<T>(res, finalConfig));
}
};
6단계: 리소스 정리 로직 추가
const request = async <T>(config: Config): Promise<T> => {
const finalConfig = /* 인터셉터 처리 */;
/* 토큰 처리 */
/* 타임아웃 설정 */
try {
/* 요청 처리 */
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
};
7단계: return 에 인터셉터 객체 넣어주기
return {
interceptors: {
request: requestInterceptors,
response: responseInterceptors,
},
get: (url, config = {}) => request({ ...config, url, method: "GET" }),
post: (url, data, config = {}) => request({ ...config, url, method: "POST", body: data }),
put: (url, data, config = {}) => request({ ...config, url, method: "PUT", body: data }),
patch: (url, data, config = {}) => request({ ...config, url, method: "PATCH", body: data }),
delete: (url, config = {}) => request({ ...config, url, method: "DELETE" }),
};
자 이제 마무리 되었다. 아래는 코드의 전문이다.
// src/lib/HttpClient/HttpClient.ts
import { APIResponse, Client, Config, Interceptor } from "@/types/api/httpClient";
import { APIError } from "./error";
export const createClient = (baseConfig: Config = {}): Client => {
const requestInterceptors: Interceptor<Config>[] = [];
const responseInterceptors: Interceptor<APIResponse>[] = [];
const getAccessToken = async () => {
if (typeof window === "undefined") {
try {
// 동적 임포트
const { cookies } = await import("next/headers");
return cookies().get("accessToken")?.value;
} catch {
return undefined;
}
} else {
const value = `; ${document.cookie}`;
const parts = value.split(`; accessToken=`);
if (parts.length === 2) return parts.pop()?.split(";").shift();
return undefined;
}
};
const createURL = (path: string, params?: Record<string, string>): string => {
if (!path) throw new Error("URL path is required");
const baseUrl = baseConfig.baseURL?.replace(/\/+$/, "") ?? "";
const normalizedPath = path.replace(/^\/+/, "/");
const fullUrl = `${baseUrl}${normalizedPath}`;
if (!params) return fullUrl;
try {
const url = new URL(fullUrl);
Object.entries(params)
.filter(([_, value]) => value != null)
.forEach(([key, value]) => url.searchParams.append(key, value));
return url.toString();
} catch (error) {
throw new Error(`Invalid URL: ${fullUrl}`);
}
};
const createRequestInit = (config: Config = {}): RequestInit => {
const isFormData = config.body instanceof FormData;
const headers = isFormData
? Object.fromEntries(
Object.entries({ ...baseConfig.headers, ...config.headers }).filter(
([key]) => key.toLowerCase() !== "content-type",
),
)
: {
"Content-Type": "application/json",
...baseConfig.headers,
...config.headers,
};
const init: RequestInit = {
method: config.method,
headers,
credentials: config.credentials,
signal: config.signal,
cache: config.cache as RequestCache,
next: config.next,
};
if (config.body != null) {
init.body =
config.body instanceof FormData ||
config.body instanceof Blob ||
config.body instanceof ArrayBuffer ||
config.body instanceof URLSearchParams ||
ArrayBuffer.isView(config.body) ||
typeof config.body === "string"
? (config.body as BodyInit)
: JSON.stringify(config.body);
}
return init;
};
const handleResponse = async <T>(response: Response, config: Config): Promise<T> => {
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const error = new APIError(
response.status,
config.body || null,
errorData?.message || `Error ${response.status}`,
config,
);
return responseInterceptors.reduce<Promise<T>>(async (promise, interceptor): Promise<T> => {
try {
const value = await promise;
return value;
} catch (err) {
if (interceptor.onRejected) {
return interceptor.onRejected(err) as Promise<T>;
}
throw err;
}
}, Promise.reject(error));
}
const data = await response.json();
const apiResponse: APIResponse = {
data,
status: response.status,
headers: response.headers,
};
const result = await responseInterceptors.reduce(async (promise, interceptor) => {
const value = await promise;
return interceptor.onFulfilled?.(value) ?? value;
}, Promise.resolve(apiResponse));
return result.data as T;
};
const request = async <T>(config: Config): Promise<T> => {
const finalConfig = await requestInterceptors.reduce(async (promise, interceptor) => {
const conf = await promise;
return interceptor.onFulfilled?.(conf) ?? conf;
}, Promise.resolve(config));
// 토큰을 가져와서 헤더에 추가
if (!finalConfig.url?.includes("/auth/refresh-token")) {
const accessToken = await getAccessToken();
finalConfig.headers = {
...finalConfig.headers,
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
};
}
const controller = new AbortController();
const timeoutId = finalConfig.timeout
? setTimeout(() => controller.abort(), finalConfig.timeout)
: undefined;
try {
const url = createURL(config.url!, config.params);
const init = createRequestInit({
...finalConfig,
signal: controller.signal,
});
return await fetch(url, init).then(res => handleResponse<T>(res, finalConfig));
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
};
return {
interceptors: {
request: requestInterceptors,
response: responseInterceptors,
},
get: (url, config = {}) => request({ ...config, url, method: "GET" }),
post: (url, data, config = {}) => request({ ...config, url, method: "POST", body: data }),
put: (url, data, config = {}) => request({ ...config, url, method: "PUT", body: data }),
patch: (url, data, config = {}) => request({ ...config, url, method: "PATCH", body: data }),
delete: (url, config = {}) => request({ ...config, url, method: "DELETE" }),
};
};