N+1 문제

첫 번째 목록을 조회하는 1번의 쿼리와 그 외 정보를 가져오기 위해서 N번의 쿼리가 실행되는 문제를 N+1 문제라고 한다.

 

아래의 코드 경우에는 주문 조회 1번, 회원 조회 N번, 배송 조회 N번이 발생한다. 주문 조회 결과 값이 2라고 가정한다면, 최악의 경우 1(주문) + 2(회원) + 2(배송) = 5 총 5번의 쿼리가 실행된다.

 

앞에서 최악의 경우라고 말한 것은 지연로딩은 기본적으로 영속성 컨텍스트에서 먼저 조회하므로 이미 조회된 경우에 쿼리를 생략한다. 만약 가져온 회원 정보가 이미 있는 경우 데이터베이스에 회원 정보를 조회하는 쿼리를 실행하지 않지만, 최악의 경우에는 매번 데이터베이스에 회원 정보를 조회하는 쿼리를 실행할 수 있다.

 

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
    // Order 조회 SQL 1번 실행 -> 2개 주문서 반환
    List<Order> orders = orderRepository.findAllByString(new OrderSearch());

    // 2개의 주문서가 있으므로 2번 루프를 반복함
    // 하나의 SimpleOrderDto 생성할 때마다 Member, Delivery 쿼리 2번 실행
    return orders.stream().map(SimpleOrderDto::new)
        .collect(toList());
}

 

이러한 문제가 발생한 이유는 Order 엔티티와 연관 관계가 있는 Member, Delivery 엔티티가 FetchType.LAZY으로 설정되어 있기 때문이다. 그렇다면 FetchType.EAGER로 변경하면 문제를 임시적으로 해결한 것처럼 보일 수 있다. 가장 좋은 해결 방법으로는 Fetch 조인을 사용해서 튜닝을 해야 한다.

Fetch Join

SQL 조인을 활용해서 연관된 엔티티를 한번의 SQL로 모두 조회하는 기능이다.

 

public List<Order> findAll() {
    return em.createQuery(
        "select o from Order o" +
            "join fetch o.member m" +
            "join fetch o.delivery d",Order.class
    ).getResultList();
}

 

실제 수행되는 쿼리는 다음과 같다.

 

select
    order0_.order_id as order_id1_6_0_,
    member1_.member_id as member_i1_4_1_,
    delivery2_.delivery_id as delivery1_2_2_,
    order0_.delivery_id as delivery4_6_0_,
    order0_.member_id as member_i5_6_0_,
    order0_.order_date as order_da2_6_0_,
    order0_.status as status3_6_0_,
    member1_.city as city2_4_1_,
    member1_.street as street3_4_1_,
    member1_.zip_code as zip_code4_4_1_,
    member1_.name as name5_4_1_,
    delivery2_.city as city2_2_2_,
    delivery2_.street as street3_2_2_,
    delivery2_.zip_code as zip_code4_2_2_,
    delivery2_.status as status5_2_2_ 
from
    orders order0_ 
inner join
    member member1_ 
        on order0_.member_id=member1_.member_id 
inner join
    delivery delivery2_ 
        on order0_.delivery_id=delivery2_.delivery_id

JPA에서 DTO로 바로 조회

쿼리 실행 결과 값을 받을 DTO 클래스 OrderSimpleQueryDto를 생성한다.

 

@Data
public class OrderSimpleQueryDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

 

생성자를 이용해서 쿼리 결과 값을 받는다. 이 메서드의 단점은 API에서 사용할 필드 값들이 노출되고 포함되어 있다는 점이다. 따라서 API 변경에 따라 DTO 클래스와 쿼리를 수정해야 한다. 복잡한 쿼리를 조회하는 경우에는 새로운 Repository 클래스를 생성해서 그 곳에 메서드를 모아 놓는 것이 유지보수 또는 코드 파악에 도움이 된다.

 

public List<OrderSimpleQueryDto> findOrderDtos() {
    return em.createQuery(
        "select new com.jayden.shop.repository.order.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
            "from Order o " +
            "join o.member m " +
            "join o.delivery d", OrderSimpleQueryDto.class
    ).getResultList();
}

 

실제 수행되는 쿼리는 다음과 같다.

 

select
    order0_.order_id as col_0_0_,
    member1_.name as col_1_0_,
    order0_.order_date as col_2_0_,
    order0_.status as col_3_0_,
    delivery2_.city as col_4_0_,
    delivery2_.street as col_4_1_,
    delivery2_.zip_code as col_4_2_ 
from
    orders order0_ 
inner join
    member member1_ 
        on order0_.member_id=member1_.member_id 
inner join
    delivery delivery2_ 
        on order0_.delivery_id=delivery2_.delivery_id

쿼리 방식 선택 순서

  1. 엔티티를 DTO로 변환한 정보를 내려준다.
  2. 성능 최적화가 필요하면 페치 조인을 사용한다.
  3. DTO로 직접 조회하는 방법을 통해 조회하는 컬럼 개수를 줄인다.
  4. JPA가 제공하는 네이티브 SQL 또는 JDBC Template을 사용해서 SQL을 직접 사용한다.

컬렉션 조회 최적화

Fetch Join

OneToMany 관계의 엔티티들을 Fetch Join 하게 되면, 결과값으로는 One에 해당하는 엔티티 정보가 Many 개수만큼 나오게 된다.

 

orders와 order_item 두 테이블을 조인하면 4개의 레코드가 출력된다. 현재 데이터는 orders 테이블에는 행 2개, order_items 테이블에는 행 4개가 있다.

 

다음과 같이 작성한 코드를 실행하면 반환값으로 Order 엔티티 4개를 갖고 있는 리스트가 반환된다. 실제 필요한 Order 엔티티는 2개임에도 불구하고 중복을 포함해서 4개의 엔티티가 반환된다.

 

public List<Order> findAllWithItems() {
    return em.createQuery(
        "select o from Order o " +
            "join fetch o.member m " +
            "join fetch o.delivery d " +
            "join fetch o.orderItems oi " +
            "join fetch oi.item i", Order.class
    ).getResultList();
}

 

이를 해결하기 위해서 queryString 값에 distinct 키워드를 추가한다. jpa에는 distinct 키워드가 있으면 중복 엔티티를 제거해주고, DB에 날리는 쿼리에도 distinct 문장을 추가해준다.

 

public List<Order> findAllWithItems() {
    return em.createQuery(
        "select distinct o from Order o " +
            "join fetch o.member m " +
            "join fetch o.delivery d " +
            "join fetch o.orderItems oi " +
            "join fetch oi.item i", Order.class
    ).getResultList();
}

 

일대다 관계에서 Fetch Join 하게 되면 페이징 쿼리가 불가능한 단점이 있다. 페이징 쿼리를 날리기 위해서 offset, limit을 설정하면 하이버네이트는 경고 로그를 남기고 메모리에서 페이징을 한다. 실제 DB에 실행되는 쿼리에도 페이징 정보가 담기지 않고 모든 정보를 가져온다.

페이징과 한계 돌파

컬렉션은 지연 로딩으로 조회해서 페이징 처리를 한다. 지연 로딩 최적화와 페이징을 위해서 hibernate.default_batch_fetch_size 또는 @BatchSize를 적용한다.

 

주문과 주문 목록은 일대다 관계이므로 지연 로딩을 통해 조회한다. 객체 그래프로 탐색하는 순간에 주문 목록에 해당하는 쿼리를 실행하는데, 위에서 말한 옵션을 활성화하면 주문 목록 아이템을 정해진 크기만큼 한번에 조회하는 쿼리를 실행한다.

 

# order_item 조회
select
    orderitems0_.order_id as order_id5_5_1_,
    orderitems0_.order_item_id as order_it1_5_1_,
    orderitems0_.order_item_id as order_it1_5_0_,
    orderitems0_.count as count2_5_0_,
    orderitems0_.item_id as item_id4_5_0_,
    orderitems0_.order_id as order_id5_5_0_,
    orderitems0_.order_price as order_pr3_5_0_ 
from
    order_item orderitems0_ 
where
    orderitems0_.order_id in (
        ?, ?
    );

# item 조회
select
    item0_.item_id as item_id2_3_0_,
    item0_.name as name3_3_0_,
    item0_.price as price4_3_0_,
    item0_.stock_quantity as stock_qu5_3_0_,
    item0_.actor as actor6_3_0_,
    item0_.director as director7_3_0_,
    item0_.artist as artist8_3_0_,
    item0_.etc as etc9_3_0_,
    item0_.author as author10_3_0_,
    item0_.isbn as isbn11_3_0_,
    item0_.dtype as dtype1_3_0_ 
from
    item item0_ 
where
    item0_.item_id in (
        ?, ?, ?, ?
    )

 

xToOne 관계는 Fetch Join으로 조회 최적화를 적용하고, xToMany 관계에서는 지연 로딩과 hibernate.default_batch_fetch_size 또는 @BatchSize를 적용해서 조회 성능을 최적화한다.

컬렉션 조회 최적화

XToOne 관계는 Fetch Join을 이용해서 가져오고, XToMany 관계인 엔티티의 경우에 지연 로딩을 통해서 정보를 가져온다. 아래 코드에서 findOrderItemMap 메서드는 주문 아이템 목록을 1번의 쿼리를 통해 모두 가져온다. 단순히 반복문을 돌려서 주문 아이템 정보를 가져오게 되면 N + 1 문제가 발생한다.

 

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    public List<OrderQueryDto> findAll() {
        List<OrderQueryDto> result = findOrders();

        List<Long> orderIds = toOrderIds(result);

        // 주문 아이템 목록을 1번의 쿼리로 가져와서 메모리에서 처리한다
        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(orderIds);

        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

        return result;
    }
    private List<OrderQueryDto> findOrders() {
        return em.createQuery(
            "select new com.jayden.shop.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address) from Order o " +
                "join o.member m " +
                "join o.delivery d", OrderQueryDto.class)
            .getResultList();
    }

    private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
        List<OrderItemQueryDto> orderItems = em.createQuery(
            "select new com.jayden.shop.repository.order.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) from OrderItem oi " +
                "join oi.item i " +
                "where oi.order.id in :orderIds", OrderItemQueryDto.class)
            .setParameter("orderIds", orderIds)
            .getResultList();

        return orderItems.stream()
            .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
    }

    private List<Long> toOrderIds(List<OrderQueryDto> result) {
        return result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());
    }

}

플랫 데이터 최적화

단 1번의 쿼리로 모든 데이터를 가져온다. 조인으로 인해서 중복 데이터가 반환되기 때문에 메모리에서 중복을 제거하는 로직이 추가되어야 한다. 조인된 쿼리를 실행하기 때문에 원하는대로 페이징 처리가 불가능하다.

 

// 주문과 주문 아이템 정보를 하나의 클래스에 Flat하게 모두 담는다.
List<OrderFlatDto> flats = orderQueryRepository.findAllByDtoFlat();

// 조인된 결과 데이터를 메모리에서 그룹핑 로직을 추가하면서 중복 데이터를 제거하고 API 스펙에 맞게 변경한다.
List<OrderQueryDto> data = flats.stream()
    .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(),
            o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
        mapping(o -> new OrderItemQueryDto(o.getOrderId(),
            o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
    )).entrySet().stream()
    .map(e -> new OrderQueryDto(e.getKey().getOrderId(),
        e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(),
        e.getKey().getAddress(), e.getValue()))
    .collect(toList());

API 개발 고급 정리

Repository에서 데이터를 가져오고 나서 엔티티 조회 또는 DTO 직접 조회 두 가지 방법이 있다. 성능이 나오지 않은 경우에 Fetch Join을 이용해서 성능을 최적화한다. 다만, 컬렉션의 경우에 Fetch Join을 하게 되면 페이징 처리가 불가능해진다.

 

컬렉션은 Fetch Join 대신 지연 로딩을 유지하고, hibernate.default_batch_fetch_size 또는 @BatchSize로 최적화한다.

 

  • 엔티티 조회
  • DTO 직접 조회

권장 순서

1.엔티티 조회 방식으로 접근

  • Fetch Join으로 쿼리 수를 최적화
  • 컬렉션 최적화
    1.페이징 필요: 옵션 사용해서 최적화
    2.페이징 필요없음: Fetch Join 사용

2.엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용

3.DTO 조회 방식으로 해결이 안되면 Native SQL or Spring JdbcTemplate 사용

OSIV(Open Session In View)와 성능 최적화

OSIV(Open Session In View)

spring.jpa.open-in-view 설정은 true가 기본값이다.

 

OSIV 설정 값이 true이면, 트랜잭션 범위가 끝나도 영속성 컨텍스트를 API 응답 및 뷰 템플릿 페이지에 렌더링 할 때까지 유지한다. 이러한 이유로 뷰 템플릿 또는 API 컨트롤러에서 지연 로딩이 가능한 것이다.

 

지연 로딩은 영속성 컨텍스트가 살아 있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다. 이 부분이 장점이자 단점이다.

 

단점으로는 오랜 시간 동안에 데이터베이스 커넥션 리소스를 사용하기 때문에 실시간 트래픽이 중요한 애플리케이션에서 커넥션이 부족해서 장애가 발생할 수 있다.

 

OSIV 설정 값을 false로 변경하면 트랜잭션 범위에서만 영속성 컨텍스트를 유지한다. 컨트롤러에서는 이미 영속성 컨텍스트가 닫히고 커넥션 리소스를 데이터베이스에 반납했기 때문에 트랜잭션 범위 밖에서 지연 로딩을 할 수 없다.

 

장점으로는 커넥션 리소스를 짧은 기간 동안만 사용한다는 것이다.

 

단점으로는 지연 로딩 관련된 코드를 모두 트랜잭션 안에서 처리하도록 해야 한다. 뷰 템플릿에서도 지연 로딩이 동작하지 않기 때문에 트랜잭션이 끝나기 직전에 지연 로딩을 강제로 호출해야 한다.

커맨드와 쿼리 분리

실무에서 OSIV 설정 값을 끈 상태에서 복잡성을 관리하는 좋은 방법은 커맨드와 쿼리를 분리하는 것이다.

 

@Enumerated(EnumType.STRING)​
EnumType.ORDINAL : enum 순서 값을 DB에 저장
EnumType.STRING : enum 이름을 DB에 저장

변경 감지와 병합(merge)

변경 감지 기능 사용

@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
	Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한 다.
	findItem.setPrice(itemParam.getPrice()); 
    //데이터를 수정한다. 
}

먼저, find를 통해 해당 Id를 가진 데이터를 조회하고, 파라미터로 넘어온 준영속 상태의 엔티티를 넣어준다.

그렇게 되면 따로 save를 하지 않아도, @Transaction이 끝날 때 commit되고, 그 값이 변경됐다는걸 감지해서 flush 된다.

병합: 기존에 있는 엔티티

 

병합 동작 방식

1. merge()를실행한다.

2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.

2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다.

3. 조회한 영속 엔티티( mergeMember )member 엔티티의 값을 채워 넣는다. (member 엔티티의 모든 값 mergeMember에 밀어 넣는다. 이때 mergeMember회원1”이라는 이름이 회원명변경으로 바뀐다.

4. 영속 상태인 mergeMember를 반환한다.

병합시 동작 방식을 간단히 정리

  1. 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회한다.
  2. 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체한다.(병합한다.)
  3. 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터베이스에 UPDATE SQL이 실행

섹션 0. 강좌 소개

강좌 소개, 수업 자료 확인

섹션1. 프로젝트 환경설정

1. 스프링 부트 스타터 (https://start.spring.io)에서 프로젝트 생성

2. 사용 기능 : web, thymeleaf, jpa, h2, lombok, validation

* buile.gradle에 설정해줌 + lombok 설치

properties 파일은 yml로 변경하여 사용(띄어쓰기 주의)

섹션2. 도메인 분석 설계

요구사항 분석 / 도메인 모델과 테이블 설계

 

회원, 주문, 상품의 관계: 회원은 여러 상품을 주문할 수 있다. 그리고 한 번 주문할 때 여러 상품을 선택할 수 있으므로 주문과 상품은 다대다 관계다. 하지만 이런 다대다 관계는 관계형 데이터베이스는 물론이고 엔티 티에서도 거의 사용하지 않는다. 따라서 그림처럼 주문상품이라는 엔티티를 추가해서 다대다 관계를 일대 다, 다대일 관계로 풀어냈다.

상품 분류: 상품은 도서, 음반, 영화로 구분되는데 상품이라는 공통 속성을 사용하므로 상속 구조로 표현했 다.

 

멤버의 값이 멤버안에서 변경이 있을수도 있고, 오더안에서 있을수도 있기 때문에 연관관계의 주인을 정해줘야됨

-> 왜래키랑 가까운곳으로 하면 됨(오더에 있는 멤버)

엔티티 클래스 개발

  • 예제에서는 설명을 쉽게하기 위해 엔티티 클래스에 Getter, Setter를 모두 열고, 최대한 단순하게 설계
  • 실무에서는 가급적 Getter는 열어두고, Setter는 꼭 필요한 경우에만 사용하는 것을 추천
@Entity
@Getter @Setter
public class Member {
	@Id @GeneratedValue
	@Column(name = "member_id")
	private Long id;
        
}

@Column괄호안에 그냥 id를 해도 되는데, 테이블명_id를 한 이유 : 객체는 member.id라고 하면 구분가능한데, 테이블을 명확하게 구분하기 힘들기 때문에! 

  • 실무에서는 @ManyToMany 를 사용하지 말자
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Embeddable;
    
    @Embeddable
    @Getter
    public class Address {
        private String city;
        private String street;
        private String zipcode;
        
        protected Address() {
        }
        
        public Address(String city, String street, String zipcode) {
            this.city = city;
            this.street = street;
            this.zipcode = zipcode;
	} 
}

!! 값 타입은 변경 불가능하게 설계해야 한다.
@Setter 를 제거하고, 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스를 만들자. JPA 스펙상 엔티티나 임베디드 타입( @Embeddable )은 자바 기본 생성자(default constructor)public 또는 protected 로 설정해야 한다. public 으로 두는 것 보다는 protected 로 설정하는 것이 그나마 더 안전 하다. JPA가 이런 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플랙션 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.

엔티티 설계시 주의점

  • 엔티티에는 가급적 Setter를 사용하지 말자
    Setter가 모두 열려있다. 변경 포인트가 너무 많아서, 유지보수가 어렵다. 나중에 리펙토링으로 Setter 제거
  • 모든 연관관계는 지연로딩으로 설정!

즉시로딩( EAGER )은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
실무에서 모든 연관관계는 지연로딩( LAZY )으로 설정해야 한다.
연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다.
@XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.

컬렉션은 필드에서 초기화 하자

애플리케이션 구현 준비

계층형 구조 사용

  • controller, web: 웹 계층
  • service: 비즈니스 로직, 트랜잭션 처리
  • repository: JPA를 직접 사용하는 계층, 엔티티 매니저 사용
  • domain: 엔티티가 모여 있는 계층, 모든 계층에서 사용

기술 설명

  • @Repository : 스프링 빈으로 등록, JPA 예외를 스프링 기반 예외로 예외 변환
  • @PersistenceContext : 엔티티 메니저( EntityManager ) 주입
  • @PersistenceUnit : 엔티티 메니터 팩토리( EntityManagerFactory ) 주입
  • @Service
  • @Transactional : 트랜잭션, 영속성 컨텍스트
    readOnly=true : 데이터의 변경이 없는 읽기 전용 메서드에 사용, 영속성 컨텍스트를 플러시 하지 않으므로 약간의 성능 향상(읽기 전용에는 다 적용)
    데이터베이스 드라이버가 지원하면 DB에서 성능 향상
  • @Autowired
    생성자 Injection 많이 사용, 생성자가 하나면 생략 가능

테스트 부분

  • @RunWith(SpringRunner.class) : 스프링과 테스트 통합
  • @SpringBootTest : 스프링 부트 띄우고 테스트(이게 없으면 @Autowired 다 실패)
  • @Transactional : 반복 가능한 테스트 지원, 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고 테스트가 끝나면 트랜잭션을 강제로 롤백 (이 어노테이션이 테스트 케이스에서 사용될 때만 롤백)

 

 

 

 

+ Recent posts