InfoGrab DocsInfoGrab Docs

백엔드 GraphQL API 가이드

요약

이 문서는 GitLab GraphQL API의 백엔드를 구현하는 엔지니어를 위한 스타일과 기술 가이드를 담고 있습니다. GraphQL 및 REST API 섹션을 참고합니다. GraphQL API는 버전이 없습니다. GraphQL API에는 버전이 없지만, 업데이트 간 하위 호환성과 그로 인해 발생할 수 있는 인시던트(예: 일부 사용자에게 사이드바가 로드되지 않은 사례)를 고려해야 합니다.

이 문서는 GitLab GraphQL API의 백엔드를 구현하는 엔지니어를 위한 스타일과 기술 가이드를 담고 있습니다.

REST API와의 관계#

GraphQL 및 REST API 섹션을 참고합니다.

버전 관리#

GraphQL API는 버전이 없습니다.

다중 버전 호환성#

GraphQL API에는 버전이 없지만, 업데이트 간 하위 호환성과 그로 인해 발생할 수 있는 인시던트(예: 일부 사용자에게 사이드바가 로드되지 않은 사례)를 고려해야 합니다.

완화 방법#

인시던트 위험을 줄이려면 GitLab Self-Managed와 GitLab Dedicated에서 @gl_introduced 디렉티브를 사용해 노드가 도입된 GitLab 마일스톤을 태그합니다. 백엔드는 태그된 마일스톤을 자체 마일스톤 (패치를 제외한 GitLab 버전의 major.minor)과 비교하여 노드를 어떻게 처리할지 결정합니다.

태그된 마일스톤과 백엔드 마일스톤 비교 동작
백엔드보다 이전 필드가 반드시 존재해야 합니다. 필드가 없으면 undefinedField 오류가 발생합니다.
백엔드의 마일스톤 또는 그 이후 스키마에 필드가 있으면 정상적으로 리졸브되고, 없으면 null을 반환합니다.

백엔드는 버전 문자열만으로는 마일스톤 도중에 필드가 존재하는지 알 수 없습니다. GitLab.com은 -pre 빌드를 실행하고 CI는 오래된 머지 리퀘스트 브랜치를 실행하므로, 두 백엔드가 같은 마일스톤을 보고하더라도 그중 하나에만 필드가 있을 수 있습니다. 그래서 백엔드 자체 마일스톤일 때는 오류 대신 null로 대체합니다. 오타가 있거나 제거된 필드는 백엔드가 태그된 마일스톤을 지나갈 때까지만 null을 반환하고, 그 이후에는 다시 오류가 발생합니다.

새 필드에는 해당 머지 리퀘스트가 머지되는 마일스톤을 태그합니다.

예를 들어 19.4 마일스톤을 태그한 필드는 다음과 같습니다.

newField @gl_introduced(version: "19.4.0")

이 필드는 다음과 같이 동작합니다.

백엔드 버전 결과
19.3.x null
19.4.0-pre, 필드의 MR 배포됨 정상 값
19.4.0-pre, 필드의 MR 배포되지 않음 null
19.5.x, 필드 존재 정상 값
19.5.x, 필드 제거되었거나 철자가 틀림 undefinedField 오류

이 디렉티브는 태그된 노드의 전체 하위 트리에 적용됩니다. 백엔드가 태그된 노드를 제거하면 그 안의 어떤 것도 검증하지 않습니다. 하위 트리 안의 알 수 없는 필드나 타입은 오류 대신 null을 반환합니다.

@gl_introduced 디렉티브는 모든 필드에 사용할 수 있습니다. 예를 들어 다음과 같습니다.

fragment otherFieldsWithFuture on Namespace {
  webUrl
  otherFutureField @gl_introduced(version: "99.9.9")
}

query namespaceWithFutureFields {
  futureField @gl_introduced(version: "99.9.9")
  namespace(fullPath: "gitlab-org") {
    name
    futureField @gl_introduced(version: "99.9.9")
    ...otherFieldsWithFuture
  }
}

응답은 다음과 같습니다.

{
  "data": {
    "futureField": null,
    "namespace": {
      "name": "Gitlab Org",
      "futureField": null,
      "webUrl": "http://gdk.test:3000/groups/gitlab-org",
      "otherFutureField": null
    }
  }
}

이 디렉티브를 다음 대상에는 사용하지 않아야 합니다.

  • 인수: 실행 가능한 디렉티브는 인수를 지원하지 않습니다.
  • 프래그먼트: 대신 프래그먼트 안의 노드에 디렉티브를 사용합니다.
Null을 허용하지 않는 필드#

미래의 필드는 백엔드에 존재하지 않으면 null로 대체됩니다. 따라서 @gl_introduced 디렉티브가 있는 Null을 허용하지 않는 필드도 프론트엔드에서 null 검사가 여전히 필요합니다.

GitLab에서 GraphQL 배우기#

GitLab에서 GraphQL을 배우려는 백엔드 엔지니어는 이 가이드를 GraphQL Ruby gem 가이드와 함께 읽어야 합니다. 해당 가이드에서는 gem의 기능을 설명하며, 그 내용은 일반적으로 이 문서에서 반복하지 않습니다.

GraphQL 자체의 설계와 기능을 알아보려면 graphql.org의 가이드를 읽습니다. 이 가이드는 GraphQL 사양의 정보를 이해하기 쉽게 줄인 버전입니다.

딥 다이브#

2019년 3월에 Nick Thomas는 GitLab GraphQL API에 대한 딥 다이브(GitLab 팀 구성원 전용: https://gitlab.com/gitlab-org/create-stage/issues/1)를 진행하여 앞으로 코드베이스의 이 부분에서 작업할 수 있는 모든 사람과 도메인 지식을 공유했습니다. 다음 자료를 확인할 수 있습니다. YouTube 녹화본과 슬라이드는 Google Slides 및 PDF에서 확인할 수 있습니다. 세부 사항은 그 이후 달라졌지만, 여전히 좋은 입문 자료로 활용할 수 있습니다.

GitLab의 GraphQL 구현 방식#

Robert Mosolgo가 작성한 GraphQL Ruby gem을 사용합니다. 또한 GraphQL Pro를 구독하고 있습니다. 자세한 내용은 GraphQL Pro 구독을 참고합니다.

모든 GraphQL 쿼리는 단일 엔드포인트 (app/controllers/graphql_controller.rb#execute)로 전달되며, 이 엔드포인트는 /api/graphql의 API 엔드포인트로 노출됩니다.

GraphiQL#

GraphiQL은 기존 쿼리를 직접 실행해 볼 수 있는 대화형 GraphQL API 탐색기입니다. 모든 GitLab 환경에서 https://<your-gitlab-site.com>/-/graphql-explorer로 접근할 수 있습니다. 예를 들어 GitLab.com의 탐색기가 있습니다.

GraphQL 변경이 포함된 머지 리퀘스트 리뷰#

GraphQL 프레임워크에는 주의해야 할 특정한 함정이 있으며, 이를 충족하려면 도메인 전문 지식이 필요합니다.

GraphQL 파일을 수정하거나 엔드포인트를 추가하는 머지 리퀘스트의 리뷰를 요청받았다면 GraphQL 리뷰 가이드를 살펴봅니다.

GraphQL 로그 읽기#

GraphQL 요청의 로그를 살펴보고 GraphQL 쿼리의 성능을 모니터링하는 방법은 GraphQL 로그 읽기 가이드를 참고합니다.

해당 페이지에는 다음과 같은 팁이 있습니다.

  • 더 이상 사용되지 않는 필드의 사용량 확인
  • 쿼리가 프론트엔드에서 왔는지 여부 식별

인증#

인증은 GraphqlController를 통해 이루어지며, 현재 Rails 애플리케이션과 동일한 인증을 사용합니다. 따라서 세션을 공유할 수 있습니다.

쿼리 문자열에 private_token을 추가하거나 HTTP_PRIVATE_TOKEN 헤더를 추가하는 것도 가능합니다.

제한#

GraphQL API에는 여러 제한이 적용되며, 이 중 일부는 개발자가 재정의할 수 있습니다.

최대 페이지 크기#

기본적으로 커넥션은 페이지당 app/graphql/gitlab_schema.rb에 정의된 최대 레코드 수까지만 반환할 수 있습니다.

개발자는 커넥션을 정의할 때 사용자 지정 최대 페이지 크기를 지정할 수 있습니다.

최대 복잡도#

복잡도는 고객용 API 페이지에 설명되어 있습니다.

필드는 기본적으로 쿼리의 복잡도 점수에 1을 더하지만, 개발자는 필드를 정의할 때 사용자 지정 복잡도를 지정할 수 있습니다.

쿼리의 복잡도 점수는 직접 쿼리로 조회할 수도 있습니다.

요청 타임아웃#

요청은 30초 후에 타임아웃됩니다.

최대 필드 호출 횟수 제한#

경우에 따라 N+1 쿼리 문제가 발생하고 최적의 해결책이 없어서 여러 부모 노드에서 특정 필드가 평가되는 것을 막아야 할 때가 있습니다. 이 방법은 최후의 수단으로 간주해야 하며, 룩어헤드로 연관 관계 미리 로드나 배칭 사용 같은 방법을 검토한 뒤에만 사용합니다.

예를 들면 다음과 같습니다.

# This usage is expected.
query {
  project {
    environments
  }
}

# This usage is NOT expected.
# It results in N+1 query problem. EnvironmentsResolver can't use GraphQL batch loader in favor of GraphQL pagination.
query {
  projects {
    nodes {
      environments
    }
  }
}

이를 방지하려면 필드에 Gitlab::Graphql::Limit::FieldCallCount 확장을 사용합니다.

# This allows maximum 1 call to the `environments` field. If the field is evaluated on more than one node,
# it raises an error.
field :environments do
        extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
      end

또는 리졸버 클래스에 확장을 적용할 수도 있습니다.

module Resolvers
  class EnvironmentsResolver < BaseResolver
    extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
    # ...
  end
end

이 제한을 추가할 때는 영향을 받는 필드의 description도 그에 맞게 업데이트해야 합니다. 예를 들면 다음과 같습니다.

field :environments,
      description: 'Environments of the project. This field can only be resolved for one project in any single request.'

호환성을 깨는 변경#

GitLab GraphQL API는 버전이 없으므로 개발자는 사용 중단 및 제거 프로세스를 숙지해야 합니다.

호환성을 깨는 변경은 다음과 같습니다.

  • 필드, 인수, 열거형 값 또는 뮤테이션을 제거하거나 이름을 변경하는 경우
  • 인수의 타입이나 타입 이름을 변경하는 경우. 인수의 타입은 변수를 사용할 때 클라이언트가 선언하므로, 변경하면 이전 타입 이름을 사용한 쿼리가 API에서 거부됩니다.
  • 필드나 열거형 값의 스칼라 타입을 변경하여 값이 JSON으로 직렬화되는 방식이 달라지는 경우. 예를 들어 JSON String에서 JSON Number로 바뀌거나 String의 형식이 달라지는 경우입니다. 다른 객체 타입으로 변경하는 것은 해당 객체의 모든 스칼라 타입 필드가 동일한 방식으로 계속 직렬화되는 한 허용될 수 있습니다.
  • 필드의 복잡도나 리졸버의 복잡도 승수를 높이는 경우
  • Nullable 필드에서 설명한 대로, 필드를 null 불가(null: false)에서 nullable(null: true)로 변경하는 경우
  • 인수를 선택 사항(required: false)에서 필수(required: true)로 변경하는 경우
  • 커넥션의 최대 페이지 크기를 변경하는 경우
  • 쿼리 복잡도와 깊이의 전역 제한을 낮추는 경우
  • 이전에는 허용되던 쿼리가 제한에 걸리게 될 수 있는 그 밖의 모든 변경

항목을 사용 중단하는 방법은 스키마 항목 사용 중단 섹션을 참고합니다.

호환성을 깨는 변경의 예외#

GraphQL API 호환성을 깨는 변경 예외 문서를 참고합니다.

글로벌 ID#

GitLab GraphQL API는 글로벌 ID(예: "gid://gitlab/MyObject/123")를 사용하며 데이터베이스 기본 키 ID는 절대 사용하지 않습니다.

글로벌 ID는 클라이언트 측 라이브러리에서 캐싱과 페칭에 사용하는 관례입니다.

다음도 참고합니다.

값이 GlobalID일 때 입력 및 출력 인수의 타입으로 사용해야 하는 사용자 지정 스칼라 타입(Types::GlobalIDType)이 있습니다. ID 대신 이 타입을 사용하면 다음과 같은 이점이 있습니다.

  • 값이 GlobalID인지 검증합니다.
  • 사용자 코드에 전달하기 전에 GlobalID로 파싱합니다.
  • 객체의 타입으로 매개변수화할 수 있어(예: GlobalIDType[Project]) 더 나은 검증과 보안을 제공합니다.

모든 새 인수와 결과 타입에 이 타입을 사용하는 것을 고려합니다. 이 타입을 concern이나 상위 타입으로 매개변수화하는 것도 충분히 가능하다는 점을 기억합니다. 더 넓은 범위의 객체를 허용하려는 경우에 유용합니다(예: GlobalIDType[Issuable]과 GlobalIDType[Issue]).

최적화#

기본적으로 GraphQL은 적극적으로 최소화하려 하지 않으면 N+1 문제를 일으키기 쉽습니다.

안정성과 확장성을 위해 쿼리에 N+1 성능 문제가 없도록 해야 합니다.

다음은 GraphQL 코드를 최적화하는 데 도움이 되는 도구 목록입니다.

  • 룩어헤드를 사용하면 쿼리에서 선택된 필드를 기준으로 데이터를 미리 로드할 수 있습니다.
  • 배치 로딩을 사용하면 데이터베이스 쿼리를 하나로 묶어 한 번의 구문으로 실행할 수 있습니다.
  • BatchModelLoader는 배치 로딩을 활용하여 ID로 레코드를 조회하는 권장 방법입니다.
  • before_connection_authorization을 사용하면 타입 인가 권한 검사에 특화된 N+1 문제를 해결할 수 있습니다.
  • 최대 필드 호출 횟수 제한을 사용하면 최적화를 더 개선할 수 없는 경우 필드가 데이터를 반환할 수 있는 횟수를 제한할 수 있습니다.

개발 중 N+1 문제 확인 방법#

기능을 개발하는 동안 다음과 같은 방법으로 N+1 문제를 발견할 수 있습니다.

  • 데이터 컬렉션을 반환하는 GraphQL 쿼리를 실행하는 동안 development.log를 tail로 확인합니다. Bullet이 도움이 될 수 있습니다.
  • GitLab UI에서 쿼리를 실행하는 경우 성능 바를 확인합니다.
  • 기능에 N+1 문제가 없거나 제한적임을 검증하는 요청 스펙을 추가합니다.

필드#

타입#

코드 우선 스키마를 사용하며, 모든 타입을 Ruby로 선언합니다.

예를 들어 app/graphql/types/project_type.rb는 다음과 같습니다.

graphql_name 'Project'

field :full_path, GraphQL::Types::ID, null: true
field :name, GraphQL::Types::String, null: true

각 타입에 이름을 지정합니다(이 경우 Project).

full_path와 name은 스칼라 GraphQL 타입입니다. full_path는 GraphQL::Types::ID입니다 (GraphQL::Types::ID를 사용하는 경우 참고). name은 일반적인 GraphQL::Types::String 타입입니다. 스칼라 데이터 타입(예: TimeType)에는 사용자 지정 GraphQL 데이터 타입도 선언할 수 있습니다.

GraphQL API를 통해 모델을 노출할 때는 app/graphql/types에 새 타입을 만듭니다.

타입에서 속성을 노출할 때는 정의 안의 로직을 가능한 한 최소한으로 유지합니다. 대신 로직을 프레젠터로 옮기는 것을 고려합니다.

class Types::MergeRequestType < BaseObject
  present_using MergeRequestPresenter

  name 'MergeRequest'
end

기존 프레젠터를 사용할 수도 있고, GraphQL 전용으로 새 프레젠터를 만들 수도 있습니다.

프레젠터는 필드가 리졸브한 객체와 컨텍스트를 사용해 초기화됩니다.

Nullable 필드#

GraphQL에서는 필드를 "nullable" 또는 "non-nullable"로 지정할 수 있습니다. 전자는 지정된 타입의 값 대신 null이 반환될 수 있다는 뜻입니다. 일반적으로 다음 이유로 non-nullable 필드보다 nullable 필드를 사용하는 것이 좋습니다.

  • 데이터가 필수에서 선택으로, 또 그 반대로 바뀌는 일이 흔합니다.
  • 필드가 선택 사항이 될 가능성이 없더라도 쿼리 시점에 사용할 수 없을 수 있습니다.
    • 예를 들어 blob의 content는 Gitaly에서 조회해야 할 수 있습니다.
    • content가 nullable이면 쿼리 전체를 실패시키는 대신 부분 응답을 반환할 수 있습니다.
  • 버전이 없는 스키마에서는 non-nullable 필드를 nullable 필드로 바꾸기가 어렵습니다.

non-nullable 필드는 필드가 필수이고, 앞으로 선택 사항이 될 가능성이 매우 낮으며, 계산이 간단할 때만 사용해야 합니다. 예로는 id 필드가 있습니다.

non-nullable GraphQL 스키마 필드는 객체 타입 뒤에 느낌표(bang) !가 붙습니다. 다음은 gitlab_schema.graphql 파일의 예입니다.

  id: ProjectID!

다음은 non-nullable GraphQL 배열의 예입니다.


  errors: [String!]!

더 읽어 볼 자료는 다음과 같습니다.

글로벌 ID 노출#

GitLab의 글로벌 ID 사용 방식에 따라, 데이터베이스 기본 키 ID를 노출할 때는 항상 글로벌 ID로 변환합니다.

id라는 이름의 모든 필드는 자동으로 변환되어 객체의 글로벌 ID가 됩니다.

id가 아닌 이름의 필드는 수동으로 변환해야 합니다. Gitlab::GlobalID.build를 사용하거나, GlobalID::Identification 모듈을 믹스인한 객체에서 #to_global_id를 호출하면 됩니다.

Types::Notes::DiscussionType의 예는 다음과 같습니다.

field :reply_id, Types::GlobalIDType[Discussion]

def reply_id
  Gitlab::GlobalId.build(object, id: object.reply_id)
end

GraphQL::Types::ID를 사용하는 경우#

GraphQL::Types::ID를 사용하면 필드가 GraphQL ID 타입이 되며, JSON 문자열로 직렬화됩니다. 그러나 ID는 클라이언트에게 특별한 의미가 있습니다. GraphQL 사양에는 다음과 같이 나와 있습니다.

The ID scalar type represents a unique identifier, often used to refetch an object or as the key for a cache.

GraphQL 사양은 ID의 고유성 범위를 명확히 정하지 않습니다. GitLab에서는 ID가 최소한 타입 이름 기준으로 고유해야 한다고 정했습니다. 타입 이름은 Types:: 클래스 중 하나의 graphql_name이며, 예를 들어 Project나 Issue입니다.

이에 따르면 다음과 같습니다.

  • Project.fullPath는 API 전체에서 같은 fullPath를 가진 다른 Project가 없고 이 필드도 식별자이므로 ID여야 합니다.
  • Issue.iid는 API 전체에서 같은 iid를 가진 Issue 타입이 여럿 있을 수 있으므로 ID가 아니어야 합니다. 이를 ID로 취급하면 클라이언트가 서로 다른 프로젝트의 Issue 캐시를 가지고 있을 때 문제가 됩니다.
  • Project.id는 해당 ID 값을 가진 Project가 하나뿐이므로 보통 ID가 될 수 있습니다. 다만 데이터베이스 ID 값에는 ID 타입 대신 글로벌 ID 타입을 사용하므로 글로벌 ID로 타입을 지정합니다.

이를 표로 요약하면 다음과 같습니다.

필드 용도 GraphQL::Types::ID를 사용하는가?
Full path ✅
Database ID ❌
IID ❌

markdown_field#

markdown_field는 field를 감싸는 헬퍼 메서드이며, 렌더링된 Markdown을 반환하는 필드에는 항상 이 메서드를 사용해야 합니다.

이 헬퍼는 기존 MarkupHelper를 사용하여 모델의 Markdown 필드를 렌더링하며, GraphQL 쿼리의 컨텍스트를 헬퍼에서 사용할 수 있습니다.

현재 사용자가 볼 수 없는 리소스에 대한 링크를 가리려면 헬퍼에서 컨텍스트를 사용할 수 있어야 합니다.

HTML을 렌더링하면 쿼리가 발생할 수 있으므로, 이러한 필드의 복잡도는 기본값보다 5 높게 설정됩니다.

Markdown 필드 헬퍼는 다음과 같이 사용할 수 있습니다.

markdown_field :note_html, null: false

이렇게 하면 모델의 Markdown 필드 note를 렌더링하는 필드가 생성됩니다. method: 인수를 추가하여 이를 재정의할 수 있습니다.

markdown_field :body_html, null: false, method: :note

이 필드에는 기본적으로 다음 설명이 지정됩니다.

The GitLab Flavored Markdown rendering of note

description: 인수를 전달하여 이를 재정의할 수 있습니다.

커넥션 타입#

Note

구현 세부 사항은 페이지네이션 구현을 참고합니다.

GraphQL은 커서 기반 페이지네이션을 사용하여 항목 컬렉션을 노출합니다. 이를 통해 클라이언트에 높은 유연성을 제공하면서 백엔드가 서로 다른 페이지네이션 모델을 사용할 수 있습니다.

CountableConnectionType과 LimitedCountableConnectionType 중 선택#

GitLab은 카운팅을 지원하는 컬렉션을 위해 두 가지 커넥션 타입을 제공합니다.

  • CountableConnectionType - 기본적으로 정확한 개수를 반환하며, 성능 최적화를 위한 선택적 limit 인수를 지원합니다.
  • LimitedCountableConnectionType - 항상 제한된 개수를 반환합니다(기본 제한: 1000).

CountableConnectionType을 사용하는 경우:

  • 정확한 개수가 사용자 경험에 중요한 경우(예: 이슈의 총 개수 표시)
  • 컬렉션 크기가 일반적으로 작거나 중간 정도인 경우
  • 정확한 개수가 필요하지 않을 때 클라이언트가 limit 인수를 제공하여 성능 최적화를 선택할 수 있는 경우

LimitedCountableConnectionType을 사용하는 경우:

  • 정확한 개수가 사용자 경험에 중요하지 않은 경우
  • 컬렉션이 매우 클 수 있어 모든 항목을 카운트하는 비용이 큰 경우
  • 성능 제한을 기본적으로 적용하려는 경우

두 커넥션 타입 모두 제한이 적용될 때 동일한 기반의 제한된 카운팅 로직을 사용하며, CountableConnectionHelper 모듈을 통해 구현을 공유합니다.

리소스 컬렉션을 노출하려면 커넥션 타입을 사용할 수 있습니다. 커넥션 타입은 배열을 기본 페이지네이션 필드로 감쌉니다. 예를 들어 프로젝트 파이프라인에 대한 쿼리는 다음과 같습니다.

query($project_path: ID!) {
  project(fullPath: $project_path) {
    pipelines(first: 2) {
      pageInfo {
        hasNextPage
        hasPreviousPage
      }
      edges {
        cursor
        node {
          id
          status
        }
      }
    }
  }
}

이 쿼리는 프로젝트의 처음 2개 파이프라인과 관련 페이지네이션 정보를 ID 내림차순으로 반환합니다. 반환되는 데이터는 다음과 같습니다.

{
  "data": {
    "project": {
      "pipelines": {
        "pageInfo": {
          "hasNextPage": true,
          "hasPreviousPage": false
        },
        "edges": [
          {
            "cursor": "Nzc=",
            "node": {
              "id": "gid://gitlab/Pipeline/77",
              "status": "FAILED"
            }
          },
          {
            "cursor": "Njc=",
            "node": {
              "id": "gid://gitlab/Pipeline/67",
              "status": "FAILED"
            }
          }
        ]
      }
    }
  }
}

다음 페이지를 가져오려면 마지막으로 확인된 요소의 커서를 전달하면 됩니다.

query($project_path: ID!) {
  project(fullPath: $project_path) {
    pipelines(first: 2, after: "Njc=") {
      pageInfo {
        hasNextPage
        hasPreviousPage
      }
      edges {
        cursor
        node {
          id
          status
        }
      }
    }
  }
}

일관된 정렬을 보장하기 위해 기본 키에 대한 정렬을 내림차순으로 추가합니다. 기본 키는 보통 id이므로 릴레이션 끝에 order(id: :desc)를 추가합니다. 기반 테이블에 기본 키가 반드시 있어야 합니다.

바로가기 필드#

매개변수가 전달되지 않았을 때 리졸버가 컬렉션의 첫 번째 항목을 반환하도록 하는 "바로가기 필드"를 구현하는 것이 간단해 보일 때가 있습니다. 이러한 "바로가기 필드"는 유지 관리 부담을 만들기 때문에 권장하지 않습니다. 바로가기 필드는 기준이 되는 필드와 동기화 상태를 유지해야 하고, 기준 필드가 변경되면 사용 중단하거나 수정해야 합니다. 다르게 해야 할 설득력 있는 이유가 없다면 프레임워크가 제공하는 기능을 사용합니다.

예를 들어 latest_pipeline 대신 pipelines(last: 1)을 사용합니다.

페이지 크기 제한#

기본적으로 API는 커넥션에서 페이지당 app/graphql/gitlab_schema.rb에 정의된 최대 레코드 수까지만 반환하며, 클라이언트가 제한 인수(first: 또는 last:)를 제공하지 않으면 이 값이 페이지당 반환되는 기본 레코드 수이기도 합니다.

max_page_size 인수를 사용하면 커넥션에 다른 페이지 크기 제한을 지정할 수 있습니다.

Warning

기본값은 GraphQL API의 성능을 유지하기 위해 설정된 값이므로, max_page_size를 높이는 것보다 프론트엔드 클라이언트나 제품 요구 사항을 변경하여 페이지당 많은 수의 레코드가 필요하지 않게 하는 것이 낫습니다.

예를 들면 다음과 같습니다.

field :tags,
  Types::ContainerRegistry::ContainerRepositoryTagType.connection_type,
  null: true,
  description: 'Tags of the container repository',
  max_page_size: 20

필드 복잡도#

GitLab GraphQL API는 지나치게 복잡한 쿼리의 실행을 제한하기 위해 복잡도 점수를 사용합니다. 복잡도는 이 주제에 관한 고객용 문서에 설명되어 있습니다.

복잡도 제한은 app/graphql/gitlab_schema.rb에 정의되어 있습니다.

기본적으로 필드는 쿼리의 복잡도 점수에 1을 더합니다. 이는 필드에 사용자 지정 complexity 값을 제공하여 재정의할 수 있습니다.

데이터를 반환하기 위해 서버가 더 많은 _작업_을 수행해야 하는 필드에는 더 높은 복잡도를 지정해야 합니다. 대부분의 경우 id나 title처럼 작업이 거의 또는 전혀 필요하지 않고 반환할 수 있는 데이터를 나타내는 필드에는 복잡도 0을 지정할 수 있습니다.

calls_gitaly#

리졸브할 때 Gitaly 호출을 수행할 가능성이 있는 필드는 정의할 때 field에 calls_gitaly: true를 전달하여 그렇게 표시해야 합니다.

예를 들면 다음과 같습니다.

field :blob, type: Types::Snippets::BlobType,
      description: 'Snippet blob',
      null: false,
      calls_gitaly: true

이렇게 하면 필드의 complexity 점수가 1 증가합니다.

리졸버가 Gitaly를 호출하는 경우 BaseResolver.calls_gitaly!로 주석을 달 수 있습니다. 이렇게 하면 해당 리졸버를 사용하는 모든 필드에 calls_gitaly: true가 전달됩니다.

예를 들면 다음과 같습니다.

class BranchResolver < BaseResolver
  type ::Types::BranchType, null: true
  calls_gitaly!

  argument name: ::GraphQL::Types::String, required: true

  def resolve(name:)
    object.branch(name)
  end
end

이후 이 리졸버를 사용하면 BranchResolver를 사용하는 모든 필드가 올바른 calls_gitaly: 값을 갖게 됩니다.

타입의 권한 노출#

현재 사용자가 리소스에 대해 가진 권한을 노출하려면 리소스의 권한을 나타내는 별도의 타입을 전달하여 expose_permissions를 호출할 수 있습니다.

예를 들면 다음과 같습니다.

module Types
  class MergeRequestType < BaseObject
    expose_permissions Types::MergeRequestPermissionsType
  end
end

권한 타입은 BasePermissionType을 상속하며, 여기에는 권한을 non-nullable 불리언으로 노출할 수 있는 몇 가지 헬퍼 메서드가 포함되어 있습니다.

class MergeRequestPermissionsType < BasePermissionType
  graphql_name 'MergeRequestPermissions'

  present_using MergeRequestPresenter

  abilities :admin_merge_request, :update_merge_request, :create_note

  ability_field :resolve_note,
                description: 'Indicates the user can resolve discussions on the merge request.'
  permission_field :push_to_source_branch, method: :can_push_to_source_branch?
end
  • permission_field: graphql-ruby의 field 메서드와 동일하게 동작하되, 기본 설명과 타입을 설정하고 non-nullable로 만듭니다. 이러한 옵션은 인수로 추가하여 여전히 재정의할 수 있습니다.
  • ability_field: 정책에 정의된 ability를 노출합니다. 이것은 permission_field와 동일한 방식으로 동작하며 동일한 인수를 재정의할 수 있습니다.
  • abilities: 정책에 정의된 여러 ability를 한 번에 노출할 수 있습니다. 이러한 필드는 모두 기본 설명을 가진 non-nullable 불리언이어야 합니다.

기능 플래그#

GraphQL에서 기능 플래그를 구현하여 다음을 전환할 수 있습니다.

  • 필드의 반환 값
  • 인수나 뮤테이션의 동작

이는 선호도와 상황에 따라 리졸버, 타입 또는 모델 메서드에서 구현할 수 있습니다.

Note

기능 플래그 뒤에 있는 동안에는 항목을 실험으로 표시하는 것도 권장합니다. 이는 공개 GraphQL API 사용자에게 해당 필드를 아직 사용하면 안 된다는 신호를 줍니다. 또한 사용 중단하지 않고도 실험 항목을 언제든지 변경하거나 제거할 수 있습니다. 플래그를 제거할 때는 experiment 속성을 제거하여 스키마 항목을 "릴리스"하고 공개로 전환합니다.

기능 플래그가 적용된 항목의 설명#

기능 플래그를 사용하여 스키마 항목의 값이나 동작을 전환하는 경우 해당 항목의 description에는 다음이 포함되어야 합니다.

  • 값이나 동작이 기능 플래그로 전환될 수 있다는 내용
  • 기능 플래그의 이름
  • 기능 플래그가 비활성화되었을 때(또는 더 적절하다면 활성화되었을 때) 필드가 반환하는 값이나 동작

기능 플래그 사용 예시#

기능 플래그가 적용된 필드#

필드 값은 기능 플래그 상태에 따라 전환됩니다. 일반적인 용도는 기능 플래그가 비활성화되었을 때 null을 반환하는 것입니다.

field :foo, GraphQL::Types::String, null: true,
      experiment: { milestone: '10.0' },
      description: 'Some test field. Returns `null`' \
                   'if `my_feature_flag` feature flag is disabled.'

def foo
  object.foo if Feature.enabled?(:my_feature_flag, object)
end

기능 플래그가 적용된 인수#

인수는 기능 플래그 상태에 따라 무시되거나 값이 변경될 수 있습니다. 일반적인 용도는 기능 플래그가 비활성화되었을 때 인수를 무시하는 것입니다.

argument :foo, type: GraphQL::Types::String, required: false,
         experiment: { milestone: '10.0' },
         description: 'Some test argument. Is ignored if ' \
                      '`my_feature_flag` feature flag is disabled.'

def resolve(args)
  args.delete(:foo) unless Feature.enabled?(:my_feature_flag, object)
  # ...
end

기능 플래그가 적용된 뮤테이션#

기능 플래그 상태 때문에 수행할 수 없는 뮤테이션은 복구 불가능한 뮤테이션 오류로 처리됩니다. 오류는 최상위 수준에서 반환됩니다.

description 'Mutates an object. Does not mutate the object if ' \
            '`my_feature_flag` feature flag is disabled.'

def resolve(id: )
  object = authorized_find!(id: id)

  raise_resource_not_available_error! '`my_feature_flag` feature flag is disabled.' \
    if Feature.disabled?(:my_feature_flag, object)
  # ...
end

스키마 항목 사용 중단#

GitLab GraphQL API는 버전이 없으므로 모든 변경에서 이전 버전의 API와 하위 호환성을 유지합니다.

필드, 인수, 열거형 값, 뮤테이션을 제거하는 대신 사용 중단 처리해야 합니다.

사용 중단된 스키마 부분은 이후 릴리스에서 GitLab 사용 중단 프로세스에 따라 제거할 수 있습니다.

GraphQL에서 스키마 항목을 사용 중단하려면 다음을 수행합니다.

  1. 해당 항목의 사용 중단 이슈를 생성합니다.
  2. 스키마에서 항목을 사용 중단으로 표시합니다.

다음도 참고합니다.

사용 중단 이슈 생성#

모든 GraphQL 사용 중단에는 사용 중단과 제거를 추적하기 위해 Deprecations 이슈 템플릿을 사용하여 생성한 사용 중단 이슈가 있어야 합니다.

사용 중단 이슈에 다음 두 레이블을 적용합니다.

  • ~GraphQL
  • ~deprecation

항목을 사용 중단으로 표시#

필드, 인수, 열거형 값, 뮤테이션은 deprecated 속성을 사용하여 사용 중단합니다. 속성의 값은 다음으로 구성된 Hash입니다.

  • reason - 사용 중단 사유
  • milestone - 필드가 사용 중단된 마일스톤

예시:

field :token, GraphQL::Types::String, null: true,
      deprecated: { reason: 'Login via token has been removed', milestone: '10.0' },
      description: 'Token for login.'

사용 중단되는 항목의 원래 description은 유지해야 하며, 사용 중단을 언급하도록 업데이트해서는 안 됩니다. 대신 reason이 description 뒤에 덧붙여집니다.

사용 중단 사유 스타일 가이드#

필드, 인수 또는 열거형 값이 다른 것으로 대체되어 사용 중단하는 경우 reason에 대체 항목을 명시해야 합니다. 예를 들어 대체된 필드의 reason은 다음과 같습니다.

Use `otherFieldName`

예시:

field :designs, ::Types::DesignManagement::DesignCollectionType, null: true,
      deprecated: { reason: 'Use `designCollection`', milestone: '10.0' },
      description: 'The designs associated with this issue.',
module Types
  class TodoStateEnum < BaseEnum
    value 'pending', deprecated: { reason: 'Use PENDING', milestone: '10.0' }
    value 'done', deprecated: { reason: 'Use DONE', milestone: '10.0' }
    value 'PENDING', value: 'pending'
    value 'DONE', value: 'done'
  end
end

사용 중단되는 필드, 인수 또는 열거형 값이 대체되지 않는 경우 사용 중단 reason에 그 이유를 설명하는 내용을 적어야 합니다.

글로벌 ID 사용 중단#

글로벌 ID를 생성하고 파싱하기 위해 rails/globalid gem을 사용하므로, 글로벌 ID는 모델 이름에 종속됩니다. 모델 이름을 변경하면 해당 글로벌 ID도 변경됩니다.

글로벌 ID가 스키마의 어느 곳에서든 인수 타입으로 사용된다면, 글로벌 ID 변경은 일반적으로 호환성을 깨는 변경에 해당합니다.

이전 글로벌 ID 인수를 사용하는 클라이언트를 계속 지원하려면 Gitlab::GlobalId::Deprecations에 사용 중단을 추가합니다.

Note

글로벌 ID가 필드로만 노출된다면 사용 중단할 필요가 없습니다. 필드에서 글로벌 ID를 표현하는 방식의 변경은 하위 호환된다고 간주합니다. 클라이언트가 이 값을 파싱하지 않을 것으로 기대합니다. 이 값은 불투명한 토큰으로 취급되어야 하며, 그 안의 구조는 부수적인 것이므로 의존해서는 안 됩니다.

예시 시나리오:

이 예시 시나리오는 이 머지 리퀘스트를 기반으로 합니다.

PrometheusService라는 모델의 이름을 Integrations::Prometheus로 변경하려고 합니다. 이전 모델 이름은 뮤테이션의 인수로 사용되는 글로벌 ID 타입을 만드는 데 사용됩니다.

# Mutations::UpdatePrometheus:

argument :id, Types::GlobalIDType[::PrometheusService],
              required: true,
              description: "The ID of the integration to mutate."

클라이언트는 PrometheusServiceID로 명명되고 "gid://gitlab/PrometheusService/1"처럼 생긴 글로벌 ID 문자열을 input.id 인수로 전달하여 뮤테이션을 호출합니다.

mutation updatePrometheus($id: PrometheusServiceID!, $active: Boolean!) {
  prometheusIntegrationUpdate(input: { id: $id, active: $active }) {
    errors
    integration {
      active
    }
  }
}

모델 이름을 Integrations::Prometheus로 변경한 다음 코드베이스를 새 이름으로 업데이트합니다. 뮤테이션을 업데이트할 때는 이름이 변경된 모델을 Types::GlobalIDType[]에 전달합니다.

# Mutations::UpdatePrometheus:

argument :id, Types::GlobalIDType[::Integrations::Prometheus],
              required: true,
              description: "The ID of the integration to mutate."

이렇게 하면 API가 id 인수를 "gid://gitlab/PrometheusService/1"로 전달하거나 쿼리 시그니처에서 인수 타입을 PrometheusServiceID로 지정하는 클라이언트를 이제 거부하므로 뮤테이션에 호환성을 깨는 변경이 발생합니다.

클라이언트가 뮤테이션을 변경 없이 계속 사용할 수 있도록 하려면 Gitlab::GlobalId::Deprecations의 DEPRECATIONS 상수를 편집하고 배열에 새 Deprecation을 추가합니다.

DEPRECATIONS = [
  Gitlab::Graphql::DeprecationsBase::NameDeprecation.new(old_name: 'PrometheusService', new_name: 'Integrations::Prometheus', milestone: '14.0')
].freeze

그런 다음 일반 사용 중단 프로세스를 따릅니다. 이후에 이전 인수 방식의 지원을 제거하려면 Deprecation을 제거합니다.

DEPRECATIONS = [].freeze

사용 중단 기간 동안 API는 인수 값에 대해 다음 두 형식을 모두 허용합니다.

  • "gid://gitlab/PrometheusService/1"
  • "gid://gitlab/Integrations::Prometheus/1"

API는 인수의 쿼리 시그니처에서도 다음 타입을 허용합니다.

  • PrometheusServiceID
  • IntegrationsPrometheusID
Note

이전 타입(이 예시에서는 PrometheusServiceID)을 사용하는 쿼리는 API에서 유효하고 실행 가능한 것으로 간주되지만, 검증 도구는 유효하지 않은 것으로 간주합니다. @deprecated 디렉티브 밖의 별도 방식으로 사용 중단하기 때문에 유효하지 않은 것으로 간주되며, 검증 도구는 이 지원을 인식하지 못합니다.

문서에는 이전 글로벌 ID 방식이 이제 사용 중단되었다고 명시합니다.

스키마 항목을 실험으로 표시#

GraphQL 스키마 항목(필드, 인수, 열거형 값, 뮤테이션)을 실험으로 표시할 수 있습니다.

실험으로 표시된 항목은 사용 중단 프로세스에서 제외되며 공지 없이 언제든지 제거될 수 있습니다. 항목이 변경될 수 있고 공개 사용에 적합하지 않은 경우 실험으로 표시합니다.

Note

새 항목만 실험으로 표시합니다. 기존 항목은 이미 공개되어 있으므로 실험으로 표시해서는 안 됩니다.

스키마 항목을 실험으로 표시하려면 experiment: 키워드를 사용합니다. 실험 항목을 도입한 milestone:을 제공해야 합니다.

예를 들면 다음과 같습니다.

field :token, GraphQL::Types::String, null: true,
      experiment: { milestone: '10.0' },
      description: 'Token for login.'

마찬가지로 app/graphql/types/mutation_type.rb에서 뮤테이션이 마운트되는 위치를 업데이트하여 뮤테이션 전체를 실험으로 표시할 수도 있습니다.

mount_mutation Mutations::Ci::JobArtifact::BulkDestroy, experiment: { milestone: '15.10' }

실험적 GraphQL 항목은 GraphQL 사용 중단 기능을 활용하는 GitLab 고유 기능입니다. 실험 항목은 GraphQL 스키마에서 사용 중단된 것으로 표시됩니다. 사용 중단된 다른 모든 스키마 항목과 마찬가지로 대화형 GraphQL 탐색기(GraphiQL)에서 실험 필드를 테스트할 수 있습니다. 다만 GraphiQL 자동 완성 편집기는 사용 중단된 필드를 제안하지 않는다는 점에 유의합니다.

이 항목은 생성된 GraphQL 문서와 GraphQL 스키마 설명에서 experiment로 표시됩니다.

열거형#

GitLab GraphQL 열거형은 app/graphql/types에 정의됩니다. 새 열거형을 정의할 때는 다음 규칙이 적용됩니다.

  • 값은 대문자여야 합니다.
  • 클래스 이름은 문자열 Enum으로 끝나야 합니다.
  • graphql_name에는 문자열 Enum이 포함되면 안 됩니다.

예를 들면 다음과 같습니다.

module Types
  class TrafficLightStateEnum < BaseEnum
    graphql_name 'TrafficLightState'
    description 'State of a traffic light'

    value 'RED', description: 'Drivers must stop.'
    value 'YELLOW', description: 'Drivers must stop when it is safe to.'
    value 'GREEN', description: 'Drivers can start or keep driving.'
  end
end

열거형이 Ruby에서 대문자 문자열이 아닌 클래스 속성에 사용되는 경우 대문자 값에 맞춰 주는 value: 옵션을 제공할 수 있습니다.

다음 예시에서는 다음과 같이 동작합니다.

  • OPENED라는 GraphQL 입력은 'opened'로 변환됩니다.
  • 'opened'라는 Ruby 값은 GraphQL 응답에서 "OPENED"로 변환됩니다.
module Types
  class EpicStateEnum < BaseEnum
    graphql_name 'EpicState'
    description 'State of a GitLab epic'

    value 'OPENED', value: 'opened', description: 'An open Epic.'
    value 'CLOSED', value: 'closed', description: 'A closed Epic.'
  end
end

열거형 값은 deprecated 키워드를 사용하여 사용 중단할 수 있습니다.

Rails 열거형에서 GraphQL 열거형을 동적으로 정의#

GraphQL 열거형이 Rails 열거형을 기반으로 하는 경우 Rails 열거형을 사용하여 GraphQL 열거형 값을 동적으로 정의하는 것을 고려합니다. 이렇게 하면 GraphQL 열거형 값이 Rails 열거형 정의에 연결되므로, Rails 열거형에 값이 추가되면 GraphQL 열거형에도 변경 사항이 자동으로 반영됩니다.

예시:

module Types
  class IssuableSeverityEnum < BaseEnum
    graphql_name 'IssuableSeverity'
    description 'Incident severity'

    ::IssuableSeverity.severities.each_key do |severity|
      value severity.upcase, value: severity, description: "#{severity.titleize} severity."
    end
  end
end

JSON#

GraphQL이 반환할 데이터가 JSON으로 저장되어 있더라도, 가능하면 계속 GraphQL 타입을 사용해야 합니다. 반환되는 JSON 데이터가 정말로 구조가 없는 경우가 아니라면 GraphQL::Types::JSON 타입은 사용하지 않습니다.

JSON 데이터의 구조가 다양하지만 알려진 몇 가지 가능한 구조 중 하나라면 유니온을 사용합니다. 이 목적으로 유니온을 사용한 예로는 !30129가 있습니다.

필요한 경우 hash_key: 키워드를 사용하여 필드 이름을 해시 데이터 키에 매핑할 수 있습니다.

예를 들어 다음과 같은 JSON 데이터가 있다고 가정합니다.

{
  "title": "My chart",
  "data": [
    { "x": 0, "y": 1 },
    { "x": 1, "y": 1 },
    { "x": 2, "y": 2 }
  ]
}

다음과 같이 GraphQL 타입을 사용할 수 있습니다.

module Types
  class ChartType < BaseObject
    field :title, GraphQL::Types::String, null: true, description: 'Title of the chart.'
    field :data, [Types::ChartDatumType], null: true, description: 'Data of the chart.'
  end
end

module Types
  class ChartDatumType < BaseObject
    field :x, GraphQL::Types::Int, null: true, description: 'X-axis value of the chart datum.'
    field :y, GraphQL::Types::Int, null: true, description: 'Y-axis value of the chart datum.'
  end
end

설명#

모든 필드와 인수에는 설명이 있어야 합니다.

필드나 인수의 설명은 description: 키워드로 지정합니다. 예를 들면 다음과 같습니다.

field :id, GraphQL::Types::ID, description: 'ID of the issue.'
field :confidential, GraphQL::Types::Boolean, description: 'Indicates the issue is confidential.'
field :closed_at, Types::TimeType, description: 'Timestamp of when the issue was closed.'

필드와 인수의 설명은 다음에서 확인할 수 있습니다.

설명 스타일 가이드#

언어와 문장부호#

필드와 인수를 설명할 때는 가능하면 {x} of the {y} 형식을 사용합니다. 여기서 {x}는 설명하는 항목이고 {y}는 그 항목이 적용되는 리소스입니다. 예를 들면 다음과 같습니다.

ID of the issue.
Author of the epics.

정렬하거나 검색하는 인수는 적절한 동사로 시작합니다. 지정된 값을 나타낼 때는 간결하게 하기 위해 the given이나 the specified 대신 this를 사용할 수 있습니다. 예를 들면 다음과 같습니다.

Sort issues by this criteria.

일관성과 간결성을 위해 설명을 The나 A로 시작하지 않습니다.

모든 설명은 마침표(.)로 끝냅니다.

불리언#

불리언 필드(GraphQL::Types::Boolean)는 그 필드가 하는 일을 설명하는 동사로 시작합니다. 예를 들면 다음과 같습니다.

Indicates the issue is confidential.

필요한 경우 기본값을 제공합니다. 예를 들면 다음과 같습니다.

Sets the issue to confidential. Default is false.

정렬 열거형#

정렬용 열거형의 설명은 'Values for sorting {x}.'여야 합니다. 예를 들면 다음과 같습니다.

Values for sorting container repositories.

Types::TimeType 필드 설명#

Types::TimeType GraphQL 필드에는 timestamp라는 단어를 포함합니다. 이렇게 하면 독자가 해당 속성의 형식이 단순한 Date가 아니라 Time임을 알 수 있습니다.

예를 들면 다음과 같습니다.

field :closed_at, Types::TimeType, description: 'Timestamp of when the issue was closed.'

copy_field_description 헬퍼#

두 설명이 항상 동일하도록 보장하고 싶을 때가 있습니다. 예를 들어 타입 필드 설명을 같은 속성을 나타내는 뮤테이션 인수의 설명과 동일하게 유지하려는 경우입니다.

설명을 직접 지정하는 대신 copy_field_description 헬퍼를 사용할 수 있으며, 설명을 복사할 타입과 필드 이름을 전달합니다.

예시:

argument :title, GraphQL::Types::String,
          required: false,
          description: copy_field_description(Types::MergeRequestType, :title)

문서 참조#

설명에서 외부 URL을 참조하고 싶을 때가 있습니다. 이를 더 쉽게 하고 생성되는 참조 문서에 올바른 마크업을 제공하기 위해 필드에 see 속성을 제공합니다. 예를 들면 다음과 같습니다.

field :genus,
      type: GraphQL::Types::String,
      null: true,
      description: 'A taxonomic genus.'
      see: { 'Wikipedia page on genera' => 'https://wikipedia.org/wiki/Genus' }

이는 문서에서 다음과 같이 렌더링됩니다.

A taxonomic genus. See: [Wikipedia page on genera](https://wikipedia.org/wiki/Genus)

문서 참조를 여러 개 제공할 수 있습니다. 이 속성의 구문은 키가 텍스트 설명이고 값이 URL인 HashMap입니다.

구독 티어 배지#

필드나 인수를 다른 필드보다 높은 구독 티어에서만 사용할 수 있는 경우 가용성 세부 정보를 인라인으로 추가합니다.

예를 들면 다음과 같습니다.

description: 'Full path of a custom template. Premium and Ultimate only.'

인가#

GraphQL 인가를 참고합니다.

리졸버#

app/graphql/resolvers 디렉터리에 저장된 _리졸버_를 사용하여 애플리케이션이 응답을 제공하는 방식을 정의합니다. 리졸버는 해당 객체를 조회하는 실제 구현 로직을 제공합니다.

필드에 표시할 객체를 찾으려면 app/graphql/resolvers에 리졸버를 추가할 수 있습니다.

인수는 뮤테이션과 같은 방식으로 리졸버에서 정의할 수 있습니다. 인수 섹션을 참고합니다.

수행되는 쿼리의 양을 제한하려면 BatchLoader를 사용할 수 있습니다.

리졸버 작성#

코드는 파인더와 서비스를 감싸는 얇은 선언형 래퍼를 지향해야 합니다. 인수 목록을 반복해서 작성하거나 concern으로 추출할 수 있습니다. 대부분의 경우 상속보다 합성을 선호합니다. 리졸버를 컨트롤러처럼 다룹니다. 리졸버는 다른 애플리케이션 추상화를 합성하는 DSL이어야 합니다.

예를 들면 다음과 같습니다.

class PostResolver < BaseResolver
  type Post.connection_type, null: true
  authorize :read_blog
  description 'Blog posts, optionally filtered by name'

  argument :name, [::GraphQL::Types::String], required: false, as: :slug

  alias_method :blog, :object

  def resolve(**args)
    PostFinder.new(blog, current_user, args).execute
  end
end

같은 객체가 노출되는 두 개의 서로 다른 필드처럼 서로 다른 두 곳에서 동일한 리졸버 클래스를 사용할 수는 있지만, 리졸버 객체를 직접 재사용해서는 안 됩니다. 리졸버는 복잡한 수명 주기를 가지며, 인가, 준비 상태, 리졸브가 프레임워크에 의해 조율되고 각 단계에서 배칭 기회를 활용하기 위해 지연 값이 반환될 수 있습니다. 애플리케이션 코드에서 리졸버나 뮤테이션을 인스턴스화해서는 안 됩니다.

대신 코드 재사용 단위는 애플리케이션의 나머지 부분과 거의 같습니다.

  • 데이터를 조회하는 쿼리의 파인더
  • 작업을 적용하는 뮤테이션의 서비스
  • 쿼리에 특화된 로더(배치를 인식하는 파인더)

뮤테이션에서 배칭을 사용할 이유는 전혀 없습니다. 뮤테이션은 순차적으로 실행되므로 배칭 기회가 없습니다. 모든 값은 요청되는 즉시 즉시 평가되므로 배칭은 불필요한 오버헤드입니다. 다음을 작성하는 경우입니다.

  • Mutation은 객체를 직접 조회해도 됩니다.
  • Resolver 또는 BaseObject의 메서드는 배칭을 허용해야 합니다.

오류 처리#

리졸버는 오류를 발생시킬 수 있으며, 이는 적절하게 최상위 오류로 변환됩니다. 예상되는 모든 오류는 잡아서 적절한 GraphQL 오류로 변환해야 합니다( Gitlab::Graphql::Errors 참고). 잡히지 않은 오류는 억제되며 클라이언트는 Internal service error 메시지를 받습니다.

한 가지 특별한 경우는 권한 오류입니다. REST API에서는 사용자가 접근할 권한이 없는 리소스에 대해 404 Not Found를 반환합니다. GraphQL에서 이에 해당하는 동작은 존재하지 않거나 인가되지 않은 모든 리소스에 대해 null을 반환하는 것입니다. 쿼리 리졸버는 인가되지 않은 리소스에 대해 오류를 발생시켜서는 안 됩니다.

그 이유는 클라이언트가 레코드가 없는 경우와 접근 권한이 없는 레코드가 있는 경우를 구별할 수 없어야 하기 때문입니다. 구별할 수 있다면 숨기고 싶은 정보가 유출되므로 보안 취약점이 됩니다.

대부분의 경우 이를 걱정할 필요가 없습니다. authorize DSL 호출로 선언하는 리졸버 필드 인가가 이를 올바르게 처리합니다. 더 맞춤화된 작업이 필요하다면, 필드를 리졸브할 때 current_user가 접근할 수 없는 객체를 만나면 해당 필드 전체가 null로 리졸브되어야 한다는 점을 기억합니다.

리졸버 파생#

(BaseResolver.single 및 BaseResolver.last 포함)

일부 사용 사례에서는 다른 리졸버에서 리졸버를 파생할 수 있습니다. 주된 사용 사례는 모든 항목을 찾는 리졸버 하나와 특정 항목 하나를 찾는 리졸버 하나를 만드는 것입니다. 이를 위해 편의 메서드를 제공합니다.

  • BaseResolver.single: 첫 번째 항목을 선택하는 새 리졸버를 생성합니다.
  • BaseResolver.last: 마지막 항목을 선택하는 리졸버를 생성합니다.

올바른 단수형 타입은 컬렉션 타입에서 추론되므로 여기에서 type을 정의할 필요가 없습니다.

이 메서드를 사용하기 전에 다음 중 하나가 더 간단하지 않은지 검토합니다.

  • 자체 인수를 정의하는 다른 리졸버를 작성합니다.
  • 쿼리를 추상화하는 concern을 작성합니다.

BaseResolver.single을 지나치게 자유롭게 사용하는 것은 안티패턴입니다. 인수가 주어지지 않으면 첫 번째 MR만 반환하는 Project.mergeRequest 필드처럼 무의미한 필드가 만들어질 수 있습니다. 컬렉션 리졸버에서 단일 리졸버를 파생할 때는 항상 더 제한적인 인수를 가져야 합니다.

이를 가능하게 하려면 when_single 블록을 사용하여 단일 리졸버를 사용자 지정합니다. 모든 when_single 블록은 다음을 충족해야 합니다.

  • 인수를 하나 이상 정의(또는 재정의)합니다.
  • 선택적 필터를 필수로 만듭니다.

예를 들어 기존 선택적 인수를 재정의하여 타입을 변경하고 필수로 만들 수 있습니다.

class JobsResolver < BaseResolver
  type JobType.connection_type, null: true
  authorize :read_pipeline

  argument :name, [::GraphQL::Types::String], required: false

  when_single do
    argument :name, ::GraphQL::Types::String, required: true
  end

  def resolve(**args)
    JobsFinder.new(pipeline, current_user, args.compact).execute
  end

여기에는 파이프라인 job을 가져오는 리졸버가 있습니다. name 인수는 목록을 가져올 때는 선택 사항이지만 단일 job을 가져올 때는 필수입니다.

인수가 여러 개이고 어느 쪽도 필수로 만들 수 없는 경우 블록을 사용하여 준비 조건을 추가할 수 있습니다.

class JobsResolver < BaseResolver
  alias_method :pipeline, :object

  type JobType.connection_type, null: true
  authorize :read_pipeline

  argument :name, [::GraphQL::Types::String], required: false
  argument :id, [::Types::GlobalIDType[::Job]],
           required: false,
           prepare: ->(ids, ctx) { ids.map(&:model_id) }

  when_single do
    argument :name, ::GraphQL::Types::String, required: false
    argument :id, ::Types::GlobalIDType[::Job],
             required: false
             prepare: ->(id, ctx) { id.model_id }

    def ready?(**args)
      raise ::Gitlab::Graphql::Errors::ArgumentError, 'Only one argument may be provided' unless args.size == 1
    end
  end

  def resolve(**args)
    JobsFinder.new(pipeline, current_user, args.compact).execute
  end

그런 다음 이러한 리졸버를 필드에서 사용할 수 있습니다.

# In PipelineType

field :jobs, resolver: JobsResolver, description: 'All jobs.'
field :job, resolver: JobsResolver.single, description: 'A single job.'

리졸버 최적화#

룩어헤드#

실행 중에는 전체 쿼리를 미리 알 수 있으므로 룩어헤드를 활용하여 쿼리를 최적화하고 필요한 것으로 알려진 연관 관계를 배치 로드할 수 있습니다. N+1 성능 문제를 피하려면 리졸버에 룩어헤드 지원을 추가하는 것을 고려합니다.

일반적인 룩어헤드 사용 사례(하위 필드가 요청되었을 때 연관 관계 미리 로드)를 지원하려면 LooksAhead를 포함할 수 있습니다. 예를 들면 다음과 같습니다.

# Assuming a model `MyThing` with attributes `[child_attribute, other_attribute, nested]`,
# where nested has an attribute named `included_attribute`.
class MyThingResolver < BaseResolver
  include LooksAhead

  # Rather than defining `resolve(**args)`, we implement: `resolve_with_lookahead(**args)`
  def resolve_with_lookahead(**args)
    apply_lookahead(MyThingFinder.new(current_user).execute)
  end

  # We list things that should always be preloaded:
  # For example, if child_attribute is always needed (during authorization
  # perhaps), then we can include it here.
  def unconditional_includes
    [:child_attribute]
  end

  # We list things that should be included if a certain field is selected:
  def preloads
    {
        field_one: [:other_attribute],
        field_two: [{ nested: [:included_attribute] }]
    }
  end
end

기본적으로 #preloads에 정의된 필드는 쿼리에서 해당 필드가 선택되었을 때 미리 로드됩니다. 때로는 너무 많은 콘텐츠나 잘못된 콘텐츠를 미리 로드하지 않도록 더 세밀한 제어가 필요할 수 있습니다.

위 예시를 확장하여, 특정 필드가 함께 요청되었을 때 다른 연관 관계를 미리 로드하고 싶을 수 있습니다. 이는 #filtered_preloads를 재정의하여 수행할 수 있습니다.

class MyThingResolver < BaseResolver
  # ...

  def filtered_preloads
    return [:alternate_attribute] if lookahead.selects?(:field_one) && lookahead.selects?(:field_two)

    super
  end
end

LooksAhead concern은 중첩된 GraphQL 필드 정의를 기반으로 한 연관 관계 미리 로드도 지원합니다. 중첩 필드가 선택되었을 때 지정한 연관 관계를 미리 로드하려면 필드 이름 배열을 해시 키로 사용합니다. 예를 들면 다음과 같습니다.

class MyThingResolver < BaseResolver
  # ...

  def preloads
    {
      [:root_field, :nested_field1] => :association_to_preload,
      [:root_field, :nested_field2] => [:association1, :association2],
      [:root_field, :nested_field2, :nested_field3] => :association3,
      other_root_field: :other_association,
    }
  end
end

실제 사용 예시는 WorkItems::LookAheadPreloads를 참고합니다.

before_connection_authorization#

before_connection_authorization 훅은 리졸버가 타입 인가 권한 검사에서 비롯되는 N+1 문제를 제거하는 데 도움이 됩니다.

before_connection_authorization 메서드는 리졸브된 노드와 현재 사용자를 받습니다. 블록에서 ActiveRecord::Associations::Preloader 또는 Preloaders:: 클래스를 사용하여 타입 인가 검사에 필요한 데이터를 미리 로드합니다.

예시:

class LabelsResolver < BaseResolver
  before_connection_authorization do |labels, current_user|
    Preloaders::LabelsPreloader.new(labels, current_user).preload_all
  end
end

배치 로딩#

GraphQL BatchLoader를 참고합니다.

Resolver#ready?의 올바른 사용#

리졸버에는 프레임워크의 일부로 두 개의 공개 API 메서드 #ready?(**args)와 #resolve(**args)가 있습니다. #resolve를 호출하지 않고 설정을 수행하거나 조기 반환하려면 #ready?를 사용할 수 있습니다.

#ready?를 사용하기 좋은 경우는 다음과 같습니다.

  • 결과가 있을 수 없음을 미리 알고 있는 경우 Relation.none을 반환합니다.
  • 인스턴스 변수 초기화 같은 설정을 수행합니다(다만 이 경우 지연 초기화 메서드를 고려합니다).

Resolver#ready?(**args) 구현은 다음과 같이 (Boolean, early_return_data)를 반환해야 합니다.

def ready?(**args)
  [false, 'have this instead']
end

이러한 이유로 리졸버를 호출할 때마다(주로 테스트에서, 프레임워크 추상화인 리졸버는 재사용 가능한 것으로 간주해서는 안 되며 파인더를 선호해야 합니다) resolve를 호출하기 전에 ready? 메서드를 호출하고 불리언 플래그를 확인해야 합니다. 예시는 GraphqlHelpers에서 볼 수 있습니다.

인수를 검증할 때는 #ready?를 사용하는 것보다 검증기를 사용하는 것이 좋습니다.

부정 인수#

부정 필터는 일부 리소스를 걸러 낼 수 있습니다(예: bug 레이블이 있지만 bug2 레이블은 지정되지 않은 모든 이슈 찾기). 부정 인수를 전달하는 권장 구문은 not 인수입니다.

issues(labelName: "bug", not: {labelName: "bug2"}) {
  nodes {
    id
    title
  }
}

타입이나 리졸버에서 Gitlab::Graphql::NegatableArguments의 negated 헬퍼를 사용할 수 있습니다. 예를 들면 다음과 같습니다.

extend ::Gitlab::Graphql::NegatableArguments

negated do
  argument :labels, [GraphQL::STRING_TYPE],
            required: false,
            as: :label_name,
            description: 'Array of label names. All resolved merge requests will not have these labels.'
end

메타데이터#

리졸버를 사용할 때 리졸버는 필드 메타데이터의 단일 진실 공급원이 될 수 있으며 그렇게 해야 합니다. 필드 이름을 제외한 모든 필드 옵션을 리졸버에서 선언할 수 있습니다. 여기에는 다음이 포함됩니다.

  • type(필수 - 모든 리졸버에는 타입 어노테이션이 있어야 합니다)
  • extras
  • description
  • Gitaly 어노테이션(calls_gitaly! 사용)

예시:

module Resolvers
  MyResolver < BaseResolver
    type Types::MyType, null: true
    extras [:lookahead]
    description 'Retrieve a single MyType'
    calls_gitaly!
  end
end

부모 객체를 자식 프레젠터에 전달#

필드를 계산하기 위해 자식 컨텍스트에서 리졸브된 쿼리의 부모에 접근해야 할 때가 있습니다. 일반적으로 부모는 Resolver 클래스에서만 parent로 사용할 수 있습니다.

Presenter 클래스에서 부모 객체를 찾으려면 다음을 수행합니다.

  1. 리졸버의 resolve 메서드에서 부모 객체를 GraphQL context에 추가합니다.

      def resolve(**args)
        context[:parent_object] = parent
      end
    
  2. 리졸버나 필드에 parent 필드 컨텍스트가 필요하다고 선언합니다. 예를 들면 다음과 같습니다.

      # in ChildType
      field :computed_field, SomeType, null: true,
            method: :my_computing_method,
            extras: [:parent], # Necessary
            description: 'My field description.'
    
      field :resolver_field, resolver: SomeTypeResolver
    
      # In SomeTypeResolver
    
      extras [:parent]
      type SomeType, null: true
      description 'My field description.'
    
  3. 프레젠터 클래스에서 필드의 메서드를 선언하고 parent 키워드 인수를 받도록 합니다. 이 인수에는 부모 GraphQL 컨텍스트가 들어 있으므로 부모 객체는 parent[:parent_object] 또는 Resolver에서 사용한 키로 접근해야 합니다.

      # in ChildPresenter
      def my_computing_method(parent:)
        # do something with `parent[:parent_object]` here
      end
    
      # In SomeTypeResolver
    
      def resolve(parent:)
        # ...
      end
    

실제 사용 예시는 IterationPresenter에 scopedPath와 scopedUrl을 추가한 이 MR을 확인합니다.

뮤테이션#

뮤테이션은 저장된 값을 변경하거나 동작을 트리거하는 데 사용됩니다. GET 요청이 데이터를 수정해서는 안 되는 것과 같이 일반 GraphQL 쿼리에서는 데이터를 수정할 수 없습니다. 하지만 뮤테이션에서는 수정할 수 있습니다.

뮤테이션 작성#

뮤테이션은 app/graphql/mutations에 저장되며, 서비스와 마찬가지로 수정하는 리소스별로 그룹화하는 것이 이상적입니다. 뮤테이션은 Mutations::BaseMutation을 상속해야 합니다. 뮤테이션에 정의된 필드가 뮤테이션의 결과로 반환됩니다.

업데이트 뮤테이션 세분성#

GitLab의 서비스 지향 아키텍처에서는 대부분의 뮤테이션이 Create, Delete 또는 Update 서비스(예: UpdateMergeRequestService)를 호출합니다. Update 뮤테이션에서는 객체의 한 측면만 업데이트하려 할 수 있으며, 그런 경우 MergeRequest::SetDraft 같은 세분화된 뮤테이션만 필요합니다.

세분화된 뮤테이션과 포괄적인 뮤테이션을 모두 두는 것은 허용되지만, 세분화된 뮤테이션이 너무 많으면 유지 관리성, 코드 이해도, 테스트 측면에서 조직적인 어려움으로 이어질 수 있다는 점에 유의합니다. 각 뮤테이션에는 새 클래스가 필요하며 이는 기술 부채로 이어질 수 있습니다. 또한 스키마가 매우 커진다는 뜻이며, 사용자가 스키마를 탐색하기 어려워질 수 있습니다. 새 뮤테이션마다 테스트(더 느린 요청 통합 테스트 포함)도 필요하므로 뮤테이션을 추가하면 테스트 스위트가 느려집니다.

변경을 최소화하려면 다음을 수행합니다.

  • 가능하면 MergeRequest::Update 같은 기존 뮤테이션을 사용합니다.
  • 기존 서비스를 포괄적인 뮤테이션으로 노출합니다.

세분화된 뮤테이션이 더 적절할 수 있는 경우는 다음과 같습니다.

  • 특정 권한이나 기타 특수한 로직이 필요한 속성을 수정하는 경우
  • 상태 머신과 같은 전환(이슈 잠금, MR 머지, 에픽 닫기 등)을 노출하는 경우
  • 중첩 속성을 허용하는 경우(자식 객체의 속성을 허용하는 경우)
  • 뮤테이션의 의미를 명확하고 간결하게 표현할 수 있는 경우

자세한 배경은 이슈 #233063을 참고합니다.

명명 규칙#

각 뮤테이션은 GraphQL 스키마에서 뮤테이션의 이름인 graphql_name을 정의해야 합니다.

예시:

class UserUpdateMutation < BaseMutation
  graphql_name 'UserUpdate'
end

graphql-ruby gem 1.13 버전의 변경으로 인해 타입 이름이 올바르게 생성되도록 graphql_name이 클래스의 첫 번째 줄에 있어야 합니다. Graphql::GraphqlNamePosition cop이 이를 강제합니다. 자세한 배경은 이슈 #27536을 참고합니다.

GraphQL 뮤테이션 이름은 역사적으로 일관성이 없지만, 새 뮤테이션 이름은 '{Resource}{Action}' 또는 '{Resource}{Action}{Attribute}' 관례를 따라야 합니다.

새 리소스를 생성하는 뮤테이션은 동사 Create를 사용해야 합니다.

예시:

  • CommitCreate

데이터를 업데이트하는 뮤테이션은 다음을 사용해야 합니다.

  • 동사 Update
  • 더 적절한 경우 Set, Add, Toggle 같은 도메인 특화 동사

예시:

  • EpicTreeReorder
  • IssueSetWeight
  • IssueUpdate
  • TodoMarkDone

데이터를 제거하는 뮤테이션은 다음을 사용해야 합니다.

  • Destroy 대신 동사 Delete
  • 더 적절한 경우 Remove 같은 도메인 특화 동사

예시:

  • AwardEmojiRemove
  • NoteDelete

뮤테이션 이름에 관한 조언이 필요하면 Slack #graphql 채널에서 의견을 구합니다.

필드#

가장 일반적인 상황에서 뮤테이션은 2개의 필드를 반환합니다.

  • 수정되는 리소스
  • 작업을 수행할 수 없었던 이유를 설명하는 오류 목록. 뮤테이션이 성공했다면 이 목록은 비어 있습니다.

모든 새 뮤테이션이 Mutations::BaseMutation을 상속하면 errors 필드가 자동으로 추가됩니다. clientMutationId 필드도 추가되며, 클라이언트는 단일 요청에서 여러 뮤테이션을 수행할 때 이 필드로 개별 뮤테이션의 결과를 식별할 수 있습니다.

resolve 메서드#

리졸버 작성과 마찬가지로, 뮤테이션의 resolve 메서드는 서비스를 감싸는 얇은 선언형 래퍼를 지향해야 합니다.

resolve 메서드는 뮤테이션의 인수를 키워드 인수로 받습니다. 여기에서 리소스를 수정하는 서비스를 호출할 수 있습니다.

그런 다음 resolve 메서드는 뮤테이션에 정의된 것과 같은 필드 이름과 errors 배열을 포함한 해시를 반환해야 합니다. 예를 들어 Mutations::MergeRequests::SetDraft는 merge_request 필드를 정의합니다.

field :merge_request,
      Types::MergeRequestType,
      null: true,
      description: "The merge request after mutation."

이는 이 뮤테이션에서 resolve가 반환하는 해시가 다음과 같아야 한다는 뜻입니다.

{
  # The merge request modified, this will be wrapped in the type
  # defined on the field
  merge_request: merge_request,
  # An array of strings if the mutation failed after authorization.
  # The `errors_on_object` helper collects `errors.full_messages`
  errors: errors_on_object(merge_request)
}

뮤테이션 마운트#

뮤테이션을 사용할 수 있게 하려면 graphql/types/mutation_type에 저장된 뮤테이션 타입에 뮤테이션을 정의해야 합니다. mount_mutation 헬퍼 메서드는 뮤테이션의 GraphQL 이름을 기반으로 필드를 정의합니다.

module Types
  class MutationType < BaseObject
    graphql_name 'Mutation'

    include Gitlab::Graphql::MountMutation

    mount_mutation Mutations::MergeRequests::SetDraft
  end
end

이렇게 하면 Mutations::MergeRequests::SetDraft가 리졸브되도록 하는 mergeRequestSetDraft라는 필드가 생성됩니다.

리소스 인가#

뮤테이션 안에서 리소스를 인가하려면 먼저 다음과 같이 뮤테이션에 필요한 ability를 제공합니다.

module Mutations
  module MergeRequests
    class SetDraft < Base
      graphql_name 'MergeRequestSetDraft'

      authorize :update_merge_request
    end
  end
end

그런 다음 resolve 메서드에서 authorize!를 호출하여 ability를 검증할 리소스를 전달할 수 있습니다.

또는 뮤테이션에서 객체를 로드하는 find_object 메서드를 추가할 수 있습니다. 이렇게 하면 authorized_find! 헬퍼 메서드를 사용할 수 있습니다.

사용자가 해당 작업을 수행할 수 없거나 인가 때문에 리소스를 찾을 수 없는 경우(사용자가 접근할 수 없음), resolve 메서드 안에서 raise_resource_not_available_error!를 호출하여 Gitlab::Graphql::Errors::ResourceNotAvailable을 발생시켜야 합니다. 사용자 입력 검증 오류(예: 잘못된 프로젝트 경로나 형식이 올바르지 않은 식별자)는 대신 뮤테이션 페이로드의 errors 배열로 반환하여 클라이언트가 사용자에게 의미 있는 메시지를 표시할 수 있도록 합니다. 자세한 내용은 뮤테이션의 오류를 참고합니다.

뮤테이션의 오류#

뮤테이션에는 데이터로서의 오류 방식을 따를 것을 권장하며, 이 방식은 오류를 누구와 관련 있는지, 즉 누가 처리할 수 있는지에 따라 구분합니다.

핵심 사항:

  • 모든 뮤테이션 응답에는 errors 필드가 있습니다. 실패 시에는 이 필드를 채워야 하며, 성공 시에도 채울 수 있습니다.
  • 오류를 누가 봐야 하는지 고려합니다. 사용자인지 개발자인지입니다.
  • 클라이언트는 뮤테이션을 수행할 때 항상 errors 필드를 요청해야 합니다.
  • 오류는 $root.errors(최상위 오류) 또는 $root.data.mutationName.errors(뮤테이션 오류)에서 사용자에게 보고될 수 있습니다. 위치는 오류의 종류와 담고 있는 정보에 따라 달라집니다.
  • 뮤테이션 필드에는 null: true가 있어야 합니다.

errors: [String]과 thing: ThingType이라는 두 필드가 있는 응답을 반환하는 예시 뮤테이션 doTheThing을 생각해 봅니다. 여기서는 오류를 다루므로 thing 자체의 구체적인 성격은 이 예시와 관련이 없습니다.

뮤테이션 응답이 가질 수 있는 세 가지 상태는 다음과 같습니다.

성공#

정상 경로에서는 예상되는 페이로드와 함께 오류가 반환될 수 있지만, 모든 것이 성공했다면 사용자에게 알려야 할 문제가 없으므로 errors는 빈 배열이어야 합니다.

{
  data: {
    doTheThing: {
      errors: [] // if successful, this array will generally be empty.
      thing: { .. }
    }
  }
}

실패(사용자와 관련 있음)#

사용자에게 영향을 미치는 오류가 발생했습니다. 이를 _뮤테이션 오류_라고 합니다.

create 뮤테이션에서는 일반적으로 반환할 thing이 없습니다.

update 뮤테이션에서는 thing의 현재 실제 상태를 반환합니다. 개발자는 이를 보장하기 위해 thing 인스턴스에서 #reset을 호출해야 할 수 있습니다.

{
  data: {
    doTheThing: {
      errors: ["you cannot touch the thing"],
      thing: { .. }
    }
  }
}

다음이 그 예시입니다.

  • 모델 검증 오류: 사용자가 입력을 변경해야 할 수 있습니다.
  • 권한 오류: 사용자는 이 작업을 할 수 없다는 것을 알아야 하며, 권한을 요청하거나 로그인해야 할 수 있습니다.
  • 사용자의 작업을 막는 애플리케이션 상태 문제(예: 머지 충돌이나 잠긴 리소스)

이상적으로는 사용자가 이 단계까지 오지 않도록 막아야 하지만, 오게 되었다면 사용자가 실패의 이유와 의도를 달성하기 위해 무엇을 할 수 있는지 이해할 수 있도록 무엇이 잘못되었는지 알려야 합니다. 예를 들어 요청을 다시 시도하기만 하면 될 수도 있습니다.

복구 가능한 오류를 뮤테이션 데이터와 함께 반환할 수 있습니다. 예를 들어 사용자가 파일 10개를 업로드했는데 3개는 실패하고 나머지는 성공한 경우, 실패에 대한 오류를 성공에 대한 정보와 함께 사용자에게 제공할 수 있습니다.

실패(사용자와 관련 없음)#

복구할 수 없는 오류를 _최상위 수준_에서 하나 이상 반환할 수 있습니다. 이러한 오류는 사용자가 거의 또는 전혀 제어할 수 없는 것이며, 주로 개발자가 알아야 하는 시스템 또는 프로그래밍 문제여야 합니다. 이 경우 data가 없습니다.

{
  errors: [
    {"message": "argument error: expected an integer, got null"},
  ]
}

이는 뮤테이션 중에 오류를 발생시켜서 생깁니다. 현재 구현에서는 인수 오류와 검증 오류의 메시지는 클라이언트에 반환되고, 그 외 모든 StandardError 인스턴스는 잡아서 기록한 뒤 메시지를 "Internal server error"로 설정하여 클라이언트에 표시합니다. 자세한 내용은 GraphqlController를 참고합니다.

이는 다음과 같은 프로그래밍 오류를 나타냅니다.

  • String 대신 Int가 전달되었거나 필수 인수가 없는 GraphQL 구문 오류
  • non-nullable 필드에 값을 제공할 수 없는 경우와 같은 스키마 오류
  • 시스템 오류: 예를 들어 Git 스토리지 예외나 데이터베이스 사용 불가

사용자가 일반적인 사용 중에 이러한 오류를 일으킬 수 있어서는 안 됩니다. 이 범주의 오류는 내부 오류로 취급하며, 사용자에게 구체적인 내용을 보여 주지 않습니다.

뮤테이션이 실패했을 때 사용자에게 알려야 하지만, 사용자가 원인을 제공한 것이 아니고 사용자가 할 수 있는 일로 해결되지도 않으므로 이유까지 알릴 필요는 없습니다. 다만 뮤테이션을 다시 시도하도록 제안할 수는 있습니다.

오류 분류#

뮤테이션을 작성할 때는 오류 상태가 이 두 범주 중 어디에 속하는지 의식해야 합니다(그리고 가정을 검증하기 위해 프론트엔드 개발자와 이에 대해 소통합니다). 이는 _사용자_의 요구와 _클라이언트_의 요구를 구별한다는 뜻입니다.

사용자가 알아야 하는 경우가 아니라면 오류를 절대 잡지 않습니다.

사용자가 알아야 한다면 프론트엔드 개발자와 소통하여 우리가 돌려주는 오류 정보가 관련이 있고 목적에 맞는지 확인합니다.

프론트엔드 GraphQL 가이드도 참고합니다.

뮤테이션 별칭 지정 및 사용 중단#

#mount_aliased_mutation 헬퍼를 사용하면 뮤테이션을 MutationType에서 다른 이름의 별칭으로 지정할 수 있습니다.

예를 들어 FooMutation이라는 뮤테이션을 BarMutation의 별칭으로 지정하려면 다음과 같습니다.

mount_aliased_mutation 'BarMutation', Mutations::FooMutation

이를 deprecated 인수와 함께 사용하면 뮤테이션 이름을 변경하면서 이전 이름도 계속 지원할 수 있습니다.

예시:

mount_aliased_mutation 'UpdateFoo',
                        Mutations::Foo::Update,
                        deprecated: { reason: 'Use fooUpdate', milestone: '13.2' }

사용 중단된 뮤테이션은 Types::DeprecatedMutations에 추가하고 Types::MutationType의 단위 테스트에서 테스트해야 합니다. 머지 리퀘스트 !34798을 사용 중단된 별칭 뮤테이션의 테스트 방법을 포함한 예시로 참고할 수 있습니다.

EE 뮤테이션 사용 중단#

EE 뮤테이션도 같은 프로세스를 따라야 합니다. 머지 리퀘스트 프로세스의 예시는 머지 리퀘스트 !42588을 읽어 봅니다.

서브스크립션#

서브스크립션을 사용하여 클라이언트에 업데이트를 푸시합니다. Action Cable 구현을 사용하여 웹소켓으로 메시지를 전달합니다.

클라이언트가 서브스크립션을 구독하면 해당 쿼리를 Puma 워커의 메모리에 저장합니다. 이후 서브스크립션이 트리거되면 Puma 워커가 저장된 GraphQL 쿼리를 실행하고 결과를 클라이언트에 푸시합니다.

Note

서브스크립션은 Action Cable 클라이언트가 필요한데 GraphiQL이 현재 이를 지원하지 않으므로 GraphiQL로 서브스크립션을 테스트할 수 없습니다.

서브스크립션 작성#

Types::SubscriptionType 아래의 모든 필드는 클라이언트가 구독할 수 있는 서브스크립션입니다. 이러한 필드에는 서브스크립션 클래스가 필요하며, 이 클래스는 Subscriptions::BaseSubscription의 하위 클래스이고 app/graphql/subscriptions 아래에 저장됩니다.

구독에 필요한 인수와 반환되는 필드는 서브스크립션 클래스에 정의됩니다. 인수가 같고 같은 필드를 반환하는 경우 여러 필드가 동일한 서브스크립션 클래스를 공유할 수 있습니다.

이 클래스는 최초 구독 요청과 이후 업데이트 중에 실행됩니다. 자세한 내용은 GraphQL Ruby 가이드에서 확인할 수 있습니다.

인가#

최초 구독과 이후 업데이트가 인가되도록 서브스크립션 클래스의 #authorized? 메서드를 구현해야 합니다.

사용자가 인가되지 않은 경우 실행이 중단되고 사용자의 구독이 해제되도록 unauthorized! 헬퍼를 호출해야 합니다.

글로벌 ID나 구독 대상 객체를 기준으로 권한을 확인하는 일반적인 경우에는 #authorize_object_or_gid! 헬퍼를 사용합니다. 최초 구독에는 객체가 없으므로 주어진 글로벌 ID를 사용해 객체를 가져옵니다. 그러나 이후 업데이트에서는 같은 객체의 다른 인스턴스를 가져오지 않도록 사용자에게 반환하는 객체를 사용합니다. object 인수를 사용하여 인가할 객체를 지정할 수도 있습니다.

서브스크립션 트리거#

서브스크립션을 트리거하려면 GraphqlTriggers 모듈 아래에 메서드를 정의합니다. 단일 진실 공급원을 유지하고 서로 다른 인수와 객체로 서브스크립션이 트리거되지 않도록 애플리케이션 코드에서 GitlabSchema.subscriptions.trigger를 직접 호출하지 않습니다.

페이지네이션 구현#

자세한 내용은 GraphQL 페이지네이션을 참고합니다.

인수#

리졸버나 뮤테이션의 인수는 argument를 사용하여 정의합니다.

예시:

argument :my_arg, GraphQL::Types::String,
         required: true,
         description: "A description of the argument."

loads:를 사용하지 않기#

인수 정의에서 loads: 옵션을 사용하지 않습니다. "not found"와 "not authorized"에 대해 서로 다른 오류를 반환하여 리소스 존재 여부에 관한 정보가 유출됩니다. 대신 글로벌 ID를 받아 authorized_find!로 객체를 직접 로드합니다. 자세한 내용과 예시는 인수 정의에서 loads:를 사용하지 않기를 참고합니다.

Null 허용 여부#

인수는 required: true로 표시할 수 있으며, 이는 값이 반드시 있어야 하고 null이 아니어야 한다는 뜻입니다. 필수 인수의 값이 null일 수 있는 경우 required: :nullable 선언을 사용합니다.

예시:

argument :due_date,
         Types::TimeType,
         required: :nullable,
         description: 'The desired due date for the issue. Due date is removed if null.'

위 예시에서 due_date 인수는 반드시 지정해야 하지만, GraphQL 사양과 달리 값은 null일 수 있습니다. 이를 통해 마감일을 제거하는 새 뮤테이션을 만들지 않고 단일 뮤테이션에서 마감일을 '해제'할 수 있습니다.

{ due_date: null } # => OK
{ due_date: "2025-01-10" } # => OK
{  } # => invalid (not given)

Null 허용 여부와 required: false#

인수가 required: false로 표시되면 클라이언트가 값으로 null을 보내는 것이 허용됩니다. 이는 바람직하지 않은 경우가 많습니다.

인수가 선택 사항이지만 null이 허용되는 값이 아닌 경우, 검증을 사용하여 null을 전달하면 오류가 반환되도록 합니다.

argument :name, GraphQL::Types::String,
         required: false,
         validates: { allow_null: false }

또는 허용되지 않는 값인 null을 허용하려는 경우 기본값으로 대체할 수 있습니다.

argument :name, GraphQL::Types::String,
         required: false,
         default_value: "No Name Provided",
         replace_null_with_default: true

자세한 내용은 검증, Nullability, 기본값을 참고합니다.

상호 배타적 인수#

인수를 상호 배타적으로 표시하여 동시에 제공되지 않도록 할 수 있습니다. 나열된 인수가 둘 이상 주어지면 최상위 오류가 추가됩니다.

예시:

argument :user_id, GraphQL::Types::String, required: false
argument :username, GraphQL::Types::String, required: false

validates mutually_exclusive: [:user_id, :username]

인수가 정확히 하나만 필요한 경우 exactly_one_of 검증기를 사용할 수 있습니다.

예시:

argument :group_path, GraphQL::Types::String, required: false
argument :project_path, GraphQL::Types::String, required: false

validates exactly_one_of: [:group_path, :project_path]

키워드#

정의된 각 GraphQL argument는 뮤테이션의 #resolve 메서드에 키워드 인수로 전달됩니다.

예시:

def resolve(my_arg:)
  # Perform mutation ...
end

입력 타입#

graphql-ruby는 인수를 입력 타입으로 묶습니다.

예를 들어 mergeRequestSetDraft 뮤테이션은 다음 인수를 정의합니다(일부는 상속을 통해 정의됨).

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: "Project the merge request belongs to."

argument :iid, GraphQL::Types::String,
         required: true,
         description: "IID of the merge request."

argument :draft,
         GraphQL::Types::Boolean,
         required: false,
         description: <<~DESC
           Whether or not to set the merge request as a draft.
         DESC

이 인수들은 지정한 3개의 인수와 clientMutationId를 가진 MergeRequestSetDraftInput이라는 입력 타입을 자동으로 생성합니다.

객체 식별자 인수#

객체를 식별하는 인수는 다음과 같아야 합니다.

  • 객체에 전체 경로나 IID가 있다면 전체 경로 또는 IID
  • 그 외 모든 객체는 객체의 글로벌 ID. 일반 데이터베이스 기본 키 ID는 절대 사용하지 않습니다.

전체 경로 객체 식별자 인수#

역사적으로 전체 경로 인수의 이름은 일관성이 없었지만, 다음과 같이 이름을 짓는 것을 선호합니다.

  • 프로젝트 전체 경로에는 project_path
  • 그룹 전체 경로에는 group_path
  • 네임스페이스 전체 경로에는 namespace_path

ciJobTokenScopeRemoveProject 뮤테이션의 예시는 다음과 같습니다.

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: 'Project the CI job token scope belongs to.'

IID 객체 식별자 인수#

객체의 iid를 부모의 project_path 또는 group_path와 함께 사용합니다. 예를 들면 다음과 같습니다.

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: 'Project the issue belongs to.'

argument :iid, GraphQL::Types::String,
         required: true,
         description: 'IID of the issue.'

글로벌 ID 객체 식별자 인수#

discussionToggleResolve 뮤테이션의 예시는 다음과 같습니다.

argument :id, Types::GlobalIDType[Discussion],
         required: true,
         description: 'Global ID of the discussion.'

글로벌 ID 사용 중단도 참고합니다.

Workhorse 지원 업로드#

파일 콘텐츠를 받는 모든 GraphQL API 뮤테이션은 Workhorse 지원 업로드를 사용해야 합니다.

구현 세부 사항은 Workhorse 업로드 문서를 참고합니다.

정렬 인수#

정렬 인수는 가능하면 사용 가능한 정렬 값 집합을 설명하는 열거형 타입을 사용해야 합니다.

열거형은 Types::SortEnum을 상속하여 몇 가지 공통 값을 상속받을 수 있습니다.

열거형 값은 {PROPERTY}_{DIRECTION} 형식을 따라야 합니다. 예를 들면 다음과 같습니다.

TITLE_ASC

정렬 열거형의 설명 스타일 가이드도 참고합니다.

ContainerRepositoriesResolver의 예시는 다음과 같습니다.

# Types::ContainerRegistry::ContainerRepositorySortEnum:
module Types
  module ContainerRegistry
    class ContainerRepositorySortEnum < SortEnum
      graphql_name 'ContainerRepositorySort'
      description 'Values for sorting container repositories'

      value 'NAME_ASC', 'Name by ascending order.', value: :name_asc
      value 'NAME_DESC', 'Name by descending order.', value: :name_desc
    end
  end
end

# Resolvers::ContainerRepositoriesResolver:
argument :sort, Types::ContainerRegistry::ContainerRepositorySortEnum,
          description: 'Sort container repositories by this criteria.',
          required: false,
          default_value: :created_desc

GitLab 사용자 지정 스칼라#

Types::TimeType#

Types::TimeType은 Ruby Time 및 DateTime 객체를 다루는 모든 필드와 인수의 타입으로 사용해야 합니다.

이 타입은 사용자 지정 스칼라이며 다음을 수행합니다.

  • GraphQL 필드의 타입으로 사용될 때 Ruby의 Time 및 DateTime 객체를 표준화된 ISO-8601 형식 문자열로 변환합니다.
  • GraphQL 인수의 타입으로 사용될 때 ISO-8601 형식의 시간 문자열을 Ruby Time 객체로 변환합니다.

이를 통해 GraphQL API가 시간을 표시하고 시간 입력을 처리하는 표준화된 방식을 갖게 됩니다.

예시:

field :created_at, Types::TimeType, null: true, description: 'Timestamp of when the issue was created.'

글로벌 ID 스칼라#

모든 글로벌 ID는 사용자 지정 스칼라입니다. 이들은 동적으로 생성되며 추상 스칼라 클래스 Types::GlobalIDType에서 만들어집니다.

테스트#

쿼리나 뮤테이션이 올바르게 실행되고 리졸브되는지는 통합 테스트만 완전히 검증할 수 있습니다.

단위 테스트는 타입에 특정 필드가 있는지, 뮤테이션에 특정 필수 인수가 있는지 등 스키마의 특정 측면을 정적으로 검증하는 용도로만 사용합니다. 필드나 인수를 정적으로 검증하는 것 이상으로 리졸버를 단위 테스트하지 않습니다.

그 외의 모든 테스트에는 통합 테스트를 사용합니다.

통합 테스트 작성#

통합 테스트는 GraphQL 쿼리나 뮤테이션의 전체 스택을 확인하며 spec/requests/api/graphql에 저장됩니다.

모든 실행 단계를 완전히 테스트하기 위해 통합 테스트를 사용합니다. 전체 요청 통합 테스트만 다음을 검증합니다.

  • 뮤테이션이 스키마에서 실제로 쿼리 가능한지(MutationType에 마운트되었는지)
  • 리졸버나 뮤테이션이 반환하는 데이터가 필드의 반환 타입과 올바르게 일치하고 오류 없이 리졸브되는지
  • 인수가 입력 시 올바르게 강제 변환되고, 필드가 출력 시 올바르게 직렬화되는지
  • 모든 인수 전처리
  • 인수나 스칼라의 검증이 올바르게 적용되는지
  • 인수의 default_value가 올바르게 적용되는지
  • 리졸버나 뮤테이션의 #ready? 메서드 로직이 올바르게 적용되는지
  • 객체가 성공적으로 리졸브되고 N+1 문제가 없는지

쿼리를 추가할 때는 a working graphql query that returns data 및 a working graphql query that returns no data shared example을 사용하여 쿼리가 유효한 결과를 렌더링하는지 테스트할 수 있습니다.

post_graphql 헬퍼를 사용하여 GraphQL 통합 테스트를 수행합니다.

예를 들면 다음과 같습니다.

# Good:
gql_query = %q(some query text...)
post_graphql(gql_query, current_user: current_user)
# or:
GitlabSchema.execute(gql_query, context: { current_user: current_user })

# Deprecated: avoid
resolve(described_class, obj: project, ctx: { current_user: current_user })

GraphqlHelpers#all_graphql_fields_for 헬퍼를 사용하여 사용 가능한 모든 필드를 포함하는 쿼리를 구성할 수 있습니다. 이렇게 하면 쿼리의 가능한 모든 필드를 렌더링하는 테스트를 더 간단하게 추가할 수 있습니다.

페이지네이션과 정렬을 지원하는 쿼리에 필드를 추가하는 경우 자세한 내용은 테스트를 참고합니다.

GraphQL 뮤테이션 요청을 테스트하기 위해 GraphqlHelpers는 두 가지 헬퍼를 제공합니다. graphql_mutation은 뮤테이션 이름과 뮤테이션 입력이 담긴 해시를 받으며, 뮤테이션 쿼리와 준비된 변수가 담긴 구조체를 반환합니다.

그런 다음 이 구조체를 post_graphql_mutation 헬퍼에 전달하면 GraphQL 클라이언트처럼 올바른 매개변수로 요청을 게시합니다.

뮤테이션의 응답에 접근하려면 graphql_mutation_response 헬퍼를 사용할 수 있습니다.

이러한 헬퍼를 사용하여 다음과 같은 스펙을 작성할 수 있습니다.

let(:mutation) do
  graphql_mutation(
    :merge_request_set_wip,
    project_path: 'gitlab-org/gitlab-foss',
    iid: '1',
    wip: true
  )
end

it 'returns a successful response' do
   post_graphql_mutation(mutation, current_user: user)

   expect(response).to have_gitlab_http_status(:success)
   expect(graphql_mutation_response(:merge_request_set_wip)['errors']).to be_empty
end

테스트 팁과 요령#

  • GraphqlHelpers 지원 모듈의 메서드에 익숙해집니다. 이 메서드 중 다수는 GraphQL 테스트 작성을 더 쉽게 해 줍니다.

  • GraphqlHelpers#graphql_data_at과 GraphqlHelpers#graphql_dig_at 같은 탐색 헬퍼를 사용하여 결과 필드에 접근합니다. 예를 들면 다음과 같습니다.

    result = GitlabSchema.execute(query)
    
    mr_iid = graphql_dig_at(result.to_h, :data, :project, :merge_request, :iid)
    
  • 결과와 비교하려면 GraphqlHelpers#a_graphql_entity_for를 사용합니다. 예를 들면 다음과 같습니다.

    post_graphql(some_query)
    
    # checks that it is a hash containing { id => global_id_of(issue) }
    expect(graphql_data_at(:project, :issues, :nodes))
      .to contain_exactly(a_graphql_entity_for(issue))
    
    # Additional fields can be passed, either as names of methods, or with values
    expect(graphql_data_at(:project, :issues, :nodes))
      .to contain_exactly(a_graphql_entity_for(issue, :iid, :title, created_at: some_time))
    
  • 빈 스키마는 직접 만들지 않고 GraphqlHelpers#empty_schema를 사용하여 생성합니다. 예를 들면 다음과 같습니다.

    # good
    let(:schema) { empty_schema }
    
    # bad
    let(:query_type) { GraphQL::ObjectType.new }
    let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
    
  • double('query', schema: nil) 대신 GraphqlHelpers#query_double(schema: nil)을 사용합니다. 예를 들면 다음과 같습니다.

    # good
    let(:query) { query_double(schema: GitlabSchema) }
    
    # bad
    let(:query) { double('Query', schema: GitlabSchema) }
    
  • 프론트엔드에서 사용하는 쿼리를 테스트하려면 GraphqlHelpers#get_graphql_query_as_string을 사용합니다. 예를 들면 다음과 같습니다.

    let(:query) { get_graphql_query_as_string('work_items/graphql/project_work_items.query.graphql') }
    let(:variables) { { 'fullPath' => project.full_path } }
    
    ...
    
    post_graphql(query, variables: variables)
    
  • 거짓 양성을 피합니다.

    post_graphql의 current_user: 인수로 사용자를 인증하면 같은 사용자에 대한 이후 요청보다 첫 번째 요청에서 더 많은 쿼리가 생성됩니다. QueryRecorder로 N+1 쿼리를 테스트하는 경우 요청마다 다른 사용자를 사용합니다.

    다음 예시는 N+1 쿼리를 피하는 테스트가 어떤 모습이어야 하는지 보여 줍니다.

    RSpec.describe 'Query.project(fullPath).pipelines' do
      include GraphqlHelpers
    
      let(:project) { create(:project) }
    
      let(:query) do
        %(
          {
            project(fullPath: "#{project.full_path}") {
              pipelines {
                nodes {
                  id
                }
              }
            }
          }
        )
      end
    
      it 'avoids N+1 queries' do
        first_user = create(:user)
        second_user = create(:user)
        create(:ci_pipeline, project: project)
    
        control_count = ActiveRecord::QueryRecorder.new do
          post_graphql(query, current_user: first_user)
        end
    
        create(:ci_pipeline, project: project)
    
        expect do
          post_graphql(query, current_user: second_user)  # use a different user to avoid a false positive from authentication queries
        end.not_to exceed_query_limit(control_count)
      end
    end
    
  • app/graphql/types의 폴더 구조를 따릅니다.

    예를 들어 app/graphql/types/ci/pipeline_type.rb의 Types::Ci::PipelineType 필드에 대한 테스트는 파이프라인 데이터를 가져오는 데 사용된 쿼리와 관계없이 spec/requests/api/graphql/ci/pipeline_spec.rb에 저장해야 합니다.

단위 테스트 작성#

단위 테스트는 스키마를 정적으로 검증하는 용도로만 사용합니다. 예를 들어 다음을 확인합니다.

  • 타입, 뮤테이션 또는 리졸버에 특정 이름의 필드가 있는지
  • 타입, 뮤테이션 또는 리졸버에 특정 이름의 authorize 권한이 있는지(단, 인가는 통합 테스트로 테스트합니다)
  • 뮤테이션이나 리졸버에 특정 이름의 인수가 있는지, 그리고 해당 인수가 필수인지 여부

정적 스키마 테스트 외에는 리졸버가 어떻게 리졸브하는지 또는 인가를 어떻게 적용하는지를 단위 테스트하지 않습니다. 대신 통합 테스트를 사용하여 전체 실행 단계를 테스트합니다.

쿼리 흐름과 GraphQL 인프라에 대한 참고 사항#

GitLab GraphQL 인프라는 lib/gitlab/graphql에 있습니다.

인스트루멘테이션은 실행 중인 쿼리를 감싸는 기능입니다. Instrumentation 클래스를 사용하는 모듈로 구현됩니다.

예시: Present

module Gitlab
  module Graphql
    module Present
      #... some code above...

      def self.use(schema_definition)
        schema_definition.instrument(:field, ::Gitlab::Graphql::Present::Instrumentation.new)
      end
    end
  end
end

쿼리 분석기에는 쿼리를 실행하기 전에 검증하는 콜백 집합이 들어 있습니다. 각 필드는 분석기를 거칠 수 있으며, 최종 값도 사용할 수 있습니다.

멀티플렉스 쿼리를 사용하면 여러 쿼리를 하나의 요청으로 보낼 수 있습니다. 이렇게 하면 서버로 보내는 요청 수가 줄어듭니다 (GraphQL Ruby가 제공하는 사용자 지정 멀티플렉스 쿼리 분석기와 멀티플렉스 인스트루멘테이션이 있습니다).

쿼리 제한#

쿼리와 뮤테이션은 지나치게 야심 찬 쿼리나 악의적인 쿼리로부터 서버 리소스를 보호하기 위해 깊이, 복잡도, 재귀로 제한됩니다. 이러한 값은 기본값으로 설정할 수 있으며 필요에 따라 특정 쿼리에서 재정의할 수 있습니다. 복잡도 값은 객체별로도 설정할 수 있으며, 최종 쿼리 복잡도는 반환되는 객체 수를 기준으로 평가됩니다. 이는 비용이 큰 객체 (예: Gitaly 호출이 필요한 객체)에 사용할 수 있습니다.

예를 들어 리졸버의 조건부 복잡도 메서드는 다음과 같습니다.

def self.resolver_complexity(args, child_complexity:)
  complexity = super
  complexity += 2 if args[:label_name]

  complexity
end

복잡도에 대한 자세한 내용은 GraphQL Ruby 문서를 참고합니다.

문서와 스키마#

스키마는 app/graphql/gitlab_schema.rb에 있습니다. 자세한 내용은 스키마 참조를 참고합니다.

스키마가 변경되면 생성된 이 GraphQL 문서를 업데이트해야 합니다. GraphQL 문서와 스키마 파일을 생성하는 방법은 스키마 문서 업데이트를 참고합니다.

독자를 돕기 위해 GraphQL API 문서에 새 페이지도 추가해야 합니다. 안내는 GraphQL API 페이지를 참고합니다.

변경 로그 항목 포함#

클라이언트에 영향을 주는 모든 변경에는 변경 로그 항목이 포함되어야 합니다.

지연 처리#

성능을 관리하기 위한 GraphQL 고유의 중요한 기법 중 하나는 지연 값을 사용하는 것입니다. 지연 값은 결과에 대한 약속을 나타내며, 해당 동작을 나중에 실행할 수 있게 하여 쿼리 트리의 서로 다른 부분에 있는 쿼리를 배치 처리할 수 있게 합니다. 코드에서 지연 값의 대표적인 예는 GraphQL BatchLoader입니다.

지연 값을 직접 관리하려면 Gitlab::Graphql::Lazy, 특히 Gitlab::Graphql::Laziness를 읽어 봅니다. 여기에는 필요한 경우 지연 처리의 생성과 제거라는 기본 작업을 구현하는 데 도움이 되는 #force와 #delay가 있습니다.

지연 값을 강제로 평가하지 않고 다루려면 Gitlab::Graphql::Lazy.with_value를 사용합니다.

백엔드 GraphQL API 가이드

GitLab v19.4
원문 보기

요약

이 문서는 GitLab GraphQL API의 백엔드를 구현하는 엔지니어를 위한 스타일과 기술 가이드를 담고 있습니다. GraphQL 및 REST API 섹션을 참고합니다. GraphQL API는 버전이 없습니다. GraphQL API에는 버전이 없지만, 업데이트 간 하위 호환성과 그로 인해 발생할 수 있는 인시던트(예: 일부 사용자에게 사이드바가 로드되지 않은 사례)를 고려해야 합니다.

이 문서는 GitLab GraphQL API의 백엔드를 구현하는 엔지니어를 위한 스타일과 기술 가이드를 담고 있습니다.

REST API와의 관계#

GraphQL 및 REST API 섹션을 참고합니다.

버전 관리#

GraphQL API는 버전이 없습니다.

다중 버전 호환성#

GraphQL API에는 버전이 없지만, 업데이트 간 하위 호환성과 그로 인해 발생할 수 있는 인시던트(예: 일부 사용자에게 사이드바가 로드되지 않은 사례)를 고려해야 합니다.

완화 방법#

인시던트 위험을 줄이려면 GitLab Self-Managed와 GitLab Dedicated에서 @gl_introduced 디렉티브를 사용해 노드가 도입된 GitLab 마일스톤을 태그합니다. 백엔드는 태그된 마일스톤을 자체 마일스톤 (패치를 제외한 GitLab 버전의 major.minor)과 비교하여 노드를 어떻게 처리할지 결정합니다.

태그된 마일스톤과 백엔드 마일스톤 비교 동작
백엔드보다 이전 필드가 반드시 존재해야 합니다. 필드가 없으면 undefinedField 오류가 발생합니다.
백엔드의 마일스톤 또는 그 이후 스키마에 필드가 있으면 정상적으로 리졸브되고, 없으면 null을 반환합니다.

백엔드는 버전 문자열만으로는 마일스톤 도중에 필드가 존재하는지 알 수 없습니다. GitLab.com은 -pre 빌드를 실행하고 CI는 오래된 머지 리퀘스트 브랜치를 실행하므로, 두 백엔드가 같은 마일스톤을 보고하더라도 그중 하나에만 필드가 있을 수 있습니다. 그래서 백엔드 자체 마일스톤일 때는 오류 대신 null로 대체합니다. 오타가 있거나 제거된 필드는 백엔드가 태그된 마일스톤을 지나갈 때까지만 null을 반환하고, 그 이후에는 다시 오류가 발생합니다.

새 필드에는 해당 머지 리퀘스트가 머지되는 마일스톤을 태그합니다.

예를 들어 19.4 마일스톤을 태그한 필드는 다음과 같습니다.

newField @gl_introduced(version: "19.4.0")

이 필드는 다음과 같이 동작합니다.

백엔드 버전 결과
19.3.x null
19.4.0-pre, 필드의 MR 배포됨 정상 값
19.4.0-pre, 필드의 MR 배포되지 않음 null
19.5.x, 필드 존재 정상 값
19.5.x, 필드 제거되었거나 철자가 틀림 undefinedField 오류

이 디렉티브는 태그된 노드의 전체 하위 트리에 적용됩니다. 백엔드가 태그된 노드를 제거하면 그 안의 어떤 것도 검증하지 않습니다. 하위 트리 안의 알 수 없는 필드나 타입은 오류 대신 null을 반환합니다.

@gl_introduced 디렉티브는 모든 필드에 사용할 수 있습니다. 예를 들어 다음과 같습니다.

fragment otherFieldsWithFuture on Namespace {
  webUrl
  otherFutureField @gl_introduced(version: "99.9.9")
}

query namespaceWithFutureFields {
  futureField @gl_introduced(version: "99.9.9")
  namespace(fullPath: "gitlab-org") {
    name
    futureField @gl_introduced(version: "99.9.9")
    ...otherFieldsWithFuture
  }
}

응답은 다음과 같습니다.

{
  "data": {
    "futureField": null,
    "namespace": {
      "name": "Gitlab Org",
      "futureField": null,
      "webUrl": "http://gdk.test:3000/groups/gitlab-org",
      "otherFutureField": null
    }
  }
}

이 디렉티브를 다음 대상에는 사용하지 않아야 합니다.

  • 인수: 실행 가능한 디렉티브는 인수를 지원하지 않습니다.
  • 프래그먼트: 대신 프래그먼트 안의 노드에 디렉티브를 사용합니다.
Null을 허용하지 않는 필드#

미래의 필드는 백엔드에 존재하지 않으면 null로 대체됩니다. 따라서 @gl_introduced 디렉티브가 있는 Null을 허용하지 않는 필드도 프론트엔드에서 null 검사가 여전히 필요합니다.

GitLab에서 GraphQL 배우기#

GitLab에서 GraphQL을 배우려는 백엔드 엔지니어는 이 가이드를 GraphQL Ruby gem 가이드와 함께 읽어야 합니다. 해당 가이드에서는 gem의 기능을 설명하며, 그 내용은 일반적으로 이 문서에서 반복하지 않습니다.

GraphQL 자체의 설계와 기능을 알아보려면 graphql.org의 가이드를 읽습니다. 이 가이드는 GraphQL 사양의 정보를 이해하기 쉽게 줄인 버전입니다.

딥 다이브#

2019년 3월에 Nick Thomas는 GitLab GraphQL API에 대한 딥 다이브(GitLab 팀 구성원 전용: https://gitlab.com/gitlab-org/create-stage/issues/1)를 진행하여 앞으로 코드베이스의 이 부분에서 작업할 수 있는 모든 사람과 도메인 지식을 공유했습니다. 다음 자료를 확인할 수 있습니다. YouTube 녹화본과 슬라이드는 Google Slides 및 PDF에서 확인할 수 있습니다. 세부 사항은 그 이후 달라졌지만, 여전히 좋은 입문 자료로 활용할 수 있습니다.

GitLab의 GraphQL 구현 방식#

Robert Mosolgo가 작성한 GraphQL Ruby gem을 사용합니다. 또한 GraphQL Pro를 구독하고 있습니다. 자세한 내용은 GraphQL Pro 구독을 참고합니다.

모든 GraphQL 쿼리는 단일 엔드포인트 (app/controllers/graphql_controller.rb#execute)로 전달되며, 이 엔드포인트는 /api/graphql의 API 엔드포인트로 노출됩니다.

GraphiQL#

GraphiQL은 기존 쿼리를 직접 실행해 볼 수 있는 대화형 GraphQL API 탐색기입니다. 모든 GitLab 환경에서 https://<your-gitlab-site.com>/-/graphql-explorer로 접근할 수 있습니다. 예를 들어 GitLab.com의 탐색기가 있습니다.

GraphQL 변경이 포함된 머지 리퀘스트 리뷰#

GraphQL 프레임워크에는 주의해야 할 특정한 함정이 있으며, 이를 충족하려면 도메인 전문 지식이 필요합니다.

GraphQL 파일을 수정하거나 엔드포인트를 추가하는 머지 리퀘스트의 리뷰를 요청받았다면 GraphQL 리뷰 가이드를 살펴봅니다.

GraphQL 로그 읽기#

GraphQL 요청의 로그를 살펴보고 GraphQL 쿼리의 성능을 모니터링하는 방법은 GraphQL 로그 읽기 가이드를 참고합니다.

해당 페이지에는 다음과 같은 팁이 있습니다.

  • 더 이상 사용되지 않는 필드의 사용량 확인
  • 쿼리가 프론트엔드에서 왔는지 여부 식별

인증#

인증은 GraphqlController를 통해 이루어지며, 현재 Rails 애플리케이션과 동일한 인증을 사용합니다. 따라서 세션을 공유할 수 있습니다.

쿼리 문자열에 private_token을 추가하거나 HTTP_PRIVATE_TOKEN 헤더를 추가하는 것도 가능합니다.

제한#

GraphQL API에는 여러 제한이 적용되며, 이 중 일부는 개발자가 재정의할 수 있습니다.

최대 페이지 크기#

기본적으로 커넥션은 페이지당 app/graphql/gitlab_schema.rb에 정의된 최대 레코드 수까지만 반환할 수 있습니다.

개발자는 커넥션을 정의할 때 사용자 지정 최대 페이지 크기를 지정할 수 있습니다.

최대 복잡도#

복잡도는 고객용 API 페이지에 설명되어 있습니다.

필드는 기본적으로 쿼리의 복잡도 점수에 1을 더하지만, 개발자는 필드를 정의할 때 사용자 지정 복잡도를 지정할 수 있습니다.

쿼리의 복잡도 점수는 직접 쿼리로 조회할 수도 있습니다.

요청 타임아웃#

요청은 30초 후에 타임아웃됩니다.

최대 필드 호출 횟수 제한#

경우에 따라 N+1 쿼리 문제가 발생하고 최적의 해결책이 없어서 여러 부모 노드에서 특정 필드가 평가되는 것을 막아야 할 때가 있습니다. 이 방법은 최후의 수단으로 간주해야 하며, 룩어헤드로 연관 관계 미리 로드나 배칭 사용 같은 방법을 검토한 뒤에만 사용합니다.

예를 들면 다음과 같습니다.

# This usage is expected.
query {
  project {
    environments
  }
}

# This usage is NOT expected.
# It results in N+1 query problem. EnvironmentsResolver can't use GraphQL batch loader in favor of GraphQL pagination.
query {
  projects {
    nodes {
      environments
    }
  }
}

이를 방지하려면 필드에 Gitlab::Graphql::Limit::FieldCallCount 확장을 사용합니다.

# This allows maximum 1 call to the `environments` field. If the field is evaluated on more than one node,
# it raises an error.
field :environments do
        extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
      end

또는 리졸버 클래스에 확장을 적용할 수도 있습니다.

module Resolvers
  class EnvironmentsResolver < BaseResolver
    extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
    # ...
  end
end

이 제한을 추가할 때는 영향을 받는 필드의 description도 그에 맞게 업데이트해야 합니다. 예를 들면 다음과 같습니다.

field :environments,
      description: 'Environments of the project. This field can only be resolved for one project in any single request.'

호환성을 깨는 변경#

GitLab GraphQL API는 버전이 없으므로 개발자는 사용 중단 및 제거 프로세스를 숙지해야 합니다.

호환성을 깨는 변경은 다음과 같습니다.

  • 필드, 인수, 열거형 값 또는 뮤테이션을 제거하거나 이름을 변경하는 경우
  • 인수의 타입이나 타입 이름을 변경하는 경우. 인수의 타입은 변수를 사용할 때 클라이언트가 선언하므로, 변경하면 이전 타입 이름을 사용한 쿼리가 API에서 거부됩니다.
  • 필드나 열거형 값의 스칼라 타입을 변경하여 값이 JSON으로 직렬화되는 방식이 달라지는 경우. 예를 들어 JSON String에서 JSON Number로 바뀌거나 String의 형식이 달라지는 경우입니다. 다른 객체 타입으로 변경하는 것은 해당 객체의 모든 스칼라 타입 필드가 동일한 방식으로 계속 직렬화되는 한 허용될 수 있습니다.
  • 필드의 복잡도나 리졸버의 복잡도 승수를 높이는 경우
  • Nullable 필드에서 설명한 대로, 필드를 null 불가(null: false)에서 nullable(null: true)로 변경하는 경우
  • 인수를 선택 사항(required: false)에서 필수(required: true)로 변경하는 경우
  • 커넥션의 최대 페이지 크기를 변경하는 경우
  • 쿼리 복잡도와 깊이의 전역 제한을 낮추는 경우
  • 이전에는 허용되던 쿼리가 제한에 걸리게 될 수 있는 그 밖의 모든 변경

항목을 사용 중단하는 방법은 스키마 항목 사용 중단 섹션을 참고합니다.

호환성을 깨는 변경의 예외#

GraphQL API 호환성을 깨는 변경 예외 문서를 참고합니다.

글로벌 ID#

GitLab GraphQL API는 글로벌 ID(예: "gid://gitlab/MyObject/123")를 사용하며 데이터베이스 기본 키 ID는 절대 사용하지 않습니다.

글로벌 ID는 클라이언트 측 라이브러리에서 캐싱과 페칭에 사용하는 관례입니다.

다음도 참고합니다.

값이 GlobalID일 때 입력 및 출력 인수의 타입으로 사용해야 하는 사용자 지정 스칼라 타입(Types::GlobalIDType)이 있습니다. ID 대신 이 타입을 사용하면 다음과 같은 이점이 있습니다.

  • 값이 GlobalID인지 검증합니다.
  • 사용자 코드에 전달하기 전에 GlobalID로 파싱합니다.
  • 객체의 타입으로 매개변수화할 수 있어(예: GlobalIDType[Project]) 더 나은 검증과 보안을 제공합니다.

모든 새 인수와 결과 타입에 이 타입을 사용하는 것을 고려합니다. 이 타입을 concern이나 상위 타입으로 매개변수화하는 것도 충분히 가능하다는 점을 기억합니다. 더 넓은 범위의 객체를 허용하려는 경우에 유용합니다(예: GlobalIDType[Issuable]과 GlobalIDType[Issue]).

최적화#

기본적으로 GraphQL은 적극적으로 최소화하려 하지 않으면 N+1 문제를 일으키기 쉽습니다.

안정성과 확장성을 위해 쿼리에 N+1 성능 문제가 없도록 해야 합니다.

다음은 GraphQL 코드를 최적화하는 데 도움이 되는 도구 목록입니다.

  • 룩어헤드를 사용하면 쿼리에서 선택된 필드를 기준으로 데이터를 미리 로드할 수 있습니다.
  • 배치 로딩을 사용하면 데이터베이스 쿼리를 하나로 묶어 한 번의 구문으로 실행할 수 있습니다.
  • BatchModelLoader는 배치 로딩을 활용하여 ID로 레코드를 조회하는 권장 방법입니다.
  • before_connection_authorization을 사용하면 타입 인가 권한 검사에 특화된 N+1 문제를 해결할 수 있습니다.
  • 최대 필드 호출 횟수 제한을 사용하면 최적화를 더 개선할 수 없는 경우 필드가 데이터를 반환할 수 있는 횟수를 제한할 수 있습니다.

개발 중 N+1 문제 확인 방법#

기능을 개발하는 동안 다음과 같은 방법으로 N+1 문제를 발견할 수 있습니다.

  • 데이터 컬렉션을 반환하는 GraphQL 쿼리를 실행하는 동안 development.log를 tail로 확인합니다. Bullet이 도움이 될 수 있습니다.
  • GitLab UI에서 쿼리를 실행하는 경우 성능 바를 확인합니다.
  • 기능에 N+1 문제가 없거나 제한적임을 검증하는 요청 스펙을 추가합니다.

필드#

타입#

코드 우선 스키마를 사용하며, 모든 타입을 Ruby로 선언합니다.

예를 들어 app/graphql/types/project_type.rb는 다음과 같습니다.

graphql_name 'Project'

field :full_path, GraphQL::Types::ID, null: true
field :name, GraphQL::Types::String, null: true

각 타입에 이름을 지정합니다(이 경우 Project).

full_path와 name은 스칼라 GraphQL 타입입니다. full_path는 GraphQL::Types::ID입니다 (GraphQL::Types::ID를 사용하는 경우 참고). name은 일반적인 GraphQL::Types::String 타입입니다. 스칼라 데이터 타입(예: TimeType)에는 사용자 지정 GraphQL 데이터 타입도 선언할 수 있습니다.

GraphQL API를 통해 모델을 노출할 때는 app/graphql/types에 새 타입을 만듭니다.

타입에서 속성을 노출할 때는 정의 안의 로직을 가능한 한 최소한으로 유지합니다. 대신 로직을 프레젠터로 옮기는 것을 고려합니다.

class Types::MergeRequestType < BaseObject
  present_using MergeRequestPresenter

  name 'MergeRequest'
end

기존 프레젠터를 사용할 수도 있고, GraphQL 전용으로 새 프레젠터를 만들 수도 있습니다.

프레젠터는 필드가 리졸브한 객체와 컨텍스트를 사용해 초기화됩니다.

Nullable 필드#

GraphQL에서는 필드를 "nullable" 또는 "non-nullable"로 지정할 수 있습니다. 전자는 지정된 타입의 값 대신 null이 반환될 수 있다는 뜻입니다. 일반적으로 다음 이유로 non-nullable 필드보다 nullable 필드를 사용하는 것이 좋습니다.

  • 데이터가 필수에서 선택으로, 또 그 반대로 바뀌는 일이 흔합니다.
  • 필드가 선택 사항이 될 가능성이 없더라도 쿼리 시점에 사용할 수 없을 수 있습니다.
    • 예를 들어 blob의 content는 Gitaly에서 조회해야 할 수 있습니다.
    • content가 nullable이면 쿼리 전체를 실패시키는 대신 부분 응답을 반환할 수 있습니다.
  • 버전이 없는 스키마에서는 non-nullable 필드를 nullable 필드로 바꾸기가 어렵습니다.

non-nullable 필드는 필드가 필수이고, 앞으로 선택 사항이 될 가능성이 매우 낮으며, 계산이 간단할 때만 사용해야 합니다. 예로는 id 필드가 있습니다.

non-nullable GraphQL 스키마 필드는 객체 타입 뒤에 느낌표(bang) !가 붙습니다. 다음은 gitlab_schema.graphql 파일의 예입니다.

  id: ProjectID!

다음은 non-nullable GraphQL 배열의 예입니다.


  errors: [String!]!

더 읽어 볼 자료는 다음과 같습니다.

글로벌 ID 노출#

GitLab의 글로벌 ID 사용 방식에 따라, 데이터베이스 기본 키 ID를 노출할 때는 항상 글로벌 ID로 변환합니다.

id라는 이름의 모든 필드는 자동으로 변환되어 객체의 글로벌 ID가 됩니다.

id가 아닌 이름의 필드는 수동으로 변환해야 합니다. Gitlab::GlobalID.build를 사용하거나, GlobalID::Identification 모듈을 믹스인한 객체에서 #to_global_id를 호출하면 됩니다.

Types::Notes::DiscussionType의 예는 다음과 같습니다.

field :reply_id, Types::GlobalIDType[Discussion]

def reply_id
  Gitlab::GlobalId.build(object, id: object.reply_id)
end

GraphQL::Types::ID를 사용하는 경우#

GraphQL::Types::ID를 사용하면 필드가 GraphQL ID 타입이 되며, JSON 문자열로 직렬화됩니다. 그러나 ID는 클라이언트에게 특별한 의미가 있습니다. GraphQL 사양에는 다음과 같이 나와 있습니다.

The ID scalar type represents a unique identifier, often used to refetch an object or as the key for a cache.

GraphQL 사양은 ID의 고유성 범위를 명확히 정하지 않습니다. GitLab에서는 ID가 최소한 타입 이름 기준으로 고유해야 한다고 정했습니다. 타입 이름은 Types:: 클래스 중 하나의 graphql_name이며, 예를 들어 Project나 Issue입니다.

이에 따르면 다음과 같습니다.

  • Project.fullPath는 API 전체에서 같은 fullPath를 가진 다른 Project가 없고 이 필드도 식별자이므로 ID여야 합니다.
  • Issue.iid는 API 전체에서 같은 iid를 가진 Issue 타입이 여럿 있을 수 있으므로 ID가 아니어야 합니다. 이를 ID로 취급하면 클라이언트가 서로 다른 프로젝트의 Issue 캐시를 가지고 있을 때 문제가 됩니다.
  • Project.id는 해당 ID 값을 가진 Project가 하나뿐이므로 보통 ID가 될 수 있습니다. 다만 데이터베이스 ID 값에는 ID 타입 대신 글로벌 ID 타입을 사용하므로 글로벌 ID로 타입을 지정합니다.

이를 표로 요약하면 다음과 같습니다.

필드 용도 GraphQL::Types::ID를 사용하는가?
Full path ✅
Database ID ❌
IID ❌

markdown_field#

markdown_field는 field를 감싸는 헬퍼 메서드이며, 렌더링된 Markdown을 반환하는 필드에는 항상 이 메서드를 사용해야 합니다.

이 헬퍼는 기존 MarkupHelper를 사용하여 모델의 Markdown 필드를 렌더링하며, GraphQL 쿼리의 컨텍스트를 헬퍼에서 사용할 수 있습니다.

현재 사용자가 볼 수 없는 리소스에 대한 링크를 가리려면 헬퍼에서 컨텍스트를 사용할 수 있어야 합니다.

HTML을 렌더링하면 쿼리가 발생할 수 있으므로, 이러한 필드의 복잡도는 기본값보다 5 높게 설정됩니다.

Markdown 필드 헬퍼는 다음과 같이 사용할 수 있습니다.

markdown_field :note_html, null: false

이렇게 하면 모델의 Markdown 필드 note를 렌더링하는 필드가 생성됩니다. method: 인수를 추가하여 이를 재정의할 수 있습니다.

markdown_field :body_html, null: false, method: :note

이 필드에는 기본적으로 다음 설명이 지정됩니다.

The GitLab Flavored Markdown rendering of note

description: 인수를 전달하여 이를 재정의할 수 있습니다.

커넥션 타입#

Note

구현 세부 사항은 페이지네이션 구현을 참고합니다.

GraphQL은 커서 기반 페이지네이션을 사용하여 항목 컬렉션을 노출합니다. 이를 통해 클라이언트에 높은 유연성을 제공하면서 백엔드가 서로 다른 페이지네이션 모델을 사용할 수 있습니다.

CountableConnectionType과 LimitedCountableConnectionType 중 선택#

GitLab은 카운팅을 지원하는 컬렉션을 위해 두 가지 커넥션 타입을 제공합니다.

  • CountableConnectionType - 기본적으로 정확한 개수를 반환하며, 성능 최적화를 위한 선택적 limit 인수를 지원합니다.
  • LimitedCountableConnectionType - 항상 제한된 개수를 반환합니다(기본 제한: 1000).

CountableConnectionType을 사용하는 경우:

  • 정확한 개수가 사용자 경험에 중요한 경우(예: 이슈의 총 개수 표시)
  • 컬렉션 크기가 일반적으로 작거나 중간 정도인 경우
  • 정확한 개수가 필요하지 않을 때 클라이언트가 limit 인수를 제공하여 성능 최적화를 선택할 수 있는 경우

LimitedCountableConnectionType을 사용하는 경우:

  • 정확한 개수가 사용자 경험에 중요하지 않은 경우
  • 컬렉션이 매우 클 수 있어 모든 항목을 카운트하는 비용이 큰 경우
  • 성능 제한을 기본적으로 적용하려는 경우

두 커넥션 타입 모두 제한이 적용될 때 동일한 기반의 제한된 카운팅 로직을 사용하며, CountableConnectionHelper 모듈을 통해 구현을 공유합니다.

리소스 컬렉션을 노출하려면 커넥션 타입을 사용할 수 있습니다. 커넥션 타입은 배열을 기본 페이지네이션 필드로 감쌉니다. 예를 들어 프로젝트 파이프라인에 대한 쿼리는 다음과 같습니다.

query($project_path: ID!) {
  project(fullPath: $project_path) {
    pipelines(first: 2) {
      pageInfo {
        hasNextPage
        hasPreviousPage
      }
      edges {
        cursor
        node {
          id
          status
        }
      }
    }
  }
}

이 쿼리는 프로젝트의 처음 2개 파이프라인과 관련 페이지네이션 정보를 ID 내림차순으로 반환합니다. 반환되는 데이터는 다음과 같습니다.

{
  "data": {
    "project": {
      "pipelines": {
        "pageInfo": {
          "hasNextPage": true,
          "hasPreviousPage": false
        },
        "edges": [
          {
            "cursor": "Nzc=",
            "node": {
              "id": "gid://gitlab/Pipeline/77",
              "status": "FAILED"
            }
          },
          {
            "cursor": "Njc=",
            "node": {
              "id": "gid://gitlab/Pipeline/67",
              "status": "FAILED"
            }
          }
        ]
      }
    }
  }
}

다음 페이지를 가져오려면 마지막으로 확인된 요소의 커서를 전달하면 됩니다.

query($project_path: ID!) {
  project(fullPath: $project_path) {
    pipelines(first: 2, after: "Njc=") {
      pageInfo {
        hasNextPage
        hasPreviousPage
      }
      edges {
        cursor
        node {
          id
          status
        }
      }
    }
  }
}

일관된 정렬을 보장하기 위해 기본 키에 대한 정렬을 내림차순으로 추가합니다. 기본 키는 보통 id이므로 릴레이션 끝에 order(id: :desc)를 추가합니다. 기반 테이블에 기본 키가 반드시 있어야 합니다.

바로가기 필드#

매개변수가 전달되지 않았을 때 리졸버가 컬렉션의 첫 번째 항목을 반환하도록 하는 "바로가기 필드"를 구현하는 것이 간단해 보일 때가 있습니다. 이러한 "바로가기 필드"는 유지 관리 부담을 만들기 때문에 권장하지 않습니다. 바로가기 필드는 기준이 되는 필드와 동기화 상태를 유지해야 하고, 기준 필드가 변경되면 사용 중단하거나 수정해야 합니다. 다르게 해야 할 설득력 있는 이유가 없다면 프레임워크가 제공하는 기능을 사용합니다.

예를 들어 latest_pipeline 대신 pipelines(last: 1)을 사용합니다.

페이지 크기 제한#

기본적으로 API는 커넥션에서 페이지당 app/graphql/gitlab_schema.rb에 정의된 최대 레코드 수까지만 반환하며, 클라이언트가 제한 인수(first: 또는 last:)를 제공하지 않으면 이 값이 페이지당 반환되는 기본 레코드 수이기도 합니다.

max_page_size 인수를 사용하면 커넥션에 다른 페이지 크기 제한을 지정할 수 있습니다.

Warning

기본값은 GraphQL API의 성능을 유지하기 위해 설정된 값이므로, max_page_size를 높이는 것보다 프론트엔드 클라이언트나 제품 요구 사항을 변경하여 페이지당 많은 수의 레코드가 필요하지 않게 하는 것이 낫습니다.

예를 들면 다음과 같습니다.

field :tags,
  Types::ContainerRegistry::ContainerRepositoryTagType.connection_type,
  null: true,
  description: 'Tags of the container repository',
  max_page_size: 20

필드 복잡도#

GitLab GraphQL API는 지나치게 복잡한 쿼리의 실행을 제한하기 위해 복잡도 점수를 사용합니다. 복잡도는 이 주제에 관한 고객용 문서에 설명되어 있습니다.

복잡도 제한은 app/graphql/gitlab_schema.rb에 정의되어 있습니다.

기본적으로 필드는 쿼리의 복잡도 점수에 1을 더합니다. 이는 필드에 사용자 지정 complexity 값을 제공하여 재정의할 수 있습니다.

데이터를 반환하기 위해 서버가 더 많은 _작업_을 수행해야 하는 필드에는 더 높은 복잡도를 지정해야 합니다. 대부분의 경우 id나 title처럼 작업이 거의 또는 전혀 필요하지 않고 반환할 수 있는 데이터를 나타내는 필드에는 복잡도 0을 지정할 수 있습니다.

calls_gitaly#

리졸브할 때 Gitaly 호출을 수행할 가능성이 있는 필드는 정의할 때 field에 calls_gitaly: true를 전달하여 그렇게 표시해야 합니다.

예를 들면 다음과 같습니다.

field :blob, type: Types::Snippets::BlobType,
      description: 'Snippet blob',
      null: false,
      calls_gitaly: true

이렇게 하면 필드의 complexity 점수가 1 증가합니다.

리졸버가 Gitaly를 호출하는 경우 BaseResolver.calls_gitaly!로 주석을 달 수 있습니다. 이렇게 하면 해당 리졸버를 사용하는 모든 필드에 calls_gitaly: true가 전달됩니다.

예를 들면 다음과 같습니다.

class BranchResolver < BaseResolver
  type ::Types::BranchType, null: true
  calls_gitaly!

  argument name: ::GraphQL::Types::String, required: true

  def resolve(name:)
    object.branch(name)
  end
end

이후 이 리졸버를 사용하면 BranchResolver를 사용하는 모든 필드가 올바른 calls_gitaly: 값을 갖게 됩니다.

타입의 권한 노출#

현재 사용자가 리소스에 대해 가진 권한을 노출하려면 리소스의 권한을 나타내는 별도의 타입을 전달하여 expose_permissions를 호출할 수 있습니다.

예를 들면 다음과 같습니다.

module Types
  class MergeRequestType < BaseObject
    expose_permissions Types::MergeRequestPermissionsType
  end
end

권한 타입은 BasePermissionType을 상속하며, 여기에는 권한을 non-nullable 불리언으로 노출할 수 있는 몇 가지 헬퍼 메서드가 포함되어 있습니다.

class MergeRequestPermissionsType < BasePermissionType
  graphql_name 'MergeRequestPermissions'

  present_using MergeRequestPresenter

  abilities :admin_merge_request, :update_merge_request, :create_note

  ability_field :resolve_note,
                description: 'Indicates the user can resolve discussions on the merge request.'
  permission_field :push_to_source_branch, method: :can_push_to_source_branch?
end
  • permission_field: graphql-ruby의 field 메서드와 동일하게 동작하되, 기본 설명과 타입을 설정하고 non-nullable로 만듭니다. 이러한 옵션은 인수로 추가하여 여전히 재정의할 수 있습니다.
  • ability_field: 정책에 정의된 ability를 노출합니다. 이것은 permission_field와 동일한 방식으로 동작하며 동일한 인수를 재정의할 수 있습니다.
  • abilities: 정책에 정의된 여러 ability를 한 번에 노출할 수 있습니다. 이러한 필드는 모두 기본 설명을 가진 non-nullable 불리언이어야 합니다.

기능 플래그#

GraphQL에서 기능 플래그를 구현하여 다음을 전환할 수 있습니다.

  • 필드의 반환 값
  • 인수나 뮤테이션의 동작

이는 선호도와 상황에 따라 리졸버, 타입 또는 모델 메서드에서 구현할 수 있습니다.

Note

기능 플래그 뒤에 있는 동안에는 항목을 실험으로 표시하는 것도 권장합니다. 이는 공개 GraphQL API 사용자에게 해당 필드를 아직 사용하면 안 된다는 신호를 줍니다. 또한 사용 중단하지 않고도 실험 항목을 언제든지 변경하거나 제거할 수 있습니다. 플래그를 제거할 때는 experiment 속성을 제거하여 스키마 항목을 "릴리스"하고 공개로 전환합니다.

기능 플래그가 적용된 항목의 설명#

기능 플래그를 사용하여 스키마 항목의 값이나 동작을 전환하는 경우 해당 항목의 description에는 다음이 포함되어야 합니다.

  • 값이나 동작이 기능 플래그로 전환될 수 있다는 내용
  • 기능 플래그의 이름
  • 기능 플래그가 비활성화되었을 때(또는 더 적절하다면 활성화되었을 때) 필드가 반환하는 값이나 동작

기능 플래그 사용 예시#

기능 플래그가 적용된 필드#

필드 값은 기능 플래그 상태에 따라 전환됩니다. 일반적인 용도는 기능 플래그가 비활성화되었을 때 null을 반환하는 것입니다.

field :foo, GraphQL::Types::String, null: true,
      experiment: { milestone: '10.0' },
      description: 'Some test field. Returns `null`' \
                   'if `my_feature_flag` feature flag is disabled.'

def foo
  object.foo if Feature.enabled?(:my_feature_flag, object)
end

기능 플래그가 적용된 인수#

인수는 기능 플래그 상태에 따라 무시되거나 값이 변경될 수 있습니다. 일반적인 용도는 기능 플래그가 비활성화되었을 때 인수를 무시하는 것입니다.

argument :foo, type: GraphQL::Types::String, required: false,
         experiment: { milestone: '10.0' },
         description: 'Some test argument. Is ignored if ' \
                      '`my_feature_flag` feature flag is disabled.'

def resolve(args)
  args.delete(:foo) unless Feature.enabled?(:my_feature_flag, object)
  # ...
end

기능 플래그가 적용된 뮤테이션#

기능 플래그 상태 때문에 수행할 수 없는 뮤테이션은 복구 불가능한 뮤테이션 오류로 처리됩니다. 오류는 최상위 수준에서 반환됩니다.

description 'Mutates an object. Does not mutate the object if ' \
            '`my_feature_flag` feature flag is disabled.'

def resolve(id: )
  object = authorized_find!(id: id)

  raise_resource_not_available_error! '`my_feature_flag` feature flag is disabled.' \
    if Feature.disabled?(:my_feature_flag, object)
  # ...
end

스키마 항목 사용 중단#

GitLab GraphQL API는 버전이 없으므로 모든 변경에서 이전 버전의 API와 하위 호환성을 유지합니다.

필드, 인수, 열거형 값, 뮤테이션을 제거하는 대신 사용 중단 처리해야 합니다.

사용 중단된 스키마 부분은 이후 릴리스에서 GitLab 사용 중단 프로세스에 따라 제거할 수 있습니다.

GraphQL에서 스키마 항목을 사용 중단하려면 다음을 수행합니다.

  1. 해당 항목의 사용 중단 이슈를 생성합니다.
  2. 스키마에서 항목을 사용 중단으로 표시합니다.

다음도 참고합니다.

사용 중단 이슈 생성#

모든 GraphQL 사용 중단에는 사용 중단과 제거를 추적하기 위해 Deprecations 이슈 템플릿을 사용하여 생성한 사용 중단 이슈가 있어야 합니다.

사용 중단 이슈에 다음 두 레이블을 적용합니다.

  • ~GraphQL
  • ~deprecation

항목을 사용 중단으로 표시#

필드, 인수, 열거형 값, 뮤테이션은 deprecated 속성을 사용하여 사용 중단합니다. 속성의 값은 다음으로 구성된 Hash입니다.

  • reason - 사용 중단 사유
  • milestone - 필드가 사용 중단된 마일스톤

예시:

field :token, GraphQL::Types::String, null: true,
      deprecated: { reason: 'Login via token has been removed', milestone: '10.0' },
      description: 'Token for login.'

사용 중단되는 항목의 원래 description은 유지해야 하며, 사용 중단을 언급하도록 업데이트해서는 안 됩니다. 대신 reason이 description 뒤에 덧붙여집니다.

사용 중단 사유 스타일 가이드#

필드, 인수 또는 열거형 값이 다른 것으로 대체되어 사용 중단하는 경우 reason에 대체 항목을 명시해야 합니다. 예를 들어 대체된 필드의 reason은 다음과 같습니다.

Use `otherFieldName`

예시:

field :designs, ::Types::DesignManagement::DesignCollectionType, null: true,
      deprecated: { reason: 'Use `designCollection`', milestone: '10.0' },
      description: 'The designs associated with this issue.',
module Types
  class TodoStateEnum < BaseEnum
    value 'pending', deprecated: { reason: 'Use PENDING', milestone: '10.0' }
    value 'done', deprecated: { reason: 'Use DONE', milestone: '10.0' }
    value 'PENDING', value: 'pending'
    value 'DONE', value: 'done'
  end
end

사용 중단되는 필드, 인수 또는 열거형 값이 대체되지 않는 경우 사용 중단 reason에 그 이유를 설명하는 내용을 적어야 합니다.

글로벌 ID 사용 중단#

글로벌 ID를 생성하고 파싱하기 위해 rails/globalid gem을 사용하므로, 글로벌 ID는 모델 이름에 종속됩니다. 모델 이름을 변경하면 해당 글로벌 ID도 변경됩니다.

글로벌 ID가 스키마의 어느 곳에서든 인수 타입으로 사용된다면, 글로벌 ID 변경은 일반적으로 호환성을 깨는 변경에 해당합니다.

이전 글로벌 ID 인수를 사용하는 클라이언트를 계속 지원하려면 Gitlab::GlobalId::Deprecations에 사용 중단을 추가합니다.

Note

글로벌 ID가 필드로만 노출된다면 사용 중단할 필요가 없습니다. 필드에서 글로벌 ID를 표현하는 방식의 변경은 하위 호환된다고 간주합니다. 클라이언트가 이 값을 파싱하지 않을 것으로 기대합니다. 이 값은 불투명한 토큰으로 취급되어야 하며, 그 안의 구조는 부수적인 것이므로 의존해서는 안 됩니다.

예시 시나리오:

이 예시 시나리오는 이 머지 리퀘스트를 기반으로 합니다.

PrometheusService라는 모델의 이름을 Integrations::Prometheus로 변경하려고 합니다. 이전 모델 이름은 뮤테이션의 인수로 사용되는 글로벌 ID 타입을 만드는 데 사용됩니다.

# Mutations::UpdatePrometheus:

argument :id, Types::GlobalIDType[::PrometheusService],
              required: true,
              description: "The ID of the integration to mutate."

클라이언트는 PrometheusServiceID로 명명되고 "gid://gitlab/PrometheusService/1"처럼 생긴 글로벌 ID 문자열을 input.id 인수로 전달하여 뮤테이션을 호출합니다.

mutation updatePrometheus($id: PrometheusServiceID!, $active: Boolean!) {
  prometheusIntegrationUpdate(input: { id: $id, active: $active }) {
    errors
    integration {
      active
    }
  }
}

모델 이름을 Integrations::Prometheus로 변경한 다음 코드베이스를 새 이름으로 업데이트합니다. 뮤테이션을 업데이트할 때는 이름이 변경된 모델을 Types::GlobalIDType[]에 전달합니다.

# Mutations::UpdatePrometheus:

argument :id, Types::GlobalIDType[::Integrations::Prometheus],
              required: true,
              description: "The ID of the integration to mutate."

이렇게 하면 API가 id 인수를 "gid://gitlab/PrometheusService/1"로 전달하거나 쿼리 시그니처에서 인수 타입을 PrometheusServiceID로 지정하는 클라이언트를 이제 거부하므로 뮤테이션에 호환성을 깨는 변경이 발생합니다.

클라이언트가 뮤테이션을 변경 없이 계속 사용할 수 있도록 하려면 Gitlab::GlobalId::Deprecations의 DEPRECATIONS 상수를 편집하고 배열에 새 Deprecation을 추가합니다.

DEPRECATIONS = [
  Gitlab::Graphql::DeprecationsBase::NameDeprecation.new(old_name: 'PrometheusService', new_name: 'Integrations::Prometheus', milestone: '14.0')
].freeze

그런 다음 일반 사용 중단 프로세스를 따릅니다. 이후에 이전 인수 방식의 지원을 제거하려면 Deprecation을 제거합니다.

DEPRECATIONS = [].freeze

사용 중단 기간 동안 API는 인수 값에 대해 다음 두 형식을 모두 허용합니다.

  • "gid://gitlab/PrometheusService/1"
  • "gid://gitlab/Integrations::Prometheus/1"

API는 인수의 쿼리 시그니처에서도 다음 타입을 허용합니다.

  • PrometheusServiceID
  • IntegrationsPrometheusID
Note

이전 타입(이 예시에서는 PrometheusServiceID)을 사용하는 쿼리는 API에서 유효하고 실행 가능한 것으로 간주되지만, 검증 도구는 유효하지 않은 것으로 간주합니다. @deprecated 디렉티브 밖의 별도 방식으로 사용 중단하기 때문에 유효하지 않은 것으로 간주되며, 검증 도구는 이 지원을 인식하지 못합니다.

문서에는 이전 글로벌 ID 방식이 이제 사용 중단되었다고 명시합니다.

스키마 항목을 실험으로 표시#

GraphQL 스키마 항목(필드, 인수, 열거형 값, 뮤테이션)을 실험으로 표시할 수 있습니다.

실험으로 표시된 항목은 사용 중단 프로세스에서 제외되며 공지 없이 언제든지 제거될 수 있습니다. 항목이 변경될 수 있고 공개 사용에 적합하지 않은 경우 실험으로 표시합니다.

Note

새 항목만 실험으로 표시합니다. 기존 항목은 이미 공개되어 있으므로 실험으로 표시해서는 안 됩니다.

스키마 항목을 실험으로 표시하려면 experiment: 키워드를 사용합니다. 실험 항목을 도입한 milestone:을 제공해야 합니다.

예를 들면 다음과 같습니다.

field :token, GraphQL::Types::String, null: true,
      experiment: { milestone: '10.0' },
      description: 'Token for login.'

마찬가지로 app/graphql/types/mutation_type.rb에서 뮤테이션이 마운트되는 위치를 업데이트하여 뮤테이션 전체를 실험으로 표시할 수도 있습니다.

mount_mutation Mutations::Ci::JobArtifact::BulkDestroy, experiment: { milestone: '15.10' }

실험적 GraphQL 항목은 GraphQL 사용 중단 기능을 활용하는 GitLab 고유 기능입니다. 실험 항목은 GraphQL 스키마에서 사용 중단된 것으로 표시됩니다. 사용 중단된 다른 모든 스키마 항목과 마찬가지로 대화형 GraphQL 탐색기(GraphiQL)에서 실험 필드를 테스트할 수 있습니다. 다만 GraphiQL 자동 완성 편집기는 사용 중단된 필드를 제안하지 않는다는 점에 유의합니다.

이 항목은 생성된 GraphQL 문서와 GraphQL 스키마 설명에서 experiment로 표시됩니다.

열거형#

GitLab GraphQL 열거형은 app/graphql/types에 정의됩니다. 새 열거형을 정의할 때는 다음 규칙이 적용됩니다.

  • 값은 대문자여야 합니다.
  • 클래스 이름은 문자열 Enum으로 끝나야 합니다.
  • graphql_name에는 문자열 Enum이 포함되면 안 됩니다.

예를 들면 다음과 같습니다.

module Types
  class TrafficLightStateEnum < BaseEnum
    graphql_name 'TrafficLightState'
    description 'State of a traffic light'

    value 'RED', description: 'Drivers must stop.'
    value 'YELLOW', description: 'Drivers must stop when it is safe to.'
    value 'GREEN', description: 'Drivers can start or keep driving.'
  end
end

열거형이 Ruby에서 대문자 문자열이 아닌 클래스 속성에 사용되는 경우 대문자 값에 맞춰 주는 value: 옵션을 제공할 수 있습니다.

다음 예시에서는 다음과 같이 동작합니다.

  • OPENED라는 GraphQL 입력은 'opened'로 변환됩니다.
  • 'opened'라는 Ruby 값은 GraphQL 응답에서 "OPENED"로 변환됩니다.
module Types
  class EpicStateEnum < BaseEnum
    graphql_name 'EpicState'
    description 'State of a GitLab epic'

    value 'OPENED', value: 'opened', description: 'An open Epic.'
    value 'CLOSED', value: 'closed', description: 'A closed Epic.'
  end
end

열거형 값은 deprecated 키워드를 사용하여 사용 중단할 수 있습니다.

Rails 열거형에서 GraphQL 열거형을 동적으로 정의#

GraphQL 열거형이 Rails 열거형을 기반으로 하는 경우 Rails 열거형을 사용하여 GraphQL 열거형 값을 동적으로 정의하는 것을 고려합니다. 이렇게 하면 GraphQL 열거형 값이 Rails 열거형 정의에 연결되므로, Rails 열거형에 값이 추가되면 GraphQL 열거형에도 변경 사항이 자동으로 반영됩니다.

예시:

module Types
  class IssuableSeverityEnum < BaseEnum
    graphql_name 'IssuableSeverity'
    description 'Incident severity'

    ::IssuableSeverity.severities.each_key do |severity|
      value severity.upcase, value: severity, description: "#{severity.titleize} severity."
    end
  end
end

JSON#

GraphQL이 반환할 데이터가 JSON으로 저장되어 있더라도, 가능하면 계속 GraphQL 타입을 사용해야 합니다. 반환되는 JSON 데이터가 정말로 구조가 없는 경우가 아니라면 GraphQL::Types::JSON 타입은 사용하지 않습니다.

JSON 데이터의 구조가 다양하지만 알려진 몇 가지 가능한 구조 중 하나라면 유니온을 사용합니다. 이 목적으로 유니온을 사용한 예로는 !30129가 있습니다.

필요한 경우 hash_key: 키워드를 사용하여 필드 이름을 해시 데이터 키에 매핑할 수 있습니다.

예를 들어 다음과 같은 JSON 데이터가 있다고 가정합니다.

{
  "title": "My chart",
  "data": [
    { "x": 0, "y": 1 },
    { "x": 1, "y": 1 },
    { "x": 2, "y": 2 }
  ]
}

다음과 같이 GraphQL 타입을 사용할 수 있습니다.

module Types
  class ChartType < BaseObject
    field :title, GraphQL::Types::String, null: true, description: 'Title of the chart.'
    field :data, [Types::ChartDatumType], null: true, description: 'Data of the chart.'
  end
end

module Types
  class ChartDatumType < BaseObject
    field :x, GraphQL::Types::Int, null: true, description: 'X-axis value of the chart datum.'
    field :y, GraphQL::Types::Int, null: true, description: 'Y-axis value of the chart datum.'
  end
end

설명#

모든 필드와 인수에는 설명이 있어야 합니다.

필드나 인수의 설명은 description: 키워드로 지정합니다. 예를 들면 다음과 같습니다.

field :id, GraphQL::Types::ID, description: 'ID of the issue.'
field :confidential, GraphQL::Types::Boolean, description: 'Indicates the issue is confidential.'
field :closed_at, Types::TimeType, description: 'Timestamp of when the issue was closed.'

필드와 인수의 설명은 다음에서 확인할 수 있습니다.

설명 스타일 가이드#

언어와 문장부호#

필드와 인수를 설명할 때는 가능하면 {x} of the {y} 형식을 사용합니다. 여기서 {x}는 설명하는 항목이고 {y}는 그 항목이 적용되는 리소스입니다. 예를 들면 다음과 같습니다.

ID of the issue.
Author of the epics.

정렬하거나 검색하는 인수는 적절한 동사로 시작합니다. 지정된 값을 나타낼 때는 간결하게 하기 위해 the given이나 the specified 대신 this를 사용할 수 있습니다. 예를 들면 다음과 같습니다.

Sort issues by this criteria.

일관성과 간결성을 위해 설명을 The나 A로 시작하지 않습니다.

모든 설명은 마침표(.)로 끝냅니다.

불리언#

불리언 필드(GraphQL::Types::Boolean)는 그 필드가 하는 일을 설명하는 동사로 시작합니다. 예를 들면 다음과 같습니다.

Indicates the issue is confidential.

필요한 경우 기본값을 제공합니다. 예를 들면 다음과 같습니다.

Sets the issue to confidential. Default is false.

정렬 열거형#

정렬용 열거형의 설명은 'Values for sorting {x}.'여야 합니다. 예를 들면 다음과 같습니다.

Values for sorting container repositories.

Types::TimeType 필드 설명#

Types::TimeType GraphQL 필드에는 timestamp라는 단어를 포함합니다. 이렇게 하면 독자가 해당 속성의 형식이 단순한 Date가 아니라 Time임을 알 수 있습니다.

예를 들면 다음과 같습니다.

field :closed_at, Types::TimeType, description: 'Timestamp of when the issue was closed.'

copy_field_description 헬퍼#

두 설명이 항상 동일하도록 보장하고 싶을 때가 있습니다. 예를 들어 타입 필드 설명을 같은 속성을 나타내는 뮤테이션 인수의 설명과 동일하게 유지하려는 경우입니다.

설명을 직접 지정하는 대신 copy_field_description 헬퍼를 사용할 수 있으며, 설명을 복사할 타입과 필드 이름을 전달합니다.

예시:

argument :title, GraphQL::Types::String,
          required: false,
          description: copy_field_description(Types::MergeRequestType, :title)

문서 참조#

설명에서 외부 URL을 참조하고 싶을 때가 있습니다. 이를 더 쉽게 하고 생성되는 참조 문서에 올바른 마크업을 제공하기 위해 필드에 see 속성을 제공합니다. 예를 들면 다음과 같습니다.

field :genus,
      type: GraphQL::Types::String,
      null: true,
      description: 'A taxonomic genus.'
      see: { 'Wikipedia page on genera' => 'https://wikipedia.org/wiki/Genus' }

이는 문서에서 다음과 같이 렌더링됩니다.

A taxonomic genus. See: [Wikipedia page on genera](https://wikipedia.org/wiki/Genus)

문서 참조를 여러 개 제공할 수 있습니다. 이 속성의 구문은 키가 텍스트 설명이고 값이 URL인 HashMap입니다.

구독 티어 배지#

필드나 인수를 다른 필드보다 높은 구독 티어에서만 사용할 수 있는 경우 가용성 세부 정보를 인라인으로 추가합니다.

예를 들면 다음과 같습니다.

description: 'Full path of a custom template. Premium and Ultimate only.'

인가#

GraphQL 인가를 참고합니다.

리졸버#

app/graphql/resolvers 디렉터리에 저장된 _리졸버_를 사용하여 애플리케이션이 응답을 제공하는 방식을 정의합니다. 리졸버는 해당 객체를 조회하는 실제 구현 로직을 제공합니다.

필드에 표시할 객체를 찾으려면 app/graphql/resolvers에 리졸버를 추가할 수 있습니다.

인수는 뮤테이션과 같은 방식으로 리졸버에서 정의할 수 있습니다. 인수 섹션을 참고합니다.

수행되는 쿼리의 양을 제한하려면 BatchLoader를 사용할 수 있습니다.

리졸버 작성#

코드는 파인더와 서비스를 감싸는 얇은 선언형 래퍼를 지향해야 합니다. 인수 목록을 반복해서 작성하거나 concern으로 추출할 수 있습니다. 대부분의 경우 상속보다 합성을 선호합니다. 리졸버를 컨트롤러처럼 다룹니다. 리졸버는 다른 애플리케이션 추상화를 합성하는 DSL이어야 합니다.

예를 들면 다음과 같습니다.

class PostResolver < BaseResolver
  type Post.connection_type, null: true
  authorize :read_blog
  description 'Blog posts, optionally filtered by name'

  argument :name, [::GraphQL::Types::String], required: false, as: :slug

  alias_method :blog, :object

  def resolve(**args)
    PostFinder.new(blog, current_user, args).execute
  end
end

같은 객체가 노출되는 두 개의 서로 다른 필드처럼 서로 다른 두 곳에서 동일한 리졸버 클래스를 사용할 수는 있지만, 리졸버 객체를 직접 재사용해서는 안 됩니다. 리졸버는 복잡한 수명 주기를 가지며, 인가, 준비 상태, 리졸브가 프레임워크에 의해 조율되고 각 단계에서 배칭 기회를 활용하기 위해 지연 값이 반환될 수 있습니다. 애플리케이션 코드에서 리졸버나 뮤테이션을 인스턴스화해서는 안 됩니다.

대신 코드 재사용 단위는 애플리케이션의 나머지 부분과 거의 같습니다.

  • 데이터를 조회하는 쿼리의 파인더
  • 작업을 적용하는 뮤테이션의 서비스
  • 쿼리에 특화된 로더(배치를 인식하는 파인더)

뮤테이션에서 배칭을 사용할 이유는 전혀 없습니다. 뮤테이션은 순차적으로 실행되므로 배칭 기회가 없습니다. 모든 값은 요청되는 즉시 즉시 평가되므로 배칭은 불필요한 오버헤드입니다. 다음을 작성하는 경우입니다.

  • Mutation은 객체를 직접 조회해도 됩니다.
  • Resolver 또는 BaseObject의 메서드는 배칭을 허용해야 합니다.

오류 처리#

리졸버는 오류를 발생시킬 수 있으며, 이는 적절하게 최상위 오류로 변환됩니다. 예상되는 모든 오류는 잡아서 적절한 GraphQL 오류로 변환해야 합니다( Gitlab::Graphql::Errors 참고). 잡히지 않은 오류는 억제되며 클라이언트는 Internal service error 메시지를 받습니다.

한 가지 특별한 경우는 권한 오류입니다. REST API에서는 사용자가 접근할 권한이 없는 리소스에 대해 404 Not Found를 반환합니다. GraphQL에서 이에 해당하는 동작은 존재하지 않거나 인가되지 않은 모든 리소스에 대해 null을 반환하는 것입니다. 쿼리 리졸버는 인가되지 않은 리소스에 대해 오류를 발생시켜서는 안 됩니다.

그 이유는 클라이언트가 레코드가 없는 경우와 접근 권한이 없는 레코드가 있는 경우를 구별할 수 없어야 하기 때문입니다. 구별할 수 있다면 숨기고 싶은 정보가 유출되므로 보안 취약점이 됩니다.

대부분의 경우 이를 걱정할 필요가 없습니다. authorize DSL 호출로 선언하는 리졸버 필드 인가가 이를 올바르게 처리합니다. 더 맞춤화된 작업이 필요하다면, 필드를 리졸브할 때 current_user가 접근할 수 없는 객체를 만나면 해당 필드 전체가 null로 리졸브되어야 한다는 점을 기억합니다.

리졸버 파생#

(BaseResolver.single 및 BaseResolver.last 포함)

일부 사용 사례에서는 다른 리졸버에서 리졸버를 파생할 수 있습니다. 주된 사용 사례는 모든 항목을 찾는 리졸버 하나와 특정 항목 하나를 찾는 리졸버 하나를 만드는 것입니다. 이를 위해 편의 메서드를 제공합니다.

  • BaseResolver.single: 첫 번째 항목을 선택하는 새 리졸버를 생성합니다.
  • BaseResolver.last: 마지막 항목을 선택하는 리졸버를 생성합니다.

올바른 단수형 타입은 컬렉션 타입에서 추론되므로 여기에서 type을 정의할 필요가 없습니다.

이 메서드를 사용하기 전에 다음 중 하나가 더 간단하지 않은지 검토합니다.

  • 자체 인수를 정의하는 다른 리졸버를 작성합니다.
  • 쿼리를 추상화하는 concern을 작성합니다.

BaseResolver.single을 지나치게 자유롭게 사용하는 것은 안티패턴입니다. 인수가 주어지지 않으면 첫 번째 MR만 반환하는 Project.mergeRequest 필드처럼 무의미한 필드가 만들어질 수 있습니다. 컬렉션 리졸버에서 단일 리졸버를 파생할 때는 항상 더 제한적인 인수를 가져야 합니다.

이를 가능하게 하려면 when_single 블록을 사용하여 단일 리졸버를 사용자 지정합니다. 모든 when_single 블록은 다음을 충족해야 합니다.

  • 인수를 하나 이상 정의(또는 재정의)합니다.
  • 선택적 필터를 필수로 만듭니다.

예를 들어 기존 선택적 인수를 재정의하여 타입을 변경하고 필수로 만들 수 있습니다.

class JobsResolver < BaseResolver
  type JobType.connection_type, null: true
  authorize :read_pipeline

  argument :name, [::GraphQL::Types::String], required: false

  when_single do
    argument :name, ::GraphQL::Types::String, required: true
  end

  def resolve(**args)
    JobsFinder.new(pipeline, current_user, args.compact).execute
  end

여기에는 파이프라인 job을 가져오는 리졸버가 있습니다. name 인수는 목록을 가져올 때는 선택 사항이지만 단일 job을 가져올 때는 필수입니다.

인수가 여러 개이고 어느 쪽도 필수로 만들 수 없는 경우 블록을 사용하여 준비 조건을 추가할 수 있습니다.

class JobsResolver < BaseResolver
  alias_method :pipeline, :object

  type JobType.connection_type, null: true
  authorize :read_pipeline

  argument :name, [::GraphQL::Types::String], required: false
  argument :id, [::Types::GlobalIDType[::Job]],
           required: false,
           prepare: ->(ids, ctx) { ids.map(&:model_id) }

  when_single do
    argument :name, ::GraphQL::Types::String, required: false
    argument :id, ::Types::GlobalIDType[::Job],
             required: false
             prepare: ->(id, ctx) { id.model_id }

    def ready?(**args)
      raise ::Gitlab::Graphql::Errors::ArgumentError, 'Only one argument may be provided' unless args.size == 1
    end
  end

  def resolve(**args)
    JobsFinder.new(pipeline, current_user, args.compact).execute
  end

그런 다음 이러한 리졸버를 필드에서 사용할 수 있습니다.

# In PipelineType

field :jobs, resolver: JobsResolver, description: 'All jobs.'
field :job, resolver: JobsResolver.single, description: 'A single job.'

리졸버 최적화#

룩어헤드#

실행 중에는 전체 쿼리를 미리 알 수 있으므로 룩어헤드를 활용하여 쿼리를 최적화하고 필요한 것으로 알려진 연관 관계를 배치 로드할 수 있습니다. N+1 성능 문제를 피하려면 리졸버에 룩어헤드 지원을 추가하는 것을 고려합니다.

일반적인 룩어헤드 사용 사례(하위 필드가 요청되었을 때 연관 관계 미리 로드)를 지원하려면 LooksAhead를 포함할 수 있습니다. 예를 들면 다음과 같습니다.

# Assuming a model `MyThing` with attributes `[child_attribute, other_attribute, nested]`,
# where nested has an attribute named `included_attribute`.
class MyThingResolver < BaseResolver
  include LooksAhead

  # Rather than defining `resolve(**args)`, we implement: `resolve_with_lookahead(**args)`
  def resolve_with_lookahead(**args)
    apply_lookahead(MyThingFinder.new(current_user).execute)
  end

  # We list things that should always be preloaded:
  # For example, if child_attribute is always needed (during authorization
  # perhaps), then we can include it here.
  def unconditional_includes
    [:child_attribute]
  end

  # We list things that should be included if a certain field is selected:
  def preloads
    {
        field_one: [:other_attribute],
        field_two: [{ nested: [:included_attribute] }]
    }
  end
end

기본적으로 #preloads에 정의된 필드는 쿼리에서 해당 필드가 선택되었을 때 미리 로드됩니다. 때로는 너무 많은 콘텐츠나 잘못된 콘텐츠를 미리 로드하지 않도록 더 세밀한 제어가 필요할 수 있습니다.

위 예시를 확장하여, 특정 필드가 함께 요청되었을 때 다른 연관 관계를 미리 로드하고 싶을 수 있습니다. 이는 #filtered_preloads를 재정의하여 수행할 수 있습니다.

class MyThingResolver < BaseResolver
  # ...

  def filtered_preloads
    return [:alternate_attribute] if lookahead.selects?(:field_one) && lookahead.selects?(:field_two)

    super
  end
end

LooksAhead concern은 중첩된 GraphQL 필드 정의를 기반으로 한 연관 관계 미리 로드도 지원합니다. 중첩 필드가 선택되었을 때 지정한 연관 관계를 미리 로드하려면 필드 이름 배열을 해시 키로 사용합니다. 예를 들면 다음과 같습니다.

class MyThingResolver < BaseResolver
  # ...

  def preloads
    {
      [:root_field, :nested_field1] => :association_to_preload,
      [:root_field, :nested_field2] => [:association1, :association2],
      [:root_field, :nested_field2, :nested_field3] => :association3,
      other_root_field: :other_association,
    }
  end
end

실제 사용 예시는 WorkItems::LookAheadPreloads를 참고합니다.

before_connection_authorization#

before_connection_authorization 훅은 리졸버가 타입 인가 권한 검사에서 비롯되는 N+1 문제를 제거하는 데 도움이 됩니다.

before_connection_authorization 메서드는 리졸브된 노드와 현재 사용자를 받습니다. 블록에서 ActiveRecord::Associations::Preloader 또는 Preloaders:: 클래스를 사용하여 타입 인가 검사에 필요한 데이터를 미리 로드합니다.

예시:

class LabelsResolver < BaseResolver
  before_connection_authorization do |labels, current_user|
    Preloaders::LabelsPreloader.new(labels, current_user).preload_all
  end
end

배치 로딩#

GraphQL BatchLoader를 참고합니다.

Resolver#ready?의 올바른 사용#

리졸버에는 프레임워크의 일부로 두 개의 공개 API 메서드 #ready?(**args)와 #resolve(**args)가 있습니다. #resolve를 호출하지 않고 설정을 수행하거나 조기 반환하려면 #ready?를 사용할 수 있습니다.

#ready?를 사용하기 좋은 경우는 다음과 같습니다.

  • 결과가 있을 수 없음을 미리 알고 있는 경우 Relation.none을 반환합니다.
  • 인스턴스 변수 초기화 같은 설정을 수행합니다(다만 이 경우 지연 초기화 메서드를 고려합니다).

Resolver#ready?(**args) 구현은 다음과 같이 (Boolean, early_return_data)를 반환해야 합니다.

def ready?(**args)
  [false, 'have this instead']
end

이러한 이유로 리졸버를 호출할 때마다(주로 테스트에서, 프레임워크 추상화인 리졸버는 재사용 가능한 것으로 간주해서는 안 되며 파인더를 선호해야 합니다) resolve를 호출하기 전에 ready? 메서드를 호출하고 불리언 플래그를 확인해야 합니다. 예시는 GraphqlHelpers에서 볼 수 있습니다.

인수를 검증할 때는 #ready?를 사용하는 것보다 검증기를 사용하는 것이 좋습니다.

부정 인수#

부정 필터는 일부 리소스를 걸러 낼 수 있습니다(예: bug 레이블이 있지만 bug2 레이블은 지정되지 않은 모든 이슈 찾기). 부정 인수를 전달하는 권장 구문은 not 인수입니다.

issues(labelName: "bug", not: {labelName: "bug2"}) {
  nodes {
    id
    title
  }
}

타입이나 리졸버에서 Gitlab::Graphql::NegatableArguments의 negated 헬퍼를 사용할 수 있습니다. 예를 들면 다음과 같습니다.

extend ::Gitlab::Graphql::NegatableArguments

negated do
  argument :labels, [GraphQL::STRING_TYPE],
            required: false,
            as: :label_name,
            description: 'Array of label names. All resolved merge requests will not have these labels.'
end

메타데이터#

리졸버를 사용할 때 리졸버는 필드 메타데이터의 단일 진실 공급원이 될 수 있으며 그렇게 해야 합니다. 필드 이름을 제외한 모든 필드 옵션을 리졸버에서 선언할 수 있습니다. 여기에는 다음이 포함됩니다.

  • type(필수 - 모든 리졸버에는 타입 어노테이션이 있어야 합니다)
  • extras
  • description
  • Gitaly 어노테이션(calls_gitaly! 사용)

예시:

module Resolvers
  MyResolver < BaseResolver
    type Types::MyType, null: true
    extras [:lookahead]
    description 'Retrieve a single MyType'
    calls_gitaly!
  end
end

부모 객체를 자식 프레젠터에 전달#

필드를 계산하기 위해 자식 컨텍스트에서 리졸브된 쿼리의 부모에 접근해야 할 때가 있습니다. 일반적으로 부모는 Resolver 클래스에서만 parent로 사용할 수 있습니다.

Presenter 클래스에서 부모 객체를 찾으려면 다음을 수행합니다.

  1. 리졸버의 resolve 메서드에서 부모 객체를 GraphQL context에 추가합니다.

      def resolve(**args)
        context[:parent_object] = parent
      end
    
  2. 리졸버나 필드에 parent 필드 컨텍스트가 필요하다고 선언합니다. 예를 들면 다음과 같습니다.

      # in ChildType
      field :computed_field, SomeType, null: true,
            method: :my_computing_method,
            extras: [:parent], # Necessary
            description: 'My field description.'
    
      field :resolver_field, resolver: SomeTypeResolver
    
      # In SomeTypeResolver
    
      extras [:parent]
      type SomeType, null: true
      description 'My field description.'
    
  3. 프레젠터 클래스에서 필드의 메서드를 선언하고 parent 키워드 인수를 받도록 합니다. 이 인수에는 부모 GraphQL 컨텍스트가 들어 있으므로 부모 객체는 parent[:parent_object] 또는 Resolver에서 사용한 키로 접근해야 합니다.

      # in ChildPresenter
      def my_computing_method(parent:)
        # do something with `parent[:parent_object]` here
      end
    
      # In SomeTypeResolver
    
      def resolve(parent:)
        # ...
      end
    

실제 사용 예시는 IterationPresenter에 scopedPath와 scopedUrl을 추가한 이 MR을 확인합니다.

뮤테이션#

뮤테이션은 저장된 값을 변경하거나 동작을 트리거하는 데 사용됩니다. GET 요청이 데이터를 수정해서는 안 되는 것과 같이 일반 GraphQL 쿼리에서는 데이터를 수정할 수 없습니다. 하지만 뮤테이션에서는 수정할 수 있습니다.

뮤테이션 작성#

뮤테이션은 app/graphql/mutations에 저장되며, 서비스와 마찬가지로 수정하는 리소스별로 그룹화하는 것이 이상적입니다. 뮤테이션은 Mutations::BaseMutation을 상속해야 합니다. 뮤테이션에 정의된 필드가 뮤테이션의 결과로 반환됩니다.

업데이트 뮤테이션 세분성#

GitLab의 서비스 지향 아키텍처에서는 대부분의 뮤테이션이 Create, Delete 또는 Update 서비스(예: UpdateMergeRequestService)를 호출합니다. Update 뮤테이션에서는 객체의 한 측면만 업데이트하려 할 수 있으며, 그런 경우 MergeRequest::SetDraft 같은 세분화된 뮤테이션만 필요합니다.

세분화된 뮤테이션과 포괄적인 뮤테이션을 모두 두는 것은 허용되지만, 세분화된 뮤테이션이 너무 많으면 유지 관리성, 코드 이해도, 테스트 측면에서 조직적인 어려움으로 이어질 수 있다는 점에 유의합니다. 각 뮤테이션에는 새 클래스가 필요하며 이는 기술 부채로 이어질 수 있습니다. 또한 스키마가 매우 커진다는 뜻이며, 사용자가 스키마를 탐색하기 어려워질 수 있습니다. 새 뮤테이션마다 테스트(더 느린 요청 통합 테스트 포함)도 필요하므로 뮤테이션을 추가하면 테스트 스위트가 느려집니다.

변경을 최소화하려면 다음을 수행합니다.

  • 가능하면 MergeRequest::Update 같은 기존 뮤테이션을 사용합니다.
  • 기존 서비스를 포괄적인 뮤테이션으로 노출합니다.

세분화된 뮤테이션이 더 적절할 수 있는 경우는 다음과 같습니다.

  • 특정 권한이나 기타 특수한 로직이 필요한 속성을 수정하는 경우
  • 상태 머신과 같은 전환(이슈 잠금, MR 머지, 에픽 닫기 등)을 노출하는 경우
  • 중첩 속성을 허용하는 경우(자식 객체의 속성을 허용하는 경우)
  • 뮤테이션의 의미를 명확하고 간결하게 표현할 수 있는 경우

자세한 배경은 이슈 #233063을 참고합니다.

명명 규칙#

각 뮤테이션은 GraphQL 스키마에서 뮤테이션의 이름인 graphql_name을 정의해야 합니다.

예시:

class UserUpdateMutation < BaseMutation
  graphql_name 'UserUpdate'
end

graphql-ruby gem 1.13 버전의 변경으로 인해 타입 이름이 올바르게 생성되도록 graphql_name이 클래스의 첫 번째 줄에 있어야 합니다. Graphql::GraphqlNamePosition cop이 이를 강제합니다. 자세한 배경은 이슈 #27536을 참고합니다.

GraphQL 뮤테이션 이름은 역사적으로 일관성이 없지만, 새 뮤테이션 이름은 '{Resource}{Action}' 또는 '{Resource}{Action}{Attribute}' 관례를 따라야 합니다.

새 리소스를 생성하는 뮤테이션은 동사 Create를 사용해야 합니다.

예시:

  • CommitCreate

데이터를 업데이트하는 뮤테이션은 다음을 사용해야 합니다.

  • 동사 Update
  • 더 적절한 경우 Set, Add, Toggle 같은 도메인 특화 동사

예시:

  • EpicTreeReorder
  • IssueSetWeight
  • IssueUpdate
  • TodoMarkDone

데이터를 제거하는 뮤테이션은 다음을 사용해야 합니다.

  • Destroy 대신 동사 Delete
  • 더 적절한 경우 Remove 같은 도메인 특화 동사

예시:

  • AwardEmojiRemove
  • NoteDelete

뮤테이션 이름에 관한 조언이 필요하면 Slack #graphql 채널에서 의견을 구합니다.

필드#

가장 일반적인 상황에서 뮤테이션은 2개의 필드를 반환합니다.

  • 수정되는 리소스
  • 작업을 수행할 수 없었던 이유를 설명하는 오류 목록. 뮤테이션이 성공했다면 이 목록은 비어 있습니다.

모든 새 뮤테이션이 Mutations::BaseMutation을 상속하면 errors 필드가 자동으로 추가됩니다. clientMutationId 필드도 추가되며, 클라이언트는 단일 요청에서 여러 뮤테이션을 수행할 때 이 필드로 개별 뮤테이션의 결과를 식별할 수 있습니다.

resolve 메서드#

리졸버 작성과 마찬가지로, 뮤테이션의 resolve 메서드는 서비스를 감싸는 얇은 선언형 래퍼를 지향해야 합니다.

resolve 메서드는 뮤테이션의 인수를 키워드 인수로 받습니다. 여기에서 리소스를 수정하는 서비스를 호출할 수 있습니다.

그런 다음 resolve 메서드는 뮤테이션에 정의된 것과 같은 필드 이름과 errors 배열을 포함한 해시를 반환해야 합니다. 예를 들어 Mutations::MergeRequests::SetDraft는 merge_request 필드를 정의합니다.

field :merge_request,
      Types::MergeRequestType,
      null: true,
      description: "The merge request after mutation."

이는 이 뮤테이션에서 resolve가 반환하는 해시가 다음과 같아야 한다는 뜻입니다.

{
  # The merge request modified, this will be wrapped in the type
  # defined on the field
  merge_request: merge_request,
  # An array of strings if the mutation failed after authorization.
  # The `errors_on_object` helper collects `errors.full_messages`
  errors: errors_on_object(merge_request)
}

뮤테이션 마운트#

뮤테이션을 사용할 수 있게 하려면 graphql/types/mutation_type에 저장된 뮤테이션 타입에 뮤테이션을 정의해야 합니다. mount_mutation 헬퍼 메서드는 뮤테이션의 GraphQL 이름을 기반으로 필드를 정의합니다.

module Types
  class MutationType < BaseObject
    graphql_name 'Mutation'

    include Gitlab::Graphql::MountMutation

    mount_mutation Mutations::MergeRequests::SetDraft
  end
end

이렇게 하면 Mutations::MergeRequests::SetDraft가 리졸브되도록 하는 mergeRequestSetDraft라는 필드가 생성됩니다.

리소스 인가#

뮤테이션 안에서 리소스를 인가하려면 먼저 다음과 같이 뮤테이션에 필요한 ability를 제공합니다.

module Mutations
  module MergeRequests
    class SetDraft < Base
      graphql_name 'MergeRequestSetDraft'

      authorize :update_merge_request
    end
  end
end

그런 다음 resolve 메서드에서 authorize!를 호출하여 ability를 검증할 리소스를 전달할 수 있습니다.

또는 뮤테이션에서 객체를 로드하는 find_object 메서드를 추가할 수 있습니다. 이렇게 하면 authorized_find! 헬퍼 메서드를 사용할 수 있습니다.

사용자가 해당 작업을 수행할 수 없거나 인가 때문에 리소스를 찾을 수 없는 경우(사용자가 접근할 수 없음), resolve 메서드 안에서 raise_resource_not_available_error!를 호출하여 Gitlab::Graphql::Errors::ResourceNotAvailable을 발생시켜야 합니다. 사용자 입력 검증 오류(예: 잘못된 프로젝트 경로나 형식이 올바르지 않은 식별자)는 대신 뮤테이션 페이로드의 errors 배열로 반환하여 클라이언트가 사용자에게 의미 있는 메시지를 표시할 수 있도록 합니다. 자세한 내용은 뮤테이션의 오류를 참고합니다.

뮤테이션의 오류#

뮤테이션에는 데이터로서의 오류 방식을 따를 것을 권장하며, 이 방식은 오류를 누구와 관련 있는지, 즉 누가 처리할 수 있는지에 따라 구분합니다.

핵심 사항:

  • 모든 뮤테이션 응답에는 errors 필드가 있습니다. 실패 시에는 이 필드를 채워야 하며, 성공 시에도 채울 수 있습니다.
  • 오류를 누가 봐야 하는지 고려합니다. 사용자인지 개발자인지입니다.
  • 클라이언트는 뮤테이션을 수행할 때 항상 errors 필드를 요청해야 합니다.
  • 오류는 $root.errors(최상위 오류) 또는 $root.data.mutationName.errors(뮤테이션 오류)에서 사용자에게 보고될 수 있습니다. 위치는 오류의 종류와 담고 있는 정보에 따라 달라집니다.
  • 뮤테이션 필드에는 null: true가 있어야 합니다.

errors: [String]과 thing: ThingType이라는 두 필드가 있는 응답을 반환하는 예시 뮤테이션 doTheThing을 생각해 봅니다. 여기서는 오류를 다루므로 thing 자체의 구체적인 성격은 이 예시와 관련이 없습니다.

뮤테이션 응답이 가질 수 있는 세 가지 상태는 다음과 같습니다.

성공#

정상 경로에서는 예상되는 페이로드와 함께 오류가 반환될 수 있지만, 모든 것이 성공했다면 사용자에게 알려야 할 문제가 없으므로 errors는 빈 배열이어야 합니다.

{
  data: {
    doTheThing: {
      errors: [] // if successful, this array will generally be empty.
      thing: { .. }
    }
  }
}

실패(사용자와 관련 있음)#

사용자에게 영향을 미치는 오류가 발생했습니다. 이를 _뮤테이션 오류_라고 합니다.

create 뮤테이션에서는 일반적으로 반환할 thing이 없습니다.

update 뮤테이션에서는 thing의 현재 실제 상태를 반환합니다. 개발자는 이를 보장하기 위해 thing 인스턴스에서 #reset을 호출해야 할 수 있습니다.

{
  data: {
    doTheThing: {
      errors: ["you cannot touch the thing"],
      thing: { .. }
    }
  }
}

다음이 그 예시입니다.

  • 모델 검증 오류: 사용자가 입력을 변경해야 할 수 있습니다.
  • 권한 오류: 사용자는 이 작업을 할 수 없다는 것을 알아야 하며, 권한을 요청하거나 로그인해야 할 수 있습니다.
  • 사용자의 작업을 막는 애플리케이션 상태 문제(예: 머지 충돌이나 잠긴 리소스)

이상적으로는 사용자가 이 단계까지 오지 않도록 막아야 하지만, 오게 되었다면 사용자가 실패의 이유와 의도를 달성하기 위해 무엇을 할 수 있는지 이해할 수 있도록 무엇이 잘못되었는지 알려야 합니다. 예를 들어 요청을 다시 시도하기만 하면 될 수도 있습니다.

복구 가능한 오류를 뮤테이션 데이터와 함께 반환할 수 있습니다. 예를 들어 사용자가 파일 10개를 업로드했는데 3개는 실패하고 나머지는 성공한 경우, 실패에 대한 오류를 성공에 대한 정보와 함께 사용자에게 제공할 수 있습니다.

실패(사용자와 관련 없음)#

복구할 수 없는 오류를 _최상위 수준_에서 하나 이상 반환할 수 있습니다. 이러한 오류는 사용자가 거의 또는 전혀 제어할 수 없는 것이며, 주로 개발자가 알아야 하는 시스템 또는 프로그래밍 문제여야 합니다. 이 경우 data가 없습니다.

{
  errors: [
    {"message": "argument error: expected an integer, got null"},
  ]
}

이는 뮤테이션 중에 오류를 발생시켜서 생깁니다. 현재 구현에서는 인수 오류와 검증 오류의 메시지는 클라이언트에 반환되고, 그 외 모든 StandardError 인스턴스는 잡아서 기록한 뒤 메시지를 "Internal server error"로 설정하여 클라이언트에 표시합니다. 자세한 내용은 GraphqlController를 참고합니다.

이는 다음과 같은 프로그래밍 오류를 나타냅니다.

  • String 대신 Int가 전달되었거나 필수 인수가 없는 GraphQL 구문 오류
  • non-nullable 필드에 값을 제공할 수 없는 경우와 같은 스키마 오류
  • 시스템 오류: 예를 들어 Git 스토리지 예외나 데이터베이스 사용 불가

사용자가 일반적인 사용 중에 이러한 오류를 일으킬 수 있어서는 안 됩니다. 이 범주의 오류는 내부 오류로 취급하며, 사용자에게 구체적인 내용을 보여 주지 않습니다.

뮤테이션이 실패했을 때 사용자에게 알려야 하지만, 사용자가 원인을 제공한 것이 아니고 사용자가 할 수 있는 일로 해결되지도 않으므로 이유까지 알릴 필요는 없습니다. 다만 뮤테이션을 다시 시도하도록 제안할 수는 있습니다.

오류 분류#

뮤테이션을 작성할 때는 오류 상태가 이 두 범주 중 어디에 속하는지 의식해야 합니다(그리고 가정을 검증하기 위해 프론트엔드 개발자와 이에 대해 소통합니다). 이는 _사용자_의 요구와 _클라이언트_의 요구를 구별한다는 뜻입니다.

사용자가 알아야 하는 경우가 아니라면 오류를 절대 잡지 않습니다.

사용자가 알아야 한다면 프론트엔드 개발자와 소통하여 우리가 돌려주는 오류 정보가 관련이 있고 목적에 맞는지 확인합니다.

프론트엔드 GraphQL 가이드도 참고합니다.

뮤테이션 별칭 지정 및 사용 중단#

#mount_aliased_mutation 헬퍼를 사용하면 뮤테이션을 MutationType에서 다른 이름의 별칭으로 지정할 수 있습니다.

예를 들어 FooMutation이라는 뮤테이션을 BarMutation의 별칭으로 지정하려면 다음과 같습니다.

mount_aliased_mutation 'BarMutation', Mutations::FooMutation

이를 deprecated 인수와 함께 사용하면 뮤테이션 이름을 변경하면서 이전 이름도 계속 지원할 수 있습니다.

예시:

mount_aliased_mutation 'UpdateFoo',
                        Mutations::Foo::Update,
                        deprecated: { reason: 'Use fooUpdate', milestone: '13.2' }

사용 중단된 뮤테이션은 Types::DeprecatedMutations에 추가하고 Types::MutationType의 단위 테스트에서 테스트해야 합니다. 머지 리퀘스트 !34798을 사용 중단된 별칭 뮤테이션의 테스트 방법을 포함한 예시로 참고할 수 있습니다.

EE 뮤테이션 사용 중단#

EE 뮤테이션도 같은 프로세스를 따라야 합니다. 머지 리퀘스트 프로세스의 예시는 머지 리퀘스트 !42588을 읽어 봅니다.

서브스크립션#

서브스크립션을 사용하여 클라이언트에 업데이트를 푸시합니다. Action Cable 구현을 사용하여 웹소켓으로 메시지를 전달합니다.

클라이언트가 서브스크립션을 구독하면 해당 쿼리를 Puma 워커의 메모리에 저장합니다. 이후 서브스크립션이 트리거되면 Puma 워커가 저장된 GraphQL 쿼리를 실행하고 결과를 클라이언트에 푸시합니다.

Note

서브스크립션은 Action Cable 클라이언트가 필요한데 GraphiQL이 현재 이를 지원하지 않으므로 GraphiQL로 서브스크립션을 테스트할 수 없습니다.

서브스크립션 작성#

Types::SubscriptionType 아래의 모든 필드는 클라이언트가 구독할 수 있는 서브스크립션입니다. 이러한 필드에는 서브스크립션 클래스가 필요하며, 이 클래스는 Subscriptions::BaseSubscription의 하위 클래스이고 app/graphql/subscriptions 아래에 저장됩니다.

구독에 필요한 인수와 반환되는 필드는 서브스크립션 클래스에 정의됩니다. 인수가 같고 같은 필드를 반환하는 경우 여러 필드가 동일한 서브스크립션 클래스를 공유할 수 있습니다.

이 클래스는 최초 구독 요청과 이후 업데이트 중에 실행됩니다. 자세한 내용은 GraphQL Ruby 가이드에서 확인할 수 있습니다.

인가#

최초 구독과 이후 업데이트가 인가되도록 서브스크립션 클래스의 #authorized? 메서드를 구현해야 합니다.

사용자가 인가되지 않은 경우 실행이 중단되고 사용자의 구독이 해제되도록 unauthorized! 헬퍼를 호출해야 합니다.

글로벌 ID나 구독 대상 객체를 기준으로 권한을 확인하는 일반적인 경우에는 #authorize_object_or_gid! 헬퍼를 사용합니다. 최초 구독에는 객체가 없으므로 주어진 글로벌 ID를 사용해 객체를 가져옵니다. 그러나 이후 업데이트에서는 같은 객체의 다른 인스턴스를 가져오지 않도록 사용자에게 반환하는 객체를 사용합니다. object 인수를 사용하여 인가할 객체를 지정할 수도 있습니다.

서브스크립션 트리거#

서브스크립션을 트리거하려면 GraphqlTriggers 모듈 아래에 메서드를 정의합니다. 단일 진실 공급원을 유지하고 서로 다른 인수와 객체로 서브스크립션이 트리거되지 않도록 애플리케이션 코드에서 GitlabSchema.subscriptions.trigger를 직접 호출하지 않습니다.

페이지네이션 구현#

자세한 내용은 GraphQL 페이지네이션을 참고합니다.

인수#

리졸버나 뮤테이션의 인수는 argument를 사용하여 정의합니다.

예시:

argument :my_arg, GraphQL::Types::String,
         required: true,
         description: "A description of the argument."

loads:를 사용하지 않기#

인수 정의에서 loads: 옵션을 사용하지 않습니다. "not found"와 "not authorized"에 대해 서로 다른 오류를 반환하여 리소스 존재 여부에 관한 정보가 유출됩니다. 대신 글로벌 ID를 받아 authorized_find!로 객체를 직접 로드합니다. 자세한 내용과 예시는 인수 정의에서 loads:를 사용하지 않기를 참고합니다.

Null 허용 여부#

인수는 required: true로 표시할 수 있으며, 이는 값이 반드시 있어야 하고 null이 아니어야 한다는 뜻입니다. 필수 인수의 값이 null일 수 있는 경우 required: :nullable 선언을 사용합니다.

예시:

argument :due_date,
         Types::TimeType,
         required: :nullable,
         description: 'The desired due date for the issue. Due date is removed if null.'

위 예시에서 due_date 인수는 반드시 지정해야 하지만, GraphQL 사양과 달리 값은 null일 수 있습니다. 이를 통해 마감일을 제거하는 새 뮤테이션을 만들지 않고 단일 뮤테이션에서 마감일을 '해제'할 수 있습니다.

{ due_date: null } # => OK
{ due_date: "2025-01-10" } # => OK
{  } # => invalid (not given)

Null 허용 여부와 required: false#

인수가 required: false로 표시되면 클라이언트가 값으로 null을 보내는 것이 허용됩니다. 이는 바람직하지 않은 경우가 많습니다.

인수가 선택 사항이지만 null이 허용되는 값이 아닌 경우, 검증을 사용하여 null을 전달하면 오류가 반환되도록 합니다.

argument :name, GraphQL::Types::String,
         required: false,
         validates: { allow_null: false }

또는 허용되지 않는 값인 null을 허용하려는 경우 기본값으로 대체할 수 있습니다.

argument :name, GraphQL::Types::String,
         required: false,
         default_value: "No Name Provided",
         replace_null_with_default: true

자세한 내용은 검증, Nullability, 기본값을 참고합니다.

상호 배타적 인수#

인수를 상호 배타적으로 표시하여 동시에 제공되지 않도록 할 수 있습니다. 나열된 인수가 둘 이상 주어지면 최상위 오류가 추가됩니다.

예시:

argument :user_id, GraphQL::Types::String, required: false
argument :username, GraphQL::Types::String, required: false

validates mutually_exclusive: [:user_id, :username]

인수가 정확히 하나만 필요한 경우 exactly_one_of 검증기를 사용할 수 있습니다.

예시:

argument :group_path, GraphQL::Types::String, required: false
argument :project_path, GraphQL::Types::String, required: false

validates exactly_one_of: [:group_path, :project_path]

키워드#

정의된 각 GraphQL argument는 뮤테이션의 #resolve 메서드에 키워드 인수로 전달됩니다.

예시:

def resolve(my_arg:)
  # Perform mutation ...
end

입력 타입#

graphql-ruby는 인수를 입력 타입으로 묶습니다.

예를 들어 mergeRequestSetDraft 뮤테이션은 다음 인수를 정의합니다(일부는 상속을 통해 정의됨).

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: "Project the merge request belongs to."

argument :iid, GraphQL::Types::String,
         required: true,
         description: "IID of the merge request."

argument :draft,
         GraphQL::Types::Boolean,
         required: false,
         description: <<~DESC
           Whether or not to set the merge request as a draft.
         DESC

이 인수들은 지정한 3개의 인수와 clientMutationId를 가진 MergeRequestSetDraftInput이라는 입력 타입을 자동으로 생성합니다.

객체 식별자 인수#

객체를 식별하는 인수는 다음과 같아야 합니다.

  • 객체에 전체 경로나 IID가 있다면 전체 경로 또는 IID
  • 그 외 모든 객체는 객체의 글로벌 ID. 일반 데이터베이스 기본 키 ID는 절대 사용하지 않습니다.

전체 경로 객체 식별자 인수#

역사적으로 전체 경로 인수의 이름은 일관성이 없었지만, 다음과 같이 이름을 짓는 것을 선호합니다.

  • 프로젝트 전체 경로에는 project_path
  • 그룹 전체 경로에는 group_path
  • 네임스페이스 전체 경로에는 namespace_path

ciJobTokenScopeRemoveProject 뮤테이션의 예시는 다음과 같습니다.

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: 'Project the CI job token scope belongs to.'

IID 객체 식별자 인수#

객체의 iid를 부모의 project_path 또는 group_path와 함께 사용합니다. 예를 들면 다음과 같습니다.

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: 'Project the issue belongs to.'

argument :iid, GraphQL::Types::String,
         required: true,
         description: 'IID of the issue.'

글로벌 ID 객체 식별자 인수#

discussionToggleResolve 뮤테이션의 예시는 다음과 같습니다.

argument :id, Types::GlobalIDType[Discussion],
         required: true,
         description: 'Global ID of the discussion.'

글로벌 ID 사용 중단도 참고합니다.

Workhorse 지원 업로드#

파일 콘텐츠를 받는 모든 GraphQL API 뮤테이션은 Workhorse 지원 업로드를 사용해야 합니다.

구현 세부 사항은 Workhorse 업로드 문서를 참고합니다.

정렬 인수#

정렬 인수는 가능하면 사용 가능한 정렬 값 집합을 설명하는 열거형 타입을 사용해야 합니다.

열거형은 Types::SortEnum을 상속하여 몇 가지 공통 값을 상속받을 수 있습니다.

열거형 값은 {PROPERTY}_{DIRECTION} 형식을 따라야 합니다. 예를 들면 다음과 같습니다.

TITLE_ASC

정렬 열거형의 설명 스타일 가이드도 참고합니다.

ContainerRepositoriesResolver의 예시는 다음과 같습니다.

# Types::ContainerRegistry::ContainerRepositorySortEnum:
module Types
  module ContainerRegistry
    class ContainerRepositorySortEnum < SortEnum
      graphql_name 'ContainerRepositorySort'
      description 'Values for sorting container repositories'

      value 'NAME_ASC', 'Name by ascending order.', value: :name_asc
      value 'NAME_DESC', 'Name by descending order.', value: :name_desc
    end
  end
end

# Resolvers::ContainerRepositoriesResolver:
argument :sort, Types::ContainerRegistry::ContainerRepositorySortEnum,
          description: 'Sort container repositories by this criteria.',
          required: false,
          default_value: :created_desc

GitLab 사용자 지정 스칼라#

Types::TimeType#

Types::TimeType은 Ruby Time 및 DateTime 객체를 다루는 모든 필드와 인수의 타입으로 사용해야 합니다.

이 타입은 사용자 지정 스칼라이며 다음을 수행합니다.

  • GraphQL 필드의 타입으로 사용될 때 Ruby의 Time 및 DateTime 객체를 표준화된 ISO-8601 형식 문자열로 변환합니다.
  • GraphQL 인수의 타입으로 사용될 때 ISO-8601 형식의 시간 문자열을 Ruby Time 객체로 변환합니다.

이를 통해 GraphQL API가 시간을 표시하고 시간 입력을 처리하는 표준화된 방식을 갖게 됩니다.

예시:

field :created_at, Types::TimeType, null: true, description: 'Timestamp of when the issue was created.'

글로벌 ID 스칼라#

모든 글로벌 ID는 사용자 지정 스칼라입니다. 이들은 동적으로 생성되며 추상 스칼라 클래스 Types::GlobalIDType에서 만들어집니다.

테스트#

쿼리나 뮤테이션이 올바르게 실행되고 리졸브되는지는 통합 테스트만 완전히 검증할 수 있습니다.

단위 테스트는 타입에 특정 필드가 있는지, 뮤테이션에 특정 필수 인수가 있는지 등 스키마의 특정 측면을 정적으로 검증하는 용도로만 사용합니다. 필드나 인수를 정적으로 검증하는 것 이상으로 리졸버를 단위 테스트하지 않습니다.

그 외의 모든 테스트에는 통합 테스트를 사용합니다.

통합 테스트 작성#

통합 테스트는 GraphQL 쿼리나 뮤테이션의 전체 스택을 확인하며 spec/requests/api/graphql에 저장됩니다.

모든 실행 단계를 완전히 테스트하기 위해 통합 테스트를 사용합니다. 전체 요청 통합 테스트만 다음을 검증합니다.

  • 뮤테이션이 스키마에서 실제로 쿼리 가능한지(MutationType에 마운트되었는지)
  • 리졸버나 뮤테이션이 반환하는 데이터가 필드의 반환 타입과 올바르게 일치하고 오류 없이 리졸브되는지
  • 인수가 입력 시 올바르게 강제 변환되고, 필드가 출력 시 올바르게 직렬화되는지
  • 모든 인수 전처리
  • 인수나 스칼라의 검증이 올바르게 적용되는지
  • 인수의 default_value가 올바르게 적용되는지
  • 리졸버나 뮤테이션의 #ready? 메서드 로직이 올바르게 적용되는지
  • 객체가 성공적으로 리졸브되고 N+1 문제가 없는지

쿼리를 추가할 때는 a working graphql query that returns data 및 a working graphql query that returns no data shared example을 사용하여 쿼리가 유효한 결과를 렌더링하는지 테스트할 수 있습니다.

post_graphql 헬퍼를 사용하여 GraphQL 통합 테스트를 수행합니다.

예를 들면 다음과 같습니다.

# Good:
gql_query = %q(some query text...)
post_graphql(gql_query, current_user: current_user)
# or:
GitlabSchema.execute(gql_query, context: { current_user: current_user })

# Deprecated: avoid
resolve(described_class, obj: project, ctx: { current_user: current_user })

GraphqlHelpers#all_graphql_fields_for 헬퍼를 사용하여 사용 가능한 모든 필드를 포함하는 쿼리를 구성할 수 있습니다. 이렇게 하면 쿼리의 가능한 모든 필드를 렌더링하는 테스트를 더 간단하게 추가할 수 있습니다.

페이지네이션과 정렬을 지원하는 쿼리에 필드를 추가하는 경우 자세한 내용은 테스트를 참고합니다.

GraphQL 뮤테이션 요청을 테스트하기 위해 GraphqlHelpers는 두 가지 헬퍼를 제공합니다. graphql_mutation은 뮤테이션 이름과 뮤테이션 입력이 담긴 해시를 받으며, 뮤테이션 쿼리와 준비된 변수가 담긴 구조체를 반환합니다.

그런 다음 이 구조체를 post_graphql_mutation 헬퍼에 전달하면 GraphQL 클라이언트처럼 올바른 매개변수로 요청을 게시합니다.

뮤테이션의 응답에 접근하려면 graphql_mutation_response 헬퍼를 사용할 수 있습니다.

이러한 헬퍼를 사용하여 다음과 같은 스펙을 작성할 수 있습니다.

let(:mutation) do
  graphql_mutation(
    :merge_request_set_wip,
    project_path: 'gitlab-org/gitlab-foss',
    iid: '1',
    wip: true
  )
end

it 'returns a successful response' do
   post_graphql_mutation(mutation, current_user: user)

   expect(response).to have_gitlab_http_status(:success)
   expect(graphql_mutation_response(:merge_request_set_wip)['errors']).to be_empty
end

테스트 팁과 요령#

  • GraphqlHelpers 지원 모듈의 메서드에 익숙해집니다. 이 메서드 중 다수는 GraphQL 테스트 작성을 더 쉽게 해 줍니다.

  • GraphqlHelpers#graphql_data_at과 GraphqlHelpers#graphql_dig_at 같은 탐색 헬퍼를 사용하여 결과 필드에 접근합니다. 예를 들면 다음과 같습니다.

    result = GitlabSchema.execute(query)
    
    mr_iid = graphql_dig_at(result.to_h, :data, :project, :merge_request, :iid)
    
  • 결과와 비교하려면 GraphqlHelpers#a_graphql_entity_for를 사용합니다. 예를 들면 다음과 같습니다.

    post_graphql(some_query)
    
    # checks that it is a hash containing { id => global_id_of(issue) }
    expect(graphql_data_at(:project, :issues, :nodes))
      .to contain_exactly(a_graphql_entity_for(issue))
    
    # Additional fields can be passed, either as names of methods, or with values
    expect(graphql_data_at(:project, :issues, :nodes))
      .to contain_exactly(a_graphql_entity_for(issue, :iid, :title, created_at: some_time))
    
  • 빈 스키마는 직접 만들지 않고 GraphqlHelpers#empty_schema를 사용하여 생성합니다. 예를 들면 다음과 같습니다.

    # good
    let(:schema) { empty_schema }
    
    # bad
    let(:query_type) { GraphQL::ObjectType.new }
    let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
    
  • double('query', schema: nil) 대신 GraphqlHelpers#query_double(schema: nil)을 사용합니다. 예를 들면 다음과 같습니다.

    # good
    let(:query) { query_double(schema: GitlabSchema) }
    
    # bad
    let(:query) { double('Query', schema: GitlabSchema) }
    
  • 프론트엔드에서 사용하는 쿼리를 테스트하려면 GraphqlHelpers#get_graphql_query_as_string을 사용합니다. 예를 들면 다음과 같습니다.

    let(:query) { get_graphql_query_as_string('work_items/graphql/project_work_items.query.graphql') }
    let(:variables) { { 'fullPath' => project.full_path } }
    
    ...
    
    post_graphql(query, variables: variables)
    
  • 거짓 양성을 피합니다.

    post_graphql의 current_user: 인수로 사용자를 인증하면 같은 사용자에 대한 이후 요청보다 첫 번째 요청에서 더 많은 쿼리가 생성됩니다. QueryRecorder로 N+1 쿼리를 테스트하는 경우 요청마다 다른 사용자를 사용합니다.

    다음 예시는 N+1 쿼리를 피하는 테스트가 어떤 모습이어야 하는지 보여 줍니다.

    RSpec.describe 'Query.project(fullPath).pipelines' do
      include GraphqlHelpers
    
      let(:project) { create(:project) }
    
      let(:query) do
        %(
          {
            project(fullPath: "#{project.full_path}") {
              pipelines {
                nodes {
                  id
                }
              }
            }
          }
        )
      end
    
      it 'avoids N+1 queries' do
        first_user = create(:user)
        second_user = create(:user)
        create(:ci_pipeline, project: project)
    
        control_count = ActiveRecord::QueryRecorder.new do
          post_graphql(query, current_user: first_user)
        end
    
        create(:ci_pipeline, project: project)
    
        expect do
          post_graphql(query, current_user: second_user)  # use a different user to avoid a false positive from authentication queries
        end.not_to exceed_query_limit(control_count)
      end
    end
    
  • app/graphql/types의 폴더 구조를 따릅니다.

    예를 들어 app/graphql/types/ci/pipeline_type.rb의 Types::Ci::PipelineType 필드에 대한 테스트는 파이프라인 데이터를 가져오는 데 사용된 쿼리와 관계없이 spec/requests/api/graphql/ci/pipeline_spec.rb에 저장해야 합니다.

단위 테스트 작성#

단위 테스트는 스키마를 정적으로 검증하는 용도로만 사용합니다. 예를 들어 다음을 확인합니다.

  • 타입, 뮤테이션 또는 리졸버에 특정 이름의 필드가 있는지
  • 타입, 뮤테이션 또는 리졸버에 특정 이름의 authorize 권한이 있는지(단, 인가는 통합 테스트로 테스트합니다)
  • 뮤테이션이나 리졸버에 특정 이름의 인수가 있는지, 그리고 해당 인수가 필수인지 여부

정적 스키마 테스트 외에는 리졸버가 어떻게 리졸브하는지 또는 인가를 어떻게 적용하는지를 단위 테스트하지 않습니다. 대신 통합 테스트를 사용하여 전체 실행 단계를 테스트합니다.

쿼리 흐름과 GraphQL 인프라에 대한 참고 사항#

GitLab GraphQL 인프라는 lib/gitlab/graphql에 있습니다.

인스트루멘테이션은 실행 중인 쿼리를 감싸는 기능입니다. Instrumentation 클래스를 사용하는 모듈로 구현됩니다.

예시: Present

module Gitlab
  module Graphql
    module Present
      #... some code above...

      def self.use(schema_definition)
        schema_definition.instrument(:field, ::Gitlab::Graphql::Present::Instrumentation.new)
      end
    end
  end
end

쿼리 분석기에는 쿼리를 실행하기 전에 검증하는 콜백 집합이 들어 있습니다. 각 필드는 분석기를 거칠 수 있으며, 최종 값도 사용할 수 있습니다.

멀티플렉스 쿼리를 사용하면 여러 쿼리를 하나의 요청으로 보낼 수 있습니다. 이렇게 하면 서버로 보내는 요청 수가 줄어듭니다 (GraphQL Ruby가 제공하는 사용자 지정 멀티플렉스 쿼리 분석기와 멀티플렉스 인스트루멘테이션이 있습니다).

쿼리 제한#

쿼리와 뮤테이션은 지나치게 야심 찬 쿼리나 악의적인 쿼리로부터 서버 리소스를 보호하기 위해 깊이, 복잡도, 재귀로 제한됩니다. 이러한 값은 기본값으로 설정할 수 있으며 필요에 따라 특정 쿼리에서 재정의할 수 있습니다. 복잡도 값은 객체별로도 설정할 수 있으며, 최종 쿼리 복잡도는 반환되는 객체 수를 기준으로 평가됩니다. 이는 비용이 큰 객체 (예: Gitaly 호출이 필요한 객체)에 사용할 수 있습니다.

예를 들어 리졸버의 조건부 복잡도 메서드는 다음과 같습니다.

def self.resolver_complexity(args, child_complexity:)
  complexity = super
  complexity += 2 if args[:label_name]

  complexity
end

복잡도에 대한 자세한 내용은 GraphQL Ruby 문서를 참고합니다.

문서와 스키마#

스키마는 app/graphql/gitlab_schema.rb에 있습니다. 자세한 내용은 스키마 참조를 참고합니다.

스키마가 변경되면 생성된 이 GraphQL 문서를 업데이트해야 합니다. GraphQL 문서와 스키마 파일을 생성하는 방법은 스키마 문서 업데이트를 참고합니다.

독자를 돕기 위해 GraphQL API 문서에 새 페이지도 추가해야 합니다. 안내는 GraphQL API 페이지를 참고합니다.

변경 로그 항목 포함#

클라이언트에 영향을 주는 모든 변경에는 변경 로그 항목이 포함되어야 합니다.

지연 처리#

성능을 관리하기 위한 GraphQL 고유의 중요한 기법 중 하나는 지연 값을 사용하는 것입니다. 지연 값은 결과에 대한 약속을 나타내며, 해당 동작을 나중에 실행할 수 있게 하여 쿼리 트리의 서로 다른 부분에 있는 쿼리를 배치 처리할 수 있게 합니다. 코드에서 지연 값의 대표적인 예는 GraphQL BatchLoader입니다.

지연 값을 직접 관리하려면 Gitlab::Graphql::Lazy, 특히 Gitlab::Graphql::Laziness를 읽어 봅니다. 여기에는 필요한 경우 지연 처리의 생성과 제거라는 기본 작업을 구현하는 데 도움이 되는 #force와 #delay가 있습니다.

지연 값을 강제로 평가하지 않고 다루려면 Gitlab::Graphql::Lazy.with_value를 사용합니다.