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

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

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

연관관계 객체 삭제

[BAD] CascadeType.REMOVE vs OrphanRemoval=true

앞에 배웠듯이 @OneToMany - @ManyToOne 양방향 Lazy관계에서 부모에 mappedBy, cascade, orphanRemoval 설정하고 자식에게 JoinColumn을 설정하는 것을 공부했다.
부모에게 설정하는 전이, 고아객체 삭제 설정에 대해 분리해서 알아보자.


orphanRemoval=true,false 차이

orphanRemoval=true 설정이 되어있는 경우 부모-자식 연결이 끊기면 자식 엔티티는 DELETE 된다.
orphanRemoval = false 시에는 연결이 끊긴 것에 대한 UPDATE만 처리한다.




CascadeType.REMOVE,OrphanRemoval=true의 결과는 흡사하다.

CascadeType.REMOVE 설정이 되어있는 경우 부모를 삭제하면 자식을 모두 개별 삭제를 한다.
orphanRemoval = true 설정이 되어있는 경우도 동일하게 자식 개별 DELETE를 수행한다.

-> 둘 다 삭제해야할 자식을 개별 삭제 하기 때문에 자식이 많으면 성능저하가 발생한다.

//CASCADE REMOVE 삭제 방법 - 부모를 삭제한다. 자식에게 삭제가 전이된다.
@Transactional
public void deleteViaCascadeRemove(){
        Author author=authorRepository.findByName("Joana Numar");
        authorRepository.delete(author);
        }

//ORPHAN REMOVAL 삭제 방법 - 연관관계를 끊어준다.
@Transactional
public void deleteViaOrphanRemoval(){
        Author author=authorRepository.findByNameWithBooks("Joana Numar");

        author.removeBooks();
        authorRepository.delete(author);
        }
-- file: `CASCADE REMOVE, ORPHAN REMOVAL 쿼리`
[Hibernate]
select a1_0.id,
       a1_0.age,
       a1_0.genre,
       a1_0.name
from author a1_0
where a1_0.name = ?
    [Hibernate]
select b1_0.author_id,
       b1_0.id,
       b1_0.isbn,
       b1_0.title
from book b1_0
where b1_0.author_id = ?
    [Hibernate]
delete
from book
where id = ?
    [Hibernate]
delete
from book
where id = ?
    [Hibernate]
delete
from book
where id = ?
    [Hibernate]
delete
from author
where id = ?

딱 봐도 엄청나게 비효율적이다.


[GOOD] 더 성능 좋은 벌크 delete

CascadeType.REMOVE나 orphanRemoval=true로 삭제하면 자식 개별 DELETE문이 발생해 성능저하. 그래서 벌크 처리를 통해 부모,자식을 삭제한다.
포인트는 자식 삭제 쿼리를 1개만 내뱉게 하는 것이다.
벌크 삭제시 영속성 컨텍스트를 무시하고 JPQL에 명시된 엔티티 삭제만을 진행한다.

단점

자동화된 낙관적 잠금 메커니즘의 이점

여러 트랜잭션이 동시 수정이 이뤄지는 일이 빈번하지 않다는 가정하에 사용한다.
별도의 Lock을 걸어 자원에 선점하는 대신, Entity 내부의 @Version 필드를 관리해서
UPDATE 시에 조회버전과 일치하지 않으면 예외를 발생시켜 동시성 문제를 해결한다.

읽기 시점에 락을 사용하지 않기 때문에 비관적 락보다 빠르게 조회 및 업데이트가 가능하다.

장점

  • 자식 삭제시 개별 SELECT, DELETE 쿼리를 하지 않아 성능이 좋다.


벌크 처리시 flushAutomatically = true, clearAutomatically = true 를 통해 영속성 콘텍스트 동기화 문제를 관리할 수 있다. 동기화의 고민은 영속성 콘텍스트에 올라와있는 엔티티 일부를 삭제하려 할 때 주요하다.

즉, 관리되는 엔티티 작업 중간에 벌크 작업을 수행할 때이다.
나는 협업에 도움이 되므로 벌크 작업시 @Modifying(clearAutomatically = true, flushAutomatically = true)를 붙이는 것을 선호한다.


Q: 벌크 삭제시 가장 효율적인 방법은?

저자는 영속성 콘텍스트, DB 동기화 상관없이 모든 항목을 삭제하는 가장 효율적인 방법은 내장된 deleteAllInBatch()를 사용하는 것이라 했는데,
실제 구현을 보고 써보면 deleteAllInBatch()는 In이 아닌 Or로 묶어서 삭제하는 것을 확인할 수 있다.

//file: `deleteAllInBatch의 구현체`
@Override
@Transactional
public void deleteAllInBatch(Iterable<T> entities){

        Assert.notNull(entities,"Entities must not be null");

        if(!entities.iterator().hasNext()){
        return;
        }

        applyAndBind(getQueryString(DELETE_ALL_QUERY_STRING,entityInformation.getEntityName()),entities,entityManager)
        .executeUpdate();
        }
//file: `applyAndBind의 구현체`
public static<T> Query applyAndBind(String queryString,Iterable<T> entities,EntityManager entityManager){

        Assert.notNull(queryString,"Querystring must not be null");
        Assert.notNull(entities,"Iterable of entities must not be null");
        Assert.notNull(entityManager,"EntityManager must not be null");

        Iterator<T> iterator=entities.iterator();

        if(!iterator.hasNext()){
        return entityManager.createQuery(queryString);
        }

        String alias=detectAlias(queryString);
        StringBuilder builder=new StringBuilder(queryString);
        builder.append(" where");

        int i=0;

        while(iterator.hasNext()){

        iterator.next();

        builder.append(String.format(" %s = ?%d",alias,++i));


        /**
         * 이부분에서 or 로 연결한다.
         */

        if(iterator.hasNext()){
        builder.append(" or");
        }
        }

        Query query=entityManager.createQuery(builder.toString());

        iterator=entities.iterator();
        i=0;

        while(iterator.hasNext()){
        query.setParameter(++i,iterator.next());
        }

        return query;
        }
-- file: `authorRepository.deleteAllInBatch(authors)의 쿼리`
delete
from author
where id = ?
   or id = ? -- or로 연결된다.

In절로 Bulk삭제하도록 Custom Query를 작성하면 어떻게 될까?

//file: `deleteBulkByAuthors의 구현체`
@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("DELETE FROM Author a WHERE a IN ?1")
    int deleteBulkByAuthors(List<Author> authors);
//file: `deleteBulkByAuthors의 사용`
@Transactional
public void deleteCustomBulkMethod(){
        List<Author> all=authorRepository.findAll();

        bookRepository.deleteBulkByAuthors(all);
        authorRepository.deleteBulkByAuthors(all);
        }
-- file: `deleteBulkByAuthors의 쿼리`
delete
from author
where id in (?, ?, ?, ?)


In절이 더 좋은 이유

사용유형InOr
시간 복잡도주어진 리스트를 정렬하고
이진 탐색을 사용하여 값이 리스트에 속하는지 확인.
O(log n)의 시간 복잡도를 가짐
리스트의 모든 값들을 각각 비교하여 확인하므로,
O(n)의 시간 복잡도를 가짐
실제 성능SELECT * FROM item WHERE id IN (1,2,3,…10000)
10000건당 0.0433
SELECT * FROM item WHERE id = 1 OR id = 2 … id = 10000
10000건당 0.1239

성능 참고 스택오버플로우 자료


따라서 저자의 말 대로 내장 deleteAllInBatch(Itemable<T> entities)를 사용하는 것은 고민해봐야 한다.
최선의 결과를 불러오지 않을 수도 있기 때문.

deleteInAllBatch의 경우, 내장 기능이기 때문에 flush와 clear하지 않는다.

삭제 방법 정리

AuthorRepository 메서드

메서드부모 영속성 컨텍스트 상태비고
delete(author)로드됨성능 저하, 자식 개수만큼 DELETE
deleteByIdentifier(id)로드되지 않음@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(“DELETE FROM Author a WHERE a.id = ?1”)
int deleteByIdentifier(Long id);
deleteBulkByIdentifier(ids)로드되지 않음@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(“DELETE FROM Author a WHERE a.id IN ?1”)
int deleteBulkByIdentifier(List id);
deleteBulkByAuthors(authors)여러 부모가 로드됨@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(“DELETE FROM Author a WHERE a IN ?1”)
int deleteBulkByAuthors(Iterable authors);
deleteAllInBatch(authors) - 내장여러 부모가 로드됨재정의 아니면 영속성 컨텍스트랑 동기화 안됨

BookRepository 메서드

메서드자식 영속성 컨텍스트 상태비고
deleteByAuthorIdentifier(id)로드되지 않음@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(“DELETE FROM Book b WHERE b.author.id = ?1”)
int deleteByAuthorIdentifier(Long id);
deleteBulkByAuthorIdentifier(ids)로드되지 않음@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(“DELETE FROM Book b WHERE b.author.id IN ?1”)
int deleteBulkByAuthorIdentifier(List id);
deleteBulkByAuthors(authors)로드되지 않음@Transactional
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(“DELETE FROM Book b WHERE b.author IN ?1”)
int deleteBulkByAuthors(List authors);
deleteAllInBatch(books) - 내장로드됨재정의 아니면 영속성 컨텍스트랑 동기화 안됨

주로 영속성 컨텍스트에 로드 됐는지, 안됐는지에 상황에 따라 취사선택하면 된다.
JPQL 쿼리로 재정의해서 당연한 말이겠지만 테스트 결과 영속성에 있는 객체나 id로 삭제하나 실제 나가는 쿼리는 동일하다.

//file: `영속성 vs 비영속성 삭제 코드 및 쿼리 비교`
@Transactional
public void deleteViaBulkHardCodedIdentifiers(){
        List<Long> authorsIds=Arrays.asList(1L,4L);

        bookRepository.deleteBulkByAuthorIdentifier(authorsIds);
        authorRepository.deleteBulkByIdentifier(authorsIds);
        }


@Transactional
public void deleteViaBulkByAuthors(){
        List<Author> all=authorRepository.findAllById(List.of(1L,4L));

        bookRepository.deleteBulkByAuthors(all);
        authorRepository.deleteBulkByAuthors(all);
        }
-- file: `두 메서드가 배출하는 동일한 DELETE 쿼리`
[Hibernate]
delete
from book
where author_id in (?, ?) [Hibernate]
delete
from author
where id in (?, ?)

신경써야할 건 제약조건 때문에 자식 먼저 지우고 부모를 지워야 한다.


그리고 영속성 컨텍스트와 동기화시켜주는 @Modifying(flushAutomatically = true, clearAutomatically = true) 설정을 쓰지 않고,
실수로 삭제된 엔티티에 추가적인 작업이 들어가면 오류를 발생시킨다.

//file: `[주의] flush, clear 하지 않고 삭제하고 삭제된 엔티티를 손대는 경우 Exception`
@Transactional
public void deleteViaDeleteInBatchX(){
        Author author=authorRepository.findByNameWithBooks("Joana Nimar");

        bookRepository.deleteAllInBatch(author.getBooks());
        authorRepository.deleteAllInBatch(List.of(author));

        // later on, we forgot that this author was deleted
        author.setGenre("Anthology");
        }

        //Exception 발생
        org.springframework.orm.ObjectOptimisticLockingFailureException:Row was updated or deleted by another transaction(or unsaved-value mapping was incorrect):[com.example.practicepersistancelayer.entity.Author#4]
//file: `flush, clear 하여 영속성 컨텍스트에 동기화할 경우 수정 무시` 
@Transactional
public void deleteViaDeleteInBatchX(){
        Author author=authorRepository.findByNameWithBooks("Joana Nimar");

        bookRepository.deleteBulkByAuthors(List.of(author));
        authorRepository.deleteBulkByAuthors(List.of(author));

        // later on, we forgot that this author was deleted
        author.setGenre("Anthology");
        }

연관관계 잘 가져오기

엔티티 그래프(fetch plans)

N+1, lazy loading 문제를 해결하는 방법 중 하나로 엔티티 그래프를 사용한다.

엔티티와 관련된 연관관계와 하나의 SELECT문에 로드돼야 할 기본적인 필드를 지정한다.
해당 엔티티에 대한 여러 엔티티 그래프를 정의해 다른 엔티티를 연결하며 하위 그래프를 사용해 복잡한 페치 플랜을 만들 수 있다.
재사용이 가능하다는 장점이 있다.

  • 엔티티 그래프 종류. FetchType
    • 페치 그래프 : default 설정으로 attributeNodes에 명시된 필드는 EAGER, 나머지는 LAZY
    • 로드 그래프 : attributeNodes에 명시된 필드만 EAGER, 나머지는 기본 설정 따름

@NamedEntityGraph

엔티티에 정의해서 사용한다.

//file: `Author - EntityGraph 정의`
@Entity
@NamedEntityGraph(
        name = "author-books-graph",//고유한 이름
        attributeNodes = {
                @NamedAttributeNode("books")//Author - books 가져와야 할 필드에 대응
        }
)
//file: `AuthorRepository - EntityGraph 사용`
@Override
@Transactional(readOnly = true)
@EntityGraph(value = "author-books-graph",
        type = EntityGraph.EntityGraphType.FETCH)
List<Author> findAll();
//file: `BookRepository - EntityGraph 사용`
@Transactional(readOnly = true)
@EntityGraph(value = "books-author-graph",
        type = EntityGraph.EntityGraphType.FETCH)
@Override
    List<Book> findAll();
-- file: `EntityGraph 사용시 쿼리`
select
    a1_0.id,
    a1_0.age,
    b1_0.author_id,
    b1_0.id,
    b1_0.isbn,
    b1_0.title,
    a1_0.genre,
    a1_0.name
from
    author a1_0
        left join
    book b1_0
    on a1_0.id=b1_0.author_id

Author - books 부모 자식간의 연관관계를 가져온다.
그래서 흥미로운 것은 bookRepository.findAll()에 EntityGraph를 적용해도 Author - books를 가져온다.

  • 쿼리 메서드 오버라이딩
  • 쿼리 빌더 메커니즘
  • Specification 사용
  • @Query JPQL 사용

애드혹 엔티티 그래프(Repository 에 단발 적용)

엔티티에 정의하지 않고 Repository에 적용한다.

@Repository
@Transactional(readOnly = true)
public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Override
    @EntityGraph(attributePaths = {"books"},
            type = EntityGraph.EntityGraphType.FETCH)
    List<Author> findAll();
}

@NamedEntityGraph를 적용했던 위치에 모두 동일하게 적용 가능하다.
엔티티와 별개로 적용하여 편리하다는 장점이 있다.

EntityGraph 사용시 주의사항, MultipleBagFetchException

다수의 즉시 가져오기를 하는 엔티티 그래프는 주의해야 한다(예 : Author에 Lazy로 선언된 List 두 자식이 있고, 둘 다 엔티티그래프에 포함될 경우) 여러 left outer join로 SELECT 호출되면, 하나 이상의 하이버네이트 Bag을 즉시 가져오게 되는데, MultipleBagFetchException이 발생한다. 즉, 엔티티그래프 hint를 사용해 쿼리를 실행할 때 즉시 여러 로딩을 시도하면 하이버네이트는 MultipleBagFetchException을 발생시킨다.


Bag이란 ?

Bag은 중복을 허용하고 순서를 유지하지 않는 컬렉션이다. 하이버네이트는 Collection으로 참조하고 있는 대상을 추적하고 관리하기 위해 내부적으로 PersistentBag 타입 객체로 실제 인스턴스를 래핑하여 사용하게 됩니다.

public class PersistentBag extends AbstractPersistentCollection implements List {

  protected List bag;
  ...

  public PersistentBag(SharedSessionContractImplementor session, Collection coll) {
     super( session );
     providedCollection = coll;
     if ( coll instanceof List ) {
        bag = (List) coll;
     }
     else {
        bag = new ArrayList( coll );
     }
     setInitialized();
     setDirectlyAccessible( true );
  }
}

한 엔티티에서 다수의 X-To-Many 연관관계를 초기화하는 최적의 방법 (MultipleBagFetchException 해결법)


@OneToMany(
    mappedBy = "post",
    cascade = CascadeType.ALL,
    orphanRemoval = true
)
private List<PostComment> comments = new ArrayList<>();
 
@ManyToMany(
    cascade = {
        CascadeType.PERSIST,
        CascadeType.MERGE
    }
)
@JoinTable(
    name = "post_tag",
    joinColumns = @JoinColumn(name = "post_id"),
    inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private List<Tag> tags = new ArrayList<>();

post 엔티티를 호출 할 때, 2가지 경우에 MultipleBagFetchException이 발생하여 쿼리가 나가지 않는다..

  • comments와 tags가 모두 EAGER로 설정되어 있을 때
  • fetch join을 comments와 tags에 적용할 때

이 문제를 해결하기 위해 흔히 알려진 잘못된 해결책이 List를 Set으로 변경하는 것이다.

List -> Set으로 변경으로 해결하면 안되는 이유


Set으로 바꿔서 post - comments, tags 를 페치조인해 가져오면 아래와 같은 쿼리가 발생한다.

SELECT
    p.id AS id1_0_0_,
    pc.id AS id1_1_1_,
    t.id AS id1_3_2_,
    p.title AS title2_0_0_,
    pc.post_id AS post_id3_1_1_,
    pc.review AS review2_1_1_,
    t.name AS name2_3_2_,
    pt.post_id AS post_id1_2_1__,
    pt.tag_id AS tag_id2_2_1__
FROM
    post p
LEFT OUTER JOIN
    post_comment pc ON p.id = pc.post_id
LEFT OUTER JOIN
    post_tag pt ON p.id = pt.post_id
LEFT OUTER JOIN
    tag t ON pt.tag_id = t.id
WHERE
    p.id BETWEEN 1 AND 50

MultipleBagFetchException는 나지 않지만 쿼리를 살펴보면 문제가 있다는 점을 알게 된다.

Set 쿼리의 문제점 카테시안곱

(post - post_comment)는 post_id 외래 키 열을 통해 연결되어 있으므로,
LEFT OUTER JOIN은 기본 키 값이 1 ~ 50 사이인 모든 post 테이블 행과 그와 연관된 post_comment 테이블 행을 포함하는 결과 집합을 생성합니다.

(post - tag) 테이블도 post_id와 tag_id post_tag 외래 키 열을 통해 연결되어 있으므로,
이 두 조인은 기본 키 값이 1 ~ 50 사이인 모든 post 테이블 행과 그와 연관된 tag 테이블 행을 포함하는 결과 집합을 생성합니다.

이제 두 결과 집합을 병합하기 위해, 데이터베이스는 카테시안 곱을 사용할 수밖에 없으므로
최종 결과 집합은 연관된 post_comment 및 tag 테이블 행과 곱해진 50개의 post 행을 포함합니다.

따라서, 만약 50개의 post 행이 20개의 post_comment 및 10개의 tag 행과 연결된 경우, 최종 결과 집합은 10,000개의 기록을 포함하게 됩니다(예: 50 x 20 x 10)

해결책

JPA EntityManager나 Hibernate Session에서 한 번에 하나의 엔티티 객체만 로딩될 수 있다는 Hibernate Persistence Context의 보장에 의존하는 것이다.
즉, 한 트랜잭션 내에서 x-To-Many를 분리해서 가져오는 것이다.

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
 
    @Query("""
        select distinct p
        from Post p
        left join fetch p.comments
        where p.id between :minId and :maxId
        """)
    List<Post> findAllWithComments(
        @Param("minId") long minId,
        @Param("maxId") long maxId
    );
 
    @Query("""
        select distinct p
        from Post p
        left join fetch p.tags
        where p.id between :minId and :maxId
        """)
    List<Post> findAllWithTags(
        @Param("minId") long minId,
        @Param("maxId") long maxId
    );
}
@Service
@Transactional(readOnly = true)
public class PostServiceImpl implements PostService {
     
    @Autowired
    private PostRepository postRepository;
 
    @Override
    public List<Post> findAllWithCommentsAndTags(
            long minId, long maxId) {
             
        List<Post> posts = postRepository.findAllWithComments(
            minId,
            maxId
        );
 
        return !posts.isEmpty() ?
            postRepository.findAllWithTags(
                minId,
                maxId
            ) :
            posts;
    }
}


하나의 영속성 컨텍스트 내에서 findAllWithCommentsfindAllWithTags 를 작동시켰다.
findAllWithComments 메소드는 Post 엔티티를 가져와 영속성 컨텍스트에 저장한다.
findAllWithTags는 데이터베이스에서 가져온 참조와 태그 컬렉션이 초기화된 기존 Post 엔티티를 MERGE한다.
결과적으로 2개의 조회 쿼리로 POST - POST_COMMENTS,TAGS를 초기화한 객체를 반환한다.

다수의 X-To-Many 연관관계를 초기화하는 최적의 방법 참고


엔티티 그래프 서브그래프로 연결된 여러 연관관계 체인 함께 가져오기

@NamedEntityGraph - 서브그래프

Author 1<->N Book N<->1 Publisher 시에 Author - Book - Publisher를 함께 가져오고 싶으면?

//file: `NamedEntityGraph - 서브그래프 예시`
@Entity
@NamedEntityGraph(
        name = "author-books-publisher-graph",
        attributeNodes = {
                @NamedAttributeNode(value = "books", subgraph = "publisher-subgraph")
        },
        subgraphs = {
                @NamedSubgraph(
                        name = "publisher-subgraph",
                        attributeNodes = {
                                @NamedAttributeNode("publisher")
                        }
                )
        }
)
//file: `AuthorRepository - 서브그래프 사용`
@Override
@Transactional(readOnly = true)
@EntityGraph(value = "author-books-publisher-graph",
        type = EntityGraph.EntityGraphType.FETCH)
List<Author> findAll();
//file: `서브그래프 사용시 쿼리`
[Hibernate]
select
  a1_0.id,
  a1_0.age,
  b1_0.author_id,
  b1_0.id,
  b1_0.isbn,
  p1_0.id,
  p1_0.company,
  b1_0.title,
  a1_0.genre,
  a1_0.name
from
  author a1_0
    left join
  book b1_0
  on a1_0.id=b1_0.author_id
    left join
  publisher p1_0
  on p1_0.id=b1_0.publisher_id
where
  a1_0.age>20
  and a1_0.age<40

author 테이블에서 시작하여, 저자의 나이가 20살 초과 40살 미만인 경우만 선택합니다.
author_id를 기준으로 book 테이블과 LEFT JOIN을 수행하여, 각 author의 모든 book 정보를 가져옵니다.

book 테이블은 publisher_id를 사용하여 publisher 테이블과 LEFT JOIN을 수행하므로,
각 book의 모든 publisher 정보를 결합한 결과 집합을 생성합니다.

결과적으로 이 쿼리는 저자의 ID, 나이, 장르, 이름과 각 책의 저자 ID, 책 ID, ISBN, 제목 그리고 각 출판사의 ID, 회사명을 포함한 결과 집합을 반환합니다.
이 결과 집합은 author 테이블의 각 행과 연관된 book 및 publisher 테이블 행을 포함합니다.

이는 author에 대한 상세 정보와 그들이 작성한 책, 그리고 해당 책을 출판한 출판사의 정보를 포괄적으로 제공하는 쿼리입니다. 카테시안 곱은 없습니다.

애드혹 엔티티 그래프 - 서브그래프

@Override
@EntityGraph(attributePaths = {"books.publisher"},
        type = EntityGraph.EntityGraphType.FETCH)
List<Author> findAll();

단순히 .으로 체인을 걸었는데, Author - books - publisher를 함께 가져온다. 편리


필드 단위로 엔티티 그래프 사용하기

기본적으로 하이버네이트의 Bytecode Enhancement의 Lazy Loading을 활성화시켜야 한다.
그리고 즉시 가져올 필드를 선언해주고, 바로 가져올 필요가 없는 필드에 @Basic(fetch = FetchType.LAZY)를 선언한다.
기본적으로 모든 필드에는 @Basic이 선언되어 있는데, 디폴트 설정이 EAGER이다.
@Basic(fetch = FetchType.LAZY)를 선언하면 다른 모든 DATA JPA 메서드들에도 영향을 끼치기 때문에 주의해야 한다.

하이버네이트 Bytecde Enhancement란?

@Entity
@NamedEntityGraph(
    name = "author-books-graph",
    attributeNodes = {
        @NamedAttributeNode("name"),
        @NamedAttributeNode("books")
    }
)
public class Author implements Serializable {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String name;
  @Basic(fetch = FetchType.LAZY)
  private String genre;
  @Basic(fetch = FetchType.LAZY)
  private int age;

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

author-books-graph의 namedEntityGraph를 사용하면 name과 books만 가져온다.
genre, age는 함께 가져오지 않는다.

//file: `Bytecode Enhancement를 사용한 AuthorRepository NamedEntityGraph 사용 예제`
@EntityGraph(value = "author-books-graph",
        type = EntityGraph.EntityGraphType.FETCH)
    List<Author> findByAgeGreaterThanAndGenre(int age, String genre);

@EntityGraph(value = "author-books-graph",
        type = EntityGraph.EntityGraphType.LOAD)
    List<Author> findByGenreAndAgeGreaterThan(String genre, int age);
-- file: `Bytecode Enhancement를 사용한 AuthorRepository NamedEntityGraph 사용 쿼리`
SELECT
    author0_.id AS id1_0_0_,
    books1_.id AS id1_1_1_,
    author0_.name AS name4_0_0_, -- author에서 name 필드만 가져온 부분
    books1_.author_id AS author_i4_1_1_,
    books1_.isbn AS isbn2_1_1_,
    books1_.title AS title3_1_1_
FROM
    author author0_
        LEFT OUTER JOIN book books1_ 
            ON author0_.id=books1_.author_id
WHERE 
    author0_.genre = ?
    AND author0_.age > ?

예상대로 name과 books만 가져온다.
bytecode Enhancement 설정을 켜주지 않으면 필드단위 LAZY관리가 안되기 때문에 필드 모두 EAGER로 가져온다.

참고로 엔티티 그래프의 종류는 아래와 같다. @Basic Lazy로 선언했기 때문에 두 쿼리가 동일하다.

  • 엔티티 그래프 종류. FetchType
    • 페치 그래프 : default 설정으로 attributeNodes에 명시된 필드는 EAGER, 나머지는 LAZY
    • 로드 그래프 : attributeNodes에 명시된 필드만 EAGER, 나머지는 기본 설정 따름

연관관계 기본 필터링 조건 추가 @SQLRestriction (@Where 6.3 대체) (SoftDelete, 기본 상태 필터링)

Hibernate 6.3부터는 @Where를 사용할 수 없다. 대신 @SQLRestriction을 사용한다.
공식 문서의 사용법을 보면, @SQLRestriction은 @Where의 상위호환이라는 점을 알 수 있다.

@SQLRestriction 사용처

  • 연관관계 필터링: 특정 연관관계 기본 상태(예: 활성, 비활성)로 데이터를 필터링해야 하는 경우.
  • 소프트 딜리트: 실제 데이터 삭제 대신, 삭제된 것으로 표시하고 조회 시에는 삭제되지 않은 데이터만 조회하는 경우.
  • 엔티티 기본 상태 필터링 : 특정 상태(예: 활성, 비활성)로 데이터를 필터링해야 하는 경우.

@SQLRestriction 사용법

엔티티나 컬렉션에 대해 생성된 SQL에 추가할 네이티브 SQL로 작성된 조건을 지정한다.

//file: `SoftDelete 예시`
@Entity
@SQLRestriction("status <> 'DELETED'")
class Document {
    ...
  @Enumerated(STRING)
  Status status;
    ...
}

//file: `엔티티 연관관계 수준 설정 예시`
@OneToMany(mappedBy = "owner")
@SQLRestriction("status <> 'DELETED'")
List<Document> documents;
//file: `조인테이블에 제약 설정`
@ManyToMany
@JoinTable(name = "collaborations")
@SQLRestriction("status <> 'DELETED'")
@SQLJoinTableRestriction("status = 'ACTIVE'")
List<Document> documents;

@SQLRestriction은 항상 적용되며 비활성화될 수 없다.
또한 매개변수화할 수도 없어서. 필터보다 훨씬 덜 유연하다.

@Filter, @SQLRestriction보다 유연한 사용법

@FilterDef와 @Filter 어노테이션을 사용하면, 더 유연하게 조건을 걸 수 있다.

  • 현재 세션에 대해 필터 정의를 활성화하거나 비활성화할 수 있다.
  • 필터 조건에서 동적 매개변수를 사용할 수 있다.
  • 이를 통해 런타임 시 필터 조건을 조정할 수 있다.



@Filter vs @SQLRestriction

@SQLRestriction 어노테이션에 정의된 조건은 항상 활성화되어 있고, 매개변수를 사용할 수 없는 단점이 있다.


기본 필터 설정

@FilterDef 어노테이션을 사용하여 클래스 또는 패키지 수준에서 적용할 수 있다.

@FilterDef(name = "proFilter", 
           parameters = @ParamDef(name = "professional", type = "boolean"), 
           defaultCondition = "pro = :professional")
            
package com.thorben.janssen.sample.model;
 
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.FilterDefs;
import org.hibernate.annotations.ParamDef;
  • parameters 속성은 @ParamDef 어노테이션 배열을 허용한다.
  • 각 매개변수는 @FilterDef의 defaultCondition이나 @Filter 어노테이션의 조건에서 사용할 수 있는 이름과 유형을 정의한다.
  • 위 예제에서는 기본 조건에서 professional 매개변수를 사용한다.

defaultCondition를 통해 SQL 조각이 필터 조건으로서 Hibernate가 자동으로 생성하는 쿼리에 포함된다.

//file: `위에서 만든 proFilter 엔티티 적용 예시`
@Filter(name = "proFilter")
@Entity
public class ChessPlayer {
    // ...
}

필터를 만들었으면,Hibernate 세션에서 이를 활성화해야 한다.
Hibernate의 필터는 기본적으로 비활성화되어 있다.
@FilterDef의 이름을 사용하여 Session의 enableFilter 메서드를 호출하면 된다.
이 메서드는 Filter 객체를 반환하며, 이를 사용하여 필터 매개변수를 설정할 수 있습니다.

이렇게 하면 참조된 @FilterDef가 이를 참조한 모든 엔티티에 대해 활성화되며, 현재 세션이 끝날 때까지 또는 disableFilter 메서드를 필터 정의 이름과 함께 호출할 때까지 활성 상태를 유지한다. 이전 섹션에서 정의한 proFilter 필터를 활성화하고 professional 매개변수를 true로 설정해보자.

// 필터 활성화 및 매개변수 설정
Session session = em.unwrap(Session.class);
        Filter filter = session.enableFilter("proFilter");
        filter.setParameter("professional", true);

// 필터가 활성화된 상태에서 쿼리 실행
        List<ChessPlayer> chessPlayersAfterEnable = em.createQuery("select p from ChessPlayer p", ChessPlayer.class)
        .getResultList();

17:59:00,949 DEBUG SQL:144 - select chessplaye0_.id as id1_1_, chessplaye0_.birthDate as birthdat2_1_, chessplaye0_.firstName as firstnam3_1_, chessplaye0_.lastName as lastname4_1_, chessplaye0_.pro as pro5_1_, chessplaye0_.version as version6_1_ from ChessPlayer chessplaye0_ where chessplaye0_.pro = ?

defaultCondition을 사용하지 않는 경우
//file: `defaultCondition를 사용하지 않은 필터 적용 예시`
@FilterDef(name = "dateFilter",
parameters = {
@ParamDef(name = "minDate", type = "java.time.LocalDate"),
@ParamDef(name = "maxDate", type = "java.time.LocalDate")
})

defaultCondition을 설정하지 않은 경우, 엔티티에 필터를 적용할 때 condition을 제공해야 한다.

//file: `condition을 설정한 필터 엔티티 적용 예시`
@Filter(name = "dateFilter", condition = "birthDate >= :minDate and birthDate <= :maxDate")
@Entity
public class ChessPlayer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private LocalDate birthDate;
    
    // 기타 필드 및 메서드
}
//file: `condition을 설정한 필터 필드 적용 예시`
@Entity
public class ChessGame {

  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  private Long id;

  @Filter(name = "dateFilter", condition = "date >= :minDate and date <= :maxDate")
  private LocalDate date;

  // 기타 필드 및 메서드
}

여러 엔티티나 엔티티 속성에 동일한 필터를 재사용할 수 있다. 예를 들어, 날짜 범위 필터를 여러 엔티티의 서로 다른 날짜 필드에 적용할 수 있다.

정적 필터

boolean값 과 같이 @SqlRestriction같은 필터를 만들어 사용할 수도 있다.

//file: `정적 필터 예시`
@FilterDef(name = "isProFilter", defaultCondition = "pro = 'true'")
//file: `정적 필터 적용 예시`
@Filter(name = "isProFilter")
@Entity
public class ChessPlayer {
    // ...
}
x-To-Many 관계에 필터 적용
//file: `x-To-Many 관계에 필터 적용 예시`
@FilterDef(name = "playerMinId", parameters = {
        @ParamDef(name = "minId", type = "integer")
})
@Entity
public class ChessTournament {
 
    @ManyToMany
    @FilterJoinTable(name = "playerMinId", condition = "players_id >= :minId")
    private Set<ChessPlayer> players = new HashSet<>();
     
    // 기타 필드 및 메서드
}

@FilterJoinTable 어노테이션을 사용하여 조인 테이블에 필터를 적용할 수 있다.

Filter + Spring Data JPA

Spring Data JPA + Filter 사용 예시

필터 제한사항 및 주의사항

Hibernate의 필터를 애플리케이션에서 사용하기 전에,많은 애플리케이션에서 문제를 일으킬 수 있는 두 가지 제한 사항을 알고 있어야 한다.

  1. 필터와 2차 캐시
    • Hibernate의 2차 캐시는 현재 세션과 특정 필터 설정과는 독립적이다.
    • 활성화된 필터가 일관성 없는 결과를 초래하지 않도록 하기 위해, 2차 캐시는 항상 필터링되지 않은 결과를 저장합니다.
    • 따라서 @Filter와 @Cache 어노테이션을 함께 사용할 수 없다.
  2. 직접 페칭 시 필터링 불가
    • Hibernate는 엔티티 쿼리에는 필터를 적용하지만, EntityManager의 find() 메서드를 호출하여 엔티티를 직접 페칭하는 경우에는 필터를 적용하지 않는다.
    • 따라서 필터를 사용하여 보안 기능을 구현해서는 안 되며, 애플리케이션에서 직접 페칭 작업을 주의 깊게 확인해야 한다.
결론
  • Hibernate의 @FilterDef와 @Filter 어노테이션은 특정 엔티티 클래스를 선택하는 모든 쿼리에 추가 동적 필터 기준을 지정한다.
  • 런타임 시 필터를 활성화해야 하며, 다양한 매개변수 값을 제공하여 필터를 사용자 지정할 수 있다. 이를 통해 각 사용 사례와 세션의 특정 요구 사항에 맞게 필터를 조정할 수 있다.

필터 참조