다룰 내용
이번 포스팅에서 다룰 내용은 아래와 같다.
- 프록시, 즉시로딩, 지연로딩
- 프록시를 사용하면 연관된 객체를 처음부터 DB에서 조회하지 않는다.
- 실제 사용하는 시점에 DB에서 조회할 수 있다.
하나씩 알아보자.
프록시
프록시가 필요한 이유
배경
- 특정 엔티티를 조회할 때, 이와 연관된 엔티티들이 항상 사용되는 것은 아니다.
- 예시) 회원 엔티티 조회 시, 이와 연관된 팀 엔티티는 비즈니스 로직에 따라 사용되지 않을 수 있다.
- 예시 코드를 통해 이 경우에 대해 자세히 알아보자.
예시 코드: 연관된 엔티티를 사용하지 않는 경우
-
회원 엔티티
@Entity public class Member { private String username; //연관 @ManyToOne private Team team; //getter, setter 생략 }
-
팀 엔티티
@Entity public class Team { private String name; //getter, setter 생략 }
-
비즈니스 로직 - 연관된 엔티티를 사용하는 로직
public void printUserAndTeam(String memberId) { Member member = em.find(Member.class, memberId); Team team = member.getTeam(); System.out.println("회원이름: " + member.getUsername()); //이때, 연관된 엔티티 team이 실제로 사용된다. System.out.println("팀이름: " + team.getName()); }
-
비즈니스 로직 - 연관된 엔티티를 사용하지 않는 로직
public void printUser(String memberId) { Member member = em.find(Member.class, memberId); System.out.println("회원이름: " + member.getUsername()); }
- 상세 설명
- 비즈니스 로직에 해당되는 메서드
printUserAndTeam
은 회원과 팀 이름을 출력한다. - 비즈니스 로직에 해당되는 메서드
printUser
은 오직 회원 이름을 출력한다. (연관된 엔티티 Team을 사용하지 않는다.) - 회원 엔티티만을 사용하는 경우,
em.find()
를 통해 회원과 연관된 팀 엔티티까지 DB에서 함께 조회하는 것은 비효율적이다.
- 비즈니스 로직에 해당되는 메서드
프록시와 지연 로딩
- 위 예시의 문제를 극복하기 위해, JPA는 지연 로딩이라는 기능을 제공한다.
- 지연 로딩: 엔티티가 실제로 사용될 때까지 DB조회를 지연하는 것
- 지연 로딩 예시)
team.getName()
를 호출했을 때, 그제서야 team 엔티티를 DB에서 조회한다.
- 프록시 객체를 통해, 지연 로딩을 사용할 수 있다.
- 프록시 객체: 실제 엔티티 객체 대신 DB 조회를 지연할 수 있는 가짜 객체
프록시 기초
EntityManager.find()
메서드
EntityManager.find()
메서드는 영속성 컨텍스트에 원하는 엔티티가 없으면, DB를 조회한다.- 이 메서드를 통해 엔티티를 직접 조회하면, 조회한 엔티티를 실제로 사용하든 사용하지 않든 DB를 조회한다.
EntityManager.getReference()
메서드
-
EntityManager.getReference()
메서드는EntityManager.find()
메서드와는 다르게, 엔티티를 실제로 사용하는 시점까지 DB 조회를 미룰 수 있게 해준다. -
사용 예시 코드
Member member = em.find(Member.class, "member1");
-
EntityManager.getReference()
메서드를 호출할 때, DB를 조회하지 않고 실제 엔티티 객체도 생성하지 않는다!- 대신에 해당 메서드는 DB 접근을 위임한 프록시 객체를 반환한다.
프록시의 구조
- 프록시는 실제 클래스를 상속받아 만들어진다.
- 따라서, 겉모양은 실제 클래스와 같다.
프록시 객체의 초기화
- 프록시 객체는 실제 사용될 때 DB를 조회해서 실제 엔티티 객체를 생성한다.
- 이것을 프록시 객체 초기화라고 한다.
-
프록시 초기화 예제
//MemberProxy를 실제 엔티티 대신 반환한다. Member member = em.getReference(Member.class, "id1"); //아래 코드 실행시, 프록시 초기화가 수행된다. member.getName();
-
그렇다면 프록시 클래스는 어떻게 작성되어있을까? 아래 코드는 프록시의 특징에 맞춰, 예상한 프록시 클래스 내용이다.
class MemberProxy extends Member { //실제 엔티티 조회시, 결과를 담는 필드 (참조) Member target = null; //오버라이딩된 메서드 @Override public String getName() { //만약 실제 엔티티가 조회된적 없다면: 프록시 초기화 진행 if (target == null) { // 1. 프록시 초기화 요청 // 2. DB 조회 // 3. 실제 엔티티 생성 및 참조 보관 } //아래 코드를 통해, 실제 엔티티의 원하는 메서드 호출 // 4. target.getName(); return target.getName(); } }
다시 말하지만, 위 코드는 이해를 위한 예상 코드이다. 실제 프록시 클래스가 위와 동일하게 구현되었다는 이야기가 아니다.
-
프록시 초기화 과정
- 프록시 객체에
member.getName()
을 호출해서 실제 데이터 조회를 시도한다. - 프록시 객체는 실제 엔티티가 생성되어 있지 않으면, 영속성 컨텍스트에 실제 엔티티 생성을 요청한다. ⇒ 초기화
- 영속성 컨텍스트는 DB를 조회해서 실제 엔티티 객체를 생성한다.
- 프록시 객체는 생성된 실제 엔티티 객체의 참조를
Member형 target
멤버변수에 보관한다. - 프록시 객체는 실제 엔티티 객체의
getName()
을 호출해서 결과를 반환한다.
- 프록시 객체에
프록시 특징
- 프록시 객체는 처음 사용할 때, 한 번만 초기화된다.
- 프록시 객체를 초기화해도, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다!
- 프록시 객체 초기화시, 프록시 객체를 통해서 실제 엔티티에 접근할 수 있는 정도이다.
- 프록시 객체는 원본 엔티티를 상속받은 객체이므로, 타입 체크 시에 주의해야 한다.
- 영속성 컨텍스트에 찾는 엔티티가 이미 있으면,
em.getReference()
를 호출해도 실제 엔티티를 반환한다.- 영속성 컨텍스트에 원하는 엔티티가 이미 있다. ⇒ DB를 조회하지 않아도 실제 엔티티를 찾을 수 있다.
- 따라서 이 경우, 프록시를 사용할 이유가 없다!
(프록시는 DB 조회를 미루려고 사용하므로) -
즉 아래와 같다.
- 프록시 초기화는 영속성 컨텍스트의 도움을 받아야 가능하다!
- 위 그림 ‘프록시 초기화 과정’ 을 다시 보자.
- 따라서 준영속 상태의 프록시를 초기화하면 문제가 발생하다.
- 이 경우는 아래에서 자세히 알아보자.
준영속 상태와 초기화
참고로 준영속 상태에 대해 잘 모른다면, 이전 게시글을 참고하자.
- 준영속 상태에서 프록시 초기화를 시도하는 예시 코드를 먼저 보자.
// member 변수에는 프록시 객체가 담긴다.
Member member = em.getReference(Member.class, "id1");
transaction.commit();
em.close(); // 이때, member 엔티티가 준영속 상태로 변경된다.
member.getName(); //준영속 상태에서 초기화 시도
//org.hibernate.LazyInitializationException 예외 발생
- 상세 설명
member.getName()
을 호출하면 프록시를 초기화해야 하는데, 영속성 컨텍스트가 없으므로 실제 엔티티를 조회할 수 없다.- 즉 이 경우는, 프록시 초기화에 사용해야할 영속성 컨텍스트가 없는 경우이다. (
close()
)
- 즉 이 경우는, 프록시 초기화에 사용해야할 영속성 컨텍스트가 없는 경우이다. (
프록시와 식별자
- 엔티티를 프록시로 조회할 때, 식별자(PK) 값을 파라미터로 전달하게 된다.
em.getReference(A.class, "PK값")
- 이때 이 PK 값을 프록시 객체가 보관한다.
- 프록시 객체에서 ‘해당 PK 값을 호출하는 것’으로는 프록시 초기화가 이루어지지 않는다.
-
즉, 아래와 같다.
// 변수 team에 프록시 객체를 담음 Team team = em.getReference(Team.class, "team1"); // 식별자 보관 team.getId(); //프록시 초기화가 이루어지지 않는다!!!
team.getId()
를 호출한 경우, 프록시 초기화가 이루어지지 않는다.- 왜냐하면 식별자는 DB조회 없이도 알 수 있기 때문이다.
- 단,
@Access(AccessType.PROPERTY)
로 설정한 경우에만 해당된다. @Access(AccessType.FIELD)
로 설정한 경우, 위와 같은 상황이더라도 프록시 객체를 초기화한다.- 왜냐하면
FIELD
로 설정시, JPA는getId()
라는 메소드가 id만 조회하는 메소드인지, 다른 필드까지 활용하는 메소드인지 알 수 없다. - 즉,
getId()
메서드가 getter로 사용되는지 알 수 없다.
@Access
에 대한 내용은 이전 게시글을 참고하자.- 왜냐하면
-
프록시는 연관관계를 설정할 때, 유용하게 사용할 수 있다.
Member member = em.find(Member.class, "member1"); //실제 엔티티 Team team = em.getReference(Team.class, "team1"); //프록시 객체 member.setTeam(team); //연관관계 설정
- JPA는 연관관계 설정시, 식별자(PK) 값을 외래키로 사용한다.
- 즉 연관관계 설정에 필요한 값은 식별자 값밖에 없으므로, 프록시 객체를 사용하여 연관관계를 설정할 수 있다.
참고로 연관관계 설정시에는
FIELD
접근 방식으로 설정해도 프록시를 초기화하지 않는다.
프록시 확인
- 프록시 인스턴스의 초기화 여부 확인하기
PersistenceUnitUtil.isLoaded(Object entity)
메서드- 초기화 X ⇒ false
- 초기화 O ⇒ true
-
예시 코드
boolean isLoad = em.getEntityManagerFactory() .getPersistenceUnitUtil().isLoaded(확인할_프록시_엔티티);
- 실제 엔티티인지, 프록시 객체인지 확인하기
- 클래스명을 직접 출력하여 확인
- 클래스 명 뒤에
javassist
단어가 붙으면 프록시 객체이다. -
예시 코드
System.out.println(member.getClass().getName());
- 프록시 강제 초기화하기
org.hibernate.Hibernate.initialize(프록시_객체)
즉시 로딩과 지연 로딩
즉시 로딩과 지연 로딩 요약
- 즉시 로딩
- 엔티티를 조회할 때, 연관된 엔티티도 함께 조회한다.
- 예시
em.find(Member.class, "member1")
을 호출할 때, 회원 엔티티와 연관된 팀 엔티티도 함께 조회한다.
- 설정 방법
@ManyToOne(fetch = FetchType.EAGER)
- 지연 로딩
- 연관된 엔티티를 실제 사용할 때 조회한다.
- 예시
member.getTeam().getName()
처럼 조회한 팀 엔티티를 실제로 사용하는 시점에 JPA가 SQL을 호출해서 팀 엔티티를 조회한다.
- 설정 방법
@ManyToOne(fetch = FetchType.LAZY)
각각에 대해 자세히 알아보자.
즉시 로딩 (Eager Loading)
- 즉시 로딩을 사용하려면,
@ManyToOne
의 속성fetch
를FetchType.EAGER
로 지정한다.
- 예시 코드
-
즉시 로딩 설정
@Entity public class Member { //... @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "TEAM_ID") private Team team; }
-
즉시 로딩 실행 코드
//아래 코드 실행 시, 연관된 엔티티 Team도 같이 조회된다. Member member = em.find(Member.class, "member1"); Team team = member.getTeam(); //객체 그래프 탐색
-
시각화
-
상세 설명
em.find(Member.class, "member1")
로 회원을 조회하는 순간 팀도 함께 조회한다.
-
- JPA 구현체들은 즉시 로딩을 최적화하기 위해, 가능하면 조인 쿼리를 사용한다.
- 연관된 엔티티까지 동시에 조회시
- “기존 엔티티 SELECT 후, 연관된 엔티티 SELECT” 방식 사용 X
- “두 테이블을 외부 조인하여, 기존 엔티티와 연관 엔티티 모두 한번에 SELECT” 방식 사용 O
- 연관된 엔티티까지 동시에 조회시
즉시 로딩: NULL 제약조건과 JPA 조인 전략
- 즉시 로딩 시, 외부조인을 사용하여 한번에 연관 엔티티까지 조회한다.
- 외부조인을 사용하는 이유
- 테이블의 FK 칼럼에 NOT NULL 제약조건이 없을 때를 대비하기 위해 외부조인을 사용한다.
- FK 칼럼에 null이 허용될 때, 내부조인을 사용하면 누락되는 데이터가 많다.
- 하지만 성능상 내부조인이 좋다.
- 외래키에 NOT NULL 제약조건이 걸리면, 내부조인을 사용할 수 있다.
- 따라서 외래키에 NOT NULL 제약조건이 걸리면,
@JoinColumn
의 속성nullable
을false
로 설정하여 JPA에서 내부조인을 사용하라고 알릴 수 있다. - 혹은
@JoinColumn
의 속성optional
을false
로 설정해도 된다. -
예시 코드
@Entity public class Member { // ... @ManyToOne(fetch = FetchType.EAGER) @JoinColumn(name = "TEAM_ID", nullable = false) //내부조인이 가능하다고 알린다. private Team team; // ... }
- 정리
- 선택적 관계 ⇒ 외부조인
- 필수 관계 ⇒ 내부조인 (성능이 더 좋음)
지연 로딩
- 지연 로딩을 사용하려면,
@ManyToOne
의fetch
속성을FetchType.LAZY
로 지정하면 된다. - 예시 코드
-
지연 로딩 설정
@Entity public class Member { //... @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "TEAM_ID") private Team team; }
-
지연 로딩 실행 코드
//member = 실제 엔티티, member.team = 프록시 객체 Member member = em.find(Member.class, "member1"); Team team = member.getTeam(); //객체 그래프 탐색 //team 객체 실제 사용 team.getName(); //이때, 프록시 초기화 (team 조회)
-
시각화
-
상세 설명
em.find(Member.class, "member1")
을 호출하면, 회원만 조회하고 팀은 조회하지 않는다.- 단, 회원의
team
멤버변수에 프록시 객체를 넣어둔다.
- 단, 회원의
Team team = member.getTeam();
- 해당 코드에서 team 변수에는 프록시 객체가 들어간다.
- 이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룬다.
team.getName();
- 이때 실제 데이터 로딩을 진행한다. (프록시 초기화)
- 조회 대상이 영속성 컨텍스트에 있으면 프록시 객체를 사용할 이유가 없다. (위에서 설명한 ‘프록시 특징’ 참고)
-
즉시 로딩, 지연 로딩 정리
- 지연 로딩 (Lazy Loading)
- 연관된 엔티티를 프록시로 조회한다.
- 프록시를 실제로 사용할 때, 초기화하면서 DB를 조회한다.
- 즉시 로딩 (Eager Loading)
- 연관된 엔티티를 즉시 조회한다.
- 하이버네이트는 가능하면 SQL 조인을 사용해서 한번에 조회한다.
지연 로딩 활용
사내 주문 관리 시스템
지연 로딩을 활용하는 예시를 들어, 지연 로딩이 어떻게 활용될 수 있는지 알아보자. ‘사내 주문 관리 시스템’을 예시로 삼겠다.
- 예시
-
클래스 모델
- 모델 분석
- 회원은 팀 하나에만 소속할 수 있다. (N:1)
- 회원은 여러 주문내역을 가진다. (1:N)
- 주문내역은 상품정보를 가진다. (N:1)
- 로직 분석
Member
와 연관된Team
은 자주 함께 사용되었다.- 그래서
Member
와Team
을 즉시 로딩으로 설정했다.
- 그래서
Member
와 연관된Order
은 가끔 함께 사용되었다.- 그래서
Member
와Order
을 지연 로딩으로 설정했다.
- 그래서
Order
와 연관된Product
은 자주 함께 사용되었다.- 그래서
Order
와Product
을 즉시 로딩으로 설정했다.
- 그래서
-
-
예시 코드: 회원 엔티티
@Entity public class Member { @Id private String id; private String username; private Integer age; //즉시 @ManyToOne(fetch = FetchType.EAGER) private Team team; //지연 @OneToMany(mappedBy = "member", fetch = FetchType.LAZY) private List<Order> orders; }
- 회원과 팀의 연관관계를
FetchType.EAGER
로 설정했다.- 따라서 회원 엔티티를 조회하면 연관된 팀 엔티티도 즉시 조회한다.
- 회원과 주문내역의 연관관계를
FetchType.LAZY
로 설정했다.- 따라서 회원 엔티티를 조회하면 연관된 주문내역 엔티티는 프록시로 조회해서 실제 사용될 때까지 로딩을 지연한다.
-
시각화
- 회원 조회시
- 외부조인을 사용하여 Member 엔티티와 Team 엔티티를 동시 SELECT 한다.
- Order 엔티티 대신 프록시 객체가 조회된다.
- 회원과 팀의 연관관계를
프록시와 컬렉션 래퍼
- 위 예시에서 즉시 로딩을 위주로 설명했다.
- 그렇다면 지연 로딩으로 설정한
List<Order> orders
는 어떻게 처리될까? 예시 코드를 통해 알아보자.
-
예시 코드: 주문내역 조회
Member member = em.find(Member.class, "member1"); List<Order> orders = member.getOrders(); //컬렉션형 프록시가 들어감 System.out.println("orders = " + orders.getClass().getName()); //결과: order = org.hibernate.collection.internal.PersistentBag
- 상세 설명
- 출력 결과가 이상한 것을 알 수 있다.
- 엔티티를 영속 상태로 만들 때, 해당 엔티티에 컬렉션이 있으면 컬렉션을 추적·관리할 목적으로, 원본 컬렉션을 ‘하이버네이트 자체 내장 컬렉션’으로 변경한다.
- 이 ‘하이버네이트 자체 내장 컬렉션’을 컬렉션 래퍼라고 한다.
- 따라서 출력 결과가 ‘~.PersistentBag’ 으로 나온 것이다.
- 상세 설명
- 컬렉션 래퍼
- 컬렉션 래퍼는 컬렉션의 지연 로딩을 처리해준다.
- 즉 아래와 같이 생각하면 쉽다.
- 컬렉션 래퍼 = 컬렉션용 프록시
- 컬렉션 래퍼가 프록시 초기화되는 경우
member.getOrders()
⇒ 프록시 초기화 Xmember.getOrders().get(0)
⇒ 프록시 초기화 O- 즉 컬렉션에서 실제 데이터를 조회할 때, DB에서 조회하여 초기화한다.
- 참고
member.getOrders()
가 초기화될 때, 상품(Product)도 같이 조회된다.- 왜냐하면 주문(Order)와 상품(Product)가 즉시 로딩으로 설정되어 있기 때문이다.
- 시각화
JPA 기본 페치 전략
fetch
속성의 기본 설정값은 아래와 같다.@ManyToOne
,@OneToOne
: 즉시 로딩@OneToMany
,@ManyToMany
: 지연 로딩
- JPA의 기본 페치(Fetch) 전략
- 즉시로딩: 연관된 엔티티가 하나일때
- 지연로딩: 연관된 엔티티가 컬렉션형으로 처리될 때
- 컬렉션에 연관 엔티티를 담을 때, 지연로딩으로 동작하는 이유
- 연관된 엔티티의 개수가 수만 건일 때, 즉시로딩으로 처리한다면 매우 비효율적이기 때문이다.
- 반면에 연관된 엔티티가 하나면 즉시 로딩해도 큰 문제가 발생하지는 않는다.
- 권장하는 전략
- 모든 연관관계에 지연 로딩을 사용하라!
- 그리고 애플리케이션 개발이 어느정도 끝날때, 꼭 필요한 곳에만 즉시 로딩을 사용하도록 수정하라!
- 여기서 알 수 있는 JPA의 장점
- SQL을 직접 다룬다면, 위와 같은 권장 전략을 사용하기 어렵다. 많은 SQL과 코드를 일일이 수정해야 하기 때문이다.
- 하지만 JPA를 사용하면, 비교적 손쉽게 로딩 설정을 바꿀 수 있다.
컬렉션에 FetchType.EAGER 사용 시 주의점
- 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않는다.
- 컬렉션과 조인하는 것 ⇒ 일대다 조인
- 서로 다른 컬렉션을 2개 이상 조인한다면, 너무 많은 데이터를 반환할 수 있다.
- 따라서 애플리케이션 성능이 저하될 수 있다.
-
컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.
- 조인 전략 정리
@ManyToOne
,@OneToOne
(optional = false)
: 내부조인(optional = true)
: 외부조인
@OneToMany
,@ManyToMany
(optional = false)
: 외부조인(optional = true)
: 외부조인
- 김영한, 『자바 ORM 표준 JPA 프로그래밍』, 에이콘