이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
기초개념 › JPA
연관된 객체는 언제 DB에서 읽어 오는가
주문(Order)은 그 주문을 한 회원(Member)을 가리킵니다. 코드에서는 Order의 member 필드가 그 회원을 가리키고, DB에서는 orders 테이블의 주문 행이 회원의 번호(member_id)만 적어 둡니다. 그래서 em.find로 주문 하나를 읽어 올 때 JPA는 정해야 합니다. 그 주문이 가리키는 회원도 지금 같이 읽어 올 것인가, 회원은 코드가 그 값을 실제로 쓰는 순간에 읽어 올 것인가. 이것을 member 필드 위의 @ManyToOne(fetch = ...)에 적습니다. FetchType.EAGER는 회원도 주문을 읽을 때 함께 읽어 오는 방식(즉시 로딩)이고, FetchType.LAZY는 회원을 쓰는 순간까지 미루는 방식(지연 로딩)입니다. 미루는 동안 member 필드에는 회원 1의 번호만 아는 비어 있는 대리 객체(프록시)가 들어 있다가, 이름처럼 회원의 값을 꺼내는 순간에 JPA가 member 테이블을 읽는 SELECT 문을 보내 채웁니다. 화면의 코드에는 SQL이 한 줄도 없는데도 SQL이 나가고, 나가는 때가 설정에 따라 달라집니다. 이 실습은 fetch 설정을 바꾸고 order.getMember().getName() 줄을 지워 가며 같은 주문을 읽어, 줄마다 어느 테이블을 읽는 SELECT가 나갔는지 봅니다.
실행으로 대조 · Hibernate 7.4.5와 H2 2.4.240에서 네 가지 조합을 실행해 줄마다 나간 SQL을 대조, FetchType 규칙은 Jakarta Persistence 3.2 API 문서와 대조
@Entity
public class Member {
@Id
private Long id;
private String name;
public String getName() { return name; }
}
@Entity
@Table(name = "orders")
public class Order {
@Id
private Long id;
// 연결: 이 주문이 가리키는 회원을 언제 읽어 올지 정한다
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
public Member getMember() { return member; }
}
// DB에는 주문 10과, 그 주문을 한 회원 1(김철수)이 들어 있다
Order order = em.find(Order.class, 10L);
// 사용: 회원의 값(이름)을 실제로 쓴다
String name = order.getMember().getName();
Hibernate 7.4.5와 H2 2.4.240(메모리 DB)에서 실행해 대조했습니다. 스프링 없이 SessionFactory를 직접 만들었고, JDK 21입니다. 네 가지 조합을 모두, 화면의 엔티티 두 클래스(Member와 Order)와 읽는 코드 줄을 글자 그대로(주석 줄 포함) 컴파일해 실행했습니다. 화면에서 줄인 것은 package와 import 줄뿐이고, 화면에 보이지 않는 것은 EntityManager를 얻고 닫는 코드입니다. 조합마다 DB와 SessionFactory를 새로 만들고, 테이블은 Hibernate가 만들게 했으며, DB에는 실행 전에 JDBC로 회원 (1, 김철수)와 주문 (10, member_id 1)을 미리 넣었습니다. 이 준비의 SQL은 세지 않았고, 읽는 코드는 그 준비와 다른 EntityManager에서 영속성 컨텍스트가 빈 상태로 실행했습니다. 읽는 코드에는 트랜잭션을 시작하는 줄이 없고 em을 열어 둔 채 실행했습니다. 대조한 것은 Hibernate의 StatementInspector로 잡은 SQL을 코드 줄마다 나눈 것입니다(어느 줄에서 나갔는지는 호출 스택으로 알아냈고, 나간 SQL 원문과 줄은 대조 출력에 남겼습니다). 이 환경에서 LAZY는 find 줄에서 orders 테이블만 읽는 SELECT 1번이, getName 줄에서 member 테이블만 읽는 SELECT 1번이 나갔고, EAGER는 find 줄에서 orders와 member를 left join한 SELECT 1번이 나가고 getName 줄에서는 나가지 않았습니다. 이름을 쓰는 줄이 없으면 LAZY는 member 테이블을 읽는 SELECT가 전혀 나가지 않았습니다. 단계 문장이 기대는 사실도 줄마다 바로 아래에 측정 줄을 끼우고 getMember()와 getName()을 두 줄로 나눈 두 번째 실행에서 쟀습니다. LAZY에서 find 직후 member 필드의 객체는 Hibernate가 만든 프록시 클래스의 객체(Member$HibernateProxy)이고 초기화되지 않았으며 식별자 1을 알고 있다는 것, order.getMember()를 부른 직후에도 SQL이 늘지 않고 여전히 초기화되지 않았다는 것, getName() 직후에 SELECT가 한 번 늘고 초기화되었다는 것, EAGER에서는 find 직후 member 필드가 프록시가 아닌 진짜 Member 객체이고 초기화되어 있으며 getMember()와 getName() 뒤에도 SQL이 늘지 않았다는 것입니다. 또 @ManyToOne에 fetch를 적지 않고 같은 코드를 실행하면 EAGER와 같게 find 줄에서 조인한 SELECT 1번으로 회원까지 읽혔습니다(화면의 설정에는 넣지 않았고, 용어 설명의 근거로만 썼습니다). 문서가 정한 것은 Jakarta Persistence 3.2 API 문서의 FetchType(EAGER는 데이터를 반드시 즉시 읽어야 한다는 요구이고, LAZY는 처음 접근할 때 읽어도 된다는 힌트이며 구현체는 LAZY로 적힌 데이터를 미리 읽어도 된다는 것)과 ManyToOne의 fetch(적지 않으면 기본값이 EAGER라는 것)입니다. Hibernate 사용자 안내서(User Guide)의 Fetching 장은 페이지에서 해당 부분을 열지 못해 읽지 못했으므로, 대리 객체를 넣어 두는 방식과 EAGER를 조인으로 읽는 방식은 문서가 아니라 Hibernate 7.4.5에서 관찰한 동작입니다. 명세상 LAZY는 힌트이고 EAGER를 조인으로 읽을지 별도 SELECT로 읽을지는 구현이 정하므로, 화면에는 이 환경에서 나간 SQL만 적었습니다. 이 실습은 em이 열려 있는 동안만 다루며, em을 닫은 뒤 대리 객체를 채우려 할 때 나는 LazyInitializationException, 주문이 여러 개일 때 SELECT가 주문 수만큼 늘어나는 N+1 문제, JPQL 조회, 컬렉션(@OneToMany)의 로딩은 다루지 않습니다. 대리 객체의 세부는 "프록시와 지연 로딩" 실습에서, N+1은 "N+1 문제" 실습에서 다룹니다. MySQL에서의 실행 대조는 하지 않았습니다.
줄을 그은 곳은 처음 코드에서 지운 것이고, 색을 칠한 곳은 새로 쓴 것입니다.