GraphQL

시작하기

유용한 자원

일반 자원:

GitLab의 GraphQL:

라이브러리

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

Vue 애플리케이션에서 GraphQL을 사용하는 경우 Vue에서 사용 섹션은 Vue Apollo를 통합하는 방법을 학습하는 데 도움이 될 수 있습니다.

다른 사용 사례의 경우 Vue 바깥에서 사용 섹션을 확인하세요.

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

도구

Apollo Client Devtools

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

GraphQL API 탐색

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

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

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 클라이언트 객체에 대해 여러 쿼리를 수행하는 경우 다음 오류가 발생할 수 있습니다: 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의 존재 여부를 확인하고 있으므로 (단위 테스트를 실행할 때 이 오류가 나타나면 모조 응답에 요청될 때마다 id가 포함되어 있는지 확인하세요) 이 경우에는 이 에러가 발생하지 않아야 합니다.

SomeEntity 타입에 id 프로퍼티가 없는 경우, 이 경고를 수정하려면 사용자 정의 병합 함수를 정의해야 합니다.

우리는 typePolicies에서 merge: true로 정의된 클라이언트 범용 유형을 가지고 있습니다 (이는 Apollo가 후속 쿼리의 경우 기존 및 들어오는 응답을 병합할 것입니다). SomeEntity를 여기에 추가하거나 사용자 정의 병합 함수를 정의하세요.

GraphQL 쿼리

실행 시에 쿼리 컴파일을 저장하기 위해 webpack는 .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 문서에서 확인할 수 있습니다.

글로벌 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 // 프로젝트에는 GraphQL 스키마에 ID가 있으므로 가져와야 합니다
    releases(...) {
      nodes {
        // 릴리스에는 GraphQL 스키마에 ID 속성이 없습니다
        name
        tagName
        tagPath
        assets {
          count
          links {
            nodes {
              id // 링크에는 GraphQL 스키마에 ID가 있으므로 가져와야 합니다
              name
            }
          }
        }
      }
      pageInfo {
        // 페이지 정보에는 GraphQL 스키마에 ID 속성이 없습니다
        startCursor
        hasPreviousPage
        hasNextPage
        endCursor
      }
    }
  }
}

비동기 변수로 쿼리 건너뛰기

쿼리에 한 개 이상의 변수가 다른 쿼리가 실행된 후에 실행되어야 한다면, 모든 관련된 쿼리에 skip() 속성을 추가하는 것이 중요합니다.

이렇게 하지 않으면 쿼리가 두 번 실행됩니다. 기본값(데이터 속성이나 undefined에 정의된 값)으로 한 번, 초기 쿼리가 해결되면 새로운 변수 값이 스마트 쿼리에 주입되어 재요청되어 Apollo에 의해 다시 가져옵니다.

data() {
  return {
    // Apollo 쿼리에 대한 데이터 속성 정의
    project: null,
    issues: null
  }
},
apollo: {
  project: {
    query: getProject,
    variables() {
      return {
        projectId: this.projectId
      }
    }
  },
  releaseName: {
    query: getReleaseName,
    // 이 skip이 없으면 쿼리는 처음에 `projectName: null`로 실행됩니다.
    // 그런 다음 `getProject`가 해결되면 다시 실행됩니다.
    skip() {
      return !this.project?.name
    },
    variables() {
      return {
        projectName: this.project?.name
      }
    }
  }
}

불변성 및 캐시 업데이트

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 플러그인과 기본 클라이언트를 가져와서 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에 대해 자세히 알아보세요.

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 대신에 @client 필드가 있는 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에 추가되지 않은 필드에 대한 API 응답을 모킹할 때 매우 유용합니다.

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

로컬 Apollo Cache를 사용하면 실제 API에 아직 추가되지 않은 경우와 같이 GraphQL API 응답, 쿼리 또는 뮤테이션을 모의해야 할 때 유용합니다.

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

fragment VersionListItem on DesignVersion {
  id
  sha
}

또한, 버전 작성자와 생성 일자 속성을 검색해야 하지만, 이러한 변경 사항들은 아직 API에 구현되지 않았습니다. 기존 프래그먼트를 변경하여 이러한 새로운 필드들에 대한 모의 응답을 얻을 수 있습니다.

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

이제 Apollo는 @client 지시어로 표시된 각 필드에 대한 리졸버 를 찾으려고 시도합니다. DesignVersion 유형에 대한 리졸버를 작성해 봅시다 (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 Client에 리졸버 객체를 전달해야 합니다.

// graphql.js

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

const defaultClient = createDefaultClient(resolvers);

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

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

Pinia와 함께 사용

싱글 Vue 어플리케이션에서 Pinia와 Apollo를 결합하는 것은 일반적으로 권장되지 않습니다. Apollo와 Pinia를 결합하는 제한 및 환경에 대해 알아보세요.

Vuex와 함께 사용

Vuex와 Apollo Client를 결합하는 것은 권장하지 않습니다. Vuex는 GitLab에서 더 이상 사용되지 않습니다. 만약 Apollo와 함께 사용되는 기존 Vuex 스토어가 있는 경우, Vuex에서 완전히 이동하는 것을 적극 권장합니다. GitLab에서 상태 관리에 대해 더 알아보세요.

프론트엔드와 백엔드의 동기화가 맞지 않을 때 GraphQL 기반 기능 작업

GraphQL 쿼리/뮤테이션을 만들거나 업데이트해야 하는 기능은 신중히 계획되어야 합니다. 프론트엔드와 백엔드 담당자는 서로에게 만족스러운 스키마를 합의해야 합니다. 이를 통해 양쪽 부서가 서로를 차단하지 않고 각자의 부분을 구현을 시작할 수 있습니다.

이상적으로는 백엔드 구현이 프론트엔드보다 미리 완료되어야 하므로 클라이언트가 최소한의 작업으로 즉시 API를 쿼리할 수 있습니다. 그러나 우리가 우선순위가 항상 일치하지는 않을 것이라는 것을 인지합니다. 반복 작업과 작업 전달을 위해, 프론트엔드가 백엔드보다 먼저 구현되어야 하는 경우가 필요할 수 있습니다.

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

이러한 경우에는, 백엔드 리졸버와 일치하지 않는 GraphQL 스키마 또는 필드를 프론트엔드가 정의합니다. 이러한 구현이 공개적인 오류로 전환하지 않도록 적절한 기능 플래그가 설정되어 있는지 확인해야 합니다. 그러나 우리는 graphql-verify CI 작업을 통해 클라이언트 측 쿼리/뮤테이션을 백엔드 GraphQL 스키마에 대해 검증합니다. 변경 사항이 병합되기 전에 검증을 통과해야 합니다.

@client 지시어 사용

선호되는 접근 방법은 백엔드에서 아직 지원되지 않는 새로운 쿼리, 뮤테이션 또는 필드에 @client 지시어를 사용하는 것입니다. 이 지시어가 있는 엔티티는 graphql-verify 검증 작업에서 제외됩니다.

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

지시어의 제거를 추적하기 위해 후속 이슈 또는 백엔드 구현 계획의 일부로 제거할 필요가 있습니다.

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

GraphQL 쿼리/뮤테이션 검증은 .eslintignore 파일을 통해 특정 파일에 대한 ESLint를 비활성화하는 것과 유사하게 파일에 대한 경로를 config/known_invalid_graphql_queries.yml 파일에 추가하여 특정 파일에 대한 검증을 완전히 비활성화할 수 있습니다. 여기에 나열된 파일은 아예 유효성을 검사하지 않습니다. 그러므로 쿼리에만 있는 필드를 추가하는 경우에는 @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 엔티티가 스키마의 일부가 아직 아니거나, 스키마 수준에서 기능 플래그가 있는 경우에만 여러 버전을 사용할 수 있습니다.

수동으로 쿼리 트리거하기

컴포넌트의 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 cursor pagination을 사용합니다. 이는 “커서”를 사용하여 데이터 세트에서 다음 항목을 가져와야 할 위치를 추적합니다. GraphQL Ruby Connection Concepts는 연결에 대한 설명과 소개를 제공합니다.

모든 연결 유형(예: DesignConnectionDiscussionConnection)은 페이지네이션에 필요한 정보를 포함하는 pageInfo 필드를 갖습니다.

pageInfo {
  endCursor
  hasNextPage
  hasPreviousPage
  startCursor
}

여기서:

  • startCursor는 처음 항목의 커서를 보여주고, endCursor는 마지막 항목의 커서를 보여줍니다.
  • hasPreviousPagehasNextPage를 사용하여 현재 페이지 앞 또는 뒤에 더 많은 페이지가 있는지 확인할 수 있습니다.

연결 유형으로 데이터를 가져올 때 커서를 after 또는 before 매개변수로 전달하여 페이지네이션의 시작 또는 끝점을 나타내고, 각각의 경우에 얼마나 많은 항목을 가져올지를 나타내도록 함께 first 또는 last 매개변수를 사용합니다.

예를 들어, 여기서는 커서(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
          }
        }
      }
    }
  }
}

Note: 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;
                  // 노드 배열만 병합하면 됩니다.
                  // 나머지 필드들(pagination)은 항상 들어오는 것으로 덮어쓰여져야 합니다
                  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(),
            },
          },
        },
      },
    },
  ),
});

이것은 새 페이지 결과가 이전 것에 추가되는 것과 비슷합니다.

어떤 경우에는 필드에 대한 올바른 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 쿼리가 우리를 위해 작업을 할 수 있습니다. 사용자 상호 작용에 따라 다음 페이지를 가져와야 하는 경우 smartQueryfetchMore-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 {
            // ... 나머지 design 변수들
            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;
        };

        // 새로운 결과마다 요청 카운트 증가
        this.requestCount += 1;
        // 더 많은 요청이 있고 가져올 다음 페이지가 있는 경우에만 다음 페이지를 가져옵니다
        if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
          this.fetchDesigns(pageInfo.endCursor);
        } else {
          this.isLoading = false;
        }
      })
      .catch(this.handleError);
  },
},

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

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 캐시 키로 데이터를 저장합니다.

캐시에서 데이터를 검색할 때는 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 context에 추가 할 새 헤더를 만드는 데 사용됩니다.

/* 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 리소스에 변경 사항이 없는 경우 모든 폴링된 요청은 다음과 같아야합니다:

  • 200이 아닌 304의 HTTP 상태를 가진 GET 요청입니다.
  • 모든 폴링된 요청은 200이 아닌 304의 HTTP 상태를 갖는데, 이는 실제로 캐시됨을 나타냅니다.

테스트 할 때 개발자 도구에서 캐싱이 비활성화되지 않도록하십시오.

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

구독

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

참고: 현재 GraphiQL은 ActionCable 클라이언트가 필요하므로 구독을 테스트 할 수 없습니다.

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

Best Practices

뮤테이션에서 update 후크를 사용할 때와 사용하지 말아야 할 때

Apollo 클라이언트의 .mutate() 메서드는 뮤테이션 수명주기 동안 두 번 호출되는 update 후크를 노출합니다.

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

이 후크는 일반적으로 상태점(ApolloCache)에 항목을 추가하거나 제거하는 경우에만 사용해야합니다. 기존 항목을 _업데이트_하는 경우, 전역 id로 나타내므로 놓치지 않아야합니다.

아래는 id가 포함된 전형적인 뮤테이션 쿼리의 예입니다:

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

테스트

GraphQL 스키마 생성

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

bundle exec rake gitlab:graphql:schema:dump

이 작업은 upstream에서 풀하거나 브랜치를 다시베이스할 때 실행해야 합니다. gdk update의 일부로 자동으로 실행됩니다.

참고: 만약 RubyMine IDE를 사용하고 tmp 디렉토리를 “배제함”으로 표시했다면, gitlab/tmp/tests/graphql에 대해 “디렉터리 표시로 -> 배제되지 않음”으로 표시해야 합니다. 이렇게 함으로써 JS GraphQL 플러그인이 스키마를 자동으로 찾아 색인화할 수 있게 됩니다.

Apollo Client 모킹

Apollo 작업을 사용하여 구성요소를 테스트하려면 유닛 테스트에서 Apollo Client를 모킹해야 합니다. 우리는 Apollo 클라이언트를 모킹하고 이를 기반으로 만든 createMockApollo 도우미mock-apollo-client 라이브러리를 사용합니다.

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: [],
          },
        },
      },
    },
  },
};

쿼리를 테스트할 때, 그들이 프로미스이기 때문에 _해결_해야 합니다. 해결하지 않으면 쿼리의 로딩 상태를 확인할 수 있습니다:

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 쿼리 및 뮤테이션을 모킹하십시오
    $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 쿼리 테스트

모의 해결사 사용

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

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

이를 해결하기 위해 모의 해결사가 아닌 모의 핸들러 대신 모의 해결사를 정의해야 합니다. 예를 들어 아래의 @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: '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 메서드를 호출한 후 프로미스 거부를 처리하거나, ApolloMutation 컴포넌트에서 발생하는 error 이벤트를 처리할 수 있습니다.

이 오류는 사용자를 위해 설계되지 않았기 때문에 탑 레벨 오류의 오류 메시지는 클라이언트 측에서 정의되어야 합니다.

오류 데이터

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

오류 데이터 처리

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

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

이제, 해당 변이를 커밋하고 오류가 발생하면 응답에 우리가 처리할 errors가 포함됩니다.

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

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

Vue 바깥에서 사용

Vue 바깥에서도 쿼리를 직접 가져와 기본 클라이언트를 사용하는 것이 가능합니다.

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입니다.

문제 해결

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

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

또한, GraphQL 쿼리 픽스처는 생성 시 자동으로 __typename을 추가합니다.

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

때로는 콘솔에 쿼리 객체의 someProperty 필드를 교체할 때 캐시 데이터가 손실될 수 있습니다. 이 문제를 해결하려면, SomeEntity의 모든 객체에 id 또는 사용자 정의 병합 함수가 있는지 확인하세요 라는 경고가 표시될 수 있습니다. 문제를 해결하려면, 다중 쿼리에 대한 섹션을 확인하세요.

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