Spring Data JPA에서 Specification으로 엔티티 JOIN하기

엔티티 쪼개기

혹시나 정보처리기사 자격증을 땄거나 혹은 따기 위해 정보처리기사 공부를 했다면 알고 있겠지만, 관계형 데이터베이스는 정규화라는 과정을 거치면서 데이터의 중복을 제거해 나간다. 이 과정에서 중복된 데이터는 중복을 제거한 뒤 다른 테이블로 쪼개지고, 기존 테이블의 컬럼 값은 FK(이하 외래 키)로 대체된다.

[JPA] 스프링 데이터 JPA에서 Specification으로 동적 WHERE절 구현하기 (1)에서 사용했던 Book 엔티티를 떠올려 보자.

@Getter
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "BOOK")
public class Book {
    
    @Id
    @Column(name = "BOOK_ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(name = "BOOK_TITLE", nullable = false)
    private String title;
    
    @Column(name = "BOOK_AUTHOR_NAME")
    private String authorName;

    @Column(name = "BOOK_PUBLISHER_NAME")
    private String publisherName;

    // 그 외, 기타 컬럼들...
}
예전 글에서 보여줬던 Book.java 코드

이 엔티티는 몇 가지 문제가 예상된다. 예를 들어, 책 하나에는 한 명의 저자 혹은 N명의 저자를 가질 수 있으며 하나의 출판사 정보를 가질 것이다. 반대로 한 명의 저자는 여러 권의 책을 저술할 수 있으며, 하나의 출판사는 여러 권의 책을 출간할 것이다. 이를 간단하게 표현하면 [ 책 : 저자 = N : M, 책 : 출판사 = 1 : N ] 와 같이 표현할 수 있다. 이렇게 1:N, N:M 관계를 가진다는 것은 중복되는 데이터가 발생할 수 있음을 의미한다. 이를 해결하기 위해, 나는 다음과 같이 엔티티를 쪼갰다.

@Getter
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "BOOK")
public class Book {
    
    @Id
    @Column(name = "BOOK_ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(name = "BOOK_TITLE", nullable = false)
    private String title;
    
    @OneToMany(mappedBy = "book")
    private List<BookAuthor> bookAuthors;
    
    @JoinColumn(name = "PUBLISHER_ID")
    @ManyToOne(cascade = CascadeType.PERSIST)
    private Publisher publisher;

    // 그 외, 기타 컬럼들...
}
Book.java
@Getter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "PUBLISHER")
public class Publisher {
    
    @Id
    @Column(name = "PUBLISHER_ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @NotEmpty
    @Column(name = "PUBLISHER_NAME", nullable = false, unique = true)
    private String name;

    @OneToMany(mappedBy = "publisher")
    private List<Book> books;
}
Publisher.java
@Getter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@IdClass(BookAuthorId.class)
public class BookAuthor implements Persistable<String> {

    @Id
    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "BOOK_ID")
    private Book book;
    
    @Id
    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "AUTHOR_ID")
    private Author author;
}
BookAuthor.java
@Getter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "AUTHOR")
public class Author {
    
    @Id
    @Column(name = "AUTHOR_ID")
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(name = "AUTHOR_NAME", nullable = false, unique = true)
    private String name;

    @OneToMany(mappedBy = "author")
    private List<BookAuthor> bookAuthors;
}
Author.java

엔티티를 네 개로 쪼갰더니 많이 복잡해 보인다. 처음부터 천천히 다시 이해해 보도록 하자. 우선 기존의 Book 엔티티에 저장했던 출판사 명의 관계부터 확인해 보자. 원래 Book 엔티티에서는 출판사 명을 publisherName이라는 이름을 가진 필드 명을 통해 저장했다. 이것을 Publisher라는 별도의 엔티티로 분리해 출판사 명을 관리하도록 변경했다. 책과 출판사는 1:N 관계이기 때문에 각각의 엔티티 내부에는, 해당하는 엔티티를 @ManyToOne, @OneToMany 어노테이션을 달아 엔티티의 연관 관계를 애플리케이션에 알린다.

그 다음은, Author 엔티티를 확인해보자. publisherName과 마찬가지로 authorName이라는 이름을 가진 필드 명을 통해 저자 명을 저장 했었다. 이것도 마찬가지로 Author라는 엔티티로 분리한 다음 저자 명을 관리하도록 변경했다. 위에서 설명했듯이 책과 저자는 N:M의 관계를 가지고 있다. 이 N:M(이하 다대다) 관계 구현을 위해 추가적으로 BookAuthor라는 가교 역할의 엔티티를 하나 더 만들었다. 물론 JPA에서는 @ManyToMany라는 다대다 관계를 애플리케이션에 알려줄 수 있는 어노테이션이 존재하기는 하지만 잘 쓰이지 않는다고 한다. 따라서 나는 위와 같이
Book - BookAuthor - Author 세 개의 엔티티를 사용해 다대다 관계를 구현했다.

위와 같이 다대다 관계를 구현하는 이유가 궁금하다면 아래에 참조로 첨부한 글을 읽어보거나 '자바 ORM 표준 JPA 프로그래밍' 이라는 책을 가지고 있다면 6장의 '6.4.3 다대다: 매핑의 한계와 극복, 연결 엔티티 사용' 항목을 참고하도록 하자.

[JPA] @ManyToMany, 다대다[N:M] 관계
다대다[N:M] 실무에선 사용하지 않는 것을 추천한다. 사용하면 안되는 이유를 학습하자. 관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없다. 연결 테이블(조인 테이블)을 추가해서 일대..

BookSpecs 재구성하기

예전에 BookSpecs라는 클래스를 생성해 spec을 조합했던 것을 기억 할 것이다.

public class BookSpecs {

    // 인스턴스화 방지
    private BookSpecs() {
        throw new AssertionError();
    }

    public static Specification<Book> equalTitle(String title) {
        return (root, query, builder) ->
               builder.equal(root.get("title"), title);
    }

    public static Specification<Book> equalAuthorName(String authorName) {
        return (root, query, builder) ->
               builder.equal(root.get("authorName"), authorName);
    }

    public static Specification<Book> equalPublisherName(String publisherName) {
        return (root, query, builder) ->
               builder.equal(root.get("publisherName"), publisherName);
    }
}
BookSpecs.java

하지만 이 코드는 더 이상 그대로 사용할 수는 없다. Book 엔티티에서 그냥 필드 명을 비교해도 됐던 이전과는 달리, 지금은 엔티티끼리 결합하지 않으면 저자 명과 출판사 명에 접근할 수 없기 때문이다. 그렇다면 이제부터 엔티티끼리 결합하는 방법에 대해 알아보도록 하자. 먼저 equalAuthorName() 함수부터 바꿔보자. builder.equal() 함수의 첫 번째 인자를 변경할 것이다. 다음의 코드가 변경된 코드다.

public static Specification<Book> equalAuthorName(String authorName) {
        return (root, query, builder) ->
               builder.equal(root.join("bookAuthors", JoinType.INNER)
               					 .join("author", JoinType.INNER)
                                 .get("name"), authorName);
    }
equalAuthorName 함수

기존의 builder.equal(root.get("authorName"), authorName) 코드를 길쭉하게도 늘려 놨는데, 요약해서 설명하자면 아래와 같은 동작을 하도록 변경된 것이다.

  1. Book 엔티티와 BookAuthor 엔티티를 INNER JOIN 한다.
  2. INNER JOIN된 엔티티에 다시 Author 엔티티를 INNER JOIN 한다.
  3. 최종적으로 JOIN된 엔티티에서 Author 엔티티의 name 값이 authorName 매개변수와 동일한 값인지 확인한다.

결국 JOIN만 여러 번 한 거지, 최종적으로는 Author 엔티티의 name 값을 기존처럼 매개변수로 들어오는 authorName의 값과 동일한지 확인하는 것이다.

그 다음은 equalPublisherName() 함수를 바꿔보자.

public static Specification<Book> equalPublisherName(String publisherName) {
        return (root, query, builder) ->
               builder.equal(root.join("publisher", JoinType.INNER)
                                 .get("name"), publisherName);
    }
equalPublisherName 함수

이것도 마찬가지로 Book 엔티티에 Publisher 엔티티를 INNER JOIN 한 후, Publisher 엔티티의 name 값이 publisherName 매개변수와 동일한지 확인한다.

Book과 Publisher 엔티티처럼 양방향 관계면서 연관 관계의 주인인 엔티티에서 Specification을 사용할 경우 한 가지 더 선택지가 있다.

public static Specification<Book> equalPublisherName(String publisherName) {
        return (root, query, builder) ->
               builder.equal(root.get("publisher").get("name"), publisherName);
    }
또 다른 equalPublisherName 함수

join() 함수를 사용하지 않고, 그냥 get() 함수로 Publisher 엔티티를 JOIN하는 방법이다. 이것이 가능한 이유는 연관 관계의 주인인 엔티티가 데이터베이스에 저장될 때, 종속되는 엔티티의 객체가 null이 아니라면 종속되는 엔티티의 ID 값이 외래 키로 같이 저장되기 때문이다.

BOOK 테이블 publisher_id 컬럼에 PUBLISHER 테이블의 기본 키가 들어간 것을 알 수 있다

외래 키가 존재해 JPA가 연관 관계를 파악할 수 있다면, 자동으로 두 엔티티를 JOIN 하는 JPQL을 작성해준다. 다만 이 경우에는 두 개의 엔티티가 JOIN 되었다는 사실을 명시적으로 보여주지 못하기 때문에 나 같은 경우에는 첫 번째 방법을 사용하고 있다.


정리하며

원래 'Specification으로 동적 WHERE절 구현하기' 글의 후속 글을 적으면서 조건부 연산자에 대해 다루려고 했었는데, JOIN에 대해 먼저 다룰 필요가 있어 보여서 둘을 하나의 글로 다룰려고 했었다. 하지만 글을 쓰다 보니 지나치게 길어질 것 같아 이번 글에서는 Specification으로 JOIN을 구현하는 방법에 대해서 만 먼저 다루어 보았다. 조만간 후속 글로 Specification으로 WHERE 절에서 조건부 연산자 사용 하는 법에 대해 다루어 보도록 하겠다.


참고한 문헌 및 블로그 글

  1. [JPA] @ManyToMany, 다대다[N:M] 관계 (tistory.com)
  2. 자바 ORM 표준 JPA 프로그래밍 - 김명환 저