GraphQL

시작하기

유익한 리소스

일반 리소스:

GitLab의 GraphQL:

라이브러리

우리는 GraphQL을 프론트엔드 개발에 사용할 때 Apollo (특히 Apollo Client)와 Vue Apollo를 사용합니다.

Vue 애플리케이션에서 GraphQL을 사용하는 경우 Vue에서의 사용 섹션을 통해 Vue Apollo를 통합하는 방법을 알 수 있습니다.

다른 사용 사례의 경우, Vue를 사용할 때 외부에서의 사용 섹션을 확인하세요.

불변 캐시 업데이트를 위해 Immer를 사용합니다. 자세한 내용은 불변성 및 캐시 업데이트를 참조하세요.

도구

Apollo GraphQL VS Code 확장 프로그램

VS Code를 사용하는 경우, Apollo GraphQL 확장 프로그램은 .graphql 파일에서 자동 완성을 지원합니다. GraphQL 확장 프로그램을 설정하려면 다음 단계를 따르세요:

  1. 스키마를 생성합니다: bundle exec rake gitlab:graphql:schema:dump
  2. gitlab 로컬 디렉토리의 루트에 apollo.config.js 파일을 추가합니다.
  3. 다음 내용으로 파일을 채웁니다:
module.exports = {
  client: {
    includes: ['./app/assets/javascripts/**/*.graphql', './ee/app/assets/javascripts/**/*.graphql'],
    service: {
      name: 'GitLab',
      localSchemaFile: './tmp/tests/graphql/gitlab_schema.graphql',
    },
  },
};
  1. VS Code를 재시작합니다.

GraphQL API 탐색

당사의 GraphQL API는 인스턴스의 /-/graphql-explorer 또는 GitLab.com에서 GraphiQL을 통해 탐색할 수 있습니다. 필요한 경우 GitLab GraphQL API 참조 문서를 참조하세요.

쿼리 및 뮤테이션의 모든 기존 항목을 확인하려면 GraphiQL의 오른쪽에 있는 Documentation explorer를 선택하세요. 작성한 쿼리 및 뮤테이션의 실행을 확인하려면 왼쪽 상단 모서리에서 쿼리 실행을 선택하세요.

GraphiQL 인터페이스

Apollo Client

다양한 앱에서 중복으로 생성되는 클라이언트를 저장하기 위해 기본 클라이언트가 있습니다. 이 클라이언트를 사용해야 합니다. 이는 올바른 URL로 Apollo 클라이언트를 설정하고 CSRF 헤더도 설정합니다.

기본 클라이언트는 resolversconfig 두 개의 매개변수를 받습니다.

  • resolvers 매개변수는 로컬 상태 관리 쿼리와 뮤테이션을 위한 리졸버 객체를 받도록 생성되었습니다.
  • config 매개변수는 설정 구성의 객체를 받습니다:
    • cacheConfig 필드는 Apollo 캐시를 사용자 정의하는 설정 객체를 선택적으로 받습니다.
    • baseUrl은 주요 엔드포인트와 다른 GraphQL 엔드포인트에 대한 URL을 전달할 수 있게 합니다(예: ${gon.relative_url_root}/api/graphql).
    • fetchPolicy은 컴포넌트가 Apollo 캐시와 상호작용하는 방식을 결정합니다. 기본값은 “cache-first”입니다.

동일한 개체에 대한 여러 클라이언트 쿼리

동일한 Apollo 클라이언트 개체에 대해 여러 쿼리를 수행하는 경우 다음 오류가 발생할 수 있습니다: Query 객체의 someProperty 필드를 교체할 때 캐시 데이터가 손실될 수 있습니다. 이 문제를 해결하려면 SomeEntity가 id를 가질 수 있도록 모든 SomeEntity 개체에 id가 있거나 사용자 정의 병합 함수를 정의하세요. 모든 GraphQL 유형에 대해 id가 있는지 여부를 이미 확인하고 있으므로 이 경우에는 이런 일이 발생해서는 안 됩니다(단위 테스트를 실행할 때 이 경고가 표시되면 요청될 때마다 id가 포함되도록 모의 응답을 확인하세요).

GraphQL 스키마에 SomeEntity 유형에 id 속성이 없는 경우 이 경고를 해결하려면 사용자 정의 병합 함수를 정의해야 합니다.

기본 클라이언트에는 typePoliciesmerge: true로 정의된 클라이언트 전체 유형이 있습니다(이는 Apollo가 이후 쿼리의 경우 기존 응답을 병합할 것이라는 것을 의미합니다). 거기에 SomeEntity를 추가하거나 이를 위해 사용자 정의 병합 함수를 정의하는 것을 고려하세요.

GraphQL 쿼리

실행 시점에서 쿼리 컴파일을 절약하기 위해 웹팩은 .graphql 파일을 직접 가져올 수 있습니다. 이를 통해 웹팩은 쿼리의 컴파일을 클라이언트의 대신 컴파일 시간에 전처리할 수 있게 됩니다.

쿼리와 뮤테이션, 프래그먼트를 구별하기 위해 다음과 같은 네이밍 규칙이 권장됩니다:

  • 쿼리에는 all_users.query.graphql;
  • 뮤테이션에는 add_user.mutation.graphql;
  • 프래그먼트에는 basic_user.fragment.graphql을 사용하세요.

CustomersDot GraphQL 엔드포인트용으로 쿼리를 사용하는 경우 파일 이름을 .customer.query.graphql, .customer.mutation.graphql, 또는 .customer.fragment.graphql로 끝내세요.

프래그먼트

프래그먼트는 복잡한 GraphQL 쿼리를 더 읽기 쉽고 재사용 가능하게 만드는 방법입니다. 여기 GraphQL 프래그먼트의 예제가 있습니다:

fragment DesignListItem on Design {
  id
  image
  event
  filename
  notesCount
}

프래그먼트는 별도의 파일에 저장되어 가져와서 쿼리, 뮤테이션 또는 다른 프래그먼트에 사용될 수 있습니다.

#import "./design_list.fragment.graphql"
#import "./diff_refs.fragment.graphql"

fragment DesignItem on Design {
  ...DesignListItem
  fullPath
  diffRefs {
    ...DesignDiffRefs
  }
}

프래그먼트에 대한 자세한 내용: GraphQL 문서

Global IDs

GitLab GraphQL API는 PostgreSQL 기본 키 id 대신 Global ID로 id 필드를 표현합니다. Global ID는 클라이언트 측 라이브러리에서 캐싱 및 가져오기에 사용되는 규칙입니다.

Global ID를 기본 키 id로 변환하려면 getIdFromGraphQLId를 사용할 수 있습니다:

import { getIdFromGraphQLId } from '~/graphql_shared/utils';

const primaryKeyId = getIdFromGraphQLId(data.id);

모든 GraphQL 유형에 대해 GraphQL 스키마에서 id를 쿼리하는 것이 필수입니다:

query allReleases(...) {
  project(...) {
    id // 프로젝트에는 GraphQL 스키마에 ID가 있으므로 가져와야 합니다
    releases(...) {
      nodes {
        // 릴리즈에는 GraphQL 스키마에 ID 속성이 없습니다
        name
        tagName
        tagPath
        assets {
          count
          links {
            nodes {
              id // 링크에는 GraphQL 스키마에 ID가 있으므로 가져와야 합니다
              name
            }
          }
        }
      }
      pageInfo {
        // 페이지 정보에는 GraphQL 스키마에 ID 속성이 없습니다
        startCursor
        hasPreviousPage
        hasNextPage
        endCursor
      }
    }
  }
}

불변성과 캐시 업데이트

Apollo 버전 3.0.0부터 모든 캐시 업데이트는 불변해야 합니다. 이것은 새롭고 업데이트된 객체로 완전히 교체되어야 합니다.

캐시를 업데이트하고 새로운 객체를 반환하는 프로세스를 용이하게 하기 위해 우리는 Immer 라이브러리를 사용합니다. 다음 규칙을 준수하세요:

  • 업데이트된 캐시는 데이터로 명명됩니다.
  • 원본 캐시 데이터는 소스데이터로 명명됩니다.

전형적인 업데이트 프로세스는 다음과 같습니다:

...
const sourceData = client.readQuery({ query });

const data = produce(sourceData, draftState => {
  draftState.commits.push(newCommit);
});

client.writeQuery({
  query,
  data,
});
...

코드 예제에서 보여지듯이 produce를 사용함으로써 draftState의 어떠한 종류의 직접적인 조작을 수행할 수 있습니다. 게다가 immerdraftState의 변경 사항이 포함된 새로운 상태를 생성하는 것을 보장합니다.

Vue에서의 사용

Vue Apollo를 사용하려면 Vue Apollo 플러그인과 기본 클라이언트를 import해야 합니다. 이는 Vue 애플리케이션이 마운트된 동일한 지점에 생성되어야 합니다.

import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);

const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient(),
});

new Vue({
  ...,
  apolloProvider,
  ...
});

Vue ApolloVue Apollo 문서에서 더 읽어보세요.

Apollo에서의 로컬 상태

기본 클라이언트를 생성할 때 Apollo를 사용하여 애플리케이션 상태를 관리할 수 있습니다.

클라이언트 측 리졸버 사용

기본 클라이언트를 설정한 후 캐시에 쓰는 것으로 기본 상태를 설정할 수 있습니다. 아래 예제에서는 @client Apollo 지시어를 사용하여 초기 데이터를 Apollo 캐시에 쓰고 나중에 Vue 구성 요소에서 이 상태를 가져옵니다.

// user.query.graphql

query User {
  user @client {
    name
    surname
    age
  }
}
// index.js

import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import userQuery from '~/user/user.query.graphql'
Vue.use(VueApollo);

const defaultClient = createDefaultClient();

defaultClient.cache.writeQuery({
  query: userQuery,
  data: {
    user: {
      name: 'John',
      surname: 'Doe',
      age: 30
    },
  },
});

const apolloProvider = new VueApollo({
  defaultClient,
});
// App.vue
import userQuery from '~/user/user.query.graphql'

export default {
  apollo: {
    user: {
      query: userQuery
    }
  }
}

writeQuery 대신에, 우리는 userQuery를 캐시에서 읽으려는 모든 시도에 user를 반환하는 유형 정책을 만들 수 있습니다:

const defaultClient = createDefaultClient({}, {
  cacheConfig: {
    typePolicies: {
      Query: {
        fields: {
          user: {
            read(data) {
              return data || {
                user: {
                  name: 'John',
                  surname: 'Doe',
                  age: 30
                },
              }
            }
          }
        }
      }
    }
  }
});

로컬 데이터를 생성하는 것 외에도, 우리는 @client 필드로 기존의 GraphQL 유형을 확장할 수도 있습니다. 이것은 우리의 GraphQL API에 아직 추가되지 않은 필드에 대한 API 응답을 모킹해야 할 때에 매우 유용합니다.

로컬 Apollo 캐시를 사용한 API 응답 모킹

실제 API에 아직 추가되지 않은 경우와 같이 로컬에서 일부 GraphQL API 응답, 쿼리 또는 뮤테이션을 모킹해야할 때 로컬 Apollo Cache 사용이 도움이 됩니다.

예를 들어, 쿼리에서 사용되는 DesignVersionfragment가 있습니다:

fragment VersionListItem on DesignVersion {
  id
  sha
}

또한 버전 작성자와 created at 프로퍼티를 가져와 버전 드롭다운 목록에 표시해야 합니다. 그러나, 이러한 변경 사항은 아직 API에 구현되지 않았습니다. 우리는 새로운 필드에 대한 모킹된 응답을 얻기 위해 기존의 fragment를 수정할 수 있습니다.

fragment VersionListItem on DesignVersion {
  id
  sha
  author @client {
    avatarUrl
    name
  }
  createdAt @client
}

이제 Apollo는 @client 지시어로 표시된 각 필드에 대해 _리졸버_를 찾으려고 시도합니다. DesignVersion 유형에 대한 리졸버를 만들어 봅시다 (DesignVersion 유형에서 fragment가 생성되었으므로 왜 DesignVersion인가?).

// resolvers.js

const resolvers = {
  DesignVersion: {
    author: () => ({
      avatarUrl:
        'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
      name: 'Administrator',
      __typename: 'User',
    }),
    createdAt: () => '2019-11-13T16:08:11Z',
  },
};

export default resolvers;

기존의 Apollo 클라이언트에 리졸버 객체를 전달해야 합니다:

// graphql.js

import createDefaultClient from '~/lib/graphql';
import resolvers from './graphql/resolvers';

const defaultClient = createDefaultClient(resolvers);

버전을 가져오기 위해 각각의 시도마다, 클라이언트는 원격 API 엔드포인트에서 idsha를 가져옵니다. 그런 다음, 고정 값으로 authorcreatedAt 버전 속성에 할당합니다. 이 데이터로 프론트엔드 개발자들은 백엔드에 의해 차단되지 않고 UI 작업을 수행할 수 있습니다. 응답이 API에 추가되면, 사용자 정의 로컬 리졸버를 제거할 수 있습니다. 쿼리/fragment의 유일한 변경 사항은 @client 지시어를 제거하는 것입니다.

Apollo로 Vue Apollo 문서에서 로컬 상태 관리에 대해 자세히 읽어보세요.

Vuex 사용 시

저희는 Vuex와 Apollo Client를 함께 사용하여 새로운 애플리케이션을 만드는 것을 권장하지 않습니다. 몇 가지 이유가 있습니다:

  • VueX와 Apollo 둘 다 글로벌 스토어이기 때문에 책임을 공유하고 두 가지 진실의 원천이 됩니다.
  • VueX와 Apollo를 동기화 유지하는 것은 매우 유지보수가 어려울 수 있습니다.
  • Apollo와 VueX 간의 통신에서 발생하는 버그는 섬세하고 디버그하기 어렵습니다.

Frontend와 Backend가 동기화되지 않은 상태에서 GraphQL 기반 기능 작업

GraphQL 쿼리/뮤테이션을 생성하거나 업데이트해야 하는 기능은 신중하게 계획되어야 합니다. Frontend와 backend 담당자들은 서로 만족하는 스키마에 동의해야 합니다. 이를 통해 양쪽 부서가 서로를 차단하지 않고 각자의 역할을 구현할 수 있게 됩니다.

이상적으로는 백엔드 구현이 프론트엔드보다 우선되어야 합니다. 이렇게 함으로써 클라이언트는 최소한의 부서 간 소통으로 API를 쿼리할 수 있습니다. 하지만, 우선순위가 항상 일치하지는 않는다는 점을 인지합니다. 반복적으로 작업하고 제공할 수 있도록 신속하게 작업하기 위해 경우에 따라 프론트엔드가 백엔드보다 먼저 구현되어야 할 수도 있습니다.

백엔드보다 프론트엔드에서 쿼리 및 뮤테이션 구현

이 경우, 프론트엔드는 아직 백엔드 리졸버와 대응되지 않는 GraphQL 스키마나 필드를 정의합니다. 이는 구현이 공개적인 오류로 전환되지 않도록 기능별로 제대로 feature-flagged 처리되는 한 괜찮습니다. 그러나 우리는 graphql-verify CI 작업으로 클라이언트 측 쿼리/뮤테이션을 백엔드 GraphQL 스키마와 검증합니다. 변경 사항이 병합되기 전에 유효성 검사를 통과해야 합니다.

아래는 이를 수행하기 위한 몇 가지 제안 사항입니다.

@client 지시문 사용

권장하는 접근 방식은 백엔드에서 아직 지원되지 않는 새로운 쿼리, 뮤테이션 또는 필드에 @client 지시문을 사용하는 것입니다. 해당 지시문이 지정된 엔티티는 graphql-verify 검증 작업에서 건너뜁니다.

또한 Apollo는 이들을 클라이언트 측에서 해결하려고 시도하며, 이는 로컬 Apollo 캐시를 사용하여 API 응답을 가장하는 데에 사용될 수 있습니다. 이는 클라이언트 측에서 정의된 가짜 데이터로 기능을 테스트하는 편리한 방법을 제공합니다. 변경 사항에 대한 병합 요청을 열 때, 리뷰어들이 쉽게 당신의 작업을 smoke-test할 수 있도록 로컬 리졸버를 제공하는 것이 좋은 아이디어일 수 있습니다.

이러한 지시문의 제거를 추적하여 후속 문제로 기록하거나 백엔드 구현 계획의 일부로 처리하는 것이 중요합니다.

알려진 실패 목록에 예외 추가

특정 파일에 대해 GraphQL 쿼리/뮤테이션 검증을 완전히 비활성화하려면 그 경로를 config/known_invalid_graphql_queries.yml 파일에 추가합니다. 마치 .eslintignore 파일을 통해 일부 파일에 대해 ESLint를 비활성화하는 것처럼 작동합니다. 이 파일에 나열된 파일은 전혀 검증되지 않음에 유의하십시오. 따라서 기존 쿼리에 필드를 추가하는 경우 나머지 쿼리가 여전히 검증되도록 @client 지시문 접근 방식을 사용해야 합니다.

다시 한번, 이러한 오버라이드가 가능한 한 짧게 유지되도록 추적하여 적절한 문제에 대한 제거를 보장하는 것이 중요합니다.

기능별 플래그 쿼리

백엔드가 완료되었고 프론트엔드가 기능별 플래그 뒤에 구현되는 경우, GraphQL 쿼리에서 기능별 플래그를 활용할 수 있는 몇 가지 옵션이 있습니다.

@include 지시문

@include (또는 반대로 @skip)는 엔티티를 쿼리에 포함해야 하는지를 제어하는 데 사용할 수 있습니다. @include 지시문이 false로 평가되면 엔티티의 리졸버는 호출되지 않고 엔티티가 응답에서 제외됩니다. 예를 들어:

query getAuthorData($authorNameEnabled: Boolean = false) {
  username
  name @include(if: $authorNameEnabled)
}

그런 다음 Vue (또는 JavaScript)에서 쿼리 호출 시 기능 플래그를 전달할 수 있습니다. 이 기능 플래그는 이미 올바르게 설정되어 있어야 합니다. 올바른 방법에 대해서는 기능 플래그 문서를 참조하십시오.

export default {
  apollo: {
    user: {
      query: QUERY_IMPORT,
      variables() {
        return {
          authorNameEnabled: gon?.features?.authorNameEnabled,
        };
      },
    }
  },
};

지시문이 false로 평가되더라도 보호된 엔티티가 백엔드로 전송되어 GraphQL 스키마와 일치합니다. 따라서이 접근 방식은 기능 플래그가 비활성화되더라도 스키마에 해당 엔티티가 존재해야 하므로, 백엔드가 제동된다면 최소한 리졸버가 같은 기능 플래그를 사용하여 null을 반환하는 것이 권장됩니다. API GraphQL 가이드를 참조하십시오.

쿼리의 다른 버전

기능 플래그를 사용하는 또 다른 접근 방식이 있지만, 이는 표준 쿼리를 복제하여 포함되어야 하는 새로운 엔티티를 추가하는 방식으로, 사용을 피해야 합니다. 원본은 변경되지 않은 채로 유지됩니다. 기능 플래그의 상태에 따라 프로덕션 코드가 올바른 쿼리를 트리거하도록 하는 것이 우선입니다.

export default {
  apollo: {
    user: {
      query() {
        return this.glFeatures.authorNameEnabled ? NEW_QUERY : ORIGINAL_QUERY,
      }
    }
  },
};
여러 쿼리 버전 회피

여러 버전 접근 방식은 기능 플래그가 존재하는 한 두 개의 유사한 쿼리를 유지해야 하므로 더 큰 병합 요청을 초래하여 권장되지 않습니다. 새로운 GraphQL 엔터티가 스키마의 일부가 아니거나 스키마 수준에서 기능 플래그가 지정된 경우에만 여러 버전을 사용할 수 있습니다(new_entity: :feature_flag).

쿼리 수동 트리거

컴포넌트의 apollo 속성에 대한 쿼리는 해당 컴포넌트가 생성될 때 자동으로 수행됩니다. 일부 컴포넌트는 대신 필요시 네트워크 요청을 수행하려고 합니다. 예를 들어 항목을 게으르게 로드하는 드롭다운 목록이 있습니다.

이 작업을 수행하는 두 가지 방법이 있습니다:

  1. skip 속성 사용
export default {
  apollo: {
    user: {
      query: QUERY_IMPORT,
      skip() {
        // 드롭다운이 열려있을 때에만 쿼리를 수행합니다.
        return !this.isOpen;
      },
    }
  },
};
  1. addSmartQuery 사용

메서드에서 Smart Query를 수동으로 생성할 수 있습니다.

handleClick() {
  this.$apollo.addSmartQuery('user', {
    // `apollo` 섹션에 있는 것과 동일한 값을 취합니다.
    query: QUERY_IMPORT,
  }),
};

페이지네이션 사용

GitLab GraphQL API는 Relay-style 커서 페이지네이션을 사용합니다. 이는 “커서”를 사용하여 데이터 집합에서 다음 항목이 가져올 위치를 추적하는 방식입니다. GraphQL Ruby Connection Concepts은 연결에 대한 좋은 개요 및 소개입니다.

각 연결 유형(예: DesignConnectionDiscussionConnection)은 페이지네이션에 필요한 정보를 포함하는 pageInfo 필드를 가지고 있습니다:

pageInfo {
  endCursor
  hasNextPage
  hasPreviousPage
  startCursor
}

여기서:

  • startCursor는 첫 번째 항목의 커서를 표시하고, endCursor는 마지막 항목의 커서를 표시합니다.
  • hasPreviousPagehasNextPage를 사용하여 현재 페이지 이전 또는 이후에 더 많은 페이지가 있는지 확인할 수 있습니다.

연결 유형 데이터를 가져올 때, 페이지네이션의 시작점 또는 끝점을 나타내는 after 또는 before 매개변수로 커서를 전달할 수 있습니다. 각각을 사용하여 주어진 끝점 이후나 이전에 가져올 항목의 얼마나 많은지를 나타냅니다.

예를 들어, 여기서 projectQuery라고 부르는 커서 이후 10개의 디자인을 가져오고 있습니다:

#import "~/graphql_shared/fragments/page_info.fragment.graphql"

query {
  project(fullPath: "root/my-project") {
    id
    issue(iid: "42") {
      designCollection {
        designs(atVersion: null, after: "Ihwffmde0i", first: 10) {
          edges {
            node {
              id
            }
          }
          pageInfo {
            ...PageInfo
          }
        }
      }
    }
  }
}

page_info.fragment.graphql을 사용하여 pageInfo 정보를 채우고 있음을 주목하세요.

컴포넌트 내 fetchMore 메서드 사용

이 방식은 사용자가 처리하는 페이지네이션을 사용하는 데 의미가 있습니다. 예를 들어 데이터를 더 가져 오거나 명시적으로 다음 페이지를 클릭하는 경우입니다. 초기에 모든 데이터를 가져와야 할 때, 대신 (비스마트) 쿼리를 사용하는 것이 좋습니다.

초기적으로 데이터를 가져올 때, 보통 페이지네이션을 처음부터 시작하려고 하는데 이 경우:

  • 커서 전달을 건너 뛰거나
  • 명시적으로 afternull을 전달합니다.

데이터가 가져와진 후, update 후크를 사용하여 Vue 컴포넌트 속성에 설정되는 데이터를 사용자 정의하는 기회로 활용할 수 있습니다. 이를 통해 다른 데이터와 함께 pageInfo 객체를 얻을 수 있습니다.

result 후크에서는 pageInfo 객체를 검사하여 다음 페이지를 가져와야 하는지 여부를 확인할 수 있습니다. 무한히 다음 페이지를 요청하는 것을 방지하기 위해 requestCount를 유지합니다.

data() {
  return {
    pageInfo: null,
    requestCount: 0,
  }
},
apollo: {
  designs: {
    query: projectQuery,
    variables() {
      return {
        // ... 나머지 디자인 변수
        first: 10,
      };
    },
    update(data) {
      const { id = null, issue = {} } = data.project || {};
      const { edges = [], pageInfo } = issue.designCollection?.designs || {};

      return {
        id,
        edges,
        pageInfo,
      };
    },
    result() {
      const { pageInfo } = this.designs;

      // 각 새로운 결과마다 요청 횟수 증가
      this.requestCount += 1;
      // 요청 횟수와 다음 페이지를 가져올 수 있는지 여부를 확인합니다
      if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
        this.fetchNextPage(pageInfo.endCursor);
      }
    },
  },
},

다음 페이지로 이동하려면, 새 커서(및 선택적으로 새 변수)를 전달하여 Apollo fetchMore 메서드를 사용합니다.

fetchNextPage(endCursor) {
  this.$apollo.queries.designs.fetchMore({
    variables: {
      // ... 나머지 디자인 변수
      first: 10,
      after: endCursor,
    },
  });
}
필드 병합 정책 정의

우리는 들어오는 결과를 기존 결과랑 어떻게 병합할 지를 지정하는 필드 정책을 정의해야 할 필요가 있습니다. 예를 들어, ‘이전/다음’ 버튼이 있다면 들어오는 결과로 기존 결과를 대체하는 것이 합리적입니다.

const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient(
    {},
    {
      cacheConfig: {
        typePolicies: {
          DesignCollection: {
            fields: {
              designs: {
                merge(existing, incoming) {
                  if (!incoming) return existing;
                  if (!existing) return incoming;

                  // 우리는 들어오는 노드들만 저장하고 기존 노드들은 대체하려고 합니다
                  return incoming
                }
              }
            }
          }
        }
      },
    },
  ),
});

무한 스크롤이 있는 경우, 기존 노드들에 들어오는 designs 노드를 대체하는 대신 기존 노드들에 추가하는 것이 합리적일 것입니다. 이 경우에는 병합 함수가 약간 다를 것입니다.

const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient(
    {},
    {
      cacheConfig: {
        typePolicies: {
          DesignCollection: {
            fields: {
              designs: {
                merge(existing, incoming) {
                  if (!incoming) return existing;
                  if (!existing) return incoming;

                  const { nodes, ...rest } = incoming;
                  // 우리는 노드 배열만 병합해야 합니다.
                  // 나머지 필드들(페이지네이션)은 항상 들어오는 것으로 덮어씌워져야 합니다.
                  let result = rest;
                  result.nodes = [...existing.nodes, ...nodes];
                  return result;
                }
              }
            }
          }
        }
      },
    },
  ),
});

apollo-client은 페이지네이션된 쿼리와 사용할 수 있는 몇 가지 필드 정책을 제공합니다. concatPagination 정책으로 무한 스크롤 페이지네이션을 달성하는 다른 방법은 다음과 같습니다:

import { concatPagination } from '@apollo/client/utilities';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';

Vue.use(VueApollo);

export default new VueApollo({
  defaultClient: createDefaultClient(
    {},
    {
      cacheConfig: {
        typePolicies: {
          Project: {
            fields: {
              dastSiteProfiles: {
                keyArgs: ['fullPath'], // 캐시 무결성을 강제하기 위해 keyArgs 옵션을 설정해야 할 수 있습니다
              },
            },
          },
          DastSiteProfileConnection: {
            fields: {
              nodes: concatPagination(),
            },
          },
        },
      },
    },
  ),
});

이것은 새로운 페이지 결과가 이전 결과에 추가되는 것과 마찬가지로 DesignCollection 예제와 유사합니다.

어떤 경우에는 필드의 keyArgs를 정의하는 것이 어려울 때가 있습니다. 이 경우 keyArgsfalse로 설정할 수 있습니다. 이렇게 하면 Apollo Client가 자동 병합을 수행하지 않고 우리가 merge 함수에 넣은 논리에 완전히 의존하도록 지시합니다.

예를 들어, 다음과 같은 쿼리가 있다고 가정해 봅시다:

query searchGroupsWhereUserCanTransfer {
  currentUser {
    id
    groups(after: 'somecursor') {
      nodes {
        id
        fullName
      }
      pageInfo {
        ...PageInfo
      }
    }
  }
}

여기서 groups 필드에는 keyArgs를 위한 적합한 후보가 없습니다: 우리는 연속된 페이지를 요청할 때마다 after 인자에 대해 고려할 필요가 없습니다. keyArgsfalse로 설정하면 의도한 대로 업데이트가 작동합니다:

typePolicies: {
  UserCore: {
    fields: {
      groups: {
        keyArgs: false,
      },
    },
  },
  GroupConnection: {
    fields: {
      nodes: concatPagination(),
    },
  },
}

컴포넌트에서 재귀 쿼리 사용

초기에 모든 페이지네이션된 데이터를 검색해야 하는 경우 Apollo 쿼리가 우리를 도와줄 수 있습니다. 사용자 상호 작용에 기반하여 다음 페이지를 검색해야 하는 경우, smartQuery와 함께 fetchMore-hook를 사용하는 것이 권장됩니다.

쿼리가 해결되면 컴포넌트 데이터를 업데이트하고 pageInfo 객체를 검사할 수 있습니다. 이를 통해 다음 페이지를 가져와야 하는지를 확인할 수 있게 됩니다. 이를 재귀적으로 호출하여 메소드를 호출할 수 있습니다.

또한, 애플리케이션이 무한하게 다음 페이지를 요청하지 않도록 하기 위해 requestCount를 유지하는 것이 중요합니다.

페이지네이션과 낙관적 업데이트

Apollo가 클라이언트 측에서 데이터를 페이지별로 캐시할 때, 캐시 키에 pageInfo 변수가 포함됩니다. 만약 해당 데이터를 낙관적으로 업데이트하려면, .readQuery() 또는 .writeQuery()를 통해 캐시와 상호 작용할 때 pageInfo 변수를 제공해야 합니다. 이는 번거롭고 직관적이지 않을 수 있습니다.

캐시된 페이지네이션 쿼리를 다루기 쉽게 하기 위해, Apollo는 @connection 지시어를 제공합니다. 이 지시어는 데이터를 캐싱할 때 정적 키로 사용되는 key 매개변수를 받습니다. 그럼으로써 페이지네이션에 특화된 변수를 제공하지 않고도 데이터를 검색할 수 있게 됩니다.

다음은 @connection 지시어를 사용한 쿼리의 예시입니다:

#import "~/graphql_shared/fragments/page_info.fragment.graphql"

query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
  project(fullPath: $fullPath) {
    siteProfiles: dastSiteProfiles(after: $after, before: $before, first: $first, last: $last)
      @connection(key: "dastSiteProfiles") {
      pageInfo {
        ...PageInfo
      }
      edges {
        cursor
        node {
          id
          # ...
        }
      }
    }
  }
}

이 예시에서 Apollo는 데이터를 안정적인 dastSiteProfiles 캐시 키로 저장합니다.

캐시에서 해당 데이터를 검색하려면, $fullPath 변수만 제공하면 되며, afterbefore와 같은 특정 페이지네이션 변수는 제공할 필요가 없습니다.

const data = store.readQuery({
  query: dastSiteProfilesQuery,
  variables: {
    fullPath: 'namespace/project',
  },
});

Apollo 문서에서 @connection 지시어에 대해 더 읽어보세요.

유사한 쿼리 일괄 처리

기본적으로 Apollo 클라이언트는 하나의 쿼리당 브라우저에서 HTTP 요청을 보냅니다. 여러 쿼리를 하나의 요청으로 일괄 처리하여 요청 수를 줄일 수 있으며, batchKey를 정의함으로써 이를 선택할 수 있습니다.

한 컴포넌트에서 여러 번 쿼리가 호출되지만 UI를 한 번만 업데이트하려는 경우에 유용합니다. 이 예시에서는 컴포넌트 이름을 키로 사용합니다:

export default {
  name: 'MyComponent'
  apollo: {
    user: {
      query: QUERY_IMPORT,
      context: {
        batchKey: 'MyComponent',
      },
    }
  },
};

일괄 처리 키는 컴포넌트의 이름일 수 있습니다.

폴링과 성능

Apollo 클라이언트는 간단한 폴링을 지원하지만, 성능적인 이유로 ETag 기반 캐싱이 데이터베이스를 매번 호출하는 것보다 선호됩니다.

백엔드에서 ETag 리소스가 캐시로 설정된 후, 프론트엔드에서 몇 가지 변경이 필요합니다.

먼저, 백엔드에서 ETag 리소스를 받아야 합니다. 이는 URL 경로 형태여야 하며, 파이프라인 그래프 예시에서는 graphql_resource_etag이며 Apollo 컨텍스트에 추가할 새 헤더를 만들 때 사용됩니다:

/* pipelines/components/graph/utils.js */

/* eslint-disable @gitlab/require-i18n-strings */
const getQueryHeaders = (etagResource) => {
  return {
    fetchOptions: {
      method: 'GET',
    },
    headers: {
      /* 이는 여러분의 기능에 따라 다를 것입니다 */
      'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph',
      'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource,
      'X-REQUESTED-WITH': 'XMLHttpRequest',
    },
  };
};
/* eslint-enable @gitlab/require-i18n-strings */

/* component.vue */

apollo: {
  pipeline: {
    context() {
      return getQueryHeaders(this.graphqlResourceEtag);
    },
    query: getPipelineDetails,
    pollInterval: 10000,
    ..
  },
},

여기서 apollo 쿼리는 graphqlResourceEtag의 변경을 주시합니다. 만약 여러분의 ETag 리소스가 동적으로 변경된다면, 쿼리 헤더에 보내는 리소스도 업데이트되도록 해야 합니다. 이를 위해 동적으로 ETag 리소스를 로컬 캐시에 저장하고 업데이트할 수 있습니다.

이를 확인할 수 있는 예시는 파이프라인 에디터의 파이프라인 상태입니다. 파이프라인 에디터는 최신 파이프라인의 변경을 주시합니다. 사용자가 새 커밋을 만들 때, 새로운 파이프라인의 변경을 주시할 수 있도록 파이프라인 쿼리를 업데이트합니다.

# pipeline_etag.query.graphql

query getPipelineEtag {
  pipelineEtag @client
}
/* pipeline_editor/components/header/pipeline_status.vue */

import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql';

apollo: {
  pipelineEtag: {
    query: getPipelineEtag,
  },
  pipeline: {
    context() {
      return getQueryHeaders(this.pipelineEtag);
    },
    query: getPipelineQuery,
    pollInterval: POLL_INTERVAL,
  },
}

/* pipeline_editor/components/commit/commit_section.vue */

await this.$apollo.mutate({
  mutation: commitCIFile,
  update(store, { data }) {
    const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath;

    if (pipelineEtag) {
      store.writeQuery({ query: getPipelineEtag, data: { pipelineEtag } });
    }
  },
});

마지막으로, 브라우저 탭이 비활성 상태일 때 쿼리 폴링을 일시 중지하는 가시성 검사를 추가할 수 있습니다. 이렇게 함으로써 페이지의 요청 부하를 줄일 수 있습니다.

/* component.vue */

import { toggleQueryPollingByVisibility } from '~/pipelines/components/graph/utils';

export default {
  mounted() {
    toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL);
  },
};

이 MR를 참고하여 프론트엔드에서 ETag 캐싱을 완전히 구현하는 방법에 대해 알아볼 수 있습니다.

한 번 subscriptions가 안정화되면, 이 과정은 그것을 사용하고 쿼리 일괄 처리로 돌아가는 것으로 대체할 수 있을 것이며, 별도의 링크 라이브러리를 제거할 수 있을 것입니다.

ETag 캐싱 테스트 방법

네트워크 탭에서 요청을 확인하여 구현이 제대로 동작하는지 테스트할 수 있습니다. ETag 리소스에 변경 사항이 없는 경우 폴링된 요청은 다음과 같아야 합니다:

  • POST 요청 대신 GET 요청이어야 합니다.
  • 200 대신 304 HTTP 상태를 가져야 합니다.

테스트 중에 개발자 도구에서 캐싱이 비활성화되지 않았는지 확인하세요.

Chrome을 사용하는 경우 계속해서 200 HTTP 상태 코드가 표시된다면 개발자 도구에서 200 대신 304가 표시됨이 될 수 있습니다. 이 경우, 실제로 요청이 캐시되었고 304 상태 코드로 반환되었는지 확인하기 위해 응답 헤더 소스를 검사하세요.

구독

우리는 웹소켓을 통해 GraphQL API에서 실시간 업데이트를 수신하기 위해 구독을 사용합니다. 현재 기존 구독 수가 제한되어 있으며 사용 가능한 구독 목록은 GraphqiQL 탐색기에서 확인할 수 있습니다.

참고: 우리는 GraphiQL을 사용하여 구독을 테스트할 수 없습니다. 왜냐하면 그것은 현재 ActionCable 클라이언트를 지원하지 않기 때문입니다.

구독에 대한 포괄적인 소개는 실시간 위젯 개발자 가이드를 참조하세요.

최선의 실천 방법

뮤테이션에서 update 훅 사용하기(그리고 사용하지 않을 때)

Apollo Client의 .mutate() 메서드는 뮤테이션 라이프사이클 중에 두 번 호출되는 update 훅을 노출합니다.

  • 한 번은 처음에, 즉 뮤테이션이 완료되기 전에 호출됩니다.
  • 다른 한 번은 뮤테이션이 완료된 후에 호출됩니다.

이 훅은 보통 전역 id로 표시되는 기존 항목을 업데이트 하는 경우에만 사용해야 합니다. 이 경우, 뮤테이션 쿼리 정의에 id가 있으면 저장소가 자동으로 업데이트됩니다. 다음은 id가 포함된 전형적인 뮤테이션 쿼리의 예시입니다:

mutation issueSetWeight($input: IssueSetWeightInput!) {
  issuableSetWeight: issueSetWeight(input: $input) {
    issuable: issue {
      id
      weight
    }
    errors
  }
}

테스트

GraphQL 스키마 생성

일부 테스트는 스키마 JSON 파일을 로드합니다. 이러한 파일을 생성하려면 다음을 실행하세요:

bundle exec rake gitlab:graphql:schema:dump

원본에서 풀링 된 후 또는 브랜치를 다시베이스할 때 이 작업을 실행해야 합니다. 이것은 gdk update의 일부로 자동으로 실행됩니다.

참고: RubyMine IDE를 사용하고 ‘tmp’ 디렉토리를 “제외”로 표시한 경우 gitlab/tmp/tests/graphql에 대해 “디렉터리 표시 -> 제외되지 않음”을 해야 합니다. 이렇게 하면 JS GraphQL 플러그인이 스키마를 자동으로 찾고 색인화할 수 있습니다.

Apollo Client 모의 테스트

Apollo 작업을 사용하여 컴포넌트를 테스트하려면 단위 테스트에서 Apollo Client를 모의화해야 합니다. 우리는 Apollo 클라이언트를 모의화하기 위해 mock-apollo-client 라이브러리를 사용하고, 이 위에 만든 createMockApollo 도우미를 사용합니다.

VueApollo를 호출하여 Vue 인스턴스에 주입해야 합니다. 이렇게 하면 해당 파일의 모든 테스트에 대해 전역으로 VueApollo가 설치됩니다. 이 파일의 중요한 부분에 Vue.use(VueApollo)를 호출하는 것이 좋습니다.

import VueApollo from 'vue-apollo';
import Vue from 'vue';

Vue.use(VueApollo);

describe('Apollo 모의화를 사용하는 컴포넌트', () => {
  let wrapper;

  function createComponent(options = {}) {
    wrapper = shallowMount(...);
  }
})

이후에 모의화된 Apollo 공급자를 만들어야 합니다:

import createMockApollo from 'helpers/mock_apollo_helper';

describe('Apollo 모의화를 사용하는 컴포넌트', () => {
  let wrapper;
  let mockApollo;

  function createComponent(options = {}) {
    mockApollo = createMockApollo(...)

    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }

  afterEach(() => {
    // 테스트 사이에 공급자가 유지되지 않도록 해야 합니다
    mockApollo = null
  })
})

이제 모든 쿼리 또는 뮤테이션에 대한 핸들러 배열을 정의해야 합니다. 핸들러는 올바른 쿼리 응답 또는 오류를 반환하는 모의 함수여야 합니다.

import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';

describe('Apollo 모의화를 사용하는 컴포넌트', () => {
  let wrapper;
  let mockApollo;

  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse)
  }) {
    mockApollo = createMockApollo([
       [getDesignListQuery, options.designListHandler],
       [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
       [moveDesignMutation, jest.fn().mockResolvedValue(moveDesignMutationResponse)],
    ])

    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }
})

추가로, 해결된 값들을 모의하는 경우, 응답의 구조가 실제 API 응답과 동일하도록 해야 합니다. 예를 들어, 루트 속성은 data여야 합니다:

const designListQueryResponse = {
  data: {
    project: {
      id: '1',
      issue: {
        id: 'issue-1',
        designCollection: {
          copyState: 'READY',
          designs: {
            nodes: [
              {
                id: '3',
                event: 'NONE',
                filename: 'fox_3.jpg',
                notesCount: 1,
                image: 'image-3',
                imageV432x230: 'image-3',
                currentUserTodos: {
                  nodes: [],
                },
              },
            ],
          },
          versions: {
            nodes: [],
          },
        },
      },
    },
  },
};

쿼리를 테스트할 때, 약속이므로 결과를 렌더링하기 위해 해결되어야 함을 주의해야 합니다. 해결되지 않으면 쿼리의 loading 상태를 확인할 수 있습니다:

it('로딩 상태를 렌더링합니다', () => {
  const wrapper = createComponent();

  expect(wrapper.findComponent(LoadingSpinner).exists()).toBe(true)
});

it('디자인 목록을 렌더링합니다', async () => {
  const wrapper = createComponent();

  await waitForPromises()

  expect(findDesigns()).toHaveLength(3);
});

쿼리 오류를 테스트해야 하는 경우, 거부된 값에 대한 모의 요청 핸들러를 모의화해야 합니다:

it('쿼리 실패 시 오류를 렌더링합니다', async () => {
  const wrapper = createComponent({
    designListHandler: jest.fn().mockRejectedValue('Houston, we have a problem!')
  });

  await waitForPromises()

  expect(wrapper.find('.test-error').exists()).toBe(true)
})

뮤테이션을 동일한 방식으로 테스트할 수 있습니다:

  const moveDesignHandlerSuccess = jest.fn().mockResolvedValue(moveDesignMutationResponse)

  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse),
    moveDesignHandler: moveDesignHandlerSuccess
  }) {
    mockApollo = createMockApollo([
       [getDesignListQuery, options.designListHandler],
       [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
       [moveDesignMutation, moveDesignHandler],
    ])

    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }

it('정확한 매개변수로 뮤테이션을 호출하고 디자인을 재정렬합니다', async () => {
  const wrapper = createComponent();

  wrapper.find(VueDraggable).vm.$emit('change', {
    moved: {
      newIndex: 0,
      element: designToMove,
    },
  });

  expect(moveDesignHandlerSuccess).toHaveBeenCalled();

  await waitForPromises();

  expect(
    findDesigns()
      .at(0)
      .props('id'),
  ).toBe('2');
});

여러 개의 쿼리 응답 상태, 성공 및 실패를 모의화할 수 있습니다. Apollo 클라이언트의 네이티브 다시 시도 동작은 Jest의 모의 함수와 결합하여 일련의 응답을 생성할 수 있습니다. 이것들은 수동으로 미리 진행할 필요는 없지만 특정 방식으로 대기해야 합니다.

describe('쿼리 시간이 초과되는 경우', () => {
  const advanceApolloTimers = async () => {
    jest.runOnlyPendingTimers();
    await waitForPromises()
  };

  beforeEach(async () => {
    const failSucceedFail = jest
      .fn()
      .mockResolvedValueOnce({ errors: [{ message: 'timeout' }] })
      .mockResolvedValueOnce(mockPipelineResponse)
      .mockResolvedValueOnce({ errors: [{ message: 'timeout' }] });

    createComponentWithApollo(failSucceedFail);
    await waitForPromises();
  });

  it('올바른 오류를 표시하고 데이터가 비어 있을 때 데이터를 덮어씌우지 않습니다', async () => {
    /* 첫 번째로 실패하고, 오류 표시, 데이터 없음 */
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(false);

    /* 성공하고, 오류 지워지고, 그래프 표시 */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(false);
    expect(getGraph().exists()).toBe(true);

    /* 다시 실패하면, 알림이 돌아오지만 데이터는 유지됩니다 */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(true);
  });
});

이전에는 마운트에서 { mocks: { $apollo ...}}을 사용하여 Apollo 기능을 테스트했습니다. 이 접근 방식은 권장되지 않습니다 - 적합한 $apollo 모의화는 테스트에 많은 구현 세부 정보를 노출시킵니다. 이를 모의화된 Apollo 공급자로 교체하는 것을 고려하세요.

wrapper = mount(SomeComponent, {
  mocks: {
    // 피하십시오! 실제 graphql 쿼리 및 뮤테이션을 모의화하세요
    $apollo: {
      mutate: jest.fn(),
      queries: {
        groups: {
          loading,
        },
      },
    },
  },
});

구독 테스트

구독을 테스트할 때, vue-apollo@4의 기본 동작은 구독을 다시 구독하고 오류 발생 시 즉시 새 요청을 보내는 것입니다(skip 값이 제한하지 않는 한).

import waitForPromises from 'helpers/wait_for_promises';

// subscriptionMock은 구독의 핸들러 함수로 등록됩니다.
// 우리의 헬퍼에
const subcriptionMock = jest.fn().mockResolvedValue(okResponse);

// ...

it('오류 상태 테스트', () => {
  // 이하를 피합니다: 아래에서 멈출 것입니다!
  subscriptionMock = jest.fn().mockRejectedValue({ errors: [] });

  // 컴포넌트가 생성 컴포넌트의 일부로 구독 모의를 호출
  createComponent();
  // 영원히 멈출 것:
  // * 거부된 프로미스는 다시 구독을 트리거하게 될 것입니다
  // * 다시 구독은 다시 subscriptionMock을 호출하고 거부된 프로미스를 일으킬 것입니다
  // * 거부된 프로미스는 다음 다시 구독을 트리거하게 될 것입니다
  await waitForPromises();
  // ...
})

vue@3vue-apollo@4를 사용할 때 무한 루프를 피하려면 일회성 거부를 사용하는 것을 고려하세요.

it('실패 테스트', () => {
  // OK: 구독이 한 번 실패할 것입니다
  subscriptionMock.mockRejectedValueOnce({ errors: [] });
  // 컴포넌트가 생성 컴포넌트의 일부로 구독 모의를 호출
  createComponent();
  await waitForPromises();

  // 이제 아래 코드가 실행될 것입니다
})

@client 쿼리 테스트

모의 resolver 사용

애플리케이션이 @client 쿼리를 포함하는 경우, 핸들러만 전달할 때 다음과 같은 Apollo Client 경고가 발생합니다.

Unexpected call of console.warn() with:
Warning: mock-apollo-client - The query is entirely client-side (using @client directives) and resolvers have been configured. The request handler will not be called.

이를 해결하려면 모의 핸들러 대신 모의 resolver를 정의해야 합니다. 예를 들어, 다음 @client 쿼리가 제공될 때:

query getBlobContent($path: String, $ref: String!) {
  blobContent(path: $path, ref: $ref) @client {
    rawData
  }
}

그리고 실제 클라이언트 측 resolver가 다음과 같은 경우:

import Api from '~/api';

export const resolvers = {
  Query: {
    blobContent(_, { path, ref }) {
      return {
        __typename: 'BlobContent',
        rawData: Api.getRawFile(path, { ref }).then(({ data }) => {
          return data;
        }),
      };
    },
  },
};

export default resolvers;

모의 resolver를 사용하여 동일한 형태의 데이터를 반환하고 모의 함수를 사용하여 결과를 모의화할 수 있습니다.

let mockApollo;
let mockBlobContentData; // 모의 함수, jest.fn();

const mockResolvers = {
  Query: {
    blobContent() {
      return {
        __typename: 'BlobContent',
        rawData: mockBlobContentData(), // 모의 함수는 모의 데이터를 해결할 수 있습니다
      };
    },
  },
};

const createComponentWithApollo = ({ props = {} } = {}) => {
  mockApollo = createMockApollo([], mockResolvers); // resolver는 두 번째 매개변수입니다

  wrapper = shallowMount(MyComponent, {
    propsData: {},
    apolloProvider: mockApollo,
    // ...
  })
};

이후 필요한 값을 모의화하거나 거부할 수 있습니다.

beforeEach(() => {
  mockBlobContentData = jest.fn();
});

it('데이터 표시', async() => {
  mockBlobContentData.mockResolvedValue(data); // 결과를 모의화하거나 거부할 수 있습니다

  createComponentWithApollo();

  await waitForPromises(); // 모의 resolver가 실행될 때까지 기다립니다

  expect(findContent().text()).toBe(mockCiYml);
});
cache.writeQuery 사용

가끔 우리는 로컬 쿼리의 result 후크를 테스트하려고 합니다. 이를 트리거하려면 이 쿼리로 가져올 올바른 데이터로 캐시를 채워야 합니다:

query fetchLocalUser {
  fetchLocalUser @client {
    name
  }
}
import fetchLocalUserQuery from '~/design_management/graphql/queries/fetch_local_user.query.graphql';

describe('Apollo 모의를 사용하는 일부 컴포넌트', () => {
  let wrapper;
  let mockApollo;

  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse)
  }) {
    mockApollo = createMockApollo([...])
    mockApollo.clients.defaultClient.cache.writeQuery({
      query: fetchLocalUserQuery,
      data: {
        fetchLocalUser: {
          __typename: 'User',
          name: 'Test',
        },
      },
    });

    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }
})

모의 Apollo 클라이언트의 캐싱 동작을 구성해야 할 때, 모의 클라이언트 인스턴스를 생성할 때 추가 캐시 옵션을 제공하고 제공된 옵션은 기본 캐시 옵션과 병합될 것입니다:

const defaultCacheOptions = {
  fragmentMatcher: { match: () => true },
  addTypename: false,
};
mockApollo = createMockApollo(
  requestHandlers,
  {},
  {
    dataIdFromObject: (object) =>
      // eslint-disable-next-line no-underscore-dangle
      object.__typename === 'Requirement' ? object.iid : defaultDataIdFromObject(object),
  },
);

에러 처리

GitLab GraphQL 변이에는 두 가지 다른 오류 모드가 있습니다: 최상위 오류와 데이터로 오류 처리.

GraphQL 변이를 사용할 때, 오류가 발생할 때 사용자가 적절한 피드백을 받을 수 있도록 이러한 두 가지 오류 모드를 모두 처리하는 것이 중요합니다.

최상위 오류

이러한 오류는 GraphQL 응답의 “최상위”에 위치합니다. 이는 복구할 수 없는 오류로, 인수 오류 및 구문 오류를 포함하며, 사용자에게 직접 표시되어서는 안 됩니다.

최상위 오류 처리

Apollo는 최상위 오류를 인지하므로, 이러한 오류를 처리하기 위해 Apollo의 여러 오류 처리 메커니즘을 활용할 수 있습니다. 예를 들어, mutate 메소드를 호출한 후 Promise 거부를 처리하거나, ApolloMutation 컴포넌트에서 발생한 error 이벤트를 처리할 수 있습니다.

이러한 오류는 사용자를 위한 것이 아니므로, 최상위 오류의 오류 메시지는 클라이언트 측에서 정의되어야 합니다.

데이터로 오류 처리

이러한 오류는 GraphQL 응답의 data 객체에 중첩되어 있습니다. 이는 복구 가능한 오류로, 이상적으로 사용자에게 직접 표시할 수 있는 오류입니다.

데이터로 오류 처리

먼저, 변이 객체에 errors를 추가해야 합니다:

mutation createNoteMutation($input: String!) {
  createNoteMutation(input: $input) {
    note {
      id
+     errors
    }
  }

이제 이 변이를 커밋하고 오류가 발생하면, 응답에는 오류를 처리할 수 있는 errors가 포함됩니다:

{
  data: {
    mutationName: {
      errors: ["죄송합니다, 메모를 업데이트할 수 없었습니다."]
    }
  }
}

데이터로 오류를 처리할 때에는 응답의 오류 메시지를 사용자에게 표시할지, 또는 사용자에게 사용자 지정 클라이언트 측 정의 메시지를 표시할지를 판단하는 것이 중요합니다.

Vue 밖에서 사용

또한, Vue 밖에서 GraphQL을 직접 가져와 쿼리와 함께 기본 클라이언트를 사용하는 것도 가능합니다.

import createDefaultClient from '~/lib/graphql';
import query from './query.graphql';

const defaultClient = createDefaultClient();

defaultClient.query({ query })
  .then(result => console.log(result));

VueX를 사용, 다음 상황에서 캐시를 비활성화합니다:

  • 데이터가 다른 곳에 캐시되는 경우
  • 사용 사례에 캐싱이 필요하지 않은 경우
import createDefaultClient from '~/lib/graphql';
import fetchPolicies from '~/graphql_shared/fetch_policy_constants';

const defaultClient = createDefaultClient(
  {},
  {
    fetchPolicy: fetchPolicies.NO_CACHE,
  },
);

GraphQL 시작 호출로 초기 쿼리 수행

성능을 향상시키기 위해 때로는 초기에 GraphQL 쿼리를 수행하기를 원할 수 있습니다. 이를 위해 시작 호출에 이러한 쿼리를 추가할 수 있습니다.

  • 응용 프로그램에서 초기에 필요한 모든 쿼리를 app/graphql/queries로 이동합니다.
  • 모든 중첩된 쿼리 수준에 __typename 속성을 추가합니다:

    query getPermissions($projectPath: ID!) {
      project(fullPath: $projectPath) {
        __typename
        userPermissions {
          __typename
          pushCode
          forkProject
          createMergeRequestIn
        }
      }
    }
    
  • 쿼리에 단편이 포함되어 있다면, 단편을 가져오는 대신 쿼리 파일에 직접 이동해야 합니다:

    fragment PageInfo on PageInfo {
      __typename
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
    }
    
    query getFiles(
      $projectPath: ID!
      $path: String
      $ref: String!
    ) {
      project(fullPath: $projectPath) {
        __typename
        repository {
          __typename
          tree(path: $path, ref: $ref) {
            __typename
              pageInfo {
                ...PageInfo
              }
            }
          }
        }
      }
    }
    
  • 단편이 한 번만 사용된다면, 단편을 완전히 제거할 수도 있습니다:

    query getFiles(
      $projectPath: ID!
      $path: String
      $ref: String!
    ) {
      project(fullPath: $projectPath) {
        __typename
        repository {
          __typename
          tree(path: $path, ref: $ref) {
            __typename
              pageInfo {
                __typename
                hasNextPage
                hasPreviousPage
                startCursor
                endCursor
              }
            }
          }
        }
      }
    }
    
  • 모든 변수가 올바르게 포함된 시작 호출을 HAML 파일에 추가합니다. GraphQL 시작 호출을 추가할 때는 add_page_startup_graphql_call 도우미를 사용하며, 첫 번째 매개변수는 쿼리의 경로이고 두 번째 매개변수는 쿼리 변수를 포함하는 객체입니다. 쿼리의 경로는 app/graphql/queries 폴더를 기준으로 상대적인 경로로 지정됩니다. 예를 들어, app/graphql/queries/repository/files.query.graphql 쿼리가 필요하다면 경로는 repository/files입니다.

문제 해결

Mock된 클라이언트가 목 응답 대신 빈 객체를 반환하는 경우

단위 테스트가 모의 데이터 대신 빈 객체를 포함한 응답으로 실패하는 경우, 모의 응답에 __typename 필드를 추가하세요.

또는 GraphQL 쿼리 fixtures는 생성 시 자동으로 __typename을 추가합니다.

캐시 데이터 손실에 대한 경고

가끔 콘솔에 Cache data may be lost when replacing the someProperty field of a Query object. To address this problem, either ensure all objects of SomeEntity have an id or a custom merge function 경고 메시지가 표시될 수 있습니다. 이 문제를 해결하려면 동일한 객체에 대한 여러 클라이언트 쿼리 섹션을 확인하세요.

- current_route_path = request.fullpath.match(/-\/tree\/[^\/]+\/(.+$)/).to_a[1]
- add_page_startup_graphql_call('repository/path_last_commit', { projectPath: @project.full_path, ref: current_ref, path: current_route_path || "" })
- add_page_startup_graphql_call('repository/permissions', { projectPath: @project.full_path })
- add_page_startup_graphql_call('repository/files', { nextPageCursor: "", pageSize: 100, projectPath: @project.full_path, ref: current_ref, path: current_route_path || "/"})