1편에서 GraphQL의 개념과 스키마를 살펴봤다. 이번 편에서는 Spring Boot가 GraphQL 요청을 어떻게 받아서 처리하는지, 그 내부 구조를 파헤친다.

요청이 처리되는 전체 흐름

REST에서는 @GetMapping("/api/books")처럼 URL과 메서드를 직접 매핑한다. Spring GraphQL은 접근 방식이 다르다. 모든 요청이 /graphql 하나의 엔드포인트로 들어오고, 요청 본문에 담긴 쿼리를 파싱해서 적절한 Resolver 메서드를 찾아간다.

전체 흐름을 따라가 보자.

sequenceDiagram autonumber participant C as 클라이언트 participant E as /graphql 엔드포인트 participant P as 쿼리 파서 participant S as 스키마 매칭 participant R as Resolver participant DB rect rgb(232, 248, 232) Note over C, S: 라우팅 단계 C->>E: POST /graphql (쿼리 문자열) E->>P: 쿼리 파싱 P->>S: AST → 스키마 필드 매칭 S->>R: 매칭된 Resolver 메서드 호출 end rect rgb(240, 248, 255) Note over R, DB: 실행 단계 R->>DB: 데이터 조회/변경 DB-->>R: 결과 R-->>C: JSON 응답 (요청한 필드만) end

초록 영역이 Spring GraphQL의 핵심 역할이다. 쿼리 문자열을 파싱하고, 스키마와 대조해서, 어떤 Resolver 메서드를 호출할지 결정한다. 개발자가 직접 URL 라우팅을 신경 쓸 필요가 없다.

REST 컨트롤러와 비교하면 차이가 선명하다.

구분RESTSpring GraphQL
라우팅 기준URL + HTTP 메서드쿼리 문자열 내 필드명
엔드포인트 수리소스마다 별도/graphql 하나
응답 직렬화전체 객체요청된 필드만
매핑 어노테이션@GetMapping, @PostMapping@QueryMapping, @MutationMapping

Resolver란 무엇인가

Resolver는 스키마의 각 필드에 데이터를 채워 넣는 함수다. 스키마가 "어떤 데이터를 제공할 수 있는지"를 선언한다면, Resolver는 "그 데이터를 실제로 어떻게 가져오는지"를 구현한다.

Spring GraphQL에서는 @Controller 클래스 안에 Resolver 메서드를 정의한다. REST 컨트롤러와 구조가 비슷하지만, 어노테이션이 다르다.

@Controller
@RequiredArgsConstructor
public class QueryResolver {

    private final BookService bookService;
    private final AuthorService authorService;

    @QueryMapping
    public List<Book> books() {
        return bookService.getAllBooks();
    }

    @QueryMapping
    public Book book(@Argument Long id) {
        return bookService.getBookById(id);
    }
}

메서드 이름이 스키마의 필드명과 정확히 일치해야 한다. 스키마에 books: [Book!]!라고 정의했으면 메서드 이름도 books()여야 한다.

이 매칭 규칙을 그림으로 보면 이렇다.

flowchart LR subgraph Schema ["schema.graphqls"] direction TB S1["books: [Book!]!"] S2["book(id: ID!): Book"] S3["searchBooks(title: String!): [Book!]!"] end subgraph Resolver ["QueryResolver.java"] direction TB R1["@QueryMapping
books()"] R2["@QueryMapping
book(@Argument Long id)"] R3["@QueryMapping
searchBooks(@Argument String title)"] end S1 ---|"이름 매칭"| R1 S2 ---|"이름 매칭"| R2 S3 ---|"이름 매칭"| R3 style Schema fill:#E8F4F8,stroke:#2196F3,stroke-width:2px style Resolver fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px

스키마 필드명과 메서드명이 1:1로 연결된다. Spring GraphQL은 이 이름 매칭을 자동으로 처리한다.

@QueryMapping — 조회 연산

@QueryMapping은 스키마의 type Query 안에 정의된 필드를 처리한다. REST의 GET 핸들러와 역할이 같다.

@QueryMapping
public List<Book> books() {
    return bookService.getAllBooks();
}

@QueryMapping
public Book book(@Argument Long id) {
    return bookService.getBookById(id);
}

@QueryMapping
public List<Book> searchBooks(@Argument String title) {
    return bookService.searchBooks(title);
}

@Argument는 GraphQL 쿼리에서 넘어온 인자를 메서드 파라미터로 바인딩한다. book(id: ID!) 스키마의 id 인자가 @Argument Long id로 들어온다.

@Argument의 타입 변환

GraphQL의 ID! 타입은 문자열로 전달되지만, Spring GraphQL이 Java의 Long으로 자동 변환해준다. IntInteger, FloatDouble, BooleanBoolean 변환도 마찬가지다. 개발자가 직접 파싱할 필요가 없다.

@MutationMapping — 변경 연산

@MutationMapping은 스키마의 type Mutation 필드를 처리한다. 데이터를 생성, 수정, 삭제하는 연산이다.

@Controller
@RequiredArgsConstructor
public class MutationResolver {

    private final BookService bookService;
    private final AuthorService authorService;

    @MutationMapping
    public Book createBook(@Argument CreateBookInput input) {
        return bookService.createBook(input);
    }

    @MutationMapping
    public Book updateBook(@Argument Long id, @Argument UpdateBookInput input) {
        return bookService.updateBook(id, input);
    }

    @MutationMapping
    public Boolean deleteBook(@Argument Long id) {
        return bookService.deleteBook(id);
    }
}

여기서 주목할 부분은 @Argument CreateBookInput input이다. GraphQL의 input 타입이 Java의 POJO 클래스로 자동 매핑된다.

스키마의 input CreateBookInput과 Java의 CreateBookInput 클래스가 필드명이 같으면 자동으로 바인딩된다.

@Getter @Setter @ToString
public class CreateBookInput {
    private String title;
    private String isbn;
    private Integer publishedYear;
    private Double price;
    private Long authorId;
}

Input 클래스는 단순한 POJO면 충분하다. @Getter, @Setter만 있으면 Spring GraphQL이 알아서 값을 채워 넣는다.

서비스 계층의 역할

Resolver가 직접 Repository를 호출하지 않고 Service를 거치는 이유가 있다. Resolver는 GraphQL 요청을 Java 메서드로 연결하는 어댑터일 뿐이고, 비즈니스 로직은 Service에 두는 것이 책임 분리 원칙에 맞다.

block-beta columns 3 block:resolver["Resolver 계층"]:3 columns 3 QR["QueryResolver"] MR["MutationResolver"] SR["SubscriptionResolver"] end block:service["Service 계층"]:3 columns 2 BS["BookService"] AS["AuthorService"] end block:repo["Repository 계층"]:3 columns 2 BR["BookRepository"] AR["AuthorRepository"] end DB[("H2 Database")]:3 resolver --> service service --> repo repo --> DB style resolver fill:#E8F8E8,stroke:#4CAF50 style service fill:#FFF3E0,stroke:#FF9800 style repo fill:#E8F4F8,stroke:#2196F3 style DB fill:#F3E5F5,stroke:#9C27B0,color:#000

Resolver → Service → Repository → DB. 각 계층이 자기 책임에만 집중한다. Resolver는 요청 매핑, Service는 비즈니스 로직, Repository는 데이터 접근을 담당한다.

Service 계층에서는 트랜잭션 관리와 비즈니스 검증을 처리한다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BookService {

    private final BookRepository bookRepository;
    private final AuthorRepository authorRepository;

    @Transactional
    public Book createBook(CreateBookInput input) {
        Author author = authorRepository.findById(input.getAuthorId())
                .orElseThrow(() -> new RuntimeException("작가를 찾을 수 없습니다"));

        Book book = Book.builder()
                .title(input.getTitle())
                .isbn(input.getIsbn())
                .publishedYear(input.getPublishedYear())
                .price(input.getPrice())
                .author(author)
                .build();

        return bookRepository.save(book);
    }
}

클래스 레벨에 @Transactional(readOnly = true)를 걸어 모든 조회 메서드에 읽기 전용 트랜잭션을 적용하고, 쓰기 메서드에만 @Transactional을 별도로 붙이는 패턴이다.

GraphiQL로 테스트하기

spring-boot-starter-graphqlGraphiQL이라는 웹 기반 IDE를 내장하고 있다. application.yml에서 활성화하면 브라우저에서 바로 쿼리를 테스트할 수 있다.

spring:
  graphql:
    graphiql:
      enabled: true

http://localhost:8080/graphiql에 접속하면 왼쪽에 쿼리 에디터, 오른쪽에 결과 창이 나타난다. 스키마를 기반으로 자동완성도 지원한다.

쿼리 테스트 예시를 보자.

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

이 쿼리를 실행하면 모든 책의 id, title, price와 저자의 name만 담긴 JSON이 응답으로 온다. isbn, publishedYear, createdAt 같은 필드는 요청하지 않았으므로 응답에 포함되지 않는다.

개발 환경에서 초기 데이터 넣기

테스트하려면 데이터가 있어야 한다. CommandLineRunner를 구현한 DataInitializer를 만들면 애플리케이션 시작 시 자동으로 샘플 데이터를 넣을 수 있다. H2 인메모리 DB를 쓰면 재시작할 때마다 깨끗한 상태에서 시작하니까 부담 없이 실험할 수 있다.

GraphQL 타입과 Java 타입 매핑

스키마 타입이 Java에서 어떤 타입으로 변환되는지 정리해두면 혼동이 줄어든다.

GraphQL 타입Java 타입비고
IDLong, String자동 변환
StringString
IntInteger
FloatDouble
BooleanBoolean
[Book!]!List<Book>Non-Null 리스트
BookBook (엔티티)Nullable
Book!BookNon-Null

주의할 점은 GraphQL의 Float가 Java의 Float가 아니라 Double이라는 것이다. GraphQL의 Float는 배정밀도 부동소수점이기 때문이다.

자주 하는 실수

메서드 이름과 스키마 필드명 불일치

스키마에 searchBooks라고 정의했는데 메서드 이름을 findBooks로 지으면 매칭에 실패한다. Spring GraphQL은 이름 기반 매칭을 하기 때문에 반드시 스키마 필드명과 메서드명이 동일해야 한다. 불일치하면 런타임에 No DataFetcher 에러가 발생한다.

[!DANGER] Resolver에서 비즈니스 로직 직접 구현

Resolver에 트랜잭션 관리, 검증, 데이터 가공 로직을 넣으면 테스트가 어려워지고 코드가 비대해진다. Resolver는 요청을 Service로 위임하는 얇은 계층으로 유지해야 한다. REST 컨트롤러에 SQL을 직접 쓰지 않는 것과 같은 이유다.