JPA를 제대로 공부하기 시작한 개발자라면 한 번씩 겪는 경험이 있을 것이다.
// 회원 1명을 조회
Member member = memberRepository.findById(1L);
System.out.println(member.getName()); // 이건 잘 나옴
근데
// 갑자기 에러가?!
System.out.println(member.getTeam().getName());
// LazyInitializationException
도대체 왜 에러가 나는거지? 하고 구글링하거나 chat gpt한테 물어볼 것이다.
그러다가 프록시, 지연 로딩, N+1문제 같은 용어들을 만나게 된다.
이 모든 것의 핵심이 바로 프록시이다.
프록시 감을 잡자.
나는 베이컨체다치즈피자를 좋아하기 때문에 도미노 피자 어플을 예시로 들어보겠다.
도미노 피자 배달 앱을 만들어서 도미노 공화국을 만들 것이다.
// 피자 주문 정보
@Entity
public class Order {
private Long id;
private String orderNumber;
@ManyToOne
private Customer customer; // 주문한 고객
@ManyToOne
private Pizza pizza; // 주문한 피자
}
문제 상황
// 주문 목록 get
List<Order> orders = orderRepository.findAll();
// 화면에 표시
for (Order order : orders) {
System.out.println("주문번호: " + order.getOrderNumber());
// 여기까지는 문제없음
}
사용자가 주문 목록을 보려고 한다. 총 1000개의 주문이 있다.
근데 아무 전략 없이 조회를 해버리면 주문 1000개를 조회할 때 각 주문마다 고객정보와 피자 정보도 함께 조회하게 되면서
총 3000번의 DB가 조회된다!
해결 방법
JPA: 지금 당장 고객 정보나 피자 정보가 필요한가? 주문 번호만 보여주는 거 아닌가?
1. 일단 주문 정보만 가져온다(DB 1번 조회).
2. 고객과 피자 자리엔 가짜 객체(프록시)를 넣어둔다.
3. 나중에 실제로 고객 정보가 필요할 때 DB에서 가져온다.
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
System.out.println("주문번호: " + order.getOrderNumber()); // DB 조회 안함
Customer customer = order.getCustomer(); // 아직 DB 조회 안함 (프록시)
System.out.println(customer.getClass());
// 출력: Customer$HibernateProxy$... (가짜 객체)
String customerName = customer.getName(); // 이제 DB 조회
}
order.getCustomer()는 프록시(가짜 객체)를 반환하므로 DB 접근이 일어나지 않는다.
customer.getName()을 호출하는 순간 진짜 데이터를 조회하며 DB 접근이 발생한다.
프록시에 대해 더 알아보자.
프록시 = 대리인
프록시를 우리말로 하면 대리자 이런 느낌이다.
JPA에서 프록시의 모습을 보자.
// 실제로는 이런 느낌
public class Customer$Proxy extends Customer {
private boolean initialized = false; // 초기화 됐나?
private Customer realCustomer; // 진짜 객체
@Override
public String getName() {
if (!initialized) {
// 이제야 DB에서 진짜 데이터를 가져옴
realCustomer = database.findCustomer(this.id);
initialized = true;
}
return realCustomer.getName();
}
}
프록시 확인 방법
Order order = orderRepository.findById(1L);
Customer customer = order.getCustomer();
// 1. 겉보기엔 똑같아 보임
System.out.println(customer instanceof Customer); // true
// 2. 하지만 실제로는 다른 클래스
System.out.println(customer.getClass());
// Customer (진짜) vs Customer$HibernateProxy$... (프록시)
// 3. 프록시인지 확인
PersistenceUnitUtil util = em.getEntityManagerFactory().getPersistenceUnitUtil();
boolean isLoaded = util.isLoaded(customer);
System.out.println("로딩됐나? " + isLoaded); // false면 프록시
N + 1 문제
N + 1 문제란?
- 1번의 조회가 N번의 추가 조회를 만들어내는 문제
이어서 피자로 예시를 들어보자.
오늘의 주문과 각 주문의 고객 이름을 출력하고 싶다.
public void printTodayOrders() {
// 1. 오늘의 주문들을 조회 (1번의 쿼리)
List<Order> orders = orderRepository.findTodayOrders();
// 2. 각 주문의 고객 이름을 출력
for (Order order : orders) {
// 여기서 매번 고객 정보를 조회 (N번의 쿼리)
String customerName = order.getCustomer().getName();
System.out.println("주문: " + order.getOrderNumber() +
", 고객: " + customerName);
}
}
주문이 10개일 경우, Order를 조회할 때 쿼리 1번, 각 주문마다 연관된 Customer를 지연 로딩(LAZY)으로 조회하므로
총 1 + 10 = 11번의 쿼리가 실행된다.
→ 이것이 바로 N+1 문제이다.
실제 실행되는 SQL을 보면
-- 첫 번째: 오늘의 주문들 조회 (1번)
SELECT o.id, o.order_number, o.customer_id
FROM orders o WHERE o.order_date = '2025-05-24';
-- 주문이 10건이라면... (N번 = 10번)
SELECT c.name FROM customer c WHERE c.id = 1;
SELECT c.name FROM customer c WHERE c.id = 2;
SELECT c.name FROM customer c WHERE c.id = 3;
-- ... 10번 반복
이렇게 총 11번의 쿼리가 발생한다.
해결법을 알아볼까?
1. 페치 조인(Fetch Join)
페치 조인이란 처음부터 연관된 데이터를 함께 가져오는 것이다.
// 기존 방식 (N+1 문제 발생)
public List<Order> getTodayOrdersBad() {
return em.createQuery(
"SELECT o FROM Order o WHERE o.orderDate = :today", Order.class)
.setParameter("today", LocalDate.now())
.getResultList();
}
// 페치 조인 적용
public List<Order> getTodayOrdersGood() {
return em.createQuery(
"SELECT o FROM Order o " +
"JOIN FETCH o.customer " + // 고객 정보도 함께
"WHERE o.orderDate = :today", Order.class)
.setParameter("today", LocalDate.now())
.getResultList();
}
페치 조인을 사용하면 단 1번의 쿼리만 된다.
SELECT
o.id, o.order_number, o.order_date,
c.id, c.name, c.phone
FROM orders o
INNER JOIN customer c ON o.customer_id = c.id
WHERE o.order_date = '2025-05-24';
2. @EntityGraph
Spring Data JPA에서 간편하게 사용할 수 있다.
EntityGraph는 페치 조인 없이도 연관 엔티티를 한 번에 함께 로딩할 수 있게 해준다.
복잡한 쿼리 없이도 지연 로딩을 즉시 로딩처럼 동작하게 만들어준다.
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// 1. 기본 사용법
@EntityGraph(attributePaths = {"customer"})
List<Order> findByOrderDate(LocalDate orderDate);
// 2. 여러 연관관계 동시에
@EntityGraph(attributePaths = {"customer", "pizza"})
List<Order> findAll();
// 3. 중첩된 연관관계
@EntityGraph(attributePaths = {"customer.address"})
List<Order> findByCustomerName(String customerName);
}
3. @BatchSize
어차피 여러 개 조회할 거면 한 번에 묶어서 가져오자는게 BatchSize이다.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@BatchSize(size = 10) // 10개씩 묶어서 조회
private Customer customer;
}
100번 개별 쿼리를 작성할 것을 BatchSize를 적용하여 10번의 쿼리로 바꿀 수 있다.
-- 기존: 100번의 개별 쿼리
SELECT * FROM customer WHERE id = 1;
SELECT * FROM customer WHERE id = 2;
-- ... 100번 반복
-- @BatchSize 적용 후: 10번의 배치 쿼리
SELECT * FROM customer WHERE id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
SELECT * FROM customer WHERE id IN (11, 12, 13, 14, 15, 16, 17, 18, 19, 20);
-- ... 10번만 실행
신경써야 할 부분들
1. FetchType 설정 가이드라인
기본적으로 모든 연관관계는 LAZY로 하자.
컬렉션은 항상 LAZY이다.
EAGER은 정말 필요한 경우에만!
2. DTO 직접 조회로 필요한 데이터만
화면에 표시할 최소한의 정보만 필요한 경우
@Query("SELECT new com.example.dto.OrderSummaryDto(" +
"o.id, o.orderNumber, c.name) " +
"FROM Order o JOIN o.customer c")
List<OrderSummaryDto> findOrderSummaries();
public class OrderSummaryDto {
private Long id;
private String orderNumber;
private String customerName;
// 생성자, getter...
}
3. 성능 모니터링
application.yml 설정으로 쿼리를 확인하자.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
정리
프록시: 실제 엔티티 대신 사용되는 가짜 객체, 지연 로딩으로 성능 최적화
N+1 문제: 1번의 쿼리가 N번의 추가 쿼리를 유발하는 성능 문제(1+N이 낫지 않나..)
해결법
- 페치 조인: JOIN FETCH로 한번에 조회
- @EntityGraph: 어노테이션으로 간편하게
- @BatchSize: 여러 개를 묶어서 조회
처음엔 모든 연관관계를 LAZY로 설정.
성능 문제가 발생하는 지점을 찾아서 선택적으로 최적화.
쿼리 개수를 항상 모니터링.
복잡한 조회는 DTO 직접 조회.
다음 시간엔 김영한 강사님의 JPA 활용 2편을 보면서 공부한 페치조인 V2부터 V6까지 직접 비교해가면서 성능 최적화에 대해 더 깊게 이해하는 시간을 가져보겠다.
'Backend > JPA' 카테고리의 다른 글
SQL 문법을 JPA로 (1) | 2025.05.26 |
---|---|
API 개발에서 엔티티를 노출하면 안되는 이유 (0) | 2025.05.23 |
[JPA] 다대일과 일대다 연관관계 매핑 이해하기 (0) | 2025.04.11 |
[JPA] 간단한 문제 해결(회원가입,조회) (0) | 2025.04.10 |
[JPA] 영속성 컨텍스트부터 엔티티 생명주기까지 확실히 알자! (0) | 2025.04.07 |