-
Notifications
You must be signed in to change notification settings - Fork 1
서비스 설계하기
현재 서버상태를 클라이언트에서 관리하기위해 react-query를 사용하고 있습니다.
이를 가독성과 유지보수를 높이고자 API의 서비스별로 따로 관리하여 사용하고 있습니다. 이런 방식을 왜 구성하였는지에 대한 경험을 공유하고자 합니다.
해당 프로젝트의 home
서비스를 기준으로 아래와 같이 폴더를 생성했습니다.
- 각각의 service 폴더에는 세 개의 폴더로 나뉘어져 있습니다.
HomeService.ts
를 작성하면 아래와 같습니다.
/** app/(home)/service/client/HomeService.ts **/
import Service from '@/lib/service';
class HomeService extends Service {
async getImageInfoByHash(hash: string) {
const startTime = new Date().getTime();
const source = await this.http.get<Source>(`/receive?dhash=${hash}`);
const endTime = new Date().getTime();
const elapsedTime = endTime - startTime;
return { source, elapsedTime };
}
getCounts() {
return this.http.get<Counter>(`/counter`);
}
getRecentUpdates() {
return this.http.get<RecentBoardData[]>(`/last_update_info`);
}
}
// 항상 HomeService 동일한 인스턴스를 사용하도록 한다. (싱글톤)
export default new HomeService();
-
HomeService
클래스는 Service 클래스를 상속받아 선언됩니다. 이는 Service 클래스에서 정의된 HTTP 요청 기능을 활용하기 위함입니다. - 항상 동일한 인스턴스를 사용하도록 싱글톤 패턴을 적용하여 클래스를 내보냅니다.
- 이처럼 API의 서비스별로 따로 관리하면 가독성과 유지 보수에 좋습니다.
싱글톤 패턴은 클래스에 인스턴스가 하나만 있도록 보장하고 이에 대한 전역 액세스 지점을 제공합니다. JavaScript(및 TypeScript)에서는 클래스 자체를 내보내는 대신 클래스 인스턴스를 직접 내보내어 이를 수행할 수 있습니다.
useHomeService.ts
를 작성하면 아래와 같습니다.
/** app/(home)/service/client/useHomeService.ts **/
import { useQuery } from '@tanstack/react-query';
import queryOptions from '@/app/(home)/service/client/queries';
export function useImageInfo({ hash }: { hash: string }) {
return useQuery(queryOptions.imageInfo(hash));
}
export function useCounts() {
return useQuery(queryOptions.counts());
}
export function useRecentUpdates() {
return useQuery(queryOptions.updates());
}
- react-query의 useQuery를 사용 할 때, 필요한 곳에서 직접적으로 호출해서 사용하기보다는 이 또한 서비스별로 Colocate 시켜서 가독성과 유지보수하기 좋도록 설계하였습니다.
관련해서 유명한 글인 Maintainability through colocation by Kent C. Dodds를 읽어보길 권장합니다.
queries.ts
를 작성하면 아래와 같습니다.
/** app/(home)/service/client/queries.ts **/
import HomeService from '@/app/(home)/service/client/HomeService';
const queryKeys = {
imageInfo: (hash: string) => ['imageInfo', hash] as const,
counts: ['counts'] as const,
updates: ['updates'] as const,
};
const queryOptions = {
imageInfo: (hash: string) => ({
queryKey: queryKeys.imageInfo(hash),
queryFn: () => HomeService.getImageInfoByHash(hash),
gcTime: 0, // 이미지 검색후 다시 홈으로 올 때, 성공 및 실패 toast 띄우기 때문에 gcTime을 0으로 설정 (feat. Update.tsx)
}),
counts: () => ({
queryKey: queryKeys.counts,
queryFn: () => HomeService.getCounts(),
}),
updates: () => ({
queryKey: queryKeys.updates,
queryFn: () => HomeService.getRecentUpdates(),
}),
};
export default queryOptions;
- 해당 파일에서는 QueryKey와 QueryOption을 모두 관리하고 있습니다.
- 쿼리 키 팩토리라고도 불립니다. 쿼리 키를 수동으로 분리하여 사용된다면, 오류를 발생하기 쉬울 뿐더러 추후 변경하기 어렵게 만듭니다. 키에 더 세분화된 수준을 추가하려고 하는 경우가 그렇습니다. 그래서 각각의 기능별 서비스에
queries.ts
에 하나의 쿼리 키 팩토리를 구성합니다. - 각각의 엔트리와 쿼리 키를 생성할 함수가 있는 간단한 객체입니다.
관련해서 react-query의 메인테이너인 TkDodo’s blog에서 효율적으로 React Query Key 관리하는 법 이라는 글을 참고하여 작성했습니다.
Service.ts
- Axios HTTP 클라이언트를 감싸는 역할을 하는 서비스 클래스를 정의합니다.
- 이 클래스는 HTTP 요청을 중앙 집중화하고 기본 URL 및 요청 시간 초과와 같은 세부 정보를 구성합니다.
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
interface HTTPInstance {
get<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
head<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
options<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>;
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>;
patch<T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<T>;
}
class Service {
public http: HTTPInstance;
private baseURL?: string;
// private headers: Record<string, string>;
constructor() {
this.baseURL = process.env.NEXT_PUBLIC_IS_LOCAL
? process.env.NEXT_PUBLIC_REDIRECT_URL
: process.env.NEXT_PUBLIC_SERVER_URL;
// this.headers = {
// csrf: 'token',
// // Referer: this.baseURL,
// };
const axiosInstance: AxiosInstance = axios.create({
baseURL: this.baseURL,
timeout: 30000, // 30초동안 응답이 없으면 요청 중단
// withCredentials: true,
// headers: {
// 'Content-Type': 'application/json',
// ...this.headers,
// },
});
this.http = {
get: <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
this.axiosRequest<T>(axiosInstance, 'get', url, undefined, config),
delete: <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
this.axiosRequest<T>(axiosInstance, 'delete', url, undefined, config),
head: <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
this.axiosRequest<T>(axiosInstance, 'head', url, undefined, config),
options: <T>(url: string, config?: AxiosRequestConfig): Promise<T> =>
this.axiosRequest<T>(axiosInstance, 'options', url, undefined, config),
post: <T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<T> =>
this.axiosRequest<T>(axiosInstance, 'post', url, data, config),
put: <T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<T> =>
this.axiosRequest<T>(axiosInstance, 'put', url, data, config),
patch: <T>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<T> =>
this.axiosRequest<T>(axiosInstance, 'patch', url, data, config),
};
}
private async axiosRequest<T>(
axiosInstance: AxiosInstance,
method: string,
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<T> {
try {
const response = await axiosInstance.request<T>({
url,
method,
data: data ?? undefined,
...config,
});
return response.data;
} catch (error) {
console.error('Error:', error);
throw error;
}
}
}
export default Service;
- constructor에서는 instance를 만들고 BaseURL과 기타 세부 정보를 구성합니다. 또, 각 HTTP 메서드(get, delete, head, options, post, put, patch)를 적절한 매개변수와 함께 axiosRequest를 호출하는 함수로 정의합니다.
- axiosRequest는 모든 HTTP 요청을 처리하는 private 메서드입니다. public HTTP 메서드에 의해 호출됩니다.
즉, Service 클래스는 Axios를 사용하여 HTTP 요청을 수행하는 중앙 집중식 재사용 가능한 방법을 제공합니다. 일관된 인터페이스(HTTPInstance)를 정의하고 baseURL 및 시간 초과와 같은 구성 세부 정보를 처리함으로써 모든 요청이 일관되고 안정적인 방식으로 이루어지도록 보장합니다. 이 패턴은 특히 애플리케이션의 여러 부분에 걸쳐 HTTP 요청을 할 때 코드를 깔끔하고 유지 관리하기 쉽게 유지하는 데 도움이 됩니다.