[JPA] Fetch - Eager, Lazy

2021. 12. 4. 23:23Spring/JPA

Fetch란?

Fetch의 사전적 의미는 '(어디를 가서) ~을 가지고 오다' 라는 뜻이다. JPA에서도 이 의미는 일맥상통한다. JPA에서 Fetch는 엔티티의 필드에 DB에서 실제 값을 가져오는 것이고 가져오는 방법에는 여러가지가 있다. 먼저 두 엔티티를 살펴보자.

@Entity
class Department {
	@Id
	@GeneratedValue
	private Long id;

	private String name;

	@OneToMany(mappedBy = "department")
	private Set<Employee> employees = new HashSet<>();
}
@Entity
class Employee {
	@Id
	@GeneratedValue
	private Long id;

	private String name;

	@ManyToOne
	@JoinColumn(name = "DEPARTMENT_ID")
	private Department department;
}

두 엔티티는 일대다, 다대일의 연관관계를 맺고 있다. 연관관계의 주인은 Employee이며 외래 키를 관리한다.
이 상황에서 Department를 조회한다고 하자. 그렇다면 발생할 쿼리는 다음과 같다.

Department dept = em.find(Department.class, 1);
SELECT d FROM Department d WHERE d.id = 1;

이때 가져온 Department에는 Set 컬렉션인 employees 필드가 존재한다. 그렇다면 JPA는 이를 어떻게 가져올까?

Eager

처음에 제안된 방법은 해당 Department와 연관된 모든 Employee를 가져오는 것이다. 이렇게 가져온다면 모든 연관된 모든 Employee가 영속성 컨텍스트에 등록되고 관리된다. 이 방식이 Eager이다. 뭔가 안좋은 냄새가 난다.

@Entity
class Department {
	@Id
	@GeneratedValue
	private Long id;

	private String name;

	@OneToMany(mappedBy = "department", fetch = FetchType.EAGER)
	private Set<Employee> employees = new HashSet<>();
}

만약 Department에서 employees를 쓰지 않고 name만 사용할 거라면? 필요없는 쿼리가 발생하고 수많은 Employee들은 메모리만 차지하게 된다. 그렇다면 이러한 문제를 어떻게 해결할까?

Lazy

지연 로딩(Lazy Loading)이라는 말을 들어보았는가? 지연 로딩은 자원이 실제로 쓰일 때까지는 요청하지 않고 있다가 실제로 필요할 때 요청하여 최대한 요청을 미루는 방식이다. 이렇게 설계하면 필요없는 데이터를 메모리에 넣고 있을 필요도 없고 쿼리 또한 적절한 시점에 발생한다. Lazy는 이같은 방식을 말한다. 그리고 JPA에선 이 방식을 프록시 패턴으로 설계되어 있다.

@Entity
class Department {
	@Id
	@GeneratedValue
	private Long id;

	private String name;

	@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
	private Set<Employee> employees = new HashSet<>();
}

처음에 Department를 조회할 때는 employees에 프록시 객체를 넣어둔다. 즉 가짜 객체를 넣어두는 것이다. 그리고 나중에 employees.size()와 같이 실제로 값에 접근할 때 쿼리를 발생하여 값을 가져온다.

기본값

JPA

  • ToMany - Lazy Loading
  • ToOne - Eager Loading

Hibernate

  • ToMany - Lazy Loading
  • ToOne - LazyLoading

살펴보면 JPA의 ToOne에 해당하는 Fetch 타입은 Eager이다. 그렇기 때문에 ToOne인 매핑은 가능한 전부 LAZY로 설정하는 편이 권장된다.
위 예제를 Lazy로 수정하여 다시 작성해보자.

@Entity
class Department {
	@Id
	@GeneratedValue
	private Long id;

	private String name;

	// ToMany인 경우 LAZY가 기본값
	@OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
	private Set<Employee> employees = new HashSet<>();
}
@Entity
class Employee {
	@Id
	@GeneratedValue
	private Long id;

	private String name;

	// ToOne인 경우 LAZY로 설정해야 한다
	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "DEPARTMENT_ID")
	private Department department;
}

Lazy로 설정하면 끝?

이 아니다. 아직 고려해야 할 문제가 남아 있다.

첫 번째 문제 - N+1

FetchType이 Lazy로 설정되었다면 프록시를 먼저 넣어놓은 뒤 실제로 요청할 때 값을 가져온다는 것은 이해했을 것이다. 그런데 값을 가져오는 과정에서 문제가 발생한다. Lazy 모드인데 ToMany인 경우 컬렉션을 가져와야 한다. 이때 만약 요소가 10개라면 각각의 요소를 SELECT 문으로 조회하게 된다. 이것이 문제가 된다. 실제 DB와 커넥션을 맺고 조회한 뒤 반환받고 종료하는 과정은 비용이 많이 발생한다. 그런데 가져올 요소가 100개라고 한다면 100번의 SELECT 문이 발생하는 것이다. 이렇게 나는 1개의 쿼리만 요청했는데 N(요소의 개수)만큼 쿼리가 추가로 발생하는 문제를 N+1 문제라고 한다. 이 문제는 Eager일 때도 똑같이 발생한다. 왜냐하면 JPA가 쿼리로 값을 가져오는 전략이 기본적으로 SELECT로 각 요소를 조회하는 것이기 때문이다. 이러한 문제는 JPQL이라는 객체 지향 쿼리 언어로 해결할 수 있는데 아래에서 간단히 설명한다.

두 번째 문제 - LazyInitializationException

프로그램이 기동되면 각 엔티티들이 초기화된다. 이때 Lazy 모드인 필드들은 위에서 배웠던 것처럼 프록시 객체가 들어가 있다. 여기서 잘 생각해보아야 할 점이 있다. 초기화 할 때는 EntityManager를 통해 프록시 객체를 넣어두었지만 초기화가 끝나면 그때는 EntityManager와의 연결이 끊어진 (준영속)상태이다. 즉 초기화한 상태에서 EntityManager를 통해 연결하지 않은 상태로 프록시를 건드리게 되면 프록시 객체는 진짜 값들을 가져올 수 없는 상태에 놓이고 이때 LazyInitializationException이 발생한다. 그렇다면 JPA가 그냥 연결해놓은 상태로 두면 되지 않느냐고 생각할 수도 있지만 그렇게 설계된다면 모든 매핑된 값들이 연결된 상태가 되어버린다. 다시 말하면 트랜잭션이 계속 유지되고 있어 DB의 동시성 성능에 치명적인 영향을 주고 메모리를 차지하게 되므로 매우 비효율적으로 동작하게 된다. 이 문제를 해결하려면 어떻게 해야 할까? 예제를 시작하기 전에 사용할 리포지토리 코드를 간단히 작성하도록 하겠다.

public class EmployeeRepository {

	public List<Employee> findAll() {
		EntitiManagerFactory emf = Persistence.createEntityManagerFactory();
		EntityManager em = emf.createEntityManager();
		Transaction tx = em.getTransaction();

		try {
			tx.begin();
			List<Employee> empls = em.createQuery("SELECT e FROM Employee e").getResultList();
			tx.commit();
			return empls;
		} catch (RuntimeException e) {
			tx.rollback();
		} finally {
			if (em != null) {
				em.close();
			}
			if (emf != null) {
				emf.close();
			}
		}
	}
}
public class DepartmentRepository {

	public List<Department> findAll(String jpql) {
		EntitiManagerFactory emf = Persistence.createEntityManagerFactory();
		EntityManager em = emf.createEntityManager();
		Transaction tx = em.getTransaction();

		try {
			tx.begin();
			List<Department> depts= em.createQuery(jpql).getResultList();
			tx.commit();
			return depts;
		} catch (RuntimeException e) {
			tx.rollback();
		} finally {
			if (em != null) {
				em.close();
			}
			if (emf != null) {
				emf.close();
			}
		}
	}
}

1. Eager

FetchType을 Eager로 설정하는 방법이다. 그러나 절대 이 방법으로 해결하면 안된다. Eager의 문제점을 해결하기 위해 Lazy를 사용했는데 이러한 문제가 생긴다고 다시 Eager를 사용하는 것은 이치상 맞지 않다.

2. @Transactional

이 방법은 스프링을 사용하는 경우 적용할 수 있는 방법이다. 메서드에 이 어노테이션을 적용하면 해당 메서드가 수행되는 동안은 세션이 계속 유지된다.

@Transactional
public void findAll() {

	List<Employee> employees = employeeRepository.findAll();

	employees.stream().forEach(empl -> System.out.println(empl.getId());
}

위와 같은 상황에서 @Transactional이 없는 경우 findAll()을 하는 동안에만 트랜잭션이 유지되고 작업이 끝나서 반환한 Set<Employee>는 영속 상태가 아니게 된다. 이때 접근하면 위 예외가 터지는 것이다. 하지만 @Transactional을 사용하면 해당 메서드의 시작부터 끝까지 트랜잭션이 유지되어 위 예외가 발생하지 않는다. 그리고 메서드 수행 중 다른 @Transactional을 만나면 기본 전략으로 트랜잭션이 합쳐진다. 이 방법이 가장 간편하지만 동작이 복합해질 경우 성능적으로 좋지 않다.

3. JPQL

JPQL(Java Persistence Query Language)은 엔티티를 대상으로 쿼리하는 객체 지향 쿼리 언어이다. 위에서 N+1 문제를 해결하는 방법으로도 설명했다. 이 방법이 가장 권장되는 방법이다. 위 문제를 해결하려면 JPQL의 JOIN FETCH를 사용하면 된다. 이를 페치 조인(Fetch Join)이라고 부른다. 이렇게 작성하면 Employee와 Department를 조인하여 하나의 쿼리로 관련된 employees를 한 번에 가져온다.

SELECT d FROM Department d LEFT JOIN FETCH d.employees
public void findAll() {

	String jpql = "SELECT d FROM Department d LEFT JOIN FETCH d.employees";
	List<Department> depts = departmentRepository.findAll(jpql);

	// 각 department는 모두 초기화되어있다.
	depts.stream().forEach(dept -> System.out.println(dept.getId());
}

JPQL에 대해서는 다른 포스트에서 자세히 다루도록 한다.

4. 엔티티 그래프

JPQL을 사용하면 문제를 효과적으로 해결할 수 있지만 비슷한 JPQL을 반복적으로 작성하게 되는 상황이 발생한다. JPQL이 데이터를 조회하는 기능과 연관된 엔티티를 함께 조회하는 기능도 제공하기 때문에 이러한 문제가 생긴다. 엔티티 그래프를 사용하면 JPQL은 일반적인 조회 쿼리만 작성하면 된다.

엔티티 그래프는 엔티티를 조회하는 시점에 연관된 엔티티를 함께 조회하는 기능이다. 그리고 가져와서 영속성 컨텍스트에 등록되어 있으면 다음부터는 엔티티 그래프는 적용되지 않는다.

/**
 * 잘 이해할 수 있는 이름을 name으로 세팅
 * 초기화할 속성을 attributeNodes에 @NamedAttributeNode로 선언
 **/
@NamedEntityGraph(
  name = "department-with-employees",
  attributeNodes = { @NamedAttributeNode("employee") }
)
@Entity
public class Department {

  // other fields...

  @OneToMany(mappedBy = "department")
  private Set<Department> employees = new HashSet<>();

  // other methods...
}

이렇게 작성한 것은 JPQL이나 EntityManager로 사용할 수 있다. 그리고 여러 개의 엔티티 그래프를 사용하고 싶으면 @NamedEntityGraphs를 사용하면 된다.

/* JPQL */

// 아까 지정한 name으로 EntityGraph 객체를 가져온다.
EntityGraph eg = entityManager.getEntityGraph("department-with-employees");

List<Department> dept = 
  entityManager.createQuery("SELECT d FROM Department d", Department.class)
               .setHint("javax.persistence.loadgraph", eg)
               .getResultList();
/* EntityManger.find() */

// 아까 지정한 name으로 EntityGraph 객체를 가져온다.
EntityGraph eg = entityManager.getEntityGraph("department-with-employees");

Map<String, Object> properties = new HashMap<>();
// properties.put("javax.persistence.fetchgraph", eg);
properties.put("javax.persistence.loadgraph", eg);

Department dept = entityManager.find(Department.class, id, properties);

fetchgraph와 loadgraph의 차이는 다음과 같다.

  • fetchgraph - 엔티티 그래프에서 선택한 속성만 조회한다.
  • loadgraph - 엔티티 그래프에서 선택한 속성과 FetchType이 Eager로 설정된 속성들도 함께 조회한다.

따라서 사용할 땐 loadgraph를 사용하는 편이 낫다.

 

참고로 엔티티 그래프는 항상 조회하는 엔티티에서 시작해야 한다. 예를 들어 Employee 엔티티를 조회하는데 Department부터 시작하는 엔티티 그래프를 사용하면 안된다.

초기화 확인

JPA와 하이버네이트는 컬렉션의 요소들이 초기화되었는지 확인할 수 있는 유틸리티를 제공한다.

JPA

PersistenceUnitUtil unitUtil = 
    em.getEntityManagerFactory().getPersistenceUnitUtil();

List<Department> depts = departmentRepository.findAll();

for (Department dept : depts) {
	log.info("{} : {}", 
    unitUtil.isLoaded(department),
    unitUtil.isLoaded(department, employee));
}

Hibernate

List<Department> depts = departmentRepository.findAll();

for (Department dept : depts) {
	log.info("{} : {}", 
    org.hibernate.Hibernate.isInitialized(department),
    org.hibernate.Hibernate.isInitialized(department.getEmployees()));
}