1편에서 스키마를 작성했다. 이번 편에서는 스키마에 대응하는 JPA 엔티티, Repository, Input 클래스, Service를 구현한다. GraphQL이든 REST든 도메인 계층은 동일하다. 차이는 컨트롤러(Resolver) 뿐이다.

단계별로 만들기

단계 1 — JPA 엔티티 작성

스키마의 type Booktype Author에 대응하는 JPA 엔티티를 만든다.

먼저 Author 엔티티다. 한 명의 저자가 여러 권의 책을 가지는 일대다 관계의 "일" 쪽이다.

package com.codeit.graphql.entity;

import jakarta.persistence.*;
import lombok.*;

import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "authors")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Author {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<Book> books = new ArrayList<>();

    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);
    }

    public void removeBook(Book book) {
        books.remove(book);
        book.setAuthor(null);
    }
}

@OneToMany(mappedBy = "author")는 Book 엔티티의 author 필드가 관계의 주인임을 선언한다. cascade = CascadeType.ALL은 Author를 저장/삭제할 때 연관된 Book도 함께 처리한다는 뜻이다.

addBook()removeBook()은 양방향 관계의 양쪽을 동시에 맞추는 편의 메서드다. books.add(book)만 하고 book.setAuthor(this)를 빼먹으면 DB에 반영되지 않는다.

다음은 Book 엔티티다.

package com.codeit.graphql.entity;

import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Entity
@Table(name = "books")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, unique = true)
    private String isbn;

    @Column(nullable = false)
    private Integer publishedYear;

    @Column(nullable = false)
    private Double price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;

    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;
}

@ManyToOne(fetch = FetchType.LAZY)가 중요하다. 기본값은 EAGER인데, 그러면 Book을 조회할 때마다 Author도 함께 로딩된다. LAZY로 설정하면 실제로 접근할 때만 Author를 조회한다. N+1 문제와 연결되는 설정이다.

@CreationTimestamp는 엔티티가 처음 저장될 때 현재 시각을 자동으로 넣어준다.

단계 2 — Repository 작성

Spring Data JPA의 JpaRepository를 상속한다. 기본 CRUD 외에 검색용 커스텀 메서드도 추가한다.

package com.codeit.graphql.repository;

import com.codeit.graphql.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface BookRepository extends JpaRepository<Book, Long> {

    List<Book> findByTitleContainingIgnoreCase(String title);

    List<Book> findByAuthorId(Long authorId);

    Optional<Book> findByIsbn(String isbn);

    @Query("SELECT b FROM Book b LEFT JOIN FETCH b.author")
    List<Book> findAllWithAuthor();
}

findByTitleContainingIgnoreCase는 제목에 특정 문자열이 포함된 책을 대소문자 무시하고 검색한다. Spring Data JPA가 메서드 이름을 파싱해서 쿼리를 만들어준다.

findAllWithAuthor()는 JPQL로 JOIN FETCH를 사용한다. Book을 가져올 때 Author를 함께 로딩해서 추가 쿼리 없이 한 번에 가져온다.

package com.codeit.graphql.repository;

import com.codeit.graphql.entity.Author;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface AuthorRepository extends JpaRepository<Author, Long> {

    Optional<Author> findByEmail(String email);

    @Query("SELECT a FROM Author a LEFT JOIN FETCH a.books")
    List<Author> findAllWithBooks();
}

단계 3 — Input 클래스 작성

스키마의 input 타입에 대응하는 Java 클래스를 만든다. 단순한 POJO면 된다.

package com.codeit.graphql.input;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter @Setter @ToString
public class CreateBookInput {
    private String title;
    private String isbn;
    private Integer publishedYear;
    private Double price;
    private Long authorId;
}
package com.codeit.graphql.input;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter @Setter @ToString
public class UpdateBookInput {
    private String title;
    private String isbn;
    private Integer publishedYear;
    private Double price;
}
package com.codeit.graphql.input;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter @Setter @ToString
public class CreateAuthorInput {
    private String name;
    private String email;
}
package com.codeit.graphql.input;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter @Setter @ToString
public class UpdateAuthorInput {
    private String name;
    private String email;
}

스키마 필드명과 Java 필드명이 정확히 일치해야 자동 바인딩이 동작한다.

단계 4 — Service 구현

비즈니스 로직을 담당하는 Service 클래스를 만든다. Resolver가 직접 Repository를 호출하지 않고, 반드시 Service를 거치도록 한다.

BookService부터 만든다.

package com.codeit.graphql.service;

import com.codeit.graphql.entity.Author;
import com.codeit.graphql.entity.Book;
import com.codeit.graphql.input.CreateBookInput;
import com.codeit.graphql.input.UpdateBookInput;
import com.codeit.graphql.repository.AuthorRepository;
import com.codeit.graphql.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

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

    private final BookRepository bookRepository;
    private final AuthorRepository authorRepository;

    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    public List<Book> getAllBooksWithAuthor() {
        return bookRepository.findAllWithAuthor();
    }

    public Book getBookById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("도서를 찾을 수 없습니다: ID=" + id));
    }

    public List<Book> searchBooks(String title) {
        return bookRepository.findByTitleContainingIgnoreCase(title);
    }

    public List<Book> getBooksByAuthor(Long authorId) {
        return bookRepository.findByAuthorId(authorId);
    }

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

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

        Book savedBook = bookRepository.save(book);
        log.info("도서 생성 완료: {}", savedBook.getTitle());
        return savedBook;
    }

    @Transactional
    public Book updateBook(Long id, UpdateBookInput input) {
        Book book = getBookById(id);

        if (input.getTitle() != null) book.setTitle(input.getTitle());
        if (input.getIsbn() != null) book.setIsbn(input.getIsbn());
        if (input.getPublishedYear() != null) book.setPublishedYear(input.getPublishedYear());
        if (input.getPrice() != null) book.setPrice(input.getPrice());

        return bookRepository.save(book);
    }

    @Transactional
    public boolean deleteBook(Long id) {
        Book book = getBookById(id);
        bookRepository.delete(book);
        return true;
    }
}

클래스에 @Transactional(readOnly = true)를 걸고, 쓰기 메서드에만 @Transactional을 별도로 붙이는 패턴이다. 읽기 전용 트랜잭션은 JPA의 더티 체킹을 건너뛰어 성능이 약간 좋아진다.

updateBook()의 null 체크 패턴에 주목하자. UpdateBookInput의 필드가 전부 선택이므로, null이 아닌 필드만 업데이트한다. 클라이언트가 title만 보내면 title만 바뀌고 나머지는 그대로 유지된다.

AuthorService도 같은 패턴으로 만든다.

package com.codeit.graphql.service;

import com.codeit.graphql.entity.Author;
import com.codeit.graphql.input.CreateAuthorInput;
import com.codeit.graphql.input.UpdateAuthorInput;
import com.codeit.graphql.repository.AuthorRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

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

    private final AuthorRepository authorRepository;

    public List<Author> getAllAuthors() {
        return authorRepository.findAll();
    }

    public List<Author> getAllAuthorsWithBooks() {
        return authorRepository.findAllWithBooks();
    }

    public Author getAuthorById(Long id) {
        return authorRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("작가를 찾을 수 없습니다: ID=" + id));
    }

    public List<Author> findAllById(List<Long> ids) {
        return authorRepository.findAllById(ids);
    }

    @Transactional
    public Author createAuthor(CreateAuthorInput input) {
        authorRepository.findByEmail(input.getEmail())
                .ifPresent(a -> {
                    throw new RuntimeException("이미 등록된 이메일: " + input.getEmail());
                });

        Author author = Author.builder()
                .name(input.getName())
                .email(input.getEmail())
                .build();

        Author savedAuthor = authorRepository.save(author);
        log.info("작가 생성 완료: {}", savedAuthor.getName());
        return savedAuthor;
    }

    @Transactional
    public Author updateAuthor(Long id, UpdateAuthorInput input) {
        Author author = getAuthorById(id);

        if (input.getName() != null) author.setName(input.getName());
        if (input.getEmail() != null) {
            authorRepository.findByEmail(input.getEmail())
                    .filter(a -> !a.getId().equals(id))
                    .ifPresent(a -> {
                        throw new RuntimeException("이미 등록된 이메일: " + input.getEmail());
                    });
            author.setEmail(input.getEmail());
        }

        return authorRepository.save(author);
    }

    @Transactional
    public boolean deleteAuthor(Long id) {
        Author author = getAuthorById(id);

        if (!author.getBooks().isEmpty()) {
            throw new RuntimeException("도서가 존재하는 작가는 삭제할 수 없습니다");
        }

        authorRepository.delete(author);
        return true;
    }
}

createAuthor()에서 이메일 중복 검사를, deleteAuthor()에서 연관 도서 존재 여부를 검증한다. 비즈니스 규칙이 Service 안에 응집되어 있다.

코드 뜯어보기

Book 엔티티 — FetchType.LAZY

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id", nullable = false)
private Author author;

FetchType.LAZY는 Book을 조회할 때 Author를 즉시 로딩하지 않는다는 설정이다. book.getAuthor()를 호출하는 순간에 별도 쿼리가 실행된다. 이렇게 해야 Book만 필요한 상황에서 불필요한 Author 조회를 피할 수 있다.

하지만 Book 목록을 가져온 뒤 각 Book의 Author에 접근하면 N+1 문제가 발생한다. 이 문제는 4편에서 @BatchMapping으로 해결한다.

Author 엔티티 — @Builder.Default

@Builder.Default
private List<Book> books = new ArrayList<>();

Lombok의 @Builder를 사용할 때 컬렉션 필드에 @Builder.Default를 붙이지 않으면, Builder로 객체를 만들 때 books가 null이 된다. new ArrayList<>()로 초기화한 값이 Builder에 의해 무시되기 때문이다. @Builder.Default를 붙여야 기본값이 유지된다.

BookService — updateBook의 null 체크

if (input.getTitle() != null) book.setTitle(input.getTitle());
if (input.getIsbn() != null) book.setIsbn(input.getIsbn());

GraphQL의 Update Input은 선택 필드로 구성된다. 클라이언트가 보내지 않은 필드는 null로 들어온다. null인 필드는 건너뛰고, 값이 있는 필드만 업데이트하는 것이 핵심이다.

전체 구조 다이어그램

지금까지 만든 코드의 계층 구조를 정리하면 이렇다.

flowchart TD subgraph Input ["Input 클래스"] direction LR CBI["CreateBookInput"] UBI["UpdateBookInput"] CAI["CreateAuthorInput"] UAI["UpdateAuthorInput"] end subgraph Service ["Service 계층"] direction LR BS["BookService"] AS["AuthorService"] end subgraph Repository ["Repository 계층"] direction LR BR["BookRepository"] AR["AuthorRepository"] end subgraph Entity ["Entity"] direction LR B["Book"] A["Author"] end Input -->|"파라미터로 전달"| Service Service -->|"조회/저장"| Repository Repository -->|"매핑"| Entity B <-->|"@ManyToOne / @OneToMany"| A style Input fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style Service fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style Repository fill:#E8F4F8,stroke:#2196F3,stroke-width:2px style Entity fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px

Input → Service → Repository → Entity 순서로 데이터가 흐른다. 다음 편에서 이 위에 Resolver 계층을 얹으면 GraphQL API가 동작한다.

이번 편 최종 전체 코드

Author.java

package com.codeit.graphql.entity;

import jakarta.persistence.*;
import lombok.*;

import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "authors")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Author {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<Book> books = new ArrayList<>();

    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);
    }

    public void removeBook(Book book) {
        books.remove(book);
        book.setAuthor(null);
    }
}

Book.java

package com.codeit.graphql.entity;

import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Entity
@Table(name = "books")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, unique = true)
    private String isbn;

    @Column(nullable = false)
    private Integer publishedYear;

    @Column(nullable = false)
    private Double price;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private Author author;

    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;
}

BookRepository.java

package com.codeit.graphql.repository;

import com.codeit.graphql.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface BookRepository extends JpaRepository<Book, Long> {
    List<Book> findByTitleContainingIgnoreCase(String title);
    List<Book> findByAuthorId(Long authorId);
    Optional<Book> findByIsbn(String isbn);

    @Query("SELECT b FROM Book b LEFT JOIN FETCH b.author")
    List<Book> findAllWithAuthor();
}

AuthorRepository.java

package com.codeit.graphql.repository;

import com.codeit.graphql.entity.Author;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface AuthorRepository extends JpaRepository<Author, Long> {
    Optional<Author> findByEmail(String email);

    @Query("SELECT a FROM Author a LEFT JOIN FETCH a.books")
    List<Author> findAllWithBooks();
}

Input 클래스 4개

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

// UpdateBookInput.java
@Getter @Setter @ToString
public class UpdateBookInput {
    private String title;
    private String isbn;
    private Integer publishedYear;
    private Double price;
}

// CreateAuthorInput.java
@Getter @Setter @ToString
public class CreateAuthorInput {
    private String name;
    private String email;
}

// UpdateAuthorInput.java
@Getter @Setter @ToString
public class UpdateAuthorInput {
    private String name;
    private String email;
}

BookService.java / AuthorService.java

위 단계 4의 전체 코드를 참조한다.

자주 하는 실수

@Builder.Default 누락

@Builder와 함께 컬렉션 필드를 초기화할 때 @Builder.Default를 빼먹으면, Builder로 생성한 객체의 컬렉션이 null이 된다. NullPointerException이 발생해도 원인을 찾기 어렵다.

[!DANGER] 양방향 관계에서 한쪽만 설정하는 것

author.getBooks().add(book)만 하고 book.setAuthor(author)를 하지 않으면, DB에는 관계가 저장되지 않는다. JPA에서 관계의 주인(FK를 가진 쪽)의 값만 DB에 반영되기 때문이다. 편의 메서드(addBook)를 사용해서 양쪽을 동시에 맞추는 습관을 들여야 한다.