Spring Data JPA - 연관 관계

포테이토웅·2023년 3월 6일
0

Spring JPA

목록 보기
6/8
post-thumbnail

1. 연관 관계 매핑 시 고려사항

(1) 다중성

  • 다대일(N : 1)[ManyToOne]
  • 일대다(1 : N)[OneToMany]
  • 일대일(1 : 1)[OneToOne]
  • 다대다(N : N)[ManyToMany]

연관 관계가 있는 두 엔티티가 어떤 관계인지 파악해야 한다. 보통 다대일, 일대다 관계를 많이 사용하고, 다대다 관계는 거의 사용하지 않는다.

(2) 방향

테이블은 외래키를 사용해서 양방향 참조가 가능하지만, 객체는 참조를 통해서만 다른 객체를 조회할 수 있다. 두 객체 사이에 하나의 객체만 참조용 필드를 갖고 참조하면 단방향 관계, 두 객체 모두가 각각 참조용 필드를 갖고 참조하면 양방향 관계라고 한다. 기본적으로 단방향 매핑으로 하고 나중에 양방향으로 객체 탐색이 필요하다고 느낄 때 추가하는 느낌으로 하면 된다.

(3) 연관 관계의 주인

양방향 관계의 경우 연관 관계의 주인을 설정해야 한다. JPA는 연관 관계의 두 객체 중 하나를 정해 외래키를 관리하는데, 외래키의 주인이 아닌 객체는 조회만 가능하다. 외래키를 가진 테이블과 매핑한 엔티티가 외래키를 관리하는게 효율적이기 때문에 연관 관계의 주인으로 설정한다. 주인이 아닌 엔티티 객체는 mappedBy를 통해 주인 필드의 이름을 value로 입력해준다. 외래 키가 있는 곳을 연관 관계의 주인으로 정하면 된다. 무조건!!

2. 다대일(N : 1)

게시글(Post)과 댓글(Comment)의 관계를 예를 들어보자.

  • 하나의 게시글(1)에는 여러 개의 댓글(N)을 작성할 수 있다.
  • 하나의 댓글은 하나의 게시판에만 작성할 수 있다.
  • 댓글과 게시글은 다대일 관계를 가진다.

일반적인 형태에 의해 외래키는 댓글(N)이 관리한다.(데이터베이스는 무조건 다(N)쪽이 외래키를 가진다고 한다.)

(1) 다대일 단방향

@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가 불가능하다.

(2) 다대일 양방향

다대일 양방향으로 만드려먼 일(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()는 연관관계 편의 메소드이다. 양방향 관계에서는 한 쪽을 선택하여 잗성하면 된다.


3. 일대다(1 : N)

일대다는 연관관계의 주인을 일(1) 쪽에 둔 것이다. 실무에서는 거의 쓰지 않는다.

(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<>();
}

양방향이 아니기 때문에 @OneToManymappedBy가 사라진다. 대신 @JoinColumn을 이용해서 조인을 한다. 일대다 단방향 연관 관계 매핑이 필요한 경우는 그냥 다대일 양방향 연관 관계를 매핑하는게 더 좋다.

(2) 일대다 양방향

일대다 양방향은 공식적으로 존재하는 것이 아니므로 생략하도록 한다. @JoinColumn(insertable = false, updatable = false) 로 사용할 수 있지만, 위에서도 말했듯이 다대일 양방향을 사용하는 것이 좋다.


4. 일대일(1 : 1)

일대일 관계에서는 반대도 일대일 관계가 된다. 일대일 관계에서는 주 테이블이나 대상 테이블에 외래 키를 둘 수 있어서 개발 시 어느 쪽에 둘지를 선택해야 한다.

(1) 주 테이블에 외래 키가 있는 경우

주 테이블에 외래 키가 있으면 주 객체에도 객체 참조를 두는 구조로 매핑을 하게 된다.

  • 주 테이블 : 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 테이블이 외래 키를 가지고 있음으로 BookBookReviewInfo를 연관관계의 주인으로 설정한다.

@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은 지연 로딩이 되지 않는다. 이는 프록시의 한계로 인해서 외래 키를 직접 관리하지 않는 일대일 관계에서는 지연 로딩으로 설정을 해도 즉시 로딩이 되는 점이다.

(2) 대상 테이블에 외래 키가 있는 경우

  • 주 테이블 : Book
  • 대상 테이블 : BookReviewInfo - 외래 키(book_id)가 있는 경우

일대일 단방향

외래 키는 BookReviewInfo 테이블에 있고 아래와 같은 일대일 연관관계는 JPA에서 지원하지 않아 매핑할 수 없다.

일대일 양방향

대상 테이블인 BookReviewInfo에 외래 키를 두고 싶으면 BookReviewInfo 엔티티에 @OneToOne 어노테이션을 설정하고, Book 엔티티에서는 @OneToOne 어노테이션과 mappedBy 속성으로 외래 키를 소유하고 있는 BookReviewInfoBook을 연관관계의 주인으로 지정한다.

@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;
    
    ...
}

5. 다대다 (N:N)

우선, 실무에서 거의 사용하면 안된다고 한다. 중간 테이블이 숨겨져 있기 때문에 자기도 모르는 복잡한 조인의 쿼리가 발생하는 경우가 생길 수 있고, 이렇게 자동 생성된 중간 테이블은 두 객체의 테이블의 외래 키만 저장되기 때문에 문제가 될 확률이 높다. 보통 이러한 중간 테이블에 외래 키 외에 다른 정보가 들어가는 경우가 많기 때문에 다대다를 일대다, 다대일로 풀어서 만드는 것이 좋다.

(1) @ManyToMany

@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의 경우 이 테이블을 활용할 수 없다. 이번에는 다대다 관계를 일대다, 다대일 관계로 풀어나가는 경우는 보자.

(2) 일대다, 다대일

@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의 문제점을 해결할 수 있고, 우리가 원하는 컬럼을 생성할 수 있다.


6. 어노테이션 정리

@OneToMany

일대다 관계를 매핑하는데 사용한다.

속성

  • targetEntity : 관계를 맺을 엔티티 클래스를 정의한다.
  • cascade : 현 엔티티의 변경에 대해 관계를 맺은 엔티티도 변경 전략을 결정한다.
  • fetch : 관계 엔티티의 데이터 읽기 전력을 결졍한다. FetchType.EAGER, FetchType.LAZY로 전략을 변경할 수 있다.
  • mappedBy : 양방향 관계 설정 시 적용하여 연관 관계의 주인이 아님을 표시한다.
  • orphanRemoval : 관계 엔티티에서 변경이 일어난 경우 데이터베이스 변경을 같이 할지 결정한다. cascade는 JPA 레이어 수준이고 이것은 데이터베이스 레이어에서 처리한다.

@ManyToOne

다대일 관계를 매핑하는데 사용한다.

속성

  • optional : false로 설정하면 해당 객체에 null이 들어갈 수 있다. 반대로 반드시 값이 필요하다면 true가 들어간다.
  • targetEntity
  • cascade
  • fetch

@OneToOne

일대일 관계를 매핑하는데 사용한다.

속성

  • targetEntity
  • cascade
  • fetch
  • optional
  • mappedBy
  • orphanRemoval

@ManyToMany

다대다 관계를 매핑하는데 사용한다.

속성

  • targetEntity
  • cascade
  • fetch
  • mappedBy

@JoinColumn

외래 키를 매핑할 때 사용한다.

속성

  • name : 매핑할 외래 키의 컬럼명을 입력한다.
  • referencedColumnName : 외래 키가 참조하는 대상 테이블의 컬럼명을 입력한다.
  • foreignKey : 외래 키 제약조건을 직접 지정할 때 사용하며, 테이블을 생성할 때만 사용한다.
  • unique, nullable, insertable, updatable, columnDefinition, table : @Column의 속성과 동일하다.

@Builder.Default

빌더 패턴을 통해 인스턴스를 만들 때 특정 필드를 특정 값으로 초기화하고 싶을 때 쓴다.

public class t{
	...
	@Builder.Default
	privare List<Test> tests = new ArrayList<>();
}

@ToString.Exclude

양방향 매핑 시 연관관계 속성이 순환참조하여 출력되어 stack overflow를 일으킬 때 사용한다. 해당 필드를 출력하지 않는다.

@ToString(callSuper = true)

부모 클래스의 필드 값들로 출력한다. (이 글에서는 Auditing 때문에 사용했다.)


참고자료

profile
주경야독

0개의 댓글