웹개발/SpringBoot

[Troubleshooting] Spring Boot 양방향 관계를 가지고 있는 엔티티로 인한Jackson 무한 루프 문제

뎁쭌 2024. 1. 4. 18:04
728x90
반응형

점프 투 스프링부트 3 Spring Boot와 React를 이용하여 게시판 풀스택 프로젝트를 진행 중에 Question 엔티티와 Answer 엔티티의 양방향 관계 때문에  JackSon 무한루프에 빠져 오버플로우가 발생하는 문제가 생겼다.  에러 내용은 너무 길어서 가장 윗 부분만 잘라 왔다.

 

// Question Entity의 일부
@OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
private List<Answer> answerList = new ArrayList<>();
// Answer Entity 일부
@ManyToOne
private Question question;

해당 문제가 발생한 이유는 다음과 같다. 예를 들어, Question 객체를 JSON으로 변환하려고 할 때, Jackson은 QuesionanswerList를 직렬화한다. 각 Answer 객체 내부에는 다시 Question 객체에 대한 참조가 있으며, 이 Question 객체를 직렬화하려고 할 때, 다시 그 안의 answerList를 직렬화하려고 시도하며 이런 식으로 무한히 반복되는 것이다.  조금 더 자세하게 살펴보면 다음과 같다.

예시 상황
하나의  '질문'(Question)에 대해 여러 '답변'(Answer)을 가지고 있는 게시판 시스템을 가지고 있고,  한 '질문'은 여러 '답변'을 포함하고, 각 '답변'은  해당 답변이 달린 '질문'을 알고 있다.
데이터 구조
- `Question` 엔티티에는 `answerList`라는 필드가 있으며, 이는 해당 질문에 대한 모든 답변들의 목록이다

- `Answer` 엔티티에는 `question`이라는 필드가 있으며, 이는 해당 답변이 속한 질문이다.

JSON 직렬화 과정 (문제의 발생)
이제 이 구조를 JSON으로 변환해야 한다고 가정해 보자. JSON 변환 과정은 다음과 같이 진행된다.

1. `Question` 엔티티를 JSON으로 변환한다.
2. 이 과정에서 `Question`의 `answerList` 필드에 있는 모든 `Answer` 객체들도 JSON으로 변환해야 한다.
3. 각 `Answer` 객체를 변환하는 과정에서, `Answer`의 `question` 필드 (즉, 원래 `Question` 객체)를 다시 JSON으로 변환하려고 시도한다.
4. 이제, 이 `Question` 객체의 `answerList`를 다시 변환하려고 시도한다.
5. 이 과정은 무한히 반복되며, 결국 `StackOverflowError`를 발생시킨다.


이는 마치 '거울 속의 거울'과 같은 상황으로, 한 객체가 다른 객체를 참조하고, 그 객체가 다시 원래 객체를 참조하는 양방향 참조가 무한 루프를 발생시킨 것이다.

간단한 비유
생각해보면, 이 상황은 마치 두 사람이 서로에게 계속해서 다음 질문을 던지는 것과 비슷하다. "너는 무슨 생각을 하고 있니?"라는 질문에 대한 대답이 "나는 네가 무슨 생각을 하고 있는지 생각하고 있어"라고 한다면, 이 대화는 끝이 없을 것이다. 이처럼, JSON 직렬화 과정에서도 이와 같은 '끝없는 대화'가 발생하여 오류가 나타나는 것이다.

이제 이 무한루프를 해결해야 한다. 문제를 끝내기 위한 방법으로는 크게 3가지가 존재한다.

해결방법

해결 방법 1: @JsonManagedReference와 @JsonBackReference 사용

@JsonManagedReference와 @JsonBackReference는 Jackson이 양방향 관계를 올바르게 처리하도록 돕는 애너테이션이다.

- Question 클래스에서 answerList 필드에 @JsonManagedReference를 추가(부모)
- Answer 클래스에서 question 필드에 @JsonBackReference를 추가(자식)

//Question Entity 일부
@OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
@JsonManagedReference
private List<Answer> answerList = new ArrayList<>();

//Answer Entity 일부
@ManyToOne
@JsonBackReference
private Question question;

 

해결 방법 2: @JsonIgnore 사용

@JsonIgnore 애너테이션을 사용하여 직렬화 과정에서 특정 필드를 제외시킬 수 있다. 이 경우에는  Answer 클래스의 question 필드에서 @JsonIgnore를 사용하면 된다.

// Answer Entity의 일부
@ManyToOne
@JsonIgnore
private Question question;

 

해결 방법 3: DTO 사용

DTO(Data Transfer Object)를 사용하여 엔티티의 양방향 참조를 제거한다. 이 방법은 직렬화 과정에서 엔티티 대신 DTO를 사용하여 무한 루프 문제를 해결할 수 있다. 예를 들어, AnswerResponseDto와 QuestionResponseDto를 생성하여 필요한 데이터만 포함시킨다.

public class AnswerResponseDto {
    private Integer id;
    private String content;
    // 'question' 필드는 포함하지 않음
}

public class QuestionResponseDto {
    private Integer id;
    private String subject;
    private List<AnswerResponseDto> answers;
    // 'answers'는 DTO 리스트
}

 

위 3가지 해결방법의 장단점을 비교해보면 아래 테이블과 같다.

해결 방법 장점 단점 추천 사유
@JsonIgnore 사용 간단하고 빠르게 구현 가능 역참조 정보가 JSON에 포함되지 않음 간단한 애플리케이션을 빠르게 구현할 때 좋음
@JsonManagedReference/@JsonBackReference 사용 순환 참조를 자동으로 처리 역참조 쪽 정보가 JSON에 포함되지 않음 양방향 관계에서 직렬화 문제를 구조적으로 해결할 때 좋음
DTO(Data Transfer Object) 사용 유연하고, 제어 가능하며, 보안에 유리 추가적인 DTO 클래스 생성 및 변환 로직 필요 대규모 애플리케이션 또는 API와 엔티티 계층을 명확히 분리하고자 할 때 좋고 가장 유연하고 제어 가능한 방법

 

가장 추천하는 방법

이 중에서 가장 추천하는 방법은 DTO 사용이다. 이유는 아래와 같다.

- 유연성과 제어: DTO를 사용하면 어떤 데이터가 클라이언트에게 전송될지 정확히 제어할 수 있다. 이는 API의 명확성과 유지 보수성을 향상시킨다.

- 보안: 민감한 데이터를 실수로 클라이언트에 노출하는 것을 방지할 수 있다.

- 엔티티와 API 계층의 분리: 엔티티 클래스는 데이터베이스와의 매핑에 집중하고, DTO는 클라이언트와의 데이터 전송 형식에 집중한다. 이렇게 하면 엔티티 구조 변경이 API 스펙에 영향을 미치는 것을 방지할 수 있다.

하지만 결국 선택은 애플리케이션의 복잡성, 특정 요구 사항 및 개발 팀의 선호도에 따라 달라질 수 있다.

 

문제 해결!!