1. 들어가며
JPA를 사용하다 보면 단순한 CRUD를 넘어서 “왜 쿼리가 이렇게 많이 나가지?”,“분명 한 번 조회했는데 또 SELECT가 나간다” 같은 의문을 마주치게 된다. 이 문제의 중심에는 항상 프록시(Proxy) 와 로딩 전략(Fetch Strategy) 이 존재한다. 이번 글에서는 JPA 내부 동작을 이해하는 데 꼭 필요한 두 개념을 비유 → 내부 구조 → 실제 코드 → 실행 쿼리 흐름 순서로 이해해보자.
2. 프록시(Proxy)의 이해
2.1. 프록시란 무엇인가?
프록시(Proxy)는 사전적으로 ‘대리인’ 을 의미한다. 즉, 실제 객체가 해야 할 일을 대신 수행하거나, 필요할 때 실제 객체에게 위임하는 객체다. JPA에서는 이 프록시 개념이 단순한 디자인 패턴을 넘어, 지연 로딩(LAZY)을 구현하는 핵심 메커니즘으로 사용된다.
2.2. 스턴트 배우 비유로 이해하는 프록시
액션 영화를 떠올려보자. 주인공이 고층 빌딩에서 뛰어내리거나 자동차 추격전을 벌이는 장면이 있다. 하지만 이런 장면을 주연 배우가 직접 촬영하지는 않는다. 대신 스턴트 배우가 위험한 장면을 대신 수행한다.
관객 입장에서는 화면에 주인공이 등장하고 모든 액션을 주연 배우가 직접 연기하는 것처럼 보인다. 하지만 실제로는 위험한 순간에는 스턴트 배우(대리인)가 대신 등장하고 감정 연기나 중요한 장면이 필요할 때만 주연 배우가 등장한다. 이때 이 스턴트 배우가 바로 프록시이다.
2.3. JPA에서의 프록시 동작 방식

JPA에서도 이와 동일한 구조가 사용된다. 실제 엔티티를 바로 로딩하지 않고, 엔티티를 감싸는 프록시 객체를 먼저 반환한다. 그리고 실제 데이터가 필요한 시점에만 DB 조회를 수행한다 즉, 프록시는 실제 엔티티의 “대리인”으로서 메서드 호출 시점에 필요 여부를 판단하고 필요할 경우에만 실제 엔티티를 로딩한다. 중요한 점은 사용자 입장에서는 프록시인지 실제 객체인지 구분할 수 없다는 것이다. 동일한 인터페이스(혹은 상속 구조)를 통해 동일하게 동작한다.
3. JPA 로딩 전략 (Loading Strategy)
프록시 개념을 이해했다면, 이제 자연스럽게 로딩 전략(Fetch Strategy) 으로 넘어갈 수 있다. JPA에서 로딩 전략이란 엔티티를 조회할 때, 연관된 엔티티를 어느 시점에 데이터베이스에서 가져올 것인지를 결정하는 방식이다. 실제 애플리케이션에서는 하나의 테이블만 조회하는 경우보다, 엔티티 간 연관 관계를 따라 여러 테이블을 함께 다루는 경우가 훨씬 많다.
예를 들어 호텔과 객실을 관리하는 시스템을 떠올려보자.
- 하나의 호텔에는 여러 개의 객실이 존재한다
- 호텔 목록을 조회할 때는 호텔 이름만 필요할 수도 있고
- 어떤 경우에는 호텔과 그에 속한 모든 객실 정보가 함께 필요할 수도 있다
이때 항상 객실 정보를 함께 조회한다면 편리할 수도 있지만, 반대로 불필요한 경우에도 매번 JOIN 쿼리가 실행되면서 성능 저하로 이어질 수 있다. 이처럼 연관 엔티티를 조회하는 시점의 차이를 제어하기 위해 JPA는 로딩 전략이라는 개념을 제공한다.
JPA의 로딩 전략은 크게 두 가지로 나뉜다. 이 선택은 단순한 옵션 설정이 아니라, 쿼리 개수, 응답 시간, 메모리 사용량에 직접적인 영향을 주는 설계 요소다.
| 전략 | 설명 | 특징 |
| 지연 로딩 (LAZY) | 연관된 데이터를 실제로 사용할 때 조회 | 프록시 객체를 사용하며 성능 최적화에 유리함 |
| 즉시 로딩 (EAGER) | 엔티티를 조회할 때 연관된 데이터를 즉시 함께 로드 | JOIN 쿼리를 사용하여 한 번에 데이터를 가져옴 |
4. 지연 로딩(LAZY)의 동작 원리와 특징
4.1. 지연 로딩이란?
지연 로딩(LAZY)은 말 그대로 “필요할 때만 데이터를 불러오는 방식” 이다. 도서관에서 책을 고르는 상황에 비유해보면 이해하기 쉽다. 처음 책장을 훑을 때는 제목과 표지 정도만 확인하고, 정말 읽고 싶어졌을 때 비로소 책을 꺼내 본문을 읽는다. JPA에서도 동일하다. 이 지연 로딩을 가능하게 만드는 핵심 기술이 바로 프록시 객체다.
- 엔티티를 처음 조회할 때는 필요한 정보만 가져오고
- 연관된 엔티티는 실제로 접근하는 순간 데이터베이스에서 조회한다
4.2. 프록시를 이용한 지연 로딩 구현
Hibernate는 지연 로딩이 설정된 연관 필드에 실제 엔티티 컬렉션 대신 프록시 컬렉션 객체를 주입한다.
@OneToMany(mappedBy = "hotel", fetch = FetchType.LAZY)
private List<Room> rooms;
이 시점에서 rooms 필드에는 일반적인 ArrayList가 아니라 Hibernate가 제공하는 PersistentBag 과 같은 프록시 객체가 들어간다.
즉, 아직 Room 데이터는 메모리에 존재하지 않고 “필요하면 나중에 조회하겠다”는 상태로 대기하고 있는 셈이다.
4.3. 실제 코드 실행 흐름과 쿼리 발생 시점
지연 로딩의 핵심은 “언제 쿼리가 실행되는가” 에 있다.
Hotel hotel = repository.findById(id).orElseThrow();
- 이 시점에는 Hotel 엔티티만 조회된다.
- Room 테이블에 대한 SELECT 쿼리는 실행되지 않는다.
- rooms 필드에는 프록시 객체가 주입된다.
hotel.getRooms();
- 프록시 객체 자체를 반환할 뿐이다.
- 여전히 데이터베이스 쿼리는 발생하지 않는다.
hotel.getRooms().size();
- 이 순간 프록시는 “실제 데이터가 필요하다”고 판단한다.
- Hibernate가 Room 테이블을 조회하는 SELECT 쿼리를 실행한다.
- 지연 로딩이 이 시점에서 초기화된다.
즉, 지연 로딩은 연관 엔티티에 ‘접근하는 순간’ 쿼리가 실행되는 방식이다.
5. 즉시 로딩(EAGER)의 메커니즘과 실제 동작
5.1. 즉시 로딩이란?
즉시 로딩(EAGER)은 지연 로딩과 정반대의 개념이다.엔티티를 조회하는 순간, 연관된 엔티티까지 한 번에 모두 조회한다. 뷔페에 가서 아직 뭘 먹을지 정하지 않았지만, 어차피 다시 오기 귀찮아서 접시에 한 번에 잔뜩 담아오는 상황과 비슷하다. 나중에 먹을지 말지는 모르지만, 일단 지금 한 번에 다 가져오는 방식이다.
5.2. 즉시 로딩의 동작 방식
@OneToMany(mappedBy = "hotel", fetch = FetchType.EAGER)
private List<Room> rooms;
즉시 로딩이 설정된 경우, Hibernate는 엔티티를 조회할 때 연관 엔티티까지 함께 로딩하려고 시도한다. 보통은 다음과 같은 방식으로 동작한다. 어떤 방식이 사용될지는 상황과 매핑 구조에 따라 달라진다.
- LEFT JOIN 을 사용한 단일 쿼리
- 또는 연관 관계 수만큼 추가 SELECT 쿼리
5.3. 즉시 로딩의 실행 흐름
Hotel hotel = repository.findById(id).orElseThrow();
- 이 한 줄에서 Hotel과 Room이 함께 조회된다.
- 대부분의 경우 JOIN 쿼리 하나가 실행된다.
- 연관된 Room 데이터가 이미 메모리에 로딩된다.
hotel.getRooms().size();
- 이미 데이터가 존재하므로 추가 쿼리는 발생하지 않는다.
- 단순히 메모리에서 컬렉션 크기만 계산한다.
⚠️ 즉시 로딩은 “쿼리는 한 번일 수 있지만, 항상 필요한 데이터는 아니다” 라는 점이 문제다. 특히 연관 관계가 많아질수록 JOIN이 기하급수적으로 늘어나며 예상치 못한 성능 저하를 유발할 수 있다.
'Spring > JPA' 카테고리의 다른 글
| [Optimization-4] JPA: N+1 모니터링 시스템 구축하기 (0) | 2025.12.26 |
|---|---|
| [Optimization-3] N+1 문제 - 대표적인 사례와 해결 전략 (0) | 2025.12.26 |
| [Optimization-1] 기초(1) - 현대 백엔드 개발의 표준, JPA의 본질 (0) | 2025.12.26 |
| [Practice-6] 단뱡향/양방향 선택 기준 (⭐⭐⭐) (0) | 2025.12.25 |
| [Practice-5] 일대다/다대일 관계 정의 (0) | 2025.12.16 |
