- 다대일(N : 1)[ManyToOne]
- 일대다(1 : N)[OneToMany]
- 일대일(1 : 1)[OneToOne]
- 다대다(N : N)[ManyToMany]
연관 관계가 있는 두 엔티티가 어떤 관계인지 파악해야 한다. 보통 다대일, 일대다 관계를 많이 사용하고, 다대다 관계는 거의 사용하지 않는다.
테이블은 외래키를 사용해서 양방향 참조가 가능하지만, 객체는 참조를 통해서만 다른 객체를 조회할 수 있다. 두 객체 사이에 하나의 객체만 참조용 필드를 갖고 참조하면 단방향 관계, 두 객체 모두가 각각 참조용 필드를 갖고 참조하면 양방향 관계라고 한다. 기본적으로 단방향 매핑으로 하고 나중에 양방향으로 객체 탐색이 필요하다고 느낄 때 추가하는 느낌으로 하면 된다.
양방향 관계의 경우 연관 관계의 주인을 설정해야 한다. JPA는 연관 관계의 두 객체 중 하나를 정해 외래키를 관리하는데, 외래키의 주인이 아닌 객체는 조회만 가능하다. 외래키를 가진 테이블과 매핑한 엔티티가 외래키를 관리하는게 효율적이기 때문에 연관 관계의 주인으로 설정한다. 주인이 아닌 엔티티 객체는
mappedBy
를 통해 주인 필드의 이름을value
로 입력해준다. 외래 키가 있는 곳을 연관 관계의 주인으로 정하면 된다. 무조건!!
게시글(Post)과 댓글(Comment)의 관계를 예를 들어보자.
- 하나의 게시글(1)에는 여러 개의 댓글(N)을 작성할 수 있다.
- 하나의 댓글은 하나의 게시판에만 작성할 수 있다.
- 댓글과 게시글은 다대일 관계를 가진다.
일반적인 형태에 의해 외래키는 댓글(N)이 관리한다.(데이터베이스는 무조건 다(N)쪽이 외래키를 가진다고 한다.)
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "POST_ID")
private Long id;
@Column(name = "POST_TITLE")
private String title;
}
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "COMMENT_ID")
private Long id;
private String content;
@ManyToOne
@JoinColumn(name = "POST_ID")
private Post post;
}
다대일 단방향에서는 다 쪽인 Comment에서
@ManyToOne
을 추가해준다. 단방향이기 때문에 반대쪽 Post에서는 참조하지 않는다.
void test() {
Post post = Post.builder()
.id(1L)
.title("post")
.build();
Comment comment = Comment.builder()
.id(1L)
.post(post)
.content("content")
.build();
postRepository.save(post);
commentRepository.save(comment);
Comment savedComment = commentRepository.getReferenceById(1L);
Post savedPost = postRepository.getReferenceById(1L);
log.info("savedComment : {}", savedComment);
// savedComment : Comment(id=1, content=content1, post=Post(id=1, title=post))
log.info("savedPost : {}", savedPost);
// savedPost : Post(id=1, title=post)
}
Hibernate:
insert
into
post
(post_id, post_title)
values
(default, ?)
Hibernate:
insert
into
comment
(comment_id, content, post_id)
values
(default, ?, ?)
다 쪽인 Comment에서는
getPost
를 통해 post를 참조할 수 있지만, 일 쪽인 Post에서는getComment
가 불가능하다.
다대일 양방향으로 만드려먼 일(1)쪽에
@OneToMany
를 추가하고 양방향 매핑을 사용했으니 연관 관계의 주인을mappedBy
로 지정해준다.
@Entity
public class Post {
...
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@ToString.Exclude
@Builder.Default
private List<Comment> comments = new ArrayList<>();
public void addComment(Comment comment) {
comment.setPost(comment.getPost());
comments.add(comment);
}
}
@Entity
public class Comment {
...
@ManyToOne()
@JoinColumn(name = "POST_ID")
private Post post;
}
@Test
@Transactional
void test() {
Post post = Post.builder()
.title("Test")
.build();
Comment comment = Comment.builder()
.content("test")
.post(post)
.build();
commentRepository.save(comment);
post.addComment(comment);
postRepository.save(post);
Post selectedPost = postRepository.findByTitle("Test");
System.out.println("selectedPost : " + selectedPost);
System.out.println("Comments : " + selectedPost.getComments());
List<Comment> comments = commentRepository.findAll();
System.out.println("Comments : " + comments);
System.out.println("Post : " + comments.get(0).getPost());
}
Post
객체에서도Comment
를 조회할 수 있고,Comment
객체에서도Post
를 조회할 수 있다.
Post
엔티티의addComment()
는 연관관계 편의 메소드이다. 양방향 관계에서는 한 쪽을 선택하여 잗성하면 된다.
일대다는 연관관계의 주인을 일(1) 쪽에 둔 것이다. 실무에서는 거의 쓰지 않는다.
@Entity
public class User extends BaseEntity {
...
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id", insertable = false, updatable = false)
@Builder.Default
private List<UserHistory> userHistories = new ArrayList<>();
}
양방향이 아니기 때문에
@OneToMany
에mappedBy
가 사라진다. 대신@JoinColumn
을 이용해서 조인을 한다. 일대다 단방향 연관 관계 매핑이 필요한 경우는 그냥 다대일 양방향 연관 관계를 매핑하는게 더 좋다.
일대다 양방향은 공식적으로 존재하는 것이 아니므로 생략하도록 한다.
@JoinColumn(insertable = false, updatable = false)
로 사용할 수 있지만, 위에서도 말했듯이 다대일 양방향을 사용하는 것이 좋다.
일대일 관계에서는 반대도 일대일 관계가 된다. 일대일 관계에서는 주 테이블이나 대상 테이블에 외래 키를 둘 수 있어서 개발 시 어느 쪽에 둘지를 선택해야 한다.
주 테이블에 외래 키가 있으면 주 객체에도 객체 참조를 두는 구조로 매핑을 하게 된다.
- 주 테이블 :
Book
- 외래 키가 (book_review_info_id)가 있는 경우- 대상 테이블 :
BookReviewInfo
주 객체인
Book
엔티티에@OneToOne
선언 이후 대상 테이블인BookReviewInfo
객체를 선언한다.Book
객체를 통해 책의 리뷰 정보를 조회할 수 있는 구조이다.
@Entity
public class Book extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String category;
private Long authorId;
private Long publisherId;
@OneToOne
@JoinColumn(name = "id")
private BookReviewInfo bookReviewInfo;
...
}
@Entity
public class BookReviewInfo extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private float averageReviewScore;
private int reviewCount;
...
}
두 객체를 가지고 저장하고 조회하는 테스트를 작성해본다.
@Test
void 주테이블_외래키_일대일_단방향() {
BookReviewInfo bookReviewInfo = BookReviewInfo.builder()
.averageReviewScore(3.8f)
.reviewCount(5)
.build();
bookReviewInfoRepository.save(bookReviewInfo);
Book book = Book.builder()
.name("JPA One to One")
.category("JPA")
.authorId(1L)
.publisherId(5L)
.bookReviewInfo(bookReviewInfo) // 연관관계를 맺는다.
.build();
bookRepository.save(book);
Book resultBook = bookRepository.findById(1L).orElseThrow(RuntimeException::new);
assertThat(resultBook.getName()).isEqualTo("JPA One to One");
assertThat(resultBook.getBookReviewInfo().getAverageReviewScore()).isEqualTo(3.8F);
}
Hibernate:
select
b1_0.id,
b1_0.author_id,
b2_0.id,
b2_0.average_review_score,
b2_0.created_at,
b2_0.review_count,
b2_0.updated_at,
b1_0.category,
b1_0.created_at,
b1_0.name,
b1_0.publisher_id,
b1_0.updated_at
from
book b1_0
left join
book_review_info b2_0
on b2_0.id=b1_0.id
where
b1_0.id=?
Book
객체에BookReviewInfo
객체를 주입함으로써 연관관계가 맺어진다. 쿼리를 보면left join
을 통해 조회를 하므로,Book
객체만 조회를 해도getBookReviewInfo()
를 통해BookReviewInfo
객체의 데이터를 사용할 수 있다.
BookReviewInfo
객체에도Book
객체를 가지도록 한다.BookReviewInfo
엔티티에 추가로@OneToOne
어노테이션을 선언한다. 그리고 양방향이므로mappedBy
속성으로 연관 관계의 주인을 지정해준다. 일반적인 룰에 따라Book
테이블이 외래 키를 가지고 있음으로Book
의BookReviewInfo
를 연관관계의 주인으로 설정한다.
@Entity
public class Book extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String category;
private Long authorId;
private Long publisherId;
@OneToOne
@JoinColumn(name = "id")
@ToString.Exclude
private BookReviewInfo bookReviewInfo;
...
}
@Entity
public class BookReviewInfo extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(mappedBy = "bookReviewInfo")
private Book book;
private float averageReviewScore;
private int reviewCount;
...
}
object references an unsaved transient instance 오류가 발생하면 부모 객체에 선언한 자식 객체에
cascade = CascadeType.ALL
을 붙여준다. 위 오류는 주로 부모 객체에서 자식 겍체를 한 번에 저장하려고 할 때 발생하는 것으로, 자식 객체가 아직 데이터베이스에 저장되지 않았기 때문이다. 이를 통해 영속성 전이가 발생해 부모 객체를 저장할 때 자식 객체도 함께 저장할 수 있다.
@ToString.Exclude
를 붙여주면 순환참조에 의한 stack overflow 오류를 피할 수 있다.
@Test
void 주테이블_외래키_일대일_양방향() {
BookReviewInfo bookReviewInfo = BookReviewInfo.builder()
.averageReviewScore(3.8f)
.reviewCount(5)
.build();
Book book = Book.builder()
.name("JPA One to One")
.category("JPA")
.authorId(1L)
.publisherId(5L)
.build();
Book savedBook = bookRepository.save(book);
bookReviewInfo.setBook(savedBook); // 연관관계를 맺는다.
bookReviewInfoRepository.save(bookReviewInfo);
BookReviewInfo resultBookReviewInfo = bookRepository.findById(1L).orElseThrow(RuntimeException::new)
.getBookReviewInfo();
Book resultBook = bookReviewInfoRepository.findById(1L).orElseThrow(RuntimeException::new).getBook();
assertThat(resultBookReviewInfo.getReviewCount()).isEqualTo(5);
assertThat(resultBook.getName()).isEqualTo("JPA One to One");
}
양방향 관계이므로
Book
을 조회해BookReviewInfo
를 사용할 수 있고,BookReviewInfo
를 조회해Book
을 사용할 수 있다.
일대일 관계에서 지연 로딩으로 설정해도 즉시 로딩이 되는 경우가 있다.
Book.bookReviewInfo
는 지연 로딩이 되지만,BookReviewInfo.book
은 지연 로딩이 되지 않는다. 이는 프록시의 한계로 인해서 외래 키를 직접 관리하지 않는 일대일 관계에서는 지연 로딩으로 설정을 해도 즉시 로딩이 되는 점이다.
- 주 테이블 :
Book
- 대상 테이블 :
BookReviewInfo
- 외래 키(book_id)가 있는 경우
외래 키는
BookReviewInfo
테이블에 있고 아래와 같은 일대일 연관관계는 JPA에서 지원하지 않아 매핑할 수 없다.
대상 테이블인
BookReviewInfo
에 외래 키를 두고 싶으면BookReviewInfo
엔티티에@OneToOne
어노테이션을 설정하고,Book
엔티티에서는@OneToOne
어노테이션과mappedBy
속성으로 외래 키를 소유하고 있는BookReviewInfo
의Book
을 연관관계의 주인으로 지정한다.
@Entity
public class Book extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String category;
private Long authorId;
private Long publisherId;
@OneToOne(mappedBy = "book")
@ToString.Exclude
private BookReviewInfo bookReviewInfo;
...
}
@Entity
public class BookReviewInfo extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne
@JoinColumn(name = "id")
private Book book;
private float averageReviewScore;
private int reviewCount;
...
}
우선, 실무에서 거의 사용하면 안된다고 한다. 중간 테이블이 숨겨져 있기 때문에 자기도 모르는 복잡한 조인의 쿼리가 발생하는 경우가 생길 수 있고, 이렇게 자동 생성된 중간 테이블은 두 객체의 테이블의 외래 키만 저장되기 때문에 문제가 될 확률이 높다. 보통 이러한 중간 테이블에 외래 키 외에 다른 정보가 들어가는 경우가 많기 때문에 다대다를 일대다, 다대일로 풀어서 만드는 것이 좋다.
@Entity
public class Book extends BaseEntity {
...
@ManyToMany
@Builder.Default
@ToString.Exclude
private List<BookAndAuthor> bookAndAuthors = new ArrayList<>();
public void addAuthor(Author... authors) {
Collections.addAll(this.authors, authors);
}
}
@Entity
public class Author extends BaseEntity {
...
@ManyToMany
@Builder.Default
@ToString.Exclude
private List<Book> books = new ArrayList<>();
public void addBook(Book... books) {
Collections.addAll(this.books, books);
}
}
@Test
@Transactional
void manyToManyTest() {
Book book1 = givenBook("책1");
Book book2 = givenBook("책2");
Book book3 = givenBook("개발책1");
Book book4 = givenBook("개발책2");
Author author1 = givenAuthor("martin");
Author author2 = givenAuthor("steve");
BookAndAuthor bookAndAuthor1 = givenBookAndAuthor(book1, author1);
BookAndAuthor bookAndAuthor2 = givenBookAndAuthor(book2, author2);
BookAndAuthor bookAndAuthor3 = givenBookAndAuthor(book3, author1);
BookAndAuthor bookAndAuthor4 = givenBookAndAuthor(book3, author2);
BookAndAuthor bookAndAuthor5 = givenBookAndAuthor(book4, author1);
BookAndAuthor bookAndAuthor6 = givenBookAndAuthor(book4, author2);
book1.addAuthor(author1);
book2.addAuthor(author2);
book3.addAuthor(author1, author2);
book4.addAuthor(author1, author2);
author1.addBook(book1, book3, book4);
author2.addBook(book2, book3, book4);
bookRepository.saveAll(Lists.newArrayList(book1, book2, book3, book4));
authorRepository.saveAll(Lists.newArrayList(author1, author2));
bookRepository.findAll().get(2).getAuthors().forEach(System.out::println);
authorRepository.findAll().get(0).getBooks().forEach(System.out::println);
}
Hibernate:
create table author_books (
author_id bigint not null,
books_id bigint not null
)
쿼리를 보면 자동으로 생성된 테이블을 확인할 수 있다. 실제로 이러한 중간 테이블에 정보를 담는 경우가 많은데
@ManyToMany
의 경우 이 테이블을 활용할 수 없다. 이번에는 다대다 관계를 일대다, 다대일 관계로 풀어나가는 경우는 보자.
@Entity
public class Book extends BaseEntity {
...
@OneToMany
@JoinColumn(name = "book_id")
@Builder.Default
@ToString.Exclude
private List<BookAndAuthor> bookAndAuthors = new ArrayList<>();
public void addBookAndAuthors(BookAndAuthor... bookAndAuthors) {
Collections.addAll(this.bookAndAuthors, bookAndAuthors);
}
}
@Entity
public class Author extends BaseEntity {
...
@OneToMany
@JoinColumn(name = "author_id")
@Builder.Default
@ToString.Exclude
private List<BookAndAuthor> bookAndAuthors = new ArrayList<>();
public void addBookAndAuthors(BookAndAuthor... bookAndAuthors) {
Collections.addAll(this.bookAndAuthors, bookAndAuthors);
}
}
@entity
public class BookAndAuthor extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private Book book;
@ManyToOne
private Author author;
}
@Test
@Transactional
void manyToManyTest() {
Book book1 = givenBook("책1");
Book book2 = givenBook("책2");
Book book3 = givenBook("개발책1");
Book book4 = givenBook("개발책2");
Author author1 = givenAuthor("martin");
Author author2 = givenAuthor("steve");
BookAndAuthor bookAndAuthor1 = givenBookAndAuthor(book1, author1);
BookAndAuthor bookAndAuthor2 = givenBookAndAuthor(book2, author2);
BookAndAuthor bookAndAuthor3 = givenBookAndAuthor(book3, author1);
BookAndAuthor bookAndAuthor4 = givenBookAndAuthor(book3, author2);
BookAndAuthor bookAndAuthor5 = givenBookAndAuthor(book4, author1);
BookAndAuthor bookAndAuthor6 = givenBookAndAuthor(book4, author2);
book1.addBookAndAuthors(bookAndAuthor1);
book2.addBookAndAuthors(bookAndAuthor2);
book3.addBookAndAuthors(bookAndAuthor3, bookAndAuthor4);
book4.addBookAndAuthors(bookAndAuthor5, bookAndAuthor6);
author1.addBookAndAuthors(bookAndAuthor1, bookAndAuthor3, bookAndAuthor5);
author2.addBookAndAuthors(bookAndAuthor2, bookAndAuthor4, bookAndAuthor6);
bookRepository.saveAll(Lists.newArrayList(book1, book2, book3, book4));
authorRepository.saveAll(Lists.newArrayList(author1, author2));
bookRepository.findAll().get(2).getBookAndAuthors().forEach(o -> System.out.println(o.getAuthor()));
authorRepository.findAll().get(0).getBookAndAuthors().forEach(o -> System.out.println(o.getBook()));
결과를 비교하면
@ManyToMany
와일대다 + 다대다
는 같다. 하지만 중간 테이블을 직접 만들어줌으로써@ManyToMany
의 문제점을 해결할 수 있고, 우리가 원하는 컬럼을 생성할 수 있다.
일대다 관계를 매핑하는데 사용한다.
targetEntity
: 관계를 맺을 엔티티 클래스를 정의한다.cascade
: 현 엔티티의 변경에 대해 관계를 맺은 엔티티도 변경 전략을 결정한다.fetch
: 관계 엔티티의 데이터 읽기 전력을 결졍한다.FetchType.EAGER
,FetchType.LAZY
로 전략을 변경할 수 있다.mappedBy
: 양방향 관계 설정 시 적용하여 연관 관계의 주인이 아님을 표시한다.orphanRemoval
: 관계 엔티티에서 변경이 일어난 경우 데이터베이스 변경을 같이 할지 결정한다.cascade
는 JPA 레이어 수준이고 이것은 데이터베이스 레이어에서 처리한다.
다대일 관계를 매핑하는데 사용한다.
optional
: false로 설정하면 해당 객체에 null이 들어갈 수 있다. 반대로 반드시 값이 필요하다면 true가 들어간다.targetEntity
cascade
fetch
일대일 관계를 매핑하는데 사용한다.
targetEntity
cascade
fetch
optional
mappedBy
orphanRemoval
다대다 관계를 매핑하는데 사용한다.
targetEntity
cascade
fetch
mappedBy
외래 키를 매핑할 때 사용한다.
name
: 매핑할 외래 키의 컬럼명을 입력한다.referencedColumnName
: 외래 키가 참조하는 대상 테이블의 컬럼명을 입력한다.foreignKey
: 외래 키 제약조건을 직접 지정할 때 사용하며, 테이블을 생성할 때만 사용한다.unique
,nullable
,insertable
,updatable
,columnDefinition
,table
:@Column
의 속성과 동일하다.
빌더 패턴을 통해 인스턴스를 만들 때 특정 필드를 특정 값으로 초기화하고 싶을 때 쓴다.
public class t{ ... @Builder.Default privare List<Test> tests = new ArrayList<>(); }
양방향 매핑 시 연관관계 속성이 순환참조하여 출력되어 stack overflow를 일으킬 때 사용한다. 해당 필드를 출력하지 않는다.
부모 클래스의 필드 값들로 출력한다. (이 글에서는 Auditing 때문에 사용했다.)