![article thumbnail image](https://blog.kakaocdn.net/dn/HyMee/btrGsoAFoTq/0gYRYEj1bgFiHoGkuLCyL1/img.png)
순환 참조가 발생하는 API
위와 같이 설계된 데이터베이스가 있고 현재 주문 + 배송 정보 + 회원을 조회하는 API를 만들었다 가정하자.
Controller의 API 코드는 다음과 같고 간단하게 주문 정보를 전부 가져오는 API이다.
보기에는 크게 문제가 있을까 싶지만 해당 API에는 컴파일 타임에는 잡히지 않는 장애 요소가 있다.
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
List<Order> result = orderRepository.findAllByString(new OrderSearch());
return result;
}
어떤 문제인지 확인하기 위해 먼저 빌드 후 포스트맨으로 테스트를 해보자.
포스트맨으로 요청을 날려보니 결과값도 안나오고 아에 요청 자체가 실패해버렸고 스프링 부트의 로그를 보면 아래와 같이 나온다.
2022-07-01 01:25:43.417 ERROR 25234 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed;
nested exception is org.springframework.http.converter.HttpMessageNotWritableException:
Could not write JSON: Infinite recursion (StackOverflowError); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
(through reference chain: com.jpa.book.jpashop.domain.Member$HibernateProxy$78zRCQQR["orders"]->org.hibernate.collection.internal.PersistentBag[0]->com.jpa.book.jpashop.domain.Order["member"]->com.jpa.book.jpashop.domain.Member$HibernateProxy$78zRCQQR["orders"]->org.hibernate.collection.internal.PersistentBag[0]
// ... //
발생한 에러를 보면 핵심적인 문구는 Infinite recursion
때문에 stack overflow
가 발생했고 그러면서 json
문자열을 쓰기를 하는데 실패했다는 내용으로 알 수 있다.
Could not write JSON: Infinite recursion (StackOverflowError); nested exception is com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError)
왜 이런 문제가 발생했을까?
결론부터 말하면 순환 참조 관계를 가진 엔티티들 간에 JPA를 통해서 값을 조회하고 Jackson을 이용해 Serialize하는 과정에서 무한으로 참조되며 스택오버플로우가 발생해 에러가 난 상황이다.
순환 참조가 일어난 이유는 데이터베이스의 구조에 있다.
현재 데이터베이스의 ERD를 보게되면 Order와 Member는 1:N관계이고 Order와 Delivery는 1:1 Order와 OrderItem은 1:N관계를 가지고 있고 각 테이블 별로 살펴보자.
먼저 Order 엔티티를 살펴보자.Order
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
private LocalDateTime orderDate;
private OrderStatus status;
}
Order 엔티티의 경우 ManyToOne관계로 Member를 가지고있고 OneToMany관계로 OrderItem을 OneToOne관계로 Delivery가 있다.
어떤 엔티티가 1:1인지 1:N인지 N:N인지가 중요한 포인트는 아니고 양방향으로 관계를 맺었냐가 관건이다.
Order
엔티티를 보게되면 ManyToOne
관계로 맺어진 Member가 있다.
Jackson이 JSON화 시키기 위해서 Member 엔티티를 바라보면 하나의 Member 엔티티가 있어서 참조를 하게되고 Member 내부를 보면 N개의 리스트 형태로 저장된 Order 정보가 있어 또 다시 Order를 참조하게 된다.
정리를 해보면 이런 그림이 된다.
Order -> Member -> Order -> Member -> ...
이런식의 무제한 순환 참조가 이뤄지면서 에러가 발생한다.
그럼 어떤식으로 해결을 할까? 해결 방법에는 몇 가지 방법이 있다.
@JsonIgnore 적용하기
이런 경우를 해결하기 위해서는 1:N 관계에서 양쪽 중 한 쪽에 필드에 @JsonIgnore
를 해야한다.
다음과 같이 멤버 클래스의 orders에 @JsonIgnore
를 적용하면 Order를 파싱하는데 Member클래스로 참조해 들어가도 해당 클래스에 또 orders가 있더라도 다시 참조하지 않고 무시를 한다.
Member
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
마찬가지로 Order
와 OrderItems
도 1:N의 관계이므로 OrderItems
쪽에 @JsonIgnore
를 적용해준다.
OrderItem
@Entity
@Table(name = "order_item")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "item_id")
private Item item;
@JsonIgnore
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "order_id")
private Order order;
}
이외에도 양방향으로 관계가 적용된 부분은 모두다 한 쪽은 @JsonIgnore
어노테이션을 적용해야 한다.
Order와 Delivery도 적용해준다.
Delivery
@Entity
@Getter @Setter
public class Delivery {
@Id @GeneratedValue
@Column(name = "delivery_id")
private Long id;
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus stratus;
}
여기까지 적용을 했으면 다시 리스타트 후 API를 요청해보자.
양쪽으로 맺어진 관계들에 대해서 모두 @JsonIgnore
도 적용했지만 이번엔 새로운 에러가 생겼다.
지연 로딩으로 인한 파싱 실패
새로 발생한 에러는 관계가 맺어진 엔티티들에서 지연 로딩 과정에서 생기는 에러다.
지연 로딩인 경우에 JPA가 Member에 실제 객체를 넣어놓지 않고 프록시 객체를 가짜로 넣어놓는다.
결론적으로 Jackson 라이브러리가 JSON으로 만드는 과정에서 아직 실제 값이 아닌 프록시 객체를 가지고 Serialize하다보니 발생한 문제다.
private Member member = new ByteBuddyInterceptor();
가장 단순한 해결 방법은 Proxy객체인 경우에는 무시하도록 하는 방법이 있다.
그 방법을 사용하려면 Hibernate5Module을 등록해야하는데 현재 해당 디펜던시가 없기 때문에 먼저 추가를 해줘야한다.
Maven 저장소에서 gradle에 해당하는 패키지 문자열을 가져오고 build.gradle
에 추가해준다.
버전은 명시해도 되지만 특별히 명시를 해주지 않아도 된다. Spring Boot가 현재 자신의 버전에 맞는 최적화된 버전을 가지고 있기 때문에 명시를 해주지 않아도 된다.
혹시나 작동이 안된다면 그 때 버전을 명시 해주면 된다.
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
패키지가 추가되었다면 이제 Application 클래스에 Bean을 등록해주면 된다.
@SpringBootApplication
public class JpashopApplication {
public static void main(String[] args) {
SpringApplication.run(JpashopApplication.class, args);
}
@Bean
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
}
Hibernate5Module
을 등록 후에 리스타트 후 다시 요청을 해보자.
이제 성공적으로 결과 값이 나온다.
하지만 지연 로딩이 적용된 항목들에 대해서는 모두 null로 표시되어서 결과가 나왔다.
Member, Delivery 모두 지연 로딩이 적용되었고 1:N의 경우 기본값이 지연 로딩이여서 OrderItem도 마찬가지다.
하지만 여기서 null로 리턴된 지연로딩이 적용된 항목에 대해서 지연 로딩을 강제로 활성화 시키는 방법이 있다.
아래 설정을 한줄 추가해서 리스타트 해보자.
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
모두 강제로 지연 로딩을 통해서 값을 가져와서 리턴해준 결과가 나온다.
해결 방법들의 문제점
현재 API가 엔티티를 직접적으로 노출하는 코드다보니 API 스펙이 바뀌면 코드 단까지 모두 바뀌는 영향이 있다.
다음으로는 API 스펙 상 orderItems까지 모두 나올 필요가 없는데 지연로딩을 강제로 활성화 시키면서 모두 가져오기 위해서 불필요한 쿼리가 늘어나며 성능상 문제가 되는 부분도 있다.
(사용하지 않는 항목까지 모두 노출되는 문제)
사실 Hibernate5Module도 엔티티를 직접 노출하지 않았다면 사용하지 않아도 되는 일이다.
💡 **`결론은 엔티티를 직접 노출하는 것은 절대로 안된다.`**
Hibernate5Module 옵션을 사용하지 않고 지연로딩 활성화 하기
현재 아래 코드에서 order.getMember()
까지는 프록시 객체다.
아직 실제로 DB에서 값을 가져오지 않은 상태지만 order.getMember().getName()
까지 하는 경우 name필드를 가져와야 하기 때문에 DB에서 값을 가져오며 Lazy가 강제로 초기화된다.
여기서 어떤 필드를 가져오는지는 크게 중요하지 않고 어떤 필드든 간에 가져오는 행위가 Lazy
를 초기화 시킨다.
Delibery도 마찬가지로 동일하게 하면 된다.
@GetMapping("/api/v1/simple-orders")
public List<Order> orderV1() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
for (Order order : orders) {
order.getMember().getName(); // Lazy 강제 초기화
order.getDelivery().getAddress(); // Lazy 강제 초기화
}
return orders;
}
여기까지가 직접 엔티티를 노출시키는 API에서 발생한 문제를 정리한 내용이다.
'SpringBoot' 카테고리의 다른 글
Querydsl 최초 설정하기 (0) | 2022.06.27 |
---|---|
JPA 변경 감지와 병합 정리 (0) | 2022.06.19 |
HTTP Status Code 제어 및 Exception Handling (0) | 2022.04.04 |
User Service API 구현 (0) | 2022.03.22 |