spring/JPA

[JPA] API 개발과 성능 최적화 1 (지연 로딩과 조회 성능 최적화)

대기업 가고 싶은 공돌이 2024. 8. 8. 19:59

API를 개발하는 여러가지 방법들과 성능을 최적화 시키는 법을 공부해 보겠다.

 

엔티티 직접 노출 방식 API

	@GetMapping("/api/v1/simple-orders")
	public List<Order> ordersV1(){
    	List<Order> all = orderRepository.findAllByString(new OrderSearch());
        
        return all;
    }

우선 위와 같이 엔티티를 그대로 노출하는 api를 하나 만들어 보았다.

 

public class Order {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member 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; //Order or cancle

 

위의 api는 현재 몇 가지 문제점을 가지고 있다.

  1. order 엔티티는 다음과 같은데 오더에서 멤버를 호출하고 멤버에서 다시 오더를 호출하기 때문에
    order 엔티티 자체를 그대로 반환하면 무한 루프에 빠지게 된다.

    이 문제를 해결하는 방법은 양방향 연관관계 둘 중 하나에 @JsonIgnore 어노테이션을 사용해주는 것이다.
    이렇게 해야 반대편으로 넘어가서 제이슨 객체 생성을 막아 무한 루프를 막아준다.
  2. 현재 엔티티들의 fetch_type은 전부 Lazy로 돼있다.
    Lazy라는 것에서 알아차린 사람들이 많을 것이다.

    order 객체를 데이터베이스에서 가져올 때 연관관계에 있는 엔티티들은 프록시 객체가 담겨있다.

    따라서 order 객체를 반환한다 하더라도, 연관관계에 있는 객체들은 프록시 객체가 반환되어 제대로 된 데이터를 받을 수 없다.

    이 문제를 해결하는 방법은 하이버네이트 모듈5를 bean으로 등록하면 된다.
    하이버네이트 모듈5는 LAZY의 엔티티를 전부 null로 설정해서 반환해준다.

    하이버네이트의 설정을 변경하여 반환시 강제로 로딩하여 반환하게 만들 수도 있다.

하지만 엔티티를 직접 노출하는 것은 성능상에도 문제가 있고,

엔티티 변경시 api 반환값도 전부 변하기 때문에 엔티티를 직접 노출시키는 일은 없어야 한다.

 

엔티티를 DTO로 변환

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2(){
	List<Order> orders = orderRepository.findAllByString(new OrderSearch());
    
    List<SimpleOrderDto> result =  orders.stream()
    		.map(o -> new SimpleOrderDto(o))
            .collect(Collectors.toList());
            
    return result;
    
}

@Data
static Class SimpleOrderDto{
	private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderSatus;
    private Address address;
    
    public SimpleOrderDto(Order order){
    	//~~~~~
    }
}

 

다음과 같이 Dto를 반환하는 api와 Dto를 만들어 주었다.

 

이렇게 Dto를 만들어 반환하면 엔티티를 수정해도 api 반환 값이 변할 이유가 없기에 더 효율적이다.

 

하지만 이렇게 Dto를 만들어 반환 시켜도 해결되지 않는 문제가 있다.

 

바로 쿼리가 너무 많이 나간다는 문제점이다.

  1. 오더 테이블에서 select 한 번
  2. 멤버를 가져올 때 LAZY를 초기화 시키기 위해 select 한 번 더
  3. delivery 도 한 번 더
  4. 이후 모든 멤버와 delivery를 초기화 시킬 때까지 계속 반복

N+1 문제가 발생한다.

 

이 문제를 해결하기 위해선 전에 배웠던 fetch join을 사용해야 한다.

 

fetch join을 사용하는 방법

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

 

다음과 같이 fetch join을 활용하여 order를 조회할 때 member와 delivery의 모든 값을 
한 방 쿼리에 전부 가져온다.

 

이제 orderRepository.findAllWithMemberDelivery() 메소드를 활용해서 쿼리 한 번에
모든 값들을 다 반환 시킬 수 있다.

 

JPA에서 Dto로 바로 조회하기

엔티티로 조회 후 Dto로 변경하는 방식은 필요 없는 컬럼까지 모두 가져오기 때문에,

불필요한 낭비가 생기는 것을 확인할 수 있었다. 우리는 이 낭비를 줄이기 위해 jpa에서 Dto로 바로 조회할 것이다.

 

우선 Repository에 dto로 반환하는 메소드를 작성해줄 것이다.

 

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

이렇게만 적어주면 jpa에서 Order 엔티티를 dto로 매핑시킬 수 없다.

 

jpa에서는 엔티티나 값타입만 매핑이 가능하다.

 

그래서 엔티티를 dto로 매핑시키기 위해선 new operation을 통해 엔티티의 필드 값을 하나 하나 넘겨줘야하고,

 

dto에선 필드 값을 하나 하나 매핑시켜 dto를 생성해야한다.

 

public List<OrderSimpleQueryDto> findORderDtos(){
	em.createQuery(
    	"select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id,o.name ,,,등등)
		 from Order o join o.member m join o.delivery d",OrderSimpleQueryDto.class
    ).getResultList();
}

 

참고로 DTO로 조회할 때는 fetch type(lazy, eager)과 별개로 필요한 데이터를 하나씩 다 찝어서 가져오기 때문에 쿼리가 한 번만 나가게 된다.

 

inner join,즉 일반 조인은 select 절에 있는 엔티티만 영속화 시킨다.

 

위의 쿼리는 페치 조인이 아니지만, select 절에 들어있는 모든 엔티티를 영속화 시키기 때문에,

페치 조인이 아니어도 N+1 문제는 발생하지 않는다.

@GetMapping("/api/v4/simple-orders")
public List<SimpleOrderDto> ordersV4(){
    return orderRepository.findOrderDtos;
}

 

트레이드 오프 관계

페치 조인과 Dto로 바로 조회하는 두 방법 사이에는 트레이드 오프가 존재한다.

 

  1. 페치 조인은 select 절에 모든 컬럼을 가져와 불필요한 낭비가 생긴다는 단점이 있지만
    엔티티를 반환하기 때문에 재사용성이 높다는 장점이 있다.
    (다른 상황에서도 언제든지 사용 가능)
  2. Dto로 바로죄하는 방법은 불필요한 컬럼을 가져오지 않아 성능상으로는 이점이 존재하지만,
    dto를 반환하기 때문에 해당 dto를 사용하는 경우에만 쓸 수 있어 재사용을 할 수 없다는
    단점이 존재한다.
    코드상으로 지저분하다는 단점도 있으며, 엔티티의 수정이 불가능하다는 단점도 있다.
    api 스펙이 바뀌면 api만 수정하는게 아닌, repository의 코드도 고쳐야 하기 때문에
    객체지향적이라고 볼 수도 없다.

김영한 님의 권장 방법

  1. 우선 엔티티를 Dto로 변환하는 방법을 선택한다.
  2. 필요하면 페치 조인으로 성능을 최적화 한다. -> 대부분의 성능 이슈가 해결된다.
  3. 그래도 안 되면 Dto로 직접 조회하는 방법을 사용한다. (트래픽이 엄청 많고 엔티티 필드가 엄청 많을 경우)
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 직접 SQL을 만들어 사용한다.

 

참고: 김영한 실전! 스프링 부트와 JPA 활용2