개요
프로젝트 규모가 방대해지고 다양한 데이터를 처리해야 할 때, API 호출 로직을 효율적으로 관리하는 것은 프론트엔드 개발자에게 중요한 역량이다. 잘 조직화되지 않은 API 호출 코드는 복잡성과 반복성을 증가시킬 수 있어, 코드의 가독성과 유지보수성을 저해할 수 있다.
따라서 초기에 API 호출 로직을 잘 설계하고 구성하는 것이 매우 중요하다.
Nuxt3에서는 useFetch, useAsyncData, 그리고 $fetch (ofetch 라이브러리)와 같은 composables를 사용하여 API 호출을 보다 체계적으로 구성한다.
이러한 새로운 접근 방식이 처음에는 적응이 필요하여, 초기에는 다소 어려움을 느꼈다. 그러나 이 개념을 잘 숙지하고 적용함으로써 프로젝트의 확장성과 유연성을 크게 향상시킬 수 있었다.
이 포스트에서는 Nuxt3에서 API 호출을 깔끔하고 효율적으로 관리하기 위해 Factory + Repository 디자인 패턴을 적용한 경험을 공유하고자 한다. 이를 통해 API 요청을 체계적으로 조직하고, 프로젝트의 유지보수성과 확장성을 높이는 방법에 대한 고민과 그 해결 과정을 자세히 다뤄보고자 한다.
Factory Pattern
Factory 패턴은 객체 생성 로직을 별도의 클래스나 메서드로 캡슐화하여, 객체 생성의 책임을 분리하고 코드의 재사용성을 높이는 디자인 패턴이다.
Factory 패턴을 사용하면 객체 생성 과정에서 발생하는 중복 코드를 줄일 수 있으며, 객체 생성 로직을 중앙 집중화하여 유지보수성을 향상할 수 있다.
Factory 패턴의 주요 아이디어
Factory 패턴의 주요 아이디어는 객체 생성 로직을 전용 팩토리 클래스나 메서드에 캡슐화하는 것이다. 이는 객체 생성 과정의 복잡성을 숨기고, 객체 생성 로직의 변경이 필요한 경우에도 쉽게 수정할 수 있게 한다.
Factory 패턴의 장점
- 객체 생성 로직의 분리: 객체 생성 로직이 한 곳에 집중되어 있어 코드의 가독성과 유지보수성을 높인다.
- 유연한 객체 생성: 객체 생성 방식이나 구체적인 클래스 변경이 필요한 경우에도 클라이언트 코드를 수정할 필요 없이 팩토리 클래스나 메서드만 수정하면 된다.
- 코드 재사용성: 공통적인 객체 생성 로직을 재사용할 수 있어 코드 중복을 줄인다.
- 추상화: 클라이언트 코드가 객체 생성의 구체적인 클래스나 로직에 의존하지 않도록 하여, 더 높은 수준의 추상화를 제공한다.
- 확장성: 새로운 클래스나 객체 생성 로직을 쉽게 추가할 수 있어 코드의 확장성이 높다.
Repository Pattern
Repository Pattern은 애플리케이션의 비즈니스 로직과 데이터 지속성 계층(일반적으로 데이터베이스나 웹 서비스) 사이에 추상화 레이어를 제공하는 디자인 패턴이다.
이 패턴의 주요 아이디어는 데이터를 검색하고 저장하는 로직을 전용 레포지토리 클래스 내에 캡슐화하는 것이다.
레포지토리는 애플리케이션과 데이터 소스 간의 다리 역할을 하며, 일관되고 균일한 인터페이스를 제공하여 데이터와 상호 작용한다.
레포지토리 패턴의 장점
- 추상화: 애플리케이션이 데이터 접근의 구현 세부 사항으로부터 보호된다. 이는 특정 데이터베이스 기술에 관계없이 일관된 메서드와 작업 세트를 사용할 수 있게 한다.
- 관심사의 분리: 데이터 접근 로직을 전용 레포지토리 클래스에 격리하여 비즈니스 로직과 데이터 지속성 문제를 분리한다. 이는 코드 가독성, 유지보수성, 테스트 가능성을 향상한다.
- 단일 책임 원칙: 각 레포지토리 클래스는 도메인 모델 내의 특정 엔티티에 집중한다.
- 테스트 용이성: 레포지토리 클래스는 단위 테스트 중에 쉽게 모킹(Mock) 또는 스터빙(Stub)할 수 있다.
- DRY(Don't Repeat Yourself) 원칙 적용: 반복적인 API 호출 코드를 방지하여 코드의 중복을 줄인다.
- Without Repository Pattern: 애플리케이션 레이어에 데이터 접근 로직이 모두 포함되어 있다. URL 변경이나 데이터 형식 변경 시 이를 사용하는 모든 페이지에서 동일한 변경을 해야 하며, 이는 DRY(Don't Repeat Yourself) 원칙을 위반하게 된다.
- With Repository Pattern: 애플리케이션과 데이터 소스 사이에 레포지토리 레이어가 도입되어 데이터 접근 로직이 이 레이어에 캡슐화된다. 애플리케이션 레이어는 데이터 소스의 구현 세부 사항을 알 필요가 없어진다.
API 호출 로직의 효율적 설계
Nuxt3에서의 데이터 페칭(data fetching)은 기본적으로 $fetch를 사용하여 이루어진다. 이는 $fetch를 단독으로 사용하거나, 특정 $fetch 위에 더해진 composable을 활용하여 사용될 수 있다. 결국 모든 API 호출은 $fetch 기반으로 이루어지므로, $fetch 호출 전용 로직을 캡슐화하여 효율적으로 관리하고자 했다. (Factory Pattern)
이후 각 비즈니스 로직 별로 API 호출 코드를 분리하여 관리하면, 보다 생산성 있는 코드를 작성할 수 있을 것이라고 판단했다. (Repository Pattern)
API 로직을 구현하기 전에 내가 작성한 API 호출 로직의 설계도이다.
- UI & Business Logic (Application Layer):
- 각 UI 컴포넌트나 비즈니스 로직이 Repository Layer를 통해 데이터를 가져온다
- await $api.community.fetchArticleList()와 같은 코드로 데이터를 요청한다
- Repository Layer (Community):
- 각 비즈니스 로직에 대한 API 호출 코드를 캡슐화한다
- FetchFactory를 상속하여 각 비즈니스 로직에 맞는 구체적인 메소드들을 제공하고, API 호출을 관리한다
- Factory Layer:
- API 호출 로직을 캡슐화하여 재사용성을 높인다
- $fetch를 사용하여 실제 API 호출을 수행하며, 공통된 로직을 관리한다
- Persistence Layer:
- 데이터베이스와 같은 영속성 계층을 나타낸다
- 실제 데이터를 저장하고 관리하는 역할을 담당한다
Nuxt3 공식문서의 $fetch 커스텀 가이드라인
Nuxt 3에서 외부 API를 호출할 때, 기본 설정 옵션을 지정하고 싶을 수 있다.
하지만 $fetch 유틸리티 함수는 글로벌 설정이 불가능하다. 이는 애플리케이션 전반에 걸쳐 일관된 fetch 동작을 유지하고, 다른 통합 모듈들이 $fetch의 동작에 의존할 수 있도록 하기 위함이다.
하지만 Nuxt는 API 호출을 위해 custom fetcher를 만들 수 있는 방법을 제공한다.
https://nuxt.com/docs/guide/recipes/custom-usefetch
Nuxt 플러그인을 사용해 custom $fetch 인스턴스를 만들 수 있다. $fetch는 ofetch의 설정된 인스턴스로, Nuxt 서버의 기본 URL 추가 및 SSR 중 직접 함수 호출을 지원한다.
import { useCookie } from "nuxt/app";
export default defineNuxtPlugin(() => {
const accessToken = useCookie("access-token").value;
const refreshToken = useCookie("refresh-token").value;
const api = $fetch.create({
baseURL: "https://jsonplaceholder.typicode.com",
onRequest({ request, options, error }) {
if (accessToken) {
const headers = (options.headers ||= {});
if (Array.isArray(headers)) {
headers.push(["Authorization", `Bearer ${accessToken}`]);
} else if (headers instanceof Headers) {
headers.set("Authorization", `Bearer ${accessToken}`);
} else {
headers.Authorization = `Bearer ${accessToken}`;
}
}
},
async onResponseError({ response }) {
if (response.status === 401) {
console.error("Unauthorized");
// await navigateTo("/login");
}
},
});
// Expose to useNuxtApp().$api
return {
provide: {
api,
},
};
});
이 Nuxt 플러그인을 통해 $api는 useNuxtApp()에서 직접 API 호출에 사용될 수 있다
<script setup>
const { $api } = useNuxtApp()
const { data: modules } = await useAsyncData('modules', () => $api('/modules'))
</script>
이제 $api에 필요한 로직이 있으므로, useAsyncData + $api 사용을 대체할 custom useFetch composable을 만들 수 있다.
// composables/useAPI.ts
import type { UseFetchOptions } from 'nuxt/app'
export function useAPI<T>(
url: string | (() => string),
options: Omit<UseFetchOptions<T>, 'default'> & { default: () => T | Ref<T> },
) {
return useFetch(url, {
...options,
$fetch: useNuxtApp().$api
})
}
<!-- app.vue -->
<script setup>
const { data: modules } = await useAPI('/modules')
</script>
위의 내용이 Nuxt3 공식문서에서 제공하는 커스텀 가이드라인이며, 각자 프로젝트 상황에 맞게 커스텀 로직을 수정하여 활용하면 된다.
Nuxt3에 Factory + Repository Pattern 적용
이제 본격적으로 위의 커스텀 코드에 더해, Factory + Repository Pattern을 적용해 보자.
1. Factory
우선 factory 역할을 해줄 폴더를 프로젝트 내에 생성한다.
mkdir factory
그다음, 모든 저장소가 확장될 추상 클래스가 있는 factory.ts 파일을 생성한다. 이 파일은 다른 모든 저장소를 확장할 수 있는 기본 클래스가 된다.
📁 factory/factory.ts
import type { $Fetch, FetchOptions } from 'ofetch';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
class FetchFactory {
private readonly $fetch: $Fetch;
constructor(fetcher: $Fetch) {
this.$fetch = fetcher;
}
/**
* The HTTP client is utilized to control the process of making API requests.
* @param method the HTTP method (GET, POST, ...)
* @param url the endpoint url
* @param data the body data
* @param fetchOptions fetch options
* @returns
*/
async call<T>(
method: HttpMethod,
url: string,
data?: object,
fetchOptions?: FetchOptions<'json'>
): Promise<T> {
return this.$fetch<T>(
url,
{
method,
body: data,
...fetchOptions
}
)
}
}
export default FetchFactory;
FetchFactory Class는 ofetch 라이브러리의 $fetch 기능을 활용하여 HTTP 요청을 캡슐화하고, 재사용 가능한 방식으로 API 호출을 처리한다.
먼저 FetchFactory Class는 $fetch 객체를 받아 초기화한다. 이는 다양한 HTTP 요청을 처리하기 위한 기본 설정을 포함한다.
call 메서드는 HTTP 요청을 수행하는 주요 메서드로, 다양한 HTTP 메서드(GET, POST 등)를 처리할 수 있다. 이 메서드는 다음과 같은 매개변수를 받는다:
- method: HTTP 메서드 (예: GET, POST, PATCH, DELETE)
- url: 요청할 엔드포인트 URL
- data: 요청 시 전송할 데이터 (선택 사항)
- fetchOptions: 추가 fetch 옵션 (선택 사항)
메서드는 $fetch를 사용해 요청을 보내고, 응답을 반환한다.
제네릭 타입 T를 사용하여 각 요청에 맞는 응답의 타입을 지정하도록 했다.
2. Repository
그다음으로는 repository 역할을 해줄 폴더를 프로젝트 내에 생성한다.
mkdir repository
repository의 각 파일 내에 정의될 각 repository 파일 특정 도메인에 대한 모든 엔드포인트 구조, payload 조작, API 요청 및 기타 정보를 포함하여 캡슐화한다.
실제로 내가 프로젝트에서 작업했던 커뮤니티 기능에서 활용한 기본적인 게시글 API 요청을 예시 들어보겠다.
📁 repository/modules/community.ts
import type { FetchOptions } from 'ofetch';
import type { AsyncDataOptions } from '#app';
import type {
Article,
ArticleListParams,
ArticlePostData,
ArticleReportData,
ArticleReactionUpdateData,
BookmarkCreateData, ArticleReactionDeleteParams, ArticleCreateImgData
} from "@/types/community"
import FetchFactory from '@/utils/factory/facotry';
class CommunityModule extends FetchFactory {
private RESOURCE = '/community';
/**
* Return the products as array
* @param params
* @param asyncDataOptions options for `useAsyncData`
* @returns
*/
async fetchArticleList(
params?: ArticleListParams, asyncDataOptions?: AsyncDataOptions<Article[]>
) {
return useAsyncData<Article[]>(
'article-list-' + params.page,
() => {
const fetchOptions: FetchOptions<'json'> = {
headers: {
'Content-Type': 'application/json',
},
};
if (params) {
fetchOptions.params = params;
}
return this.call(
'GET',
`${this.RESOURCE}/post`,
undefined, // body
fetchOptions
)
},
asyncDataOptions
)
}
async fetchArticleById(
articleId: number, asyncDataOptions?: AsyncDataOptions<Article>
) {
return useAsyncData<Article>(
'article' + articleId,
() => {
const fetchOptions: FetchOptions<'json'> = {
headers: {
'Content-Type': 'application/json',
},
};
return this.call(
'GET',
`${this.RESOURCE}/post/${articleId}`,
undefined, // body
fetchOptions
)
},
asyncDataOptions
)
}
async createArticle(
articlePostData: ArticlePostData
) {
const fetchOptions: FetchOptions<'json'> = {
headers: {
'Content-Type': 'multipart/form-data',
}
};
return this.call(
'POST',
`${this.RESOURCE}/post`,
articlePostData, // body
fetchOptions
)
}
async updateArticle(
articleUpdateData: ArticleUpdateData
) {
const fetchOptions: FetchOptions<'json'> = {
headers: {
'Content-Type': 'application/json',
}
};
return this.call(
'PATCH',
`${this.RESOURCE}/post`,
articlePostData, // body
fetchOptions
)
}
async deleteArticle(
articleID: number
) {
const fetchOptions: FetchOptions<'json'> = {
headers: {
'Content-Type': 'application/json',
}
};
fetchOptions.params = {
id: articleID
}
return this.call(
'DELETE',
`${this.RESOURCE}/post`,
undefined, // body
fetchOptions
)
}
}
export default CommunityModule;
이 CommunityModule Class는 기존의 FetchFactory Class를 확장하여 Community repository를 구현한 것이다.
커뮤니티와 관련된 다양한 API 호출을 통해 커뮤니티 관련 데이터를 가져오고, 생성하고, 업데이트하고, 삭제하는 기능을 이 하나의 repository에서 관리하고 제공한다.
각 메서드에서는 필요한 추가 파라미터를 fetchOptions 변수를 통해 설정할 수 있다.
특히 GET 메소드에서는 useAsyncData로 감싸 요청을 보내므로, asyncDataOptions를 사용하여 비동기 데이터 호출 옵션을 설정할 수 있다.
GET 요청에만 useAsyncData로 감싸고 나머지 POST/PATCH/DELETE 요청에는 $fetch로만 처리하는 이유는 해당 포스트에 근거를 정리해 놨다.
https://cychann.tistory.com/entry/Data-fetching-in-Nuxt3-fetch-useFetch-useAsyncData
이제 만든 repository를 Nuxt Plugin에 등록해 주면 된다. 이 과정에서는 Nuxt 공식 문서의 가이드라인을 따라 API 호출 및 토큰 관리 로직을 프로젝트에 맞게 수정하여 적용했다.
📁 plugins/api.ts
import type { $Fetch, FetchOptions } from 'ofetch';
import AuthModule from "@/utils/repository/auth";
import UserModule from "@/utils/repository/user";
import {handle401Error} from "@/utils/httpHandler";
interface IApiInstance {
auth: AuthModule;
user: UserModule
}
const setAuthorizationHeader = (options: any, token: string) => {
const headers = (options.headers ||= {});
headers["Authorization"] = "Bearer " + token;
};
export default defineNuxtPlugin((nuxtApp) => {
const fetchOptions: FetchOptions = {
baseURL: nuxtApp.$config.public.apiBase,
async onRequest({ options }) {
const tokenStore = useTokenStore();
const { accessToken } = storeToRefs(tokenStore);
if (accessToken.value) {
setAuthorizationHeader(options, accessToken.value);
}
},
async onResponseError(context): Promise<void> {
if (context.response.status === 401) {
await handle401Error(context)
}
},
};
const apiFetcher = $fetch.create(fetchOptions) as $Fetch;
const modules: IApiInstance = {
auth: new AuthModule(apiFetcher),
user: new UserModule(apiFetcher),
};
return {
provide: {
api: modules,
},
};
});
이제 API 호출이 필요한 곳에서 다음과 같이 사용할 수 있다.
예시 1: fetchArticleList 호출
const { $api } = useNuxtApp();
const {
data: articles,
pending,
error
} = await $api.community.fetchArticleList();
예시 2: fetchArticleList 호출 (useAsyncData 옵션 사용)
const { data: articles } = await $api.community.fetchArticleList(
{ last_id: lastId.value },
{ server: false, watch: [lastId] }
);
마치며...
Nuxt 3의 데이터 패칭(data fetching)에 대해 깊이 탐구하면서, 애플리케이션에서 API 호출을 어떻게 구조화하고 관리해야 할지 고민하게 되었다.
초기에는 각 컴포넌트에서 직접 API 호출을 하거나 Pinia를 이용한 상태 관리만으로도 충분하다고 생각했다.
그러나 시간이 지나면서 애플리케이션의 복잡도가 증가하자, 코드의 구조화와 관리가 점점 더 중요해지는 것을 깨달았다.
이런 상황에서 Factory 패턴을 이용해 $fetch 호출을 캡슐화하고, Repository 패턴을 도입하여 각 API 요청을 별도의 모듈로 분리하였다. 각 모듈은 명확하게 API 엔드포인트와 직접적인 통신을 담당하며, 이를 통해 컴포넌트나 페이지에서는 간단한 호출만으로 필요한 데이터를 획득할 수 있다.
해당 코드는 회사 내부의 Nuxt3 템플릿에 적용되었으며, 그 결과 코드의 재사용성이 크게 향상되었다. 이제 변경이 필요한 경우 관련 모듈만 수정하면 되므로, 유지보수도 훨씬 효율적으로 이루어지고 있다.
'Nuxt.js' 카테고리의 다른 글
JWT in Nuxt3 (0) | 2024.09.03 |
---|---|
Caching in Nuxt3 (0) | 2024.07.30 |
Data fetching in Nuxt3 ($fetch, useFetch, useAsyncData) (0) | 2024.07.06 |
[Nuxt3] 렌더링 모드의 핵심과 라이프사이클 이해하기 (1) | 2024.07.06 |