[Spring-DATA-JPA-2] Entity Relationships Mapping

6 분 소요

1. Entity Relationships Mapping

객체는 참조를 통해 관계를 정의하지만, 데이터베이스는 외래 키를 사용하여 테이블 관계를 정의합니다.
엔티티 연관관계 매핑(Entity Relationship Mapping)은 두 데이터베이스 테이블 간의 관계를 도메인 모델의 속성으로 모델링합니다.
즉, 데이터베이스 테이블 간의 관계를 객체 간의 관계로 매핑하는 것을 의미합니다.

엔티티 연관관계는 일대일, 다대일, 다대다 등 연결 방식으로 분류되기도 하며,
연결 방향에 따라 엔티티가 상호 접근이 가능하면 양방향, 한쪽 방향으로만 접근이 가능하면 단방향으로 분류됩니다.

엔티티 연관관계는 연결 방식과 연결 방향에 따라 다른 쿼리가 생성되며,
간혹 의도하지 않은 쿼리가 생성되는 경우도 있습니다.
따라서 상황에 따른 적절한 연결 방식과 연결 방향을 선택하는 것이 중요합니다.

image


2. 다대일 단방향

하나의 Publisher는 여러개의 Book을 가질 수 있는 다대일 관계를 예시가 있습니다.
데이터베이스 테이블에서는 Book 테이블의 외래 키가 Publisher 테이블의 기본 키를 참조하게 설계하는게 일반적입니다.
이 경우, 객체 설계 방법으로 Book 클래스에 Publisher 클래스를 참조하는 멤버 변수를 추가할 수 있습니다.
다대일 관계의 멤버 변수를 설정 할 때는 @ManyToOne 어노테이션을 사용합니다.

@Entity
class Book {
    @Id @GeneratedValue
    Long id;

    @ManyToOne
    Publisher publisher;

    // ...
}

image 1

  • book 테이블에 publisher_id라는 외래 키 필드가 생성

image 2

  • book 테이블에 publisher_id 필드에 대한 외래 키 제약 조건이 추가
  • book 테이블의 publisher_idpublisher 테이블의 id 필드를 참조

3. 다대일 양방향

만약 특정 Publisher가 발행한 모든 책을 조회해야 한다면, PublisherBook을 멤버 변수로 관계를 맺으면 좋습니다.
이 경우 BookPublisher에 의존하는 상황에서 PublisherBook에 의존하는 관계를 양방향이라고 합니다.
양방향 연관관계를 만들기 위해서는, Publisher 클래스에 콜렉션 값 속성을 추가하고 이를 @OneToMany으로 지정합니다.
이렇게 하면 Book 클래스와 Publisher 클래스 사이에 양방향 연관관계가 설정됩니다.

양방향 연관관계임을 명확히 표시하고, 이미 지정된 매핑 정보를 재사용하기 위해서는 ‘mappedBy‘를 사용해야 합니다.

데이터베이스에서 테이블간의 관계를 맺을 때, 외래키를 가지는 테이블연관관계의 주인이라고 합니다.
엔티티 클래스에서도 연관관계의 주인을 지정해야 합니다.
연관관계의 주인은 mappedBy 속성을 통해 지정할 수 있습니다.
mappedBy 속성은 연관관계의 주인이 아닌 엔티티 클래스의 멤버 변수 이름을 지정해야 합니다.

⇒ 정리

foreign key == @ManyToOne Publisher publisher;

연관관계의 주인 == foreign key를 가지는 테이블 == book table

  • 실제로 데이터베이스에서 관계를 관리

연관관계의 주인이 아닌 엔티티 == publisher table == mappedBy 속성을 사용

  • mappedBy : 주인이 되는 엔티티의 어떤 필드(멤버 변수)(foreign key)에 의해 매핑되는지를 지정
  • 데이터베이스에 외래 키를 직접 관리하지 않으며, 단지 매핑 정보를 재사용
@Entity
class Publisher {
    @Id @GeneratedValue
    Long id;

    @OneToMany(mappedBy="publisher")
    Set<Book> books;

    // ...
}

@OneToMany
Book book; 

‘One To Many’ attribute type should be a container
Set<Book> books = new HashSet<>();

1). mappedBy 설정이 있는 경우 (Publisher 클래스)

@Entity
public class Publisher {
    // ...
    @OneToMany(mappedBy="publisher")
    Set<Book> books = new HashSet<>();
    // ...
}

  • SQL 쿼리: 이 경우, PublisherBook 사이의 관계에서 외래 키(publisher_id)는 Book 테이블에만 존재하며, Publisher 엔티티는 이를 단순히 참조합니다.
  • 쿼리 생성: Book 엔티티가 외래 키를 관리하므로, Book을 삽입할 때 외래 키(publisher_id)가 함께 삽입됩니다. Publisher 테이블에는 외래 키 관련 정보가 포함되지 않습니다.
  • 쿼리 예시:

    image 3

2). mappedBy 설정이 없는 경우 (Publisher 클래스)

@Entity
public class Publisher {
    // ...
    @OneToMany
    Set<Book> books = new HashSet<>();
    // ...
}

  • SQL 쿼리: mappedBy 설정이 없으면 JPA는 PublisherBook 간의 양방향 관계를 두 개의 독립적인 관계로 인식합니다. 따라서 Publisher 테이블에 books 컬렉션을 표현하기 위한 중간 테이블이 생성될 수 있습니다.
  • 쿼리 생성: 이 경우, JPA는 PublisherBook 사이에 추가적인 관계를 관리하기 위해 연결 테이블을 생성할 수 있으며, 이로 인해 더 많은 쿼리가 발생합니다.
  • 쿼리 예시:

    image 4

다대일 관계 매핑 후 repository를 이용하여 데이터를 조회하면 어떤 쿼리가 생성되는지 확인해보세요.

findById - JPA repository에 포함

JPA의 로딩 전략(FetchType.EAGER vs FetchType.LAZY)

EAGER는 즉시 로딩을 위해 JOIN을 사용하고, LAZY는 필요할 때만 데이터를 로딩하기 때문에 초기 쿼리에서는 JOIN이 사용되지 않습니다.

이 현상은 JPA에서의 연관관계 매핑과 로딩 전략(fetch 옵션)이 SQL 쿼리 생성에 어떻게 영향을 미치는지를 보여줍니다. 각각의 경우에 대해 설명하겠습니다.

1. findByIdForBook 메서드에서 LEFT JOIN 사용

@ManyToOne
Publisher publisher;

  • 설명: findByIdForBook 메서드는 Book 엔티티를 조회합니다. 이때 Book@ManyToOne 관계를 통해 Publisher와 연결되어 있습니다.
  • 기본 설정: @ManyToOne 관계는 기본적으로 FetchType.EAGER로 설정되어 있습니다. 즉, Book 엔티티를 조회할 때 연관된 Publisher 엔티티도 함께 로딩됩니다.
  • SQL 쿼리: 따라서 Book을 조회하는 쿼리가 실행될 때, Publisher를 함께 조회하기 위해 LEFT JOIN이 사용됩니다.

image 5

2. findByIdForPublisher 메서드에서 LEFT JOIN이 사용되지 않음

@OneToMany(mappedBy = "publisher")
Set<Book> books;

  • 설명: findByIdForPublisher 메서드는 Publisher 엔티티를 조회합니다. Publisher@OneToMany 관계를 통해 여러 Book 엔티티와 연결되어 있습니다.
  • 기본 설정: @OneToMany 관계는 기본적으로 FetchType.LAZY로 설정되어 있습니다. 즉, Publisher를 조회할 때 연관된 Book 컬렉션은 즉시 로딩되지 않고, 실제로 접근할 때 로딩됩니다.
  • SQL 쿼리: findByIdForPublisher 메서드에서는 Publisher를 조회할 때 LEFT JOIN이 사용되지 않고, 단순히 Publisher 엔티티만 조회됩니다. 이는 Book 컬렉션이 지연 로딩되기 때문입니다. 실제로 Publisher.getBooks()를 호출하기 전까지는 Book 엔티티들이 로드되지 않습니다.

image 6

3. @OneToMany(mappedBy = "publisher", fetch = FetchType.EAGER)로 설정 변경 후 LEFT JOIN 사용

@OneToMany(mappedBy = "publisher", fetch = FetchType.EAGER)
Set<Book> books;

  • 설명: 여기서는 @OneToMany 관계에서 fetch = FetchType.EAGER로 설정이 변경되었습니다.
  • 변경된 설정: FetchType.EAGER로 설정하면 Publisher를 조회할 때 연관된 Book 엔티티들도 즉시 로딩됩니다.
  • SQL 쿼리: 이로 인해 findByIdForPublisher 메서드를 실행할 때 Publisher와 관련된 Book들을 함께 조회하기 위해 LEFT JOIN 쿼리가 실행됩니다.

image 7

  • Lazy Loading: 연관된 엔티티를 필요할 때 로딩합니다. 성능 최적화에 유리하지만, 추가적인 쿼리가 발생할 수 있으며, 올바르게 사용하지 않으면 LazyInitializationException이 발생할 수 있습니다.
  • Eager Loading: 연관된 엔티티를 즉시 로딩합니다. 모든 데이터를 한 번에 로딩하기 때문에 이후 쿼리를 줄일 수 있지만, 불필요한 데이터를 로딩할 위험이 있으며, 대규모 데이터에서는 성능 문제가 발생할 수 있습니다.

4. 일대일 단방향

일대일 관계는 UNIQUE 제약 조건이 있는 외래 키 열에 매핑된다는 점을 제외하면 @ManyToOne 연관과 거의 동일합니다.
Author 테이블에는 연결된 Person의 식별자를 보유하는 외래키 컬럼이 있습니다.

@Entity
class Author {
    @Id @GeneratedValue
    Long id;

    @OneToOne
    Person person;

    // ...
}

image 8

  • person_id column이 UNIQUE 제약 조건이 있는 외래 키 열에 매핑

5. 일대일 양방향

Person 엔터티의 Author에 대한 참조를 다시 추가하여 이 연결을 양방향으로 만들 수 있습니다.
mappedBy로 표시되지 않은 쪽의존 관계의 주인이기 때문에, Author 엔티티의 author 멤버 변수에는 mappedBy 속성을 지정해야 합니다.

@Entity
class Person {
  @Id @GeneratedValue
  Long id;

  @OneToOne(mappedBy = "person")
  Author author;

  // ...
}

auth table

image 9

  • person_id 외래키를 가지고 있음
  • 의존 관계의 주인

person table

image 10

  • mappedBy 속성 설정
  • 의존 관계의 주인이 아닌 엔티티 클래스

mappedBy 설정이 없는 경우

에러가 발생!

⇒ OneToOne의 경우 어느 테이블에 외래 키가 존재할지를 결정하기 위해, 반드시 두 엔티티 중 하나가 관계의 소유자가 되어야 합니다.

mappedBy 속성이 없으면, JPA는 두 엔티티가 각각의 외래 키를 소유한다고 생각합니다. 이는 OneToOne 관계에서 불필요한 외래 키가 두 테이블에 생성될 수 있음을 의미하고, 데이터의 일관성을 유지할 수 없는 오류가 발생할 수 있습니다. 따라서 mappedBy 속성을 사용하여 양방향 관계에서 소유자와 대상 엔티티를 명확하게 지정해 주어야 합니다.

ManyToOne의 경우

항상 Many쪽이 외래 키를 가진다!

즉, Many == 의존 관계의 주인, One == mappedBy를 붙인다.

다대일 관계 매핑 후 repository를 이용하여 데이터를 조회하면 어떤 쿼리가 생성되는지 확인해보세요.

  • findByIdForAuthor

image 11

  • findByIdForPerson
    • @ManyToOne과 다르게 fetch 설정을 해주지 않아도 join이 포함되는 이유를 고민해보세요.
    • @OneToOne 관계의 기본 fetch 전략: FetchType.EAGER

image 12

  • @OneToOne(mappedBy = "person", fetch = FetchType.*LAZY*)
    • @OneToOne 관계에서 fetch = FetchType.LAZY로 설정했음에도 불구하고 left join이 발생하는 것은 Hibernate가 성능 최적화와 데이터 무결성을 유지하기 위한 전략 중 하나입니다. 이를 통해 프록시 객체 사용 시 발생할 수 있는 비효율성이나 N+1 문제를 미리 방지하고, 관계된 엔티티의 즉각적인 접근을 가능하게 합니다. 이런 이유로 LAZY 설정에도 불구하고 left join을 사용하는 것이 JPA 구현체의 기본 동작일 수 있습니다.

6. 다대다

다대다 연관관계는 컬렉션 값 속성으로 표현됩니다.
다대다 관계 매핑을 구현할 때 주의해야 할 점은 이런 관계가 데이터베이스 스키마에서 직접적으로 표현되기 어렵다는 것입니다.
대부분의 관계형 데이터베이스는 다대다 관계를 직접 지원하지 않으므로,
이를 구현하기 위해서는 보통 ‘조인 테이블’ 또는 ‘연결 테이블‘을 사용합니다.
이 테이블은 두 엔티티 간의 관계를 연결하는 데 사용되며, 각 엔티티의 키를 외래 키로 포함합니다.

@Entity
class Book {
    @Id @GeneratedValue
    Long id;

    @ManyToMany
    Set<Author> authors;

    // ...
}

양방향 연관관계인 경우, mappedBy를 지정하여 연관관계의 주인이 아님을 나타내야 합니다.
이는 Book 측에서 설정된 속성이 연관관계에서 이미 정의된 매핑을 따른다는 것을 명시하는 것입니다.

@Entity
class Author {
    // ...

    @ManyToMany(mappedBy="authors")
    Set<Book> books;

    // ...
}

초기에는 Author 클래스와 Book 사이의 연관 관계를 명료하게 설정할 수 있지만,
추가 정보가 필요해 지는 경우 연관 관계 테이블에 추가 열이 필요해집니다.
추가 정보는 연관 테이블에 속해야 하는데, 이러한 속성들은 Book 속성이나 Author 속성으로 쉽게 저장할 수 없습니다.
따라서 이 경우 연관 테이블에 대한 새로운 엔티티 클래스를 도입하는 것이 바람직합니다.
예를 들어 ‘BookAuthor’,에 저장되며, 이는 작가와 책 사이의 @OneToMany@ManyToOne 연관관계로 매핑됩니다.
이 접근법은 다대다 연관관계를 중간 엔티티를 사용하여 표현함으로써 추가 정보를 쉽게 관리할 수 있게 해줍니다.

@Entity
class BookAuthor {
    @Id @GeneratedValue
    Long id;

    @ManyToOne
    Book book;

    @ManyToOne
    Author author;

    // ...
}

@Entity
class Book {
    // ...

    @OneToMany(mappedBy="book")
    Set<BookAuthor> authors;

    // ...
}

@Entity
class Author {
    // ...

    @OneToMany(mappedBy="author")
    Set<BookAuthor> books;

    // ...
}

각 엔티티 - @OneToMany

중간 엔티티 - @ManyToOne

image 13

7. 참고자료