티스토리 뷰
DB의 테이블들은 외래키를 사용해 테이블끼리 관계를 맺게 된다. 이를 JPA에서는 @OneToMany
, @ManyToOne
등의 애노테이션을 사용해 엔티티 클래스 간의 연관관계로 매핑시키고, 매핑된 엔티티(객체)를 대상으로 쿼리를 날릴 수 있다. 이 때문에 기존의 SQL로 조회하는 것보다 객체 지향적으로 개발할 수 있다.
하지만 지연 로딩 설정, N+1 문제, 페치 조인 최적화 등 고려해야 할 부분이 많다. 특히 @__ToOne
관계를 가지는 엔티티를 함께 조회해야 할 때, 생각한 것과 다른 결과가 도출돼 장애가 나거나 성능도 안 나오게 된다.
본 포스팅에서는 조회하고 싶은 엔티티(루트 엔티티)가 1:N 관계를 가지는 컬렉션 객체를 필드로 가지고 있을 때 어떤 부분들을 주의해야 하고, 어떻게 최적화할 수 있는 지 다뤄보겠다.
(전체 예제 코드 참고 : https://github.com/on1ystar/jpa-study/tree/blog)
도메인 모델 예제
본문에 들어가기에 앞서 여러 연관관계를 가지는 간단한 예제 하나를 소개하겠다.

위 그림에서 우리가 집중해서 볼 클래스는 주문(Order
) 클래스다. 주문 클래스는 다음과 같은 연관관계를 맺고 있다.
- 회원(
Member
)과 N:1(다대일) 관계 - 주문상품(
OrderItem
)과 1:N(일대다) 관계
클라이언트에게 전달하는 방식은 API로, JSON 데이터를 응답한다고 가정한다. 우리가 만들어야할 API는 주문 전체 내역을 응답하는 것으로 자세한 요구사항은 다음과 같다.
- 전체 주문된 내역 조회
- 어떤 회원이 주문했는지 알아야 함
- 회원 이름만 필요
- 어떤 상품을 주문했는지 알아야 함
- 상품 id와 상품명만 필요
- 만약 주문된 내역이 10개가 넘어가면, 10개 단위로 페이징 처리 (뒷부분에서 다룸)
- 어떤 회원이 주문했는지 알아야 함
편의를 위해 더미 데이터들을 미리 넣어 놨다.
- 회원 1명이 2개의 주문
- 각 주문 당 2개의 아이템을 담음

그리고 예제를 간단히 하기 위해 몇 가지 조치를 하겠다.
- 각 도메인 클래스 구현 및 세세한 Repository 코드는 본 포스팅의 핵심이 아니기 때문에 생략
- Service 클래스도 복잡한 비즈니스 로직 없이 단순히 Repository 결과를 전달하는 식이 된다면, 이를 생략하고 Controller에서 Repository를 바로 접근하도록 유연하게 작성
- 모든 Fetch 전략은 지연 로딩으로 설정
(Fetch 전략(즉시/지연 로딩)에 대한 자세한 내용은 [JPA] Fetch 전략(Eager/Lazy)과 Fetch join으로 N+1 문제 해결하기 참고)
JPA에서 엔티티 클래스로 조회
먼저 가장 간단한 구현 방법을 떠올려 보면, Order
엔티티를 직접 조회하는 것이다.
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public List<Order> findAll() {
return em.createQuery("select o from Order o", Order.class)
.getResultList();
}
}
OrderRepository
에서는 가장 단순한 방식으로 Order
엔티티 전체를 조회하는 JPQL을 날려 List<Order>
를 반환한다. JPA는 앞서 언급했듯이 엔티티를 대상으로 쿼리하게 때문에, DB에서 조회한 데이터들을 Order
엔티티의 각 필드에 이쁘게 담아줄 것이다.
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/orders")
public Result<List<Order>> ordersV1() {
return new Result<>(orderRepository.findAll());
}
}
이를 컨트롤러에서 받은 뒤 반환하기만 하면 된다. 실행 결과를 확인해 보면 에러가 발생한다.
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: jpabook.jpashop2.api.Result["data"]->java.util.ArrayList[0]->jpabook.jpashop2.domain.Order["member"]->jpabook.jpashop2.domain.Member$HibernateProxy$qdRF4jIT["hibernateLazyInitializer"])
이는 회원 엔티티의 Fetch 전략을 지연 로딩(FetchType.LAZY
)으로 설정돼 있기 때문에 처음 주문 엔티티를 DB에서 가져올 때, 회원 필드를 ByteBuddyInterceptor
라는 프록시 객체로 할당한다. 하지만 객체를 JSON으로 변환해주는 jackson 라이브러리는 이 프록시 객체를 모르기 때문에 에러가 발생한다.
그러면 지연 로딩이 아닌 즉시 로딩을 사용하면 되지 않느냐? 틀린 말은 아니다. Fetch 전략을 즉시 로딩으로 변경하면 당장 이 문제는 해결된다. 하지만 1:N 관계를 가진 엔티티를 즉시 로딩으로 설정하는 것은 매우매우 위험하다.
Hibernate6Module
즉시 로딩을 사용하지 않고, 위 문제를 해결할 수 있는 방법으로 Hibernate6Module
이 있다. Jackson의 jackson-datatype-hibernate 프로젝트에 포함된 모듈로, Hibernate 6에서 사용하는 객체(특히 지연 로딩된 엔티티)를 JSON으로 직렬화할 때 발생할 수 있는 여러 문제들을 해결해 준다.
(공식 문서 : https://github.com/FasterXML/jackson-datatype-hibernate)
이를 사용하기 위해서는 먼저 build.gradle에 의존성을 추가해야 한다.
// https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-hibernate6/2.18.3
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate6'
그 다음 Hibernate6Module
객체를 반환해 스프링 빈으로 등록해 준다.
@Configuration
public class JacksonConfig {
@Bean
Hibernate6Module hibernate6Module() {
return new Hibernate6Module();
}
}
Hibernate6Module
은 프록시 객체를 직렬화 하지 않고 무시하거나 강제 초기화를 시킬 수 있다. 위와 같이 단순히 객체만 생성하면 프록시 객체를 null
로 직렬화한다. 이제 다시 API를 호출해 보자.

정상 호출되지만, 초기화 되지 않은 엔티티들은 null
로 반환됐다.
프록시 객체 강제 초기화
프록시 객체를 강제로 초기화하는 가장 간단한 방법은 해당 객체에 접근하는 로직을 추가해 주면 된다. 그러면 JPA가 프록시 객체에 실제 엔티티를 로드하기 위해 다시 쿼리를 날리게 된다.
@GetMapping("/api/v1/orders")
public Result<List<Order>> ordersV1() {
List<Order> findOrders = orderRepository.findAll();
findOrders.forEach(o -> {
o.getMember().getName(); //Member 강제 초기화
o.getOrderItems().forEach(oi -> oi.getItem().getName()); //OrderItem, Item 강제 초기화
});
return new Result<>(findOrders);
}
그런데 실제 결과를 보면 예상치 못한 응답 데이터를 반환한다.

이는 주문 엔티티와 주문상품 엔티티가 서로 양방향 연관관계이기 때문이다. 이렇게 되면, 서로가 서로를 조회하는 쿼리를 무한대로 호출하는 무한 참조가 발생한다.
- 주문 엔티티는 주문상품 필드를 채우기 위해 주문상품을 조회하는 쿼리 실행
- 조회된 주문상품 엔티티는 다시 주문 필드를 채우기 위해 주문을 호출하는 쿼리 실행
- 1~2를 무한히 반복
(참고 : https://www.baeldung.com/jackson-bidirectional-relationships-and-infinite-recursion )
@JsonIgnore
이럴 때는 양쪽 필드 중 한 곳에 직렬화를 무시하는 @JsonIgnore
애노테이션을 추가해야 한다.
(참고 : https://github.com/FasterXML/jackson-annotations/wiki/Jackson-Annotations)
// ...
public class OrderItem {
private OrderItem(Item item, int orderPrice, int count) {
this.item = item;
this.orderPrice = orderPrice;
this.count = count;
}
@JsonIgnore
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "order_id")
private Order order;
}

이번에는 정상 응답된 걸 확인할 수 있다. 그림에서는 좀 잘리긴 했지만, 응답된 데이터 결과도 회원1이 총 2개의 주문을 했고, 각 주문당 2개의 상품이 담겨있는걸 확인할 수 있다.
다만, 요구사항을 보면 상품 데이터는 상품 id와 상품명만 응답하면 되는데 필요 없는 필드까지 응답됐다. 이를 필터링하는 가장 간단한 방법은 방금 사용했던 @JsonIgnore
를 각 필드에 붙여주면 된다.
하지만 위 방식은 다음과 같은 문제점들이 있다.
- 도메인 클래스에 특정 API와 관련된
@JsonIgnore
같은 애노테이션을 붙이면, 스펙이 다른 API에서 해당 필드를 사용할 수 없게 되는 상황 발생 - 가장 큰 문제점은 실제 엔티티를 반환하는 것이 보안 상으로도 좋지 않고, 엔티티가 변경되면 API 스펙도 같이 변경돼야 하기 때문에 유지보수 측면에서 매우 안 좋음
- N+1 문제 발생(뒤에서 자세히 설명)
때문에 정석은 엔티티가 아닌 DTO 같은 클래스로 변환해서 반환하는게 이상적이다.
(참고 : DTO를 사용하는 이유)
💡 컨트롤러 반환 타입을 보면 List를 Result라는 클래스로 감싸서 반환했다. 리스트를 그대로 반환해 버리면, 자바의 리스트가 JSON의 배열로 변환된다. 이러면 추후 다른 메타 데이터를 추가로 반환하는 등의 API 스펙 변경이 있을 때 유지보수 하기가 어려워진다.
대신에 응답 데이터를 별도의 클래스로 감싸서 제네릭 타입으로 반환하면, 추후 스펙이 변경되더라도 Result 클래스에 추가된 스펙에 맞게 필드를 추가해 주기만 하면 된다.
DTO 변환
JPA에서 조회한 엔티티를 DTO로 변환하는 작업을 해 보자.
@Getter @Setter
class OrderDto {
private Long orderId;
private String memberName;
private List<OrderItemDto> orderItems;
public OrderDto(Order order) {
this.orderId = order.get();
this.memberName = order.getMember().getName();
this.orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
}
}
@Getter @Setter
class OrderItemDto {
private Long itemId;
private String name;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
this.itemId = orderItem.getId();
this.name = orderItem.getItem().getName();
this.orderPrice = orderItem.getOrderPrice();
this.count = orderItem.getCount();
}
}
각 DTO 클래스에 API 스펙에 맞는 데이터만 담을 수 있도록 했고, 생상자를 통해 엔티티를 변환하게 했다.
@GetMapping("/api/v2/orders")
public Result<List<OrderDto>> ordersV2() {
List<Order> findOrders = orderRepository.findAll();
return new Result<>(findOrders.stream()
.map(OrderDto::new)
.toList());
}
컨트롤러에서는 위와 같이 조회한 주문 리스트를 OrderDto
로 변환해 주면 된다.
//응답 결과
{
"data": [
{
"orderId": 1,
"memberName": "회원1",
"orderItems": [
{
"itemId": 1,
"name": "책1",
"orderPrice": 10000,
"count": 1
},
{
"itemId": 2,
"name": "책2",
"orderPrice": 10000,
"count": 1
}
]
},
{
"orderId": 2,
"memberName": "회원1",
"orderItems": [
{
"itemId": 3,
"name": "책3",
"orderPrice": 20000,
"count": 1
},
{
"itemId": 4,
"name": "책4",
"orderPrice": 20000,
"count": 1
}
]
}
]
}
응답 결과를 확인해 보면, API 스펙에 맞게 잘 응답한 것을 확인할 수 있다.
💡 위와 같은 강제 초기화 방식은 OSIV(Open Session In View)를 끈 상태에서는 에러가 발생한다. OSIV 전략은 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다.
하지만 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기 때문에, 실시간 트래픽이 중요한 애플리케이션에서는 OSIV 설정을 꺼서 최적화를 한다.
이렇게 되면 컨트롤러에서 반환 받은 Order 엔티티는 준영속 상태가 된다. 이 때문에 프록시 객체를 접근하지 못하고 LazyInitializationException 예외를 던진다.
이를 해결하기 위한 대표적인 방안은 조인이나 페치 조인 등을 사용해 DB에서 한 번에 조회하거나, 서비스 계층에서 트랜젝션을 시작하고, 종료되기 전에 필요한 엔티티 데이터를 미리 로드시켜서 컨트롤러에 넘겨줘야 한다.
N + 1 문제
위 방식에서는 치명적인 1가지 문제가 있다. 주문 엔티티를 생성하기 위해 실행된 쿼리들을 확인해 보자.
2025-03-26T05:26:02.365+09:00 DEBUG 54618 --- [jpashop2] [nio-8080-exec-2] org.hibernate.SQL :
select
o1_0.order_id,
o1_0.member_id
from
orders o1_0
2025-03-26T05:26:02.369+09:00 DEBUG 54618 --- [jpashop2] [nio-8080-exec-2] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.name
from
member m1_0
where
m1_0.member_id=?
2025-03-26T05:26:02.371+09:00 DEBUG 54618 --- [jpashop2] [nio-8080-exec-2] org.hibernate.SQL :
select
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
oi1_0.item_id,
oi1_0.order_price
from
order_item oi1_0
where
oi1_0.order_id=?
2025-03-26T05:26:02.372+09:00 DEBUG 54618 --- [jpashop2] [nio-8080-exec-2] org.hibernate.SQL :
select
i1_0.item_id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.item_id=?
2025-03-26T05:26:02.373+09:00 DEBUG 54618 --- [jpashop2] [nio-8080-exec-2] org.hibernate.SQL :
select
i1_0.item_id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.item_id=?
2025-03-26T05:26:02.373+09:00 DEBUG 54618 --- [jpashop2] [nio-8080-exec-2] org.hibernate.SQL :
select
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
oi1_0.item_id,
oi1_0.order_price
from
order_item oi1_0
where
oi1_0.order_id=?
2025-03-26T05:26:02.374+09:00 DEBUG 54618 --- [jpashop2] [nio-8080-exec-2] org.hibernate.SQL :
select
i1_0.item_id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.item_id=?
2025-03-26T05:26:02.374+09:00 DEBUG 54618 --- [jpashop2] [nio-8080-exec-2] org.hibernate.SQL :
select
i1_0.item_id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.item_id=?
위와 같이 총 8개의 쿼리가 실행됐다. 의도한 것은 주문 엔티티를 조회하는 1개의 쿼리인데 7개의 쿼리가 추가로 발생한 것이다. 쿼리가 실행된 흐름은 다음과 같다.
- 전제 주문 조회
- 주문한 회원 조회 (회원1)
- 회원이 주문한 상품 조회 (주문상품)
- 아이템 조회 (책1)
- 아이템 조회 (책2)
- 회원이 주문한 상품 조회 (주문상품)
- 아이템 조회 (책3)
- 아이템 조회 (책4)
- 회원이 주문한 상품 조회 (주문상품)
- 주문한 회원 조회 (회원1)
이렇게 총 8번의 쿼리가 실행된다. 이런 식으로 주문 1개당 N개의 주문 상품이 발생하고, 그 주문 상품에는 또 다시 M가의 상품이 담기게 되므로 1+N+M 개의 쿼리가 실행된다.
이런 식의 N+1 문제는 특히나 위 경우처럼 1:N 관계를 가지는 컬렉션 필드가 있으면 더 심각해 진다. 만약 주문 1개당 10개의 상품이 담긴다면, 주문 1개를 조회하는데 10 + a 개의 쿼리가 발생할 수 있다.
N+1 문제는 즉시 로딩이나 지연 로딩 모두 발생하기 때문에 지연 로딩에서 해결 방법을 찾는 것이 바람직하다. 그 중 가장 대표적인 방법인 페치 조인(Fetch Join)을 알아보자.
페치 조인(Fetch Join)으로 최적화
JPQL의 페치 조인을 사용해서 위 N+1 문제를 해결해 보자.
(페치 조인에 대한 자세한 내용 참고 : https://bnzn2426.tistory.com/160)
// OrderRepository
public List<Order> findAllWithItem() {
return em.createQuery("select o from Order o" +
" join fetch o.member m" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.getResultList();
}
위와 같이 필요한 엔티티를 전부 페치 조인하면 된다. 그러면 결과를 반환받는 시점에는 주문 리스트 엔티티를 생성할 때 필요한 모든 데이터가 각 필드에 대입되어 있다. DB에서 실제 데이터를 조회해 온 것이기 때문에 각 엔티티는 모두 프록시 객체가 아닌 실제 엔티티 객체다. 그렇게 때문에 컨트롤러에서 굳이 프록시 초기화를 할 필요가 없다.
// OrderApiController
@GetMapping("/api/v3/orders")
public Result<List<OrderDto>> ordersV3() {
List<Order> findOrders = orderRepository.findAllWithItem();
return new Result<>(findOrders.stream().map(OrderDto::new).toList());
}
// 생성된 SQL
select
o1_0.order_id,
m1_0.member_id,
m1_0.name,
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
i1_0.item_id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
oi1_0.order_price
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
join
order_item oi1_0
on o1_0.order_id=oi1_0.order_id
join
item i1_0
on i1_0.item_id=oi1_0.item_id
실제 생성된 SQL을 확인해 보면, 각 테이블을 모두 조인한다. 이렇게 페치 조인을 사용하면 1개의 쿼리로 필요한 데이터를 한 번에 로드할 수 있으므로 N+1 문제를 효과적으로 해결할 수 있다. 심지어 주문 엔티티의 각 필드에 대응되는 데이터를 이쁘게 담아 객체로 반환해 주기 때문에 사용하기도 편리하다.
💡 사실 원하는 결과를 얻고 싶으면 프로젝션 부분에 distinct 를 사용해야 한다. 데이터베이스는 조인된 데이터들을 컬럼에 추가하고, 데이터를 로우 단위로 표현한다. 따라서 위 SQL 문을 DB에 그대로 실행하면 총 4개의 로우가 조회된다. 그 과정에서 어쩔 수 없이 주문 데이터의 중복이 생기게 된다.하지만 Hibernate 6 버전부터 페치 조인을 사용한 부모 엔티티(위 예제에서는 Order)가 중복되면 이를 자동으로 제거해 준다.
페이징 문제
컬렉션을 페치 조인하면 치명적인 단점이 일반적인 페이징이 불가능하다. 페이징을 한다면 부모 엔티티인 주문 데이터를 페이징 해야 한다. 그러나 1:N 관계인 주문상품을 조인하기 때문에 사실상 주문상품의 총 개수만큼 데이터가 조회된다.
예를 들어서 100개의 주문 데이터가 있어서 10개씩 페이징 하려고 한다. 하지만 데이터베이스에서 조회되는 데이터는 상품 데이터와 조인되므로 최소 100개 이상이다. 그럼 데이터베이스 입장에서는 row를 기준으로 페이징을 해야하는데, 이러면 원하는 결과와 다르게 된다.
데이터베이스에서 위 쿼리를 그대로 실행한 뒤 결과를 확인해 보자.

쿼리 결과를 보면 총 4개의 row 데이터가 조회된 것을 알 수 있다. 사실 주문은 2개 뿐이지만 각 주문 당 2개의 상품을 주문했기 때문에 이를 모두 조인한 총 4개의 row가 생성된 것이다. 위 결과에서 하이버네이트(JPA)가 주문과 관련된 중복을 제거해 줬기 때문에 우리는 이전에 원하는 결과를 얻을 수 있었다.
하지만 만약 우리가 총 2개의 주문 중 첫 번째 주문만 조회하고 싶어서 페이징을 한다면 어떻게 될까? 코드로 작성하면 다음과 같을 것이다.
public List<Order> findAllWithItem() {
return em.createQuery("select o from Order o" +
" join fetch o.member m" +
" join fetch o.orderItems oi" +
" join fetch oi.item i", Order.class)
.setFirstResult(0) // offset
.setMaxResults(1) // limit
.getResultList();
}
// 생성된 SQL
select
//... 생략
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
join
order_item oi1_0
on o1_0.order_id=oi1_0.order_id
join
item i1_0
on i1_0.item_id=oi1_0.item_id
생성된 SQL을 보면 페이징과 관련된 limit
이나 offset
키워드가 없다. 왜냐하면 하이버네이트 입장에서도 컬렉션 데이터를 페이징 처리하게 되면 예상과 다른 결과가 나온다는 것을 알아서 페이징 관련 쿼리를 생성하지 않는 것이다.
우리가 기대하는 반환 값은 첫 번째 주문 데이터지만, 데이터베이스는 위 4개의 row 중, 첫 번째 row만 조회하게 된다. 그런데 두 번째 row에는 첫 번째 주문의 주문 상품(책2
)에 대한 데이터가 있기 때문에 이러면 데이터 정합성이 깨지게 된다.
그런데 사실 조회 결과를 보면 의도한 대로 첫 번째 주문이 정상 조회된 것을 확인할 수 있다.

생성된 SQL에는 페이징 관련 키워드가 없었는데 어떻게 된 것일까? 콘솔을 보면 WARN
로그가 남겨져 있다.
2025-03-27T00:17:35.704+09:00 WARN 83218 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
firstResult/maxResults specified with collection fetch; applying in memory
하이버네이트가 컬렉션 페치 조인에 지정된 페이징을 메모리에서 적용했다고 경고를 띄워 준다. 이 의미는 데이터베이스가 페이징을 할 수 없으니, 일단 모든 데이터를 메모리에 로드한 뒤, 메모리에서 페이징 처리를 했다는 것이다. 어떻게 보면 참 똑똑한 것 같지만, 사실 하이버네이트가 경고했듯이 상당히 위험하다.
페이징을 하는 주 목적은 데이터베이스에서 한 번에 로드해야 할 데이터가 너무 많을 때 성능 최적화와 서버 부담을 줄이기 위함이다. 즉, 조회해야할 데이터가 수 만, 수십 만 건 이상에 달하는 상황에서 이를 메모리에 다 로드하게 되면 OOM(Out Of Memory) 장애가 터질 수 있다. 따라서 페이징은 데이터베이스가 수행해야 하는데, 그럴 수 없으니까 하이버네이트가 울며 겨자먹기로 수행해 준 것이다. 이러한 사실을 모르거나 간과하면 추후 심각한 장애를 맞닥뜨리게 될 것이다.
hibernate.default_batch_fetch_size(@BatchSize) 설정
컬렉션 엔티티 조회 시 페이징을 적용할 수 있는 방법으로 배치 사이즈 설정이 있다.
hibernate.default_batch_fetch_size
: 글로벌 설정@BatchSize
: 개별 설정
이 기능은 엔티티 로딩 시, 초기화 되지 않은 프록시 객체를 초기화할 때 설정된 크기 만큼을 데이터베이스에서 한 번에 로드한 뒤, 엔티티를 채워 넣는 기능이다. 자세한 설명은 설정한 뒤 예시로 확인해 보자.
설정 방법은 다음과 같다.
// applicatoin.yml 파일로 글로벌 설정
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 5
// ...
public class Order {
// ...
@BatchSize(size = 5) // 애노테이션으로 개별 설정
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItem> orderItems = new ArrayList<>();
// ...
이렇게 설정한 뒤, 두 번째 예시에서 사용했던, 페치 조인 없이 단순히 주문 엔티티를 조회하는 쿼리를 날려 보겠다.
@GetMapping("/api/v2/orders")
public Result<List<OrderDto>> ordersV2() {
List<Order> findOrders = orderRepository.findAll();
return new Result<>(findOrders.stream().map(OrderDto::new).toList());
}
생성된 쿼리를 확인해 보자.
select
o1_0.order_id,
o1_0.member_id
from
orders o1_0
2025-03-27T01:32:08.950+09:00 DEBUG 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.SQL :
select
m1_0.member_id,
m1_0.name
from
member m1_0
where
m1_0.member_id=?
2025-03-27T01:32:08.950+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]
2025-03-27T01:32:08.952+09:00 DEBUG 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.SQL :
select
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
oi1_0.item_id,
oi1_0.order_price
from
order_item oi1_0
where
oi1_0.order_id in (?, ?, ?, ?, ?)
2025-03-27T01:32:08.952+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]
2025-03-27T01:32:08.952+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (2:BIGINT) <- [2]
2025-03-27T01:32:08.952+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (3:BIGINT) <- [null]
2025-03-27T01:32:08.952+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (4:BIGINT) <- [null]
2025-03-27T01:32:08.952+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (5:BIGINT) <- [null]
2025-03-27T01:32:08.955+09:00 DEBUG 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.SQL :
select
i1_0.item_id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.item_id in (?, ?, ?, ?, ?)
2025-03-27T01:32:08.955+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]
2025-03-27T01:32:08.955+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (2:BIGINT) <- [2]
2025-03-27T01:32:08.955+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (3:BIGINT) <- [3]
2025-03-27T01:32:08.955+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (4:BIGINT) <- [4]
2025-03-27T01:32:08.955+09:00 TRACE 91669 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (5:BIGINT) <- [null]
생성된 쿼리가 이전과는 다른 것을 확인할 수 있다. 원래 N개 만큼의 추가 쿼리가 발생해야 하는 주문상품과 상품을 조회하는 쿼리에 in
절이 추가됐다. 바인딩 되는 ?
는 각각 5개로 설정한 배치 사이즈의 크기다. 로그를 잘 보면 어떤 값이 바인딩 되는지 알 수 있는데, 주문 엔티티를 초기화하는 데 필요한 주문상품 id 값과 상품 id 값이다.
이처럼 엔티티 로딩 시 설정한 크기 만큼 미리 데이터를 땡겨서 초기화 해주는 기능이다. 이때, 데이터를 로드시키는 시점은 지연 로딩과 마찬가지로 실제 데이터 값에 접근할 때다. 즉, Repository에서 엔티티를 조회하는 것만으로 로드된다는 것이 아니다. 때문에 이 역시 OSIV 설정이 꺼져있을 때는 데이터 로드 시점을 어떻게 가져가야할 지 고민해 봐야 한다.
💡 사실, 지연 로딩이 아닌 즉시 로딩 전략에서 데이터를 로드시킬 때도 적용된다. 이 때, 배치 사이즈보다 큰 데이터가 필요하면, 배치 사이즈만큼 나눠서 데이터를 로드한다. 즉, 프록시 객체가 아니더라도 엔티티를 초기화하기 위해 데이터를 로드해야할 때 배치 사이즈 설정이 적용된다. 단, JPQL이나 네이티브 SQL에 where 절로 어떤 데이터들을 가져올 지 직접 명시하지 않았을 경우에만 적용된다.
💡 위 예제에서 굳이 null 값을 대입해서라도 바인딩 되는 ? 의 개수를 배치 사이즈로 설정한 5개로 고정한 이유는 성능 최적화 때문이다.
데이터베이스는 SQL 구문을 이해하는 과정을 최적화하기 위해 이전에 실행된 SQL 구문에 대한 정보를 내부에 캐싱하고 있다. 그러면 이후에 같은 모양의 SQL이 실행되어도 이미 파싱된 결과를 그대로 사용해서 성능을 최적화 할 수 있다. 때문에 ? 의 개수를 일정하게 맞춰 놓으면, 그 안에 대입되는 값이 달라지더라도 이전에 캐싱 된 SQL 구문 자체에 대한 정보를 사용할 수 있다.
이 배치 사이즈를 잘 사용하면 조회해야 할 데이터가 아무리 많아도 설정한 크기 만큼만 조히한 뒤 메모리에 로드하기 때문에 메모리 초과 걱정이 없다. 또한, 각 테이블의 인덱스로 설정된 pk 값을 in 절에 사용하므로 성능상에서도 최적화가 가능하다.
이걸 컬렉션 페이징과 어떻게 연관시켜야 할까?? 조회해야 하는 엔티티가 어떤 연관관계를 가지냐에 따라서 전략을 다르게 가져가야 된다.
- N:1 관계인 엔티티들은 페치 조인으로 최적화
- 1:N 관계인 엔티티들은 배치 사이즈로 최적화
기존의 N:1 관계인 엔티티들은 페치 조인을 사용함으로써 1개의 쿼리로 필요한 데이터들을 로드시키면 된다. 이후, 1:N 관계인 데이터들은 설정된 배치 사이즈 만큼 알아서 초기화될 것이다. 예를 들어, 다음과 같이 쿼리를 짜면 된다.
// offset, limit 파라미터 추가
public List<Order> findAllWithItem(int offset, int limit) {
return em.createQuery("select o from Order o" +
" join fetch o.member m", Order.class)
.setFirstResult(offset) // offset
.setMaxResults(limit) // limit
.getResultList();
}
@GetMapping("/api/v4/orders")
public Result<List<OrderDto>> ordersV4() {
List<Order> findOrders = orderRepository.findAllWithItem(0, 1);
return new Result<>(findOrders.stream().map(OrderDto::new).toList());
}
위와 같이 회원 엔티티와는 페치 조인을 사용해 한 번에 데이터를 로드시킨 뒤, 여기에 페이징 관련 로직을 적용시킨다. 여기에서 조회된 주문 데이터에는 중복이 없기 때문에 생성된 SQL에도 페이징 처리가 들어가 원하는 결과를 얻을 수 있다. 그런 다음, 주문상품과 상품 엔티티는 DTO로 변환할 때, 설정한 배치 사이즈로 초기화를 하면 된다.

응답 결과 원하는 데로 첫 번째 주문만 페이징된 것을 확인할 수 있다. 생성된 쿼리를 보면 총 3개가 생성됐다.
2025-03-27T02:04:28.386+09:00 DEBUG 95263 --- [jpashop2] [nio-8080-exec-1] org.hibernate.SQL :
select
o1_0.order_id,
m1_0.member_id,
m1_0.name
from
orders o1_0
join
member m1_0
on m1_0.member_id=o1_0.member_id
offset
? rows
fetch
first ? rows only
2025-03-27T02:04:28.389+09:00 TRACE 95263 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (1:INTEGER) <- [0]
2025-03-27T02:04:28.389+09:00 TRACE 95263 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (2:INTEGER) <- [1]
2025-03-27T02:04:28.392+09:00 DEBUG 95263 --- [jpashop2] [nio-8080-exec-1] org.hibernate.SQL :
select
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
oi1_0.item_id,
oi1_0.order_price
from
order_item oi1_0
where
oi1_0.order_id=?
2025-03-27T02:04:28.393+09:00 TRACE 95263 --- [jpashop2] [nio-8080-exec-1] org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [1]
2025-03-27T02:04:28.397+09:00 DEBUG 95263 --- [jpashop2] [nio-8080-exec-1] org.hibernate.SQL :
select
i1_0.item_id,
i1_0.name,
i1_0.price,
i1_0.stock_quantity
from
item i1_0
where
i1_0.item_id in (?, ?, ?, ?, ?)
- 첫 번째 쿼리 : 주문과 회원을 조인한 뒤 페이징 적용
- 두 번째 쿼리 : 배치 사이즈 만큼 주문상품을 조회해야 하지만 1개 이므로 조건절로 최적화
- 세 번째 쿼리 : 배치 사이즈 만큼 아이템 조회
만약 limit
값을 2 이상으로 주면, 두 번째 쿼리에서도 주문상품을 미리 5개 로드시키기 위해 in
절을 사용한다.
이처럼 배치 사이즈를 사용하면 N+1 문제도 1+1로 최적화할 수 있다. 물론 페치 조인을 사용하면 하나의 쿼리로 조회가 가능하지만, 페이징도 불가능할 뿐더러 데이터 전송량 차이도 있다.
페치 조인은 여러 번의 조인 때문에 조회해야 할 데이터 필드가 많아져서 한 번에 전송해야 할 데이터 전송량이 커진다. 또한 컬렉션을 페치 조인하면 중복 데이터도 생긴다. 하지만 위 방식은 중복 데이터를 없애고, 조금씩 나눠서 데이터를 로드할 수 있기 때문에 데이터 전송에 대한 부담도 적다.
💡 default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다. 메모리 크기나 데이터베이스 환경에 따라 다르므로, 직접 테스트를 해 보고 적당한 사이즈를 선택하는걸 추천한다.
References
- 자바 ORM 표준 JPA 프로그래밍 - 김영한
- https://www.inflearn.com/course/스프링부트-JPA-API개발-성능최적화/dashboard
- https://www.baeldung.com/jackson-bidirectional-relationships-and-infinite-recursion
- https://github.com/FasterXML/jackson-datatype-hibernate
- https://github.com/FasterXML/jackson-annotations/wiki/Jackson-Annotations
'JSP&Servlet' 카테고리의 다른 글
Session (0) | 2019.01.24 |
---|---|
Cookie (0) | 2019.01.23 |
Servlet 데이터 공유 (1) | 2019.01.22 |
JSP 내장객체 (0) | 2019.01.21 |
JSP request, response (0) | 2019.01.20 |
- Total
- Today
- Yesterday
- Thymeleaf
- 방명록 프로젝트
- 선형 회귀
- Computer_Networking_A_Top-Down_Approach
- 쉽게 배우는 운영체제
- 스프링 컨테이너
- jsp
- 쉘 코드
- 파이썬 for Beginner 솔루션
- JPA
- Gradle
- git branch
- Python Cookbook
- 패킷 스위칭
- Spring Boot
- 지옥에서 온 git
- 김영환
- 운영체제 반효경
- 프로그래머스
- git
- Do it! 정직하게 코딩하며 배우는 딥러닝 입문
- Spring Data JPA
- Spring
- 스프링 mvc
- fetch join
- 스프링
- git merge
- 파이썬 for Beginner 연습문제
- 스프링 테스트
- 생활코딩 javascript
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |