지연로딩이 필요한 이유와 프록시( proxy )

지연 로딩이란 자신과 연관된 엔티티를 실제로 사용할 때 연관된 엔티티를 조회( SELECCT )하는 것을 말합니다.

반대로 즉시 로딩이란 엔티티를 조회할 때 자신과 연관되는 엔티티를 조인( join )을 통해 함께 조회하는 방식을 말합니다.


즉시 로딩과 같이, 어떤 엔티티를 조회하는데 그 엔티티와 관련된 모든 엔티티들이 함께 조회 된다면 성능상의 문제가 발생할 것입니다.

예를들어 Category와 Book 엔티티가 관계를 맺고 있을 때, 책의 이름을 사용하는 상황( book.getTitle() )에서 사용하지 않는 Category 엔티티가 함께 조회 되는 상황이죠.

물론 카테고리 이름이 필요한 상황이 있을 수 있기 때문에 함께 조회 되는 방식( 즉시로딩 )도 좋긴 하지만, 항상 그렇지만은 않습니다.

그래서 카테고리 이름이 필요할 때( book.getCategory().getName() ), 그제서야 조회를 하는 지연로딩 방식이 필요합니다.


지연 로딩과 즉시 로딩은 어떻게 사용될 수 있을까요?

페이스북 " 댓글 더보기 "에서 즉시 로딩을 사용할 경우 " 댓글 더보기 "를 누르지 않았음에도 이미 댓글이 조회 돼버리기 때문에 성능이 느려질 수 있습니다.

이 때는 " 댓글 더보기 "를 클릭 했을 때 댓글 목록을 불러오도록 하는 지연 로딩 방식이 좋을 것입니다.

반면 쇼핑몰에서는 장바구니에서 유저와 상품을 같이 보여줘야 하므로 즉시 로딩 방식을 사용하면 좋겠죠.


따라서 언제 어떤 방식의 fetch 전략을 사용할 것인지가 중요한 이슈가 되겠습니다.



지연로딩을 하기 위해서는 프록시라는 것이 필요합니다.

프록시란 실제 엔티티 객체 대신에 사용되는 객체로서 실제 엔티티 클래스와 상속 관계 및 위임 관계에 있습니다.

프록시 객체는 실제 엔티티 클래스를 상속 받아서 만들어지므로 실제 엔티티와 겉모습이 같습니다.





프록시 사용하기

public static void proxy1(EntityManager em) {
	// Entity proxy 객체 반환
	// Book 엔티티를 상속받은 Book Proxy를 리턴
	Book book = em.getReference(Book.class, 1);
	
	// System.out.println(book.getTitle());
}

프록시를 얻으려면 getReference() 메서드를 호출하면 됩니다.

그런데 위의 코드에서 프록시 객체를 얻기는 했지만 콘솔창에는 SELECT 쿼리를 수행하지 않습니다.

프록시는 지연로딩과 관련된 것임을 생각해보면, 아직 book 객체에 대해 아무런 동작을 하지 않았기 때문입니다.


그러나 System.out.println(book.getTitle()); 주석을 풀고 실행해보면 SELECT 쿼리가 수행됩니다.

즉 프록시를 사용하면 엔티티가 사용될 때 까지 조회하지 않고 있다가 필요할 때 조회하는 방식이라는 것을 알 수 있습니다.


getReference() 메서드를 호출하면 실제 엔티티를 상속받은 객체를 반환합니다.

그래서 실제 엔티티와 겉모습이 같기 때문에 실제 엔티티를 사용하는 것처럼 느낄 수 있습니다.

그러나 실제 엔티티가 아니기 때문에 엔티티의 정보가 필요하면, 실제 엔티티에 접근해서 데이터를 가져옵니다.

물론 프록시 객체는 실제 엔티티를 참조하고 있겠죠.


그러면 지연 로딩 방식에서 언제 DB에 접근해서 엔티티를 가져올까요?

이와 관련된 개념이 프록시 초기화입니다.





프록시 초기화

프록시 초기화란 프록시 객체가 참조하는 실제 엔티티가 persistence context에 생성되어 있지 않을 때, persistence context에 실제 엔티티 생성을 요청하고 생성된 실제 엔티티를 프록시 객체의 참조 변수에 할당하는 과정을 말합니다 .


getReference() 메서드를 호출하면 Book 엔티티의 프록시를 반환합니다.

아직 이 상태에서는 persistence context에 엔티티가 없습니다.

프록시는 지연로딩 방식이기 때문이죠.


다음으로 책 제목 데이터를 얻기 위해 book.getTitle() 메서드를 호출합니다.

프록시 객체에는 실제 엔티티를 참조하는 변수( target )가 있지만 persistence context에 Book 엔티티가 없기 때문에 null을 가르키고 있을 것입니다.

getTitle() 메서드를 실행되려면 엔티티 정보가 필요하므로 DB에 접근해서 book 엔티티를 조회합니다. ( 초기화 요청 )


DB 조회 결과 Book 엔티티가 persistence context에 저장되면 프록시는 방금 조회된 엔티티를 참조하게 됩니다.

그래서 원래 하려고 했던 동작인 getTitle()을 수행하여 책 제목을 반환합니다.



그러면 이미 persistence context에 엔티티가 있다면 어떻게 될까요?

이 경우에는 지연 로딩의 의미가 없겠죠.

이미 persistence context에 엔티티가 있기 때문입니다.

그래서 getReference() 메서드를 호출할 경우 실제 엔티티를 반환합니다.





즉시로딩과 지연로딩 비교

[ 즉시 로딩 ]

@Entity
@Table(name="book")
public class Book{
	@Id
	@Column(name="no")
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	private Integer no;
	
	@Column( name="name", nullable=false, length=100 )
	private String title;
	
	@ManyToOne(fetch = FetchType.EAGER)
	@JoinColumn(name="category_no")
	private Category category;
}


public static void eager(EntityManager em) {
	Book book = em.getReference(Book.class, 1);
	Category category = book.getCategory();
			
	System.out.println(book.getTitle());
	System.out.println(category.getName());
}

Book과 Category 엔티티가 관계를 맺고 있을 때 즉시로딩하는 방식입니다.

@ManyToOne 어노테이션의 속성으로 fetch 값이 FetchType.EAGER이면 즉시 로딩 방식을 의미합니다.



그래서 Book 엔티티를 조회하는 find() 메서드를 호출할 때 조인( join )이 일어나며, 그 때 Category도 조회하는 것을 확인할 수 있습니다.

즉시 로딩에서 Hibernate는 SELECT 쿼리를 2번 실행하는 것보다 성능 최적화를 위해 join을 수행합니다.


또 눈 여겨 볼 점은 outer join을 수행한다는 점입니다.

outer join을 사용한 이유는 외래키가 null이 허용되기 때문입니다.


@JoinColumn(name="category_no")

@JoinColumn 어노테이션에서 nullable 속성을 추가할 수 있는데, 기본 값으로 true를 갖습니다.

따라서 @JoinColumn(name="category_no", nullable=false로 수정하면 즉시 로딩 할 때 outer join이 아닌 inner join을 수행합니다.

outer join보다 inner join이 성능이 더 좋다는 것을 감안하여 외래키의 null 허용 여부를 결정해야 합니다.





[ 지연 로딩 ]

@ManyToOne(fetch = FetchType.LAZY)


Book 클래스에서 @ManyToOne 어노테이션을 위와 같이 수정하기만 하면 지연 로딩 방식이 됩니다.

이 경우, 출력 되는 쿼리는 다음과 같습니다.



먼저 Book 엔티티를 조회할 때 SELECT 쿼리가 수행되고 book.getTitle() 메서드가 호출되어 책의 제목( " operating system ")이 출력됩니다.

그리고 나서 category.getName() 메서드가 호출 될 때 persistence context에 Category 엔티티가 없으므로 그제서야 DB에 접근하여 Category에 대한 조회가 일어납니다.

즉, join이 발생하지 않고 SELECT 쿼리가 2번 수행되는 것을 확인할 수 있습니다.





Fetch 기본 전략

@ManyToOne , @OneToOne 어노테이션의 경우 FetchType.EAGER ( 즉시로딩 )을 기본 전략으로 사용하며,

@OneToMany, @ManyToMany 어노테이션의 경우 FetchType.Lazy ( 지연로딩 )을 기본 전략으로 사용합니다.





이상으로 프록시와 지연로딩에 대해 알아보았습니다.

즉시 로딩은 어떤 엔티티를 조회 했을 때 그 엔티티와 연관된 엔티티가 join이 일어나서 같이 조회 되는 것이고,

지연 로딩은 엔티티가 실제로 사용되는 시점까지 기다리다가 그제서야 엔티티 조회되는 것을 의미합니다.


그렇다면 언제 어떤 방식을 사용해야 할까요?

성능 면에서 지연 로딩을 사용하는 것이 더 좋기 때문에 애플리케이션 개발 시 모두 지연 로딩으로 한 뒤에,

성능 최적화가 필요한 부분을 즉시 로딩으로 바꿔주면 좋을 것입니다.


댓글 펼치기 👇
  1. Favicon of https://happy-coding-day.tistory.com like_len 2020.09.28 10:50 신고

    글 감사히 읽었습니다.