[하이버네이트 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 문제가 무엇이며 각 해결 방법을 살펴봤다. 끝!


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

이 방법도 역시 간단하다. 지난번까지 사용했었던 배치 패칭 설정은 지우고 다음과 같이 설정한다.

@OneToMany(mappedBy=”owner”, fetch=FetchType.EAGER)
@BatchSize(size=30)
private Set items;

이렇게 설정하면 Member 클래스를 Criteria나 Session.get(), load()로 읽어올 때 C도 같이 로딩해온다. 그래서 eager다. Lazy Collection Fetching과 정반대다.

주의 할 것이 있는데, 이 설정은 HQL에선 먹히지 않는다. 확인해보자.

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

List<Member> memberList = getSession().createCriteria(Member.class).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]

이렇게 Criteria를 사용하면 다음과 같이 Member를 가져올때 left outer join으로 Member가 가지고 있는 모든 Item을 다 가져온다.

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

List<Member> memberList = getSession().createQuery("from Member").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을 사용하면 N+1 Select 문제가 여전히 발생한다.


이런 경우를 대비해 @BatchSize를 다시 붙여 놓는게 좋을 거 같기는 하다.

[java]
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
@BatchSize(size=30)
private Set<Item> items;
[/java]
이렇게 배치 패칭을 설정해 두면 그래도 완벽하진 않지만 HQL을 사용하는 경우를 대비해서, 어느정도 성능을 개선할 수는 있다.

자 이렇게 Eager 패칭을 사용하는 방법을 알아봤지만, 이 방법에는 큰 문제가 하나 있다. 매핑 정보에서 패칭 전략을 Eager로 설정했기 때문에 앞으로 Session.get(), load()로 어떤 Member 객체 하나를 읽어간다고 하도 해당 Member의 List까지 매번 같이 가져가게 된다. 매번 필요한 정보라면 그렇게 하는게 맞겠지만 필요없는 경우라면 오히려 불필요한 메모리 소비와 성능만 소비하게 되는 꼴이다.

하지만 분명히 이 방법이 유용한 경우도 있겠다.


[하이버네이트 N+1 Select] HQL Aggregation으로 해결하기

HQL이나 JPQL을 사용하면 SQL 못지않게 여러 잡다한 기능을 활용할 수 있는데, 그런 잡다한 기능 줄 일부를 이용해서 N+1 Select 발생을 원천봉쇄할 수 있는 경우도 있습니다.

지금 해결하려는 N+1 Select 문제가 바로 그런 경우중 하나인데, 하려는게 무엇인지 파악해보면 매우 단순합니다. 그냥 item을 제일 많이 가지고 있는 사용자가 누구고 몇개를 가지고 있는지 궁금한 겁니다.

이전에는 루프를 돌면서 일일히 컬렉션 사이즈를 확인했지만… HQL을 이용하면 다음과 같이 할 수 있습니다.

[java]
@Test
public void memberItemWithBatchPatching(){
int mostItemCount = 0;
mostItemCount = (Integer)getSession().createQuery("select max(m.items.size) from Member m").uniqueResult();
System.out.println(mostItemCount);
}
[/java]

이런 쿼리를 작성할 땐 인텔리J의 HQL 콘솔을 사용해서 미리 실행해보면서 만들면 편하겠죠.

실행 결과, 실행 시간, 실제 DB로 날아갈 SQL 등을 참조할 수 있고 이밖에도 Persistence 탭에서 ERD, 매핑 정보 참조 등 여러 기능을 제공하고 있습니다.


[하이버네이트 N+1 Select] Batch Patching으로 해결하기

간단하다. Member -> C 연관 관계 위에다가 @BatchSize라는 애노테이션을 붙여주면 된다. 애노테이션의 값으로는 해당 컬렉션 몇 개를 동시에 읽어올지 설정하면 된다.

[java]

@OneToMany(mappedBy = "owner")
@BatchSize(size=30)
private Set<Item> items;

[/java]

이렇게 설정해 두면 Member를 순회하면서 Item 컬렉션에 접근할 때 개별적으로 Item 컬렉션을 select 하는게 아니라 첫번째 Member의 Item 컬렉션에 접근할 때 미리 배치 사이즈 만큼의 컬렉션을 같이 가져오게 된다. 만약에 배치 사이즈를 30으로 했다면 n/30+1 로 문제 크기가 줄어든다. 실제로는 그렇게 간단한 로직은 아니였던 것 같은 기억이 난다. 하이버네이트 책에 보면 잘 설명되어 있지만 대충 저정도 쿼리가 발생하니까 그냥 저렇게 알고 있어도 될 것 같다.

이렇게 말이다. 테스트 데이타로 member 객체 100명을 만들어 놨으니 이전 같으면 100 + 1 = 101 번의 select 가 날아갔을 터인데.. 이번에는 4 + 1 번밖에 안날아 갔다.

하지만 이 방법은 필요없는 컬렉션까지도 미리 패치해오는 경우가 발생할 수 있으니 최선책은 아니라고 볼 수 있다. 물론 위와 같이 모든 Member 객체를 순회하며 모든 컬렉션에 접근하는 경우라면 이렇게 사용해도 괜찮겠지만 말이다… 그래서 이 방법 말고도 다른 해결책이 또 있다.

그건 다음에~


[하이버네이트 N+1 Select] 문제 상황 재현

하이버네이트 매핑 기본값은 Collection을 Lazy Fetching하게 되어 있다. 이걸 Lazy Collection Fetching이라고 하는데 일반적인 경우에는 매우 유용한 방법이다.

A -> C

A라는 클래스에서 B 타입의 Collection을 가지고 있다는 것을 이렇게 표기하겠다.

Member -> C Member -> C Member -> C

Member가 여러 컬렉션을 가지고 있다고 할 때 만약 하이버네이트나 다른 ORM 기술을 사용해 Member 객체를 읽어올 때 Member가 연관 관계를 맺고 있는 모든 컬렉션까지 전부 다 가져온다고 생각해보자.

메모리와 실행 시간이 엄청 오래 걸릴 것이다. 잘하면(?) 데이터베이스에 들어있는 모든 데이터를 한방에 메모리로 올리는 상황도 연출될 수 있다.

그래서 Lazy Collection Fetching같은게 필요하다. 딱 Member 객체만 가져오고 C, C, C 같은건 실제 해당 컬렉션에 접근할 때 쿼리가 발생한다.

문제 상황을 연출해봤다.

[java]
public void generateTestData() {
List<Integer> idList = new ArrayList<Integer>();

for (int i = 0; i < 100; i++) {
Member member = new Member();
member.setName(i + "keesun");
member.setEmail("keesun" + i + "@mail.com");
getSession().save(member);
idList.add(member.getId());
}

for (int j = 0; j < 10000; j++) {
Item item = new Item();
item.setName(j + "items");

int randomId = (int) (Math.random() * 100);
Member owner = (Member) getSession().load(Member.class, idList.get(randomId));

item.setOwner(owner);
getSession().save(item);
}
}
[/java]

랜덤으로 Member 객체 100개와 Item 객체 10000개를 조합했다.

이제 Item을 가장 많이 가지고 있는 Member 정보와 해당 Item의 갯수를 알고 싶다고 해보자.

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

List<Member> memberList = getSession().createCriteria(Member.class).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]

이때 발생한 쿼리는 다음과 같다.

Criteria를 사용한 아주 간단한 쿼리로 모든 Member 객체를 가져온 다음 Memebr를 순회하며 각 Member의 C 크기를 비교하고 있다. 첫번째 쿼리는 Member를 가져올 때 발생하고 그 다음 N번의 쿼리는 member.getItems()를 호출할 때 발생하게 된다. 이 방법은 select 문이 너무 많이 발생하기 때문에 DB에 부하를 줄 수 있어 보통 N+1 Select Problem이라고 한다.

보통 하이버네이트를 사용했을 때 뭔가 무척 느리다고 느껴진다면 이런 현상이 발생하는 부분이 없는지 확인해봐야 한다. 그리고 해결책을 적용하면 된다. 하이버네이트는 이미 N+1 Select 문제를 해결할 수 있는 여러가지 방법을 제공한다. 앞으로 그것들을 하나씩 살펴볼 계획이다.