-
JPA 연관관계 살펴보기 (1:1, 1:N, N:1)백엔드 2023. 8. 21. 23:18728x90반응형SMALL
현재까지 Entity 객체 설정 방법과 Entity에서 원하는 컬럼의 값을 조회해보거나 새로운 값을 넣는 방법에 대해 살펴보았습니다.
이번 포스팅에서는 연관관계에 있는 엔티티들을 어떻게 매핑시키는지에 대해 알아보도록 하겠습니다.
ERD
ERD(Entity Relation Diagram)는 다음과 같습니다.
ERD user - user_history : 1:N 관계
user - review : 1:N 관계
book - book_review_info : 1:1 관계
book - review : 1:N 관계
book - author : N:N 관계
위와 같이 4개의 연관 관계를 갖는 Entity가 존재합니다. 위 관계들을 실제 Entity에 설정해보도록 하겠습니다.
1:1 연관 관계
book과 book_review_info Entity가 1:1 관계를 맺고 있습니다. 1개의 book 엔티티 당 1 개의 book_review_info 엔티티가 매핑되는 것을 알 수 있습니다. book이 새롭게 추가되거나 평
book_review_info
@Entity @NoArgsConstructor @Data public class BookReviewInfo extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToOne(optional = false) private Book book; private float averageReviewScore; private int reviewCount; }
book_review_info와 매핑되는 book 엔티티를 그대로 필드에 선언해줍니다. 이 때, 어노테이션으로 @OneToOne을 선언해주며 optional을 false로 해줍니다(optional의 default는 true이지만 book_review_info와 매핑된 book은 반드시 존재하기 때문에 false로 설정해줍니다. optional=false일 경우 실제 book_review_info에 select 쿼리를 보낼 경우 inner join으로 생성됩니다).
이제 book_review_info 엔티티에서 참조하고 있는 book Entity의 값을 알아낼 수 있습니다.
book
(author_id 컬럼은 추후에 계속 추가하겠습니다.)
@Entity @NoArgsConstructor @Data public class Book extends BaseEntity{ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToOne private BookReviewInfo bookReviewInfo; }
book에서 book_review_info Entity에 대한 정보를 알고 싶다면 위와 같이 @OneToOne 어노테이션을 선언한 BookReviewInfo를 추가해주면 됩니다.
위와 같이 @OneToOne 어노테이션으로 연결할 Entity를 필드에 선언해주면 실제 table이 만들어지는 과정에 매핑된 각 Entity의 FK를 저장하는 컬럼이 추가되는 것을 확인할 수 있습니다.
또한, select 쿼리를 보낼 경우 left outer join을 2번해서 조회를 하는 것을 확인할 수 있습니다. 이를 막기 위해 mappedBy 속성을 추가해줍니다.
mappedBy
public class Book extends BaseEntity{ ... @OneToOne(mappedBy = "book") @ToString.Exclude private BookReviewInfo bookReviewInfo; }
mappedBy를 선언할 경우 해당 연관키를 Table에서 더 이상 가지지 않게 됩니다. 그러므로, Book Table에는 BookReviewInfo의 id가 존재하지 않게 되었습니다. (이 때, toString 메서드에 의해 순환참조가 발생할 수 있으므로 제외시켜주어야 합니다.)
DB의 경우 외래키만으로 연관관계를 나타냅니다. 하지만, 두 엔티티의 연관관계를 나타낼 때 위와 같이 필드로 선언해서 연관관계를 설명하기 때문에, 각 엔티티의 두 필드 변수가 연관관계가 나타내게 됩니다. 이는 두 변수를 통해 서로에게 접근이 가능하다는 뜻입니다. 그러므로 두 Entity 중 이 연관 관계의 소유자를 선언해줌으로써 한 쪽에서만 수정이 가능하도록 하는 것이 좋습니다.
관계의 소유자의 경우 FK를 갖는 쪽으로 설정하게 됩니다. 위와 같은 경우도 Book의 id를 BookReviewInfo에서 외래키로 갖기 때문에 BookReviewInfo가 이 관계의 주인이 되고, Book에서는 이를 조회만 할 수 있게 됩니다. 그러므로 Book의 연관 관계를 나타내는 BookReviewInfo 필드에 mappedBy 속성을 선언해줌으로써 연관된 필드 변수는 무엇인지 설정해주었습니다.
(averageReviewScore과 reviewCount의 타입을 primitive 타입으로 선언해준 이유는 각 컬럼 값은 Null이 포함되면 안되기 때문입니다.)
예제 )
@Test public void test(){ Book book = new Book(); book.setName("book1"); book.setCategory("category1"); Book result = bookRepository.save(book); BookReviewInfo br = new BookReviewInfo(); br.setCount(0); br.setAverage(0); br.setBook(result); bookReviewInfoRepository.save(br); System.out.println(bookRepository.findById(1L).orElseThrow(RuntimeException::new).getBookReviewInfo()); System.out.println(bookReviewInfoRepository.findById(1L).orElseThrow(RuntimeException::new).getBook()); }
// 출력 Hibernate: select book0_.id as id1_0_0_, book0_.category as category2_0_0_, book0_.name as name3_0_0_, bookreview1_.id as id1_1_1_, bookreview1_.average as average2_1_1_, bookreview1_.book_id as book_id4_1_1_, bookreview1_.count as count3_1_1_ from book book0_ left outer join book_review_info bookreview1_ on book0_.id=bookreview1_.book_id where book0_.id=? BookReviewInfo(id=1, book=Book(id=1, name=book1, category=category1), average=0.0, count=0) Hibernate: select bookreview0_.id as id1_1_0_, bookreview0_.average as average2_1_0_, bookreview0_.book_id as book_id4_1_0_, bookreview0_.count as count3_1_0_, book1_.id as id1_0_1_, book1_.category as category2_0_1_, book1_.name as name3_0_1_ from book_review_info bookreview0_ inner join book book1_ on bookreview0_.book_id=book1_.id where bookreview0_.id=? Book(id=1, name=book1, category=category1)
BookRepository를 통해 얻은 Book Entity에서 BookReviewInfo를 조회할 경우 book 컬럼이 존재하는 것을 확인할 수 있습니다.
BookReviewInfo를 조회할 경우 BookReviewInfo와 관련된 컬럼이 존재하지 않는 것을 확인할 수 있습니다.(mappedBy 선언 때문)
1:1 관계서부터 어떤 속성을 추가해주는지에 따라 조회 쿼리가 달라지게 되었습니다.
여러 속성을 추가 및 제거 해보면서 어떤 식으로 쿼리가 실행되는지 확인해보는 것이 좋을 것 같습니다.
1:N
User, UserHistory는 1:N 관계에 있습니다. UserHistory는 User에서 변경 사항이 생길 때마다 그 변경사항을 저장하는 Entity입니다.
1:N관계에서 Entity를 설정해보도록 하겠습니다.
User Entity
@Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NonNull private String name; @NonNull private String email; @OneToMany(fetch = FetchType.EAGER) private List<UserHistory> userHistories = new ArrayList<>(); }
UserHistory Entity
@Entity public class UserHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "user_id") private Long userId; private String name; private String email; }
위와 같이 설정을 해줄 경우 다음과 같은 ddl이 생성됩니다.
create table user ( id bigint generated by default as identity, email varchar(255), name varchar(255), primary key (id) ) create table user_user_histories ( user_id bigint not null, user_histories_id bigint not null ) create table user_history ( id bigint generated by default as identity, email varchar(255), name varchar(255), user_id bigint, primary key (id) )
user_user_histories라는 새로운 매핑 테이블이 생성된 것을 확일 할 수 있습니다. 이는 join되는 컬럼이 어떤 컬럼인지 지정되지 않아 자동으로 생성이되는 경우입니다. 그러므로 @JoinColumn 어노테이션을 통해 어떤 컬럼으로 Join을 할지 지정을 해주어야 합니다.
@JoinColumn
public class User { ... @OneToMany(fetch = FetchType.EAGER) @JoinColumn(name = "user_id") private List<UserHistory> userHistories = new ArrayList<>(); }
userHistories 변수가 join될 Entity의 user_id와 매핑된다는 것을 지정해줍니다. 위의 UserHistory의 클래스의 userId 컬럼의 경우 미리 @Column 어노테이션을 통해 실제 db에 적용될 컬럼의 이름을 지정해주었습니다.🙃
User Entity가 UserHistory의 user_id 컬럼에 매핑된다는 것을 지정해주어 새로운 테이블이 생성되지 않고 지정된 컬럼으로 매핑된 것을 알 수 있습니다.
//ddl create table user ( id bigint generated by default as identity, email varchar(255), name varchar(255), primary key (id) ) create table user_history ( id bigint generated by default as identity, email varchar(255), name varchar(255), user_id bigint, primary key (id) )
예제 )
void givenUserHistory(User user){ UserHistory userHistory = new UserHistory(); userHistory.setUserId(user.getId()); userHistory.setName(user.getName()); userHistory.setEmail(user.getEmail()); userHistoryRepository.save(userHistory); } @Test void test(){ User user = new User(); user.setName("user1"); user.setEmail("user1@gmail.com"); givenUserHistory(userRepository.save(user)); user.setName("user2"); givenUserHistory(userRepository.save(user)); user.setEmail("user2@gmail.com"); givenUserHistory(userRepository.save(user)); userHistoryRepository.findAll().forEach(System.out::println); System.out.println(userRepository.findById(1L).orElseThrow(RuntimeException::new).getUserHistories()); }
// 출력 Hibernate: select userhistor0_.id as id1_3_, userhistor0_.email as email2_3_, userhistor0_.name as name3_3_, userhistor0_.user_id as user_id4_3_ from user_history userhistor0_ UserHistory(id=1, userId=1, name=user1, email=user1@gmail.com) UserHistory(id=2, userId=1, name=user2, email=user1@gmail.com) UserHistory(id=3, userId=1, name=user2, email=user2@gmail.com) Hibernate: select user0_.id as id1_2_0_, user0_.email as email2_2_0_, user0_.name as name3_2_0_, userhistor1_.user_id as user_id4_3_1_, userhistor1_.id as id1_3_1_, userhistor1_.id as id1_3_2_, userhistor1_.email as email2_3_2_, userhistor1_.name as name3_3_2_, userhistor1_.user_id as user_id4_3_2_ from user user0_ left outer join user_history userhistor1_ on user0_.id=userhistor1_.user_id where user0_.id=? [UserHistory(id=1, userId=1, name=user1, email=user1@gmail.com), UserHistory(id=2, userId=1, name=user2, email=user1@gmail.com), UserHistory(id=3, userId=1, name=user2, email=user2@gmail.com)]
User Entity에서도 UserHistory 정보를 조회해볼 수 있습니다.
N:1
위의 User와 UserHistory의 경우 참조되는 값을 User에서 갖고 있었습니다. 그런데 OneToMany 관계에서 참조하는 값은 One에서 가지지 않는 경우가 있습니다. 그럴 경우, One에 해당하는 Entity의 PK값을 Many쪽에서 FK로 가지고 있게 됩니다.
이를 위의 예시에 대입하면, UserHistory테이블에서 User id 값을 가지고 있어야합니다(UserHistory가 FK로 User의 PK값을 가지고 있어야 하기 때문에).
👉 @ManyToOne으로 구성해보도록 하겠습니다.
@ManyToOne
public class UserHistory { // userId 컬럼 제거! ... @ManyToOne private User user; }
userId 컬럼을 제거한 뒤 User 컬럼을 추가해줍니다. @ManyToOne 어노테이션을 선언해줍니다.
위의 테스트를 다시 동작하면 제거했던 userId가 Table Create할 때 생성이 되고, 잘 동작하는 것을 확인할 수 있습니다.
위와 같이 Jpa에서는 연관된 객체를 FK를 통해서가 아니라 단순히 getXxx() 메서드를 통해 얻어올 수 있습니다.
그러므로 어느 Entity에서 연관 Entity를 필요로하는지에 따라서 어떤 어노테이션(@OneToMany, @ManyToOne)을 선언할지 결정해주어야 합니다.
(위의 예시의 경우 User 객체를 통해 UserHistory를 조회하는 경우가 많이 생깁니다. 그러므로 @OneToMany를 통해 연관관계를 설정해주는 것이 더 좋을 것입니다.)
지금까지 알게 된 내용으로 Review Entity를 추가해보도록 하겠습니다.
Review
@Data @NoArgsConstructor @Entity public class Review { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String content; private float score; @ManyToOne private User user; @ManyToOne private Book book; }
User
public class User { ... @OneToMany @JoinColumn(name = "user_id") @ToString.Exclude private List<Review> reviews = new ArrayList<>(); }
Book
public class Book { ... @OneToMany @JoinColumn(name = "book_id") @ToString.Exclude private List<Review> reviews = new ArrayList<>(); }
Review와 User, Review와 Book이 모두 ManyToOne입니다. User, Book과 Review의 관계는 OneToMany입니다.
User와 Review, Book과 Review는 따로 매핑 테이블이 필요 없기 때문에, @JoinColumn을 통해 매핑되는 컬럼의 이름을 적어주었습니다.
그 결과, Book과 User 테이블에서는 Review에 대한 FK가 존재하지 않고, Review에서만 Book과 User에 대한 FK값이 생성된 것을 확인할 수 있습니다.
create table book ( id bigint generated by default as identity, category varchar(255), name varchar(255), primary key (id) ) create table user ( id bigint generated by default as identity, email varchar(255), name varchar(255), primary key (id) ) create table review ( id bigint generated by default as identity, content varchar(255), name varchar(255), score float not null, book_id bigint, user_id bigint, primary key (id) )
오늘은 1:1, 1:N 연관 관계일 때 Entity를 설정하는 방법에 대해 배웠습니다. 아직 mappedBy와 @JoinColumn의 사용 시기를 정확하게 알지 못한게 아쉬웠습니다. 많은 구현을 통해 실제 사용 시기를 빠르게 익혀보도록 하겠습니다. (영속성, multi bag? 도...)
다음 포스팅에서는 N:N 관계에서 Entity 설정 방법에 대해 다뤄보도록 하겠습니다.
반응형LIST'백엔드' 카테고리의 다른 글
영속성 컨텍스트, Entity Cache, Entity Lifecycle (2) 2023.08.24 Jpa 연관관계 살펴보기 (N:N) (0) 2023.08.23 Entity annotation, Listener (1) 2023.08.19 JPA 쿼리메서드 (0) 2023.08.18 JPA 살펴보기 (0) 2023.08.17