Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[spring graphql] #51

Open
backtony opened this issue May 7, 2023 · 0 comments
Open

[spring graphql] #51

backtony opened this issue May 7, 2023 · 0 comments

Comments

@backtony
Copy link
Owner

backtony commented May 7, 2023

techdozo : https://techdozo.dev/category/microservices/graphql/

위 자료를 한글로 번역 + 참고 자료 내용 추가 -> 추가 참고자료는 글 맨 아래 명시

spring graphql

  • graphql-java / graphql-java-spring
  • graphql-java-kickstart / graphql-spring-boot
  • Netflix / dgs-framework

기존 Java 진영에서 Spring과 함께 GraphQL을 사용하기 위해서는 위 3개가 대표적인 라이브러리/프레임워크로 3개 중 1개를 선택해 스프링에서 graphql을 사용했었다.

이 중 graphql-java에서 spring에서 사용하기 위해 제공하던 graphql-java-spring 라이브러리가 spring project로 이전되어 spring-graphql로 변경되었다.

앞서 설명한 라이브러리/프레임워크를 사용하면 Resolver를 개발하고 별도의 설정 등 필요했지만 Spring for GraphQL은 graphql-java의 단순 후속 프로젝트뿐 아니라 graphql-java 개발팀이 개발을 하여서 Spring이 추구하는 방향답게 추가적인 코드 없이 기존 MVC 개발하듯 개발하면 된다.

spring-graphql은 2022 5월에 1.0.x이 release 되었고, 22년 11월에 1.1.x가 release 되었다. 23년 5월에 1.2.x 가 release 예정이다.

getting start

GraphQL

  • API용 쿼리 언어
  • 서버 런타임
    • 서비스 측에서 graphQL 서비스는 API에 의해 노출된 데이터의 구조를 설명하는 런타임 계층을 제공하며 이 런타임 계층은 GraphQL 요청을 구문 분석하고 각 필드에 대해 적절한 데이터 가져오기 프로그램(리졸버)를 호출한다.

즉, GraphQL은 API 쿼리 언어이자 쿼리 실행을 위한 서버측 런타임이며, 특정 db, 언어에 종속되지 않는다.

operation

HTTP method(GET, POST ...) 처럼 graphQL에는 3가지 operation이 존재한다.

  • query : read 작업
  • mutation : write 작업(write 후 읽기도 가능)
  • subscription : 지속적인 읽기 (ex, websocket)

object type and field

type Book {
    id : ID
    name : String
    author: String
    price: Float
    ratings: [Rating]
}

graphQL의 스키마는 object type으로 구성된다.

  • book : object
  • field : 구성요소
  • scalar type : string, float, int, boolean은 scalar type으로 graphql의 내장 빌트인 타입
  • id : 내장 빌트인 타입으로 유니크한 identifier

server transport

GraphQL over HTTP 사양에 따르면, 요청은 요청 세부 정보를 JSON으로 요청 본문에 포함하여 HTTP POST를 사용해야 한다. JSON 본문이 성공적으로 디코딩되면, HTTP 응답 상태는 항상 200(OK)이 되며, GraphQL 요청 실행에서 발생한 모든 오류는 GraphQL 응답의 "errors" 섹션에 표시 된다.

미디어 타입의 기본 및 선호되는 선택은 "application/graphql+json"이지만, "application/json"도 지원된다.

graphQL service implementation

Spring Boot에서는, GraphQL Java API의 저수준 구현이나 REST API와 유사한 Controller 어노테이션과 같은 고수준 추상화를 사용하여 GraphQL 서비스를 구현할 수 있다. 그러나, 시작하는 단계에서는 몇 가지 중요한 개념을 다루는 저수준 GraphQL Java 구현부터 시작하는 것이 좋으므로 annotation 방식 대신 먼저 graphqQL java api를 사용해서 알아보자.

종속성

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-graphql'
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'io.projectreactor:reactor-test'
	testImplementation 'org.springframework.graphql:spring-graphql-test'
}

mvc를 사용할 수도 있고, 필요하면 webflux를 사용할 수도 있다.

runtimewiring

GraphQL Java의 RuntimeWiring.Builder는 DataFetchers, 타입 리졸버, 커스텀 스칼라 타입 등을 등록하는 데 사용된다. RuntimeWiringConfigurer 빈을 Spring 구성에서 선언하여 RuntimeWiring.Builder에 액세스할 수 있다.

먼저 RuntimeWiring.Builder에 Query 타입을 등록하여 Query 타입의 타입 연결을 생성한다. 이를 위해 다음과 같이 Query 타입을 RuntimeWiring.Builder에 등록한다.

return new RuntimeWiringConfigurer() {
  @Override
  public void configure(RuntimeWiring.Builder builder) {

    builder.type(
        "Query",
        new UnaryOperator<TypeRuntimeWiring.Builder>() {
          @Override
          public TypeRuntimeWiring.Builder apply(TypeRuntimeWiring.Builder builder) {
            .....
          }
        });    
  }
};

typeRuntimeWiring

type Query {
    books: [Book]
    bookById(id : ID) : Book
}

type Book {
    id : ID
    name : String
    author: String
    price: Float
    ratings: [Rating]
}

type Rating {
    id: ID
    rating: Int
    comment: String
    user: String
}

위 타입에 대해서 예시를 앞으로 설명한다.

Query 타입에 books와 bookById 두 개의 필드가 있으므로, 다음 단계는 필드 books와 bookById에 대한 DataFetcher를 정의하고 TypeRuntimeWiring.Builder에 데이터 페처를 등록하는 것이다.

다음은 RuntimeWiringConfigurer를 반환하는 코드이다.

return new RuntimeWiringConfigurer() {
  @Override
  public void configure(RuntimeWiring.Builder builder) {
    builder.type(
        "Query",
        new UnaryOperator<TypeRuntimeWiring.Builder>() {
          @Override
          public TypeRuntimeWiring.Builder apply(TypeRuntimeWiring.Builder builder) {
            return builder
                .dataFetcher(
                    "books",
                    new DataFetcher<>() {
                      @Override
                      public Collection<Book> get(DataFetchingEnvironment environment)
                          throws Exception {
                        return bookCatalogService.getBooks();
                      }
                    })
                .dataFetcher(
                    "bookById",
                    new DataFetcher<>() {
                      @Override
                      public Book get(DataFetchingEnvironment environment) throws Exception {
                        return bookCatalogService.bookById(
                            Integer.parseInt(environment.getArgument("id")));
                      }
                    });
          }
        });
  }
};
  • 필드 books의 Data Fetcher는 bookCatalogService.getBooks() 메소드를 호출하여 모든 책을 가져온다.
  • 필드 bookById의 Data Fetcher는 bookCatalogService.bookById() 메소드를 호출하여 책을 가져온다.
    • DataFetchingEnvironment에서 인수를 가져와서 사용할 수 있다.
    • 예를 들어, "id"라는 인수를 가져오려면 environment.getArgument("id")와 같이 사용한다.

마찬가지로, 타입 Book의 필드 ratings에 대한 DataFetcher를 다음과 같이 정의할 수 있다.

builder.type(
    "Book",
    new UnaryOperator<TypeRuntimeWiring.Builder>() {
      @Override
      public TypeRuntimeWiring.Builder apply(TypeRuntimeWiring.Builder builder) {
        return builder.dataFetcher(
            "ratings",
            new DataFetcher<>() {
              @Override
              public List<Rating> get(DataFetchingEnvironment environment)
                  throws Exception {
                return bookCatalogService.ratings(environment.getSource());
              }
            });
      }
    });

Data Fetchers

데이터 페쳐(DataFetcher)는 GraphQL Java 서버에서 가장 중요한 개념 중 하나이다. GraphQL Java가 쿼리를 실행하는 동안, 쿼리에서 만난 각 필드에 대해 적절한 DataFetcher를 호출한다.

스키마에서 모든 필드에는 연결된 DataFetcher가 있다. 특정 필드에 대해 DataFetcher를 지정하지 않으면, 기본값으로 PropertyDataFetcher가 사용된다.

위의 예제인 Query.books, Query.bookById 및 Book 타입의 데이터 페쳐가 있지만, 각각의 필드에 대한 데이터 페처는 등록하지 않았다. GraphQL Java는 필드 이름을 기반으로 POJO 패턴을 따르는 방법을 알고 있는 스마트한 PropertyDataFetcher를 기본값으로 제공한다. 위 예제에서는 name 필드가 있으므로, 데이터를 가져오기 위해 public String getName() POJO 메소드를 찾으려고 한다.

RuntimeWiringConfigurer

UnaryOperator<TypeRuntimeWiring.Builder>을 람다 표현식으로 변경할 수 있다. 그런 다음, 마지막 단계는 RuntimeWiringConfigurer를 빈으로 정의하여 Spring에 GraphQL 연결을 알리는 것이다.

@Configuration
public class GraphQLConfiguration {

  @Bean
  public RuntimeWiringConfigurer runtimeWiringConfigurer(BookCatalogService bookCatalogService) {

    return builder -> {
      builder.type(
          "Query",
          wiring ->
              wiring
                  .dataFetcher("books", environment -> bookCatalogService.getBooks())
                  .dataFetcher(
                      "bookById",
                      env -> bookCatalogService.bookById(Integer.parseInt(env.getArgument("id")))));
      builder.type(
          "Book",
          wiring ->
              wiring.dataFetcher("ratings", env -> bookCatalogService.ratings(env.getSource())));
    };
  }
}

@controller, @QueryMapping, @schemamapping

이제 annotation 방식을 알아보자.

REST API와 마찬가지로, Spring Boot은 GraphQL API를 구현하기 위해 @controller 어노테이션을 제공한다. @controller 어노테이션을 사용하여 컨트롤러 클래스에 어노테이션을 지정하고, 핸들러 메소드에 @QueryMapping 또는 @schemamapping 어노테이션을 정의하면 된다.

Spring은 @controller 빈을 감지하고 해당 어노테이션된 핸들러 메소드( @QueryMapping 또는 @schemamapping)를 데이터 페쳐(또는 리졸버)로 등록한다.

앞서 복잡하게 등록했던 것을 애노테이션 하나만으로 자동으로 등록해주는 것이다.

리졸버(DataFetcher)는 GraphQL 스키마의 단일 필드에 대한 데이터를 채우는 기능을 담당하는 함수다.

schemaMapping annotation

@schemamapping 어노테이션은 핸들러 메소드를 GraphQL 스키마의 필드에 대한 DataFetcher로 매핑한다. typeName을 정의하고 선택적으로 필드를 @schemamapping으로 정의하면 된다.

@SchemaMapping(typeName = "Query", field = "books")
public Collection<Book> books() {
  return bookCatalogService.getBooks();
}

여기서 typeName은 Query, mutation중 하나에 해당하고, field는 client에서 query를 요청할 때, 원하는 필드를 의미한다. 아래의 books 요청이 위의 schemaMapping의 field에 매핑된다.

query {
    books {
        name
        author
    }
}

@SchemaMapping(typeName = "Query")
public Collection<Book> books() {
  return bookCatalogService.getBooks();
}

또한, 메소드 이름을 필드 이름과 동일하게 유지하면 @schemamapping에서 필드를 생략할 수 있다.


@SchemaMapping(typeName = "Query")
public Book bookById(@Argument("id") Integer id) {
  return bookCatalogService.bookById(id);
}

여기서는 @argument 어노테이션을 사용하여 인자 id를 @argument("id") Integer id로 매핑했다.

1

data fetcher for the child type field

만약 Book의 ratings 필드로 GraphQL 서비스를 쿼리한다면, 응답에 "ratings": null이 포함될 수 있다.

이는 해당 Book 인스턴스에 연관된 ratings 정보가 없기 때문이다. Book 인스턴스와 연결된 ratings가 없는 경우, 해당 필드에 null 값을 반환한다.

2

만약 필드에 대해 Data Fetcher를 지정하지 않으면, GraphQL Java는 기본값으로 PropertyDataFetcher를 할당한다. 그리고 런타임에서 이 PropertyDataFetcher는 Book POJO에서 public getRatings() 필드를 찾는다(ratings 필드가 Book에 속하기 때문이다).

이를 해결하기 위해서는 ratings에 대한 Data Fetcher를 정의해야 한다. ratings 필드가 부모 타입인 Book에 속하므로, 인자로 Book을 전달해야 한다.

@SchemaMapping(typeName = "Book", field = "ratings")
public List<Rating> ratings(Book book) {
  return bookCatalogService.ratings(book);
}

GraphQL Java가 쿼리를 실행하는 동안, 쿼리에서 만난 각 필드에 대해 적절한 DataFetcher를 호출한다. 필드에 대해 DataFetcher를 지정하지 않으면 기본값으로 PropertyDataFetcher가 할당된다.

위에서는 typeName이 query나 mutation이 아니고 book이다. book 데이터를 찾아오는 과정에서 ratings 필드가 필요한 경우 이 데이터 페처가 호출됨을 의미한다.

@SchemaMapping(typeName = "Book")
public List<Rating> ratings(Book book) {
  return bookCatalogService.ratings(book);
}

그리고 다음과 같이 필드를 지정하지 않고 작성할 수 있다.

@schemamapping에서 typeName 및 field 속성을 생략할 수도 있다. 이 경우 필드 이름은 메소드 이름으로 기본값으로 설정되며, 타입 이름은 메소드에 주입된 소스/부모 객체의 단순 클래스 이름으로 기본값으로 설정된다.

3

QueryMapping annotation

@schemamapping 대신 @QueryMapping을 사용할 수도 있다. 이는 Query 타입 아래의 필드에 대한 어노테이션된 메소드를 바인딩하기 위한 단축 어노테이션이다.
쉽게 생각하면 @RequestMapping에서 @GetMapping, @PostMapping이 분리된 것처럼 생각하면 된다.

@QueryMapping(name = "books")
public Collection<Book> books() {
  return bookCatalogService.getBooks();
}

QueryMapping이므로 typeName 옵션은 query인 것이고, name으로 field 매핑을 해주면 된다. name옵션을 생략하면 메서드 명과 같은 이름으로 들어가게 된다.

Configuration

기본적으로 Boot 스타터는 src/main/resources/graphql 디렉토리에서 graphqls 또는 gqls 확장자를 가진 GraphQL 스키마 파일을 찾는다. 이 동작을 변경하려면 다음 속성을 수정할 수 있다.

spring.graphql.schema.locations=classpath:graphql/ 
spring.graphql.schema.fileExtensions=.graphqls, .gqls

앞서 type, query, mutation 같은 선언을 해당 위치에 작성하면 되는 것이다.

N+1 문제

type Query {
    books: [Book]
}

type Book {
    id : ID
    name : String
    author: String
    price: Float
    ratings: [Rating]
}

type Rating {
    id: ID
    rating: Int
    comment: String
    user: String
}
@QueryMapping
public Collection<Book> books() {
  return bookCatalogService.getBooks();
}

@SchemaMapping
public List<Rating> ratings(Book book) {
  return bookCatalogService.ratings(book);
}

위의 코드에서, 두 개의 Data Fetcher를 정의했다.

  • books(): Query 객체의 books 필드를 위한 Data Fetcher
  • ratings(..): Book 타입의 ratings 필드를 위한 Data Fetcher

여기서 중요한 점은, 필드에 대해 Data Fetcher를 지정하지 않으면 GraphQL은 해당 타입에 정의된 POJO 객체의 public XXX getXXX() 메소드를 찾는 기본 PropertyDataFetcher를 할당한다는 것이다.

따라서 위의 예제에서 GraphQL은 Book 객체의 name 필드를 resolve하기 위해서 public String getName() 메소드를 호출하게 된다. 즉, GraphQL 서비스가 쿼리를 실행할 때마다 모든 필드에 대해 Data Fetcher를 호출한다.

만약 아래와 같이 요청이 들어온다면 어떻게 될까?

query {
  books {
    id
    name
    ratings {
      rating
      comment
    }
  }
}

graphQL 런타임 엔진은 다음과 같은 작업을 수행한다.

  • 요청을 구문 분석하고 스키마에 대해 요청을 유효성 검사한다.
  • 그런 다음, book 정보를 한 번 가져오기 위해 book Data Fetcher (books() 핸들러 메소드)를 호출한다.
  • 그리고 나서 각 Book에 대해 ratings Data Fetcher를 호출합니다.
  • Books와 Ratings가 다른 데이터베이스에 저장되어 있거나, Books와 Ratings가 전혀 다른 마이크로서비스에 저장되어 있을 수 있다. 어떤 경우에도, 이는 1+N 개의 네트워크 호출을 발생시키게 된다.

해결책 1

Spring에서는 @BatchMapping 어노테이션을 사용하여 이 문제를 해결할 수 있다.

List을 인자로 받고 Book과 List을 포함하는 Map을 반환하는 핸들러 메소드에 @BatchMapping 어노테이션을 선언해야 한다.

@BatchMapping (typeName = "Book", field = "ratings",)
public Map<Book, List<Rating>> ratings(List<Book> books) {
  log.info("Fetching ratings for all books");
  return ratingService.ratingsForBooks(books);
}

@BatchMapping에서 Spring은 rating Data Fetcher 호출을 일괄 처리한다.

또한, field와 typeName을 생략할 수 있다. 이 경우, 필드 이름은 메소드 이름으로, 타입 이름은 입력 List 요소 유형의 간단한 클래스 이름으로 기본 설정된다.

@BatchMapping
public Map<Book, List<Rating>> ratings(List<Book> books) {
  log.info("Fetching ratings for all books");
  return ratingService.ratingsForBooks(books);
}

해결책 2

spring for GraphQL에서 N+1 문제를 해결하는 또 다른 방법은 낮은 수준의 GraphQL Java Data Loader API를 사용하는 것이다. Data Loader는 일련의 고유한 키로 식별된 데이터의 일괄 로드를 허용하는 유틸리티 클래스이다. 이는 Facebook DataLoader의 순수 Java 포트입니다.

다음과 같이 작동합니다.

  • 고유한 키 집합을 사용하고 결과를 반환하는 Data Loader를 정의한다.
  • DataLoaderRegistery에 Data Loader를 등록한다.
  • Data Loader를 @schemamapping 핸들러 메소드에 전달합니다.
  • DataLoader는 일괄 처리할 수 있도록 future를 반환하여 로딩을 지연시킨다. DataLoader는 로드된 엔티티의 per request 캐시를 유지하여 성능을 더욱 개선할 수 있다.

BatchLoaderRegistry는 빈으로 사용할 수 있으므로, 편의상 Controller 클래스에 주입할 수 있다.

batchLoaderRegistry
    .forTypePair(Book.class, List.class)
    .registerMappedBatchLoader(
        (books, env) -> {
          log.info("Calling loader for books {}", books);
          Map bookListMap = ratingService.ratingsForBooks(List.copyOf(books));
          return Mono.just(bookListMap);
        });
@SchemaMapping
public CompletableFuture<List<Rating>> ratings(Book book, DataLoader<Book, List<Rating>> loader) {
  log.info("Fetching rating for book, id {}", book.id());
  return loader.load(book);
}

Mutation

GraphQL에서 mutation은 데이터를 삽입, 업데이트 또는 삭제하는 데 사용된다. Mutation API는 Query 대신 Mutation 타입으로 정의된다.
http method도 get메서드에서 write 등 여러 작업을 할 수 있는 것처럼 mutation도 마찬가지로 query에서 다 수행할 수 있는 행위이다. 하지만 규칙이므로 삽입, 업데이트, 삭제 시에는 mutation을 사용하도록 한다.

type Mutation {
    addBook(name: String!, author: String!, publisher: String!,price: Float!): String!
}

위의 예제에서 addBook API는 mutation이다. 이를 사용하여 책을 추가/저장하고 성공적으로 저장된 후 책의 ID를 반환할 수 있다. !는 필수 필드 임을 나타낸다.

마찬가지로, 책을 업데이트, 삭제하는 API를 다음과 같이 정의할 수 있다.

type Mutation {
    updateBook(id: ID!, name: String, author: String, publisher: String, price: Float): String!
}

type Mutation {
    deleteBook(id: ID!): String!
}

다음과 같이 동작을 수행한 후, info를 반환하도록 설계할 수도 있다.

type Mutation {
    addBook(name: String!, author: String!, publisher: String!,price: Float!): BookInfo!
    updateBook(id: ID!, name: String, author: String, publisher: String,price: Float): BookInfo!
    deleteBook(id: ID!): BookInfo!
}

type BookInfo {
    id : ID
    name : String
    author: String
    publisher: String
    price: Float
}

4

input type

스칼라 인자로 API를 정의하는 대신, 예를 들어 addBook(name: String!, author: String!, publisher: String!, price: Float!)와 같이 스칼라 인자를 사용하는 대신, input type이라는 복잡한 객체를 정의할 수 있다. 이렇게 하면 업데이트와 삽입에 모두 재사용할 수 있다.

input type은 type 대신 input 키워드를 사용하여 정의된다.

input BookInput {
    name : String
    author: String
    publisher: String
    price: Float
}

type Mutation {
    addBook(book: BookInput!): BookInfo!
    updateBook(id: ID!, book: BookInput!): BookInfo!
    deleteBook(id: ID!): BookInfo!
}

5

implementing mutations

type Mutation {
    addBook(name: String!, author: String!, publisher: String!,price: Float!): String!
}
@SchemaMapping(typeName = "Mutation", field = "addBook")
public String addBook(
    @Argument String author,
    @Argument String name,
    @Argument String publisher,
    @Argument Double price) {
  log.info("Saving book, name {}", name);
  var book = new BookInput(name, author, publisher, price);
  return bookCatalogService.saveBook(book);
}

앞선 경우와 마찬가지로 field 속성은 메서드 이름과 같을 경우 생략할 수 있다.
6

implementing mutation with input type

type Mutation {
    addBook(book: BookInput!): BookInfo!
}
@MutationMapping
public BookInfo addBook(@Argument BookInput book) {
  log.info("Saving book, name {}", book.name());
  return bookCatalogService.saveBook(book);
}

input 타입을 사용하면 더 간략하게 해결할 수 있다.

Directive

GraphQL directive는 GraphQL 스키마의 일부를 애노테이션 처리하여 추가 동작을 추가하는 방법이다. @ 문자로 시작하는 지시자를 정의할 수 있으며, 이름, 인자(선택 사항) 및 실행 위치가 뒤에 따라온다. 아래와 같이 정의된다.

Descriptionopt directive @ Name ArgumentsDefinitionopt on DirectiveLocations

실제 built-in directive @deprecated는 아래와 같이 구성되어 있다.

directive @deprecated(
  reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE
  • 디렉티브의 이름은 @deprecated
  • @deprecated 디렉티브는 default값이 "No longer supported"인 선택적 인자 reason을 받는다.
  • 이 디렉티브는 필드 정의와 enum 값에 적용할 수 있다.

이름이 나타내는 것처럼, @deprecated 디렉티브를 사용하면 API의 특정 필드를 더 이상 사용하지 않는 것으로 표시할 수 있다.

예를 들어, 기존의 name 필드 대신 더 자세한 bookName 필드를 도입하기로 결정한 경우, @deprecated 디렉티브를 사용하여 name을 표시할 수 있다.

type Book {
    id: ID
    bookName: String
    name: String @deprecated(reason: "Use `bookName`.")
    author: String
    publisher: String
    price: Float
}

GraphQL Directive Type

GraphQL 지시어를 적용하는 위치에 따라 지시어를 schema directive와 operation directive으로 분류할 수 있다.

schema directive

스키마 지시어는 GraphQL 스키마에 적용된다 (GraphQL 사양에서 TypeSystemDirectiveLocation으로 지정됨).
스키마 지시어의 한 예는 앞서 본 @deprecated으로 이를 사용하면 API 필드를 폐기된 것으로 표시할 수 있다.

스키마 지시어는 다음 중 하나에 적용될 수 있다.

SCHEMA
SCALAR
OBJECT
FIELD_DEFINITION
ARGUMENT_DEFINITION
INTERFACE
UNION
ENUM
ENUM_VALUE
INPUT_OBJECT
INPUT_FIELD_DEFINITION

Operation Directive

operation Directive은 연산 (쿼리 및 뮤테이션)에 적용되며, GraphQL 서버가 연산을 처리하는 방식에 영향을 미친다. 내장 연산 지시문의 예로는 @Skip이 있고 아래와 같이 정의되어 있다.

directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

operation directive는 다음과 같은 곳에 적용될 수 있다.

QUERY
MUTATION
SUBSCRIPTION
FIELD
FRAGMENT_DEFINITION
FRAGMENT_SPREAD
INLINE_FRAGMEN

built-in directives

22년 9월 기준으로 @Skip, @include, @deprecated 내장 지시어가 존재한다.

@Skip

@Skip 지시어는 FIELD, FRAGMENT_SPREAD, INLINE_FRAGMENT에서 사용할 수 있다. 이 지시어는 'if' 인자를 기반으로 실행 중 조건부 제외를 허용한다.

directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

예를 들어, GetBooks 작업에서 ratings의 comment 필드를 조건부로 제외하려면 @Skip을 다음과 같이 사용할 수 있다.

query GetBooks($itTest: Boolean!) {
  books {
    id
    name
    author 
    publisher
    price
    ratings {
      id
      rating
      comment @skip(if: $itTest)
    }
  }
}

GraphiQL 변수 섹션에서 변수 값을 설정할 수 있다. 아래와 같이 변수를 정의하고 사용하기 전에 해당 변수를 연산 이름의 인수로 정의해야한다.
7

@include

@include 역시 @Skip과 마찬가지로 operation directive이며, 아래와 같이 정의된다.

directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT

@if argument는 Boolean 타입이며, 실행 시 조건부 포함 여부를 결정한다. 값이 true이면 해당 필드/프래그먼트가 실행되고, false이면 제외된다.

예를 들어, 아래의 쿼리에서 @include를 이용해 필드 포함 여부를 결정할 수 있습니다.

query GetBooks($showComments: Boolean!) {
  books {
    id
    name
    ratings {
      stars
      comment @include(if: $showComments)
    }
  }
}

@deprecated

@deprecated 지시어는 스키마 지시어로, 필드나 열거형 값에 대해 폐지(deprecated) 마크를 할 수 있다.

directive @deprecated(
  reason: String = "No longer supported"
) on FIELD_DEFINITION | ENUM_VALUE

Directive Use Cases

case 1: access control

하나의 공개 API에서 가장 일반적인 사용 사례 중 하나는 액세스 제어를 지원하는 것이며, GraphQL 기반의 공개 API도 예외는 아니다.

지시어를 사용하여 액세스 제어를 구현하는 방법을 이해해보자.
Book 타입에 수익이라는 민감한 필드가 있다고 상상해 봅시다.

type Book {
    id: ID
    name: String
    author: String
    publisher: String
    price: Float
    revenue: Float
    ratings: [Rating]
}

다음과 같이 @auth directive을 revenue 필드에 적용한다.

directive @auth (
    role: String!
) on FIELD_DEFINITION

type Book {
    id: ID
    name: String
    author: String
    publisher: String
    price: Float
    revenue: Float @auth(role: "manager")
}

유저가 revenue 필드를 쿼리할 때, 서비스 구현은 사용자가 요구되는 역할을 가지고 있는지 확인한 후에 응답을 반환할 수 있다. 역할 확인은 사용자 토큰의 클레임 또는 다른 메커니즘을 사용할 수 있다. 간단하게, 우리는 역할이 API 요청의 HTTP 헤더로 전달된다고 가정한다.

8

implementing @auth directive

AuthDirective 클래스를 정의해야 하며, 이 클래스는 SchemaDirectiveWiring을 상속해야 한다. 그리고 onField 메소드를 오버라이딩해야 하는데, 이는 @auth schema directive 메소드가 필드에 적용되기 때문이다.

이후 authDataFetcher 라는 새로운 데이터 패쳐를 정의하고 다음과 같이 기존 데이터 패쳐를 래핑하여 기존 데이터 패쳐에 대한 호출 전에 인증 검사를 수행하도록 구현해야 한다.

@Override
public GraphQLFieldDefinition onField(
    SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> environment) {

  //..............
  var schemaDirectiveRole = environment.getAppliedDirective("auth").getArgument("role").getValue();
  //....................
  DataFetcher<?> authDataFetcher = 
      dataFetchingEnvironment -> {
        var graphQlContext = dataFetchingEnvironment.getGraphQlContext();
          // role is set in context in interceptor code
        String userRole = graphQlContext.get("role");

        if (userRole != null && userRole.equals(schemaDirectiveRole)) {
          return originalDataFetcher.get(dataFetchingEnvironment);
        } else {
          return null;
        }
      };
  //......

  // now change the field definition to have the new auth data fetcher
  environment.getCodeRegistry().dataFetcher(parentType, field, authDataFetcher);
  return field;

}
  • authDataFetcher는 먼저 GraphQlContext에서 역할을 가져와 사용자가 필요한 역할을 가지고 있는지 확인한다.
  • 사용자가 필요한 역할을 가지고 있으면 원래 데이터 패쳐를 호출한다.
  • 사용자가 필요한 역할을 가지고 있지 않으면 null을 반환한다. 따라서 인증되지 않은 사용자는 API 응답에서 null 값을 볼 수 있다.
  • 마지막 단계는 revenue 필드와 부모 유형인 book에 대한 새 데이터 패쳐인 authDataFetcher를 설정하는 것이다.

위의 코드에서는 graphQlContext.get("role")로부터 역할 정보를 해결했다. WebGraphQlInterceptor를 사용하여 GraphQLContext에서 역할을 설정할 수 있다.

public class RequestHeaderInterceptor implements WebGraphQlInterceptor {

  @Override
  public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {

    var roleHeader = request.getHeaders().get("role");
    if (roleHeader != null) {
      request.configureExecutionInput(
          (executionInput, builder) ->
              builder.graphQLContext(Collections.singletonMap("role", roleHeader.get(0))).build());
    }
    return chain.next(request);
  }
}

최종적으로 완성하면 다음과 같이 구성된다.

public class AuthDirective implements SchemaDirectiveWiring {

  @Override
  public GraphQLFieldDefinition onField(
      SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> environment) {

    // Skipping fields for which directive auth is not applied as this is called for every field.
    if (environment.getAppliedDirective("auth") == null) {
      return environment.getElement();
    }

    var schemaDirectiveRole = environment.getAppliedDirective("auth").getArgument("role").getValue();
    var field = environment.getElement();
    var parentType = environment.getFieldsContainer();

    // build a data fetcher that first checks authorisation roles before then calling the original data fetcher
    
    var originalDataFetcher = environment.getCodeRegistry().getDataFetcher(parentType, field);
    // at runtime authDataFetcher is called
    DataFetcher<?> authDataFetcher =
        dataFetchingEnvironment -> {
          var graphQlContext = dataFetchingEnvironment.getGraphQlContext();
            // role is set in context in interceptor code
          String userRole = graphQlContext.get("role");

          if (userRole != null && userRole.equals(schemaDirectiveRole)) {
            return originalDataFetcher.get(dataFetchingEnvironment);
          } else {
            return null;
          }
        };
    // now change the field definition to have the new auth data fetcher
    environment.getCodeRegistry().dataFetcher(parentType, field, authDataFetcher);
    return field;
  }
}

이렇게 구성했으면 빈으로 등록해줘야 한다.

@Configuration
public class AppConfig {
    @Bean
    public RuntimeWiringConfigurer runtimeWiringConfigurer() {
        return builder -> builder.directiveWiring(new AuthDirective());
    }
}

이렇게 설정하면, 애플리케이션이 시작될 때 GraphQL Java 엔진이 AuthDirective의 onField 메서드를 호출하고, @auth 디렉티브가 적용된 필드에 대해 authDataFetcher를 새로운 데이터 페처로 할당한다.

case 2: input validation

다른 API와 마찬가지로, 사용자의 입력값을 유효성 검사하고 유용한 오류 메시지를 반환하는 것은 GraphQL Directive로 구현할 수 있는 공통적인 사용 사례이다.

예를 들어, 다음과 같은 입력 유형인 BookInput이 있다고 가정해보자.

input BookInput {
    name: String
    author: String
    publisher: String
    price: Float
}

만약 이름, 작가 및 출판사 필드가 최소 10자에서 최대 100자까지여야 하는 비즈니스 규칙이 있다면 어떻게 해야 할까?

이러한 비즈니스 검증을 구현하는 일반적인 방법 중 하나는 javax.validation을 사용하여 BookInput 객체 내에서 유효성 검사를 정의하는 것이다.

public class BookInput {
  @Size(min = 10, max = 100)
  private String name;
  @Size(min = 10, max = 100)
  private String author;
  @Size(min = 10, max = 100)
  private String publisher;
  private Double price;
}

위 접근 방식의 문제점은 클라이언트가 요청을 보낸 후 실행 시간에 이러한 유효성 검사를 발견할 수 있다는 것이다. 더 나은 방법은 SDL을 사용하여 지시어를 사용하여 비즈니스 유효성 검사를 정의하고, API 문서의 일부로 만드는 것이다. (또한 introspection을 통해 발견할 수 있다).

@SiZe directive를 만들어보자.

directive @Size(
    min : Int = 0, max : Int = 2147483647, message : String = "graphql.validation.Size.message"
)
on INPUT_FIELD_DEFINITION

input BookInput {
    name: String! @Size(min : 10, max : 100)
    author: String! @Size(min : 10, max : 100)
    publisher: String! @Size(min : 10, max : 100)
    price: Float
}

Implementing Input Validation Directive

위와 같이 실제로 validation을 전부 정의하고 사용하기에는 무리가 있다.
이에 도움을 주는 graphql-java-extended-validation 라이브러리가 있다.

implementation 'com.graphql-java:graphql-java-extended-validation:18.1-hibernate-validator-6.2.0.Final'
@Configuration
public class AppConfig {
  @Bean
  public RuntimeWiringConfigurer runtimeWiringConfigurer() {
    var validationRules =
        ValidationRules.newValidationRules()
            .onValidationErrorStrategy(OnValidationErrorStrategy.RETURN_NULL)
            .build();
    var schemaWiring = new ValidationSchemaWiring(validationRules);
    
    return builder -> {
      builder.directiveWiring(schemaWiring);
    };
  }
}

라이브러리 의존성을 받고 bean에 설정을 해주고 문서에 있는 directive를 복사해서 붙여넣어 사용하면 된다.

directive @Size(
    min : Int = 0, max : Int = 2147483647, message : String = "graphql.validation.Size.message"
)
on INPUT_FIELD_DEFINITION

input BookInput {
    name: String! @Size(min : 10, max : 100)
    author: String! @Size(min : 10, max : 100)
    publisher: String! @Size(min : 10, max : 100)
    price: Float
}

다양한 validation을 지원하니 공식 문서를 참고하자.
23년 5월 9일 기준으로 아직까지 spring boot 2.x 버전대만 지원한다.

9

Use case 3: Adding Functionality to the Query

지시어(directives)를 사용하여 쿼리와 뮤테이션의 동작을 향상시키는 방법에 대해 예시를 들어 이해해보자.
책 목록 API가 항상 기본 통화 $로 가격을 반환한다고 상상해보자.

type Query {
    bookById(id : ID) : Book
}
type Book {
    id : ID
    name : String
    author: String
    publisher: String
    price: Float
}

만약 클라이언트가 다른 통화로 가격을 표시하고 싶다면 어떻게 해야 할까? 클라이언트가 동적으로 다른 통화로 가격을 요청할 수 있는 API를 구축하는 방법은 무엇일까?

이러한 경우를 해결하기 위해 대상 통화를 인수로 받는 작업 지시어(@Currency)를 정의할 수 있다.

directive @currency(
    currency: String!
) on FIELD

type Query {
    bookById(id : ID) : Book
}

type Book {
    id: ID
    name: String
    author: String
    publisher: String
    price: Float
}

위와 같이 서버에서 정의해두면 클라이언트는 다음과 같이 호출할 수 있다.

query GetBookById {
  bookById(id:1000) {
    id
    name
    author
    publisher
    price
    priceInr : price @currency(currency: "INR")
  }
}

필드 price @Currency(currency: "INR")는 요청된 INR 통화로 가격을 반환하는 것에 한다. 작업 지시어의 한 가지 명백한 문제는 클라이언트가 가격 필드 이외의 필드에 @Currency 지시어를 적용할 수 있다는 것이다.

Implementing Operation Directive

스키마 지시어와 비교하여 작업 지시어는 작업의 동작을 변경하는 지시어를 지원하기 때문에 구현이 복잡하다. 따라서 유일한 옵션은 Data Fetcher 콜백을 사용하고 지시어에 기반한 사용자 정의 동작을 추가하는 것이다.

기본 구현에서는 @Currency 지시어가 필드 가격에 제공되면 GraphQL 엔진은 이 지시어를 무시하고 기본 구현(graphql.schema.PropertyDataFetcher로 이전 글에서 설명한 바와 같음)에 따라 가격을 반환한다. 이 동작을 재정의하기 위해 가격에 대한 Data Fetcher를 다음과 같이 정의할 수 있다.

@SchemaMapping(typeName = "Book")
public Double price(Book book, DataFetchingEnvironment dataFetchingEnvironment) {
  double price = book.price();
  var maybeAppliedDirective =
      dataFetchingEnvironment.getQueryDirectives().getImmediateAppliedDirective("currency");

  if (!maybeAppliedDirective.isEmpty()) {
    String currency = maybeAppliedDirective.get(0).getArgument("currency").getValue();
    log.info("Getting price in currency {}", currency);
    var rate = currencyConversionService.convert(DEFAULT_CCY, currency);
    price = price * rate;
  }
  return price;
}
  • 적용된 지시어를 dataFetchingEnvironment.getQueryDirectives().getImmediateAppliedDirective("currency")로 읽는다.
  • 적용된 지시어의 인수를 maybeAppliedDirective.get(0).getArgument("currency").getValue()로 읽는다. 여기서 가격 필드에 하나의 지시어만 적용된다는 가정을 하고 있다.
  • 목표 통화 정보를 전달하기 위해 CurrencyConversionService를 호출한다.

코드에서 기본값을 하드코딩하는 대신 다음과 같이 지시어 정의에서 통화의 기본값을 정의하고 코드에서 기본값을 읽을 수도 있다.

directive @currency(
currency: String! = "$"
) on FIELD

Directive Documentation

문서화는 GraphQL의 일급 기능이며, 모든 GraphQL 타입, 필드, 인수 및 기타 정의는 자체 설명이 아니라면 설명을 제공해야한다. 지시어는 다음과 같이 문서화할 수 있다.

"""Convert price from one currency to other. Default value is $"""
directive @currency(
    "target currency" currency: String! = "$"
) on FIELD

그럼 클라이언트는 다음과 같이 서버에서 지원하는 모든 지시어를 가져올 수도 있다.

query AllDirectives {
  __schema {
    directives {
      name
      description
      locations
      args {
        name
        description
        defaultValue
      }
    }
  }
}

scalar 추가

scalar는 하위 필드를 가지지 않는 데이터 타입으로 graphql은 built-in scalar 타입으로 5가지를 제공한다.

  • int : 32비트 정수
  • float : 부동소수점
  • String : utf-8 문자열
  • Boolean : true or false
  • ID : 데이터 고유 식별자 - String과 같은 타입 취급

이외에는 제공하지 않으므로 직접 등록해야 한다.

// java 타입 제공
// config에 추가 등록 필요
implementation 'com.graphql-java:graphql-java-extended-scalars:20.2'
// java time 관련 타입 제공
// 자동으로 등록되므로 따로 등록할 필요 없이 사용 가능
implementation("com.tailrocks.graphql:graphql-datetime-spring-boot-starter:6.0.0")
@Configuration
public class GraphQLConfig {

    @Bean
    public RuntimeWiringConfigurer runtimeWiringConfigurer() {
        return wiringBuilder -> wiringBuilder
            .scalar(ExtendedScalars.GraphQLLong)
            ;
    }
}

위와 같이 scalar 타입을 추가 등록할 수 있다.
그리고 graphqls 파일에 정의하고 사용하면 된다.

-- film.graphqls
scalar LocalDate
scalar LocalDateTime
scalar Long

type Film {
    id: ID!
    directorId: Long!
    title: String!
    subtitle: String!
    description: String!
    genre: String!
    runningTime: Int!
    posterImg: String!
    releaseDate: LocalDate!
}

@GraphqlRepository

https://docs.spring.io/spring-graphql/docs/current-SNAPSHOT/reference/html/#data
https://www.youtube.com/watch?v=ahBjkmkltcc&list=PLgGXSWYM2FpNRPDQnAGfAHxMl3zUG2Run&index=8

@controller, @QueryMapping, @schemamapping 를 사용하면 데이터 페쳐로 자동으로 등록되는 것처럼 @GraphqlRepository 애노테이션을 사용하면 해당 리포지토리가 데이터 페쳐로 자동으로 등록된다.

@GraphqlRepository
public interface CustomerRepository extends ReactiveCrudRepository<Customer, Long>,
            QuerydslPredicateExecutor<Customer> {
}
type Query {
    customers: [Customer]
    customerById(id: ID): Customer
    customersByName(name: String): [Customer]
}

type Customer {
    id: ID
    name: String
}

예외 처리

https://docs.spring.io/spring-graphql/docs/current/reference/html/#execution.exceptions
https://docs.spring.io/spring-graphql/docs/current-SNAPSHOT/reference/html/#controllers.exception-handler
https://www.baeldung.com/spring-graphql-error-handling
https://dzone.com/articles/error-handling-in-spring-for-graphql

테스트 변수 사용법

스크린샷 2023-08-03 오후 1 20 27





추가 참고자료

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant