[Spring] DTO 반환으로 순환참조 오류 해결하기

김재연·2023년 3월 12일
3

수숙관

목록 보기
7/17
post-thumbnail

🤔 문제상황

reviewList를 반환하기 위해 Entity를 JSON으로 직렬화 후 반환하는데서 순환참조 라는 문제가 생겼다.

❓ 순환참조란 ❓

: JPA에서 양방향으로 연결된 엔티티를 JSON 형태로 직렬화하는 과정에서, 서로의 정보를 계속 순환하며 참조하여 StackOverflowError를 발생시키는 현상
1. A Entity(이하 A)와 B Entity(이하 B)가 양방향으로 연결된 상태
2. A를 JSON으로 직렬화하기 위해 A가 참조하고 있는 B를 조회
3. B를 조회하는 과정에서 B가 참조하고 있는 A를 조회
4. A를 조회하는 과정에서 A가 참조하고 있는 B를 조회
5. 무한반복... ☠️

먼저 Entity를 보면, ReviewNoteTag는 다대일 관계로 연결되어있다. 아직 Tag와는 단방향 연결만 해놨기 때문에, ReviewNote의 관계만 놓고 보도록 하겠다.

/* Review Entity */
public class Review {
    // ...생략...
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "note_id")
    private Note note;
}

/* Note Entity */
public class Note {
    // ...생략...
    @OneToMany(mappedBy = "note", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    private final List<Review> reviews = new ArrayList<>();
}

1. @JsonIgnore 사용하기 (비추👎)

이전에도 잠시 테스트 용으로 Review Entity 하나를 반환하는 코드를 짜다가 순환참조 오류가 났던 적이 있는데, 이때는 오류만 안나면 됐던터라 N:1 매핑이 되어있는 필드에 @JsonIgnore을 붙여서 간단하게 해결했었다.

public class Review {
    // ...생략...
    @JsonIgnore // <- 이런식으로!
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "note_id")
    private Note note;
}

이후로 딱히 Entity를 반환할 일이 없어서 대충 넘어가고 있었는데, Review 리스트를 반환해야 하는데서 발목을 잡혔다.

반환하는거야 별 문제가 없었지만 문제는 반환값이

[
    {
        "id": 1,
        "body": "단어책 1~15p",
        "isCompleted": true
    },
    {
        "id": 2,
        "body": "단어책 16~20p",
        "isCompleted": true
    },
    ...
]

이런식으로 notetag만 쏙 빼고 나온 것이었다. 🤯😧

찾아보니 @JsonIgnore는 직렬화할 때 해당 필드를 아예 무시하는거라 Review Entity를 JSON으로 직렬화할 때 notetag가 아예 무시되어서 안나온거였다..

그래서 어노테이션으로 대충 해결할 수 없을까 싶어서 이것저것 찾다보니 @JsonIgnore은 별로 추천하는 방법이 아니라고 한다.

2. DTO 반환하기 (추천👍)

대신 @JsonIgnore의 대체 방법이자 순환참조를 해결하는 방법으로 가장 많이 추천되는 것이 바로 "DTO를 반환" 하는 것이었다. 또한 Entity를 직접 반환하는 것 자체도 썩 좋은 방법이 아니라고 한다.

- ReviewDTO 사용하기

그래서 Review Entity와 거의 동일한 형태의 DTO를 아래와 같이 작성했다.

/* ReviewDTO */
public static class ReviewDTO {
        private Long id;
        private String body;
        private Boolean isCompleted;
        private Note note;
        private Tag tag;

		// Entity => DTO
        public ReviewDTO(Review review) {
            this.id = review.getId();
            this.body = review.getBody();
            this.isCompleted = review.getIsCompleted();
            this.note = review.getNote();
            this.tag = review.getTag();
        }
}
/* service */
public List<ReviewDTO> reviewList() {
	List<Review> reviewList = reviewRepository.findAll();
	List<ReviewDTO> reviewDTOList = reviewList.stream()
									.map(o->new ReviewDTO(o))
                               		.collect(Collectors.toList());
	return reviewDTOList;
}

아 근데~~!! reviewListreviewDTOList로 변환되는거까진 잘됐는데 return할때 계속!! 계속!!!! 순환참조 오류가 났다. 🤬🤬🤬 터미널에 Hibernate 찍히는걸 보니까 앞에서 내가 짠대로 쿼리 돌아갈때는 잘만 돌아가놓고 return할때 쿼리가 한번 더 찍히는게 도무지 이해가 안가서 화가 무진장 났는데 머리를 한번 식히고 생각해보니..... notetag 부분이 매우엄청겁나많이 수상해보였다.

한번.. 생각을 해보자? 나야?

  1. ReviewDTO를 JSON으로 직렬화할건데,
  2. ReviewDTO의 필드인 Note도 JSON으로 직렬화해서 보여줘야겠지.
  3. 근데? Note를 보면 얘는 Review를 참조하고 있다.
  4. 그럼 Review를 또 봐.
  5. 근데 Review는 또 Note를 참조하고 있네?
  6. 그럼 Note를 또 봐.

뭐야 이게!! 바보다 바보 🫠

- (Review)DTO 안의 (Note)DTO

그래서 ReviewDTO를 직렬화할때 Note를 직렬화할 포맷을 따로 작성했다. (Tag도 마찬가지) 그래서 완성한 코드는 아래와 같다.

/* ReviewDTO */
@Getter
@Setter
public static class ReviewDTO {
		/* ReviewDTO의 필드들 */
        private Long id;
        private String body;
        private Boolean isCompleted;
        private ReviewNoteDTO note;
        private ReviewTagDTO tag;

		/* Review => ReviewDTO */
        public ReviewDTO(Review review) {
            this.id = review.getId();
            this.body = review.getBody();
            this.isCompleted = review.getIsCompleted();
            this.note = new ReviewNoteDTO(review.getNote());
            this.tag = new ReviewTagDTO(review.getTag());
        }

		/* Review => ReviewDTO 할때 참조할 NoteDTO */
        @Getter
        public static class ReviewNoteDTO {
            private Long id;
            /* Note => NoteDTO */
            public ReviewNoteDTO(Note note) {
                this.id = note.getId();
            }
        }

		/* Review => ReviewDTO 할때 참조할 TagDTO */
        @Getter
        public static class ReviewTagDTO {
            private Long id;
            private String name;
            /* Tag => TagDTO */
            public ReviewTagDTO(Tag tag) {
                this.id = tag.getId();
                this.name = tag.getName();
            }
        }
}

service 코드는 앞과 동일하다. 이렇게 반환할 정보를 깔끔하게 담은 DTO를 활용하면!

이렇게 오류 없이 잘 나온다! 🥳🥳

➕ 2023.06.17 추가
한참 뒤에 덧붙이자면.. 어차피 필드도 많지 않으니 이렇게 쓰는게 다른데서도 안꼬이고 편하더라..

public class ReviewResponseDTO {
    private Long noteId;
    private Long tagId;
    private String tagName;
    ...
    public ReviewResponseDTO(Review review) {
        this.noteId = review.getNote().getId();
        this.tagId = review.getTag().getId();
        this.tagName = review.getTag().getName();
        ...
    }
}

Reference

[JPA] JSON 직렬화 순환 참조 해결하기

profile
일기장같은 공부기록📝

0개의 댓글