Spring, Apache, Java

[Spring/JPA] 컨트롤러에서 Entity를 반환할 때 생기는 문제점

cwchoiit 2023. 11. 13. 11:14
728x90
반응형
SMALL

JPA와 Spring을 공부하던 중 REST API를 통해 데이터를 주고 받아야하는 상황이 빈번하게 있을텐데 이 때 컨트롤러에서 엔티티를 반환하는 일이 생기면 절대 안된다. 많은 문제가 있지만, 그 중에서도 어떤 문제가 있냐면, 엔티티의 변경이 생겼을 때 API 스펙까지도 아예 변경되어야 하기 때문이다. 또한, 엔티티는 지연 로딩 전략을 사용할텐데 지연 로딩 전략을 사용한 상태에서 이를 처리하지 않는 경우 Response를 제대로 할 수 없거나 성능에 문제가 생기거나 불필요한 Response data가 생긴다.

 

예시를 살펴보자, 우선 컨트롤러가 엔티티를 다루는 코드를 살펴보자.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/")
public class OrderApiController {

    private final OrderRepository orderRepository;

    @GetMapping("orders")
    public List<Order> orders() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        return orders;
    }
}

 

간단한 주문정보 리스트를 반환하는 컨트롤러를 만들고 이를 실제로 요청해보면 (나같은 경우) 다음과 같은 에러를 마주하게 된다.

at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:107) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:25) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.serializeFields(BeanSerializerBase.java:772) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.BeanSerializer.serialize(BeanSerializer.java:178) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serializeContents(CollectionSerializer.java:145) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:107) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.std.CollectionSerializer.serialize(CollectionSerializer.java:25) ~[jackson-databind-2.15.3.jar:2.15.3]
	at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.serializeAsField(BeanPropertyWriter.java:732) ~[jackson-databind-2.15.3.jar:2.15.3]

 

이 에러는 왜 발생하냐면 Jackson 라이브러리에서 엔티티를 JSON으로 반환할 때 무한 루프에 빠지기 때문인데 왜 무한 루프에 빠지냐면 Order 엔티티는 Member 엔티티를 참조하고 Member 엔티티가 Order 엔티티를 참조하기 때문에 계속 서로가 서로를 참조하는 무한 루프에 빠지는 것.

반응형
728x90

Order Entity

@Entity
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
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", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) // Order가 persist될 때 order에 넣어져있는 delivery 역시 persist
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus;
}

Member Entity

@Entity
@Getter @Setter
public class Member {

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

    private String username;

    @Embedded
    private Address address;

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

 

이런 경우부터 문제가 생기는데 이를 어찌저찌 해결할 순 있다. Jackson 라이브러리에게 JSON으로 변환할 때 무시하라고 명령하면 된다.

@JsonIgnore 어노테이션을 Order가 참조하는 엔티티 중 Order를 참조하는 엔티티에서 @JsonIgnore 어노테이션을 달아주면 된다.

@Entity
@Getter @Setter
public class Member {

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

    private String username;

    @Embedded
    private Address address;

    @JsonIgnore 🟢🟢🟢🟢
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter @Setter
public class OrderItem {
    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @JsonIgnore 🟢🟢🟢🟢
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;
    private int count;
}

 

@Entity
@Getter @Setter
public class Delivery {
    @Id @GeneratedValue
    @Column(name = "delivery_id")
    private Long id;

    @JsonIgnore 🟢🟢🟢🟢
    @OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
    private Order order;

    @Embedded
    private Address address;

    @Enumerated(EnumType.STRING)
    private DeliveryStatus deliveryStatus;
}

 

이렇게 다시 Order를 참조하는 모든 엔티티에 @JsonIgnore를 걸어주고 다시 요청하면 다음과 같은 에러가 출력된다.

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]->com.example.jpabook.domain.Order["member"]->com.example.jpabook.domain.Member$HibernateProxy$7rXeVz14["hibernateLazyInitializer"])

 

이번엔 다른 에러지만 역시 에러는 발생한다. 이는 또 어떤 경우냐면 org.hibernate.proxy.bytebuddy.ByteBuddyInterceptor에 주목하면 되는데 지연 로딩 전략으로 데이터베이스에서 가져올 때 스프링은 지연 로딩 객체를 객체 그대로로 받아오지 않고 프록시로 받아온다. 그리고 프록시로 받아오는 객체가 바로 ByteBuddyInterceptor이다. 이 때 이 녀석은 초기화되지 않은 상태이기 때문에 리턴값으로 돌려주는 JSON으로 변환하는 과정에서 Jackson 라이브러리는 어찌할 방도가 없는 것이다. 

 

이를 해결하기 위해 Jackson Datatype Hibernate5를 내려받고 @Bean으로 등록하면 지연 로딩 전략으로 가져오는 모든 엔티티에 대해서 그냥 없는값으로 처리를 해준다. 그래서 우선은 저 dependency를 내려받자.

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

 

(참고로 Spring ^3.0 에서는 Jackson Datatype Hibernate5 Jakarta를 사용해야 한다.)

@Bean
public Hibernate5JakartaModule hibernate5Module() {
    return new Hibernate5JakartaModule();
}

 

이렇게 @Bean으로 등록한 후 다시 요청해보면 비로소 다음과 같은 정상 결과가 출력된다.

 

그러나, null 값으로 나오는 것도 지저분하고 필요없는 데이터까지 전부 보여진다는 것도 좋은 응답은 아니다. 그렇기 때문에 더 좋은 방법이 필요한데 그 방법은 DTO를 추가적으로 만들어서 리턴하는 것이다.   

SMALL
@GetMapping("orders")
public List<OrdersResponse> orders() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    return orders.stream().map(OrdersResponse::new).toList();
}

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

        public OrdersResponse(Order order) {
            orderId = order.getId();
            name = order.getMember().getUsername();
            orderDate = order.getOrderDate();
            orderStatus = order.getOrderStatus();
            address = order.getDelivery().getAddress();
        }
    }

이처럼 이너클래스로 Response에 대한 DTO 클래스를 정의한 후 컨트롤러는 반환 타입을 해당 DTO로 설정해준다. 그리고 해당 DTO 형식에 맞게 데이터를 변환해서 만들어주면 불필요한 데이터 노출도 하지 않고 엔티티가 변경된다고 한들 API 스펙이 변경될 일이 없다. 

 

주의할 점은 "컨트롤러는 반환을 DTO를 만들어서 해야하니 DTO를 만들자!" 이렇게 잘 했는데 DTO 클래스의 필드로 엔티티를 받는 경우가 있다. 이 경우도 안된다. 반환하는 DTO에 엔티티가 있으면 이 엔티티도 DTO로 바꿔줘야 한다. 

 

다음 예시를 보자.

@Data
static class OrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItem> orderItems; //엔티티가 DTO에 포함된 경우

    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getUsername();
        orderDate = order.getOrderDate();
        orderStatus = order.getOrderStatus();
        address = order.getDelivery().getAddress();

        order.getOrderItems().stream().forEach(o -> o.getItem().getName());
        orderItems = order.getOrderItems();
    }
}

 

OrderDto 클래스를 보면 필드로 List<OrderItem> 타입의 orderItems 필드가 있는데, 여기서 OrderItem은 엔티티다. 이렇게 되면 안된다. 이 또한 DTO로 변경해줘야 한다. 다음 코드처럼.

@Data
static class OrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemDto> orderItems;

    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getUsername();
        orderDate = order.getOrderDate();
        orderStatus = order.getOrderStatus();
        address = order.getDelivery().getAddress();

        orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
    }
}

@Data
static class OrderItemDto {

    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemDto(OrderItem orderItem) {
        itemName = orderItem.getItem().getName();
        orderPrice = orderItem.getOrderPrice();
        count = orderItem.getCount();
    }
}

 

728x90
반응형
LIST