InfoGrab DocsInfoGrab Docs

GraphQL

요약

GitLab Unfiltered GraphQL 재생목록 GitLab에서의 GraphQL: 심층 분석 (영상) by Nick Thomas GitLab에서 GraphQL의 역사에 대한 개요 (프론트엔드 한정이 아님) GraphQL을 이용해 GitLab에서 프론트엔드 기능을 구현하는 실제 예시

시작하기#

유용한 리소스#

일반 리소스:

GitLab에서의 GraphQL:

GitLab에서 GraphQL의 역사에 대한 개요 (프론트엔드 한정이 아님)

GraphQL을 이용해 GitLab에서 프론트엔드 기능을 구현하는 실제 예시

Apollo가 Vuex보다 나은 선택이 될 수 있는 경우와 전환 방법에 대한 개요

Vue+GraphQL+(Vuex 또는 Apollo) 앱에서 상태 관리의 가능한 접근 방식을 보여주는 예시 모음

라이브러리#

프론트엔드 개발에 GraphQL을 사용할 때는 Apollo (구체적으로 Apollo Client)와 Vue Apollo를 사용합니다.

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

다른 사용 사례의 경우 Vue 외부에서의 사용법 섹션을 참조하세요.

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

도구#

Apollo GraphQL VS Code 확장#

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

스키마 생성: bundle exec rake gitlab:graphql:schema:dump

로컬 gitlab 디렉터리의 루트에 apollo.config.js 파일을 추가합니다.

파일에 다음 내용을 입력합니다:

module.exports = {
  client: {
    includes: ['./app/assets/javascripts/**/*.graphql', './ee/app/assets/javascripts/**/*.graphql'],
    service: {
      name: 'GitLab',
      localSchemaFile: './tmp/tests/graphql/gitlab_schema.graphql',
    },
  },
};

VS Code를 재시작합니다.

GraphQL API 탐색#

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

사용 가능한 모든 쿼리와 뮤테이션을 보려면 GraphiQL 탐색기 왼쪽에서 Show Documentation Explorer를 선택하세요. 작성한 쿼리와 뮤테이션을 실행하려면 오른쪽 위 모서리에서 Execute query라고 표시된 재생 버튼을 선택하세요.

[

](/19.1/development/fe_guide/img/graphiql_explorer_v18_8.png)

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가 있는 모든 GraphQL 타입에 대해 id 존재 여부를 이미 확인하고 있으므로, 이런 경우는 없어야 합니다 (단위 테스트 실행 시 이 경고가 보이는 경우 제외; 이 경우 모킹된 응답에 id가 요청될 때마다 포함되어 있는지 확인하세요).

SomeEntity 타입에 GraphQL 스키마에 id 속성이 없는 경우, 이 경고를 해결하려면 커스텀 머지 함수를 정의해야 합니다.

기본 클라이언트에는 typePoliciesmerge: true가 정의된 일부 클라이언트 전역 타입이 있습니다 (이는 Apollo가 후속 쿼리의 경우 기존 응답과 수신 응답을 병합함을 의미합니다). 거기에 SomeEntity를 추가하거나 커스텀 머지 함수를 정의하는 것을 고려해 보세요.

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 쿼리, 뮤테이션, 구독 파일에는 해당 기능 카테고리를 명시하는 주석이 반드시 포함되어야 합니다. 이 요구 사항은 local-rules/graphql-require-feature-category ESLint 규칙으로 적용됩니다.

.graphql 파일 상단에 다음 형식으로 주석을 추가하세요:

# @feature_category: <category>

카테고리는 config/feature_categories.yml에 정의된 유효한 카테고리 중 하나여야 합니다.

긴급도 태그 (선택 사항)#

GraphQL 쿼리, 뮤테이션, 구독 파일은 선택적으로 작업의 성능 기대치를 나타내는 @urgency 주석을 포함할 수 있습니다. 이는 local-rules/graphql-require-valid-urgency ESLint 규칙으로 검증됩니다.

긴급도 주석이 있는 경우 다음의 유효한 값 중 하나를 사용해야 합니다:

  • high - 중요하고 시간에 민감한 작업

  • medium - 중간 정도로 중요한 작업

  • default - 표준 작업

  • low - 중요하지 않은 백그라운드 작업

긴급도 태그는 선택 사항입니다. 생략해도 린터 오류가 발생하지 않습니다. 그러나 포함하는 경우 위의 유효한 옵션 중 하나여야 합니다.

형식#

# @urgency: <value>

프래그먼트#

프래그먼트는 복잡한 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 필드를 PostgreSQL 기본 키 id 대신 글로벌 ID로 표현합니다. 글로벌 ID는 클라이언트 사이드 라이브러리에서 캐싱과 페칭에 사용되는 컨벤션입니다.

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

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

const primaryKeyId = getIdFromGraphQLId(data.id);

스키마에 id가 있는 모든 GraphQL 타입에 대해 글로벌 id를 쿼리하는 것이 필수입니다:

query allReleases(...) {
  project(...) {
    id // Project has an ID in GraphQL schema so should fetch it
    releases(...) {
      nodes {
        // Release has no ID property in GraphQL schema
        name
        tagName
        tagPath
        assets {
          count
          links {
            nodes {
              id // Link has an ID in GraphQL schema so should fetch it
              name
            }
          }
        }
      }
      pageInfo {
        // PageInfo no ID property in GraphQL schema
        startCursor
        hasPreviousPage
        hasNextPage
        endCursor
      }
    }
  }
}

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

쿼리에 다른 쿼리가 먼저 실행되어야 사용할 수 있는 변수가 하나 이상 있을 때마다, 모든 관계와 함께 쿼리에 skip() 속성을 추가하는 것이 필수입니다.

그렇지 않으면 쿼리가 두 번 실행됩니다: 한 번은 기본값 (data 속성에 정의된 값 또는 undefined)으로, 초기 쿼리가 해결되면 스마트 쿼리에 새 변수 값이 주입되어 Apollo가 다시 페치하면서 한 번 더 실행됩니다.

data() {
  return {
    // Define data properties for all apollo queries
    project: null,
    issues: null
  }
},
apollo: {
  project: {
    query: getProject,
    variables() {
      return {
        projectId: this.projectId
      }
    }
  },
  releaseName: {
    query: getReleaseName,
    // Without this skip, the query would run initially with `projectName: null`
    // Then when `getProject` resolves, it will run again.
    skip() {
      return !this.project?.name
    },
    variables() {
      return {
        projectName: this.project?.name
      }
    }
  }
}

GraphQL에서 쿼리 분할#

Apollo에서 쿼리를 분할하는 것은 대규모 단일 쿼리를 더 작고 관리하기 쉬운 조각으로 나눔으로써 데이터 페칭을 최적화하기 위해 자주 사용됩니다.

GraphQL에서 쿼리를 분할하는 이유#

  • 쿼리 복잡도 증가 GraphQL 쿼리에는 준수해야 하는 제한이 있습니다.

  • 성능 더 작고 타깃화된 쿼리는 서버의 응답 시간이 더 빠른 경우가 많아, 데이터를 클라이언트에 더 빨리 제공하여 프론트엔드에 직접적인 이점을 줍니다.

  • 컴포넌트 분리 및 유지보수성 향상 각 컴포넌트가 자체 데이터 요구를 처리할 수 있으므로, 크고 공유된 쿼리에 대한 액세스 없이도 앱 전체에서 컴포넌트를 더 쉽게 재사용할 수 있습니다.

쿼리 분할 방법#

  • 여러 쿼리를 정의하고 컴포넌트 계층의 다양한 부분에서 독립적으로 사용하세요. 이렇게 하면 각 컴포넌트가 필요한 데이터만 페치합니다.

work item 쿼리 아키텍처를 보면, 쿼리 복잡도와 관심사 분리를 위해 대부분의 위젯에 대해 쿼리를 분할한 것을 확인할 수 있습니다.

#import "ee_else_ce/work_items/graphql/work_item_development.fragment.graphql"

query workItemDevelopment($id: WorkItemID!) {
  workItem(id: $id) {
    id
    iid
    namespace {
      id
    }
    widgets {
      ... on WorkItemWidgetDevelopment {
        ...WorkItemDevelopmentFragment
      }
    }
  }
}
#import "~/graphql_shared/fragments/user.fragment.graphql"

query workItemParticipants($fullPath: ID!, $iid: String!) {
  namespace(fullPath: $fullPath) {
    id
    workItem(iid: $iid) {
      id
      widgets {
        ... on WorkItemWidgetParticipants {
          type
          participants {
            nodes {
              ...User
            }
          }
        }
      }
    }
  }
}
  • @include@skip 지시어를 사용한 조건부 쿼리

Apollo는 이 지시어를 사용한 조건부 쿼리를 지원하여 컴포넌트 상태나 다른 조건에 따라 쿼리를 분할할 수 있습니다.

query projectWorkItems(
  $searchTerm: String
  $fullPath: ID!
  $types: [IssueType!]
  $in: [IssuableSearchableField!]
  $iid: String = null
  $searchByIid: Boolean = false
  $searchByText: Boolean = true
) {
  namespace: project(fullPath: $fullPath) {
    id
    workItems(search: $searchTerm, types: $types, in: $in) @include(if: $searchByText) {
      nodes {
        ...
      }
    }
    workItemsByIid: workItems(iid: $iid, types: $types) @include(if: $searchByIid) {
      nodes {
        ...
      }
    }
  }
}
#import "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"

query workspaceAutocompleteUsersSearch(
  $search: String!
  $fullPath: ID!
  $isProject: Boolean = true
) {
  groupWorkspace: group(fullPath: $fullPath) @skip(if: $isProject) {
    id
    users: autocompleteUsers(search: $search) {
      ...
    }
  }
  namespace: project(fullPath: $fullPath) {
    id
    users: autocompleteUsers(search: $search) {
      ...
    }
  }
}

주의 쿼리를 분할할 때 기존 GraphQL 쿼리가 무효화되지 않도록 주의해야 합니다. 쿼리를 분할할 때 동일한 쿼리가 여러 번 호출되지 않는지 inspector를 통해 확인해야 합니다.

불변성과 캐시 업데이트#

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 대신, 캐시에서 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 응답 모킹#

로컬 Apollo 캐시 사용은 아직 실제 API에 추가되지 않은 등의 이유로 일부 GraphQL API 응답, 쿼리 또는 뮤테이션을 로컬에서 모킹해야 할 때 유용합니다.

예를 들어, 쿼리에서 사용되는 DesignVersion에 대한 프래그먼트가 있다고 가정합니다:

fragment VersionListItem on DesignVersion {
  id
  sha
}

버전 드롭다운 목록에 표시하기 위해 버전 작성자와 created at 속성도 가져와야 합니다. 그런데 이 변경 사항이 아직 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 지시어를 제거하는 것입니다.

Vue Apollo 문서에서 Apollo를 이용한 로컬 상태 관리에 대해 더 읽어보세요.

Pinia와 함께 사용#

단일 Vue 애플리케이션에서 Pinia와 Apollo를 결합하는 것은 일반적으로 권장되지 않습니다. Apollo와 Pinia를 결합하는 제약 사항과 상황에 대해 알아보세요.

Vuex와 함께 사용#

Vuex와 Apollo Client를 결합하는 것은 권장하지 않습니다. Vuex는 GitLab에서 deprecated입니다. Apollo와 함께 사용되는 기존 Vuex 스토어가 있다면 Vuex에서 완전히 마이그레이션하는 것을 강력히 권장합니다. GitLab에서 상태 관리에 대해 더 알아보세요.

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

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

이상적으로는 백엔드 구현이 프론트엔드보다 먼저 완료되어야 클라이언트가 부서 간 번잡한 소통 없이 즉시 API 쿼리를 시작할 수 있습니다. 그러나 우선순위가 항상 일치하지는 않는다는 것을 알고 있습니다. 반복과 커밋한 작업 결과물 제공을 위해, 백엔드보다 프론트엔드를 먼저 구현해야 할 수도 있습니다.

백엔드보다 먼저 프론트엔드 쿼리와 뮤테이션 구현#

이 경우 프론트엔드는 아직 백엔드 리졸버에 해당하지 않는 GraphQL 스키마나 필드를 정의합니다. 구현이 적절한 기능 플래그로 제어되어 제품에서 사용자 대면 오류로 이어지지 않는 한 이는 괜찮습니다. 그러나 graphql-verify CI job으로 백엔드 GraphQL 스키마에 대해 클라이언트 사이드 쿼리/뮤테이션을 검증합니다.

백엔드가 실제로 지원하기 전에 변경 사항을 병합하려면 검증을 통과하는지 확인해야 합니다. 아래에 이를 처리하기 위한 몇 가지 제안이 있습니다.

@client 지시어 사용#

선호하는 방법은 아직 백엔드에서 지원되지 않는 새 쿼리, 뮤테이션 또는 필드에 @client 지시어를 사용하는 것입니다. 지시어가 있는 엔티티는 graphql-verify 검증 job에서 건너뜁니다.

또한 Apollo는 클라이언트 사이드에서 이를 해결하려 시도하는데, 이는 로컬 Apollo 캐시로 API 응답 모킹과 함께 사용할 수 있습니다. 이는 클라이언트 사이드에서 정의된 가짜 데이터로 기능을 테스트하는 편리한 방법을 제공합니다. 변경 사항에 대해 머지 리퀘스트를 열 때 리뷰어가 GDK에 적용하여 쉽게 스모크 테스트를 할 수 있도록 로컬 리졸버를 패치로 제공하는 것이 좋습니다.

지시어 제거를 후속 이슈에서 추적하거나 백엔드 구현 계획의 일부로 포함시키세요.

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

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 엔티티가 아직 스키마에 없거나, 스키마 레벨에서 기능 플래그가 적용된 경우 (new_entity: :feature_flag)에 사용할 수 있습니다.

수동으로 쿼리 트리거#

컴포넌트의 apollo 속성에 있는 쿼리는 컴포넌트가 생성될 때 자동으로 실행됩니다. 일부 컴포넌트는 예를 들어 지연 로드되는 항목이 있는 드롭다운 목록처럼 필요에 따라 네트워크 요청을 만들기를 원합니다.

두 가지 방법이 있습니다:

  • skip 속성 사용
export default {
  apollo: {
    user: {
      query: QUERY_IMPORT,
      skip() {
        // only make the query when dropdown is open
        return !this.isOpen;
      },
    }
  },
};
  • addSmartQuery 사용

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

handleClick() {
  this.$apollo.addSmartQuery('user', {
    // this takes the same values as you'd have in the `apollo` section
    query: QUERY_IMPORT,
  }),
};

페이지네이션 작업#

GitLab GraphQL API는 connection 타입에 Relay 스타일 커서 페이지네이션을 사용합니다. 이는 "커서"가 데이터 세트에서 다음 항목을 가져와야 하는 위치를 추적하는 데 사용됨을 의미합니다. GraphQL Ruby Connection 개념은 connection에 대한 좋은 개요와 소개입니다.

모든 connection 타입 (예: DesignConnectionDiscussionConnection)에는 페이지네이션에 필요한 정보가 담긴 pageInfo 필드가 있습니다:

pageInfo {
  endCursor
  hasNextPage
  hasPreviousPage
  startCursor
}

여기서:

  • startCursor는 첫 번째 항목의 커서를 표시하고 endCursor는 마지막 항목의 커서를 표시합니다.

  • hasPreviousPagehasNextPage는 현재 페이지 이전 또는 이후에 더 많은 페이지가 있는지 확인하는 데 사용됩니다.

connection 타입으로 데이터를 가져올 때 커서를 after 또는 before 매개변수로 전달하여 페이지네이션의 시작점 또는 끝점을 나타낼 수 있습니다. 이는 주어진 엔드포인트 이후 또는 이전에 얼마나 많은 항목을 가져올지를 나타내는 first 또는 last 매개변수와 함께 사용해야 합니다.

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

#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
          }
        }
      }
    }
  }
}

pageInfo 정보를 채우기 위해 page_info.fragment.graphql을 사용하고 있다는 점에 유의하세요.

컴포넌트에서 fetchMore 메서드 사용#

이 방법은 사용자가 처리하는 페이지네이션과 함께 사용하는 것이 적합합니다. 예를 들어, 더 많은 데이터를 가져오기 위해 스크롤하거나 명시적으로 다음 페이지 버튼을 클릭할 때입니다. 모든 데이터를 초기에 가져와야 하는 경우에는 대신 (스마트가 아닌) 쿼리를 사용하는 것이 권장됩니다.

초기 페치를 할 때 일반적으로 처음부터 페이지네이션을 시작하고 싶습니다. 이 경우 다음 중 하나를 할 수 있습니다:

  • 커서 전달 건너뛰기.

  • afternull을 명시적으로 전달.

데이터를 가져온 후 update 훅을 기회로 삼아 Vue 컴포넌트 속성에 설정되는 데이터를 커스터마이징할 수 있습니다. 이를 통해 다른 데이터 중에서 pageInfo 객체를 얻을 수 있습니다.

result 훅에서 pageInfo 객체를 검사하여 다음 페이지를 가져와야 하는지 확인할 수 있습니다. 애플리케이션이 다음 페이지를 무한정 요청하지 않도록 requestCount도 유지하고 있음을 유의하세요:

data() {
  return {
    pageInfo: null,
    requestCount: 0,
  }
},
apollo: {
  designs: {
    query: projectQuery,
    variables() {
      return {
        // ... The rest of the design variables
        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;

      // Increment the request count with each new result
      this.requestCount += 1;
      // Only fetch next page if we have more requests and there is a next page to fetch
      if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
        this.fetchNextPage(pageInfo.endCursor);
      }
    },
  },
},

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

fetchNextPage(endCursor) {
  this.$apollo.queries.designs.fetchMore({
    variables: {
      // ... The rest of the design 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;

                  // We want to save only incoming nodes and replace existing ones
                  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;
                  // We only need to merge the nodes array.
                  // The rest of the fields (pagination) should always be overwritten by 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'], // You might need to set the keyArgs option to enforce the cache's integrity
              },
            },
          },
          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 쿼리가 도움이 될 수 있습니다. 사용자 상호작용을 기반으로 다음 페이지를 가져와야 하는 경우에는 fetchMore과 함께 smartQuery를 사용하는 것이 권장됩니다.

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

애플리케이션이 다음 페이지를 무한정 요청하지 않도록 requestCount도 유지하고 있음을 유의하세요.

data() {
  return {
    requestCount: 0,
    isLoading: false,
    designs: {
      edges: [],
      pageInfo: null,
    },
  }
},
created() {
  this.fetchDesigns();
},
methods: {
  handleError(error) {
    this.isLoading = false;
    // Do something with `error`
  },
  fetchDesigns(endCursor) {
    this.isLoading = true;

    return this.$apollo
      .query({
        query: projectQuery,
        variables() {
          return {
            // ... The rest of the design variables
            first: 10,
            endCursor,
          };
        },
      })
      .then(({ data }) => {
        const { id = null, issue = {} } = data.project || {};
        const { edges = [], pageInfo } = issue.designCollection?.designs || {};

        // Update data
        this.designs = {
          id,
          edges: [...this.designs.edges, ...edges];
          pageInfo: pageInfo;
        };

        // Increment the request count with each new result
        this.requestCount += 1;
        // Only fetch next page if we have more requests and there is a next page to fetch
        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 컨텍스트에 추가할 새 헤더를 만드는 데 사용됩니다:

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

/* eslint-disable @gitlab/require-i18n-strings */
const getQueryHeaders = (etagResource) => {
  return {
    fetchOptions: {
      method: 'GET',
    },
    headers: {
      /* This will depend on your feature */
      '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_editor_header.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: getPipelineIidQuery,
    pollInterval: PIPELINE_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 { setupQueryPollingByVisibility } from '~/pipelines/components/graph/utils';

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

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

구독이 성숙해지면 이 프로세스를 구독으로 대체하고 별도의 링크 라이브러리를 제거하여 쿼리 배치 처리로 돌아갈 수 있습니다.

ETag 캐싱 테스트 방법#

네트워크 탭에서 요청을 확인하여 구현이 작동하는지 테스트할 수 있습니다. ETag 리소스에 변경 사항이 없으면 폴링된 모든 요청은:

  • POST 요청 대신 GET 요청이어야 합니다.

  • 200 대신 304 HTTP 상태를 가져야 합니다.

테스트 시 개발자 도구에서 캐싱이 비활성화되어 있지 않은지 확인하세요.

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

구독#

GraphQL API에서 웹소켓을 통해 실시간 업데이트를 받기 위해 구독을 사용합니다. 현재 기존 구독의 수는 제한적이며, GraphiQL 탐색기에서 사용 가능한 목록을 확인할 수 있습니다.

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

구독 사용 시기#

구독은 아껴서 사용하세요. 대부분의 경우 폴링으로 충분합니다. 다음과 같은 사용 사례 (이에만 한정되지 않음)에 구독을 사용해야 합니다:

  • 저지연, 실시간 업데이트: 10-15초(표준 폴링)의 지연도 사용자 경험에 부정적인 영향을 미칠 때

  • 고빈도 상태 변경: 짧은 시간 내에 객체가 여러 번 상태를 변경할 때. 구독은 모든 상태 변경을 "푸시"하는 반면, 폴링은 간격 사이의 중간 단계를 놓칠 수 있습니다.

  • 대형 객체의 효율성: 대형 객체를 반복적으로 폴링하는 것은 비용이 많이 듭니다. 특히 객체의 대부분 필드가 거의 변경되지 않는 경우 (예: 50개 이상의 속성을 가진 파이프라인). 구독을 통해 서버는 실제로 변경되는 특정 필드(예: 상태 또는 finished_at)만 푸시할 수 있어 클라이언트와 서버 모두의 데이터 전송 및 처리 부하를 크게 줄일 수 있습니다.

구독 모범 사례#

구독을 구현할 때 성능 문제와 메모리 누수를 방지하기 위해 다음 패턴을 따르세요.

result 훅 사용 시기#

대부분의 경우 Apollo 쿼리 정의의 subscribeToMore 훅이 구독에 사용하고자 하는 패턴입니다. 그러나 result 훅과 수동 subscribeToMore 호출을 사용해야 하는 경우가 있습니다:

  • 쿼리 데이터에서 파생된 구독 타깃: 구독 변수(예: 파이프라인 ID)가 상위 쿼리 결과에서 나오는 경우, subscribeToMore는 쿼리 데이터를 사용하기 전에 실행되어 undefined 변수 오류를 유발합니다. result 훅은 구독하기 전에 유효한 데이터를 기다릴 수 있게 합니다.

  • 복잡한 건너뛰기 로직: 구독 여부를 결정하는 것이 skip 함수로 쉽게 표현하기 어려운 쿼리 결과의 여러 필드에 의존할 때.

  • 데이터 변경 시 구독 정리: 쿼리 결과의 특정 조건에 따라 명시적으로 구독을 취소하고 재구독해야 할 때.

result() {
  const currentPipelineId = this.commit?.pipeline?.id;

  // If pipeline ID changed, reset subscription state
  if (this.subscribedPipelineId && this.subscribedPipelineId !== currentPipelineId) {
    this.pipelineSubscription?.unsubscribe();
    this.isSubscribed = false;
  }

  // Subscribe only once we have a valid pipeline ID
  if (currentPipelineId && !this.isSubscribed) {
    this.isSubscribed = true;
    this.subscribedPipelineId = currentPipelineId;

    this.pipelineSubscription = this.$apollo.queries.commit.subscribeToMore({
      document: pipelineStatusUpdatedSubscription,
      variables: {
        pipelineId: currentPipelineId,
      },
      updateQuery(previousData, { subscriptionData }) {
        if (Visibility.hidden()) return previousData;
        // Update logic...
        return previousData;
      },
    });
  }
},

리페치로 얇은 페이로드 사용#

result 훅 패턴과 네트워크 작업을 사용할 때, 구독 응답에서 id만 반환하고 전체 데이터는 별도로 가져오세요. 이 시그널 및 페치 방법은 WebSocket 오버헤드를 줄이고 기존에 최적화된 쿼리를 활용합니다:

subscription pipelineUpdated($projectId: ID!) {
  pipelineUpdated(projectId: $projectId) {
    id # Only return the ID, fetch full data separately
  }
}

가시성 확인으로 구독 보호#

updateQuery가 네트워크 호출을 트리거할 때, 탭이 숨겨진 경우 이러한 작업을 보호하세요. 이렇게 하면 사용자가 보고 있지 않을 때 불필요한 페치를 건너뜁니다:

updateQuery(prev, { subscriptionData }) {
  // Skip network operations while tab is hidden
  if (Visibility.hidden()) return prev;
},

사용자가 돌아오면 단일 리페치를 트리거하여 동기화하세요:

import Visibility from 'visibilityjs';

export default {
  created() {
    this.visibilityId = Visibility.change(() => {
      if (!Visibility.hidden()) {
        this.$apollo.queries.pipelines.refetch();
      }
    });
  },
  beforeDestroy() {
    Visibility.unbind(this.visibilityId);
  },
};

구독 트리거 페치 배치 처리#

목록 뷰의 updateQuery 내에서 즉시 네트워크 요청을 보내지 마세요. CI/CD와 같이 활동이 많은 환경에서는 많은 항목이 몇 초 내에 업데이트될 수 있습니다. 디바운스된 배처를 사용하여 ID를 수집하고 단일 요청으로 가져오세요.

import { debounce } from 'lodash-es';
import { createAlert } from '~/alert';
import Sentry from '~/sentry/sentry_bundle';

const BATCH_DEBOUNCE = 3000;
const MAX_BATCH_SIZE = 15;

export default {
  data() {
    return {
      pendingIds: new Set(),
    };
  },
  created() {
    this.fetchUpdatedPipelines = debounce(this.processPendingUpdates, BATCH_DEBOUNCE);
  },
  beforeDestroy() {
    this.fetchUpdatedPipelines?.cancel();
    this.pendingIds.clear();
  },
  methods: {
    // Called from subscription updateQuery
    queuePipelineUpdate(pipelineId) {
      this.pendingIds.add(pipelineId);
      this.fetchUpdatedPipelines();
    },
    async processPendingUpdates() {
      if (this.pendingIds.size === 0) return;

      const idsToFetch = Array.from(this.pendingIds).slice(0, MAX_BATCH_SIZE);
      idsToFetch.forEach((id) => this.pendingIds.delete(id));

      try {
        await this.$apollo.query({
          query: getPipelinesQuery,
          fetchPolicy: 'network-only',
          variables: { fullPath: this.fullPath, ids: idsToFetch },
        });

        // Process remaining IDs if any
        if (this.pendingIds.size > 0) {
          this.fetchUpdatedPipelines();
        }
      } catch (error) {
        createAlert({
          message: s__('Pipelines|Something went wrong while updating pipeline information'),
        });
        Sentry.captureException(error);
      }
    },
  },
};

안전망으로 폴링 사용#

WebSocket 연결이 조용히 끊어지는 경우를 처리하기 위해 구독과 함께 가시성 인식 폴링을 사용하세요. ETag 캐싱은 이러한 폴링 요청을 경량화합니다.

import { setupQueryPollingByVisibility, etagQueryHeaders } from '~/graphql_shared/utils';

const POLL_INTERVAL = 60000;

export default {
  apollo: {
    pipelines: {
      query: getPipelinesQuery,
      pollInterval: POLL_INTERVAL,
      context() {
        return etagQueryHeaders('ci/pipelines-page', this.projectPipelinesEtagPath);
      },
    },
  },
  created() {
    setupQueryPollingByVisibility(this.$apollo.queries.pipelines, POLL_INTERVAL);
  },
};

구독 누적 방지#

메모리 누수의 일반적인 원인은 이전 구독을 정리하지 않고 새 구독을 만드는 것입니다. 이는 일반적으로 subscribeToMore가 상태를 추적하지 않고 모든 result()에서 호출될 때 발생합니다:

// BAD: Subscriptions accumulate on every result()
result() {
  this.$apollo.queries.commit.subscribeToMore({ ... });
}

// GOOD: Track subscription state explicitly
result() {
  if (this.isSubscribed) return;
  this.isSubscribed = true;
  this.subscription = this.$apollo.queries.commit.subscribeToMore({ ... });
}

모범 사례#

뮤테이션에서 update 훅 사용 시기 (및 사용하지 않을 시기)#

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

  • 한 번은 시작 시. 즉, 뮤테이션이 완료되기 전.

  • 한 번은 뮤테이션이 완료된 후.

이 훅은 스토어 (즉, ApolloCache)에서 항목을 추가하거나 제거하는 경우에만 사용해야 합니다. 기존 항목을 업데이트하는 경우 일반적으로 글로벌 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` 디렉터리를 "Excluded"로 표시한 경우,

gitlab/tmp/tests/graphql에 대해 "Mark Directory As -> Not Excluded"를 선택해야 합니다. 이를 통해 JS GraphQL 플러그인이 자동으로 스키마를 찾고 인덱싱할 수 있습니다.

Apollo Client 모킹#

Apollo 작업으로 컴포넌트를 테스트하려면 단위 테스트에서 Apollo Client를 모킹해야 합니다. 제어된 쿼리 해결로 커스텀 Apollo Link 구현을 제공하는 mock_apollo_helper를 사용합니다.

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

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

Vue.use(VueApollo);

describe('Some component with Apollo mock', () => {
  let wrapper;

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

그 다음 모킹된 Apollo 프로바이더를 만들어야 합니다:

import createMockApollo from 'helpers/mock_apollo_helper';

describe('Some component with Apollo mock', () => {
  let wrapper;
  let mockApollo;

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

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

  afterEach(() => {
    // we need to ensure we don't have provider persisted between tests
    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('Some component with 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 응답과 동일한지 확인하세요. 예를 들어 루트 속성은 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('renders a loading state', () => {
  const wrapper = createComponent();

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

it('renders designs list', async () => {
  const wrapper = createComponent();

  await waitForPromises()

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

쿼리 오류를 테스트해야 하는 경우 요청 핸들러로 거부된 값을 모킹해야 합니다:

it('renders error if query fails', 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('calls a mutation with correct parameters and reorders designs', 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 Client의 네이티브 재시도 동작과 Jest의 모킹 함수를 결합하여 일련의 응답을 만들 수 있습니다. 이를 수동으로 진행할 필요는 없지만 특정 방식으로 기다려야 합니다.

describe('when query times out', () => {
  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('shows correct errors and does not overwrite populated data when data is empty', async () => {
    /* fails at first, shows error, no data yet */
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(false);

    /* succeeds, clears error, shows graph */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(false);
    expect(getGraph().exists()).toBe(true);

    /* fails again, alert returns but data persists */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(true);
  });
});

이전에는 Apollo 기능을 테스트하기 위해 mount{ mocks: { $apollo ...}}를 사용했습니다. 이 방법은 권장되지 않습니다 - 적절한 $apollo 모킹은 테스트에 많은 구현 세부 사항을 노출합니다. 모킹된 Apollo 프로바이더로 교체하는 것을 고려하세요.

wrapper = mount(SomeComponent, {
  mocks: {
    // avoid! Mock real graphql queries and mutations instead
    $apollo: {
      mutate: jest.fn(),
      queries: {
        groups: {
          loading,
        },
      },
    },
  },
});

구독 테스트#

구독을 테스트할 때, vue-apollo@4에서 구독의 기본 동작은 오류 발생 시 재구독하고 즉시 새 요청을 발행한다는 점을 주의하세요 (단, skip 값이 이를 제한하지 않는 경우).

import waitForPromises from 'helpers/wait_for_promises';

// subscriptionMock is registered as handler function for subscription
// in our helper
const subcriptionMock = jest.fn().mockResolvedValue(okResponse);

// ...

it('testing error state', () => {
  // Avoid: will stuck below!
  subscriptionMock = jest.fn().mockRejectedValue({ errors: [] });

  // component calls subscription mock as part of
  createComponent();
  // will be stuck forever:
  // * rejected promise will trigger resubscription
  // * re-subscription will call subscriptionMock again, resulting in rejected promise
  // * rejected promise will trigger next re-subscription,
  await waitForPromises();
  // ...
})

vue@3vue-apollo@4 사용 시 이런 무한 루프를 방지하려면 일회성 거부를 사용하는 것을 고려하세요.

it('testing failure', () => {
  // OK: subscription will fail once
  subscriptionMock.mockRejectedValueOnce({ errors: [] });
  // component calls subscription mock as part of
  createComponent();
  await waitForPromises();

  // code below now will be executed
})

@client 쿼리 테스트#

모킹 리졸버 사용#

애플리케이션에 @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.

이를 해결하려면 모킹 handlers 대신 모킹 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; // mock function, jest.fn();

const mockResolvers = {
  Query: {
    blobContent() {
      return {
        __typename: 'BlobContent',
        rawData: mockBlobContentData(), // the mock function can resolve mock data
      };
    },
  },
};

const createComponentWithApollo = ({ props = {} } = {}) => {
  mockApollo = createMockApollo([], mockResolvers); // resolvers are the second parameter

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

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

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

it('shows data', async() => {
  mockBlobContentData.mockResolvedValue(data); // you may resolve or reject to mock the result

  createComponentWithApollo();

  await waitForPromises(); // wait on the resolver mock to execute

  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('Some component with Apollo mock', () => {
  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),
  },
);

Mock Apollo 헬퍼 (제어된 해결)#

mock_apollo_helpermock-apollo-client 라이브러리를 커스텀 Apollo Link로 대체합니다. 쿼리와 뮤테이션은 명시적으로 해결될 때까지 보류 상태로 유지되며, 로딩 상태에 대한 정밀한 제어가 가능합니다. 모든 새 테스트에 사용하세요.

임포트 및 반환 형태#

헬퍼는 두 가지 내보내기를 제공합니다:

  • createControlledMockApollo (이름 있는) — 제어 모드: 명시적으로 해결될 때까지 쿼리가 보류됩니다.

  • createMockApollo (기본) — 레거시 모드: 쿼리가 즉시 해결됩니다.

두 방법 모두 (handlers, resolvers, cacheOptions)를 받습니다.

제어 모드 (createControlledMockApollo 이름 있는 내보내기)#

명시적으로 해결할 때까지 쿼리와 뮤테이션이 보류 상태로 유지되어 로딩 상태에 대한 정밀한 어설션이 가능합니다.

import { createControlledMockApollo } from 'helpers/mock_apollo_helper';

const handler = jest.fn().mockResolvedValue(mockResponse);
const { apolloProvider, resolveQuery } = createControlledMockApollo([[projectQuery, handler]]);

wrapper = shallowMount(Component, { apolloProvider });
await waitForPromises();
// Component is still in loading state

await resolveQuery(projectQuery);
// Component now shows data

해결 메서드는 타입 안전합니다: resolveQuery/rejectQuery는 쿼리 문서만 받고, resolveMutation/rejectMutation은 뮤테이션 문서만 받습니다. 잘못된 타입을 전달하면 오류가 발생합니다. 모든 해결 메서드는 프로미스를 반환하므로 (내부적으로 waitForPromises()를 호출) 직접 await할 수 있습니다.

레거시 모드 (기본 내보내기)#

기본 내보내기는 핸들러를 즉시 해결합니다. 이는 이전 mock-apollo-client 기반 구현의 동작과 일치합니다. 기존 테스트에 사용하세요.

import createMockApollo from 'helpers/mock_apollo_helper';

const handler = jest.fn().mockResolvedValue({ data: { project: { id: '1' } } });
const apolloProvider = createMockApollo([[projectQuery, handler]]);

wrapper = shallowMount(Component, { apolloProvider });
await waitForPromises();
expect(handler).toHaveBeenCalledWith({ id: '1' });

보류 중인 모든 작업을 재귀적으로 해결하려면 resolveAll()을 사용하세요. 이는 현재 웨이브를 해결하고, 프로미스를 기다리며, 새 작업이 나타나면 반복합니다. 이는 하나의 쿼리를 해결하면 다른 쿼리가 트리거되는 연쇄 쿼리를 처리합니다. 보류 중인 작업이 없으면 resolveAll()은 오류를 발생시킵니다. 보류 중인 작업 없이 마이크로태스크를 플러시하려면 waitForPromises()를 사용하세요.

실패를 시뮬레이션하려면 rejectQuery(queryDoc, error) 또는 rejectMutation(mutationDoc, error)를 사용하세요.

등록된 핸들러 없이 작업을 사용하면 오류가 발생하여 핸들러 설정 누락을 조기에 발견하는 데 도움이 됩니다.

핸들러 형식#

핸들러는 모킹 함수 또는 일반 데이터 객체가 될 수 있습니다.

// Function handler (recommended when you need to assert on calls)
const handler = jest.fn().mockResolvedValue({ data: { project: { id: '1' } } });

// Plain data handler (simpler, no assertion support)
const handlers = [[projectQuery, { data: { project: { id: '1' } } }]];
레거시 모드에서 제어 모드로 마이그레이션#

기본 임포트를 사용하는 테스트는 레거시 모드(자동 해결)가 됩니다. 제어 모드로 마이그레이션하려면:

이름 있는 임포트를 사용하고 반환 값을 구조분해하세요:

// Legacy mode (default export)
import createMockApollo from 'helpers/mock_apollo_helper';
const apolloProvider = createMockApollo([...]);

// Controlled mode (named export)
import { createControlledMockApollo } from 'helpers/mock_apollo_helper';
const { apolloProvider, resolveQuery } = createControlledMockApollo([...]);

마운트 호출을 구조분해된 프로바이더를 사용하도록 업데이트하세요:

wrapper = shallowMount(SomeComponent, { apolloProvider });

await waitForPromises()를 명시적 해결로 교체하세요:

// Legacy mode
await waitForPromises();

// Controlled mode
await resolveQuery(projectQuery);
// or resolve all pending operations:
await resolveAll();

캐시 옵션은 여전히 세 번째 인수로 작동합니다:

const { apolloProvider } = createControlledMockApollo(handlers, resolvers, {
  dataIdFromObject: (object) => object.iid,
});

오류 처리#

GitLab GraphQL 뮤테이션에는 두 가지 오류 모드가 있습니다: 최상위 오류데이터로서의 오류.

GraphQL 뮤테이션을 사용할 때 오류 발생 시 사용자가 적절한 피드백을 받을 수 있도록 두 오류 모드 모두 처리하는 것을 고려하세요.

최상위 오류#

이러한 오류는 GraphQL 응답의 "최상위 레벨"에 있습니다. 인수 오류와 문법 오류를 포함하는 복구 불가능한 오류이며, 사용자에게 직접 표시되어서는 안 됩니다.

최상위 오류 처리#

Apollo는 최상위 오류를 인식하므로 Apollo의 다양한 오류 처리 메커니즘을 활용할 수 있습니다. 예를 들어 mutate 메서드를 호출한 후 Promise 거부를 처리하거나 ApolloMutation 컴포넌트에서 방출된 error 이벤트를 처리하는 방법이 있습니다.

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

데이터로서의 오류#

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

데이터로서의 오류 처리#

먼저 뮤테이션 객체에 errors를 추가해야 합니다:

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

이제 이 뮤테이션을 커밋하고 오류가 발생하면 응답에 처리할 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));

Vuex 사용 시, 다음 경우에 캐시를 비활성화하세요:

  • 데이터가 다른 곳에서 캐시되고 있는 경우

  • 사용 사례가 캐싱을 필요로 하지 않는 경우 데이터가 다른 곳에서 캐시되고 있거나 해당 사용 사례에 캐시가 필요하지 않은 경우.

import createDefaultClient, { fetchPolicies } from '~/lib/graphql';

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을 추가합니다.

캐시 데이터 손실 경고#

때때로 콘솔에서 경고를 볼 수 있습니다: 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. 이슈를 해결하려면 다중 쿼리 섹션을 확인하세요.

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

GraphQL

GitLab v19.1
원문 보기
요약

GitLab Unfiltered GraphQL 재생목록 GitLab에서의 GraphQL: 심층 분석 (영상) by Nick Thomas GitLab에서 GraphQL의 역사에 대한 개요 (프론트엔드 한정이 아님) GraphQL을 이용해 GitLab에서 프론트엔드 기능을 구현하는 실제 예시

시작하기#

유용한 리소스#

일반 리소스:

GitLab에서의 GraphQL:

GitLab에서 GraphQL의 역사에 대한 개요 (프론트엔드 한정이 아님)

GraphQL을 이용해 GitLab에서 프론트엔드 기능을 구현하는 실제 예시

Apollo가 Vuex보다 나은 선택이 될 수 있는 경우와 전환 방법에 대한 개요

Vue+GraphQL+(Vuex 또는 Apollo) 앱에서 상태 관리의 가능한 접근 방식을 보여주는 예시 모음

라이브러리#

프론트엔드 개발에 GraphQL을 사용할 때는 Apollo (구체적으로 Apollo Client)와 Vue Apollo를 사용합니다.

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

다른 사용 사례의 경우 Vue 외부에서의 사용법 섹션을 참조하세요.

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

도구#

Apollo GraphQL VS Code 확장#

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

스키마 생성: bundle exec rake gitlab:graphql:schema:dump

로컬 gitlab 디렉터리의 루트에 apollo.config.js 파일을 추가합니다.

파일에 다음 내용을 입력합니다:

module.exports = {
  client: {
    includes: ['./app/assets/javascripts/**/*.graphql', './ee/app/assets/javascripts/**/*.graphql'],
    service: {
      name: 'GitLab',
      localSchemaFile: './tmp/tests/graphql/gitlab_schema.graphql',
    },
  },
};

VS Code를 재시작합니다.

GraphQL API 탐색#

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

사용 가능한 모든 쿼리와 뮤테이션을 보려면 GraphiQL 탐색기 왼쪽에서 Show Documentation Explorer를 선택하세요. 작성한 쿼리와 뮤테이션을 실행하려면 오른쪽 위 모서리에서 Execute query라고 표시된 재생 버튼을 선택하세요.

[

](/19.1/development/fe_guide/img/graphiql_explorer_v18_8.png)

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가 있는 모든 GraphQL 타입에 대해 id 존재 여부를 이미 확인하고 있으므로, 이런 경우는 없어야 합니다 (단위 테스트 실행 시 이 경고가 보이는 경우 제외; 이 경우 모킹된 응답에 id가 요청될 때마다 포함되어 있는지 확인하세요).

SomeEntity 타입에 GraphQL 스키마에 id 속성이 없는 경우, 이 경고를 해결하려면 커스텀 머지 함수를 정의해야 합니다.

기본 클라이언트에는 typePoliciesmerge: true가 정의된 일부 클라이언트 전역 타입이 있습니다 (이는 Apollo가 후속 쿼리의 경우 기존 응답과 수신 응답을 병합함을 의미합니다). 거기에 SomeEntity를 추가하거나 커스텀 머지 함수를 정의하는 것을 고려해 보세요.

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 쿼리, 뮤테이션, 구독 파일에는 해당 기능 카테고리를 명시하는 주석이 반드시 포함되어야 합니다. 이 요구 사항은 local-rules/graphql-require-feature-category ESLint 규칙으로 적용됩니다.

.graphql 파일 상단에 다음 형식으로 주석을 추가하세요:

# @feature_category: <category>

카테고리는 config/feature_categories.yml에 정의된 유효한 카테고리 중 하나여야 합니다.

긴급도 태그 (선택 사항)#

GraphQL 쿼리, 뮤테이션, 구독 파일은 선택적으로 작업의 성능 기대치를 나타내는 @urgency 주석을 포함할 수 있습니다. 이는 local-rules/graphql-require-valid-urgency ESLint 규칙으로 검증됩니다.

긴급도 주석이 있는 경우 다음의 유효한 값 중 하나를 사용해야 합니다:

  • high - 중요하고 시간에 민감한 작업

  • medium - 중간 정도로 중요한 작업

  • default - 표준 작업

  • low - 중요하지 않은 백그라운드 작업

긴급도 태그는 선택 사항입니다. 생략해도 린터 오류가 발생하지 않습니다. 그러나 포함하는 경우 위의 유효한 옵션 중 하나여야 합니다.

형식#

# @urgency: <value>

프래그먼트#

프래그먼트는 복잡한 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 필드를 PostgreSQL 기본 키 id 대신 글로벌 ID로 표현합니다. 글로벌 ID는 클라이언트 사이드 라이브러리에서 캐싱과 페칭에 사용되는 컨벤션입니다.

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

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

const primaryKeyId = getIdFromGraphQLId(data.id);

스키마에 id가 있는 모든 GraphQL 타입에 대해 글로벌 id를 쿼리하는 것이 필수입니다:

query allReleases(...) {
  project(...) {
    id // Project has an ID in GraphQL schema so should fetch it
    releases(...) {
      nodes {
        // Release has no ID property in GraphQL schema
        name
        tagName
        tagPath
        assets {
          count
          links {
            nodes {
              id // Link has an ID in GraphQL schema so should fetch it
              name
            }
          }
        }
      }
      pageInfo {
        // PageInfo no ID property in GraphQL schema
        startCursor
        hasPreviousPage
        hasNextPage
        endCursor
      }
    }
  }
}

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

쿼리에 다른 쿼리가 먼저 실행되어야 사용할 수 있는 변수가 하나 이상 있을 때마다, 모든 관계와 함께 쿼리에 skip() 속성을 추가하는 것이 필수입니다.

그렇지 않으면 쿼리가 두 번 실행됩니다: 한 번은 기본값 (data 속성에 정의된 값 또는 undefined)으로, 초기 쿼리가 해결되면 스마트 쿼리에 새 변수 값이 주입되어 Apollo가 다시 페치하면서 한 번 더 실행됩니다.

data() {
  return {
    // Define data properties for all apollo queries
    project: null,
    issues: null
  }
},
apollo: {
  project: {
    query: getProject,
    variables() {
      return {
        projectId: this.projectId
      }
    }
  },
  releaseName: {
    query: getReleaseName,
    // Without this skip, the query would run initially with `projectName: null`
    // Then when `getProject` resolves, it will run again.
    skip() {
      return !this.project?.name
    },
    variables() {
      return {
        projectName: this.project?.name
      }
    }
  }
}

GraphQL에서 쿼리 분할#

Apollo에서 쿼리를 분할하는 것은 대규모 단일 쿼리를 더 작고 관리하기 쉬운 조각으로 나눔으로써 데이터 페칭을 최적화하기 위해 자주 사용됩니다.

GraphQL에서 쿼리를 분할하는 이유#

  • 쿼리 복잡도 증가 GraphQL 쿼리에는 준수해야 하는 제한이 있습니다.

  • 성능 더 작고 타깃화된 쿼리는 서버의 응답 시간이 더 빠른 경우가 많아, 데이터를 클라이언트에 더 빨리 제공하여 프론트엔드에 직접적인 이점을 줍니다.

  • 컴포넌트 분리 및 유지보수성 향상 각 컴포넌트가 자체 데이터 요구를 처리할 수 있으므로, 크고 공유된 쿼리에 대한 액세스 없이도 앱 전체에서 컴포넌트를 더 쉽게 재사용할 수 있습니다.

쿼리 분할 방법#

  • 여러 쿼리를 정의하고 컴포넌트 계층의 다양한 부분에서 독립적으로 사용하세요. 이렇게 하면 각 컴포넌트가 필요한 데이터만 페치합니다.

work item 쿼리 아키텍처를 보면, 쿼리 복잡도와 관심사 분리를 위해 대부분의 위젯에 대해 쿼리를 분할한 것을 확인할 수 있습니다.

#import "ee_else_ce/work_items/graphql/work_item_development.fragment.graphql"

query workItemDevelopment($id: WorkItemID!) {
  workItem(id: $id) {
    id
    iid
    namespace {
      id
    }
    widgets {
      ... on WorkItemWidgetDevelopment {
        ...WorkItemDevelopmentFragment
      }
    }
  }
}
#import "~/graphql_shared/fragments/user.fragment.graphql"

query workItemParticipants($fullPath: ID!, $iid: String!) {
  namespace(fullPath: $fullPath) {
    id
    workItem(iid: $iid) {
      id
      widgets {
        ... on WorkItemWidgetParticipants {
          type
          participants {
            nodes {
              ...User
            }
          }
        }
      }
    }
  }
}
  • @include@skip 지시어를 사용한 조건부 쿼리

Apollo는 이 지시어를 사용한 조건부 쿼리를 지원하여 컴포넌트 상태나 다른 조건에 따라 쿼리를 분할할 수 있습니다.

query projectWorkItems(
  $searchTerm: String
  $fullPath: ID!
  $types: [IssueType!]
  $in: [IssuableSearchableField!]
  $iid: String = null
  $searchByIid: Boolean = false
  $searchByText: Boolean = true
) {
  namespace: project(fullPath: $fullPath) {
    id
    workItems(search: $searchTerm, types: $types, in: $in) @include(if: $searchByText) {
      nodes {
        ...
      }
    }
    workItemsByIid: workItems(iid: $iid, types: $types) @include(if: $searchByIid) {
      nodes {
        ...
      }
    }
  }
}
#import "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"

query workspaceAutocompleteUsersSearch(
  $search: String!
  $fullPath: ID!
  $isProject: Boolean = true
) {
  groupWorkspace: group(fullPath: $fullPath) @skip(if: $isProject) {
    id
    users: autocompleteUsers(search: $search) {
      ...
    }
  }
  namespace: project(fullPath: $fullPath) {
    id
    users: autocompleteUsers(search: $search) {
      ...
    }
  }
}

주의 쿼리를 분할할 때 기존 GraphQL 쿼리가 무효화되지 않도록 주의해야 합니다. 쿼리를 분할할 때 동일한 쿼리가 여러 번 호출되지 않는지 inspector를 통해 확인해야 합니다.

불변성과 캐시 업데이트#

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 대신, 캐시에서 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 응답 모킹#

로컬 Apollo 캐시 사용은 아직 실제 API에 추가되지 않은 등의 이유로 일부 GraphQL API 응답, 쿼리 또는 뮤테이션을 로컬에서 모킹해야 할 때 유용합니다.

예를 들어, 쿼리에서 사용되는 DesignVersion에 대한 프래그먼트가 있다고 가정합니다:

fragment VersionListItem on DesignVersion {
  id
  sha
}

버전 드롭다운 목록에 표시하기 위해 버전 작성자와 created at 속성도 가져와야 합니다. 그런데 이 변경 사항이 아직 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 지시어를 제거하는 것입니다.

Vue Apollo 문서에서 Apollo를 이용한 로컬 상태 관리에 대해 더 읽어보세요.

Pinia와 함께 사용#

단일 Vue 애플리케이션에서 Pinia와 Apollo를 결합하는 것은 일반적으로 권장되지 않습니다. Apollo와 Pinia를 결합하는 제약 사항과 상황에 대해 알아보세요.

Vuex와 함께 사용#

Vuex와 Apollo Client를 결합하는 것은 권장하지 않습니다. Vuex는 GitLab에서 deprecated입니다. Apollo와 함께 사용되는 기존 Vuex 스토어가 있다면 Vuex에서 완전히 마이그레이션하는 것을 강력히 권장합니다. GitLab에서 상태 관리에 대해 더 알아보세요.

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

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

이상적으로는 백엔드 구현이 프론트엔드보다 먼저 완료되어야 클라이언트가 부서 간 번잡한 소통 없이 즉시 API 쿼리를 시작할 수 있습니다. 그러나 우선순위가 항상 일치하지는 않는다는 것을 알고 있습니다. 반복과 커밋한 작업 결과물 제공을 위해, 백엔드보다 프론트엔드를 먼저 구현해야 할 수도 있습니다.

백엔드보다 먼저 프론트엔드 쿼리와 뮤테이션 구현#

이 경우 프론트엔드는 아직 백엔드 리졸버에 해당하지 않는 GraphQL 스키마나 필드를 정의합니다. 구현이 적절한 기능 플래그로 제어되어 제품에서 사용자 대면 오류로 이어지지 않는 한 이는 괜찮습니다. 그러나 graphql-verify CI job으로 백엔드 GraphQL 스키마에 대해 클라이언트 사이드 쿼리/뮤테이션을 검증합니다.

백엔드가 실제로 지원하기 전에 변경 사항을 병합하려면 검증을 통과하는지 확인해야 합니다. 아래에 이를 처리하기 위한 몇 가지 제안이 있습니다.

@client 지시어 사용#

선호하는 방법은 아직 백엔드에서 지원되지 않는 새 쿼리, 뮤테이션 또는 필드에 @client 지시어를 사용하는 것입니다. 지시어가 있는 엔티티는 graphql-verify 검증 job에서 건너뜁니다.

또한 Apollo는 클라이언트 사이드에서 이를 해결하려 시도하는데, 이는 로컬 Apollo 캐시로 API 응답 모킹과 함께 사용할 수 있습니다. 이는 클라이언트 사이드에서 정의된 가짜 데이터로 기능을 테스트하는 편리한 방법을 제공합니다. 변경 사항에 대해 머지 리퀘스트를 열 때 리뷰어가 GDK에 적용하여 쉽게 스모크 테스트를 할 수 있도록 로컬 리졸버를 패치로 제공하는 것이 좋습니다.

지시어 제거를 후속 이슈에서 추적하거나 백엔드 구현 계획의 일부로 포함시키세요.

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

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 엔티티가 아직 스키마에 없거나, 스키마 레벨에서 기능 플래그가 적용된 경우 (new_entity: :feature_flag)에 사용할 수 있습니다.

수동으로 쿼리 트리거#

컴포넌트의 apollo 속성에 있는 쿼리는 컴포넌트가 생성될 때 자동으로 실행됩니다. 일부 컴포넌트는 예를 들어 지연 로드되는 항목이 있는 드롭다운 목록처럼 필요에 따라 네트워크 요청을 만들기를 원합니다.

두 가지 방법이 있습니다:

  • skip 속성 사용
export default {
  apollo: {
    user: {
      query: QUERY_IMPORT,
      skip() {
        // only make the query when dropdown is open
        return !this.isOpen;
      },
    }
  },
};
  • addSmartQuery 사용

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

handleClick() {
  this.$apollo.addSmartQuery('user', {
    // this takes the same values as you'd have in the `apollo` section
    query: QUERY_IMPORT,
  }),
};

페이지네이션 작업#

GitLab GraphQL API는 connection 타입에 Relay 스타일 커서 페이지네이션을 사용합니다. 이는 "커서"가 데이터 세트에서 다음 항목을 가져와야 하는 위치를 추적하는 데 사용됨을 의미합니다. GraphQL Ruby Connection 개념은 connection에 대한 좋은 개요와 소개입니다.

모든 connection 타입 (예: DesignConnectionDiscussionConnection)에는 페이지네이션에 필요한 정보가 담긴 pageInfo 필드가 있습니다:

pageInfo {
  endCursor
  hasNextPage
  hasPreviousPage
  startCursor
}

여기서:

  • startCursor는 첫 번째 항목의 커서를 표시하고 endCursor는 마지막 항목의 커서를 표시합니다.

  • hasPreviousPagehasNextPage는 현재 페이지 이전 또는 이후에 더 많은 페이지가 있는지 확인하는 데 사용됩니다.

connection 타입으로 데이터를 가져올 때 커서를 after 또는 before 매개변수로 전달하여 페이지네이션의 시작점 또는 끝점을 나타낼 수 있습니다. 이는 주어진 엔드포인트 이후 또는 이전에 얼마나 많은 항목을 가져올지를 나타내는 first 또는 last 매개변수와 함께 사용해야 합니다.

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

#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
          }
        }
      }
    }
  }
}

pageInfo 정보를 채우기 위해 page_info.fragment.graphql을 사용하고 있다는 점에 유의하세요.

컴포넌트에서 fetchMore 메서드 사용#

이 방법은 사용자가 처리하는 페이지네이션과 함께 사용하는 것이 적합합니다. 예를 들어, 더 많은 데이터를 가져오기 위해 스크롤하거나 명시적으로 다음 페이지 버튼을 클릭할 때입니다. 모든 데이터를 초기에 가져와야 하는 경우에는 대신 (스마트가 아닌) 쿼리를 사용하는 것이 권장됩니다.

초기 페치를 할 때 일반적으로 처음부터 페이지네이션을 시작하고 싶습니다. 이 경우 다음 중 하나를 할 수 있습니다:

  • 커서 전달 건너뛰기.

  • afternull을 명시적으로 전달.

데이터를 가져온 후 update 훅을 기회로 삼아 Vue 컴포넌트 속성에 설정되는 데이터를 커스터마이징할 수 있습니다. 이를 통해 다른 데이터 중에서 pageInfo 객체를 얻을 수 있습니다.

result 훅에서 pageInfo 객체를 검사하여 다음 페이지를 가져와야 하는지 확인할 수 있습니다. 애플리케이션이 다음 페이지를 무한정 요청하지 않도록 requestCount도 유지하고 있음을 유의하세요:

data() {
  return {
    pageInfo: null,
    requestCount: 0,
  }
},
apollo: {
  designs: {
    query: projectQuery,
    variables() {
      return {
        // ... The rest of the design variables
        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;

      // Increment the request count with each new result
      this.requestCount += 1;
      // Only fetch next page if we have more requests and there is a next page to fetch
      if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
        this.fetchNextPage(pageInfo.endCursor);
      }
    },
  },
},

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

fetchNextPage(endCursor) {
  this.$apollo.queries.designs.fetchMore({
    variables: {
      // ... The rest of the design 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;

                  // We want to save only incoming nodes and replace existing ones
                  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;
                  // We only need to merge the nodes array.
                  // The rest of the fields (pagination) should always be overwritten by 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'], // You might need to set the keyArgs option to enforce the cache's integrity
              },
            },
          },
          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 쿼리가 도움이 될 수 있습니다. 사용자 상호작용을 기반으로 다음 페이지를 가져와야 하는 경우에는 fetchMore과 함께 smartQuery를 사용하는 것이 권장됩니다.

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

애플리케이션이 다음 페이지를 무한정 요청하지 않도록 requestCount도 유지하고 있음을 유의하세요.

data() {
  return {
    requestCount: 0,
    isLoading: false,
    designs: {
      edges: [],
      pageInfo: null,
    },
  }
},
created() {
  this.fetchDesigns();
},
methods: {
  handleError(error) {
    this.isLoading = false;
    // Do something with `error`
  },
  fetchDesigns(endCursor) {
    this.isLoading = true;

    return this.$apollo
      .query({
        query: projectQuery,
        variables() {
          return {
            // ... The rest of the design variables
            first: 10,
            endCursor,
          };
        },
      })
      .then(({ data }) => {
        const { id = null, issue = {} } = data.project || {};
        const { edges = [], pageInfo } = issue.designCollection?.designs || {};

        // Update data
        this.designs = {
          id,
          edges: [...this.designs.edges, ...edges];
          pageInfo: pageInfo;
        };

        // Increment the request count with each new result
        this.requestCount += 1;
        // Only fetch next page if we have more requests and there is a next page to fetch
        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 컨텍스트에 추가할 새 헤더를 만드는 데 사용됩니다:

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

/* eslint-disable @gitlab/require-i18n-strings */
const getQueryHeaders = (etagResource) => {
  return {
    fetchOptions: {
      method: 'GET',
    },
    headers: {
      /* This will depend on your feature */
      '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_editor_header.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: getPipelineIidQuery,
    pollInterval: PIPELINE_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 { setupQueryPollingByVisibility } from '~/pipelines/components/graph/utils';

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

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

구독이 성숙해지면 이 프로세스를 구독으로 대체하고 별도의 링크 라이브러리를 제거하여 쿼리 배치 처리로 돌아갈 수 있습니다.

ETag 캐싱 테스트 방법#

네트워크 탭에서 요청을 확인하여 구현이 작동하는지 테스트할 수 있습니다. ETag 리소스에 변경 사항이 없으면 폴링된 모든 요청은:

  • POST 요청 대신 GET 요청이어야 합니다.

  • 200 대신 304 HTTP 상태를 가져야 합니다.

테스트 시 개발자 도구에서 캐싱이 비활성화되어 있지 않은지 확인하세요.

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

구독#

GraphQL API에서 웹소켓을 통해 실시간 업데이트를 받기 위해 구독을 사용합니다. 현재 기존 구독의 수는 제한적이며, GraphiQL 탐색기에서 사용 가능한 목록을 확인할 수 있습니다.

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

구독 사용 시기#

구독은 아껴서 사용하세요. 대부분의 경우 폴링으로 충분합니다. 다음과 같은 사용 사례 (이에만 한정되지 않음)에 구독을 사용해야 합니다:

  • 저지연, 실시간 업데이트: 10-15초(표준 폴링)의 지연도 사용자 경험에 부정적인 영향을 미칠 때

  • 고빈도 상태 변경: 짧은 시간 내에 객체가 여러 번 상태를 변경할 때. 구독은 모든 상태 변경을 "푸시"하는 반면, 폴링은 간격 사이의 중간 단계를 놓칠 수 있습니다.

  • 대형 객체의 효율성: 대형 객체를 반복적으로 폴링하는 것은 비용이 많이 듭니다. 특히 객체의 대부분 필드가 거의 변경되지 않는 경우 (예: 50개 이상의 속성을 가진 파이프라인). 구독을 통해 서버는 실제로 변경되는 특정 필드(예: 상태 또는 finished_at)만 푸시할 수 있어 클라이언트와 서버 모두의 데이터 전송 및 처리 부하를 크게 줄일 수 있습니다.

구독 모범 사례#

구독을 구현할 때 성능 문제와 메모리 누수를 방지하기 위해 다음 패턴을 따르세요.

result 훅 사용 시기#

대부분의 경우 Apollo 쿼리 정의의 subscribeToMore 훅이 구독에 사용하고자 하는 패턴입니다. 그러나 result 훅과 수동 subscribeToMore 호출을 사용해야 하는 경우가 있습니다:

  • 쿼리 데이터에서 파생된 구독 타깃: 구독 변수(예: 파이프라인 ID)가 상위 쿼리 결과에서 나오는 경우, subscribeToMore는 쿼리 데이터를 사용하기 전에 실행되어 undefined 변수 오류를 유발합니다. result 훅은 구독하기 전에 유효한 데이터를 기다릴 수 있게 합니다.

  • 복잡한 건너뛰기 로직: 구독 여부를 결정하는 것이 skip 함수로 쉽게 표현하기 어려운 쿼리 결과의 여러 필드에 의존할 때.

  • 데이터 변경 시 구독 정리: 쿼리 결과의 특정 조건에 따라 명시적으로 구독을 취소하고 재구독해야 할 때.

result() {
  const currentPipelineId = this.commit?.pipeline?.id;

  // If pipeline ID changed, reset subscription state
  if (this.subscribedPipelineId && this.subscribedPipelineId !== currentPipelineId) {
    this.pipelineSubscription?.unsubscribe();
    this.isSubscribed = false;
  }

  // Subscribe only once we have a valid pipeline ID
  if (currentPipelineId && !this.isSubscribed) {
    this.isSubscribed = true;
    this.subscribedPipelineId = currentPipelineId;

    this.pipelineSubscription = this.$apollo.queries.commit.subscribeToMore({
      document: pipelineStatusUpdatedSubscription,
      variables: {
        pipelineId: currentPipelineId,
      },
      updateQuery(previousData, { subscriptionData }) {
        if (Visibility.hidden()) return previousData;
        // Update logic...
        return previousData;
      },
    });
  }
},

리페치로 얇은 페이로드 사용#

result 훅 패턴과 네트워크 작업을 사용할 때, 구독 응답에서 id만 반환하고 전체 데이터는 별도로 가져오세요. 이 시그널 및 페치 방법은 WebSocket 오버헤드를 줄이고 기존에 최적화된 쿼리를 활용합니다:

subscription pipelineUpdated($projectId: ID!) {
  pipelineUpdated(projectId: $projectId) {
    id # Only return the ID, fetch full data separately
  }
}

가시성 확인으로 구독 보호#

updateQuery가 네트워크 호출을 트리거할 때, 탭이 숨겨진 경우 이러한 작업을 보호하세요. 이렇게 하면 사용자가 보고 있지 않을 때 불필요한 페치를 건너뜁니다:

updateQuery(prev, { subscriptionData }) {
  // Skip network operations while tab is hidden
  if (Visibility.hidden()) return prev;
},

사용자가 돌아오면 단일 리페치를 트리거하여 동기화하세요:

import Visibility from 'visibilityjs';

export default {
  created() {
    this.visibilityId = Visibility.change(() => {
      if (!Visibility.hidden()) {
        this.$apollo.queries.pipelines.refetch();
      }
    });
  },
  beforeDestroy() {
    Visibility.unbind(this.visibilityId);
  },
};

구독 트리거 페치 배치 처리#

목록 뷰의 updateQuery 내에서 즉시 네트워크 요청을 보내지 마세요. CI/CD와 같이 활동이 많은 환경에서는 많은 항목이 몇 초 내에 업데이트될 수 있습니다. 디바운스된 배처를 사용하여 ID를 수집하고 단일 요청으로 가져오세요.

import { debounce } from 'lodash-es';
import { createAlert } from '~/alert';
import Sentry from '~/sentry/sentry_bundle';

const BATCH_DEBOUNCE = 3000;
const MAX_BATCH_SIZE = 15;

export default {
  data() {
    return {
      pendingIds: new Set(),
    };
  },
  created() {
    this.fetchUpdatedPipelines = debounce(this.processPendingUpdates, BATCH_DEBOUNCE);
  },
  beforeDestroy() {
    this.fetchUpdatedPipelines?.cancel();
    this.pendingIds.clear();
  },
  methods: {
    // Called from subscription updateQuery
    queuePipelineUpdate(pipelineId) {
      this.pendingIds.add(pipelineId);
      this.fetchUpdatedPipelines();
    },
    async processPendingUpdates() {
      if (this.pendingIds.size === 0) return;

      const idsToFetch = Array.from(this.pendingIds).slice(0, MAX_BATCH_SIZE);
      idsToFetch.forEach((id) => this.pendingIds.delete(id));

      try {
        await this.$apollo.query({
          query: getPipelinesQuery,
          fetchPolicy: 'network-only',
          variables: { fullPath: this.fullPath, ids: idsToFetch },
        });

        // Process remaining IDs if any
        if (this.pendingIds.size > 0) {
          this.fetchUpdatedPipelines();
        }
      } catch (error) {
        createAlert({
          message: s__('Pipelines|Something went wrong while updating pipeline information'),
        });
        Sentry.captureException(error);
      }
    },
  },
};

안전망으로 폴링 사용#

WebSocket 연결이 조용히 끊어지는 경우를 처리하기 위해 구독과 함께 가시성 인식 폴링을 사용하세요. ETag 캐싱은 이러한 폴링 요청을 경량화합니다.

import { setupQueryPollingByVisibility, etagQueryHeaders } from '~/graphql_shared/utils';

const POLL_INTERVAL = 60000;

export default {
  apollo: {
    pipelines: {
      query: getPipelinesQuery,
      pollInterval: POLL_INTERVAL,
      context() {
        return etagQueryHeaders('ci/pipelines-page', this.projectPipelinesEtagPath);
      },
    },
  },
  created() {
    setupQueryPollingByVisibility(this.$apollo.queries.pipelines, POLL_INTERVAL);
  },
};

구독 누적 방지#

메모리 누수의 일반적인 원인은 이전 구독을 정리하지 않고 새 구독을 만드는 것입니다. 이는 일반적으로 subscribeToMore가 상태를 추적하지 않고 모든 result()에서 호출될 때 발생합니다:

// BAD: Subscriptions accumulate on every result()
result() {
  this.$apollo.queries.commit.subscribeToMore({ ... });
}

// GOOD: Track subscription state explicitly
result() {
  if (this.isSubscribed) return;
  this.isSubscribed = true;
  this.subscription = this.$apollo.queries.commit.subscribeToMore({ ... });
}

모범 사례#

뮤테이션에서 update 훅 사용 시기 (및 사용하지 않을 시기)#

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

  • 한 번은 시작 시. 즉, 뮤테이션이 완료되기 전.

  • 한 번은 뮤테이션이 완료된 후.

이 훅은 스토어 (즉, ApolloCache)에서 항목을 추가하거나 제거하는 경우에만 사용해야 합니다. 기존 항목을 업데이트하는 경우 일반적으로 글로벌 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` 디렉터리를 "Excluded"로 표시한 경우,

gitlab/tmp/tests/graphql에 대해 "Mark Directory As -> Not Excluded"를 선택해야 합니다. 이를 통해 JS GraphQL 플러그인이 자동으로 스키마를 찾고 인덱싱할 수 있습니다.

Apollo Client 모킹#

Apollo 작업으로 컴포넌트를 테스트하려면 단위 테스트에서 Apollo Client를 모킹해야 합니다. 제어된 쿼리 해결로 커스텀 Apollo Link 구현을 제공하는 mock_apollo_helper를 사용합니다.

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

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

Vue.use(VueApollo);

describe('Some component with Apollo mock', () => {
  let wrapper;

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

그 다음 모킹된 Apollo 프로바이더를 만들어야 합니다:

import createMockApollo from 'helpers/mock_apollo_helper';

describe('Some component with Apollo mock', () => {
  let wrapper;
  let mockApollo;

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

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

  afterEach(() => {
    // we need to ensure we don't have provider persisted between tests
    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('Some component with 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 응답과 동일한지 확인하세요. 예를 들어 루트 속성은 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('renders a loading state', () => {
  const wrapper = createComponent();

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

it('renders designs list', async () => {
  const wrapper = createComponent();

  await waitForPromises()

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

쿼리 오류를 테스트해야 하는 경우 요청 핸들러로 거부된 값을 모킹해야 합니다:

it('renders error if query fails', 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('calls a mutation with correct parameters and reorders designs', 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 Client의 네이티브 재시도 동작과 Jest의 모킹 함수를 결합하여 일련의 응답을 만들 수 있습니다. 이를 수동으로 진행할 필요는 없지만 특정 방식으로 기다려야 합니다.

describe('when query times out', () => {
  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('shows correct errors and does not overwrite populated data when data is empty', async () => {
    /* fails at first, shows error, no data yet */
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(false);

    /* succeeds, clears error, shows graph */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(false);
    expect(getGraph().exists()).toBe(true);

    /* fails again, alert returns but data persists */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(true);
  });
});

이전에는 Apollo 기능을 테스트하기 위해 mount{ mocks: { $apollo ...}}를 사용했습니다. 이 방법은 권장되지 않습니다 - 적절한 $apollo 모킹은 테스트에 많은 구현 세부 사항을 노출합니다. 모킹된 Apollo 프로바이더로 교체하는 것을 고려하세요.

wrapper = mount(SomeComponent, {
  mocks: {
    // avoid! Mock real graphql queries and mutations instead
    $apollo: {
      mutate: jest.fn(),
      queries: {
        groups: {
          loading,
        },
      },
    },
  },
});

구독 테스트#

구독을 테스트할 때, vue-apollo@4에서 구독의 기본 동작은 오류 발생 시 재구독하고 즉시 새 요청을 발행한다는 점을 주의하세요 (단, skip 값이 이를 제한하지 않는 경우).

import waitForPromises from 'helpers/wait_for_promises';

// subscriptionMock is registered as handler function for subscription
// in our helper
const subcriptionMock = jest.fn().mockResolvedValue(okResponse);

// ...

it('testing error state', () => {
  // Avoid: will stuck below!
  subscriptionMock = jest.fn().mockRejectedValue({ errors: [] });

  // component calls subscription mock as part of
  createComponent();
  // will be stuck forever:
  // * rejected promise will trigger resubscription
  // * re-subscription will call subscriptionMock again, resulting in rejected promise
  // * rejected promise will trigger next re-subscription,
  await waitForPromises();
  // ...
})

vue@3vue-apollo@4 사용 시 이런 무한 루프를 방지하려면 일회성 거부를 사용하는 것을 고려하세요.

it('testing failure', () => {
  // OK: subscription will fail once
  subscriptionMock.mockRejectedValueOnce({ errors: [] });
  // component calls subscription mock as part of
  createComponent();
  await waitForPromises();

  // code below now will be executed
})

@client 쿼리 테스트#

모킹 리졸버 사용#

애플리케이션에 @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.

이를 해결하려면 모킹 handlers 대신 모킹 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; // mock function, jest.fn();

const mockResolvers = {
  Query: {
    blobContent() {
      return {
        __typename: 'BlobContent',
        rawData: mockBlobContentData(), // the mock function can resolve mock data
      };
    },
  },
};

const createComponentWithApollo = ({ props = {} } = {}) => {
  mockApollo = createMockApollo([], mockResolvers); // resolvers are the second parameter

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

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

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

it('shows data', async() => {
  mockBlobContentData.mockResolvedValue(data); // you may resolve or reject to mock the result

  createComponentWithApollo();

  await waitForPromises(); // wait on the resolver mock to execute

  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('Some component with Apollo mock', () => {
  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),
  },
);

Mock Apollo 헬퍼 (제어된 해결)#

mock_apollo_helpermock-apollo-client 라이브러리를 커스텀 Apollo Link로 대체합니다. 쿼리와 뮤테이션은 명시적으로 해결될 때까지 보류 상태로 유지되며, 로딩 상태에 대한 정밀한 제어가 가능합니다. 모든 새 테스트에 사용하세요.

임포트 및 반환 형태#

헬퍼는 두 가지 내보내기를 제공합니다:

  • createControlledMockApollo (이름 있는) — 제어 모드: 명시적으로 해결될 때까지 쿼리가 보류됩니다.

  • createMockApollo (기본) — 레거시 모드: 쿼리가 즉시 해결됩니다.

두 방법 모두 (handlers, resolvers, cacheOptions)를 받습니다.

제어 모드 (createControlledMockApollo 이름 있는 내보내기)#

명시적으로 해결할 때까지 쿼리와 뮤테이션이 보류 상태로 유지되어 로딩 상태에 대한 정밀한 어설션이 가능합니다.

import { createControlledMockApollo } from 'helpers/mock_apollo_helper';

const handler = jest.fn().mockResolvedValue(mockResponse);
const { apolloProvider, resolveQuery } = createControlledMockApollo([[projectQuery, handler]]);

wrapper = shallowMount(Component, { apolloProvider });
await waitForPromises();
// Component is still in loading state

await resolveQuery(projectQuery);
// Component now shows data

해결 메서드는 타입 안전합니다: resolveQuery/rejectQuery는 쿼리 문서만 받고, resolveMutation/rejectMutation은 뮤테이션 문서만 받습니다. 잘못된 타입을 전달하면 오류가 발생합니다. 모든 해결 메서드는 프로미스를 반환하므로 (내부적으로 waitForPromises()를 호출) 직접 await할 수 있습니다.

레거시 모드 (기본 내보내기)#

기본 내보내기는 핸들러를 즉시 해결합니다. 이는 이전 mock-apollo-client 기반 구현의 동작과 일치합니다. 기존 테스트에 사용하세요.

import createMockApollo from 'helpers/mock_apollo_helper';

const handler = jest.fn().mockResolvedValue({ data: { project: { id: '1' } } });
const apolloProvider = createMockApollo([[projectQuery, handler]]);

wrapper = shallowMount(Component, { apolloProvider });
await waitForPromises();
expect(handler).toHaveBeenCalledWith({ id: '1' });

보류 중인 모든 작업을 재귀적으로 해결하려면 resolveAll()을 사용하세요. 이는 현재 웨이브를 해결하고, 프로미스를 기다리며, 새 작업이 나타나면 반복합니다. 이는 하나의 쿼리를 해결하면 다른 쿼리가 트리거되는 연쇄 쿼리를 처리합니다. 보류 중인 작업이 없으면 resolveAll()은 오류를 발생시킵니다. 보류 중인 작업 없이 마이크로태스크를 플러시하려면 waitForPromises()를 사용하세요.

실패를 시뮬레이션하려면 rejectQuery(queryDoc, error) 또는 rejectMutation(mutationDoc, error)를 사용하세요.

등록된 핸들러 없이 작업을 사용하면 오류가 발생하여 핸들러 설정 누락을 조기에 발견하는 데 도움이 됩니다.

핸들러 형식#

핸들러는 모킹 함수 또는 일반 데이터 객체가 될 수 있습니다.

// Function handler (recommended when you need to assert on calls)
const handler = jest.fn().mockResolvedValue({ data: { project: { id: '1' } } });

// Plain data handler (simpler, no assertion support)
const handlers = [[projectQuery, { data: { project: { id: '1' } } }]];
레거시 모드에서 제어 모드로 마이그레이션#

기본 임포트를 사용하는 테스트는 레거시 모드(자동 해결)가 됩니다. 제어 모드로 마이그레이션하려면:

이름 있는 임포트를 사용하고 반환 값을 구조분해하세요:

// Legacy mode (default export)
import createMockApollo from 'helpers/mock_apollo_helper';
const apolloProvider = createMockApollo([...]);

// Controlled mode (named export)
import { createControlledMockApollo } from 'helpers/mock_apollo_helper';
const { apolloProvider, resolveQuery } = createControlledMockApollo([...]);

마운트 호출을 구조분해된 프로바이더를 사용하도록 업데이트하세요:

wrapper = shallowMount(SomeComponent, { apolloProvider });

await waitForPromises()를 명시적 해결로 교체하세요:

// Legacy mode
await waitForPromises();

// Controlled mode
await resolveQuery(projectQuery);
// or resolve all pending operations:
await resolveAll();

캐시 옵션은 여전히 세 번째 인수로 작동합니다:

const { apolloProvider } = createControlledMockApollo(handlers, resolvers, {
  dataIdFromObject: (object) => object.iid,
});

오류 처리#

GitLab GraphQL 뮤테이션에는 두 가지 오류 모드가 있습니다: 최상위 오류데이터로서의 오류.

GraphQL 뮤테이션을 사용할 때 오류 발생 시 사용자가 적절한 피드백을 받을 수 있도록 두 오류 모드 모두 처리하는 것을 고려하세요.

최상위 오류#

이러한 오류는 GraphQL 응답의 "최상위 레벨"에 있습니다. 인수 오류와 문법 오류를 포함하는 복구 불가능한 오류이며, 사용자에게 직접 표시되어서는 안 됩니다.

최상위 오류 처리#

Apollo는 최상위 오류를 인식하므로 Apollo의 다양한 오류 처리 메커니즘을 활용할 수 있습니다. 예를 들어 mutate 메서드를 호출한 후 Promise 거부를 처리하거나 ApolloMutation 컴포넌트에서 방출된 error 이벤트를 처리하는 방법이 있습니다.

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

데이터로서의 오류#

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

데이터로서의 오류 처리#

먼저 뮤테이션 객체에 errors를 추가해야 합니다:

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

이제 이 뮤테이션을 커밋하고 오류가 발생하면 응답에 처리할 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));

Vuex 사용 시, 다음 경우에 캐시를 비활성화하세요:

  • 데이터가 다른 곳에서 캐시되고 있는 경우

  • 사용 사례가 캐싱을 필요로 하지 않는 경우 데이터가 다른 곳에서 캐시되고 있거나 해당 사용 사례에 캐시가 필요하지 않은 경우.

import createDefaultClient, { fetchPolicies } from '~/lib/graphql';

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을 추가합니다.

캐시 데이터 손실 경고#

때때로 콘솔에서 경고를 볼 수 있습니다: 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. 이슈를 해결하려면 다중 쿼리 섹션을 확인하세요.

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