지나공 : 지식을 나누는 공간
JPA 5 : 프록시(Proxy)란? 지연로딩(LAZY), 즉시로딩(EAGER), N+1문제 본문
이번 포스팅에서 공부할 것은,
- 프록시 들어가기
- 프록시의 개념과 특징
- 프록시 확인하기
- 즉시로딩(EAGER)과 지연로딩(LAZY)
- N+1의 문제
들어가기
Team 과 Member가 1:N으로 매핑될 때를 생각해보자.
member를 가져올 때 연관관계가 있다고 해서 항상 team도 가져온다면, 최적화가 되지 않은 상황이 될 것이다. 하지만 언젠가 member를 가져오면서 그 멤버가 속한 team의 정보도 사용해야 할 때도 있을 것이다. 이 경우에는 한 번에 둘 다 가져와야 하고, 또 가져오려니 항상 가져오면 낭비가 심하고. 이 부분에 대한 JPA의 해결을 오늘 공부할 예정이다.
프록시란?
먼저, em.find() vs em.getReference() 에 대하여
find()는 데이터베이스를 통해 실제 엔티티를 조회하고, getReference()는 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체를 조회한다. getReference는 쉽게 말해서 DB에 쿼리가 날아가지 않는데 객체가 조회되는 것이다.
그리고 이렇게 조회된 가짜 객체가 바로 '프록시(Proxy)' 이다.
em.getReference는 실제로 DB가 실행되지 않고 가짜객체를 가져오는데, getReference를 호출하여 얻어낸 데이터를 어디선가 사용하는 순간 이 값을 채우기 위해 DB에 쿼리를 날려서 진짜 객체를 가져온다.
프록시 특징 1
- 실제 클래스를 상속 받아서 만들어지므로 실제 클래스와 겉 모양이 같다.
- 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용해도 된다.
- 프록시 객체는 실제 객체의 참조(target이라는)를 보관한다. 따라서 프록시 객체의 getName()을 하면 target이 실제 객체의 메소드를 호출한다.
프록시 객체의 초기화, 동작
Member member = em.getReference(Member.class, "id1"); //프록시 객체를 가져와
member.getName();
일단, 프록시는 실제 객체에 대한 참조를 가지고 있다. 여기선 그 참조값을 target이라고 하겠다.
1. 처음 getName()을 하면 DB에서 조회한 적이 없어서 Proxy의 target이 null이다.
2. 그러면 JPA가 영속성 컨텍스트에 진짜 객체를 가져오라고 요청한다.
이에 따라 영속성 컨텍스트는 진짜 객체를 DB에서 조회해서 실제 엔티티 객체를 생성한다.
3. 그리고 그걸 프록시 객체의 target에 연결해 준다.
4. 그래서 getName을 하면 target의 진짜 객체에 대해 getName을 해 준다.
5. 이후에 같은 메소드를 호출하면 쿼리문 없이 프록시 객체로부터 바로 값을 가져온다.
프록시 객체의 어떤 멤버변수에 접근하면 db에서 조회가 되면서 영속성컨텍스트에 값이 초기화된다.
프록시 특징 2 - 중요!
- 프록시 객체는 처음 사용할 때 한 번만 초기화된다. 두번 세번 호출해도 초기화 안되고 한 번 호출한 걸 계속 쓴다.
- 프록시 객체를 초기화할 때, 프로시 객체가 실제 엔티티로 바뀌는 것이 아니다.
초기화되면 프록시 객체를 통해서 실제 엔티티에 접근이 가능한 것이다. - 프록시 객체는 원본 엔티티를 상속 받으므로 타입 체크시에 주의 해야 한다.
객체 타입을 비교할 때, == 비교 대신에 instanceof 을 사용해야 한다.
왜냐? 프록시가 아닌 멤버랑 프록시인 멤버랑 서로 타입이 안 맞을 수 있기 때문이다. - 아래 코드를 보며 주의사항을 다시 새기자. == 비교를 하면 안 된다. 언젠가 get을 통해 값을 가져올 때, 이게 프록시 객체로부터 오는지 실제 객체로부터 오는 지 서로 모를 수 있으므로, instanceof을 사용하자.
- instanceof은 참조객체가 실제로 참조하고 있는 대상의 타입을 가져온다.
//엔티티팩토리는 persistence-unit name과 동일하게
EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin() //데이터베이스 트랜잭션 시작
try{
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
em.persist(member2);
em.flush();
em.clear();
Member m1 = em.find(Member.class, member1.getId());
Member m2 = em.find(Member.class, member2.getId());
System.out.println("m1 == m2" + (m1.getClass() == m2.getClass()); //타입체크
//여기서의 타입 체크는 당연히 맞게 나옴. 둘 다 실제 객체이다.
//하지만!! 아래와 같이 하면?
Member m1 = em.find(Member.class, member1.getId());
Member m2 = em.getReference(Member.class, member2.getId());
//엇? m1은 실제 객체, m2는 프록시 객체니까 타입이 다를 것 같은데!
//타입체크
System.out.println("m1 == m2" + (m1.getClass() == m2.getClass());
//false나옴. 응 둘의 타입은 다르다. 하나는 class 하나는 proxy.
//물론 이건 최초 조회라고 전제한다. 최초 조회일 때 하나는 class 하나는 프록시다.
System.out.println("m1 == m2" + (m1 instanceof Member));
System.out.println("m1 == m2" + (m2 instanceof Member));
//둘다 true나옴
//왜냐면 프록시가 참조하는 것과 실제 객체가 참조하는 것은 동일하니까.
}catch() //이하생략
- 영속성 컨텍스트에 이미 찾으려는 엔티티가 있다면 em.getReference()를 호출하더라도 실제 엔티티를 반환한다.
다음 경우의 코드를 보자.
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);
em.flush();
em.clear();
Member m1 = em.find(Member.class, member1.getId());
System.out.println("m1 = " + m1.getClass());
Member reference = em.getReference(Member.class, member1.getID());
System.out.println("reference = " + reference.getClass());
//똑같이 둘 다 Member로 나옴
m1 = class hellojpa.Member
reference = class hellojpa.Member
//게다가 이번에 얘네 둘을 ==으로 비교하면 true가 나옴
System.out.println("a == a : " + m1 == reference);
//하나는 getReference 한 거니까 프록시 객체 아니야? 그럼 getClass했을 때 실제 객체랑 달라야 하는거 아닌가?
//라고 생각이 들지만 아니다. 아래 글 참고
위의 현상은 왜 일어날까?
첫째, 처음에 실제 객체를 영속성 컨텍스트에 올려 놨으니 프록시를 조회하는 코드를 쓰더라도 실제 객체를 가져오는 게 최적화에 더 낫다. 그래서 실제 객체가 가져와진다.
둘째, JPA는 한 트랜잭션 내에서 같은 것을 부르면 같은 값을 주려고 하는 편이라서, 영속성 컨텍스트에 이미 프록시 객체가 부르려는 게 있으면, 즉 프록시가 참조하고 싶어하는 그 객체에 대한 초기화가 되어있으면 그걸 가져온다. 그래서 == 로 비교할 때에도 true 결과를 얻게 된다. 실제 객체를 가져온 것이니까.
이번에도 아래 코드와 주석을 읽어보자.
Member member1 = new Member();
member1.setUsername("member1");
em.persist(member1);
em.flush();
em.clear();
Member m1 = em.getReference(Member.class, member1.getId());
System.out.println("m1 = " + m1.getClass());
Member reference = em.getReference(Member.class, member1.getID());
System.out.println("reference = " + reference.getClass());
//똑같이 둘 다 Member로 나옴
m1 = class hellojpa.MemberHibernateProxy~
reference = class hellojpa.MemberHibernateProxy~
//이번에 얘네 둘을 ==으로 비교하면 true가 나옴 -> JPA가 이걸 같게 만들려고 해. 기본적인 기능으로.
System.out.println("a == a : " + m1 == reference);
둘 다 프록시를 가져오면 똑같이 ==으로 비교할 때 같은 값으로 인식된다. 한 트랜잭션 안에서 같은 걸 두 번 호출하면 이 둘은 컬렉션에서 꺼낸 것처럼, 같은 것으로 인식되도록 하는 JPA의 기본적인 기능을 알 수 있다.
또 다른 코드를 생각해보자.
이번엔, 처음에 getReference를 통해 프록시를 얻어오고, 그 다음에 find()를 통해 실제 객체를 얻어왔다고 가정하자. 이때에 둘을 ==으로 비교하면 과연 true일까 false일까?
-> 답은 true다. 그리고 각각의 타입을 출력해보면 reference한 결과도 프록시, find한 결과도 프록시이다. find로 인해 DB에 쿼리도 날라갔는데도 그렇다!
-> 왜?????? : 프록시가 한 번 조회되면 em.find()에서 프록시를 반환해버린다. 그래야 이 둘을 ==비교할 때 true가 나오니까. true가 나오게 하기 위해서 그러는 것이다. 즉, 프록시든 아니든 개발을 잘 하는 게 중요하니까 JPA는 이런 것을 지원한다는 걸 알고 있으면 된다.
- (중요) 영속성 컨텍스트의 도움을 받을 수 없는 준영속 상테일 때, 프록시를 초기화하면 문제 발생
(하이버네이트는 org.hibernate.LazyInitializationException 예외를 터뜨림)
또 아래 코드를 보자.
Member refMember = em.getReference(Member.class, member1.getId()); //프록시상태
em.detach(refMember); //영속성 컨텍스트에서 빼서 준영속상태로 만듦
refMember.getUsername(); // 에러발생함
-> 원래 getUsername()을 하면 영속성컨텍스트에 요청해서 실제 객체를 조회하도록 해야하는데 detach를 함으로써 영속성 컨텍스트에서 "관리 안해~"의 상태가 되었다. 그렇게 되면 could not initilize Proxy라는 메세지가 뜨면서 에러 난다. em.close()도 마찬가지로 영속성컨텍스트가 닫히므로 프록시를 초기화할 수 없고 같은 에러가 난다.
프록시 확인하기
- 프록시 인스턴스의 초기화 여부 확인하기
-> PersistenceUnitUtil.isLoaded(Object entity) - 프록시 클래스 확인 방법
-> entity.getClass().getName() 출력하면 javasist... or ...HibernateProxy...이런식으로 나온다. - 프록시 강제 초기화
-> org.hibernate.Hibernate.initialize(entity); // 사용예 ) Hibernate.initialize(refMember);
JPA 표준은 강제 초기화 기능을 제공하지 않고 member.getName() 처럼 강제 호출하는 방식이 있다.
위의 강제초기화 코드는 hibernate에서 제공하는 메소드이다.
즉시로딩과 지연 로딩
사용방식, 사용예 -> FetchType을 설정
지연로딩 LAZY를 사용해서 조회해보자.
그러면 진짜 객체 조회가 아닌 프록시를 조회할 것이다
Team team1 = new Team();
team1.setName(team1);
em.persist(team1);
Member member1 = new Member();
member1.setUsername("member1");
member1.setTeam(team);
em.persist(member1);
Member m = em.find(Member.class, member1.getId());
System.out.println("m = "+ m.getTeam().getClass()); // 프록시를 가져온다.
위의 코드까지는 team을 가져오면 프록시를 가져오지만, m.getTeam().getName();을 통해 team 내의 어떤 속성을 조회하면 이 시점에 쿼리가 실행된다. (team에 있는 어떤 것을 실제로 사용할 때 쿼리문이 나간다.)
즉시로딩 EAGER을 하면 같은 코드로 m.getTeam().getClass();를 출력하면 실제 클래스 타입이 반환된다.
즉시 한번에 연관된 것을 가져오는 방식이다.
지연로딩을 할지 즉시로딩을 할지는 비즈니스 로직에 따라 선택하면 된다.
team을 자주 사용한다면 즉시로딩을 사용해야 할 것이다.
하지만 프록시와 즉시로딩 주의할 점이 있다.
- 가급적 지연 로딩만 사용하라
-> 전혀 예상하지 못한 SQL이 발생할 수 있으므로 - 즉시 로딩은 JPQL에서 N+1 문제를 일으킨다.
N+1 문제
List<Member> members = em.createQuery("select m from Member m", Member.class)
.getResultList();
이걸 실행하면 같은 쿼리가 두 번 나간다.
em. find는 PK를 찍어서 가져오므로 JPA가 내부적으로 한 번만 쿼리를 실행하지만, JPQL은 코드에 적은 쿼리가 그대로 DB에 실행된다. 저 코드만 실행된다면 Member만 가져오고 Team은 가져오지 않을 것이다. 그런데 Team이 즉시로딩이라서, 가져올 때 무조건 값이 다 채워져 있어야한다. 즉, member의 개수가 10개 가져와졌다면(하나의 쿼리로 멤버 10명이 모두 불림) 그 즉시 team도 각각 10개의 멤버에 맞게 가져와야 한다는 것이다.
그래서 실행된 쿼리를 보면 select * from Member; select * from Team where Team_id = xxx 이런 식이다.
-> select * from Member라는 쿼리 1개를 통해 10명이라는 모든 멤버를 가져왔더니 team은 그 멤버 한명한명으로 where문을 써서 한 명씩 team을 가져옴. 1개의 쿼리를 썼더니 10개의 쿼리가 더 나가더라.
이런 현상을 N+1 문제라고 하고, 이 말의 의미는 N+1에서의 1 처럼 하나의 쿼리를 날렸는데 그 하나와 관련한 N개의 쿼리가 더 실행된다는 의미이다.
그렇다고 LAZY로 바꿔야 하나? 그러면 연관된 데이터를 어떻게 가져올 것인가?
대안 : 일단은 fetchType을 LAZY로 해 두고, fetch join을 사용하여 member와 team을 같이 가져올 수 있다.
나중에 이 부분에 대한 포스팅도 올릴 것이다. 일단 아래 예시와 같이 사용한다는 것 정도만 알아두자.
List<Member> members = em.createQuery("select m from Member m join fetch m.team", Member.class)
.getResultList();
- @ManyToOne, @OneToOne은 디폴트가 EAGER이므로 반드시 LAZY로 직접 설정하라
- @OneToMany, @ManyToMany는 디폴트가 LAZY이니 크게 신경쓰지 않아도 된다.
(ManyToMany 는 가급적 쓰지마라)
실무 tip 정리
1. 모든 연관관계에서 지연로딩(LAZY)를 사용해라.
2. 실무에서 즉시로딩을 사용하지 마라.
3. JPQL fetch 조인이나 엔티티 그래프 기능을 사용해라 (뒷포스팅)
4. 즉시 로딩은 상상하지 못한 쿼리가 날아간다는 점을 기억해라.
5. @OneToOne, @ManyToOne은 조심하자. 기본이 즉시로딩이므로 LAZY로 꼭 설정하고, @OneToMany나 @ManyToMany는 기본이 지연로딩이니까 그냥 두자
출처 : 김영한 님의 JPA 기본 프로그래밍 강의를 수강한 뒤 정리한 내용입니다.
'Tech > JPA' 카테고리의 다른 글
JPA 7 : 임베디드 타입, JPA 데이터 타입 분류 (0) | 2020.11.09 |
---|---|
JPA 6 : 영속성 전이(CASCADE)란? (2) | 2020.11.01 |
JPA 4 : @GeneratedValue 기본키 매핑 전략 정리 (0) | 2020.10.14 |
JPA 3 : 데이터베이스 스키마 자동생성 기능 (0) | 2020.09.21 |
JPA 2 : 영속성 컨텍스트(Persistence Context)란? - (EntityManager, EntityFactory, Flush, transactional write behind) (0) | 2020.09.10 |