컨트롤러에서 엔티티를 직접 반환하지 마라! 이유는 너무 많다.
- 엔티티를 반환하는 설계를 하면 엔티티의 변경이 생기면 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;
....
}
- Order는 Member를 가지고 있다. 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;
}
}
- Member는 Order를 가지고 있다. 자꾸 서로가 서로를 호출하다가 더 이상 못하겠다! 라고 말하는 중이다.
- 이게 맨 위에서 설명한 무한 재호출이다.
그럼 이제, 이걸 해결하기 위해 혹시라도 @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 FETCH는 JPA에서 제공해주는 기능인데 조인을 한 후 데이터를 모두 넣어서 한번에 돌려준다.
- 그래서 지연로딩의 프록시 데이터도 프록시 초기화를 할 필요가 없어진다.
나가는 쿼리를 한번 확인해보자! 아래와 같이 딱 한번의 쿼리로 모든 데이터를 다 받아왔고, 더 이상 쿼리가 나가지 않는다.
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 절에 new로 DTO 클래스를 사용하고 있다. 그래서 딱 원하는 데이터만 넣어주고 있다.
- 이렇게 해서 반환 자체를 저 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절에서 받아오고 있음을 확인할 수 있다. 그리고 더이상의 쿼리는 나가지 않는다. "어? 페치 조인이 아닌데 왜 지연로딩을 초기화하는 쿼리가 안나가요?" → DTO로 SELECT절을 받을때, 생성자로 넘겨주는 값 자체를 넘겼으니 그때 그 값을 가져와야 하므로 이미 쿼리를 날릴때 필요한 필드들을 모두 메모리에 퍼 올리게 된다.
이러면, 정말 극한의 최적화가 완성이 되는데.. 이 방법은 트레이드 오프가 있다.
- 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();
}
- 물론 컨트롤러가 의존성이 더 추가됐지만, 컨트롤러는 얼마든지 그럴 수 있다.
결론
결론을 얘기하면 다음 순서를 따르자!
- 무조건 컨트롤러는 엔티티 말고 DTO를 반환한다.
- 필요하면 페치 조인으로 성능을 최적화한다. 대부분은 이걸로 성능 이슈가 해결된다.
- 그래도 더욱 더욱 최적화 하고 싶다면 SELECT절을 DTO로 받아서 정말 딱 필요한 데이터만 메모리에 퍼올리자.
- 아예 이것도 저것도 안되면, JPA가 제공하는 네이티브 SQL이나 스프링 JDBCTemplate을 사용해서 SQL을 직접 날린다.
'JPA(Java Persistence API)' 카테고리의 다른 글
[우아하게 JPA 사용] Part.3 OSIV와 성능 최적화 (0) | 2024.11.13 |
---|---|
[우아하게 JPA 사용] Part.2 컬렉션 조회 최적화 (0) | 2024.11.12 |
[JPA] 엔티티와 인덱스 (0) | 2024.11.10 |
[JPA] Dirty Checking, Merge (8) | 2024.11.10 |
[JPA] OSIV (Open Session In View) (0) | 2023.11.16 |