효율적으로 연관관계 맺기 - 1

효율적으로 연관관계 맺기 - 1

JPA의 기본 사항을 정리한다.

[Good] 양방향 OneToMany

  • 양방향 관계에 대해 부모(Author) - 자식(Books)에 대한 관계로 표현할 수 있다.
  • 부모 하나 (행, Row) 는 여러 자식 (행, Row)을 가질 수 있다. (책에서는 참조될 수 있다고 표현된다.)
  • 자식 하나 (행, Row) 는 하나의 부모 (행, Row) 만을 가질 수 있다. (책에서는 참조할 수 있다고 표현된다.)



Q: 왜 연관관계의 주인은 외래키를 가진 쪽인가? (자식)

`연관관계의 주인`은 (데이터베이스 or 영속성 콘텍스트) 상의 관계를 결정하고 관리하는 책임을 가진 엔티티를 말한다.
`외래키 열`은 데이터베이스 테이블에서 다른 테이블로의 참조를 관리하는 열이다.
자식 엔티티 (@ManyToOne)는 `외래키 열`을 직접 소유하여 데이터베이스 상의 연결을 관리하고, 그 외래키로 부모 엔티티와의 관계를 관리한다.
또한 자식은 `외래키 열`을 영속성 컨텍스트와 동기화하는 역할을 한다.(book.setAuthor(this)처럼)


최초에는 어린이 반에 보낸 엄마가 있으면 엄마가 `연관관계의 주인` 아닌가? 하는 의구심이 들었었다.
각각 어린이의 가슴에 어머니 성함 명찰을 달아놓는다면 어린이의 명찰(외래키)을 보고 부모를 찾아가는 것이 더 효율적이라고 생각하게 되었다.

즉, `관계에 대한 기록된 단서 (Join Column)`가 있는 쪽이 `연관관계에서는 주인`이 되는게 맞구나 생각이 들었다.


부모 - 자식 관계에서의 주요 특징

부모주도권자식
 JoinColumn(외래키)

연관관계 주인이다.
어떤 부모와 관계를 맺는지 적을 책임을 가지고 있다.
전이(Cascading)

부모로부터 자식에 대한 생성, 삭제 등이 전이되는게 자연스럽다.
 
매핑 된(mappedBy)@ManyToOne

연관관계 주인인 자식으로부터 매핑이 되어
자식 외래키에 매핑되면 미러링한다는 신호다.
고아 삭제(orphanRemoval)

부모에 의해 생성되지 않은 객체들을 삭제할건지 말건지 부모가 결정한다.
 
동기화 메서드

부모없이 자식이 생기는 일이 없다는 가정하에
부모쪽에 자식 외래키와 동기화 메서드를 포함시킨다.
 
  @ManyToOne은 기본적으로 EAGER일 수 있는데 LAZY로 설정해주는게 좋다.
//file: `연관관계 예시`

@OneToMany(mappedBy = "author"
        ,cascade = CascadeType.ALL
        , orphanRemoval = true)
private List<Book> books = new ArrayList<>();



@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;


양방향 OneToMany 관계에서 동기화 메서드가 부모쪽에 있어야 하는 이유는 뭘까?

대부분의 경우, 자식 엔티티는 부모 엔티티 없이 존재할 수 없다. <생성 의존성> 예를 들어, 책(Book)이 저자(Author) 없이는 존재할 수 없다. 부모쪽에 관계 동기화 메서드를 놓음으로써 객체의 생성과 관계 설정을 자연스럽게 통합할 수 있다.

도메인 모델로 생각해도 부모 엔티티가 자식 엔티티보다 더 중심적인 역할을 한다. (모든 도메인의 중요도가 평등하지 않다.)

그리고 비즈니스 로직을 부모 엔티티에 집중시키면, 관련 로직이 한 곳에 모여 있어 관리와 유지보수가 용이해진다.(이건 뇌피셜이다.)



메서드 종류 및 의도를 알아보자

find… 메서드

용도
find..., findBy..., findOne..., read..., get... 등의 접두사를 사용한 메서드는 엔티티를 조회하는 데 사용된다.

예시
findById: 주어진 ID로 엔티티를 찾는다.
findByName: 이름으로 엔티티를 조회한다.

이 메서드들은 데이터베이스에서 주어진 조건에 맞는 엔티티를 조회하여 반환하다.

getReferenceById 메서드

설명
getReferenceById 메서드는 지정된 ID에 해당하는 엔티티의 지연로딩 객체를 반환한다.
정확히는 entity 대신 empty proxy placeholder를 할당해놓는다.
이 메서드는 실제 데이터를 데이터베이스에서 즉시 로딩하지 않고, 엔티티에 처음 접근하는 시점에서 데이터를 로딩한다(Lazy Loading).

이점
이 방식은 메모리 사용을 최적화하고 성능을 개선할 수 있는 경우에 유용히다.

getReferenceById 보통 한 트랜잭션에 한 영속성컨텍스트가 배정된다

참고글 baeldung-getreferencebyid

fetch

설명
fetch는 일반적으로 JPQL 또는 SQL 쿼리에서 명시적으로 사용된다.
이는 연관된 엔티티를 즉시 로딩하는 데 사용되며, 쿼리의 성능 최적화를 돕는다.

예시

  • @Query("SELECT u FROM User u JOIN FETCH u.posts WHERE u.id = :id"): 이 쿼리는 JOIN FETCH를 통해 사용자와 그의 게시글을 함께 즉시 로드한다.

[Bad] 단방향 @OneToMany

단방향 @OneToMany로 사용하는 경우, 중간 외래키 관리 연결 Table이 생성된다.(author_books 같은)
이로 인해 중간 테이블을 관리하는 복잡성이 증가한다.

  • (부모 + 자식) 을 새로 등록할 경우, 외래키 관리 Table에 추가적인 INSERT문이 발생한다.
  • 자식들을 삭제/등록 할 경우, 연결 Table에서 모든 연결을 삭제하고 수정된 개수만큼 새로 INSERT한다.
  • 연결 Table의 수정이 이뤄지는 경우, 외래키 컬럼과 관련된 인덱스 항목 삭제, 재추가도 성능 저하의 원인이 된다.


아무래도 외래키에 대한 관리에 대한 공수가 따로 들어간다.

[Good] 단방향 @ManyToOne

//file: `단방향 @ManyToOne`

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;

연결 Table이 생성되지 않고 자식이 추가될 때 마다 외래키 컬럼에 부모의 PK가 추가된다.
추가적인 INSERT가 발생하지 않는다는 의미이다.


@ManyToMany 잘 맺는 법

두 엔티티가 모두 부모기 때문에 외래키를 관리해줄 연결 Table이 생성된다.
그래도 설정에는 오너 (변경사항에 대해 관리하는)를 설정해야 한다.

//file: `관계에서 오너 설정`


//Author, 연관관계의 오너이자 변경사항에 대해 관리를 한다. helper method를 통해 관리한다.
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(name = "author_book",
        joinColumns = @JoinColumn(name = "author_id"),
        inverseJoinColumns = @JoinColumn(name = "book_id")
)
private Set<Book> books = new HashSet<>();
//addBook, removeBook, removeBooks 등 동기화 & 변경사항 전파용 메서드는 오너에게



//Book
@ManyToMany(mappedBy = "books")
private Set<Author> authors = new HashSet<>();
  • 더 많이 쓰이는 쪽에 오너로 정한다.
  • 오너쪽에 보기 편하게 JoinTable로 연관관계 명시해준다.
  • 오너쪽에 동기화 메서드를 만들어 변경사항을 전파한다.
  • 무조건 Set을 쓴다
  • 변겅전이에 ALL, REMOVE를 쓰지 않는다. (다른 곳에서 함께 참조되어 있기 때문에 멋대로 지워지면 안된다)
  • 결과적으로 연결테이블과 2개의 OneToMany 단방향처리가 된다.



toMany 관계에서 Set vs List 뭐가 더 좋을까?

일반적으로, List와 Set은 지연 로딩(lazy fetch)을 사용할 때 비슷하게 작동한다. Eager fetch를 사용할 때 발생하는 문제를 바탕으로 차이점을 파악해봐야 한다.

ManyToOne - OneToMany - EAGER

  • 단일 조회 (Single Fetch)
사용유형설명
ListList와 Set은 모두 단일 조회시에는 한 번의 쿼리로 유저의 정보와 해당 유저의 포스트를 모두 가져온다.
하지만 결과 세트에는 포스트의 수만큼 유저의 정보가 반복되는 문제가 있다.
Set 
-- file: `List,Set 사용자와 단일 조회 발생 쿼리`
SELECT u.id, u.email, u.username, p.id, p.author_id, p.content
FROM simple_user u
      LEFT JOIN post p ON u.id = p.author_id
WHERE u.id = ?


  • 다수 조회 (Multiple Fetch)
사용유형설명
ListList와 Set은 모두 N + 1 문제에 처한다.
Set 
-- file: `List,Set N + 1 문제 쿼리`
-- 모든 유저 조회 (1)
SELECT u.id, u.email, u.username
FROM simple_user u

-- 각 유저의 포스트 조회 (N)
SELECT p.id, p.author_id, p.content
FROM post p
WHERE p.author_id = ?

ManyToMany - EAGER

  • All 그룹 조회 (Group Fetch)
사용유형설명
List모든 그룹을 가져올 때, Hibernate는 각 그룹의 멤버 및 각 멤버의 게시물을 가져 오기 위해 추가적인 쿼리를 실행한다.
따라서 세 가지 유형의 1+N+M 쿼리가 발생.
여기서 N은 그룹의 수이고, M은 이러한 그룹의 고유한 사용자 수.
Set단지 N + 1이 있지만, 더 복잡한 쿼리를 얻게 만든다.
여전히 모든 그룹을 가져오기 위한 별도의 쿼리가 있지만,
Hibernate는 두 개의 JOIN을 사용하여 단일 쿼리에서 사용자 및 그들의 게시물을 가져온다.
카테시안 곱 문제가 발생할 수 있다.

카테시안 곱

From절에 2개 이상의 Table이 있을때 두 Table 사이에 유효 join 조건을 적지 않았을때 해당 테이블에 대한 모든 데이터를 전부 결합하여 Table에 존재하는 행 갯수를 곱한 만큼의 결과값이 반환되는 것이다.

-- file: `List ManyToMany 그룹과 멤버, 게시물 조회 쿼리`
-- 모든 그룹 조회 (1)
SELECT g.id, g.name
FROM interest_group g

-- 각 그룹의 멤버 조회 (N)
SELECT gm.interest_group_id, u.id, u.email, u.username
FROM interest_group_members gm
      JOIN simple_user u ON u.id = gm.members_id
WHERE gm.interest_group_id = ?

-- 각 멤버의 게시물 조회 (M)
SELECT p.author_id, p.id, p.content
FROM post p
WHERE p.author_id = ?
-- file: `Set ManyToMany 그룹과 복잡한 join 멤버 & 게시물 조회 쿼리`
-- 모든 그룹 조회 (1)
SELECT g.id, g.name
FROM interest_group g

-- 그룹 멤버와 그들의 게시물을 한 번에 조회하는 쿼리 (N)
SELECT u.id,
       u.username,
       u.email,
       p.id,
       p.author_id,
       p.content,
       gm.interest_group_id,
FROM interest_group_members gm
      JOIN simple_user u ON u.id = gm.members_id
      LEFT JOIN post p ON u.id = p.author_id
WHERE gm.interest_group_id = ?

  • 제거 (Deletion)
사용유형설명
ListLists는 객체를 제거할 때 조인 테이블의 전체 그룹을 제거하고 다시 만든다.
SetSet을 사용하여 객체를 제거할 때는 해당 객체를 조인 테이블에서 제거.
-- file: `List ManyToMany 멤버 제거시 발생 쿼리`
-- 그룹과 그 멤버, 게시물을 조회하는 쿼리
SELECT u.id, u.email, u.username, g.name,
       g.id, gm.interest_group_id,
FROM interest_group g
         LEFT JOIN (interest_group_members gm JOIN simple_user u ON u.id = gm.members_id)
                   ON g.id = gm.interest_group_id
WHERE g.id = ?

SELECT p.author_id, p.id, p.content
FROM post p
WHERE p.author_id = ?

-- 일단 한번 조인 테이블에서 관계를 전체 제거하는 쿼리
DELETE
FROM interest_group_members
WHERE interest_group_id = ? 
    
-- 조인테이블에서 다시 새로운 관계를 추가하는 쿼리 (N번)
INSERT
INTO interest_group_members (interest_group_id, members_id)
VALUES (?, ?)

-- file: `Set ManyToMany 멤버 제거시 발생 쿼리`

-- 그룹과 그 멤버, 게시물을 조회하는 쿼리 (1)
SELECT g.id, g.name,
       u.id, u.username, u.email,
       p.id, p.author_id, p.content,
       m.interest_group_id,
FROM interest_group g
         LEFT JOIN (interest_group_members m JOIN simple_user u ON u.id = m.members_id)
                   ON g.id = m.interest_group_id
         LEFT JOIN post p ON u.id = p.author_id

-- 조인테이블에서 삭제된 멤버 관계를 제거하는 쿼리 (1)
DELETE
FROM interest_group_members
WHERE interest_group_id = ? AND members_id = ?

Set은 대부분의 경우 중복을 허용하지 않는 컬렉션은 도메인 모델을 완벽하게 반영하게 된다.
그룹 내에 두 개의 동일한 사용자가 존재할 수 없으며, 사용자는 두 개의 동일한 게시물을 가질 수 없다.

ManyToMany에서 List를 사용하면 삭제 동작에서 오버헤드를 발생시킨다.(연관관계 모두 삭제, 모두 추가)

참고글 baeldung-onetomany-list-vs-set


그럼 Set이 더 좋은거니?

그러나 중복을 제거하겠다고 Set OneToMany와 Fetch Lazy를 함께 사용하면 성능 저하가 발생한다.
컬력션이 아직 초기화 되지 않은 상태에서 컬렉션에 값을 넣게 되면 List와 달리 프록시가 강제로 초기화 되는 문제가 발생한다.
Set의 특성상 입력이 발생하면 중복 데이터가 있는지 비교해야 하는데, 비교를 위해 모든 데이터를 로딩해야 하기 때문이다.
List는 추가시에 이런 중복 체크가 필요없기 때문에 초기화가 발생하지 않는다.

참고글 인프런 이영한님 답변


또 본문에 나왔던 Set과 List에 대해 생각해볼 바로는 HHH-5855 문제이다. 지금은 해결되어서 신경쓰지 않아도 될듯.

HHH-5855 문제 요약

HHH-5855는 Hibernate에서 java.util.List를 사용하여 엔티티의 자식을 관리할 때 발생했던 버그입니다.
EntityManager의 merge 메소드를 사용할 때 주의할 점이 있습니다.
예를 들어, 엔티티 A가 엔티티 B와 OneToMany(fetch=LAZY, cascade=ALL or (MERGE and PERSIST)) 관계를 맺고 있을 경우,
새로운 B 인스턴스를 A에 추가하려고 하면, 커밋 시 중복 insert 명령이 생성될 수 있습니다.

참고글 HHH-5855문제

결론

결과적으로 ManyToMany에는 삭제와 같은 상황에서 조인 테이블에 성능저하가 와서 Set을,
OneToMany에는 레이지로딩으로 원치 않을 경우 컬렉션 초기화를 방지하기 위해 양방향으로 List를 사용하는 것이 좋다.

ManyToMany에서 Set이 좋아 쓰기 때문에, 순서 유지가 필요한 경우를 고려해야 한다.
그럴 땐 @OrderBy로 Order By문을 추가해주거나, @OrderColumn을 사용해 연결테이블에 순서를 저장해야 한다. 이후 LinkedHashSet으로 초기화해주면 된다.

//file: `ManyToMany Set 순서 유지`

@ManyToMany(mappedBy = "books")
@OrderBy("name DESC")
private Set<Author> authors = new LinkedHashSet<>();