GraphQL

시작하기

유용한 자료

일반 자료:

GitLab의 GraphQL:

라이브러리

우리는 프론트엔드 개발에 GraphQL을 사용할 때 Apollo ClientVue Apollo를 사용합니다.

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

다른 사용 사례를 위해서는 Vue 바깥에서의 사용법 섹션을 확인하세요.

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

도구

Apollo GraphQL VS Code extension

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',
        },
      },
    };
    
  4. VS Code 재시작

GraphQL API 탐색

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

기존 쿼리 및 뮤테이션을 모두 확인하려면 GraphiQL의 오른쪽에서 Documentation Explorer를 선택하세요. 작성한 쿼리와 뮤테이션의 실행을 확인하려면 왼쪽 상단에서 Execute query를 선택하세요.

GraphiQL 인터페이스

Apollo Client

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

기본 클라이언트는 resolversconfig 두 매개변수를 허용합니다.

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

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

동일한 Apollo 클라이언트 객체에 대해 여러 쿼리를 수행하는 경우 다음 오류가 발생할 수 있습니다: Cache data may be lost when replacing the someProperty field of a Query object. To address this problem, either ensure all objects of SomeEntityhave an id or a custom merge function. 우리는 id 존재 여부를 모든 SomeEntity 유형의 모든 객체에 대해 확인하고 있으므로(모의 응답에서 id가 요청될 때마다)이 경우에는 해당하지 않습니다.

SomeEntity 유형이 GraphQL 스키마에 id 속성을 가지고 있지 않은 경우, 이 경고를 해결하기 위해 사용자 정의 Merge 함수를 정의해야 합니다.

기본 클라이언트에는 typePolicies로 정의된 merge: true를 갖는 클라이언트 전체 유형이 있습니다(즉, Apollo는 연이어 발생하는 응답을 Merge합니다). 이곳에 SomeEntity를 추가하거나 해당 유형에 대한 사용자 정의 Merge 함수를 정의해야 합니다.

GraphQL 쿼리

실행 시 쿼리 컴파일을 저장하기 위해 webpack는 .graphql 파일을 직접 가져올 수 있습니다. 이를 통해 webpack은 쿼리를 클라이언트가 컴파일하는 대신 컴파일 시간에 쿼리를 전처리할 수 있습니다.

쿼리, 뮤테이션 및 프래그먼트를 구별하기 위해 다음 네이밍 컨벤션을 권장합니다:

  • 쿼리의 경우 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하고 쿼리, 뮤테이션 또는 다른 프래그먼트에서 사용할 수 있습니다.

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

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

프래그먼트에 대한 자세한 내용은 GraphQL 문서에서 확인하세요.

전역 ID

GitLab GraphQL API는 기본 키 id 대신에 전역 ID로 id 필드를 표현합니다. 전역 ID는 클라이언트 측 라이브러리의 캐싱 및 검색에 사용되는 관례입니다.

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

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

const primaryKeyId = getIdFromGraphQLId(data.id);

[주의] GraphQL 스키마에서 id를 가진 모든 GraphQL 타입에 대해 전역 id를 쿼리하는 것이 필수입니다:

query allReleases(...) {
  project(...) {
    id // Project는 GraphQL 스키마에 ID가 있으므로 가져와야 함
    releases(...) {
      nodes {
        // Release는 GraphQL 스키마에 ID 속성이 없음
        name
        tagName
        tagPath
        assets {
          count
          links {
            nodes {
              id // Link는 GraphQL 스키마에 ID가 있으므로 가져와야 함
              name
            }
          }
        }
      }
      pageInfo {
        // PageInfo는 GraphQL 스키마에 ID 속성이 없음
        startCursor
        hasPreviousPage
        hasNextPage
        endCursor
      }
    }
  }
}

변경 불가성 및 캐시 업데이트

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

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

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

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

...
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 Apollo에 대해 자세히 알아보려면 Vue Apollo documentation를 참조하세요.

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
                },
              }
            }
          }
        }
      }
    }
  }
});

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

로컬 Apollo 캐시를 사용하여 API 응답 흉내내기

로컬 Apollo 캐시를 사용하면 아직 실제 API에 추가되지 않은 경우와 같이 GraphQL API 응답, 쿼리 또는 뮤테이션을 흉내내는 경우에 유용합니다.

예를 들어 쿼리에서 사용되는 DesignVersion에 대한 fragment가 있습니다.

fragment VersionListItem on DesignVersion {
  id
  sha
}

또한 버전 작성자 및 created at 속성을 가져와 버전 드롭다운 디렉터리에 표시해야 합니다. 그러나 이러한 변경 사항들은 아직 우리의 API에 구현되지 않았습니다. 존재하는 fragment를 변경하여 이러한 새로운 필드에 대한 모킹 응답을 받을 수 있습니다.

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

이제 Apollo는 @client 지시어로 표시된 각 필드에 대한 _resolver_를 찾으려고 합니다. (왜 DesignVersion 타입에 대한 fragment를 생성했느냐고요? 그 이유는 우리의 fragment가 이 타입에서 생성되었기 때문입니다).

// 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 Client에 resolver 객체를 전달해야 합니다.

// graphql.js

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

const defaultClient = createDefaultClient(resolvers);

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

Apollo로 로컬 상태 관리에 대해 자세히 알아보려면 Vue Apollo 문서를 참조하세요.

Vuex와 함께 사용

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

  • Vuex와 Apollo는 모두 전역 스토어이므로 책임을 공유하며 두 가지 진실의 원천이 됩니다.
  • Vuex와 Apollo를 동기화하는 것은 유지 관리 비용이 많이 듭니다.
  • Apollo와 Vuex 간의 통신으로 인한 버그는 세심하게 처리되어 디버그하기 어렵습니다.

프론트엔드와 백엔드가 동기화되지 않은 상태에서 GraphQL 기반 기능 개발

GraphQL 쿼리/뮤테이션을 만들거나 업데이트해야 하는 기능은 신중하게 계획되어야 합니다. 프론트엔드와 백엔드 담당자는 클라이언트 사이드와 서버 사이드 요구 사항을 충족하는 스키마에 동의해야 합니다. 이를 통해 양쪽 부서가 서로를 차단하지 않고 각각의 파트를 구현할 수 있게 됩니다.

이상적으로는 프론트엔드보다 백엔드 구현이 먼저 완료되어야 합니다. 이렇게 하면 클라이언트가 최소한의 왔다갔다 없이 API를 즉시 쿼리할 수 있습니다. 그러나 우선 순위가 항상 일치하지는 않을 수 있다는 점을 인지합니다. 반복과 제공할 작업의 측면에서 프론트엔드가 백엔드보다 먼저 구현되어야 하는 경우가 있을 수 있습니다.

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

이러한 경우에 프론트엔드는 아직 백엔드 리졸버와 일치하지 않는 GraphQL 스키마 또는 필드를 정의합니다. 기능이 피처 플래그로 올바르게 구성되어 있다면 이것이 허용됩니다. 퍼블릭으로 활성화되지 않는 제품 상에서의 오류로 변경되지 않도록 기능이 피처 플래그로 올바르게 구성되어야 합니다. 그러나 우리는 클라이언트 측 쿼리/뮤테이션을 백엔드 GraphQL 스키마와 함께 graphql-verify CI 작업을 사용하여 확인합니다. 변경 사항이 실제 지원되기 전에 본 피처 플래그가 통과하는지 확인해야 합니다. 이를 위해 다음과 같은 제안 몇 가지를 고려하십시오.

@client 지시어 사용

추천하는 접근 방법은 백엔드에서 아직 지원되지 않는 새로운 쿼리, 뮤테이션 또는 필드에 @client 지시어를 사용하는 것입니다. 이 지시어가 지정된 개체는 graphql-verify 확인 작업에서 건너뛰게 됩니다.

이에 더하여 Apollo는 클라이언트 측에서 가져다 사용하려고 시도하면서 이들을 클라이언트 측에서 해결해 줄 수 있습니다. 이는 로컬 Apollo 캐시를 사용하여 API 응답을 흉내내기와 함께 사용할 수 있어서 클라이언트 측에서 가짜 데이터로 기능을 테스트하는 편리한 방법을 제공합니다. 변경 사항에 대한 Merge Request을 할 때 리뷰어가 간단하게 GDK에 적용할 수 있는 로컬 리졸버를 제공하는 것은 좋은 방법일 수 있습니다.

지시어의 제거를 추적하는 것은 후속 문제의 일부로 추적하거나 백엔드 구현 계획의 일부로 추적해야 합니다.

알려진 실패 디렉터리에 예외 추가

GraphQL 쿼리/뮤테이션 검증은 config/known_invalid_graphql_queries.yml 파일에 경로를 추가함으로써 특정 파일에 대한 완전한 검증을 비활성화할 수 있습니다. 마치 일부 파일에 대해 ESLint를 .eslintignore 파일을 통해 비활성화하는 것처럼 특정 파일에 대한 검증을 완전히 비활성화할 수 있습니다.

여기에 나열된 파일은 모두 검증되지 않습니다. 따라서 기존 쿼리에 필드만 추가하는 경우에는 여전히 쿼리의 나머지 부분이 검증되도록 @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 스키마와 일치됩니다. 따라서 이 접근 방법은 피처 플래그가 비활성화되었더라도 스키마에 해당 엔터티를 반드시 존재해야 합니다. 피처 플래그가 꺼진 경우 리졸버가 최소한 같은 피처 플래그를 사용하여 널 값을 반환하는 것이 권장됩니다. API GraphQL 가이드를 참조하세요.

쿼리의 여러 버전

표준 쿼리를 복제하는 다른 방법이 있지만, 피해야 합니다. 복사본에는 새로운 엔터티가 포함되어 있고, 원본은 변경되지 않은 채로 남아 있습니다. 프로덕션 코드에서는 피처 플래그의 상태에 따라 올바른 쿼리를 트리거할 수 있습니다. 예를 들면:

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

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

매뉴얼으로 쿼리 트리거하기

컴포넌트의 apollo 속성에 대한 쿼리는 컴포넌트가 생성될 때 자동으로 수행됩니다. 일부 컴포넌트는 대신 필요에 따라 네트워크 요청을 수행할 수 있습니다. 예를 들어 항목이 지연 로드된 드롭다운 디렉터리 등이 있습니다.

이를 수행하는 두 가지 방법이 있습니다:

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

메서드에서 스마트 쿼리를 매뉴얼으로 생성할 수 있습니다.

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

페이징 작업 처리

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

각 연결 형식(예: DesignConnectionDiscussionConnection)에는 페이징에 필요한 정보를 포함하는 pageInfo 필드가 있습니다:

pageInfo {
  endCursor
  hasNextPage
  hasPreviousPage
  startCursor
}

여기에서:

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

연결 유형으로 데이터를 가져올 때 after 또는 before 매개변수로 커서를 전달하여 페이징의 시작 또는 끝점을 나타낼 수 있습니다. 이후 또는 이전에 가져올 항목의 얼마나 많은 항목을 가져올지를 나타내기 위해 각각 first 또는 last 매개변수를 사용해야 합니다.

예를 들어 여기에서 커서 이후에 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,
    },
  });
}
필드 Merge 정책 정의

우리는 들어오는 결과와 기존 결과를 어떻게 Merge할지를 지정하는 필드 정책을 정의해야 합니다. 예를 들어 이전/다음 버튼이 있을 때, 기존 결과를 들어오는 결과로 바꾸는 것이 합리적입니다.

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 노드를 기존 것에 추가하는 것이 합리적일 것입니다. 이 경우, Merge 함수는 약간 다를 것입니다.

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;
                  // 노드 배열만 Merge하면 됩니다.
                  // 나머지 필드들(페이지네이션)은 항상 들어오는 것에 덮어쓰여져야 합니다.
                  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을 수행하지 말고 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 쿼리가 우리에게 도움이 될 수 있습니다. 사용자 상호 작용에 따라 다음 페이지를 가져와야 하는 경우, 컴포넌트 데이터를 업데이트하고 pageInfo 객체를 검사할 수 있는 smartQuery와 함께 fetchMore-hook를 사용하는 것이 좋습니다.

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

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

data() {
  return {
    requestCount: 0,
    isLoading: false,
    designs: {
      edges: [],
      pageInfo: null,
    },
  }
},
created() {
  this.fetchDesigns();
},
methods: {
  handleError(error) {
    this.isLoading = false;
    // `error`와 함께 무언가를 수행합니다.
  },
  fetchDesigns(endCursor) {
    this.isLoading = true;
    
    return this.$apollo
      .query({
        query: projectQuery,
        variables() {
          return {
            // ... 나머지 디자인 변수들
            first: 10,
            endCursor,
          };
        },
      })
      .then(({ data }) => {
        const { id = null, issue = {} } = data.project || {};
        const { edges = [], pageInfo } = issue.designCollection?.designs || {};
        
        // 데이터를 업데이트합니다.
        this.designs = {
          id,
          edges: [...this.designs.edges, ...edges];
          pageInfo: pageInfo;
        };
        
        // 각 새로운 결과마다 요청 수를 증가시킵니다.
        // 요청 수가 더 많고 다음 페이지를 가져올 수 있을 경우에만 다음 페이지를 가져옵니다.
        if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
          this.fetchDesigns(pageInfo.endCursor);
        } else {
          this.isLoading = false;
        }
      })
      .catch(this.handleError);
  },
},

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

Apollo이 클라이언트 측에 페이지별 데이터를 캐시할 때, pageInfo 변수를 캐시 키에 포함합니다. 만약 해당 데이터를 낙관적으로 업데이트하려면, 캐시와 상호작용할 때 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 캐시 키로 데이터를 저장합니다.

캐시에서 해당 데이터를 검색하려면 after 또는 before와 같은 페이지네이션에 특화된 변수를 제외하고 $fullPath 변수만 제공하면 됩니다.

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);
  },
};

ETag 캐싱을 완전히 구현하는 방법에 대한 참고로 이 MR을 사용할 수 있습니다.

구독이 더욱 성숙해지면,이 프로세스는 바쳐 리 쿼리로 대체하고 별도의 링크 라이브러리를 제거하여 일괄 처리 쿼리로 돌아갈 수 있습니다.

ETag 캐싱 테스트 방법

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

  • POST 요청 대신 GET 요청이어야 합니다.
  • HTTP 상태 코드가 200이 아닌 304여야 합니다.

테스트할 때 개발자 도구에서 캐싱이 비활성화되지 않았는지 확인해야 합니다.

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

구독

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

참고: GraphiQL은 현재 ActionCable 클라이언트를 요구하기 때문에 구독을 테스트할 수 없습니다.

구독에 대한 포괄적인 소개를 보려면 실시간 위젯 개발 가이드를 참조하십시오.

최적의 방법

뮤테이션에서 update 훅을 사용하거나 사용하지 않을 때

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

  • 뮤테이션이 완료되기 전에 한 번.
  • 뮤테이션이 완료된 후에 한 번.

이 훅은 보통 전역 id를 나타내는 기존 항목 갱신이 아닌, 스토어(ApolloCache)에 항목을 추가하거나 삭제할 때만 사용해야 합니다. 기존 항목을 갱신하는 경우 대부분의 경우 뮤테이션 쿼리 정의에 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의 일부로 자동으로 실행됩니다.

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

Apollo Client 모의(Mocking)

Apollo 작업을 포함하여 컴포넌트를 테스트하려면 유닛 테스트에서 Apollo Client를 모의해야 합니다. 우리는 Apollo Client를 모의하기 위해 mock-apollo-client 라이브러리를 사용하고 이에 기반한 createMockApollo 헬퍼를 사용합니다.

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

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

Vue.use(VueApollo);

describe('Apollo 모의(Mock)를 사용하는 일부 컴포넌트', () => {
  let wrapper;
  
  function createComponent(options = {}) {
    wrapper = shallowMount(...);
  }
})

이후, 모의된 Apollo 프로바이더를 생성해야 합니다.

import createMockApollo from 'helpers/mock_apollo_helper';

describe('Apollo 모의(Mock)를 사용하는 일부 컴포넌트', () => {
  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 모의(Mock)를 사용하는 일부 컴포넌트', () => {
  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 응답과 동일한지 확인해야 합니다. 예를 들어, root 속성은 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: [],
          },
        },
      },
    },
  },
};

쿼리를 테스트할 때, 쿼리가 프로미스이므로 결과를 렌더링하려면 _해결(resolve)_해야 합니다. 해결하지 않은 채로라면 쿼리의 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('휴스턴, 문제가 발생했습니다!')
  });
  
  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');
});

여러 쿼리 응답 상태를 모의하려면, 성공과 실패의 두 가지 상태를 함께 만들 수 있습니다. 이것들은 매뉴얼으로 고급화될 필요는 없지만 특정한 방식으로 기다려야 합니다.

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);
  });
});

이전에 mount에서 { mocks: { $apollo ...}}을 사용하여 Apollo 기능을 테스트했었습니다. 이 접근 방식은 추천되지 않습니다. 올바른 $apollo 모의는 테스트에 많은 구현 세부 정보를 노출시킵니다. 이를 대신 모의된 Apollo 프로바이더로 교체하는 것이 좋습니다.

wrapper = mount(SomeComponent, {
  mocks: {
    // 피하세요! 실제 graphql 쿼리와 뮤테이션을 모의(mock) 대신하십시오
    $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();
  // 영원히 멈출 것입니다:
  // * 거부된 프라미스는 다시 구독을 트리거할 것입니다
  // * 다시 구독은 구독 모의를 다시 호출하여 거부된 프라미스 결과
  // * 거부된 프라미스는 다음 재구독을 트리거할 것입니다,
  await waitForPromises();
  // ...
})

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

it('실패 테스트', () => {
  // OK: 한 번 구독이 실패합니다
  subscriptionMock.mockRejectedValueOnce({ errors: [] });
  // 컴포넌트는 구독 모의를 호출합니다.
  createComponent();
  await waitForPromises();
  
  // 이제 아래 코드가 실행됩니다.
})

@client 쿼리 테스트

모의 해결사 사용

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

console.warn()의 예상치 못한 호출:
경고: mock-apollo-client - 쿼리가 완전히 클라이언트 측( @client 지시문 사용)이며 리졸버가 구성되었습니다. 요청 핸들러가 호출되지 않을 것입니다.

이를 해결하려면 핸들러 대신 모의 resolvers를 정의해야 합니다. 예를 들어, 다음 @client 쿼리가 있다고 가정합니다.

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

그리고 실제 클라이언트 측 해결사:

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;

동일한 모양의 데이터를 반환하는 모의 해결사를 사용할 수 있습니다. 모의 함수로 결과를 모의화합니다.

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

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

const createComponentWithApollo = ({ props = {} } = {}) => {
  mockApollo = createMockApollo([], mockResolvers); // 해결사는 두 번째 매개변수입니다
  
  wrapper = shallowMount(MyComponent, {
    propsData: {},
    apolloProvider: mockApollo,
    // ...
  })
};

이후 필요한 값을 해결하거나 거부할 수 있습니다.

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

it('데이터 표시', async() => {
  mockBlobContentData.mockResolvedValue(data); // 결과를 모의화하려면 거부 또는 해결할 수 있습니다
  
  createComponentWithApollo();
  
  await waitForPromises(); // 해결자 모의 실행 대기
  
  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: '테스트',
        },
      },
    });
    
    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }
})

모의 apollo 클라이언트의 캐싱 동작을 구성해야 하는 경우, 모킹된 클라이언트 인스턴스를 작성할 때 추가적인 캐시 옵션을 제공하고 제공된 옵션은 기본 캐시 옵션과 Merge됩니다:

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 이벤트를 처리할 수 있습니다.

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

Errors-as-data

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

Errors-as-data 처리

먼저, mutation 객체에 errors를 추가해야 합니다.

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

이제 이 mutation을 커밋하고 오류가 발생하면, 응답에 오류를 처리할 수 있도록 errors가 포함됩니다.

{
  data: {
    mutationName: {
      errors: ["Sorry, we were not able to update the note."]
    }
  }
}

오류를 처리할 때는, 응답에서 오류 메시지를 표시할지 또는 사용자에게 정의된 다른 메시지를 클라이언트 측에서 제시할지를 판단할 때 최선의 판단을 사용하세요.

Vue 외부에서의 사용

또한 Vue 외부에서 GraphQL을 직접 가져와서 기본 클라이언트를 사용하는 것이 가능합니다.

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

const defaultClient = createDefaultClient();

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

Vue와 함께 사용할 때, 다음과 같은 경우에 캐시를 비활성화하세요:

  • 데이터가 다른 곳에 캐시되어 있는 경우
  • 사용 사례에 캐싱이 필요하지 않은 경우
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 쿼리 fixture를 통해 생성 시 자동으로 __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 경고가 표시됩니다. 문제를 해결하려면 multiple queries 섹션을 확인하세요.

  - 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 || "/"})