2편에서 도메인 계층을 완성했다. 이번 편에서는 스키마와 Service를 연결하는 Resolver를 구현하고, GraphiQL에서 실제로 쿼리와 뮤테이션을 실행해본다. 이 편을 끝내면 동작하는 GraphQL API가 완성된다.

왜 Resolver가 필요한가

스키마는 "어떤 데이터를 제공할 수 있는지" 선언한다. Service는 "데이터를 어떻게 가져오는지" 구현한다. 하지만 이 둘을 연결하는 다리가 없다. 스키마의 books 필드를 호출했을 때 BookService.getAllBooks()를 실행하라고 누가 알려줘야 한다.

그 역할을 하는 것이 Resolver다. REST의 Controller가 URL을 메서드에 매핑하듯, Resolver는 스키마 필드를 메서드에 매핑한다.

단계별로 만들기

단계 1 — QueryResolver 구현

스키마의 type Query 안에 정의된 6개 필드를 처리하는 Resolver를 만든다.

package com.codeit.graphql.resolver;

import com.codeit.graphql.entity.Author;
import com.codeit.graphql.entity.Book;
import com.codeit.graphql.service.AuthorService;
import com.codeit.graphql.service.BookService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

import java.util.List;

@Controller
@RequiredArgsConstructor
@Slf4j
public class QueryResolver {

    private final BookService bookService;
    private final AuthorService authorService;

    @QueryMapping
    public List<Book> books() {
        log.info("GraphQL Query: books");
        return bookService.getAllBooks();
    }

    @QueryMapping
    public Book book(@Argument Long id) {
        log.info("GraphQL Query: book(id={})", id);
        return bookService.getBookById(id);
    }

    @QueryMapping
    public List<Book> searchBooks(@Argument String title) {
        log.info("GraphQL Query: searchBooks(title={})", title);
        return bookService.searchBooks(title);
    }

    @QueryMapping
    public List<Author> authors() {
        log.info("GraphQL Query: authors");
        return authorService.getAllAuthorsWithBooks();
    }

    @QueryMapping
    public Author author(@Argument Long id) {
        log.info("GraphQL Query: author(id={})", id);
        return authorService.getAuthorById(id);
    }

    @QueryMapping
    public List<Book> booksByAuthor(@Argument Long authorId) {
        log.info("GraphQL Query: booksByAuthor(authorId={})", authorId);
        return bookService.getBooksByAuthor(authorId);
    }
}

@Controller를 쓴다는 점에 주목하자. @RestController가 아니다. GraphQL Resolver에서는 JSON 직렬화를 Spring GraphQL이 알아서 처리하기 때문에 @ResponseBody가 필요 없다.

각 메서드 이름이 스키마의 필드명과 정확히 일치한다. books()books: [Book!]!, book()book(id: ID!): Book.

단계 2 — MutationResolver 구현

스키마의 type Mutation 필드를 처리한다.

package com.codeit.graphql.resolver;

import com.codeit.graphql.entity.Author;
import com.codeit.graphql.entity.Book;
import com.codeit.graphql.input.CreateAuthorInput;
import com.codeit.graphql.input.CreateBookInput;
import com.codeit.graphql.input.UpdateAuthorInput;
import com.codeit.graphql.input.UpdateBookInput;
import com.codeit.graphql.service.AuthorService;
import com.codeit.graphql.service.BookService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;

@Controller
@RequiredArgsConstructor
@Slf4j
public class MutationResolver {

    private final BookService bookService;
    private final AuthorService authorService;

    @MutationMapping
    public Book createBook(@Argument CreateBookInput input) {
        log.info("GraphQL Mutation: createBook({})", input);
        return bookService.createBook(input);
    }

    @MutationMapping
    public Book updateBook(@Argument Long id, @Argument UpdateBookInput input) {
        log.info("GraphQL Mutation: updateBook(id={}, {})", id, input);
        return bookService.updateBook(id, input);
    }

    @MutationMapping
    public Boolean deleteBook(@Argument Long id) {
        log.info("GraphQL Mutation: deleteBook(id={})", id);
        return bookService.deleteBook(id);
    }

    @MutationMapping
    public Author createAuthor(@Argument CreateAuthorInput input) {
        log.info("GraphQL Mutation: createAuthor({})", input);
        return authorService.createAuthor(input);
    }

    @MutationMapping
    public Author updateAuthor(@Argument Long id, @Argument UpdateAuthorInput input) {
        log.info("GraphQL Mutation: updateAuthor(id={}, {})", id, input);
        return authorService.updateAuthor(id, input);
    }

    @MutationMapping
    public Boolean deleteAuthor(@Argument Long id) {
        log.info("GraphQL Mutation: deleteAuthor(id={})", id);
        return authorService.deleteAuthor(id);
    }
}

Resolver는 정말 얇다. 로깅 한 줄, Service 위임 한 줄이 전부다. 비즈니스 로직은 Service에, 데이터 접근은 Repository에, Resolver는 매핑만 담당한다.

단계 3 — DataInitializer로 테스트 데이터 준비

GraphiQL에서 테스트하려면 데이터가 있어야 한다. CommandLineRunner를 구현해서 애플리케이션 시작 시 샘플 데이터를 넣는다.

package com.codeit.graphql.config;

import com.codeit.graphql.entity.Author;
import com.codeit.graphql.entity.Book;
import com.codeit.graphql.repository.AuthorRepository;
import com.codeit.graphql.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class DataInitializer implements CommandLineRunner {

    private final AuthorRepository authorRepository;
    private final BookRepository bookRepository;

    @Override
    public void run(String... args) {
        Author author1 = authorRepository.save(Author.builder()
                .name("김영한")
                .email("younghank@example.com")
                .build());

        Author author2 = authorRepository.save(Author.builder()
                .name("로버트 마틴")
                .email("unclebob@example.com")
                .build());

        bookRepository.save(Book.builder()
                .title("스프링 부트와 JPA 활용")
                .isbn("978-89-12345-01-1")
                .publishedYear(2023)
                .price(35000.0)
                .author(author1)
                .build());

        bookRepository.save(Book.builder()
                .title("Clean Code")
                .isbn("978-01-32350-88-4")
                .publishedYear(2008)
                .price(33000.0)
                .author(author2)
                .build());

        log.info("샘플 데이터 초기화 완료: 작가 2명, 도서 2권");
    }
}

H2 인메모리 DB를 쓰기 때문에 서버를 재시작하면 데이터가 초기화된다. 매번 깨끗한 상태에서 테스트할 수 있어 개발 단계에서 편리하다.

GraphiQL 실습

애플리케이션을 실행하고 http://localhost:8080/graphiql에 접속한다.

전체 도서 조회

query {
  books {
    id
    title
    price
    author {
      name
    }
  }
}

결과에서 isbn, publishedYear, createdAt은 빠져있다. 요청하지 않았기 때문이다. 이것이 GraphQL의 핵심 — 클라이언트가 필요한 필드만 선택한다.

도서 검색

query {
  searchBooks(title: "스프링") {
    id
    title
    author {
      name
      email
    }
  }
}

제목에 "스프링"이 포함된 책을 검색한다. 대소문자를 구분하지 않는다.

작가 생성

mutation {
  createAuthor(input: {
    name: "마틴 파울러"
    email: "fowler@example.com"
  }) {
    id
    name
    email
  }
}

Mutation은 mutation 키워드로 시작한다. 생성된 Author의 id, name, email을 응답으로 받는다.

도서 생성

mutation {
  createBook(input: {
    title: "리팩터링"
    isbn: "978-89-66261-26-7"
    publishedYear: 2020
    price: 40000.0
    authorId: 3
  }) {
    id
    title
    author {
      name
    }
  }
}

authorId: 3은 방금 생성한 마틴 파울러의 ID다. 응답에서 author { name }을 요청했으므로 저자 이름도 함께 온다.

도서 수정

mutation {
  updateBook(id: 1, input: {
    price: 38000.0
  }) {
    id
    title
    price
  }
}

price만 보내면 price만 바뀐다. 나머지 필드는 그대로 유지된다.

도서 삭제

mutation {
  deleteBook(id: 1)
}

삭제 결과로 true가 반환된다.

전체 요청 흐름

지금까지 만든 코드를 관통하는 전체 흐름이다. Query와 Mutation이 어떤 경로를 타는지 한눈에 보자.

sequenceDiagram autonumber participant C as GraphiQL participant GQL as Spring GraphQL participant QR as QueryResolver participant MR as MutationResolver participant S as Service participant R as Repository participant DB as H2 DB rect rgb(232, 248, 232) Note over C, DB: Query 흐름 C->>GQL: { books { title, author { name } } } GQL->>QR: books() 호출 QR->>S: getAllBooks() S->>R: findAll() R->>DB: SELECT * FROM books DB-->>C: 결과 JSON end rect rgb(255, 243, 224) Note over C, DB: Mutation 흐름 C->>GQL: mutation { createBook(input: {...}) } GQL->>MR: createBook(input) 호출 MR->>S: createBook(input) S->>R: save(book) R->>DB: INSERT INTO books DB-->>C: 생성된 Book JSON end

초록 영역이 Query, 주황 영역이 Mutation이다. 경로는 같지만 어느 Resolver로 라우팅되는지가 다르다.

이번 편 최종 전체 코드

QueryResolver.java

package com.codeit.graphql.resolver;

import com.codeit.graphql.entity.Author;
import com.codeit.graphql.entity.Book;
import com.codeit.graphql.service.AuthorService;
import com.codeit.graphql.service.BookService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

import java.util.List;

@Controller
@RequiredArgsConstructor
@Slf4j
public class QueryResolver {
    private final BookService bookService;
    private final AuthorService authorService;

    @QueryMapping
    public List<Book> books() {
        log.info("GraphQL Query: books");
        return bookService.getAllBooks();
    }

    @QueryMapping
    public Book book(@Argument Long id) {
        log.info("GraphQL Query: book(id={})", id);
        return bookService.getBookById(id);
    }

    @QueryMapping
    public List<Book> searchBooks(@Argument String title) {
        log.info("GraphQL Query: searchBooks(title={})", title);
        return bookService.searchBooks(title);
    }

    @QueryMapping
    public List<Author> authors() {
        log.info("GraphQL Query: authors");
        return authorService.getAllAuthorsWithBooks();
    }

    @QueryMapping
    public Author author(@Argument Long id) {
        log.info("GraphQL Query: author(id={})", id);
        return authorService.getAuthorById(id);
    }

    @QueryMapping
    public List<Book> booksByAuthor(@Argument Long authorId) {
        log.info("GraphQL Query: booksByAuthor(authorId={})", authorId);
        return bookService.getBooksByAuthor(authorId);
    }
}

MutationResolver.java

package com.codeit.graphql.resolver;

import com.codeit.graphql.entity.Author;
import com.codeit.graphql.entity.Book;
import com.codeit.graphql.input.*;
import com.codeit.graphql.service.AuthorService;
import com.codeit.graphql.service.BookService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;

@Controller
@RequiredArgsConstructor
@Slf4j
public class MutationResolver {
    private final BookService bookService;
    private final AuthorService authorService;

    @MutationMapping
    public Book createBook(@Argument CreateBookInput input) {
        log.info("GraphQL Mutation: createBook({})", input);
        return bookService.createBook(input);
    }

    @MutationMapping
    public Book updateBook(@Argument Long id, @Argument UpdateBookInput input) {
        log.info("GraphQL Mutation: updateBook(id={}, {})", id, input);
        return bookService.updateBook(id, input);
    }

    @MutationMapping
    public Boolean deleteBook(@Argument Long id) {
        log.info("GraphQL Mutation: deleteBook(id={})", id);
        return bookService.deleteBook(id);
    }

    @MutationMapping
    public Author createAuthor(@Argument CreateAuthorInput input) {
        log.info("GraphQL Mutation: createAuthor({})", input);
        return authorService.createAuthor(input);
    }

    @MutationMapping
    public Author updateAuthor(@Argument Long id, @Argument UpdateAuthorInput input) {
        log.info("GraphQL Mutation: updateAuthor(id={}, {})", id, input);
        return authorService.updateAuthor(id, input);
    }

    @MutationMapping
    public Boolean deleteAuthor(@Argument Long id) {
        log.info("GraphQL Mutation: deleteAuthor(id={})", id);
        return authorService.deleteAuthor(id);
    }
}

DataInitializer.java

package com.codeit.graphql.config;

import com.codeit.graphql.entity.Author;
import com.codeit.graphql.entity.Book;
import com.codeit.graphql.repository.AuthorRepository;
import com.codeit.graphql.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class DataInitializer implements CommandLineRunner {
    private final AuthorRepository authorRepository;
    private final BookRepository bookRepository;

    @Override
    public void run(String... args) {
        Author author1 = authorRepository.save(Author.builder()
                .name("김영한").email("younghank@example.com").build());
        Author author2 = authorRepository.save(Author.builder()
                .name("로버트 마틴").email("unclebob@example.com").build());

        bookRepository.save(Book.builder()
                .title("스프링 부트와 JPA 활용").isbn("978-89-12345-01-1")
                .publishedYear(2023).price(35000.0).author(author1).build());
        bookRepository.save(Book.builder()
                .title("Clean Code").isbn("978-01-32350-88-4")
                .publishedYear(2008).price(33000.0).author(author2).build());

        log.info("샘플 데이터 초기화 완료: 작가 2명, 도서 2권");
    }
}

자주 하는 실수

@Controller 대신 @RestController를 사용하는 것

@RestController를 쓰면 반환 값에 @ResponseBody가 적용되어 Spring GraphQL의 직렬화 과정을 방해할 수 있다. GraphQL Resolver는 반드시 @Controller를 사용해야 한다.

[!DANGER] @Argument의 이름 불일치

@Argument Long id에서 파라미터명 id는 스키마의 인자명과 일치해야 한다. 스키마에 book(id: ID!)로 정의했는데 @Argument Long bookId로 받으면 바인딩에 실패한다. 이름이 다를 때는 @Argument(name = "id") Long bookId처럼 명시적으로 지정한다.