728x90
반응형
SMALL

컨트롤러에서 엔티티를 직접 반환하지 마라! 이유는 너무 많다.

  • 엔티티를 반환하는 설계를 하면 엔티티의 변경이 생기면 API 스펙이 변경된다.
    • Member 엔티티의 name 필드를 username 필드로 변경하면, API 스펙 자체가 변경된다. 가져다가 사용하는 쪽은 매우 화가 난다.
  • 엔티티에 양방향 연관관계가 있는 경우, 무한 재호출로 인한 무응답 현상이 일어난다.
    • 이후에 코드로 직접 살펴보자. 
  • 엔티티에는 민감한 데이터가 있기도 하고, 굳이 외부로 노출시키지 않아도 되는 데이터가 있는데 그런 데이터까지 노출된다.
    • @JsonIgnore 애노테이션으로 막으면 되잖아요? → 다른 곳에서는 그 필드가 필요한 경우엔 어떡할래?
  • 지연로딩으로 설정한 연관 엔티티를 처리하기가 매우 귀찮아진다. 

 

컨트롤러에서 엔티티를 직접 반환하면 생기는 문제들

이런 여러 문제가 있고 엔티티를 직접 노출하지 않는 코드를 작성해서 더 우아하게 JPA를 사용하자. 다음 코드를 보자.

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        return orderRepository.findAllByCriteria(new OrderSearch());
    }
}
  • Order 라는 엔티티를 직접 반환하고 있다. 일단 이걸 실행해보자.
  • 아래와 같은 에러가 발생하고 뭔가 제대로 동작하지 않는다.
Ignoring exception, response committed already: org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Document nesting depth (1001) exceeds the maximum allowed (1000, from `StreamWriteConstraints.getMaxNestingDepth()`)

 

이게 무슨 일이냐면, Order로 들어가보자.

@Entity
@Getter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 생성 메서드를 만들었으면 그 메서드로만 인스턴스를 생성할 수 있도록 막아야 함
public class Order {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

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

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

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    ....
}
  • OrderMember를 가지고 있다. Member로 들어가보자.
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(
        indexes = {
                @Index(name = "idx_name", columnList = "name"),
                @Index(name = "idx_name_address", columnList = "name, city")
        }
)
public class Member {

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

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    @Builder.Default
    private List<Order> orders = new ArrayList<>();


    public void change(String name) {
        this.name = name;
    }
}
  • MemberOrder를 가지고 있다. 자꾸 서로가 서로를 호출하다가 더 이상 못하겠다! 라고 말하는 중이다.
  • 이게 맨 위에서 설명한 무한 재호출이다.

 

그럼 이제, 이걸 해결하기 위해 혹시라도 @JsonIgnore 애노테이션을 사용했다고 해보자. 다음과 같이 말이다.

Order 일부분

@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

...

 

이번에는 응답은 받았는데 뭔가 또 문제가 생겼다.

 

어떤 문제일까? 에러 내용은 다음과 같다.

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: java.util.ArrayList[0]->cwchoiit.shoppingmall.domain.Order["member"]->cwchoiit.shoppingmall.domain.Member$HibernateProxy$UdxcWMDQ["hibernateLazyInitializer"])
...
  • 결론부터 말하면 지연로딩으로 처리한 연관관계 엔티티를 반환하려 할 때 나 지연로딩이라 데이터를 돌려줄 수 없어! 라고 말하는 것이다.
  • 이렇듯, 엔티티를 직접 노출하면 지연 로딩을 원하든 원치않든 무조건 다뤄야 한다는 또 하나의 단점이 있다.

그럼 이제 이 여러 문제를 어떻게 해결하면 될까? 

 

1. 엔티티를 DTO로 변환

우선, 가장 먼저 엔티티를 전부 제거해버려라. 그리고 엔티티를 반환하지 말고 DTO로 변환해서 반환해라. 다음 코드를 보자.

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDTO> ordersV2() {
    return orderService.findOrders(new OrderSearch()).stream()
            .map(SimpleOrderDTO::new)
            .toList();
}
  • OrderController에서 주문 데이터들을 반환하는데 반환값이 List<SimpleOrderDTO>이다. 즉, 이젠 엔티티를 반환하지 않고 DTO로 반환한다. (물론, 이 데이터를 응답 공통 객체로 감싸서 count, page, maxResults 등 추가 정보를 같이 넘기는게 일반적이다)
  • 그리고 DTO는 어떻게 생겼냐면, 별게 없다. 
@Data
static 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.getMember().getAddress();
    }
}
  • 이너 클래스로 간단하게 만들어봤다. 다른 방식으로 해도 좋다. 
  • DTO는 곧 API 스펙이 되면 된다. 이제 API 스펙은 데이터를 orderId, name, orderDate, orderStatus, address 외 다른것은 넘기지 않게 서로 협의를 하면 끝이다. 이러면 Order 엔티티에 변경이 생겨도 이 API 스펙만 유지해줄 수 있다면 아무런 문제도 발생하지 않게 된다.

 

그럼 실제로 결과를 보자. 아래와 같이 데이터가 잘 나간다. 

 

 

그럼 이대로 괜찮을까? 아니다. 왜냐? 지연 로딩에 대한 문제가 여전히 남아있다. 나간 쿼리를 로그로 확인해보자.

 

  • 우선, 첫번째로 Order에 대해 조회를 하기 때문에 당연히 Order 관련 쿼리가 처음으로 나간다. 그리고 그 쿼리엔 Member와 조인하는 것도 있기 때문에 멤버를 조인한다.
  • 그런데 이게 조인을 한다고 해서 바로 지연로딩으로 설정한 연관관계들의 데이터를 다 가져오는 게 아니라, 지연로딩으로 설정한 녀석들은 프록시로 만들어 돌려준다. 이게 JPA의 규칙이다. (페치 조인과 헷갈리면 안된다. 애시당초에 데이터베이스에는 페치 조인이라는 개념 자체가 없다. 이 페치 조인은 JPA에서 사용하는 것이다)
  • 그리고 실제로 이 프록시를 초기화할때, 그러니까 위 코드에서는 엔티티를 DTO로 변환할 때 생성자에서 order.getMember().getName()과 같은 메서드를 실행하는 순간, JPA는 "어? 멤버가 필요하구나? 초기화 해야겠다!"라고 판단하고 해당 데이터를 가져오기 위해 쿼리를 날린다.
  • 그럼 결론적으로 Order에 대한 조회를 했고 데이터가 2개가 나왔다. 그 각각의 레코드에는 멤버 관련 데이터도 있어야 하지만 우선은 지연로딩이기 때문에 바로 데이터를 가져오는게 아니라 프록시로 만들어둔다. 그리고 이후에 해당 프록시를 초기화하는 순간, 그 데이터를 뽑아오기 위해 쿼리를 날리게 된다. 그 얘기는 Order가 10개고 각각의 주문이 모두 다 다른 유저라면 1 + 10개의 쿼리가 나가는 것이다. 이게 바로 N + 1 문제이고. 만약, 지연 로딩으로 설정한 연관관계가 더 많고 그 연관관계의 엔티티를 모두 프록시 초기화를 한다면 10개보다 더 나갈것이다.

 

결론은, 엔티티를 DTO로 변환하는 것으로 API 스펙이 바뀌는 문제를 해결하고 불필요한 데이터를 외부로 노출하지 않게 됐지만 여기서 끝내면 안된다는 것이다!

 

2. 페치조인을 사용해 N +1 문제 해결 

이제 정말 JPA에서 정말 중요한 페치 조인을 사용해서 딱 한번의 쿼리로 원하는 데이터를 다 가져와보자!

아래와 같이 GET 매핑을 하나 더 만들자.

@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDTO> ordersV3() {
    return orderRepository.findAllWithMemberDelivery().stream()
            .map(SimpleOrderDTO::new)
            .toList();
}

 

그리고, OrderRepository에서 다음과 같은 메서드를 하나 만든다.

public List<Order> findAllWithMemberDelivery() {
    return entityManager
            .createQuery(
                    "SELECT o " +
                            "FROM Order o " +
                            "JOIN FETCH o.member m " +
                            "JOIN FETCH o.delivery d", Order.class)
            .getResultList();
}

 

  • JPQL 부분을 보면, JOIN FETCH 라는 키워드가 있다. 그냥 JOIN이 아니다! 
  • JOIN FETCHJPA에서 제공해주는 기능인데 조인을 한 후 데이터를 모두 넣어서 한번에 돌려준다.
  • 그래서 지연로딩의 프록시 데이터도 프록시 초기화를 할 필요가 없어진다. 

 

나가는 쿼리를 한번 확인해보자! 아래와 같이 딱 한번의 쿼리로 모든 데이터를 다 받아왔고, 더 이상 쿼리가 나가지 않는다.

 

 

V2와 달랐던 점은, JOIN을 한다고 해도 그 엔티티가 지연로딩이면 바로 데이터를 넣어주는 게 아니라 프록시로 만들어서 실제로 필요한 시점에 메서드를 호출하면 그때 비로소 프록시가 초기화 되면서 그 값을 가져오기 위해 쿼리를 추가적으로 날렸는데 지금은 아예 처음부터 데이터를 다 가져와버린다. 이렇게 해서 N+1 문제를 해결할 수 있다. 

 

그런데, 한가지만 더 최적화해보자. 지금은 모든 필드가 SELECT절에 다 걸린다. 막상 필요한 데이터만 있을 수도 있는데 이건 다 걸려있기 때문에 이 부분마저 최적화해보자.

 

3. SELECT 절을 DTO로 받기

일단 위에서 사용한 페치 조인과 컨트롤러에서 엔티티를 직접 반환하는 게 아니라 DTO로 변환하여 반환하는 이 두가지 기법을 사용하면 거의 모든 대부분의 성능 문제? API 스펙 문제? 해결 된다. 근데, 위 쿼리를 보면 알겠지만 모든 필드를 다 SELECT절에서 받기 때문에 아무렴 DB에서 데이터를 메모리에 많이 퍼올리게 된다. 

 

만약, 어떤 유저가 주문한 [주문 내역]이라는 화면에 들어갔을때 보여져야 할 필드들이 주문번호, 주문자명, 배달위치, 주문일시, 주문상태 이렇게 딱 5개만 필요하다고 해보자. 그럼 저 나머지 필드들은 의미없이 메모리에 데이터를 퍼올리는 셈이 된다. 

 

그래서, 이 경우에 SELECT절 자체를 DTO로 받아볼 수 있다. 다음 코드를 보자.

 

OrderRepository 일부분

public List<SimpleOrderQuery> findOrderByDTO() {
    return entityManager.createQuery(
            "SELECT new cwchoiit.shoppingmall.dto.SimpleOrderQuery(o.id, m.name, o.orderDate, o.status, d.address) " +
                    "FROM Order o " +
                    "JOIN o.member m " +
                    "JOIN o.delivery d", SimpleOrderQuery.class)
            .getResultList();
}
  • 지금 보면, SELECT 절에 newDTO 클래스를 사용하고 있다. 그래서 딱 원하는 데이터만 넣어주고 있다. 
  • 이렇게 해서 반환 자체를 저 DTO의 리스트로 반환하면 된다.

SimpleOrderQuery

package cwchoiit.shoppingmall.dto;

import cwchoiit.shoppingmall.domain.Address;
import cwchoiit.shoppingmall.domain.OrderStatus;
import lombok.Data;

import java.time.LocalDateTime;

@Data
public class SimpleOrderQuery {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderQuery(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;
    }
}
  • 이렇게 DTO를 만들었다.

OrderController 일부분

@GetMapping("/api/v4/simple-orders")
public List<SimpleOrderQuery> ordersV4() {
    return orderRepository.findOrderByDTO();
}
  • 컨트롤러는 단지 위임만 하고 있다.

 

이렇게 하고 실행해보면, 다음과 같이 딱 우리가 필요한 필드들만 SELECT절에서 받아오고 있음을 확인할 수 있다. 그리고 더이상의 쿼리는 나가지 않는다. "어? 페치 조인이 아닌데 왜 지연로딩을 초기화하는 쿼리가 안나가요?"DTOSELECT절을 받을때, 생성자로 넘겨주는 값 자체를 넘겼으니 그때 그 값을 가져와야 하므로 이미 쿼리를 날릴때 필요한 필드들을 모두 메모리에 퍼 올리게 된다.

 

 

이러면, 정말 극한의 최적화가 완성이 되는데.. 이 방법은 트레이드 오프가 있다.

  • SELECT절이 지저분해진다. 아래와 같이 패키지 명까지 모두 작성을 해줘야하는 번거로움이 있다.

  • Repository의 순수함이 사라진다.
    • 이 말은 무슨말이냐면, Repository는 결국 엔티티에 의존해야 한다. 그 외 어떤것에도 의존하지 않는게 가장 좋은 방법이다. 그런데 지금 같은 경우, 좁게 보면 DTO(SimpleOrderQuery)에 의존하고 있으며 넓게 보면 화면 하나에 의존하고 있다.
    • 화면 하나에 의존한다는 말은 우리가 [주문 내역]이라는 화면에서 필요한 데이터만을 위해 지금 이 쿼리를 만들었단 뜻이다. 
    • 다음 전체 코드를 보자. 이 메서드가 만들어지기 전까지 이 레포지토리는 오로지 Order 라는 엔티티에만 의존하고 있었다.

OrderRepository

package cwchoiit.shoppingmall.repository;

import cwchoiit.shoppingmall.domain.Order;
import cwchoiit.shoppingmall.dto.OrderSearch;
import cwchoiit.shoppingmall.dto.SimpleOrderQuery;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager entityManager;

    public void save(Order order) {
        entityManager.persist(order);
    }

    public Order findById(Long id) {
        return entityManager.find(Order.class, id);
    }

    public List<Order> findAllByCriteria(OrderSearch orderSearch) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> cq = cb.createQuery(Order.class);

        Root<Order> o = cq.from(Order.class);
        Join<Object, Object> m = o.join("member", JoinType.INNER);

        List<Predicate> predicates = new ArrayList<>();

        if (orderSearch.getOrderStatus() != null) {
            Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
            predicates.add(status);
        }

        if (StringUtils.hasText(orderSearch.getMemberName())) {
            Predicate name = cb.like(m.get("name"), "%" + orderSearch.getMemberName() + "%");
            predicates.add(name);
        }

        cq.where(cb.and(predicates.toArray(new Predicate[0])));

        return entityManager
                .createQuery(cq)
                .setMaxResults(1000)
                .getResultList();
    }

    public List<Order> findAllWithMemberDelivery() {
        return entityManager.createQuery(
                        "SELECT o " +
                                "FROM Order o " +
                                "JOIN FETCH o.member m " +
                                "JOIN FETCH o.delivery d", Order.class)
                .getResultList();
    }

    public List<SimpleOrderQuery> findOrderByDTO() {
        return entityManager.createQuery(
                        "SELECT new cwchoiit.shoppingmall.dto.SimpleOrderQuery(o.id, m.name, o.orderDate, o.status, d.address) " +
                                "FROM Order o " +
                                "JOIN o.member m " +
                                "JOIN o.delivery d", SimpleOrderQuery.class)
                .getResultList();
    }
}
  • 전부 다 반환값으로 Order 엔티티를 반환하고 있고, 사실 이게 가장 모범적인 방식이다. 마지막 findOrderByDTO()는 오로지 하나의 화면을 위해서 만들어진 메서드이지 범용적으로 사용할 수 없다.
  • 그럼 방안은 없을까?

방안

그래서, 이런 경우 방안이 있는데, 이렇게 가장 기본이 되는 Repository는 순수한 Repository로 남겨두고 저런 특정 화면에 필요한 최적화된 쿼리들을 모아두는 Repository를 하나 더 만드는 것이다.

 

OrderQueryRepository

package cwchoiit.shoppingmall.repository.order;

import cwchoiit.shoppingmall.dto.SimpleOrderQuery;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager entityManager;

    public List<SimpleOrderQuery> findOrderByDTO() {
        return entityManager.createQuery(
                        "SELECT new cwchoiit.shoppingmall.dto.SimpleOrderQuery(o.id, m.name, o.orderDate, o.status, d.address) " +
                                "FROM Order o " +
                                "JOIN o.member m " +
                                "JOIN o.delivery d", SimpleOrderQuery.class)
                .getResultList();
    }
}
  • 이제 OrderQueryRepository를 따로 만들어서, 특정 화면이나 어떤 딱 필요한 부분에만 사용되는 쿼리들을 따로 모아두고 관리하는 것이다. 
  • 이러면 두가지 장점이 생기는데 첫번째는 유지보수성이 좋아진다. 순수한 OrderRepository는 다시 오로지 엔티티에만 의존하고 있기 때문에 결합력이 약해진다. 두번째는 분리된 Repository로 인해 개발하는 개발자도 이 쿼리는 어딘가에 특정된 쿼리구나를 인식할 수 있게 한다. 

그리고 가져다가 사용하는 쪽도, 이렇게 변경해주면 된다. 

@GetMapping("/api/v4/simple-orders")
public List<SimpleOrderQuery> ordersV4() {
    return orderQueryRepository.findOrderByDTO();
}
  • 물론 컨트롤러가 의존성이 더 추가됐지만, 컨트롤러는 얼마든지 그럴 수 있다.

 

 

결론

결론을 얘기하면 다음 순서를 따르자!

  1. 무조건 컨트롤러는 엔티티 말고 DTO를 반환한다.
  2. 필요하면 페치 조인으로 성능을 최적화한다. 대부분은 이걸로 성능 이슈가 해결된다.
  3. 그래도 더욱 더욱 최적화 하고 싶다면 SELECT절을 DTO로 받아서 정말 딱 필요한 데이터만 메모리에 퍼올리자.
  4. 아예 이것도 저것도 안되면, JPA가 제공하는 네이티브 SQL이나 스프링 JDBCTemplate을 사용해서 SQL을 직접 날린다.
728x90
반응형
LIST

+ Recent posts