Next.js로 Edukit 프로젝트를 개발하면서 겪었던 다양한 문제들과 해결 과정을 공유하려고 합니다.
그 첫 번째 이야기로 서비스 전반에서 사용하는 API 로직을 왜 공통화했는지와 어떤 방식으로 구현했는지, 개선 효과에 대해 작성하겠습니다.
프로젝트를 진행하면서 다음과 같은 문제들에 직면했습니다.
- 서버 컴포넌트인지, 클라이언트 컴포넌트인지, 테스트 환경인지에 따라 URL을 다르게 설정해야 했습니다.
- 이를 각 서비스 로직에서 반복해서 구현하면 코드가 중복되고 실수할 여지가 많았습니다.
TanStack Query
로 fetch api 로직을 감싼 커스텀 훅에서auth context
를 가져와 accessToken을 파라미터로 전달하고 있었습니다.- 따라서 type이 복잡해지고, auth context의 관심사가 모든 커스텀 훅에 퍼져있었습니다.
import { useAuth } from '@/contexts/auth/use-auth';
export const useCreateRecords = () => {
const { accessToken } = useAuth();
return useMutation<Response<null>, Error, CreateRecordsInput>({
mutationFn: (params) => createRecords({ ...params, accessToken }),
});
};
export const createRecords = async ({
recordType, studentRecords, semester, accessToken
}: CreateRecords) => {
return fetch(..., {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
};
- accessToken 만료 시간이 30분이라, 만료 후 요청 시 401 에러가 발생했습니다.
- 이를 자동으로 처리하려면 refreshToken 기반으로 토큰을 갱신하고, 이전 요청을 재실행하는 Interceptor 로직이 필요했습니다.
- 각 서비스 함수에서 비슷한 방식으로 response를 파싱하고, 에러를 던졌습니다.
- 중복 코드를 제거하고 일관된 에러 처리 방식이 필요했습니다.
위의 4가지 문제들은 사실 axios
의 Interceptor나 기본 설정 기능으로 쉽게 해결할 수 있습니다.
하지만 fetch에서 제공하는 cache?: 'force-cache' | 'no-store' | 'no-cache' | 'reload';
옵션을 통해 캐싱 / 재검증 기능을 활용할 예정이였고, 서버 컴포넌트와 클라이언트 컴포넌트간의 일관된 로직을 작성하고자 프로젝트 초기에 fetch를 선택했습니다.
function buildURL(endpoint: string, params?: Record<string, string | number | boolean>): string {
const isMSWEnabled = process.env.NEXT_PUBLIC_API_MOCKING === 'enabled';
const isServer = typeof window === 'undefined';
if (isMSWEnabled) {
if (isServer) {
// MSW 서버: Mock 서버로 직접 요청
const fullUrl = `http://localhost:9090${endpoint}`;
return addQueryParams(fullUrl, params);
} else {
// MSW 클라이언트: 상대 경로만 필요 (MSW가 인터셉트)
return addQueryParams(endpoint, params);
}
}
// 실제 API 환경 (서버 컴포넌트인지, 클라이언트 컴포넌트인지에 따라 분기 처리)
const baseURL = isServer ? process.env.API_URL! : process.env.NEXT_PUBLIC_API_URL!;
const fullUrl = `${baseURL}${endpoint}`;
return addQueryParams(fullUrl, params);
}
addQueryParams()
는 쿼리 파라미터를 URL에 추가하는 함수입니다.buildHeaders
// HTTP 헤더를 빌드하는 함수
function buildHeaders(options: FetchOptions): HeadersInit {
const headers: Record<string, string> = {};
// 1. 기본 Content-Type 설정 (FormData가 아닌 경우에만)
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
// 2. 기존에 직접 넣어주는 사용자 정의 헤더 병합
if (options.headers) {
Object.assign(headers, options.headers);
}
// 3. Authorization 헤더 추가 (토큰이 있는 경우)
const token = getCurrentToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
getCurrentToken()
은 토큰 저장소에서 토큰을 가져오는 함수입니다.refreshAccessToken
async function refreshAccessToken(): Promise<string | null> {
if (tokenRefreshPromise) {
return tokenRefreshPromise;
}
tokenRefreshPromise = (async () => {
const authData = await reissue();
if (authData) {
if (authContextRef) {
authContextRef.setAuthData(authData.accessToken, authData.isAdmin);
}
return authData.accessToken;
} else {
if (authContextRef) {
clearAuthContext();
}
return null;
}
})();
return tokenRefreshPromise;
}
handleResponse
async function handleResponse<T>(response: globalThis.Response): Promise<T> {
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (errorData.status && errorData.code) {
throw new ApiError(errorData.status, errorData.code, errorData.message);
}
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if ('data' in json) {
return json.data as T;
} else {
return json as T;
}
}
data
필드만 추출하므로 서비스 코드가 간결해집니다.// services 폴더에 있는 createRecords 함수에 accessToken을 주입해주기 위해
// auth context 에서 accessToken을 가져오면서 accessToken이 추가된 타입 코드를 따로 생성,
// context에 커스텀 훅이 의존되어 있는 좋지 않은 구조 발생
// hooks/api/useCreateRecords.tsx
import { useMutation } from '@tanstack/react-query';
import { useAuth } from '@/contexts/auth/use-auth';
import { createRecords } from '@/services/student-manage/create-records';
export const useCreateRecords = () => {
const { accessToken } = useAuth();
return useMutation<Response<null>, Error, CreateRecordsInput>({
mutationFn: (params) => createRecords({ ...params, accessToken }),
// 성공 후 처리 로직 onSuccess:
// 실패 후 처리 로직 onError:
});
};
// services/student-manage/create-records.tsx
import type { Response } from '@/types/api/response';
import type { CreateRecords } from '@/types/api/student-record';
export const createRecords = async ({
recordType,
studentRecords,
semester,
accessToken,
}: CreateRecords): Promise<Response<null>> => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/student-records/${recordType}/students/batch`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({ recordType, studentRecords, semester }),
},
);
const json: Response<null> = await res.json();
if (!res.ok) {
throw new Error(json.message || '학생 기록 생성 실패');
}
return json;
};
// hooks/api/useCreateRecords.tsx
import { useMutation } from '@tanstack/react-query';
import { createRecords } from '@/services/student-manage/create-records';
export const useCreateRecords = () => {
return useMutation<ApiResponseWithoutData, Error, CreateRecords>({
mutationFn: createRecords,
// 성공 후 처리 로직 onSuccess:
// 실패 후 처리 로직 onError:
});
};
// services/student-manage/create-records.tsx
import { api } from '@/lib/api';
import type { ApiResponseWithoutData } from '@/types/api/response';
import type { CreateRecords } from '@/types/api/student-record';
export const createRecords = async ({ recordType, studentRecords, semester }: CreateRecords) => {
return api.post<ApiResponseWithoutData>(`/api/v1/student-records/${recordType}/students/batch`, {
recordType,
studentRecords,
semester,
});
};
https://github.com/Team-bbangbbangs/Edukit-FE/blob/develop/src/shared/lib/api.ts - 전체 코드
- 1차 MVP를 마무리하고, 2차 MVP를 시작하기전 쌓여있던 가장 큰 기술 부채를 해결하였다고 생각했습니다.
- axios 라이브러리의 소중함을 깨달았고, 구현에 어려움은 있었지만, 장기적으로 코드 생산성을 높이고 유연한 구조를 만들었다고 생각합니다.
- class나 클로저를 활용하면 더욱 견고한 코드 구조를 만들 수 있다고 생각하여 앞으로 한 차례 더 개선할 예정입니다..!