프로젝트를 진행하다 보면 레거시 시스템과의 연동이나 마이크로서비스 아키텍처(MSA) 도입 등의 이유로, 하나의 클라이언트 앱에서 두 개 이상의 서로 다른 GraphQL 엔드포인트를 바라봐야 하는 상황이 발생합니다.

최근 진행한 프로젝트에서도 메인 비즈니스 로직을 담당하는 API와 실시간 채팅 기능을 담당하는 API가 분리되어 있었습니다. 이 글에서는 Next.js(App Router) 환경에서 두 개의 독립적인 Apollo Client를 안전하고 효율적으로 설정한 경험을 공유합니다.


1. 개요: 왜 두 개의 Client인가?

일반적인 Apollo Client 사용법은 앱 최상단에 하나의 ApolloProvider를 감싸는 것입니다.
하지만 엔드포인트가 다르다는 것은 스키마(Schema)가 다르다는 뜻이고,
이는 타입 시스템과 캐시 전략이 완전히 분리되어야 함을 의미합니다.

우리는 다음과 같은 전략을 세웠습니다.

  1. Main API: 전역적으로 사용되므로 기본 ApolloProvider에 주입.
  2. Sub API (Chat): 특정 도메인에서만 사용되므로, 필요한 곳에서 명시적으로 주입.
  3. Codegen: 두 스키마의 타입이 섞이지 않도록 격리하여 생성.

 

2. GraphQL Codegen 설정: 스키마 격리하기

가장 먼저 해결해야 할 문제는 타입 충돌 방지입니다.
두 API가 동일한 이름의 Type(예: User, Message)을 가질 수 있기 때문에,
Codegen 설정 단계에서부터 파일 생성 위치를 물리적으로 분리했습니다.

codegen.ts 설정 파일에서 generates 항목을 두 개의 섹션으로 나누어 구성했습니다.

// codegen.ts 예시
const config: CodegenConfig = {
  // ...공통 설정
  generates: {
    // 1. 메인 API용 타입 생성
    "src/lib/graphql/main/gqlGenerated.ts": {
      schema: MAIN_API_SCHEMA_URL,
      documents: ["src/lib/graphql/main/**/*.{ts,tsx,graphql}"], // 경로 제한
      plugins: ["typescript-react-apollo", /* ... */],
      // ...
    },

    // 2. 서브 API용 타입 생성
    "src/lib/graphql/sub/gqlGenerated.ts": {
      schema: SUB_API_SCHEMA_URL,
      documents: ["src/lib/graphql/sub/**/*.{ts,tsx,graphql}"], // 경로 제한
      plugins: ["typescript-react-apollo", /* ... */],
      // ...
    },
  },
};
  • Documents 경로 제한: 각 Codegen 설정이 서로 다른 폴더의 쿼리 파일만 바라보게 하여,
    메인 API용 훅 생성 시 서브 API 쿼리가 포함되지 않도록 했습니다.
  • 출력 파일 분리: 각각 별도의 폴더에 생성된 훅(useQuery 등)을 import 하여 사용합니다.

 

3. Apollo Client 인스턴스 구성

두 개의 클라이언트는 엔드포인트만 다를 뿐, 인증(Auth) 로직이나 에러 핸들링 로직은 유사한 경우가 많습니다. 이를 위해 Link 설정은 재사용하되, 클라이언트 인스턴스 생성은 분리했습니다.

팩토리 패턴을 활용한 클라이언트 생성

// clientFactory.ts (개념적 코드)

// 공통으로 사용할 Auth Link, Error Link
const authLink = createAuthLink();
const errorLink = createErrorLink();

// 1. 메인 클라이언트 (기본)
export const getMainClient = () => {
  return new ApolloClient({
    link: from([authLink, errorLink, createHttpLink({ uri: MAIN_API_URL })]),
    cache: new InMemoryCache(), // 독립된 캐시
  });
};

// 2. 서브 클라이언트 (채팅 등)
let subClientInstance: ApolloClient<any> | null = null;

export const getSubClient = () => {
  // 싱글톤 패턴으로 인스턴스 재사용
  if (!subClientInstance) {
    subClientInstance = new ApolloClient({
      link: from([authLink, errorLink, createHttpLink({ uri: SUB_API_URL })]),
      cache: new InMemoryCache(), // 독립된 캐시 필수!
    });
  }
  return subClientInstance;
};
  • InMemoryCache는 공유하면 안 됩니다. 스키마 구조가 다르기 때문에 캐시 키 충돌이 발생할 수 있습니다.
    각 클라이언트마다 new InMemoryCache()를 따로 생성해야 합니다.

 

4. 실제 사용: Context vs Explicit Injection

이제 컴포넌트에서 어떻게 사용하는지 살펴보겠습니다. 이 부분이 다중 클라이언트 전략의 핵심입니다.

1) 메인 API 사용 (기본 방식)

메인 클라이언트는 ApolloProvider를 통해 주입되므로, Codegen으로 생성된 훅을 평소처럼 사용하면 됩니다.

// 일반적인 사용 (Main Client 자동 연결)
import { useUserQuery } from "@/lib/graphql/main/gqlGenerated";

export default function UserProfile() {
  const { data } = useUserQuery(); // Provider의 client 사용
  return <div>{data?.me.name}</div>;
}

 

2) 서브 API 사용 (명시적 주입)

서브 API를 사용하는 훅은 client 옵션을 명시적으로 전달해야 합니다.

Codegen이 생성해 주는 React Hook들은 QueryHookOptions를 받는데, 여기에 client 프로퍼티가 포함되어 있습니다.

// 서브 API 사용 (Client 명시)
import { useChatMessagesQuery } from "@/lib/graphql/sub/gqlGenerated";
import { getSubClient } from "@/lib/graphql/sub/client";

export default function ChatRoom() {
  // ✅ 여기서 client를 직접 넣어줍니다.
  const { data } = useChatMessagesQuery({
    variables: { roomId: "123" },
    client: getSubClient(), 
  });

  return <div>...</div>;
}

만약 client 옵션을 넣지 않으면, Apollo는 상위 Provider에 있는 메인 클라이언트를 사용하여 요청을 보냅니다.
메인 API에는 해당 쿼리에 맞는 스키마가 없으므로 "Field not found" 에러가 발생하게 됩니다.

 

5. 마무리 및 트러블 슈팅 팁

다중 클라이언트 구조를 운영하면서 겪을 수 있는 실수들과 해결 팁을 정리합니다.

  1. Import 경로 혼동:
    • IDE의 자동 완성 기능을 사용하다 보면 메인 API용 쿼리 훅을 서브 API 컴포넌트에서 잘못 import 하는 경우가 생깁니다. 폴더 구조를 명확히 나누고(예: domain-a/, domain-b/), 팀 내 컨벤션을 통해 이를 방지해야 합니다.
  2. 캐시 업데이트 (Mutation):
    • 서브 API에 Mutation을 날릴 때도 client: getSubClient()를 꼭 명시해야 합니다.
    • update 함수 내에서 cache.modify 등을 사용할 때, 해당 cache 객체는 자동으로 올바른 클라이언트의 캐시를 참조하므로 걱정하지 않아도 됩니다.
  3. Client Component vs Server Component:
    • Next.js App Router 환경에서는 서버 컴포넌트용 클라이언트 함수(getServerApolloClient)와 클라이언트 컴포넌트용 래퍼를 구분해서 작성해야 합니다. 이 원칙은 다중 클라이언트 환경에서도 동일하게 적용됩니다.

이러한 구조를 통해 서로 다른 두 개의 백엔드 서비스를 하나의 프론트엔드 프로젝트에서 타입 안전성을 잃지 않고 깔끔하게 통합할 수 있었습니다. 특수한 상황이지만, 비슷한 고민을 하시는 분들에게 도움이 되기를 바랍니다.

적는다 해놓고 아직 완성하지 못한 글들이 많네요..ㅎㅎㅠㅠ 회고는 돌아오는 주 내에 꼭 작성할려고 합니다..! 회사에서 신기능을 연달아 배포하면서 시간이 많이 부족했었네요..하핳

어쨌든 지금 적을려고 하는 것은..! Apollo Client에서 실제 API 엔드포인트에 연결하여 사용하는 것처럼 Mocking하여 작업하는 방법입니다!


기능을 구현하는 과정에서 백엔드가 아직 완성되지 않았다면 보통 간단한 목 데이터를 만들어 UI적인 기능을 완성시켜 놓고, 백엔드가 구현되면 연결을 하는 방식으로 진행합니다. 아닌 경우도 있겠지만 대부분 이렇게 진행하는 것으로 알고 있습니다. 여기서 MSW를 이용해 API에서 데이터를 받는 것처럼 구현할 수도 있죠..! 해당 작업은 Apollo Client에서 MSW를 사용하는 것처럼 API를 통해 데이터를 받아 UI를 구현할 수 있는 Mocking 환경을 만드는 작업입니다. 이 작업을 진행하는 환경은 다음과 같습니다.

  • 그래프큐엘 쿼리 및 뮤테이션을 프론트엔드에서 구성
  • 그래프큐엘 코드젠을 이용해 쿼리 및 뮤테이션 기반 useQuery 훅과 useMutation 훅을 생성
  • 백엔드 측에서 API 구현 전 그래프큐엘 스키마를 제일 먼저 구현

 

이 과정에서 Mocking 하는 방법을 러프하게 설명하자면, 

  1. 백엔드로부터 생성하려고 하는 그래프큐엘 스키마를 전달 받음
  2. 해당 스키마를 mocks 폴더에 gql 파일로 생성
  3. 해당 gql 파일이 존재하는 경로를 기존 코드젠 코드가 참조하는 스키마 옵션에 추가
  4. faker.js와 같은 라이브러리를 이용해 사용하려고 하는 데이터 타입에 맞춰 목데이터 코드 구현
  5. ApolloLink를 이용해 API 요청을 가로채서 목데이터를 반환해주는 코드 구현
  6. 아폴로 클라이언트를 생성하는 함수에서 Mocking 하려는 쿼리 혹은 뮤테이션 타입 이름 기준으로 분기처리
    -> Mocking 하려는 쿼리 혹은 뮤테이션일 경우,
    -> 목데이터를 반환하는 Mocking 용 Apollo Link를 사용

위와 같습니다. 해당 방법으로 코드를 구현하면 백엔드에서는 아직 구현되지 않았더라도 구현된 것처럼 사용할 수 있습니다. 그리고 백엔드에서 구현했다면 코드젠에서 mocks의 스키마를 참조하지 않도록 바꾸고, 아폴로 클라이언트를 생성하는 함수에서의 분기처리를 무효화하면 됩니다.

 

1.  Mocking용 스키마와 쿼리 파일 생성

 

 

2. faker.js를 이용한 mock 데이터 구현

const createRandomData = (index: number): CoverLetter => {
  const yearsOfExperience = faker.number.int({ min: 1, max: 5 });

  return {
    __typename: "{Mocking 하는 쿼이 데이터 타입}",
    id: String(index + 1),
	//...
    university: `${faker.location.city()}대학교`,
  };
};

export const MOCK_DATA: {Mocking 하는 쿼이 데이터 타입}[] = Array.from(
  { length: 100 },
  (_, index) => createRandomData(index)
);

 

 

3. Mocking 하려는 쿼리에 대한 작업을 해주는 ApolloLink 생성 함수 

import { MOCK_DATA } from "../mocks/mock-cover-letter";
import { ApolloLink, Observable, Operation } from "@apollo/client";

export const createMockLink = () => {
  return new ApolloLink((operation: Operation) => {
    const { operationName, variables } = operation;

    return new Observable(observer => {
      if (operationName === "{Mocking 쿼리 타입 1}") {
        console.log("✅ Mocking with variables:", variables);
        
        let results = [...MOCK_DATA];

        if (variables?.keyword) {
          const lowerKeyword = variables.keyword.toLowerCase();
          results = results.filter(content =>
            content.toLowerCase().includes(lowerKeyword)
          );
        }
        if (variables?.companyName) {
          const lowerCompanyName = variables.companyName.toLowerCase();
          results = results.filter(content =>
            content.companyName.toLowerCase().includes(lowerCompanyName)
          );
        }

        const page = pagination?.page ?? 1;
        const pageSize = pagination?.pageSize ?? 10;
        const offset = (page - 1) * pageSize;
        const paginatedNodes = results.slice(offset, offset + pageSize);

        observer.next({
          data: {
            {Mocking 쿼리에 대한 반환 데이터 1}: {
              __typename: "{반환 데이터에 대한 타입 1}",
              nodes: paginatedNodes,
              totalCount: results.length,
            },
          },
        });
      } else if (operationName === "{반환 데이터에 대한 타입 2}") {
        console.log("✅ Mocking with variables:", variables);
        const found = MOCK_DATA.find(c => c.id === variables.id);

        observer.next({
          data: {
           {Mocking 쿼리에 대한 반환 데이터 2}: found || null,
          },
        });
      }

      observer.complete();
    });
  });
};

 

 

4. Apollo Client 생성 함수에서 Mocking 하려는 쿼리에 대한 분기 처리 코드 추가
(ApolloLink.split 활용)

const createApolloClient = (accessToken: Token, refreshToken: Token) => {
  //...
 
  const realHttpLink = ApolloLink.from([authLink, errorLink, httpLink]);

  const MOCKED_OPERATIONS = ["{Mocking 쿼리 타입 1}", "{Mocking 쿼리 타입 2}"];

  const splitLink = ApolloLink.split(
    operation => {
      const definition = getMainDefinition(operation.query);

      if (definition.kind === "OperationDefinition" && definition.name) {
        return MOCKED_OPERATIONS.includes(definition.name.value);
      }

      return false;
    },
    createMockLink(),
    realHttpLink
  );

  return new ApolloClient({
    link: splitLink,
    //...
  });
};

 

  • ApolloLink.split의 역할은 다음과 같습니다.
    • ApolloLink.split(test, left, right);
    • test: boolean 값을 반환하는 함수입니다. 들어오는 모든 GraphQL 요청(operation)에 대해 이 함수가 실행됩니다.
    • left: test 함수가 true를 반환했을 때 요청을 보낼 Link입니다. (이 코드에서는 createMockLink())
    • right: test 함수가 false를 반환했을 때 요청을 보낼 Link입니다. (이 코드에서는 realHttpLink)
  • 즉, 정의가 가능한 Operation이고, 이름이 있는지 확인한 후, Operation이 MOCKED_OPERATIONS 배열 내부의 값인지 확인하여
if (definition.kind === "OperationDefinition" && definition.name) { 
    return MOCKED_OPERATIONS.includes(definition.name.value); 
}
  • 맞다면 createMockLink()를 실행하여 Mock 데이터를 보내주고, 아니라면 실제 백엔드에 요청하여 실제 데이터를 가져옵니다.

 

 

5. 코드젠의 schema 옵션에 배열 형태로 Mocking용 스키마 파일을 추가

import { CodegenConfig } from "@graphql-codegen/cli";

const codegenConfig: CodegenConfig = {
  //...
  schema: [
    //...
    "src/lib/graphql/mocks/develop.schema.graphql",
  ],
  //...
};

export default codegenConfig;

 

 

이렇게 진행하면 아직 구현되지 않은 백엔드 API를 Mocking하여 사용할 수 있습니다!

긴 글 읽어주셔서 감사합니다 :)

회사에서 GQL을 쓰고 있는데 사용 과정에서 Fragment와 Apollo Client의 InMemoryCache의 관계에 대해 알아보고 정리했던 자료를 공유해 봅니다! 회고는 아직 작성중이에요ㅠㅠ 작성 완료 후 바로 올리겠습니다!


요약 :

캐시 히트율(cache hit rate)은 _“총 요청 수 중 캐시만으로 응답한 비율”_을 뜻한다. Apollo Client의

InMemoryCache는 GraphQL 응답을 정규화해 저장하고, 동일 데이터를 요청할 때 네트워크 대신 캐시에서 즉시 반환한다.

Fragment를 일관되게 사용하면 동일한 필드 집합으로 캐시에 저장·조회가 이뤄져 캐시 히트율이 높아진다. 반대로 서로 다른 필드 구성이면 히트율이 떨어지거나 추가 네트워크 요청이 발생한다.

 

1. 캐시 히트율이란?

용어 정의 그래프큐엘/아폴로 맥락

Cache Hit 클라이언트가 요청한 데이터를 캐시에서 완전히 찾아 바로 반환한 경우 useQuery·watchQuery 등이 캐시에서 필요한 모든 필드를 찾았을 때
Cache Miss 캐시에 일부 또는 전혀 없어서 네트워크로 다시 요청한 경우 캐시가 필드를 완전히 충족 못 하면 Apollo가 네트워크 요청(또는 오류) 발생
Cache Hit Rate Hit ÷ (Hit + Miss) 값이 높을수록 네트워크 왕복이 줄어 애플리케이션이 빠르고 효율적

 

 

2. InMemoryCache가 히트율을 높이는 방식

  1. 정규화(Normalization)같은 엔티티가 다른 쿼리에서도 재사용되면 동일 캐시 레코드를 가리키므로 중복 저장이 없다.
  2. 응답 객체를 __typename:id 키로 평탄한 테이블 형태로 저장한다.
  3. 필드 병합(Merging)이후 요청이 같은 필드를 요구하면 hit가 난다.
  4. 동일 객체의 새 필드가 들어오면 기존 캐시 엔트리에 병합된다.
  5. fetchPolicyno-cache 등을 쓰면 히트율 계산 자체가 의미 없어진다.
  6. cache-first (기본값)는 캐시가 충족되면 네트워크를 건너뛴다.

 

3. Fragment가 히트율에 미치는 영향

3-1. 왜 Fragment가 중요한가?

Fragment는 재사용 가능한 필드 묶음 이다.

동일 Fragment를 여러 쿼리에 사용하면 항상 같은 필드 집합이 캐시에 저장·조회된다.

  • 결과: 두 번째부터는 캐시가 모든 필드를 이미 갖고 있어 hit가 발생.

3-2. 서로 다른 Fragment가 히트율을 깎는 사례

fragment BasicUser on User { id name }
fragment EmailUser on User { id name email }
  1. 목록 페이지 → ...BasicUser 로 사용자 1-100을 조회
  2. 캐시에 id·name만 저장됨.
  3. 주소록 페이지 → ...EmailUser 로 같은 사용자 요청
  4. 캐시는 email 필드가 없어 miss 발생, 네트워크 추가 호출.

Apollo 문서도 필드 집합이 달라지면 “캐시 hit rate가 크게 줄어든다” 고 지적

 

3-3. 효율 높이는 전략

  1. 계층적 Fragment 구성
    • 목록은 UserList, 상세는 UserDetail 사용
    • 공통 부분(UserBase) 은 항상 hit → 기본 정보 렌더링이 즉시 가능
    • 때문에 Fregment를 계층적으로 구성하는게 성능 측면에서 유리함
      • 계층 구성을 통해 Fregemrnt로만 이루어진 반환된 결과 객체 자체가 캐싱되기 때문에 캐시 hit → 성능적으로 이점 챙김
  2. fragment UserBase on User { id name } fragment UserList on User { ...UserBase } fragment UserDetail on User { ...UserBase email phone }
  3. 필드 통제 & fetchPolicy
    • 목록 컴포넌트에서 **email**이 정말 필요 없다면 요청하지 않는다.
    • 상세 컴포넌트는 cache-and-network 등으로 먼저 캐시를 쓰고 부족분만 보충.
  4. readFragment / writeFragment 활용
    • 특정 객체를 ID 기반으로 직접 읽거나 써서 필요 필드만 갱신 가능.
    • UI는 즉시 업데이트되고, 네트워크 왕복 없이 히트율 유지.
    • readFragment / writeFragment 활용 사례
      // Fragment로 캐시 읽기
      const userData = client.readFragment({
        id: 'User:123',
        fragment: gql`
          fragment UserInfo on User {
            id
            name
            email
          }
        `
      });
      
      // Fragment로 캐시 쓰기
      client.writeFragment({
        id: 'User:123',
        fragment: gql`
          fragment UserInfo on User {
            id
            name
            email
          }
        `,
        data: {
          id: '123',
          name: 'Updated Name',
          email: 'updated@example.com'
        }
      });
      
    • Fragment를 사용해 캐시와 상호작용할 때는 readFragment, writeFragment 메서드를 사용

 

4. 히트율 최적화를 위한 체크리스트

체크포인트 설명

Fragment 일관성 동일 엔티티에 대해 가능한 한 같은 Fragment를 재사용
과도한 필드 요구 방지 목록 페이지에 상세 필드 포함 X
keyFields, keyArgs 설정 잘못된 키 설정은 중복 엔티티 생성 → 히트율 저하
fetchPolicy 조정 cache-first(기본), 필요 시 cache-and-network
캐시 검사 도구 Apollo DevTools → “cache” 탭으로 엔티티·필드 저장 여부 확인

+ Recent posts