GraphQL에 대해 알아보는 글을 포스팅합니다. 이 글에선 GraphQL의 구조에 대해 알아봅니다. 

이전 글인 Graphql에 대한 기본적인 소개와 REST API와의 비교를 통한 차이점은 다음 글에서 확인해 볼 수 있습니다.

 

 

 

 

 

 

 

1. 쿼리(Query)와 뮤테이션(Mutation)

 

쿼리는 읽기를 요청하는 구문이며 뮤테이션은 수정을 요청하는 구문입니다. gql에서는 굳이 쿼리와 뮤테이션을 나누는데 사실상 이 둘은 별 차이가 없어 보입니다. 쿼리는 데이터를 읽는데(R) 사용하고, 뮤테이션은 데이터를 변조(CUD) 하는 데 사용한다는 개념 적인 규약을 정해 놓은 것뿐입니다.

 

다만 쿼리가 실행되는 순서에 중요한 차이점이 있는데 Query 필드가 병렬로 실행되는 동안 Mutation 필드는 순차적으로 실행됩니다. 즉, 하나의 요청에서 두 개의 Mutation를 보내면 순차적으로 실행되어 경쟁 상태가 되지 않도록 합니다. (GraphQL의 구현 조건입니다.)

 

다음 두 쿼리문을 한번 봅시다.

{
  human(id: "1000") {
    name
    height
  }
}

query HeroNameAndFriends($episode: Episode) {
  hero(episode: $episode) {
    name
    friends {
      name
    }
  }
}

 

두 쿼리 문의 차이가 보이시나요? 결론부터 말하면 위의 쿼리는 오퍼레이션 이름(Operation name)이 붙지 않은 일반적이 인 오퍼레이션 즉, gql의 기본 쿼리문이고 아래의 쿼리는 오퍼레이션 이름이 붙은 쿼리입니다.

** 오퍼레이션(Operation)은 GraphQL 실행 엔진으로 해석할 수 있는 단일 쿼리(Queyr), 뮤테이션(Mutation) 또는 구독(Subscription)을 의미합니다.

 

일반적인 경우 클라이언트에서 static 한 쿼리문을 작성하지는 않을 것입니다. 주로 정보를 불러올 때 id 값이나, 다른 인자 값을 가지고 데이터를 불러올 것입니다. gql을 구현한 클라이언트에서는 이 변수에 프로그래밍으로 값을 할당할 수 있는 함수 인터페이스가 존재합니다. 

 

오퍼레이션 이름이 있는 쿼리

 

위의 그림을 통해 오퍼레이션에 대해 좀 더 자세히 알아보겠습니다. 

  • 오퍼레이션 타입: 어떤 유형의 작업을 수행하고자 하는지 표시합니다. query, mutation 또는 subscription입니다. 
  • 오퍼레이션 이름: 오퍼레이션에 이름을 부여합니다. 프로그래밍 언어의 함수 이름과 유사합니다. 이름을 정해두면 네트워크 로그나 GraphQL 서버에서 문제가 발생했을 시 내용을 해독할 필요 없이 코드 베이스에서 해당 쿼리를 쉽게 찾을 수 있습니다.
  • 변수 정의: 쿼리에 필요한 변수입니다. GraphQL 서버로 쿼리를 보낼 때 실제 쿼리는 동일하지만 요청마다 변경되는 동적 부분이 있을 수 있습니다. 이때 변수가 사용됩니다. 이 변수는 쿼리와 별도로 전송되며 전송 포맷은 JSON입니다.

 

오퍼레이션 이름이 붙은 쿼리는 데이터베이스에서의 프로시저(Procedure) 개념과 유사하다고 생각하면 됩니다.

이 개념 덕분에 REST API를 호출할 때와 다르게, 한 번의 인터넷 네트워크 왕복으로 여러분이 원하는 모든 데이터를 가져올 수 있습니다.

 

데이터베이스의 프로시저는 DBA 혹은 백앤드 프로그래머가 작성하고 관리하였지만, gql 오퍼레이션은 는 클라이언트 프로그래머가 작성하고 관리합니다.

이전 협업 방식(REST API)에서는 프런트 앤드 프로그래머는 백앤드 프로그래머가 작성하여 전달하는 API의 request / response의 형식에 의존하게 됩니다. 그러나, gql을 사용한 방식에 서는 이러한 의존도가 많이 사라집니다만 여전히 데이터 스키마에 대한 협업 의존성은 존재합니다.

 

 

2. 스키마(Schema)와 타입(Type)

 

데이터베이스 스키마(Schema)를 작성할 때의 경험을 SQL 쿼리 작성으로 비유한다면, gql 스키마를 작성할 때의 경험은 C, C++의 헤더 파일 작성에 비유가 됩니다. 그러므로, 프로그래밍 언어(C, C++, JAVA 등)에 익숙한 프로그래머라면 스키마 정의 또한 쉽게 배울 수 있습니다.

 

GraphQL 스키마의 가장 기본적인 구성 요소는 객체(Object) 타입입니다. 객체 타입은 서비스에서 가져올 수 있는 객체의 종류와 그 객체의 필드를 나타냅니다. GraphQL 스키마 언어에서는 다음과 같이 표현할 수 있습니다.

 

type Character {
  name: String!
  appearsIn: [Episode]!
}

 

위 스키마에 대해 좀 더 자세히 알아봅시다.

  • Character: GraphQL의 객체 타입. 즉, 필드가 있는 타입이 됩니다. 대부분의 스키마는 객체 타입입니다.
  • name과 appersIn: Character 타입의 필드.  name과 appearsIn은 GraphQL 쿼리의 Character 타입 어디서든 사용할 수 있는 필드가 됩니다.
  • String: 내장된 스칼라 타입 중 하나. 스칼라 객체로 해석되는 타입을 의미하며 쿼리에서 하위 선택을 할 수 없습니다.
  • !: 해당 필드가 NULL을 허용하지 않음을 의미합니다. 이 필드를 쿼리 할 때 GraphQL 서비스가 항상 값을 반환함을 의미합니다. 
  • [Episode]!: Episode 객체의 배열을 나타내며 NULL이 아님을 의미합니다. 따라서 appearIn 필드를 쿼리 하면 항상 0개 이상의 아이템을 가진 배열을 기대할 수 있습니다.

 

 

3.3 리졸버(Resolver)

 

데이터베이스 사용 시, 데이터를 가져오기 위해서 sql을 작성했습니다. 또한, 데이터베이스에는 데이터베이스 애플리케이션을 사용하여 데이터를 가져오는 구체적인 과정이 구현되어 있습니다. 그러나 gql에서는 데이터를 가져오는 구체적인 과정을 직접 구현해야 합니다. 


gql 쿼리문 파싱은 대부분의 gql 라이브러리에서 처리를 하지만, gql에서 데이터를 가져오는 구체적인 과정은 resolver(이하 리졸버)가 담당하고, 이를 직접 구현해야 합니다.  프로그래머는 리졸버를 직접 구현해야 하는 부담은 있지만, 이를 통해서 데이터 source의 종류에 상관없이 구현이 가능합니다. 


예를 들어서, 리졸버를 통해 데이터를 데이터베이스에서 가져올 수 있고, 일반 파일에서 가져올 수 있고, 심지어 http, SOAP와 같은 네트워크 프로토콜을 활용해서 원격 데이터를 가져올 수 있습니다. 
이러한 특성을 이용하면 legacy 시스템을 gql 기반으로 바꾸는데 활용할 수 있습니다.

gql 쿼리에서는 각각의 필드마다 함수가 하나씩 존재한다고 생각하면 됩니다. 이 함수는 다음 타입을 반환합니다. 이러한 각각의 함수를 리졸버(resolver)라고 합니다. 다시 말하자면 리졸버는 쿼리에서 특정 필드에 대한 요청이 있을 때, 그것을 어떤 로직으로 처리할지 GraphQL에게 알려주는 역할을 맡습니다. 만약 필드가 스칼라 값(문자열이나 숫자와 같은 primitive 타입)인 경우에는 실행이 종료됩니다. 즉 더 이상의 연쇄적인 리졸버 호출이 일어나지 않습니다. 
하지만 필드의 타입이 스칼라 타입이 아닌 우리가 정의한 타입이라면 해당 타입의 리졸버를 호출되게 됩니다.

 

다음 예시를 통해 리졸버에 대해 좀 더 알아봅시다.

 

Query: {
  human(obj, args, context, info) {
    return context.db.loadHumanByID(args.id).then(
      userData => new Human(userData)
    )
  }
}

 

이 예시에서 Query 타입은 인자 id를 받아 human 필드를 반환합니다. 이 필드의 리졸버 함수는 데이터베이스에 접근한 다음 Human 객체를 생성하고 반환합니다. 이 예시는 자바스크립트로 작성되었지만 GraphQL 서버는 다양한 언어로 만들 수 있습니다. 

 

리졸버 함수는 네 개의 인수를 받습니다.

  • obj: 루트 Query 타입 이전의 객체.
  • args: GraphQl 쿼리의 필드에 제공된 인수들.
  • context: 모든 리졸버 함수에 전달되며 현재 로그인한 사용자, 데이터베이스 액세스 등 중요한 컨텍스트 정보를 갖고 있는 값.
  • info: 현재 쿼리, 스키마 정보와 관련된 빌드 별 정보를 갖고 있는 값.

 

예시에서 볼 수 있듯이 각각의 리졸버 함수에는 내부적으로 데이터베이스 쿼리가 존재합니다. 이 말인즉, 쿼리에 맞게 필요한 만큼만 최적화하여 호출할 수 있다는 의미입니다.

기존에 REST API 시대에는 정해진 쿼리는 무조건 전부 호출이 되었습니다. 이러한 리졸버 체인을 잘 활용한다면, 효율적인 설계가 가능합니다. 

 

 

 

4. 인스트로펙션(Instropection, 스키마 확인)

 

기존 서버-클라이언트 협업 방식에서는 연동 규격서라고 하는 API 명세서를 주고받는 절차가 반드시 필요했습니다. 프로젝트 관리 측면에서 관리해야 할 대상의 증가는 작업의 복잡성 및 효율성 저해를 의미합니다. 이 API 명세서는 때때로 관리가 제대로 되지 않아, 인터페이스 변경 사항을 제때 문서에 반영하지 못하기도 하고, 제 타이밍에 전달 못하곤 합니다.

이러한 REST의 API 명세서 공유와 같은 문제를 해결하는 것이 gql의 인트로스펙션 기능입니다. gql의 인트로스펙션은 서버 자체에서 현재 서버에 정의된 스키마의 실시간 정보를 공유를 할 수 있게 합니다. 이 스키마 정보만 알고 있으면 클라이언트 사이드에서는 따로 연동 규격서를 요청할 필요가 없게 됩니다. 클라이언트 사이드에서는 실시간으로 현재 서버에서 정의하고 있는 스키마를 의심할 필요 없이 받아들이고, 그에 맞게 쿼리문을 작성하면 됩니다.

이러한 인트로스펙션용 쿼리가 따로 존재합니다. 일반 gql 쿼리문을 작성하듯이 작성하면 됩니다. 다만 실제로는 굳이 스키마 인트로스펙션을 위해 gql 쿼리문을 작성할 필요가 없습니다. 대부분의 서버용 gql 라이브러리에는 쿼리용 IDE를 제공합니다. 

 

프로그래머는 인트로스펙션을 활용하여, 직접 쿼리 및 뮤테이션, 필드 스키마를 확인할 수 있습니다. 물론 보안상의 이슈로 상용 환경에서는 이러한 스키마의 공개는 신중해야 합니다. 대부분의 라이브러리는 해당 기능을 켜고 끄게 하는 옵션이 존재합니다.

 

아래의 예시로 인스트로펙션 쿼리와 그에 대한 결과를 확인해 봅시다.

일반적으로 타입 시스템을 사용하기 때문에 우리는 유효한 타입이 무엇인지 알고 있지만, 그렇지 않은 경우에는 Query의 루트 타입에서 항상 사용할 수 있는 __schema 필드를 쿼리 하여 GraphQL에 요청할 수 있습니다. 아래의 쿼리는 그 예시입니다.

 

{
  __schema {
    types {
      name
    }
  }
}

 

더보기

{
  "data": {
    "__schema": {
      "types": [
        {
          "name": "Query"
        },
        {
          "name": "Episode"
        },
        {
          "name": "Character"
        },
        {
          "name": "ID"
        },
        {
          "name": "String"
        },
        {
          "name": "Int"
        },
        {
          "name": "FriendsConnection"
        },
        {
          "name": "FriendsEdge"
        },
        {
          "name": "PageInfo"
        },
        {
          "name": "Boolean"
        },
        {
          "name": "Review"
        },
        {
          "name": "SearchResult"
        },
        {
          "name": "Human"
        },
        {
          "name": "LengthUnit"
        },
        {
          "name": "Float"
        },
        {
          "name": "Starship"
        },
        {
          "name": "Droid"
        },
        {
          "name": "Mutation"
        },
        {
          "name": "ReviewInput"
        },
        {
          "name": "__Schema"
        },
        {
          "name": "__Type"
        },
        {
          "name": "__TypeKind"
        },
        {
          "name": "__Field"
        },
        {
          "name": "__InputValue"
        },
        {
          "name": "__EnumValue"
        },
        {
          "name": "__Directive"
        },
        {
          "name": "__DirectiveLocation"
        }
      ]
    }
  }
}

* 접은 글을 펼치면 예시 결과를 확인할 수 있습니다.

 

  • Query, Character, Human, Episode, Droid: 타입 시스템에서 정의한 것들입니다.
  • __Schema, __Type, __TypeKind, __Field, __InputValue, __EnumValue, __Directive: 모두 앞에는 두 개의 밑줄이 붙어있는데, 이것은 인스트로펙션 시스템의 일부임을 나타냅니다.

 

어떤 객체가 어떤 필드를 사용할 수 있는지 알아보기 위해 예시로 Droid를 인스트로펙션 시스템에 요청해 봅시다.

 

{
  __type(name: "Droid") {
    name
    fields {
      name
      type {
        name
        kind
      }
    }
  }
}
더보기

{
  "data": {
    "__type": {
      "name": "Droid",
      "fields": [
        {
          "name": "id",
          "type": {
            "name": null,
            "kind": "NON_NULL"
          }
        },
        {
          "name": "name",
          "type": {
            "name": null,
            "kind": "NON_NULL"
          }
        },
        {
          "name": "friends",
          "type": {
            "name": null,
            "kind": "LIST"
          }
        },
        {
          "name": "friendsConnection",
          "type": {
            "name": null,
            "kind": "NON_NULL"
          }
        },
        {
          "name": "appearsIn",
          "type": {
            "name": null,
            "kind": "NON_NULL"
          }
        },
        {
          "name": "primaryFunction",
          "type": {
            "name": "String",
            "kind": "SCALAR"
          }
        }
      ]
    }
  }
}

* 접은 글을 펼치면 예시 결과를 확인할 수 있습니다.

 

결과를 확인해 보면 "NON_NULL" 타입의 경우 "name"에 null인 것을 확인할 수 있습니다. 종류가 "NON_NULL"인 wrapper 타입으로 인식하기 때문입니다.

비슷하게, friends와 appearIn 둘 다 LIST wrapper 타입이기 때문에 이름이 없습니다. 이 타입에 대해 ofType을 쿼리 할 수 있습니다. 그러면 이 list 가 어떤 list 인지 알 수 있습니다.

 

{
  __type(name: "Droid") {
    name
    fields {
      name
      type {
        name
        kind
        ofType {
          name
          kind
        }
      }
    }
  }
}
더보기

{
  "data": {
    "__type": {
      "name": "Droid",
      "fields": [
        {
          "name": "id",
          "type": {
            "name": null,
            "kind": "NON_NULL",
            "ofType": {
              "name": "ID",
              "kind": "SCALAR"
            }
          }
        },
        {
          "name": "name",
          "type": {
            "name": null,
            "kind": "NON_NULL",
            "ofType": {
              "name": "String",
              "kind": "SCALAR"
            }
          }
        },
        {
          "name": "friends",
          "type": {
            "name": null,
            "kind": "LIST",
            "ofType": {
              "name": "Character",
              "kind": "INTERFACE"
            }
          }
        },
        {
          "name": "friendsConnection",
          "type": {
            "name": null,
            "kind": "NON_NULL",
            "ofType": {
              "name": "FriendsConnection",
              "kind": "OBJECT"
            }
          }
        },
        {
          "name": "appearsIn",
          "type": {
            "name": null,
            "kind": "NON_NULL",
            "ofType": {
              "name": null,
              "kind": "LIST"
            }
          }
        },
        {
          "name": "primaryFunction",
          "type": {
            "name": "String",
            "kind": "SCALAR",
            "ofType": null
          }
        }
      ]
    }
  }
}

* 접은 글을 펼치면 예시 결과를 확인할 수 있습니다.

 

 

 

 

 

References

반응형

+ Recent posts