referencedColumnName 을 사용한 Join 과 N+1 문제
JPA 를 사용할때 N+1 로 인한 성능 저하는 경계해야할 대표적인 문제중에 하나입니다.
N+1 이 발생하는 경우는 여러가지가 있지만 그 중에서 referncedColmnName 과 관련된 내용을 코드 분석과 함께 알아보고자 합니다.
N+1 재현 환경
Repo : https://github.com/SonUngBea/hibernate-project/tree/N%2B1/referencedName
ERD
DATA
Student | School |
Code
Student Entity
- NOTE : @ManyToOne 으로 Join 을 걸때 school 의 PK 인 id 가 아닌 name 으로 Join 을 걸었다.
@Entity
public class Student implements Serializable {
@Id
private Long id;
private String name;
private Long schoolId;
private String schoolName;
@ManyToOne
@JoinColumn(name = "schoolName", referencedColumnName = "name", insertable = false ,updatable = false)
private School school;
}
School Entity
@Entity
public class School implements Serializable {
@Id
private Long id;
private String name;
}
Load all student
List<Student> students = em.createQuery("select m from Student as m", Student.class).getResultList();
Result
불러온 Student 들의 School 을 채우기 위해서 Student 갯수만큼 School 을 조회하기 위한 Query 가 날라간 것을 확인할 수 있다.
Hibernate:
/* select
m
from
Student as m */ select
student0_.id as id1_1_,
student0_.name as name2_1_,
student0_.schoolName as schoolNa4_1_,
student0_.schoolId as schoolId3_1_
from
Student student0_
Hibernate:
/* load School */ select
school0_.id as id1_0_0_,
school0_.name as name2_0_0_
from
School school0_
where
school0_.name=?
Hibernate:
/* load School */ select
school0_.id as id1_0_0_,
school0_.name as name2_0_0_
from
School school0_
where
school0_.name=?
N+1 해결해보기
일반적으로 @ManyToOne N+1 문제를 해결하기 위하여 FetchType 을 LAZY 로 설정하여 School 을 참조할때 불러오도록한다.
@Entity
public class Student implements Serializable {
@Id
private Long id;
private String name;
private Long schoolId;
private String schoolName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "schoolName", referencedColumnName = "name", insertable = false ,updatable = false)
private School school;
}
위와 같이 ManyToOne 의 fetchType 을 LAZY 로 설정하여 실행해보았다.
Hibernate:
/* select
m
from
Student as m */ select
student0_.id as id1_1_,
student0_.name as name2_1_,
student0_.schoolName as schoolNa4_1_,
student0_.schoolId as schoolId3_1_
from
Student student0_
Hibernate:
/* load School */ select
school0_.id as id1_0_0_,
school0_.name as name2_0_0_
from
School school0_
where
school0_.name=?
Hibernate:
/* load School */ select
school0_.id as id1_0_0_,
school0_.name as name2_0_0_
from
School school0_
where
school0_.name=?
하지만 여전히 Student 갯수만큼 School 을 조회하는 쿼리가 날라가는 것을 확인할 수 있다.
fetchType 이 LAZY 임에도 불구하고 Student 의 멤버 변수인 school 에 접근하지 않았음에도 fetch 하는 이유가 무엇일까
LAZY 동작하지 않는 원인 파악
Hibernate 를 이용하여 Entity 를 조회하게되면 우리가 작성한 Entity 를 Parsing 하여 resolve 하는 단계를 거치게 된다. 이때 Parsing 된 Type 별로 각기 다른 resolve() 함수를 타게 되며 @ManyToOne 을 사용하여 Join 을 걸어 놓은 멤버변수는 EntityType 이 된다.
따라서 EntityType 클래스의 resolve() 함수에서 해당 멤버변수를 초기화 하게 된다.
public Object resolve(Object value, SessionImplementor session, Object owner) throws HibernateException {
if ( isNotEmbedded( session ) ) {
return value;
}
if ( value != null && !isNull( owner, session ) ) {
if ( isReferenceToPrimaryKey() ) {
return resolveIdentifier( (Serializable) value, session );
}
else if ( uniqueKeyPropertyName != null ) {
return loadByUniqueKey( getAssociatedEntityName(), uniqueKeyPropertyName, value, session );
}
}
return null;
}
해당 함수에 isRefernceToPrimaryKey() 에 따라서 resolveIdentifier() 를 호출할지 loadByUniqueKey() 를 호출할지 결정하는 조건문을 확인할 수 있다.
Student 엔티티에서 볼 수 있듯이 refernceColumnName 을 사용하여 School 테이블의 PK 가 아닌 Column 를 참조하도록 설정하였다.
그로 인해 isRefernceToPrimaryKey() 는 false 가 리턴되어 loadbyUniqueKey() 함수를 사용하여 School Entity 를 채우게 된다.
public Object loadByUniqueKey(
String entityName,
String uniqueKeyPropertyName,
Object key,
SessionImplementor session) throws HibernateException {
final SessionFactoryImplementor factory = session.getFactory();
UniqueKeyLoadable persister = ( UniqueKeyLoadable ) factory.getEntityPersister( entityName );
//TODO: implement caching?! proxies?!
EntityUniqueKey euk = new EntityUniqueKey(
entityName,
uniqueKeyPropertyName,
key,
getIdentifierOrUniqueKeyType( factory ),
persister.getEntityMode(),
session.getFactory()
);
final PersistenceContext persistenceContext = session.getPersistenceContext();
Object result = persistenceContext.getEntity( euk );
if ( result == null ) {
result = persister.loadByUniqueKey( uniqueKeyPropertyName, key, session );
}
return result == null ? null : persistenceContext.proxyFor( result );
}
loadbyUniqueKey() 에서는 EntityUniqueKey 클래스를 사용하여 영속성컨텍스트에서 해당 엔티티를 조회해보고 리턴값이 없다면 DB 조회를 하게 된다.
즉, loadbyUniqueKey() 함수 내에는 LAZY 처리를 위한 기능이 없기 때문에 LAZY 옵션을 주더라도 EAGER 로 동작하게 되는 것이다.
그렇다면 resolveIdentifier() 는 어떨까?
protected final Object resolveIdentifier(Serializable id, SessionImplementor session) throws HibernateException {
boolean isProxyUnwrapEnabled = unwrapProxy &&
getAssociatedEntityPersister( session.getFactory() )
.isInstrumented();
Object proxyOrEntity = session.internalLoad(
getAssociatedEntityName(),
id,
eager,
isNullable() && !isProxyUnwrapEnabled
);
if ( proxyOrEntity instanceof HibernateProxy ) {
( ( HibernateProxy ) proxyOrEntity ).getHibernateLazyInitializer()
.setUnwrap( isProxyUnwrapEnabled );
}
return proxyOrEntity;
}
resolveIdentifier() 내에는 internalLoad() 함수를 호출할때 eager 라는 파라미터를 받고 있으며 Lazy 로 동작해야할 경우 Proxy 가 리턴될 것을 암시하는 변수명을 확인할 수 있다.
resolveIdentifier() 를 호출할 경우에 LAZY 로딩이 동작하는 것을 확인해보기 위하여 Student Entity 의 School 조인을 schoolName 이 아닌 schoolId 로 수정하여 실행해보았다.
@Entity
public class Student implements Serializable {
@Id
private Long id;
private String name;
private Long schoolId;
private String schoolName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "schoolId", insertable = false ,updatable = false)
private School school;
}
예상한대로 resolveIdentifier() 함수를 타게 되었고 아래와 같이 LAZY 로딩이 정상적으로 동작하였다.
Hibernate:
/* select
m
from
Student as m */ select
student0_.id as id1_1_,
student0_.name as name2_1_,
student0_.schoolId as schoolId3_1_,
student0_.schoolName as schoolNa4_1_
from
Student student0_
PK 가 아닌 컬럼으로 조인한 경우에 Lazy fetch 가 동작하지 않는 이유
PK 가 아닌 컬럼으로 조인하였을때 LAZY fetch 가 동작하지 않는 이유가 궁금하여 찾아보았다.
Lazy Initialize 기능을 사용하려면 Hibernate Proxy 로 감싸진 객체를 Return 해야하는데 Proxy 는 Null 을 감쌀 수 없기 때문에 PK 가 아닌 컬럼으로 Join 이 걸려있으면 Null 이 아닐 가능성이 있으므로 LAZY 로 동작하지 않도록 하였다는 내용을 찾을 수 있다.
하지만 이와 관련된 코드를 찾지는 못하였다.
결론
N+1 문제를 해결하기 위하여 Lazy Fetch 를 적용한다고 하더라도 일부 케이스에서는 해결책이 될 수 없다.
추가정보1) Hiberate4 에서는 Student 의 School 이 동일하더라도 매번 School 을 찾기 위해 쿼리가 날라간 반면 Hibernate5 에서는 School 이 동일하다면 한번의 쿼리만 날라갔다.
Hibernate4
같은 School 이더라도 매번 조회 함
Hibernate:
/* select
m
from
Student as m */ select
student0_.id as id1_1_,
student0_.name as name2_1_,
student0_.schoolName as schoolNa4_1_,
student0_.schoolId as schoolId3_1_
from
Student student0_
Hibernate:
/* load School */ select
school0_.id as id1_0_0_,
school0_.name as name2_0_0_
from
School school0_
where
school0_.name=?
Hibernate:
/* load School */ select
school0_.id as id1_0_0_,
school0_.name as name2_0_0_
from
School school0_
where
school0_.name=?
Hibernate5
같은 School 은 한번 조회하고 다시 쿼리가 안날라감
Hibernate:
/* select
m
from
Student as m */ select
student0_.id as id1_1_,
student0_.name as name2_1_,
student0_.schoolName as schoolNa4_1_,
student0_.schoolId as schoolId3_1_
from
Student student0_
Hibernate:
/* load School */ select
school0_.id as id1_0_0_,
school0_.name as name2_0_0_
from
School school0_
where
school0_.name=?
이 차이는 앞서 살펴보았던 loadbyUniqueKey() 내부에서 영속성 Context 에 School 이 저장되느냐 안되느냐의 차이에서 발생한다.
final PersistenceContext persistenceContext = session.getPersistenceContext();
Object result = persistenceContext.getEntity( euk );
Hibernate4 의 경우에는 persistenceContext.getEntity( euk ); 했을때 이전에 조회한 Entity 가 Return 되지 않아 DB 에서 다시 조회하였고 Hiberante5 의 경우에는 이전에 조회한 Entity 가 Return 되어 영속성 컨텍스트에 저장되어있는 Entity 가 사용되어 쿼리가 다시 날라가지 않았다.
// If the entity was not in the Persistence Context, but was found now,
// add it to the Persistence Context
if (result != null) {
persistenceContext.addEntity(euk, result);
}
Hibernate5 부터 loadbyUniqueKey() 내부에서 불러온 Entity 를 영속성 컨텍스트에 저장하고 있기 때문에 발생하는 차이이다.
즉, 불러온 Student 의 School 이 모두 같을 경우 Hibernate4 의 경우에는 Student 갯수 만큼 School 조회 쿼리가 날라가고 Hibernate5 의 경우에는 School 조회 쿼리가 딱 한번만 날라간다. 하지만 School 의 종류가 다르다면 Hibernate5 에서도 School 의 종류 만큼 쿼리가 날라간다.