GraphQL

시작하기

유용한 자료

일반 자료:

GitLab의 GraphQL:

라이브러리

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

Vue 애플리케이션에서 GraphQL을 사용하는 경우 Vue에서의 사용 섹션을 참고하면 도움이 됩니다.

다른 사용 사례는 Vue 밖에서의 사용 섹션을 확인하세요.

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

도구

Apollo Client Devtools

아폴로 GraphQL VS Code 확장 프로그램

만약 VS Code를 사용 중이라면, 아폴로 GraphQL 확장 프로그램은 .graphql 파일에서 자동 완성을 지원합니다. GraphQL 확장 프로그램을 설정하려면 다음 단계를 따르세요:

  1. 스키마를 생성합니다: bundle exec rake gitlab:graphql:schema:dump
  2. apollo.config.js 파일을 gitlab 로컬 디렉터리 최상위에 추가합니다.
  3. 다음 내용으로 파일을 채웁니다:

    module.exports = {
      client: {
        includes: ['./app/assets/javascripts/**/*.graphql', './ee/app/assets/javascripts/**/*.graphql'],
        service: {
          name: 'GitLab',
          localSchemaFile: './tmp/tests/graphql/gitlab_schema.graphql',
        },
      },
    };
    
  4. VS Code를 다시 시작합니다.

GraphQL API 탐색

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

모든 기존 쿼리 및 뮤테이션을 확인하려면, GraphiQL의 오른쪽에서 문서 탐색기를 선택하세요. 작성한 쿼리 또는 뮤테이션을 실행하려면, 왼쪽 상단에서 쿼리 실행을 선택하세요.

GraphiQL 인터페이스

Apollo Client

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

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

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

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

동일한 Apollo 클라이언트 객체에 대해 여러 쿼리를 수행하는 경우 다음 오류가 발생할 수 있습니다: Query 객체의 someProperty 필드를 대체하는 동안 캐시 데이터가 손실될 수 있습니다. 이 문제를 해결하려면 SomeEntity의 모든 객체에는 id가 있거나 사용자 정의 Merge 함수가 필요합니다. 우리는 id가 있는 모든 GraphQL 타입에 대해 id의 존재 여부를 확인하고 있으므로 (단위 테스트를 실행할 때) 이 문제가 발생하지 않아야 합니다. (이경우 MOcked Responses에서 id가 요청될 때 해당 경고가 보인다; 이 경우에는 요청되는 경우 Mocked Responses가 id를 포함하도록 하십시오).

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

우리는 기본 클라이언트에서 typePoliciesmerge: true로 정의된 클라이언트 전반적인 유형이 있습니다 (이것은 Apollo가 후속 쿼리의 경우 기존 및 수신 응답을 Merge합니다). 따라서 SomeEntity를 여기에 추가하거나 해당 타입에 대해 사용자 정의 Merge 함수를 정의하는 것을 고려하세요.

GraphQL 쿼리

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

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

  • 쿼리에 대한 all_users.query.graphql;
  • 뮤테이션에 대한 add_user.mutation.graphql;
  • 프래그먼트에 대한 basic_user.fragment.graphql.

CustomersDot GraphQL 엔드포인트를 위해 쿼리를 사용 중이라면, 파일 이름을 .customer.query.graphql, .customer.mutation.graphql, 또는 .customer.fragment.graphql로 끝내십시오.

조각

Fragments는 복잡한 GraphQL 쿼리를 보다 가독성 있고 재사용 가능하게 만드는 방법입니다. 다음은 GraphQL 조각의 예시입니다:

fragment DesignListItem on Design {
  id
  image
  event
  filename
  notesCount
}

조각은 별도의 파일에 저장되어 쿼리, 뮤테이션 또는 다른 조각에서 import되고 사용될 수 있습니다.

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

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

조각에 대해 더 알아보기: GraphQL 문서

글로벌 ID

GitLab GraphQL API는 id 필드를 후원 PostgreSQL 기본 키 id 대신 글로벌 ID로 나타냅니다. 글로벌 ID는 클라이언트 측 라이브러리에서 캐싱 및 검색에 사용되는 규칙입니다.

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

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

const primaryKeyId = getIdFromGraphQLId(data.id);

GraphQL 스키마에서 id를 갖는 모든 GraphQL 유형을 위해 전역 id를 쿼리하는 것이 필요합니다:

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

불변성 및 캐시 업데이트

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

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

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

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

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

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

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

produce를 사용하면 코드 예제에서 볼 수 있듯이 draftState의 모든 종류의 직접 조작을 수행할 수 있습니다. 또한 immerdraftState의 변경 사항이 포함된 새 상태를 생성한다는 것을 보장합니다.

Vue에서의 사용

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

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

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

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

Vue Apollo에 대해 자세히 알아보려면 Vue Apollo 문서를 읽어보세요.

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 대신에 user를 읽으려는 모든 시도에서 userQuery를 반환하는 타입 정책을 만들 수 있습니다:

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

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

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

실제 API에 아직 추가되지 않은 경우(예: 아직 API에 추가되지 않은 필드에 대한 모킹이 필요한 경우) 로컬 Apollo 캐시를 사용하는 것이 도움이 됩니다.

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

fragment VersionListItem on DesignVersion {
  id
  sha
}

또한 버전 작성자 및 버전 드롭다운 디렉터리에 표시하기 위해 authorcreatedAt 속성을 가져와야 합니다. 그러나 이러한 변경 사항은 아직 API에 구현되지 않았습니다. 기존 조각을 변경하여 이러한 새 필드에 대한 모킹 응답을 얻을 수 있습니다:

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

이제 Apollo는 @client 지시문으로 표시된 각 필드에 대한 _리졸버_를 찾으려고 합니다. 따라서 해당 유형인 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 클라이언트에 resolvers 객체를 전달해야 합니다:

// graphql.js

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

const defaultClient = createDefaultClient(resolvers);

버전을 가져오려고 시도할 때마다 클라이언트는 원격 API 엔드포인트에서 idsha를 가져옵니다. 그런 다음 우리는 하드코드된 값을 authorcreatedAt 버전 속성에 할당합니다. 이렇게 함으로써 프런트엔드 개발자는 백엔드에 의해 차단되지 않고 UI에 작업할 수 있습니다. 응답이 API에 추가되면 사용자 지정 로컬 리졸버를 제거할 수 있습니다. 쿼리/조각에서 @client 지시문을 제거하는 것 외에는 아무 변경점도 없습니다.

Apollo를 사용한 로컬 상태 관리에 대해 자세히 알아보려면 Vue Apollo 문서를 읽어보세요.

Vuex 사용하기

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

  • VueX와 Apollo는 모두 전역 리포지터리이므로 책임을 공유하고 두 개의 진실의 원천이 있습니다.
  • VueX와 Apollo의 동기화 유지 관리가 어려울 수 있습니다.
  • Apollo와 VueX 간의 통신으로 인한 버그는 미묘하고 디버그하기 어려울 수 있습니다.

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

생성 또는 업데이트될 GraphQL 쿼리/뮤테이션을 필요로 하는 모든 기능은 신중하게 계획되어야 합니다. Frontend와 backend 담당자들은 클라이언트 측 및 서버 측 요구 사항을 모두 충족하는 스키마에 동의해야 합니다. 이는 양쪽 부서 모두가 서로를 차단하지 않고 각각의 역할을 구현할 수 있도록 해줍니다.

이상적으로는 백엔드 구현이 프론트엔드보다 먼저 완료되어야 하므로 클라이언트는 부서 간의 최소한의 확인을 통해 즉시 API를 쿼리할 수 있어야 합니다. 그러나 우리는 우선순위가 항상 일치하지는 않을 수 있다는 점을 인식합니다. 반복과 제공해야 할 작업을 고려하면 프론트엔드가 백엔드보다 먼저 구현되어야 할 수도 있습니다.

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

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

이를 위한 몇 가지 제안은 다음과 같습니다.

@client 지시어 사용

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

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

지시어의 제거를 추적하고, 해당 수정사항이나 백엔드 구현 계획의 일부로서 이를 수행하세요.

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

GraphQL 쿼리/뮤테이션을 유효성 검사를 통째로 비활성화하려면, 특정 파일을 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,
      }
    }
  },
};
여러 쿼리 버전 피하기

다중 버전 접근 방식은 대규모의 Merge Request을 유발하고 피처 플래그가 존재하는 동안 두 유사한 쿼리를 유지하기 위해 유지될 필요가 있는 경우에 사용될 수 있습니다. 새로운 GraphQL 엔터티가 아직 스키마의 일부가 아니거나 스키마 수준에서 피처 플래그가 있는 경우에만 여러 버전을 사용할 수 있습니다.

쿼리 매뉴얼 트리거

컴포넌트의 apollo 속성에 대한 쿼리는 해당 컴포넌트가 생성될 때 자동으로 수행됩니다. 일부 컴포넌트에서는 필요에 따라 네트워크 요청을 매뉴얼으로 하고 싶어합니다. 예를 들어, 레이지 로드된 항목이 있는 드롭다운 디렉터리과 같은 경우입니다.

이것을 수행하는 두 가지 방법이 있습니다.

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

메소드에서 Smart Query를 매뉴얼으로 생성할 수 있습니다.

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

페이징 처리하기

GitLab GraphQL API는 연결 유형에 대해 Relay-style 커서 페이징 을 사용합니다. 이는 “커서”가 데이터 집합의 다음 항목을 어디서 가져와야 하는지 추적하는 데 사용됨을 의미합니다. GraphQL Ruby 커넥션 개념은 커넥션에 대한 좋은 개요와 소개입니다.

각 연결 유형(예: DesignConnectionDiscussionConnection)은 페이징에 필요한 정보인 pageInfo 필드를 포함하고 있습니다.

pageInfo {
  endCursor
  hasNextPage
  hasPreviousPage
  startCursor
}

여기서:

  • startCursor는 첫 항목의 커서를 표시하고 endCursor는 마지막 항목의 커서를 표시합니다.
  • hasPreviousPagehasNextPage는 현재 페이지 앞이나 뒤에 더 많은 페이지가 있는지를 확인할 수 있게 합니다.

연결 유형을 사용하여 데이터를 가져올 때, after 또는 before 매개변수로 커서를 전달하여 페이징의 시작 또는 끝점을 나타내고, 이 뒤에 first 또는 last 매개변수가 따라와야 합니다. 이는 주어진 끝점 뒤에나 앞에 가져올 아이템 얼마나 많은지를 나타냅니다.

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

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

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

또한 저희는 page_info.fragment.graphql를 사용하여 pageInfo 정보를 채우고 있습니다.

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

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

초기 검색을 수행할 때 보통 페이지네이션을 처음부터 시작하려고 합니다. 이 경우 다음 중 하나를 선택할 수 있습니다:

  • 커서를 전달하지 않거나
  • 명시적으로 afternull을 전달합니다.

데이터를 가져온 후, Vue 컴포넌트 속성에 설정된 데이터를 사용자 정의하기 위해 update 훅을 사용할 수 있습니다. 이를 통해 다른 데이터 중 pageInfo 객체를 얻을 수 있습니다.

result 훅에서는 pageInfo 객체를 검사하여 다음 페이지를 가져와야 하는지 확인할 수 있습니다. 또한 응용 프로그램이 계속해서 다음 페이지를 요청하는 것을 방지하기 위해 requestCount를 유지합니다.

data() {
  return {
    pageInfo: null,
    requestCount: 0,
  }
},
apollo: {
  designs: {
    query: projectQuery,
    variables() {
      return {
        // ... 나머지 디자인 변수
        first: 10,
      };
    },
    update(data) {
      const { id = null, issue = {} } = data.project || {};
      const { edges = [], pageInfo } = issue.designCollection?.designs || {};
      
      return {
        id,
        edges,
        pageInfo,
      };
    },
    result() {
      const { pageInfo } = this.designs;
      
      // 새로운 결과마다 요청 카운트 증가
      this.requestCount += 1;
      // 더 많은 요청이 있고 가져올 다음 페이지가 있는 경우에만 다음 페이지를 가져옵니다.
      if (this.requestCount < MAX_REQUEST_COUNT && pageInfo?.hasNextPage) {
        this.fetchNextPage(pageInfo.endCursor);
      }
    },
  },
},

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

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

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

const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient(
    {},
    {
      cacheConfig: {
        typePolicies: {
          DesignCollection: {
            fields: {
              designs: {
                merge(existing, incoming) {
                  if (!incoming) return existing;
                  if (!existing) return incoming;
                  
                  // 들어오는 노드만 저장하여 기존 노드를 대체하고자 합니다.
                  return incoming
                }
              }
            }
          }
        }
      },
    },
  ),
});

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

const apolloProvider = new VueApollo({
  defaultClient: createDefaultClient(
    {},
    {
      cacheConfig: {
        typePolicies: {
          DesignCollection: {
            fields: {
              designs: {
                merge(existing, incoming) {
                  if (!incoming) return existing;
                  if (!existing) return incoming;
                  
                  const { nodes, ...rest } = incoming;
                  // 노드 배열만 Merge해야 합니다.
                  // 나머지 필드(pagination)는 항상 들어오는 것에 덮어씌워져야 합니다.
                  let result = rest;
                  result.nodes = [...existing.nodes, ...nodes];
                  return result;
                }
              }
            }
          }
        }
      },
    },
  ),
});

apollo-client는 페이지네이션 쿼리와 함께 사용할 수 있는 몇 가지 필드 정책을 제공합니다. 여기에서는 concatPagination 정책을 사용하여 무한 스크롤 페이지네이션을 달성하는 다른 방법을 소개합니다.

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

Vue.use(VueApollo);

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

이는 새 페이지 결과가 이전 결과에 추가되기 때문에 DesignCollection 예제와 유사합니다.

일부 경우에는 올바른 keyArgs를 정의하기가 어려울 수 있습니다. 이 경우 keyArgsfalse로 설정할 수 있습니다. 이렇게 하면 Apollo Client가 자동 Merge을 수행하지 않고 merge 함수에 논리적으로 완전히 의존하도록 지시합니다.

예를 들어, 다음과 같은 쿼리가 있는 경우:

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

여기서 groups 필드에는 keyArgs 후보가 없습니다: 이후 요청에서 변경되기 때문에 after 인수를 고려할 필요가 없습니다. keyArgsfalse로 설정하면 의도한 대로 업데이트 작업을 수행할 수 있습니다.

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

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

모든 페이지네이션 데이터를 초기에 한꺼번에 가져와야 하는 경우 Apollo 쿼리가 해결 방법을 제공할 수 있습니다. 사용자 상호작용에 따라 다음 페이지를 가져와야 하는 경우, smartQueryfetchMore-hook를 함께 사용하는 것이 좋습니다.

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

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

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

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

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

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

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

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

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

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

캐시에서 해당 데이터를 검색할 때는 $fullPath 변수만 제공하면 되며 after 또는 before와 같은 페이지네이션과 관련된 변수는 제공할 필요가 없습니다.

캐시 상에서 데이터를 검색하는 방법에 대해 더 알아보려면 Apollo의 문서를 참고하세요.

유사한 쿼리를 일괄 처리하기

기본적으로 Apollo 클라이언트는 브라우저로부터 각 쿼리 당 하나의 HTTP 요청을 보냅니다. 여러 개의 쿼리를 하나의 요처으로 묶어서 요청 수를 줄이고 싶다면 batchKey를 정의하세요.

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

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

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

폴링과 성능

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

백엔드에서 ETag 리소스가 캐시되도록 설정되었다면 프론트엔드에서 몇 가지 변경이 필요합니다.

먼저 백엔드로부터 ETag 리소스를 받은 후, URL 경로 형식으로 이어지는 Apollo 콘텍스트에 추가해야 합니다. 파이프라인 그래프의 예시에서는 graphql_resource_etag이라는 것을 이용하여 새로운 헤더를 생성합니다:

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

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

/* component.vue */

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

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

이는 파이프라인 편집기의 파이프라인 상태에서 볼 수 있습니다. 파이프라인 편집기는 최신 파이프라인의 변경을 감시합니다. 사용자가 새로운 커밋을 만들면, 새로운 파이프라인의 변경을 감시하도록 파이프라인 쿼리를 업데이트합니다.

# pipeline_etag.query.graphql

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

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

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

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

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

마지막으로, 컴포넌트가 비활성화되었을 때 쿼리 폴링이 일시 중단되도록 가시성 확인을 추가할 수 있습니다. 이는 페이지의 요청 부하를 줄일 수 있습니다.

/* component.vue */

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

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

완전한 ETag 캐싱을 프론트엔드에 적용하는 방법에 대한 참고 사항으로 이 MR를 확인할 수 있습니다.

한 번 구독이 신뢰성 있게 되면, 이 프로세스는 구독을 사용하도록 대체될 수 있으며, 별도의 링크 라이브러리를 제거하고 쿼리 일괄 처리로 돌아갈 수 있을 것입니다.

ETag 캐싱 테스트 방법

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

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

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

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

구독

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

참고: GraphiQL에서는 지원되지 않는 ActionCable 클라이언트가 필요하기 때문에, 구독을 GraphiQL을 통해 테스트할 수 없습니다.

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

Best Practices

뮤테이션에서 update 훅을 사용하는 시점(사용하는 경우와 사용하지 않는 경우)

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

  • 하나는 시작 시에 호출됩니다. 즉, 뮤테이션이 완료되기 전에 호출됩니다.
  • 또 다른 하나는 뮤테이션이 완료된 후에 호출됩니다.

이 훅은 리포지터리(즉, ApolloCache)에 항목을 추가하거나 제거하는 경우에만 사용해야 합니다. 기존 항목을 _업데이트_하는 경우에는 보통 전역 id로 표시됩니다.

이 경우 뮤테이션 쿼리에 id가 포함되어 있으면 리포지터리가 자동으로 업데이트됩니다. id가 포함된 전형적인 뮤테이션 쿼리의 예는 다음과 같습니다:

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

Testing

GraphQL 스키마 생성

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

bundle exec rake gitlab:graphql:schema:dump

이 작업은 상위 리포지터리에서 가져온 후 또는 브랜치를 리베이스할 때 실행해야 합니다. 이것은 gdk update의 일부로 자동으로 실행됩니다.

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

Apollo Client 모의(Mocking)

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

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

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

Vue.use(VueApollo);

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

이후, 모의 Apollo 공급자를 생성해야 합니다:

import createMockApollo from 'helpers/mock_apollo_helper';

describe('Apollo 모의를 사용하는 일부 컴포넌트', () => {
  let wrapper;
  let mockApollo;
  
  function createComponent(options = {}) {
    mockApollo = createMockApollo(...)
    
    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }
  
  afterEach(() => {
    // 테스트 사이에 공급자가 지속되지 않도록 해야 합니다
    mockApollo = null
  })
})

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

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

describe('Apollo 모의를 사용하는 일부 컴포넌트', () => {
  let wrapper;
  let mockApollo;
  
  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse)
  }) {
    mockApollo = createMockApollo([
       [getDesignListQuery, options.designListHandler],
       [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
       [moveDesignMutation, jest.fn().mockResolvedValue(moveDesignMutationResponse)],
    ])
    
    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }
})

해결된 값들을 모의로 사용할 때는 실제 API 응답과 동일한 구조로 유지해야 합니다. 예를 들어, 루트 속성은 data여야 합니다:

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

쿼리를 테스트하는 경우, 프로미스이므로 결과를 렌더링하려면 _해결_되어야 합니다. 해결되지 않은 경우, 쿼리의 loading 상태를 확인할 수 있습니다:

it('로딩 상태를 렌더링합니다', () => {
  const wrapper = createComponent();
  
  expect(wrapper.findComponent(LoadingSpinner).exists()).toBe(true)
});

it('디자인 디렉터리을 렌더링합니다', async () => {
  const wrapper = createComponent();
  
  await waitForPromises()
  
  expect(findDesigns()).toHaveLength(3);
});

쿼리 오류를 테스트해야 하는 경우, 거부된 값의 모킹된 핸들러를 사용하여 예상되는대로 동작하는지 확인해야 합니다:

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

마찬가지로 뮤테이션도 동일한 방식으로 테스트할 수 있습니다:

  const moveDesignHandlerSuccess = jest.fn().mockResolvedValue(moveDesignMutationResponse)
  
  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse),
    moveDesignHandler: moveDesignHandlerSuccess
  }) {
    mockApollo = createMockApollo([
       [getDesignListQuery, options.designListHandler],
       [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)],
       [moveDesignMutation, moveDesignHandler],
    ])
    
    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }

it('올바른 매개변수로 뮤테이션을 호출하고 디자인을 다시 정렬합니다', async () => {
  const wrapper = createComponent();
  
  wrapper.find(VueDraggable).vm.$emit('change', {
    moved: {
      newIndex: 0,
      element: designToMove,
    },
  });
  
  expect(moveDesignHandlerSuccess).toHaveBeenCalled();
  
  await waitForPromises();
  
  expect(
    findDesigns()
      .at(0)
      .props('id'),
  ).toBe('2');
});

여러 쿼리 응답 상태를 모의에서 테스트할 경우, 성공과 실패가 번갈아가며 나타나는 Apollo Client의 기본 재시도 동작을 모의 함수와 조합하여 생성할 수 있습니다. 이러한 응답들은 매뉴얼으로 진행하지 않아도 되지만 특정한 방식으로 대기해야 합니다.

describe('쿼리 시간 초과 시', () => {
  const advanceApolloTimers = async () => {
    jest.runOnlyPendingTimers();
    await waitForPromises()
  };
  
  beforeEach(async () => {
    const failSucceedFail = jest
      .fn()
      .mockResolvedValueOnce({ errors: [{ message: 'timeout' }] })
      .mockResolvedValueOnce(mockPipelineResponse)
      .mockResolvedValueOnce({ errors: [{ message: 'timeout' }] });
    
    createComponentWithApollo(failSucceedFail);
    await waitForPromises();
  });
  
  it('올바른 오류를 표시하고 데이터가 비어 있을 때 채워 넣지 않음', async () => {
    /* 첫 번째 실패, 오류 표시, 아직 데이터 없음 */
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(false);
    
    /* 성공, 오류 제거, 그래프 표시 */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(false);
    expect(getGraph().exists()).toBe(true);
    
    /* 다시 실패, 알림 반환하지만 데이터는 유지됨 */
    await advanceApolloTimers();
    expect(getAlert().exists()).toBe(true);
    expect(getGraph().exists()).toBe(true);
  });
});

이전에는 mount{ mocks: { $apollo ...}}를 사용하여 Apollo 기능을 테스트했습니다. 이 접근 방식은 비추천됩니다. 올바른 $apollo 모의는 테스트에 많은 구현 세부 정보를 노출시킵니다. 이것을 모의된 Apollo 공급자로 대체하는 것을 고려해야 합니다.

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

구독 테스트

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

import waitForPromises from 'helpers/wait_for_promises';

// subscriptionMock은 구독의 핸들러 함수로 등록되어 있습니다.
// helper에서
const subcriptionMock = jest.fn().mockResolvedValue(okResponse);

// ...

it('오류 상태 테스트', () => {
  // 피하세요: 아래에서 멈출 것입니다!
  subscriptionMock = jest.fn().mockRejectedValue({ errors: [] });
  
  // 구성요소가 구독 모의를 호출하는 부분으로
  createComponent();
  // 영원히 멈출 것이다:
  // * 거부된 프라미스가 재구독을 트리거할 것입니다
  // * 재구독은 다시 subscriptionMock을 호출하여 거부된 프라미스를 반환하게 됩니다
  // * 거부된 프라미스가 다음 재구독을 트리거할 것입니다
  await waitForPromises();
  // ...
})

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

it('실패 테스트', () => {
  // OK: 구독은 한 번 실패할 것입니다
  subscriptionMock.mockRejectedValueOnce({ errors: [] });
  // 구성요소가 구독 모의를 호출하는 부분으로
  createComponent();
  await waitForPromises();
  
  // 이제 아래 코드가 실행될 것입니다
})

@client 쿼리 테스트

모의 리졸버 사용

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

console.warn() 호출 삽입하지 않은 것:  
경고: mock-apollo-client - 쿼리가 완전히 클라이언트 측( @client 지시문 사용)이고 리졸버가 구성되어 있습니다. 요청 핸들러는 호출되지 않을 것입니다.

이를 해결하려면 모의 핸들러 대신 모의 리졸버를 정의해야 합니다. 예를 들어, 다음 @client 쿼리와 실제 클라이언트 측 리졸버가 주어진 경우:

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

그리고 실제 클라이언트 측 리졸버:

import Api from '~/api';

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

export default resolvers;

데이터의 모양이 동일한 모의 리졸버를 사용하여 데이터를 반환하는 모의 함수:

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

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

const createComponentWithApollo = ({ props = {} } = {}) => {
  mockApollo = createMockApollo([], mockResolvers); // 두 번째 매개변수로 리졸버를 전달
  
  wrapper = shallowMount(MyComponent, {
    propsData: {},
    apolloProvider: mockApollo,
    // ...
  })
};

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

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

it('데이터 표시', async() => {
  mockBlobContentData.mockResolvedValue(data); // 원하는 결과를 트리거하거나 거부할 수 있습니다
  
  createComponentWithApollo();
  
  await waitForPromises(); // 리졸버 모의가 실행될 때까지 기다립니다
  
  expect(findContent().text()).toBe(mockCiYml);
});
cache.writeQuery 사용

때로는 로컬 쿼리의 result 후크를 테스트하고 싶을 수 있습니다. 이를 트리거하려면 해당 쿼리로 검색할 데이터와 함께 캐시를 채워야 합니다.

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

describe('모의 Apollo 가진 컴포넌트', () => {
  let wrapper;
  let mockApollo;
  
  function createComponent(options = {
    designListHandler: jest.fn().mockResolvedValue(designListQueryResponse)
  }) {
    mockApollo = createMockApollo([...])
    mockApollo.clients.defaultClient.cache.writeQuery({
      query: fetchLocalUserQuery,
      data: {
        fetchLocalUser: {
          __typename: 'User',
          name: '테스트',
        },
      },
    });
    
    wrapper = shallowMount(SomeComponent, {
      apolloProvider: mockApollo
    });
  }
})

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

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

오류 처리

GitLab의 GraphQL 뮤테이션에는 최상위 오류 및 데이터 오류 두 가지 구분된 오류 모드가 있습니다.

GraphQL 뮤테이션을 사용할 때, 오류가 발생했을 경우 적절한 피드백을 사용자가 받도록 두 가지 오류 모드를 모두 처리하는 것이 좋습니다.

최상위 오류

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

최상위 오류 처리

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

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

데이터 오류

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

데이터 오류 처리

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

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

이제 이 뮤테이션을 실행하고 오류가 발생할 경우, 응답에 errors가 포함될 것입니다.

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

데이터 오류를 처리할 때, 응답에 있는 오류 메시지를 사용자에게 표시할지, 다른 클라이언트 측에서 정의된 메시지를 사용자에게 표시할지를 판단하는 것이 좋습니다.

Vue 바깥에서의 사용

Vue 바깥에서도 GraphQL을 직접 가져와서 사용할 수 있습니다.

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

const defaultClient = createDefaultClient();

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

VueX와 함께 사용할 때에는 다음 경우에는 캐시를 비활성화해야 합니다.

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

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

초기 GraphQL 시작 쿼리로 초기 조회 개설

성능을 개선하기 위해 때로는 초기에 GraphQL 쿼리를 실행하고 싶을 때가 있습니다. 이를 위해 시작 쿼리에 다음 단계를 따라 추가할 수 있습니다:

  • 애플리케이션 초기에 필요한 모든 쿼리를 app/graphql/queries로 이동합니다.
  • 각 중첩된 쿼리 수준에 __typename 속성을 추가합니다:

    query getPermissions($projectPath: ID!) {
      project(fullPath: $projectPath) {
        __typename
        userPermissions {
          __typename
          pushCode
          forkProject
          createMergeRequestIn
        }
      }
    }
    
  • 쿼리에 프래그먼트가 포함되어 있다면, import 대신 직접 쿼리 파일에 프래그먼트를 이동해야 합니다:

    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 시작 쿼리를 추가하려면, 첫 번째 매개변수는 쿼리에 대한 경로이고, 두 번째 매개변수는 쿼리 변수를 포함하는 객체입니다. 쿼리의 경로는 app/graphql/queries 폴더를 기준으로 상대적입니다: 예를 들어, app/graphql/queries/repository/files.query.graphql 쿼리가 필요하다면, 해당 경로는 repository/files입니다.

문제 해결

모킹된 클라이언트가 목 응답이 아닌 빈 객체를 반환하는 경우

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

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

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

콘솔에 때로는 다음과 같은 경고가 표시될 수 있습니다: Query 객체의 someProperty 필드를 대체할 때 일부 캐시 데이터가 손실될 수 있습니다. 이 문제를 해결하려면, SomeEntity의 모든 객체에 id 또는 사용자 정의 Merge 함수를 포함하도록 하거나 동일한 객체에 대한 여러 쿼리 섹션을 확인하여 문제를 해결하세요.

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