3. Resolver 작성과 GraphiQL 실습 ← 현재 문서
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이 어떤 경로를 타는지 한눈에 보자.
초록 영역이 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권");
}
}
자주 하는 실수
@RestController를 쓰면 반환 값에 @ResponseBody가 적용되어 Spring GraphQL의 직렬화 과정을 방해할 수 있다. GraphQL Resolver는 반드시 @Controller를 사용해야 한다.
[!DANGER] @Argument의 이름 불일치
@Argument Long id에서 파라미터명 id는 스키마의 인자명과 일치해야 한다. 스키마에 book(id: ID!)로 정의했는데 @Argument Long bookId로 받으면 바인딩에 실패한다. 이름이 다를 때는 @Argument(name = "id") Long bookId처럼 명시적으로 지정한다.