JPA N:1 조회 최적화

떡ol·2023년 6월 1일
0

SQL을 사용하는데 있어서는 Query tuning이 중요합니다. 어떻게 Join하느냐, Index를 어떻게 설정하느냐 등, 전부 어플리케이션 속도와 연결됩니다. 마찬가지로 JPA도 튜닝을 할 줄 알아야 합니다.

시작 조회조건

update문이나, insert문 같은경우에는 단순작업이기 때문에 튜닝에 대상이 아닙니다.
따라서 이글에서는 조회(select)의 단순한 방법부터 시작하여 fetch join까지의 튜닝방법을 진행해봅니다.

주문과 주문자 조회하기

Repository의 조회조건입니다. JPA를 기본적으로 아신다는 전제에서 설명과 코드는 제외합니다.

"select o from Order o join o.member m";

1. return findAll()

첫번째 방법입니다. 그냥 단순히 전체를 조회하여List에 자료를 불러오는 방식입니다.

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1(){
    	//new OrderSearch() 는 비어 있는 객체입니다. null
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }

하지만 Entity의 구성에따라 에러가 발생할 수 있습니다.
Order와 조인관계에 있는 대상에 양방향 관계를 갖고있다면 무한 루프에 빠지게 됩니다.

public class Order {

    @Id @GeneratedValue
    @Column (name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;  // <-- 1. Member로 이동해보면 

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;
public class Member{

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>(); // <- 2.Order역시 조회가 될겁니다.
    // 3. 이렇게 되면 Member에서->orders로 가고 orders의 각 order들이->Member가고... 무한루프...


}

조회 값이 계속 루프를 타기때문에 결과를 얻지못하고 에러를 발생하게 됩니다.
이를 해결하기 위해서는 한쪽 Entity에다가는 결과를 보내지 않겠다는 @JsonIgnore를 입력해주면 됩니다.

	@JsonIgnore
	@OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>(); 

하지만 Entity의 스팩을 수정하는건 함부로 해서는 안되는 방법입니다. Entity를 수정해버리면 만들어버린 API스팩까지 영향을 끼칠 수 있습니다.

2. DTO를 따로 만들어서 setter에 담기

위에서 Entity의 스팩을 함부로 건드려서는 안된다고 했습니다. 어쩌면 당연한 이야기일 수 있습니다. Entity는 DB작업외에 업무를 맡겨서는 안되겠지요. 그래서 DTO를 만들어서 조회하는것을 알아봅시다.

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

        public SimpleOrderDto(Order order) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
        }
    }

별로 특별한게 없습니다. 이어서 Controller도 확인해봅시다.

    @GetMapping("api/v2/simple-orders")
    public List<SimpleOrderDto> orderV2(){
        return orderRepository.findAllByString(new OrderSearch()).stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.toList());
        /*
        	List<Order> all = orderRepository.findAllByString(new OrderSearch());
            List<SimpleOrderDto> result = new ArrayList();
            for(Order o : all){
            	SimpleOrderDto dto = new SimpleOrderDto(o);
                result.add(dto);
            }
        return result;
        */
    }

Controller로 받아온 객체를 DTO에 정리만 해주면 됩니다.역시 이해하는데 어렵지는 않을겁니다.
결과를 확인해 봅시다.

[
    {
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "11111"
        },
        "name": "userA",
        "orderDate": "2023-06-02T01:19:07.017",
        "orderId": 4,
        "orderStatus": "ORDER"
    },
    {
        "address": {
            "city": "부산",
            "street": "2",
            "zipcode": "22222"
        },
        "name": "userB",
        "orderDate": "2023-06-02T01:19:07.074",
        "orderId": 11,
        "orderStatus": "ORDER"
    }
]

값은 잘나왔습니다. 하지만 아직 개선해야할 요인이 남아 있습니다. 지연로딩(FetchType.LAZY)때문에 생긴 쿼리 조회 횟수가 문제가 됩니다.

위의 결과값을 얻기위해 실행된 쿼리의 갯수는 5개입니다. (두개는 길어서 안찍었어요...)
생각을 해야하는 부분인데, 위에서 json객체를 2개를 반환했고 각각 맴버와 주소를 지연로딩하였습니다.
즉, N+1조회가 발생하는겁니다.

사실 이러한 문제는 FetchType.EAGER를 쓰면 되지않나 하겠지만, 글의 요지는 그게 아니며, JPA의 최적화는 FetchType.LAZY을 기반으로 움직이기 때문에 좋지 않습니다.

3. fetch join을 사용

조회가 여러번 되는 문제는 사실 JPA 스팩에서만 발생합니다. 우리가 기존에 사용했던 Mybatis에서는 query를 애초에 join으로 튜닝을 한채로 조회를 하니 이런일이 발생할리가 없죠.
JPA에서는 그 역할을 해주는게 fetch join입니다.

    public List<Order> findAllWithMemberDelivery() {
    	// join 뒤에 fetch를 붙혀서 사용합니다. 
        // Entity에서 불러온 o에 연관관계가 있는 대상을 fetch join합니다.
        return em.createQuery("select o from Order o join fetch o.member m join fetch o.delivery d", Order.class)
                .getResultList();
    }
    @GetMapping("api/v3/simple-orders")
    public List<SimpleOrderDto> orderV3(){
        return orderRepository.findAllWithMemberDelivery().stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.toList());
    }

우리가 알고 있는방식처럼 한번에 join하여 쿼리를 실행한 것을 확인가능합니다.

join만 사용하면 어떻게 될까?

그럼 fetch빼고 join하면 우리가 아는 sql의 그 join이 아닌가 할 수 있습니다. 바로 테스트 해본 결과를 봅시다.

분명 join을 안쓴 2. DTO를 따로 만들어서 setter에 담기 섹션에 있는 로그하고 달라진게 있어보이기는 합니다.
Member, Delivery 모두 join은 했네요. 하지만 조회는 Order만 하고 있습니다. 연관된 Entity를 전체 조회해주는게 오롯이 select o from Order oo만 조회해주고, 필요한 것을 다시 지연로딩해서 받아오네요.

4. DTO에 바로 담기

이 방법을 처음보자마자 엥? 이걸 사용하나? 라는 생각이 들었습니다. 딱 이 조회조건 말고는 사용할 수 없게 만들어 놨기 때문에 확장성이 없거든요.

    public List<OrderSimpleQueryDto> findOrderDtos(){
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.simplequery.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();
    }
@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;
    }
}

실무에서 만들어 놓은 조회쿼리를 가져와 사용하시면 알겠지만, 원하는 Column만 골라 사용하는데, 이건 골라사용하기도 애매하고, 새로 만들어 쓰기도 귀찮게 하네요.
그렇다고 원하는 Column만 뽑아쓴다고 속도가 빨라질거 같지도 않습니다.
하지만 그럼에도 관리 차원에서 쓸 일이 있다면 참고하시면 됩니다.

Json 리턴 객체 정리하기

위에서 받아온 Json 객체를 확인해보시면, 리스트 형식으로 받아진것을 알 수 있습니다.
하지만 API 형식으로 리턴될때 다양한 서브데이터를 보내줘야 하기때문에 객체로 한번 감싸주시는게 관리하기 좋을 것입니다.

[ // << 리스트로 되어있다 'data : {' 를 한번더 감싸봅시다.
    {
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "11111"
        },
        "name": "userA",
        "orderDate": "2023-06-02T01:19:07.017",
        "orderId": 4,
        "orderStatus": "ORDER"
    },
// 생략...
]

Generic <T>를 입력받는 DTO를 하나 생성한후에 이 DTO에 자료를 넘기면 됩니다.

	@Data
	@AllArgsConstructor
	public class Result<T> {
    	private int count;
    	private T data;
	}
	// 3.버전을 수정하여 사용하였습니다. 
    @GetMapping("api/v3/simple-orders")
    public Result orderV3(){
        List<SimpleOrderDto> collect =  orderRepository.findAllWithMemberDelivery().stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.toList());

        return new Result(collect.size(), collect);
    }
{ // 의도한것과 같이 객체로 넘겨줍니다.
    "count": 2, // 서브데이터인 count도 넣어주고...
    "data": [ // 데이터로 해서 리스트를 불러오고 있습니다. 
        {
            "address": {
                "city": "서울",
                "street": "1",
                "zipcode": "11111"
            },
            "name": "userA",
            "orderDate": "2023-06-03T02:59:05.64",
            "orderId": 4,
            "orderStatus": "ORDER"
        },
        {
            "address": {
                "city": "부산",
                "street": "2",
                "zipcode": "22222"
            },
            "name": "userB",
            "orderDate": "2023-06-03T02:59:05.686",
            "orderId": 11,
            "orderStatus": "ORDER"
        }
    ]
}

결론

fetch join으로 대부분의 N:1의 매핑관계의 조회는 최적화해서 사용가능합니다.
다음에는 1:N 관계(컬렉션 조회)의 튜닝에 대해서 알아보겠습니다.

profile
하이

0개의 댓글