엔티티 정의
위의 ERD는 간단하게 Company(기업)과 Dividend(배당금)간의 연관관계를 정의한 것이다. 기업은 여러 배당금 정보를 가질 수 있으니 기업과 배당금은 OneToMany
관계를 가진다고 말할 수 있다.
Spring에서는 JPA를 사용하여 각각 다음과 같이 정의한다. (설명에 불필요한 부분은 삭제함)
@Entity
public class Company {
@Id
@GeneratedValue
private Long id;
@Column(unique = true)
private String ticker;
private String name;
@OneToMany(mappedBy = "company")
private Set<Dividend> dividends = new HashSet<>();
}
@Entity
public class Dividend {
@Id
@GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "companyId")
private Company company;
private LocalDateTime date;
private String dividend;
}
Company 조회
Company company = companyRepository.findByName(companyName).get();
company.getDividends();
name
으로 Company(기업)을 조회하고 조회한 Company 객체 내의 dividends에 접근해보면 Hibernate의 SQL 로그로 다음과 같이 출력된다.
Hibernate: select company0_.id as id1_0_, company0_.name as name2_0_, company0_.ticker as ticker3_0_ from company company0_ where company0_.name=?
Hibernate: select dividends0_.company_id as company_4_1_0_, dividends0_.id as id1_1_0_, dividends0_.id as id1_1_1_, dividends0_.company_id as company_4_1_1_, dividends0_.date as date2_1_1_, dividends0_.dividend as dividend3_1_1_ from dividend dividends0_ where dividends0_.company_id=?
이렇게 기업을 조회할 때의 쿼리와 배당금을 조회할 때의 쿼리가 나누어지는 현상을 JPA N + 1이라고 한다. 다시말하면 하나의 엔티티를 조회하는 쿼리 개수만큼 추가로 쿼리가 생기는 현상이다.
그리고 dividends의 FetchType을 EAGER
변경한다면 위와 같이 company.getDividends()
으로 배당금 정보에 접근하지 않더라도 기업을 조회할 때 같이 배당금 정보를 조회한다.
Join Fetch
JPA N + 1을 해결하기위해 Fetch Join을 적용할 수 있다.
JPQL
다음과 같이 @Query
어노테이션을 사용하여 JOIN FETCH
문을 적용하면 된다. 물론 현재 예시는 연관관계가 간단하기 때문에 이렇게 쉽게 적용할 수 있는 것이다.
public interface CompanyRepository extends JpaRepository<Company, Long> {
@Query("SELECT c FROM COMPANY c JOIN FETCH c.dividends")
Optional<Company> findByName(String name);
}
출력된 쿼리는 다음과 같이 하나로 합쳐져서 나오는 것을 확인할 수 있다.
Hibernate: select company0_.id as id1_0_0_, dividends1_.id as id1_1_1_, company0_.name as name2_0_0_, company0_.ticker as ticker3_0_0_, dividends1_.company_id as company_4_1_1_, dividends1_.date as date2_1_1_, dividends1_.dividend as dividend3_1_1_, dividends1_.company_id as company_4_1_0__, dividends1_.id as id1_1_0__ from company company0_ inner join dividend dividends1_ on company0_.id=dividends1_.company_id
데이터 중복 문제
위와 같이 특정 데이터를 조회할 경우에는 한 개의 객체만 가져오지만 여러 개를 조회할 경우 데이터 중복 문제가 발생할 수 있다. 예를 들어서 다음과 같이 findAll
을 정의해보자.
@Query("select c from COMPANY c left join fetch c.dividends")
List<Company> findAll();
실행한 결과는 다음과 같이 dividends의 개수만큼 객체가 반환된 것을 확인할 수 있다.
이렇게 중복된 객체가 발생할 경우 SQL에서처럼 DISTINCT
를 적용해주면 된다.
@Query("select distinct c from COMPANY c left join fetch c.dividends")
List<Company> findAll();
EntityGraph
또 다른 방법으로 다음과 같이 @EntityGraph
를 사용하는 것이다. 간단하게 attributePaths에 join fetch할 대상을 지정하면 적용할 수 있다.
public interface CompanyRepository extends JpaRepository<Company, Long> {
@EntityGraph(attributePaths = "dividends")
Optional<Company> findByName(String name);
}
그런데 쿼리 출력 결과를 확인해보면 left outer join
이 수행된 것을 볼 수 있다.
Hibernate: select company0_.id as id1_0_0_, dividends1_.id as id1_1_1_, company0_.name as name2_0_0_, company0_.ticker as ticker3_0_0_, dividends1_.company_id as company_4_1_1_, dividends1_.date as date2_1_1_, dividends1_.dividend as dividend3_1_1_, dividends1_.company_id as company_4_1_0__, dividends1_.id as id1_1_0__ from company company0_ left outer join dividend dividends1_ on company0_.id=dividends1_.company_id where company0_.name=?
즉, join fetch할 대상이 DB에 존재하지 않더라도 조회가 된다는 것이다.
NOTE
EntityGraph를 사용했을 때는 데이터 중복 현상이 일어나지 않았다.
EntityGraphType
@EntityGraph
의 type 옵션에는 EntityGraphType을 지정해줄 수 있다. default로 FETCH
type이 적용되어있는데 다른 type으로 LOAD
가 존재한다. 이 두 가지 type의 차이는 다음과 같다.
FETCH | LOAD |
---|---|
EntityGraph에 지정된 모든 엔티티를 EAGER로 처리하고 지정되지 않은 엔티티는 LAZY로 처리 | EntityGraph에 지정된 모든 엔티티를 EAGER로 처리하고 지정되지 않은 엔티티는 설정한 FetchType대로 처리 |
보통 모든 엔티티들을 LAZY로 처리하겠지만 특수한 경우 성능 개선을 위해 EAGER로 처리한 엔티티도 존재할 것이다. 따라서 EntityGraphType을 LOAD로 설정하여 지정한 FetchType으로 처리하도록하는 것이 좋은 선택이라고 생각한다.
JPQL vs EntityGraph
실제로 배당금 정보가 존재하지 않는 기업을 임의로 추가하고 테스트를 해봤다. 기대하고 있는 조회 결과는 다음과 같아야할 것이다.
{
"company": {
"ticker": "GME",
"name": "GameStop"
},
"dividends": []
}
하지만 JPQL로 JOIN FETCH
결과는 Optional.empty()
가 반환된다. 왜냐하면 inner join은 두 테이블 중 하나라도 없으면 join을 수행하지 않기때문이다. 반면에 EntityGraph를 사용한 결과는 LEFT OUTER JOIN
을 적용하기 때문에 위와 같이 dividends가 비어있더라도 출력하게 된다. 하지만 EntityGraph는 LEFT JOIN만 지원한다는 단점을 가지고 있다. 만약 JPQL로 EntityGraph처럼 LEFT JOIN을 적용하려면 LEFT JOIN FETCH
으로 수정하면 된다.
연관관계가 복잡한 경우
위와 같이 연관관계가 이루어져있을 경우 Article을 조회하면 Article을 작성한 작성자의 정보(UserAccount)와 파일 정보(ArticleFile)도 필요하여 같이 조회하게 될 것이다. 추가로 작성자가 기업일 경우 기업 정보(Company)도 조회해야한다.
실제로 DB에 Article이 9개가 존재하고 작성자는 2명이 존재할때 이를 모두 조회(findAll)을 해보면 다음과 같은 쿼리가 출력된다.
SELECT ~ FROM article
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM user_account
SELECT ~ FROM user_account
ArticleFile, UserAccount가 Article 개수만큼 쿼리가 생성된 모습이다. join fetch
를 적용하기에 앞서 SELECT문을 하나로 줄여보자.
default_batch_fetch_size
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
위와 같이 application 속성 값에서 default_batch_fetch_size
값을 지정해주면 다음과 같이 쿼리가 생성된다.
SELECT ~ FROM article
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM article_file
SELECT ~ FROM user_account
앗, 그런데 UserAccount만 한 개로 줄여지고 ArticleFile은 그대로이다. 그 이유는 Article과 ArticleFile이 OneToOne
이며 Article은 ArticleFile에 대한 foreign key를 가지고 있지 않기 때문이다. OneToOne
은 owner가 아닌 테이블을 조회하게되면 연관된 테이블도 같이 조회하는 EAGER
이 자동 적용된다. 왜냐하면 Article은 owner인 ArticleFile이 있어야 존재하는 것이기때문에 owner가 있는지 확인을 해야하기 때문이다.
IMPORTANT
OneToOne관계에서 owner가 아닌 테이블을 조회할시 EAGER이 적용된다.
JPQL
JPQL로 JOIN FETCH
를 적용하기 위해서 다음과 같이 작성하면 된다. 이때 Company의 정보는 UserAccount가 기업이 아닐 경우 존재하지 않기 때문에 LEFT JOIN
을 한다는 점을 유의한다.
@Query("select a from Article a join fetch a.articleFile join fetch a.userAccount u left join fetch u.company")
List<Article> findAll();
그런데 predicate
와 pageable
은 어떻게 사용해야할까...? JPQL로는 적용할 수 없어보인다.
EntityGraph와 NamedEntityGraph
먼저 엔티티에 NamedEntityGraph
를 정의해준다.
@NamedEntityGraph(
name = "Article.fetchIndex",
attributeNodes = {
@NamedAttributeNode(value = "userAccount", subgraph = "company"),
@NamedAttributeNode(value = "articleFile"),
},
subgraphs = {
@NamedSubgraph(name = "company", attributeNodes = @NamedAttributeNode("company")),
}
)
attributeNodes
에 UserAccount와 ArticleFile을 지정해주고 UserAccount의 Company는 subgraphs
에 지정해준다. 그리고 repository에 다음과 같이 정의한 NamedEntityGraph의 이름을 지정해주면 된다.
@EntityGraph(value = "Article.fetchIndex")
Page<Article> findAll(Predicate predicate, Pageable pageable);
이러면 predicate
와 pageable
을 모두 사용하면서 JOIN FETCH
까지 적용할 수 있다. 하지만 더 복잡하고 더 상세한 기능에서는 JPQL과 EntityGraph만으로 적용하기 힘들 수 있다. 그럴 경우 QueryDSL
등을 활용해야할 것 같다.