[하이버네이트] 빌드 타임 인스트루먼트로 OneToOne Lazy Fetching하기

어제 작성한 글에서 이어집니다. 그 해결책 세개 중에서 그나마 쓸만한 해결책은 이것이고, 그 효과도 똑같고, 그 단점도 똑같은 방법입니다.

@OneToOne 맵핑에 @LazyToOne(LazyToOneOption.NO_PROXY)를 추가합니다.

[java]
@Entity
public class Product {

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

private BigDecimal price;

private String name;

@OneToOne(fetch = FetchType.LAZY, optional = true, mappedBy = "product")
@LazyToOne(LazyToOneOption.NO_PROXY)
private ProductDetails productDetails;

@OneToOne(fetch = FetchType.LAZY, optional = true, mappedBy = "product")
@LazyToOne(LazyToOneOption.NO_PROXY)
private ProductInfo productInfo;

}
[/java]

이렇게요. 그리고 빌드 타임에 코드를 조작하도록 메이븐 플러그인을 설정합니다.
[xml]
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<executions>
<execution>
<phase>process-classes</phase>
<goals>
<goal>run</goal>
</goals>
</execution>
</executions>
<configuration>
<tasks>
<taskdef name="instrument"
classname="org.hibernate.tool.instrument.javassist.InstrumentTask">
<classpath>
<path refid="maven.runtime.classpath" />
<path refid="maven.plugin.classpath" />
</classpath>
</taskdef>
<instrument verbose="false">
<fileset dir="${project.build.outputDirectory}">
<include name="/usecase/snapshot/domain/*.class" />
</fileset>
</instrument>
</tasks>
</configuration>
</plugin>
[/xml]

여기서 include에 사용한 경로는 자신의 프로젝트에 맞게 잘 고쳐야겠죠.

그리고 빌드를 합니다. antrun의 run 골을 실행하는게 주요 목적이겠죠.

[java]
//Test
Product product = productRepository.findOne(1l);
assertThat(product, is(notNullValue()));
assertThat(product.getName(), is("keesun"));

System.out.println("========LAZY LOADING…=========");
product.getProductDetails().getDetails();
[/java]

이제 다시 테스트를 실행하면 다음과 같은 로그를 볼 수 있습니다.

18:51:54.224 [main] DEBUG org.hibernate.loader.Loader – loading entity: [usecase.snapshot.domain.Product#1]
18:51:54.226 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open PreparedStatement (open PreparedStatements: 0, globally: 0)
18:51:54.227 [main] DEBUG org.hibernate.SQL –
select
product0_.id as id1_0_,
product0_.name as name1_0_,
product0_.price as price1_0_
from
Product product0_
where
product0_.id=?
Hibernate:
select
product0_.id as id1_0_,
product0_.name as name1_0_,
product0_.price as price1_0_
from
Product product0_
where
product0_.id=?
18:51:54.231 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open ResultSet (open ResultSets: 0, globally: 0)
18:51:54.231 [main] DEBUG org.hibernate.loader.Loader – result row: EntityKey[usecase.snapshot.domain.Product#1]
18:51:54.238 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close ResultSet (open ResultSets: 1, globally: 1)
18:51:54.238 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close PreparedStatement (open PreparedStatements: 1, globally: 1)
18:51:54.239 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – resolving associations for [usecase.snapshot.domain.Product#1]
18:51:54.244 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – done materializing entity [usecase.snapshot.domain.Product#1]
18:51:54.245 [main] DEBUG o.h.e.StatefulPersistenceContext – initializing non-lazy collections
18:51:54.245 [main] DEBUG org.hibernate.loader.Loader – done entity load
========LAZY LOADING…=========
18:51:54.246 [main] DEBUG org.hibernate.loader.Loader – loading entity: [usecase.snapshot.domain.ProductDetails#1]
18:51:54.247 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open PreparedStatement (open PreparedStatements: 0, globally: 0)
18:51:54.247 [main] DEBUG org.hibernate.SQL –
    select
productdet0_.id as id2_0_,
productdet0_.details as details2_0_,
productdet0_.product_id as product3_2_0_
from
ProductDetails productdet0_
where
productdet0_.product_id=?

Hibernate:
select
productdet0_.id as id2_0_,
productdet0_.details as details2_0_,
productdet0_.product_id as product3_2_0_
from
ProductDetails productdet0_
where
productdet0_.product_id=?
18:51:54.249 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open ResultSet (open ResultSets: 0, globally: 0)
18:51:54.250 [main] DEBUG org.hibernate.loader.Loader – result row: EntityKey[usecase.snapshot.domain.ProductDetails#1]
18:51:54.251 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close ResultSet (open ResultSets: 1, globally: 1)
18:51:54.251 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close PreparedStatement (open PreparedStatements: 1, globally: 1)
18:51:54.251 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – resolving associations for [usecase.snapshot.domain.ProductDetails#1]
18:51:54.252 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – done materializing entity [usecase.snapshot.domain.ProductDetails#1]
18:51:54.253 [main] DEBUG o.h.e.StatefulPersistenceContext – initializing non-lazy collections
18:51:54.253 [main] DEBUG org.hibernate.loader.Loader – done entity load
18:51:54.253 [main] DEBUG org.hibernate.loader.Loader – loading entity: [usecase.snapshot.domain.ProductInfo#1]
18:51:54.254 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open PreparedStatement (open PreparedStatements: 0, globally: 0)
18:51:54.254 [main] DEBUG org.hibernate.SQL –
    select
productinf0_.id as id0_0_,
productinf0_.info as info0_0_,
productinf0_.product_id as product3_0_0_
from
ProductInfo productinf0_
where
productinf0_.product_id=?

Hibernate:
select
productinf0_.id as id0_0_,
productinf0_.info as info0_0_,
productinf0_.product_id as product3_0_0_
from
ProductInfo productinf0_
where
productinf0_.product_id=?
18:51:54.256 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open ResultSet (open ResultSets: 0, globally: 0)
18:51:54.256 [main] DEBUG org.hibernate.loader.Loader – result row: EntityKey[usecase.snapshot.domain.ProductInfo#1]

ㅎㅎ충격적이지 않나요? 일단 Lazy Fetching이 되긴 했습니다. 그런데 문제는 OneToOne 관계이면서, 아직 로딩하지 않았던 다른 객체도 같이 가져왔네요? 이게 바로 제가 OneToOne Lazy Fetching을 파보게 된 계기입니다. ㅎㅎ;;

이것 때문에 동료분과 하이버네이트와 javassist 소스 코드를 까보았는데… 아주… 그냥.. 죽여주네요. OP코드도 필드 추가하고, 세터 게터 메서드 추가하고, 하이버 Session 가지고 initialize도 시키고.. @_@;;; 아오.. 이 방법 밖에 없는것인가? 필드를 개별적으로 Lazy Fetching 할 순 없는 것인가?….

[하이버네이트] OneToOne 연관 관계 Lazy Fetching이 안 먹어!?

OneToOne 관계를 맵핑 했을 때, Lazy Fetching이 제대로 적용되는 경우가 있고, 그렇지 않은 경우가 있다. 우선 제대로 동작하는 경우부터 볼까나…

Product 1 –> 1 ProductDetails

Product 1 –> 1 ProductInfo

이렇게 OneToOne 관계가 두 개 있다고 가정하고, 다음과 같이 맵핑했다.

[java]
@Entity
public class Product {

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

private BigDecimal price;

private String name;

@OneToOne(fetch = FetchType.LAZY)
private ProductDetails productDetails;

@OneToOne(fetch = FetchType.LAZY)
private ProductInfo productInfo;

//나머지 생략

}

@Entity
public class ProductDetails {

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

private String details;

}

@Entity
public class ProductInfo {

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

private String info;

}
[/java]

각 엔티티에 선언한 속성이 의미가 있는건 아니니까 주의깊게 살펴보지 마시고, 연관 관계를 유심히 살펴보는게 좋겠다. 단방향 관계다. 그리고 FetchMode.LAZY다.

Repository는 Spring-Data-JPA 1.0.1.RELEASE를 사용해서 만들었고, 테스트 코드는 다음과 같다.

[java]
DatabaseOperation.CLEAN_INSERT.execute(connection, dataSet);

//Test
Product product = productRepository.findOne(1l);
assertThat(product, is(notNullValue()));
assertThat(product.getName(), is("keesun"));

System.out.println("========LAZY LOADING…=========");
product.getProductDetails().getDetails();
[/java]

이보다 더 많은 테스트 코드가 있지만, 나머지는 DBUnit으로 데이터를 넣는 부분과 스프링 TestContext 설정이라서 생략했다. 테스트 데이터를 DBUnit으로 넣지 않고, JDBC로 넣어도 된다. JPA만 아니면 된다. 왜 그래야 하냐면… 흠… 갑자기 설명하기가 귀찮네;; 퀴즈로 남기자. 왜 그래야 할까요?

아무튼, 다시 돌아가서… 저 테스트를 실행했을 때 콘솔을 살펴보자.

Hibernate:
select
product0_.id as id0_0_,
product0_.name as name0_0_,
product0_.price as price0_0_,
product0_.productDetails_id as productD4_0_0_,
product0_.productInfo_id as productI5_0_0_
from
Product product0_
where
product0_.id=?
18:24:53.609 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open ResultSet (open ResultSets: 0, globally: 0)
18:24:53.609 [main] DEBUG org.hibernate.loader.Loader – result row: EntityKey[usecase.snapshot.domain.Product#1]
18:24:53.615 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close ResultSet (open ResultSets: 1, globally: 1)
18:24:53.616 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close PreparedStatement (open PreparedStatements: 1, globally: 1)
18:24:53.618 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – resolving associations for [usecase.snapshot.domain.Product#1]
18:24:53.621 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – done materializing entity [usecase.snapshot.domain.Product#1]
18:24:53.622 [main] DEBUG o.h.e.StatefulPersistenceContext – initializing non-lazy collections
18:24:53.623 [main] DEBUG org.hibernate.loader.Loader – done entity load
========LAZY LOADING…=========
18:24:53.623 [main] DEBUG org.hibernate.impl.SessionImpl – initializing proxy: [usecase.snapshot.domain.ProductDetails#1]
18:24:53.624 [main] DEBUG org.hibernate.loader.Loader – loading entity: [usecase.snapshot.domain.ProductDetails#1]
18:24:53.624 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open PreparedStatement (open PreparedStatements: 0, globally: 0)
18:24:53.625 [main] DEBUG org.hibernate.SQL –
select
productdet0_.id as id1_0_,
productdet0_.details as details1_0_
from
ProductDetails productdet0_
where
productdet0_.id=?

됐다. Lazy Loading이 되었다!! 그럼 이걸 이제 양방향 관계로 바꿔보면 어떻게 될까?

우선 맵핑을 다음과 같이 변경하자.

[java]
@Entity
public class Product {

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

private BigDecimal price;

private String name;

@OneToOne(fetch = FetchType.LAZY, <strong>mappedBy = "product"</strong>)
private ProductDetails productDetails;

@OneToOne(fetch = FetchType.LAZY, <strong>mappedBy = "product"</strong>)
private ProductInfo productInfo;

}

@Entity
public class ProductDetails {

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

private String details;

<strong>    @OneToOne(fetch = FetchType.LAZY)
private Product product;</strong>

}

@Entity
public class ProductInfo {

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

private String info;

<strong>@OneToOne(fetch = FetchType.LAZY)
private Product product;</strong>

}
[/java]

나는 사실 이 부분이 조금 의문이다. 왜 양방향이어야 할까… ProductInfo와 ProductDetails를 개별적으로 사용하게 될 일이 있을까? 그러다가 ProductInfo –> Product 방향으로 참조할 일이 있을까? 이런 의문을 가지기 시작하면 ProductInfo와 ProductDetails에 id가 있는 것도 의문이 간다. 이거 엔티티가 맞는건가? Value Object는 아닌가? 뭐 내가 모델링한것도 아니고 도메인 전문가도 아니기 때문에 그렇다치고 넘어가자.

이제 다시 테스트 해보자.

18:35:37.430 [main] DEBUG org.hibernate.SQL –
select
product0_.id as id0_0_,
product0_.name as name0_0_,
product0_.price as price0_0_
from
Product product0_
where
product0_.id=?
Hibernate:
select
product0_.id as id0_0_,
product0_.name as name0_0_,
product0_.price as price0_0_
from
Product product0_
where
product0_.id=?
18:35:37.433 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open ResultSet (open ResultSets: 0, globally: 0)
18:35:37.435 [main] DEBUG org.hibernate.loader.Loader – result row: EntityKey[usecase.snapshot.domain.Product#1]
18:35:37.441 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close ResultSet (open ResultSets: 1, globally: 1)
18:35:37.441 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close PreparedStatement (open PreparedStatements: 1, globally: 1)
18:35:37.443 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – resolving associations for [usecase.snapshot.domain.Product#1]
18:35:37.445 [main] DEBUG org.hibernate.loader.Loader – loading entity: [usecase.snapshot.domain.ProductDetails#1]
18:35:37.446 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open PreparedStatement (open PreparedStatements: 0, globally: 0)
18:35:37.446 [main] DEBUG org.hibernate.SQL –
select
productdet0_.id as id1_0_,
productdet0_.details as details1_0_,
productdet0_.product_id as product3_1_0_
from
ProductDetails productdet0_
where
productdet0_.product_id=?
Hibernate:
select
productdet0_.id as id1_0_,
productdet0_.details as details1_0_,
productdet0_.product_id as product3_1_0_
from
ProductDetails productdet0_
where
productdet0_.product_id=?
18:35:37.449 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open ResultSet (open ResultSets: 0, globally: 0)
18:35:37.450 [main] DEBUG org.hibernate.loader.Loader – result row: EntityKey[usecase.snapshot.domain.ProductDetails#1]
18:35:37.451 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close ResultSet (open ResultSets: 1, globally: 1)
18:35:37.451 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close PreparedStatement (open PreparedStatements: 1, globally: 1)
18:35:37.451 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – resolving associations for [usecase.snapshot.domain.ProductDetails#1]
18:35:37.453 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – done materializing entity [usecase.snapshot.domain.ProductDetails#1]
18:35:37.454 [main] DEBUG org.hibernate.loader.Loader – done entity load
18:35:37.455 [main] DEBUG org.hibernate.loader.Loader – loading entity: [usecase.snapshot.domain.ProductInfo#1]
18:35:37.455 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open PreparedStatement (open PreparedStatements: 0, globally: 0)
18:35:37.455 [main] DEBUG org.hibernate.SQL –
select
productinf0_.id as id2_0_,
productinf0_.info as info2_0_,
productinf0_.product_id as product3_2_0_
from
ProductInfo productinf0_
where
productinf0_.product_id=?
Hibernate:
select
productinf0_.id as id2_0_,
productinf0_.info as info2_0_,
productinf0_.product_id as product3_2_0_
from
ProductInfo productinf0_
where
productinf0_.product_id=?
18:35:37.460 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to open ResultSet (open ResultSets: 0, globally: 0)
18:35:37.460 [main] DEBUG org.hibernate.loader.Loader – result row: EntityKey[usecase.snapshot.domain.ProductInfo#1]
18:35:37.461 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close ResultSet (open ResultSets: 1, globally: 1)
18:35:37.461 [main] DEBUG org.hibernate.jdbc.AbstractBatcher – about to close PreparedStatement (open PreparedStatements: 1, globally: 1)
18:35:37.462 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – resolving associations for [usecase.snapshot.domain.ProductInfo#1]
18:35:37.462 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – done materializing entity [usecase.snapshot.domain.ProductInfo#1]
18:35:37.462 [main] DEBUG org.hibernate.loader.Loader – done entity load
18:35:37.462 [main] DEBUG org.hibernate.engine.TwoPhaseLoad – done materializing entity [usecase.snapshot.domain.Product#1]
18:35:37.463 [main] DEBUG o.h.e.StatefulPersistenceContext – initializing non-lazy collections
18:35:37.463 [main] DEBUG org.hibernate.loader.Loader – done entity load
========LAZY LOADING…=========
18:35:37.464 [main] DEBUG o.s.t.c.t.TransactionalTestExecutionListener – No method-level @Rollback override: using default rollback [true] for test context [[TestContext@2c84d9 testClass = SnapshotTest, testInstance = usecase.snapshot.SnapshotTest@c5c3ac, testMethod = di@SnapshotTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@1b16e52 testClass = SnapshotTest, locations = ‘{classpath:/applicationContext.xml}’, classes = ‘{}’, activeProfiles = ‘{}’, contextLoader = org.springframework.test.context.support.GenericXmlContextLoader@1c1ea29]]]
18:35:37.466 [main] DEBUG o.s.orm.jpa.JpaTransactionManager – Initiating transaction rollback
18:35:37.466 [main] DEBUG o.s.orm.jpa.JpaTransactionManager – Rolling back JPA transaction on EntityManager [org.hibernate.ejb.EntityManagerImpl@514f7f]

 

이게 뭐시얏. Lazy Fetching이 안 된다. 사실, 나도 왜 단방향일 때는 되고, 양방향일 때는 안 되는지 모르겠다.

http://community.jboss.org/wiki/SomeExplanationsOnLazyLoadingone-to-one

실마리가 될만한 글은 찾았지만… 단방향 일때와 양방향 일때의 생기는 테이블 스키마가 다른것과 뭔가 연관이 있지 않을까 싶다.

흠;; 생각해볼까? 단방향 일때는 프록시를 사용할 수 있다. 이때의 스키마는 Product 테이블에 ProductDetail_Id와 ProductInfo_Id 컬럼이 생긴다. 그리고 Product만 읽어올 때는 ProductDetail_Id와 ProductInfo_Id 컬럼의 값으로 Product.getProductDetail()과 Product.getProductInfo()의 프록시 객체로 채울 수 있겠다. 위 글에서는 optional 여부와 관계지어 설명하지만 optional 여부와는 관계 없다.

양방향일 때는 스키마가 좀 다르다. Product에 있던 연관 관계 컬럼은 사라지고, ProductDetail 테이블과 ProductInfo 테이블에 각각 product_id 컬럼이 생긴다. 이때 Product를 읽어올 때 productDetail과 productInfo 객체에 필요한 정보를 각각 DB에서 읽어온다. 첫번째 쿼리를 보면 이렇다.

select
product0_.id as id3_0_,
product0_.name as name3_0_,
product0_.price as price3_0_
from
Product product0_
where
product0_.id=?

 

이것만 날아간다는 것은… productInfo와 productDetail이 null인지 아닌지 Product 테이블만 보고서는 알 수 없다는게 된다. 그럼 결국 Product가 참조할 ProductInfo와 ProductDetail의 프록시 객체를 만들어야되는가 아닌가가 고민되게 된다. 실제로는 null일수도 있는데 무조건 프록시 객체를 만들어 넣어버리면 null이 안되니깐;; 그렇다고 optional 속성을 false로 해서 NOT NULL로 하면 Proxy 객체를 만들 수 있지 않을까? 확인해 봤지만.. optional 속성의 값은 결과에 아무런 영향을 주지 않았다;

어찌됐든… 이 문제를 우회하는 방법은 세가지 정도 된다.

  1. 빌드 타임 인스트루먼트
  2. FieldHandler
  3. @OneToMany로 변경

각각은 다음에 살펴보겠다.

하이버네이트, 패칭(fetching) 전략을 사용한 성능 최적화 가이드

하이버네이트 완벽 가이드(Java Persistence With Hibernate) 13장 2절 5단에 보시면 하이버네이트 패칭 전략을 사용한 성능 최적화 가이드가 나와있습니다. 읽어본지가 하두 오래되서 기억을 되새기며 짧게 요약합니다.

기본적으로 하이버네이트는 요청하지 않은 데이터는 가져오지 않는다.

=> 책에는 이렇게 적혀있지만, 그 당시와는 달리 요즘은 기본적으로 x2Many는 모두 lazy 패칭이고, x2One은 모두 eager 패칭

그러다보니 n+1 select 문제라는게 발생할 수 있다.

=> 그래서 eager 패칭, 배치 패칭, 서브 셀렉트 등을 사용해서 n+1 select에 대한 여러 해결책을 제공한다.

그렇다고, join을 사용해서 쿼리 수를 줄이면 다 해결 되느냐? 아니, 또 다른 문제가 생긴다. 바로, 카테시안 곱. 쿼리 수는 줄이겠지만, 가져오는 데이터가 너무 많다.

=> 그래서 균형을 잘 잡아야 돼. 글로벌 패칭 전략이 무엇인지 알아야 하고, 특정 쿼리에 적용되는 패칭 전략은 무엇인지 알아야 돼.

N+1 Select 문제

 

Item <-> Bid를 가지고 살펴보자.

List<Item> allItems = session.createQuery("from Item").list();
// List<Item> allItems = session.createCriteria(Item.class).list();
Map<Item, Bid> highestBids = new HashMap<Item, Bid>();
for (Item item : allItems) {
    Bid highestBid = null;
    for (Bid bid : item.getBids() ) { // Initialize the collection
         if (highestBid == null)
             highestBid = bid;
         if (bid.getAmount() > highestBid.getAmount())
             highestBid = bid;
    }
    highestBids.put(item, highestBid);
}

Criteria API를 사용하던, HQL을 사용하던 똑같이, Item 목록만 가져온다. 첫번째 줄에서 Item을 N개 가져오는 쿼리가 하나 날아갔다. 그 다음, 반복문 안에서 해당 Item과 연관 관계에 있는 Bid 목록을 가져오느라도 N개 만큼 쿼리가 날아간다. 그래서 N+1 select 문제라고 한다.

해결책1. 배치 패칭

<set name="bids"
     inverse="true"
     batch-size="10">
    <key column="ITEM_ID"/>
    <one-to-many class="Bid"/>
</set>

이렇게 배치 패칭을 사용하면, n+1 select에서 n+1/10 select로 쿼리 수를 줄일 수 있다. 이 방법은 Bid를 필요할 때 가져오는 방법을 계속 고수할 수 있으면서(Lazy Fetching)도, Bid 컬렉션을 하나 가져올 때 다른 Item의 Bid 컬렉션도 같이 몇 건 더 가져오는 방식으로 select 쿼리 수를 줄인다.

해결책2. Subselect 기반 선 패칭

<set name="bids"
     inverse="true"
     fetch="subselect">
    <key column="ITEM_ID"/>
    <one-to-many class="Bid"/>
</set>

이 방법은 select 쿼리 수를 n+1에서 2개로 줄이는 방법이다. 이 방법도 Bid를 필요할 때 가져오는 방법을 고수할 수 있으지만, 배치 패칭과의 차이가 있는데, 바로 첫번째 Bid 컬렉션을 가져올 때 나머지 모든 Item의 Bid 컬렉션도 다 같이 가져온다는 것이다.

해결책3. Eager 패칭 설정

<set name="bids"
     inverse="true"
     fetch="join">
    <key column="ITEM_ID"/>
    <one-to-many class="Bid"/>
</set>

이 방법은 select 수를 1번으로 줄이는 방법이다. Item 목록을 가져올 때 해당 Item의 Bid 목록도 반드시 필요한 경우라면 패칭 전략을 Eager 모드로 설정할 수 있다. 하지만 그런 경우가 드물 뿐더러, Cartesian product 문제가 발생하고, 메모리 소비가 크다는 문제가 있다.

해결책4. 필요한 쿼리만 Eager 패칭

List<Item> allItems =
    session.createQuery("from Item i left join fetch i.bids")
            .list();
List<Item> allItems =
    session.createCriteria(Item.class)
        .setFetchMode("bids", FetchMode.JOIN)
        .list();
// Iterate through the collections…

이 방법은 특정 쿼리에만 Eager 패칭을 적용한다. 사실 내용은 해결책3번과 같지만, 중요한 차이가 있는데, 해당 쿼리에만 적용된다는 것이다.

Cartesian product 문제

이 문제는 Eager 패치, 즉 FetchMode=Join, 즉 위에서 살펴봤던 해결책3과 4를 두 개 이상의 컬렉션에 적용했을 때 생기는 문제다.

Item <-> Bid와 Item <-> Image 이렇게 Item에 두 개의 컬렉션이 있다고 가정하고, 이 두 컬렉션에 모두 해결책3을 적용하면 이렇게 된다.

<class name="Item">
    …
    <set name="bids"
         inverse="true"
         fetch="join">
        <key column="ITEM_ID"/>
        <one-to-many class="Bid"/>
    </set>
    <set name="images"
         fetch="join">
        <key column="ITEM_ID"/>
        <composite-element class="Image">…
    </set>
</class>

이 상태에서 Item 목록을 가져오면 하이버네이트는 다음과 같은 쿼리를 보낸다.

select item.*, bid.*, image.*
  from ITEM item
    left outer join BID bid on item.ITEM_ID = bid.ITEM_ID
    left outer join ITEM_IMAGE image on item.ITEM_ID = image.ITEM_ID

만약에 Item이 3개 있고, 1번 Item에 Bid가 3개 Iamger가 2개, 2번 Item에는 Bid와 Image가 각각 1개식, 3번 Item에는 아무런 Bid나 Image가 없다고 가정했을 때 쿼리 결과 데이터수는 다음과 같다.

1 * 3 * 2

+ 1 * 1 * 1

+ 1 * 1 * 1

= 8

자, 그럼 숫자를 조금 더 키워서 Item이 1,000개, 각 Item에 Bid가 20개 그리고 Imager가 5개씩 있다면 어떻게 될까? 100,000건이 된다.

이게 문제다. 너무 크고, 결과 Result에 중복 데이터가 엄청 많다.

해결책. Subseleclt

커다른 쿼리 하나 보다는 오히려 작은 쿼리 셋으로 쪼개는 게 속도나 메모리 면에서 더 좋다. N+1 문제 해결책으로 살펴봤던, 해결책 2번과 같은 방식으로 풀 수 있다. 그러면 쿼리 수는 세번으로 늘어나지만, 데이터를 중복해서 읽어오지 않기 때문에 메모리를 덜 사용할 수 있고, 속도도 더 빠를 것이다.

최적화 스탭 바이 스탭

 

  1. 하이버네이트 쿼리를 로깅한다. hibernate.format_sql 과 hibernate.use_sql_comments를 사용해서 SQL문을 로깅하고 가독성을 높인다.
  2. 유즈케이스 별도 얼마나 많이 어떤 쿼리가 실행되는지 확인한다.
  3. 패치 모드를 변경 해본다.
    1. join을 사용한 SQL이 너무 복잡하고 느린 경우: join해서 가져오는 컬렉션이나 엔티티를 두번째 select(subselect)로 대체할 수는 없는지 고민해본다. hibernate.max_fetch_depth 설정을 1~5 사이로 유지한다.
    2. 너무 많은 select 쿼리: 특이한 상황에서 확실시 된다면, fetch=”join”을 사용해도 좋치만, 두 개 이상의 컬렉션에 적용하는 것은 Cartesian Product 문제가 있다는 걸 염두하자. 배치 패칭을 사용할 수도 있는데, 그 크기는 3~15 사이를 사용하자.

OSIV 패턴 사용시 SQL 누수 현상에 대한 대처 방안

OSIV 패턴을 안티 패턴으로 보는 시각도 있다. 이해한다. OSIV 필터나 인터셉터를 사용하면 개발할 때 편하지만, 뷰 랜더링 시점에 예측하지 못한 쿼리가 발생해서 시스템 성능에 영향을 줄 수 있다. 그래서 확인해봐야 하는데.. 문제는 테스트할 때 OSIV 필터 적용으로 발생하는 추가적인 SQL까지 잡아낼 수 있느냐가 관건이다.

결론부터 말하자면, 그렇게 새어나가는 SQL을 모두 잡을 수 있다.

쉽다. 서블릿 컨테이너에 무조건 올려서 해당 URL을 모두 브라우저 주소창에 입력하고, 매번 콘솔로 가서 찍힌 쿼리 목록을 수집하면 된다. 쉬운데 피곤하다. 그래서 자동화된 테스트가 필요하다.

토비의 스프링 3을 잘 읽은 사람이라면 손쉽게 이 불편함을 해결하면서 새어나가는 SQL을 잡아낼 수 있다.

지금은 업무 중이라 길게 못 쓰겠다. @_@;; Adios~

 

ps: 참 애매하다. 내가 업무 중에 알아낸 기술적인 내용을 블로깅 해도 되는 것일까 안되는 것일까. 잘 모르겠다. 업무 시간에 내가 알아낸 사실들은 전부 회사의 자산으로 봐야하기 때문에.. 내가 업무중에 알아낸 내용을 공개하면 처벌을 받을 수도 있겠다. 그런데.. 내가 업무중에 알아낸 내용은 내가 이 회사에 다니기 전부터 공부해 왔던 지식들이나 업무중이 아닐 때 머리속으로 생각한 내용 때문이기도 하다. 그리고.. 내가 업무 중일 때 알아낸 게 사실은 업무 중이 아닐 때 알아낸 것일 수도 있다. 스터디에 나가서 상담을 했다거나, 다른 회사에 다니는 사람과 전화 통화를 하다가 알아 냈다거나.. 그런거는 블로깅을 해도 되는 것일까?

모르겠다. 한가지 분명한건 회사가 알려준게 아니라 내가 알아낸 거다.

그러니까.. 내가 업무 중이 아닐 때 알아냈다고 우기면 그만 아닌가? 내 머릿속에 DRM이나 감시용 프로세스를 달아 놓은게 아니라면 어떻게 알겠어..

[하이버네이트 N+1 Select] Runtime Eager Fetching으로 해결하기

이전에 살펴본 방법은 Egaer Fetching 옵션을 매핑에 설정해두고 사용하는 방법이었는데, 그 방법 보다 좀 더 유연하게 해당 DAO 오퍼레이션을 수행할 때 FetchMode를 설정해서 사용하는 방법이 있다.

Criteria를 사용하는 경우

[java]
@Test
public void eagerFetchingByCriteria(){
int mostItemCount = 0;
Member mostItemOwner = null;

List<Member> memberList = getSession().createCriteria(Member.class).setFetchMode("items", FetchMode.JOIN).list();
for (Member thisMember : memberList) {
Set<Item> memberItems = thisMember.getItems();
if(mostItemCount < memberItems.size()) {
mostItemCount = memberItems.size();
mostItemOwner = thisMember;
}
}

System.out.println(mostItemCount);
System.out.println(mostItemOwner);
}

[/java]

쿼리는 똑같이 left outer join이 발생하며, FetchMode.EAGER는 deprecated 됐다. JOIN, SELECT, DEFAULT가 있는데 FetchMode.EAGER 대신 JOIN을 사용하면 되며, SELECT를 사용하며 FetchMode.LAZY와 같다. DEFAULT는 전역 설정을 따르는것 같지만 확인하진 않았다.

HQL을 사용하는 경우
[java]
@Test
public void eagerFetchingByHQL(){
int mostItemCount = 0;
Member mostItemOwner = null;

List<Member> memberList = getSession().createQuery("from Member m left join fetch m.items").list();
for (Member thisMember : memberList) {
Set<Item> memberItems = thisMember.getItems();
if(mostItemCount < memberItems.size()) {
mostItemCount = memberItems.size();
mostItemOwner = thisMember;
}
}

System.out.println(mostItemCount);
System.out.println(mostItemOwner);
}
[/java]

HQL로 fetch join을 지원하지 때문에 위와 같이 left join fetch를 사용해서 Criteria를 사용할떄와 똑같은 쿼리를 만들어 낼 수 있다.

개인적으로 HQL과 Criteria 중에서는 Criteria를 선호한다. SQL을 싫어하는 이유는 패러다임을 떠나서 “문자열”이기 때문에 싫어하는데 HQL도 “문자열”이라서 되도록이면 Criteria를 사용한다. 그래야 에러가 덜 발생하며 관리하기도 편하다. 자동완성도 되고 얼마나 좋은가.

이것으로 N+1 Select 문제가 무엇이며 각 해결 방법을 살펴봤다. 끝!