이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › JPA › 프록시와 로딩
프록시와 지연 로딩
지연 로딩으로 받은 객체는 진짜 엔티티인가
em.getReference나 지연 로딩으로 받은 회원 객체는 진짜 Member가 아니라, 식별자만 알고 있는 프록시(대리 객체)일 수 있습니다. 프록시는 진짜 데이터가 필요해지는 순간에야 DB를 조회합니다.
실행으로 대조 · Hibernate 7.4.5와 H2에서 스무 가지 조합을 실행해 프록시 여부, 조회 SQL이 나간 시점, 결과 값과 예외를 대조
이 실습에 나오는 개념
- 엔티티 프록시
- 진짜 엔티티 대신 들어오는 대리 객체다. Hibernate가 엔티티 클래스를 상속한 클래스를 실행 중에 만들어 그 객체를 돌려주며, 처음에는 식별자만 알고 나머지 내용은 비어 있다. 식별자가 아닌 값을 처음 읽는 순간 DB를 조회해 진짜 엔티티를 읽어 오는데, 이것을 프록시 초기화라고 한다. em.getReference의 결과나 지연 로딩으로 설정한 연관 객체가 프록시로 들어온다. 프록시의 클래스는 엔티티 클래스와 다르므로 타입을 비교할 때는 getClass() 대신 instanceof를 쓴다.
- LazyInitializationException
- 초기화되지 않은 프록시나 지연 로딩 컬렉션을 영속성 컨텍스트가 닫힌 뒤에 사용하려 할 때 Hibernate가 던지는 예외다. 프록시를 초기화하려면 영속성 컨텍스트를 통해 DB를 조회해야 하는데, 이미 닫혀서 조회할 길이 없기 때문에 발생한다. 트랜잭션이 끝난 뒤 컨트롤러나 뷰에서 연관 객체를 꺼낼 때 자주 만난다. 필요한 값을 트랜잭션 안에서 미리 읽어 두거나, fetch join으로 처음부터 함께 가져오거나, 엔티티 대신 필요한 값만 담은 객체로 바꿔 돌려주는 방법으로 피한다.
- 지연 로딩과 즉시 로딩
- 연관된 엔티티를 언제 조회할지에 대한 설정이다. 즉시 로딩(EAGER)은 엔티티를 조회할 때 연관된 엔티티도 함께 가져온다. 지연 로딩(LAZY)은 연관된 엔티티 자리에 프록시 객체를 넣어 두고 실제로 그 값을 사용할 때 조회한다. 즉시 로딩은 필요 없는 데이터까지 항상 가져오고 예상하기 어려운 쿼리를 만들기 때문에 지연 로딩을 기본으로 쓰는 것이 권장된다.
- 영속성 컨텍스트
- 엔티티를 보관하고 관리하는 JPA의 논리적 공간이다. 조회하거나 저장한 엔티티는 이곳에 보관되며, JPA는 이곳의 엔티티에 일어난 변화를 추적해 트랜잭션이 끝날 때 DB에 반영한다. 보통 트랜잭션 하나와 생명주기를 같이한다(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다). 1차 캐시, 변경 감지, 쓰기 지연 같은 기능이 이 위에서 동작한다.
- 1차 캐시
- 영속성 컨텍스트 안에 엔티티를 식별자 기준으로 보관하는 저장소다. 같은 트랜잭션 안에서 같은 식별자로 다시 조회하면 DB에 쿼리를 보내지 않고 보관된 엔티티를 돌려준다. 그 결과 같은 트랜잭션 안에서 같은 식별자의 엔티티는 항상 같은 객체다. 기본적으로 트랜잭션이 끝나면 사라지므로(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다) 애플리케이션 전체의 캐시와는 다르다.
이렇게 바꿔 보세요
- 처음 설정으로 — getReference로 받을 때는 SQL이 없고, 이름을 읽을 때 SELECT가 나갑니다.
- 식별자만 읽으면 — 프록시가 이미 알고 있는 값이라 SQL이 나가지 않습니다.
- 지연 로딩으로 받은 회원 — order.getMember()가 돌려주는 것도 프록시입니다.
- 영속성 컨텍스트를 닫은 뒤 — 비어 있는 프록시를 채울 길이 없어 예외가 납니다.
- getClass()로 비교하면 — 프록시의 클래스는 Member가 아니라서 false입니다.
- 이미 읽어 둔 회원이 있으면 — getReference가 프록시가 아니라 진짜 엔티티를 돌려줍니다.
- 프록시를 먼저 받았다면 — 뒤에 부른 em.find도 그 프록시를 돌려줍니다.
처음 설정으로 실행하면
- em.getReference(Member.class, 1L): DB를 조회하지 않고, 식별자 1만 담은 프록시 객체를 만들어 돌려줍니다. 프록시는 Hibernate가 Member를 상속해 실행 중에 만든 클래스의 객체이고, 이름 같은 나머지 내용은 아직 비어 있습니다.
- m.getName(): 프록시가 가진 것은 식별자뿐이라 이름을 알 수 없습니다. 이 순간 SELECT를 보내 진짜 Member를 읽어 오고(프록시 초기화), 프록시는 그 객체와 연결됩니다.
- 프록시가 진짜 Member의 getName()을 대신 불러 "김철수"를 돌려줍니다. 한번 초기화된 프록시는 다시 조회하지 않습니다.
실행 결과
- 조회 SQL은 객체를 받는 순간이 아니라, 식별자가 아닌 값을 처음 쓰는 순간에 나갑니다. 이것이 지연 로딩입니다.
코드
@Entity
class Member {
@Id Long id;
String name;
Long getId() { return id; }
String getName() { return name; }
}
// DB에는 회원 1 "김철수"가 있다
Member m = em.getReference(Member.class, 1L);
String name = m.getName();
이 모형의 전제
스무 가지 조합(얻는 방법 다섯, 하는 일 넷)을 모두 실제 Hibernate 7.4.5와 H2 데이터베이스에서 실행해, m이 프록시인지, 회원 테이블 조회가 언제 몇 번 나갔는지, 마지막 줄의 값이나 예외가 화면과 일치하는 것을 확인했습니다. 엔티티의 필드에 @Id를 붙이고 게터를 둔 보통의 매핑, 하나의 영속성 컨텍스트 기준입니다. 프록시의 클래스 이름은 이 환경에서 Member$HibernateProxy였고, 버전에 따라 뒤에 꼬리표가 더 붙기도 합니다. 같은 환경에서 다음도 확인했습니다. 게터를 거치지 않고 프록시의 필드를 직접 읽으면(m.name) 초기화되지 않고 null이 나옵니다. DB에 없는 식별자로 getReference를 하면 그때는 예외가 없고, 값을 처음 읽을 때 조회가 나간 뒤 예외(ObjectNotFoundException)가 납니다. 영속성 컨텍스트를 닫은 뒤에도 프록시의 getId()는 값을 돌려줍니다. 스프링 부트의 웹 요청에서는 OSIV 기본 설정 때문에 응답이 끝날 때까지 영속성 컨텍스트가 열려 있어 이 예외가 늦게 드러날 수 있는데, 그 부분은 실행으로 확인하지 않았습니다. 바이트코드 강화(enhancement)를 쓰는 환경에서는 프록시 대신 다른 방식으로 지연 로딩이 될 수 있다고 문서에 나와 있으나, 이것도 실행해 보지는 않았습니다.
앞 실습: 영속성 전이와 고아 객체 · 다음 실습: N+1