“ 당신은 소프트웨어 품질을 추구할 수도 있고, 포인터 연산을 할 수도 있다. 그러나 두 개를 동시에 할 수는 없다. ”
Nuxt 프로젝트를 진행하시는 분들은 아마도 API 통신을 위해 Axios를 주로 사용했을 것입니다.
그러나 Nuxt 3에서는 새로운 데이터 통신 방식을 권장하며, 이것이 바로 fetch입니다.
이번 글에서는 Nuxt 3에서의 데이터 요청을 위한 fetch 방식에 대해 자세히 알아보겠습니다.
Nuxt3에서 useFetch 사용
Nuxt3 공식문서를 보면 useFetch 컴포저블을 사용하여 아래와 같은 형식으로 API 통신을 합니다.
import { useFetch } from 'nuxt';
const { data, error } = useFetch('/api/data');
useFetch를 사용하면서 느꼈던 점은 HTTP 메서드(GET, POST, PUT, DELETE)를 분리해서 각각 재사용하면
코드의 가독성과 유지보수성을 높일 수 있을 것 같다는 생각이 들었습니다. 그래서 저는 컴포저블 구조 보다는 Class로 구조를 조직화하여 사용하게 되면 각 메서드를 독립적으로 관리하면서도 통일된 구조로 코드를 작성할 수 있을 것으로 기대될 거라 생각합니다. 이제부터 Class 구조로 구현하는 방법에 대해서 설명해볼까 합니다.
Server Response 타입 인터페이스 작성
먼저, 서버로부터 내려오는 응답에 대한 타입 정의를 작성해 보겠습니다. 서버마다 응답 구조가 다를 수 있기 때문에 응답의 구성을 조율하고 표준화하면 데이터에 접근하는 것이 더 편리해집니다.
interface폴더를 하나 생성해서 server interface를 작성해 봅니다.
useFetch옵션에 관한 타입과, ServerResponse를 제네릭으로 구성하여 결괏값에 대한 타입 또한 지정할 수 있게 합니다.
// TODO: interface/server/index.ts 디렉토리
import { MultiWatchSources } from 'nuxt/dist/app/composables/asyncData'
// TODO: userFetch에서 사용할 옵션
export interface IFetchOptions {
method: string
body?: object
params?: object
query?: object
headers?: HeadersInit
watch?: MultiWatchSources
}
// TODO: 서버 Response 구조
export interface ServerResponse<T> {
status: number
result: T
messages: string
}
IFetchOptions을 보시면 기본 HTTP 요청시 사용하는 옵션들이 정의되어있고 useFetch에서 사용하는 옵션들 중 watch까지만 적용하여 구현해 보겠습니다. watch 옵션은 설정된 반응값이 변경될 때마다 요청을 다시 보냅니다.
useFetch Class 구현하기
위에 useFetch에 사용할 옵션에 대한 타입과 ServerResponse에 대한 타입을 지정을 했습니다 이제 이것을 활용하여 Class를 구현해보겠습니다.
1. ApiService 클래스 구조
코드를 살펴보면 서버 URL과 옵션을 매개변수로 받아서 fetchOptions을 Request 옵션으로 적용하고
각 Response 데이터를 Promise resolve 또는 reject로 넘겼습니다. 그리고 fetchOptions에 watch 옵션이 있으면 해당 옵션을 적용했습니다. 이제 코드 맨 아래 주석을 보시면 이 부분에 HTTP 메서드를 구현하면 됩니다.
import { IFetchOptions, ServerResponse } from '~/interface/server'
export default class ApiService {
private static async fetch<T>(
url: string,
fetchOptions: IFetchOptions
): Promise<ServerResponse<T>> {
// TODO: 옵션 초기화
const optionsInit = {
...fetchOptions,
initialCache: false,
headers: { ...fetchOptions.headers }
}
// TODO: 토큰 설정
const getToken = useCookie('token')
// TOOD: 토큰이 있으면 헤더에 설정
if (getToken.value) {
optionsInit.headers = {
...optionsInit.headers,
Authorization: `Bearer ${getToken.value}`
}
}
return new Promise((resolve, reject) => {
useFetch(url, {
// 요청시 fetchOptions 옵션 설정
onRequest({ options }) {
Object.assign(options, optionsInit)
},
onResponse({ request, response, options }) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { status, _data } = response
// TODO: status 200일때 데이터 넘기기
if (status === 200) {
console.log('response', status)
resolve(_data)
}
},
onResponseError({ response }) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { status, _data } = response
console.log('response Error')
// TODO: 에러 넘기기
reject(_data)
},
// TODO: watch 설정
watch: fetchOptions.watch
})
})
}
// TODO: GET,POST,PUT,DELETE 작성
}
2. HTTP 메서드 함수 구현
public으로 각 HTTP 메서드에 대해서 함수로 구현합니다. watch 옵션 또한 적용해 봤는데 lazy, ssr 뭐 이런 옵션은 전체 코드 참고하시고 사용하실 옵션 추가하셔서 진행하시면 될 거 같습니다.
// 위에 코드 이어서 진행
}
// GET 메서드
public async GET<T>(url: string, params?: any, watchData?: any) {
const data = await ApiService.fetch<T>(url, {
method: 'GET',
params,
watch: [watchData]
})
return data
}
// POST 메서드
public async POST<T>(url: string, body?: any,watchData?:any) {
const headers: HeadersInit = {
'Content-Type': 'application/json'
}
const data = await ApiService.fetch<T>(url, {
method: 'POST',
headers,
body,
watch: [watchData]
})
return data
}
// PUT 메서드
public async PUT<T>(url: string, body?: any, watchData?: any) {
const headers: HeadersInit = {
'Content-Type': 'application/json'
};
const data = await ApiService.fetch<T>(url, {
method: 'PUT',
headers,
body,
watch: [watchData]
});
return data;
}
// DELETE 메서드
public async DELETE<T>(url: string) {
const data = await ApiService.fetch<T>(url, {
method: 'DELETE',
});
return data;
}
// FormData
public async PostFormData<T>(url: string, formData: FormData) {
const headers: HeadersInit = {
'Content-Type': 'multipart/form-data'
};
const data = await ApiService.fetch<T>(url, {
method: 'POST',
headers,
body: formData
});
return data;
}
}
3. ApiService 클래스 전체 코드
위에 구현한 내용에 대한 전체 코드입니다 참고 바랍니다.
import { IFetchOptions, ServerResponse } from '~/interface/server'
export default class ApiService {
private static async fetch<T>(
url: string,
fetchOptions: IFetchOptions
): Promise<ServerResponse<T>> {
const optionsInit = {
...fetchOptions,
initialCache: false,
headers: { ...fetchOptions.headers }
}
const getToken = useCookie('token')
if (getToken.value) {
optionsInit.headers = {
...optionsInit.headers,
Authorization: `Bearer ${getToken.value}`
}
}
return new Promise((resolve, reject) => {
useFetch(url, {
onRequest({ options }) {
Object.assign(options, optionsInit)
},
onRequestError({ error }) {},
async onResponse({ request, response, options }) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { status, _data } = response
if (status === 200) {
console.log('response', status)
resolve(_data)
}
},
onResponseError({ response }) {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { status, _data } = response
console.log('responseError')
reject(_data)
},
watch: fetchOptions.watch
})
})
}
// GET 메서드
public async GET<T>(url: string, params?: any, watchData?: any) {
const data = await ApiService.fetch<T>(url, {
method: 'GET',
params,
watch: [watchData]
})
return data
}
// POST 메서드
public async POST<T>(url: string, body?: any, watchData?: any) {
const headers: HeadersInit = {
'Content-Type': 'application/json'
}
const data = await ApiService.fetch<T>(url, {
method: 'POST',
headers,
body,
watch: [watchData]
})
return data
}
// PUT 메서드
public async PUT<T>(url: string, body?: any, watchData?: any) {
const headers: HeadersInit = {
'Content-Type': 'application/json'
}
const data = await ApiService.fetch<T>(url, {
method: 'PUT',
headers,
body,
watch: [watchData]
})
return data
}
// DELETE 메서드
public async DELETE<T>(url: string) {
const data = await ApiService.fetch<T>(url, {
method: 'DELETE'
})
return data
}
// FormData
public async PostFormData<T>(url: string, formData: FormData) {
const headers: HeadersInit = {
'Content-Type': 'multipart/form-data'
}
const data = await ApiService.fetch<T>(url, {
method: 'POST',
headers,
body: formData
})
return data
}
}
useFetch 커스텀한 ApiService 클래스 사용법
코드를 설명해 보면 구현한 ApiService 클래스를 확장한 FETCH_AUTH라는 클래스 인스턴스를 생성합니다.
그리고 사용하고자 할 메서드를 구현합니다.
예를 들어서 로그인 API 요청 기능을 만든다고 하면 아래처럼 ApiService 클래스를 확장하여 인스턴스를 생성하고 그 안에
API 요청에 대한 메서드들을 작성하시면 됩니다.
// TODO: api/auth.ts
import ApiService from './index'
// body에 담을 UserData 인터페이스
interface UserData {
email: string
password: string
name: string
}
// Response로 내려오는 데이터
interface UserResponse {
email: string
}
export const FETCH_AUTH = new (class extends ApiService {
// 로그인 메서드
async signIn(userData: UserData) {
try {
const res = await this.POST<UserResponse>('/api/sign', body)
console.log(res.result.email)
} catch (err) {
console.error(err)
}
}
})()
useFetch에 watch 옵션을 사용하는 방법은 예시로 아래처럼 getUser를 구현하고 테스트로 매개변수 id값이 변경될 때마다 요청되게 설정합니다.
import ApiService from './index'
import { UnwrapRef, Ref } from 'vue'
export const FETCH_AUTH = new (class extends ApiService {
//GET 3번째 매개변수를 watch로 설정했는데 테스트로 getUser에 id값을 watch 설정해보겠습니다
getUser(name:string, id: Ref<UnwrapRef<number>>) {
return this.GET<any>('/api/user', name, id)
}
})()
page에서 mounted로 한번 조회하고 버튼으로 id값을 한번 변경해 봅니다.
<template>
<div>
<v-btn @click="test">{{ id }}</v-btn>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { FETCH_AUTH } from '~/api/auth'
const id = ref<number>(0)
const test = () => {
id.value += 1
}
onMounted(async () => {
try {
// id.value가 아니라 id를 넣는다
const data = await FETCH_AUTH.getUser(id)
} catch (error) {
console.log('2', error)
}
})
</script>
버튼을 클릭해 보면 계속 조회되는 것을 확인할 수 있습니다.
각 API 요청마다 useFetch 컴포저블을 사용하는 메서드를 만들어서 사용해도 되긴 하지만 클래스로 구현하여 구조적으로 한 번 구현해 봤습니다. 이렇게 클래스 내에 각각의 HTTP 메서드를 구현하고 확장하는 방법은 코드의 가독성과 유지 보수성을 높이는 좋은 방법 중 하나라고 생각합니다. `ApiService` 클래스 내에서 GET, POST, PUT, DELETE 메서드를 구현함으로써 코드의 재사용성이 증가되었다고 생각합니다. 굳이 이런 방법이 아니더라도 한번 참고해서 본인이 어떻게 코드 구조를 생각하고 구현해야 할지 생각해 보면 좋을 거 같습니다.