티스토리 뷰
반응형
[JPA] JSON 직렬화 순환 참조 해결하기
순환 참조.
JPA에서 양방향으로 연결된 엔티티를 JSON 형태로 직렬화하는 과정에서, 서로의 정보를 계속 순환하며 참조하여 StackOverflowError
를 발생시키는 현상
.
직렬화.
객체/데이터를 바이트 형태로 변환하여 네트워크를 통해 송수할 수 있도록 만드는 것
Situation
Product
에서ProductCategory
를 조회할 수도 있고,ProductCategory
에서Product
도 조회할 수 있어야 한다.Controller 에서
Product
목록을 JSON 형태 직렬화하여 반환해야 한다.
@Getter
@NoArgsConstructor
@Entity
public class Product extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_category_id", nullable = false)
private ProductCategory productCategory;
@Column(length = 100, nullable = false)
private String name;
@Column(length = 20000)
private String contents;
@Column(nullable = false, columnDefinition = "BIGINT default 0")
private Long hits;
@Enumerated(EnumType.STRING)
@Column(length = 1, nullable = false, columnDefinition = "BIGINT default N")
private BooleanFormatType deleteYn;
@Column(nullable = false)
private Long userId;
private LocalDateTime deletedDateTime;
@OneToMany(mappedBy = "product", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private Set<ProductUploadFile> productUploadFiles = new LinkedHashSet<>();
}
@ResponseBody
@GetMapping("/scroll")
public ResponseEntity<List<Product>> scrollList(
@PageableDefault(page = 0, size = 10) Pageable pageable,
Model model) {
Page<Product> productListPage = categoryService.findAllSortByIdDescPaging(pageable.getPageNumber(), pageable.getPageSize());
return new ResponseEntity<>(productListPage.getContent(), HttpStatus.OK);
}
Problem
- 매핑된 데이터를
FetchType.LAZY
로 사용하고 있고, - 두 Entity 가
1:N
,N:1
양방향 관계를 가지고 있고, - Entity 자체를 JSON 으로 직렬화하여 반환할 경우
순환 참조가 발생
- 또는, Entity 에 @Data, @ToString, @EqualsAndHashCode 을 사용하면서 두 객체가 서로의 필드를 계속 참조하며 순환참조 발생
Cannot call sendError() after the response has been committed
...
...
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.leather.workshop.domain.product.domain.Product["productCategory"]->com.leather.workshop.domain.product.domain.
ProductCategory$HibernateProxy$Q6UsphhP["products"]->org.hibernate.collection.internal.PersistentSet[0]->com.leather.workshop.domain.product.domain.Product["productCategory"]->com.leather.workshop.domain.product.domain.ProductCategory$HibernateProxy$Q6UsphhP["products"]->org.hibernate.collection.internal.PersistentSet[0]->com.leather.workshop.domain.product.domain.Product["productCategory"]->com.leather.workshop.domain.product.domain.
ProductCategory$HibernateProxy$Q6UsphhP["products"]->org.hibernate.collection.internal.PersistentSet[0]->com.leather.workshop.domain.product.domain.Product["productCategory"]->com.leather.workshop.domain.product.domain.ProductCategory$HibernateProxy$Q6UsphhP["products"]->org.hibernate.collection.internal.PersistentSet[0]->com.leather.workshop.domain.product.domain.Product["productCategory"]->com.leather.workshop.domain.product.domain
...
...
ProductCategory$HibernateProxy$Q6UsphhP["products"]->org.hibernate.collection.internal.PersistentSet[0]->com.leather.workshop.domain.product.domain.Product["createdDateTime"])] with root cause
Cause
Product
엔티티를 JSON 형태로 직렬화하는 과정에서,Product
엔티티가 참조하고 있는ProductCategory
엔티티를 조회하게 된다.ProductCategory
엔티티를 조회하는 과정에서,ProductCategory
엔티티가 참조하고 있는Product
엔티티를 조회한다.- 다시
Product
엔티티를 조회하는 과정에서,Product
엔티티가 참조하고 있는ProductCategory
엔티티를 조회... 다시ProductCategory
엔티티를 조회.. - 이렇게 위 과정이 끊임없이 반복되며 두 Entity는 계속 서로를 참조하다 결국 StackOverflowError 를 발생시키게 된다.
@ResponseBody 와 객체 직렬화
Spring Boot 는 Controller에 @ResponseBody 선언 시 Object 를 JSON 형태로 직렬화하기 위해 HttpMessageConverters
에서 jackson Library
활용
- jackson의 직렬화 방식
- 기본적으로 public 필드만 직렬화를 시도
- private 필드를 직렬화하기 위해 getter 선언
- 아래 Exception 이 발생한다면 getter 선언이 되어있는지 확인해 보자.
No serializer found for class
no properties discovered to create BeanSerializer
- 아래 Exception 이 발생한다면 getter 선언이 되어있는지 확인해 보자.
Solution
1. Entity 대신 DTO 로 반환
- 가장 추천하는 방식이다.
- Entity 클래스는 데이터베이스와 맞닿는 핵심 클래스이다.
- Entity 클래스를 기준으로 수많은 클래스나 비즈니스 로직들이 동작하고 있다.
- Entity 클래스를 통해 여러 클래스들이 영향을 받을 수 있으므로 Entity 클래스를 Request/Response 클래스로 사용하는 것은 강력하게 추천하지 않는다.
- 컨트롤러에서 Response 값으로 여러 테이블을 조인해야하는 경우가 많으므로, DB Layer 와 View Layer 의 역할 분리를 철저하게 해주자.
- 역시나 객체지향 설계에 있어서 역할과 책임은 중요한 요소인 것 같다.
[스프링 부트와 AWS로 혼자 구현하는 웹 서비스 참고]
public class ProductDto {
@Getter
@NoArgsConstructor
public static class Response {
private Long id;
private ProductCategoryDto productCategory;
private String name;
private String contents;
private Long hits;
private BooleanFormatType deleteYn;
private Long userId;
private LocalDateTime deletedDateTime;
private List<ProductUploadFileDto> productUploadFiles = new ArrayList<>();
public Response(Product entity) {
this.id = entity.getId();
this.productCategory = new ProductCategoryDto(entity.getProductCategory());
this.name = entity.getName();
this.contents = entity.getContents();
this.hits = entity.getHits();
this.deleteYn = entity.getDeleteYn();
this.userId = entity.getUserId();
this.deletedDateTime = entity.getDeletedDateTime();
this.productUploadFiles = entity.getProductUploadFiles().stream()
.map(productUploadFile -> new ProductUploadFileDto(productUploadFile))
.collect(Collectors.toList());
}
}
@Getter
public static class ProductCategoryDto {
private Long id;
private String title;
private Integer orderNo;
private String categoryUseYn;
public ProductCategoryDto(ProductCategory entity) {
this.id = entity.getId();
this.title = entity.getTitle();
this.orderNo = entity.getOrderNo();
this.categoryUseYn = entity.getCategoryUseYn();
}
}
@Getter
public static class ProductUploadFileDto {
private Long id;
private String uploadFileName;
private String storeFileName;
private BooleanFormatType thumbnailYn;
public ProductUploadFileDto(ProductUploadFile entity) {
this.id = entity.getId();
this.uploadFileName = entity.getUploadFileName();
this.storeFileName = entity.getStoreFileName();
this.thumbnailYn = entity.getThumbnailYn();
}
}
}
2. @JsonManagedReference & @JsonBackReference
양방향 관계에서 직렬화 방향을 설정하여 순환 참조를 해결할 수 있도록 설계된 애노테이션
@JsonManagedReference
연관관계 주인 반대 Entity 에 선언
정상적으로 직렬화 수행
@OneToMany(mappedBy = "product", cascade = {CascadeType.PERSIST, CascadeType.MERGE}) @JsonManagedReference private Set<ProductUploadFile> productUploadFiles = new LinkedHashSet<>();
@JsonBackReference
연관관계의 주인 Entity 에 선언
직렬화가 되지 않도록 수행
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_category_id", nullable = false) @JsonBackReference private ProductCategory productCategory;
3. @JsonIgnore
- 양방향 관계를 가지고 있는 두 엔티티 중 하나의 엔티티의 참조 필드에 직렬화를 제외시키는 방법
- JSON 직렬화 과정에서 해당 애노테이션이 선언된 필드는 직렬화 대상에서 제외
- 해당 필드가 직렬화에 필요할 경우에는 적합하지 않은 방법
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_category_id", nullable = false)
@JsonIgnore
private ProductCategory productCategory;
Reference
https://dev-coco.tistory.com/133
https://subji.github.io/posts/2020/08/06/infiniterecusionofjpa
반응형
'Web > Spring' 카테고리의 다른 글
[JPA] 양방향 관계 Entity 저장하기 (0) | 2022.03.14 |
---|---|
Spring Boot, Gradle 환경에 Querydsl 설정 방법 (2) | 2022.03.09 |
Spring Boot 2.4 Config file processing (0) | 2022.02.11 |
SpringBoot & Mybatis & MS-SQL (0) | 2022.01.31 |
[Spring Boot TDD] Spring Boot API TDD Start (0) | 2022.01.09 |
댓글