[패턴] Async Surf Pattern

windowook·2025년 7월 1일
post-thumbnail

등장 배경

그간 Next.js 환경에서 개인 프로젝트와 팀 프로젝트를 여러 번 진행해왔습니다.
매번 구조도 다르고, 개발하는 방법도 달랐습니다. 그러면서 느낀 것은 효율적인 구조의 중요성이었습니다.

API 라우트나 서버 액션을 활용하는 Next.js 프로젝트에서는 당연한 소리겠지만,
비동기 통신 구조가 매우 중요하지 않나? 생각이 들었습니다.
그렇게 다음과 같은 구조를 구현해보고 싶다는 목표를 세웠습니다.

가독성 좋고, 확장하기 쉽고, 일관성 있는 비동기 통신 구조

정확히는 클라이언트 컴포넌트/서버 컴포넌트 각각에 최적화된 비동기 통신 구조를 추상화하는 것입니다.
마침 이번에 스프린트에 참가하며 팀 프로젝트를 하게 되어 이 목표를 실현할 수 있었습니다.

클라이언트 / 서버 비동기 흐름 제어 최적화 패턴

저는 비동기 통신이 클라이언트-서버 간 http 핸드셰이크로 이루어지는 흐름이라 생각해왔습니다.

흐름이라는 건, 읽을 수 있다는 것이죠.

쉽게 읽을 수 있게 제어한다면 DX를 매우 향상시킬 수 있지 않을까?라는 결론을 낼 수 있었습니다.
쉽게 읽기 위한 특징을 다음과 같이 정리해보았습니다.

  • 전체는 작은 덩어리로 나뉘어 있어야 한다.
  • 작은 덩어리는 하나의 역할만 할 수 있어야 한다.
  • 덩어리들은 최대한의 재사용과 최대한의 중앙화에 속해 있어야 한다.

이것을 이번 프로젝트에서 흐름을 타고 움직이는 Surfer와 Surfer들에게 장비를 지원하는 Station, 그리고 Surfer의 안전을 책임지는 안전요원인 Surf Guard로 이루어진 Async Surf라는 패턴으로 추상화해보았습니다.

개요

Surf는 비동기 통신 흐름을 마치 서프를 타는 것에 비유한 것입니다.
이는 클라이언트 사이드로부터 출발하는 Surf와 서버 사이드에서 출발하는 Surf로 분리되어있습니다.
요소와 역할을 소개하는데에 예시로 사용한 코드의 전체는 깃허브에서 보실 수 있습니다.

https://github.com/window-ook/meet-meet

요소 및 역할

클라이언트 컴포넌트

Surf On Client는 클라이언트 컴포넌트에서 시작합니다.
클라이언트 컴포넌트에서는 useQuery, useInfiniteQuery, useMutation과 같은 Tanstack Query의 훅 기반의 커스텀 훅을 사용합니다.

ex) src/components/gatherings/CreateGatheringDialog.tsx

    const { createGathering } = useCreateGathering({
        token,
        onCallback: (message) => openConfirmDialog(setConfirmDialog, message),
    });

API Hooks

이번 프로젝트에서는 클라이언트 컴포넌트에서 API 라우트를 거쳐 요청을 보내는 비동기 통신이 중심이었습니다. 이들은 모두 API 라우트를 거쳐 백엔드의 API를 사용하기 때문에 API Hooks로 이름 붙여졌습니다.

이들을 별도의 커스텀 훅으로 분리한 까닭은, 컴포넌트에서 Tanstack Query Hook의 로직을 관리하는 것은 가독성, 중앙화, 유지보수 모두 좋지 않다고 생각했기 때문입니다.

ex) src/hooks/api/gatherings/useCreateGathering.ts

/**
 * 모임 생성 훅
* @param token 토큰
* @param onCallback 모달에 표시할 메세지를 전달
 * @returns {function} createGathering - 모임 생성 함수
 */
export const useCreateGathering = ({ token, onCallback }: GatheringApiParams) => {
    const queryClient = useQueryClient();

    const createGathering = useMutation({
        mutationFn: async (formData: FormData) => {
            if (!token) throw new Error('로그인이 필요합니다.');
            const response = await internalClient.post(INTERNAL_PATHS.GATHERINGS, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
            return response.data;
        },
        onSuccess: () => {
            queryClient.invalidateQueries({ queryKey: gatheringsQuery.all() });
            queryClient.invalidateQueries({ queryKey: myPageQuery.createdGatherings(token!) });
            onCallback?.('모임을 생성했습니다');
        },
        onError: (error) => {
            const err = error as AxiosError;
            const message = (isErrorResponse(err?.response?.data) && err?.response?.data.message) || '모임 생성에 실패했습니다';
            onCallback?.(message);
        }
    });

    return { createGathering: createGathering.mutate }
}

Queries Station

훅은 모두 queryKey를 사용합니다. queryKey는 하드 코딩으로 관리하지 않고 Queries Station이 중앙화하여 관리합니다. Queries Station은 FSD에서 제공하는 Query Factory Pattern 에서 영감을 얻었습니다.

(참고: https://feature-sliced.design/docs/guides/tech/with-react-query#1-creating-a-query-factory)

Queries Station에는 Feature Based 원칙에 따라서 Feature 별로 쿼리 키가 저장된 파일들이 모여있습니다. useQuery로 캐싱하는 부분과 useMutation으로 refetch 트리거를 하기 위해 같은 queryKey인지 대조해야 하는 번거로움도 줄이고, 하드 코딩으로 선언하면서 실수로 추가하거나 추가 파라미터를 다르게 사용하는 등의 문제를 방지할 수 있는 유용한 요소입니다.

덕분에 훅은 Queries Station으로부터 자기한테 필요한 키를 참조만 해서 사용할 수 있습니다.

ex) src/queries/gatherings.query.ts

export const gatheringsQuery = {
    all: () => ['gatherings'] as const,
    infinite: (
        mainType?: string,
        location?: string,
        date?: string,
        sortBy?: string,
        sortOrder?: string,
        startIndex?: number,
        filterSavedIds?: string
    ) => [...gatheringsQuery.all(), { mainType, location, date, sortBy, sortOrder, startIndex, filterSavedIds }] as const,
};

export const gatheringDetailQuery = {
  detail: (id: string | number) => ['gatheringDetail', id],
  reviews: (id: string | number) => ['gatheringReviews', id],
  checkJoined: (id: string | number) => ['checkGatheringJoined', id],
};

API Paths Station

클라이언트와 Next.js 서버에서 내외부 통신을 위한 API의 경로를 중앙화한 객체 모듈입니다.
EXTERNAL_PATHS는 API 라우트 → 백엔드, 서버 액션 → 백엔드로의 외부 통신을 위한 경로입니다.
INTERNAL_PATHS는 클라이언트 → API 라우트로의 외부 통신을 위한 경로입니다.

Queries Station과 마찬가지로 내부 API(API 라우트), 외부 API(백엔드) 경로를 하드 코딩으로 선언하지 않고 중앙화하여 관리합니다. 엔드 포인트 변경에 대응하기도 쉽고, 새로운 경로가 추가되어도 이를 관리하기 용이합니다.

export const EXTERNAL_PATHS = {
  SIGN_IN: '/auths/signin',
  SIGN_OUT: '/auths/signout',
  SIGN_UP: '/auths/signup',
  USER: '/auths/user',
  GATHERINGS: '/gatherings',
  REVIEWS: '/reviews',
  CHECK_JOINED: '/gatherings/joined',
  fetchGatheringDetail: (id: number) => `/gatherings/${id}`,
  fetchDetailReview: (id: number) =>
    `/reviews?gatheringId=${id}&limit=4&offset=0`,
  fetchGatheringParticipants: (id: number) =>
    `/gatherings/${id}/participants?limit=100`,
  joinGathering: (id: number) => `/gatherings/${id}/join`,
  cancelGathering: (id: number) => `/gatherings/${id}/cancel`,
  leaveGathering: (id: number) => `/gatherings/${id}/leave`,
} as const;

export const INTERNAL_PATHS = {
  SIGN_IN: '/api/auth/signin',
  SIGN_OUT: '/api/auth/signout',
  SIGN_UP: '/api/auth/signup',
  USER: '/api/auth/user',
  GATHERINGS: '/api/gatherings',
  REVIEWS: `/api/reviews`,
  CHECK_JOINED: `/api/gatherings/joined`,
  fetchGatheringDetail: (id: number) => `/api/gatherings/detail?id=${id}`,
  fetchGatheringParticipants: (id: number) =>
    `/api/gatherings/participants?id=${id}`,
  joinGathering: (id: number) => `/api/gatherings/join?id=${id}`,
  cancelGathering: (id: number) => `/api/gatherings/cancel?id=${id}`,
  leaveGathering: (id: number) => `/api/gatherings/leave?id=${id}`,
  FETCH_JOINED_GATHERINGS: `/api/gatherings/joined?&limit=100`,
  fetchCreatedGatherings: (userId: number) =>
    `/api/gatherings?createdBy=${userId}&limit=100&sortBy=registrationEnd`,
} as const;

Client Fetchers

axios 를 기반으로 만든 클라이언트 사이드 전용 내외부 통신 fetchers입니다. axios를 활용한 이유는 http 상태 코드 기반 자동 에러 처리, 응답 데이터의 자동 직렬화 / 역직렬화 기능이 내장되어었기 때문입니다. 클라이언트 컴포넌트에서 시작하는 비동기 통신이 주를 이루는 프로젝트에서 2명의 팀원만으로 제한된 기간 내에 개발해야 하는 상황에서 가장 이상적인 선택이었습니다.

Client FetchersinternalClient, externalClient로 이루어져 있습니다.

internalClient는 요청 인터셉터, 응답 인터셉터를 적용하였습니다.
기본적으로 baseURL(클라이언트 도메인), ‘withCredentials: true’이 설정되어있습니다.
요청 인터셉터는 internalClient의 요청을 가로채서 Access Token을 headers에 추가합니다.
응답 인터셉터는 성공한 응답이 오면 그대로 반환하고, 에러를 응답으로 받을 경우 parseAxiosError라는 에러 메세지 parsing 함수로 에러 객체에서 error?.response?.data?.error 로 오거나 error?.response?.data?.message로 오는 메세지를 정제합니다. 그리고 에러 상태를 추출하여 정제된 에러 메세지와 에러 상태가 담긴 Promise 객체를 반환합니다.

/**
 * 내부 통신용 클라이언트
 * @param baseURL - API 기본 URL
 * @param withCredentials - 쿠키 포함 여부
 * @warning 외부 경로에는 사용하지 말 것
 */
export const internalClient = axios.create({
    baseURL: process.env.NEXT_PUBLIC_BASE_URL,
    withCredentials: true,
});

// 요청 인터셉터: headers에 토큰 추가
internalClient.interceptors.request.use(
    (config) => {
        const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
        if (token) config.headers['Authorization'] = `Bearer ${token}`;
        return config;
    },
    (error) => Promise.reject(error)
);

// 응답 인터셉터: 에러 메세지, 에러 코드 parsing 후 반환
internalClient.interceptors.response.use(
    response => response,
    error => {
        const message = parseAxiosError(error);
        error.parsedMessage = message;
        error.parsedStatus = error?.response?.status ?? null;
        return Promise.reject(error);
    }
);

// 에러 메세지 파싱
const parseAxiosError = (error: unknown, message = '요청 중 에러가 발생했습니다.') => {
    if (axios.isAxiosError(error)) {
        const serverError = error?.response?.data?.error;
        if (serverError && typeof serverError === 'object' && 'message' in serverError) return serverError.message;
        if (error?.response?.data?.message) return error.response.data.message;
    }
    return message;
}

externalClient는 baseURL(백엔드 도메인), ‘withCredentials: true’이 설정되어있습니다. 내부 통신에 인터셉터를 붙여놨기 때문에 외부 통신은 단순히 역할 분리를 위한 최소한의 설정만 되어있습니다.

/**
 * 외부 통신용 클라이언트
 * @param baseURL - 기본 URL
 * @param withCredentials - 쿠키 포함 여부
 * @warning 내부 경로에는 사용하지 말 것
 */
export const externalClient = axios.create({
    baseURL: process.env.API_URL,
    withCredentials: true,
});

Surf Guard

API Hooks와 API 라우트에서 발생하는 에러를 일관되게 처리하기 위한 유틸리티 모듈입니다.
isErrorResponse 는 API Hooks의 onError로, handleApiError 는 API 라우트의 catch문으로 지원합니다.

isErrorResponse 는 에러 응답 데이터가 예상된 형식(ErrorResponse)을 따르는지 검증하는 타입 가드 함수입니다.

  • 안전한 타입 캐스팅: unknown 타입을 안전하게 ErrorResponse로 변환합니다.
  • 에러 응답 구조 검증: 서버 응답이 예상된 형태인지 확인합니다.
/** 
 * 에러 응답 타입 가드 함수
 * @description 에러 응답 데이터가 ErrorResponse 타입인지 확인
 * @param data - 에러 응답 데이터
 * @returns {boolean}
 */
export function isErrorResponse(data: unknown): data is ErrorResponse {
    return typeof data === 'object' && data !== null;
}

handleApiError 는 외부 통신 중 발생한 에러를 상황별로 적절한 적절히 처리하는 에러 핸들러입니다. 먼저 에러가 존재하는지, 객체 타입인지, 에러에 isAxiosError가 있는지 검증합니다. 만약 검증이 통과한 상황이라면 아래와 같이 처리하는 방법이 나뉩니다.

  • 응답이 있는 경우
    • 401 상태 특별 처리: 401 상태 코드와 함께 '유효하지 않은 토큰입니다'라는 메시지가 오면 '로그인이 만료되었습니다'로 변환합니다. (다이얼로그 UI에 나타낼 사용자를 위한 메세지로 변환)
    • 기본: 원본 에러 데이터 그대로 전달
  • 응답이 없는 경우
    • 네트워크 연결 실패, 서버 다운, 타임아웃
  • 요청 자체 에러
    • 잘못된 URL, 잘못된 요청 형식, 기타 요청 구성 에러

검증이 통과하지 않은, AxiosError가 아니라면(런타임 에러, 에러 타입) SERVER_ERROR라는 코드와 기본 fallback 메세지를 반환합니다.

/**
 * 에러 응답 처리
 * @param error - 에러 객체
 * @param fallbackMessage - 기본 메시지
 * @param fallbackStatus - 기본 상태 코드
 * @returns {NextResponse} 응답 객체
 */
export function handleApiError(error: unknown, fallbackMessage = '서버 에러 확인이 필요합니다', fallbackStatus = 500): NextResponse {
    if (error && typeof error === 'object' && 'isAxiosError' in error && (error as AxiosError).isAxiosError) {
        const err = error as AxiosError;

        if (err.response) {
            if (err.response.status === 401 &&
                isErrorResponse(err.response.data) &&
                err.response.data.message === '유효하지 않은 토큰입니다') {
                return new NextResponse(JSON.stringify({
                    ...err.response.data,
                    message: '로그인이 만료되었습니다'
                }), { status: err.response.status });
            }

            return new NextResponse(JSON.stringify(err.response.data), { status: err.response.status || fallbackStatus });
        }
        else if (err.request) return new NextResponse(JSON.stringify({ code: 'SERVER_ERROR', message: '서버에서 응답이 없습니다.' }), { status: 500 });
        else return new NextResponse(JSON.stringify({ code: 'REQUEST_ERROR', message: err.message || fallbackMessage }), { status: 500 });
    }

    return new NextResponse(JSON.stringify({ code: 'SERVER_ERROR', message: fallbackMessage }), { status: fallbackStatus });
}

따라서 서버로부터 온 모든 에러를 { code, message } 형식을 따르도록 보장하기 때문에 클라이언트에서는 일관된 에러 포맷을 전달받습니다. 이를 가지고 ConfirmDialog 와 같은 사용자 알림용 다이얼로그에 메세지를 나타내기 쉬웠습니다.

요소 및 역할

서버 컴포넌트

Surf On Server는 서버 컴포넌트에서 시작합니다.

Server Fetcher

Fetch API 기반 서버 사이드 전용 외부 통신 fetcher입니다. 그럼 왜 서버 사이드는 Fetch API를 썼을까요?

Fetch API 는 Next.js의 cache 옵션을 설정하여, 페이지 렌더링 방식을 선택할 수 있습니다.
(no-store, force-cache)

그리고 Request Memoization 기능이 있습니다. React Component Tree를 렌더링 하는 동안 같은 요청(fetch)이 반복되면, 첫 호출에만 네트워크 요청이 수행되고 이후 호출은 캐시된 결과로 반환합니다. 따라서 반복된 통신을 막아줍니다.

export interface ServerFetcherOptions extends RequestInit {
    next?: {
        revalidate?: number;
    };
}

/**
 * Server Side API Fetcher (SSG)
 * @param url 
 * @param options 
 * @returns Response JSON
 */
export async function serverFetcher<T = unknown>(
    url: string,
    options?: ServerFetcherOptions,
): Promise<T> {
    const defaultHeaders = { 'Content-Type': 'application/json' };

    const mergedOptions: RequestInit = {
        ...options, // revalidate, no-store, cache 등 옵션 설정
        headers: {
            ...defaultHeaders,
            ...(options?.headers || {}),
        },
    };

    const fullUrl = `${process.env.API_URL}${url}`;

    const response = await fetch(fullUrl, mergedOptions);

    if (!response.ok) {
        // 서버사이드라면 에러를 throw해서 상위에서 핸들링하도록
        const errorText = await response.text();
        throw new Error(`serverFetcher: ${response.status} ${response.statusText} - ${errorText}`);
    }

    try {
        return await response.json();
    } catch (error) {
        console.error('serverFetcher: ', error);
        return (await response.text()) as T;
    }
}

근거

axiosFetch API 모두 훌륭한 클라이언트입니다.
따라서 하나만 일괄 사용하지 않고 각각의 장점에 맞게 클라이언트에서 시작하는 비동기 통신과 서버에서 시작하는 비동기 통신에 하이브리드로 운영하고 싶었습니다. 장단점은 아래와 같습니다.

비교장점단점
axios• 인터셉터 탑재
• 자동 요청 본문 직렬화
• 자동 JSON 변환
• 타임아웃 설정
• 동시 요청 처리
• 요청 취소
• CSRF 보호
• HTTP 상태 코드 기반 자동 에러 처리
• 번들 크기 (47~50KB)
• 복잡한 브라우저 호환성 포함
• 외부 라이브러리
Fetch API• 브라우저 내장 클라이언트 (0KB)
• Next.js 캐시 담당 활용 가능
• Web API 표준
• Promise 기반
• 인터셉터, 타임아웃 수동 구현 필요
• 네트워크 에러 시 Promise reject 안 됨
• 쿠키 자동 전송 안 됨
• 상황에 따라 복잡해지는 보일러 플레이트 발생
• 구형 브라우저 지원 안 됨

따라서 Next.js의 클라이언트 사이드, 서버 사이드 특성에 맞게 각각의 Fetcher로 사용했습니다.

환경클라이언트이유최적화
Surf on Clientaxios다양한 내장 기능을 통한 신속한 개발인터셉터, 에러 핸들링
Surf on ServerFetch APINext.js 환경 특화네이티브 지원, 경량화

결과적으로 Fetcher에 의해 코드 가독성, 개발 효율성, 재사용성은 증가했습니다.

Surf On Client에서는 internalClient에 붙은 인터셉터로 인해 토큰 추가와 같은 코드 중복이 제거되었고, 각 요소 별로 단일 책임을 수행할 수 있게 되었습니다.

Surf On Server에서는 서버 액션을 호출할 때 Next.js 캐시 옵션 설정과 빠른 빌드 속도라는 이점을 누릴 수 있었습니다.

마무리

프로젝트를 진행하면서 기존의 설계안에 수정과 추가가 이루어졌습니다.

실제로 개발하는 동안 비동기 통신의 흐름을 추적하고 관리하는 데에 매우 도움이 되었습니다.
팀원 또한 경로와 queryKey의 중앙화, 요소의 단일 책임 덕분에 코드 일관성이 형성되어, 프로젝트 전반에서 코드 이해나 수정이 많이 수월했다고 하였습니다.

새로운 프로젝트를 진행하며 Async Surf를 좀 더 보완하여 나갈 생각입니다. 지금은 1.0이라 봐야겠죠.
Next.js를 주력으로 사용하는 프론트엔드 개발자가 목표이며, Async Surf와 궁합이 좋은 아키텍처(디렉토리 구조)도 자체적으로 찾아내고 싶은 욕심이 있습니다.

아직도 모르는 것이 많고, 부족한 점도 많기 때문에 매일매일 정진하며 유니크한 프론트엔드 엔지니어가 되려고 합니다:)

포스트를 읽어주셔서 감사합니다😊

profile
안녕하세요

0개의 댓글