Ayden's journal

RAD 아키텍처

원래는 "프론트엔드에서 데이터를 페칭할 때 리액트 쿼리를 효과적으로 구조화하기 위해 레포지토리 패턴, 어댑터 패턴, 데코레이터를 활용하는 아키텍처"인데, 내가 워낙 이름 짓는데 소질도 없고 하여 적용되는 패턴들의 앞글자를 따 RAD 아키텍처라 부르고 있다.

레포지토리 패턴은 데이터 액세스를 추상화하여, 컴포넌트가 서버와 직접적으로 상호작용하지 않도록 한다. 어댑터 패턴은 서버에서 반환되는 데이터 구조를 클라이언트에서 필요로 하는 형태로 변환하고, 데코레이터는 각 메서드에 직접적으로 어댑터를 적용한다. 이를 도표로 나타내면 아래와 같다.

 

RAD 아키텍처는 Query 흐름과 Mutation 흐름을 분리하여 설계한다. 다만 근본적인 구조 자체는 동일하다 할 수 있기에 이 포스트에서는 ─ 분량이 너무 늘어날까봐 걱정되어 ─ Query 흐름을 따라가며 RAD 아키텍처가 어떻게 리액트 쿼리를 효과적으로 구조화하는지 살펴보고자 한다.

 

 

class Fetcher

Fetcher 클래스는 RAD 아키텍처에서 '레포지토리 패턴'의 핵심 기반이 되는 요소로, 실제 네트워크 요청을 처리하는 역할을 담당한다. Fetcher 클래스는 구체적인 도메인 지식이나 비즈니스 로직을 포함하지 않으며, 순수하게 데이터를 주고받기만 할 뿐이다. 이는 관심사의 분리 원칙을 따르는 것으로, 상위 계층인 Query와 Mutation 클래스에서 이 Fetcher를 기반으로 도메인별 데이터 액세스 로직을 구현하게 된다.

const instance: AxiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_BASE_URL,
  timeout: 5 * 1000,
  withCredentials: true,
});

const client: GraphQLClient = new GraphQLClient(process.env.NEXT_PUBLIC_BASE_URL + "/graphql", {
  credentials: "include",
});

export class Fetcher {
  public async doFetch<T>(config: AxiosRequestConfig): Promise<T> {
    const { data } = await instance<T>({
      ...config,
    });

    return data;
  }

  public async doRequest<T, V extends object>(
    query: RequestDocument,
    variables?: V,
    requestHeaders?: HeadersInit,
  ): Promise<T> {
    return client.request(query, variables, requestHeaders);
  }
}

 

 

abstract class Query

Query 클래스는 RAD 아키텍처에서 데이터 조회 로직을 추상화하는 핵심 계층이다. Fetcher 클래스를 상속받아 네트워크 통신 기능을 확장하면서, React Query의 다양한 쿼리 기능들을 효과적으로 활용할 수 있는 인터페이스를 제공한다. 이 클래스가 추상(abstract) 클래스로 설계된 것은 의도적인 결정으로, 개발자가 직접 Query 클래스를 인스턴스화하는 것을 방지하고 반드시 도메인별 구체 클래스를 통해 사용하도록 강제한다.
Query 클래스는 queryKey, queryFn, infiniteQueryFn, graphql 등의 메서드를 통해 React Query의 복잡한 설정을 추상화한다. 특히 abstract queryKey 속성은 모든 하위 클래스가 반드시 고유한 쿼리 키를 정의하도록 함으로써, React Query의 캐싱 메커니즘을 효과적으로 활용할 수 있게 한다

export abstract class Query extends Fetcher {
  abstract queryKey: QueryKey;

  queryFn<T>(url: string) {
    return () =>
      this.doFetch<T>({
        method: "get",
        url,
      });
  }

  infiniteQueryFn<T>(url: string) {
    return ({ pageParam }: { pageParam: number }) =>
      this.doFetch<T>({
        method: "get",
        url: `${url}&skip=${pageParam}`,
      });
  }

  graphql<T, V extends object>(query: RequestDocument, variables?: V) {
    return this.doRequest<T, V>(query, variables);
  }
}

 

 

class DomainQuery

DomainQuery 클래스는 RAD 아키텍처에서 추상 Query 클래스와 실제 비즈니스 로직 사이의 중요한 연결 고리다. 각 도메인별로 특화된 쿼리 클래스로, BookQuery, ReportQuery, UserQuery 등이 여기에 해당한다. 이 클래스들은 추상 Query 클래스를 상속받아 구체적인 도메인 로직을 구현한다.
DomainQuery 클래스의 주요 특징은 엔드포인트에 특화된 쿼리 메서드를 정의한다는 점이다. 예를 들어 BookQuery는 책 검색(getBookList), 책 상세 정보 조회(getBook) 등 도서 도메인에 필요한 쿼리 메서드를 제공한다. 각 메서드는 적절한 GraphQL 쿼리나 REST 엔드포인트를 사용하여 서버로부터 데이터를 가져오는 옵션을 구성한다.
특히 queryKey 속성을 도메인별로 고유하게 설정함으로써 React Query의 캐싱 시스템을 효과적으로 활용한다. 메서드 내부에서는 this.queryOptions나 this.infiniteQueryOptions를 호출하여 React Query에 필요한 설정을 생성하며, 필요한 매개변수와 함께 적절한 GraphQL 쿼리를 연결한다. 이런 구조는 비즈니스 로직(어떤 데이터가 필요한지)과 데이터 액세스 로직(어떻게 데이터를 가져올지)을 명확히 분리함으로써, 코드의 재사용성과 유지보수성을 크게 향상시킨다.

export class BookQuery extends Query {
  constructor() {
    super();
  }

  queryKey = ["book"];

  getBook = (isbn13: string) =>
    queryOptions({
      queryKey: [...this.queryKey, isbn13, "getBook"],
      queryFn: () =>
        this.graphql<GetBookQuery, GetBookQueryVariables>(GET_BOOK_BY_ISBN, {
          isbn13,
        }),
      enabled: !!isbn13,
    });

  getBookList = (keyword: string) =>
    infiniteQueryOptions({
      queryKey: [...this.queryKey, keyword],
      queryFn: ({ pageParam }: { pageParam: GetBookListQueryVariables }) =>
        this.graphql<GetBookListQuery, GetBookListQueryVariables>(GET_BOOK_LIST, pageParam),
      initialPageParam: {
        BookSearchInput: {
          keyword,
          take: 12,
          skip: 1,
        },
      },
      getNextPageParam: (lastPage: GetBookListQuery, _: any, lastPageParam: GetBookListQueryVariables) => {
        return {
          BookSearchInput: {
            keyword,
            take: 12,
            skip: lastPage.bookList?.hasNext ? lastPageParam.BookSearchInput.skip + 1 : NaN,
          },
        };
      },
    });
}

 

 

useDomainQuery

useDomainQuery는 RAD 아키텍처에서 가장 리액트 컴포넌트와 맞닿아 있는 계층이다. DomainQuery에서 제공하는 쿼리 옵션을 React Query의 훅으로 변환하여 컴포넌트에서 바로 사용할 수 있게 해주며 useBookQuery, useReportQuery 등의 구현체가 여기에 해당한다.

이 레이어의 핵심은 데이터 변환과 React Query 훅 연결이다. 내부적으로 해당 도메인의 Query 클래스 인스턴스를 생성하고, 그 인스턴스의 메서드가 반환하는 쿼리 옵션을 useQuery나 useInfiniteQuery 같은 React Query 훅에 전달한다. 이를 통해 서버에서 가져온 데이터를 리액트 컴포넌트에서 편리하게 사용할 수 있다.

 

useDomainQuery 레이어에서는 어댑터 패턴을 적용하여 서버 응답 데이터를 UI에 적합한 형태로 변환하는 작업을 담당한다. 이 덕분에 서버 데이터 구조가 변경되어도 UI 컴포넌트에 미치는 영향은 최소화되며, 데이터 형식 변환 로직을 한 곳에서 관리할 수 있게 해준다. 어댑터 패턴의 적용에는 크게 두 가지 방식이 있는데, 훅과 데코레이터가 그것이다.

 

with hook

우선 아래와 같은 useBookQuery 훅이 있다고 해보자. 이 훅은 각각의 세부 훅을 리턴하며, 세부 훅에서는 DomainQuery에서 제공하는 쿼리 옵션을 React Query의 훅으로 변환하여 컴포넌트에서 바로 사용할 수 있게 한다.

export const useBookQuery = () => {
  const bookQuery = new BookQuery();

  const GetBook = (isbn13: string) => {
    return useQuery(bookQuery.getBook(isbn13))
  }

  const GetBookList = (keyword: string) => {
    return useInfiniteQuery(bookQuery.getBookList(keyword))
  };
  
  return {
    GetBook,
    GetBookList,
  };
}

 

이 훅은 컴포넌트에 필요한 data를 포함하는 객체를 리턴하는데, 여기에는 두 가지 문제가 있다. 한 가지는 아주 사소한 문제인데, data 객체의 타입에 undefined가 포함되어있다는 점이다. 다른 한 가지는 서버 인터페이스를 그대로 사용하고 있다는 것인데 이 때문에 서버에서 인터페이스를 바꿀 때마다 컴포넌트가 함께 수정되어야 하는 문제가 생긴다. 이는 컴포넌트의 유지보수를 어렵게 만들고, 서버와 클라이언트 간의 강한 결합도를 초래하게 된다.

 

이 두 가지 문제를 한 번에 해결하기 위한 방법으로 어댑터 패턴을 사용할 수 있다. 어댑터는 서버에서 받아온 원시 데이터를 컴포넌트에서 사용하기 적절한 형태로 변환해주는 역할을 한다. 이를 통해 컴포넌트는 서버 응답 구조에 직접 의존하지 않게 되고, 서버 인터페이스가 변경되더라도 어댑터만 수정하면 되기 때문에 컴포넌트는 영향을 받지 않는다. 또한 어댑터에서 undefined 값을 명확히 처리하거나 기본값을 설정함으로써 컴포넌트에서의 타입 안정성도 확보할 수 있다. 결과적으로 어댑터는 데이터 구조와 표현을 분리함으로써 애플리케이션의 유연성과 유지보수성을 높이는 데 기여한다.

export class BookQueryAdaptor {
  static getBook = (data?: GetBookQuery) => ({
    isbn13: data?.book.isbn13 ?? "",
    title: data?.book.title ?? "",
    // 가나다 (지은이) 라마바 (옮긴이) 이런 형태로 옮
    author: formatAuthor(data?.book.author) ?? "",
    description: data?.book.description ?? "",
    cover: data?.book.cover ?? "",
    categoryId: data?.book.categoryId ?? 0,
    categoryName: data?.book.categoryName ?? "",
    pubDate: data?.book.pubDate ?? "",
    publisher: data?.book.publisher ?? "",
    priceStandard: data?.book.priceStandard ?? 0,
    customerReviewRank: data?.book.customerReviewRank ?? 0,
    itemPage: data?.book.subInfo.itemPage ?? 0,
  });

  static getBookList = (data?: InfiniteData<GetBookListQuery>) => {
    return (
      data ?? {
        pages: [{ bookList: { items: [], hasNext: false } }],
      }
    );
  };
}
export const useBookQuery = () => {
  const bookQuery = new BookQuery();

  const GetBook = (isbn13: string) => {
    const result = useQuery(bookQuery.getBook(isbn13))
    return { ...result, data: BookQueryAdaptor.getBook(result.data) };
  }
  
  const GetBookList = (keyword: string) => {
    const result = useInfiniteQuery(bookQuery.getBookList(keyword));
    return { ...result, data: BookQueryAdaptor.getBookList(result.data) };
  };
  
  return {
    GetBook,
    GetBookList,
  }
}

 

with Decorator

훅의 형태에서 어댑터 패턴을 적용하려면 모든 메서드가 똑같은 ─ useQuery를 호출해 result를 받고 이를 어댑터에 연결시키는 ─ 구조를 가지게 된다. [ decorator ]를 사용하면 어댑터 로직 적용을 자동화하고 메서드를 더욱 간결하게 만들 수 있다. 데코레이터는 클래스 및 클래스의 멤버(메서드, 필드 등)에만 적용할 수 있으므로 함수형 훅 대신 [ 클래스 훅 ]을 사용해야 한다. 아래의 예시 코드에서 사용한 데코레이터들에 대해서 더 자세히 알고 싶다면 [ RAD 아키텍처의 decorator ] 포스트가 도움이 될 것이다.

@thisBind
export class useBookQuery {
  private bookQuery = new BookQuery();

  getBookList(keyword: string) {
    return useInfiniteQuery(this.bookQuery.getBookList(keyword));
  }

  @transformResult(BookAdaptor.toClient)
  getBook(isbn13: string) {
    return useQuery(this.bookQuery.getBook(router.isbn13));
  }

  @transformResult(BookAdaptor.toClient)
  getBookAllData(isbn13: string) {
    useQuery(this.bookQuery.getBookAllData(isbn13))
  }
}

 

데코레이터와 클래스 훅을 사용하니 코드가 아주 깔끔하게 정리되었다. 하지만 여기에는 다양한 문제가 존재한다. 가장 큰 문제는 클래스 훅이 훅의 규칙을 어긴다는 점이다. 이 방식은 코드상으로는 깔끔해 보일 수 있지만, 실제로는 React의 동작 원칙과 맞지 않기 때문에 예측 불가능한 동작이나 디버깅의 어려움을 초래할 수 있다. 특히, 훅의 호출 순서나 상태 관리가 중요한 상황에서는 이러한 우회 방식이 심각한 버그로 이어질 수 있다. 향후 React 버전이 업데이트되면서 예기치 못한 문제를 유발할 가능성도 존재한다. 결과적으로 일관성 있고 안정적인 훅 사용을 보장하지 못한다는 점이 가장 큰 단점이다.

 

타입스크립트가 정적 타입 검사 단계에서 메서드의 원래 반환 타입만 인식할 뿐, 데코레이터가 런타임에 수행하는 데이터 변환까지는 파악하지 못한다는 점도 문제다. 어댑터를 통해 데이터 구조를 변환하더라도 그 로직이 데코레이터 내부에 감춰져 있기 때문에, 호출부에서는 여전히 변환 이전의 서버 응답 타입으로 인식되는 것이다. 이로 인해 타입 안정성이 떨어지고, 개발자는 매번 명시적으로 타입을 지정하거나 타입 단언을 사용해야 하는 번거로움을 겪게 된다. 결국 데코레이터를 통해 로직은 깔끔해졌지만, 타입 체계와의 연결은 느슨해져 타입스크립트의 가장 큰 장점인 자동 추론과 강력한 타입 보장의 효과가 약화되는 결과를 초래할 수 있다.

 

만약 소규모 개인 프로젝트였다면 데코레이터의 사용을 고려하겠지만, 그렇지 않다면 일반적인 훅의 방식으로 처리하는 것이 좋겠다.

 

 

React Component

컴포넌트에서는 useDomainQuery을 호출하여 사용하면 끝이다. 야호!

export default function Page() {
  const { id: isbn13 } = useRouterAdv();
  const { data: bookData } = useBookQuery().GetBook(isbn13);
  const { data: BookAllData } = useBookQuery().GetBookAllData(isbn13);
  
  return { ... }
}

 

QnA : 누군가 물어본 질문

1. 왜 DomainQuery와 useDomainQuery 계층이 분리되어 있는 건가요?

DomainQuery는 쿼리 옵션을 구조화하는 계층이고, useDomainQuery는 실제로 쿼리를 호출하는 로직을 구조화하는 계층입니다. 이처럼 쿼리 옵션과 쿼리 호출을 분리해서 다루게 되는 이유는 순전히 SSR 등과 같은 환경 때문입니다. getServerSideProps에서는 useQuery가 아니라 queryClient.prefetchQuery를 사용하여 데이터를 페칭하고, 그 결과를 클라이언트에 queryClient 형태로 넘겨줍니다.

따라서 DomainQuery와 useDomainQuery 계층을 하나로 합쳐버린다면 getServerSideProps에 쿼리 옵션을 제공하기 위해 코드를 중복해서 작성하게 됩니다. 만약 이러한 환경에서의 데이터 페칭을 고려하지 않아도 된다면 이 두 계층을 하나로 합쳐도 문제가 되지 않을 것이라 생각됩니다.

하지만 나눠진 걸 합치는 건 쉬워도 합쳐진 걸 나누는 건 여간 귀찮은 일이 아니니, 가능하다면 두 계층을 분리하는 편을 저는 권장합니다 :)

 

2. FSD 아키텍처에서도 쓸 수 있나요?

RAD 아키텍처는 서버로부터 데이터를 가져와 프론트엔드 도메인 모델에 맞게 가공하는 일련의 '흐름'을 정의한 것입니다. 따라서 ─ 저는 FSD를 써본 적이 없지만, 제가 보고 들은 바에 의하면 ─ 원칙적으로는 FSD 아키텍처의 구조 내에서 RAD 아키텍처를 적용하는 것은 가능합니다.

FSD는 아니지만 저는 일반적으로 프로젝트를 꾸려나갈 때 아래와 같은 폴더 구조로 RAD 아키텍처를 작성합니다.

ㄴ Decorator
  ㄴ thisBind.ts
  ㄴ transformArgs.ts
  ㄴ transformResult.ts
ㄴ RAD
  ㄴ Adaptor
    ㄴ Book.adaptor.ts
  ㄴ Adaptor
    ㄴ Fetcher.ts
    ㄴ Mutation.ts
    ㄴ Query.ts
    ㄴ Socket.ts
  ㄴ Domain
    ㄴ Book.query.ts
    ㄴ Book.mutation.ts
  ㄴ useDomain
    ㄴ useBook.query.ts
    ㄴ useBook.mutation.ts
  ㄴ Mock

 

3. 나중에 가면 useDomainQuery 내의 모든 곳에 어댑터가 달리는 거 아니에요?

주기적인 리팩터링을 통해 어댑터를 점진적으로 제거하는 편이 좋습니다. 서버 인터페이스가 자주 변경되는 프로젝트 초기에는 어댑터가 굉장히 유용하지만, API 구조가 안정화되면 오히려 코드의 복잡도만 높이고 유지보수를 어렵게 만들 수 있습니다. 이 시점에는 어댑터를 걷어내고 도메인 로직이 API 형태에 보다 직접적으로 접근하도록 리팩터링하는 것이 바람직합니다. 이를 통해 코드의 가독성과 직관성을 높이고, 타입 안정성이나 자동완성 같은 개발 도구의 이점을 더욱 효과적으로 활용할 수 있습니다.

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기