서비스를 개발하면서 마주친 JSON 순환 참조 문제와 그 해결 과정 정리.
게시글 상세 조회 API를 개발하던 중, 다음과 같은 에러 메시지를 만났습니다.
{
"status": "fail",
"data": {
"code": "UNK000",
"message": "알 수 없는 에러 발생",
"details": "Could not write JSON: Infinite recursion (StackOverflowError)"
}
}
처음에는 단순한 에러라고 생각했는데, 로그를 자세히 살펴보니 JSON 응답이 무한히 반복되는 현상을 발견했습니다.
문제의 원인을 찾기 위해 엔티티 구조를 살펴보았습니다.
@Entity
public class Post {
@OneToMany(mappedBy = "post")
private List<Comment> comments;
}
@Entity
public class Comment {
@ManyToOne
private Post post;
}
그러자 문제의 원인이 보였습니다. Post와 Comment 엔티티가 서로를 참조하는 양방향 관계를 가지고 있었고, 이로 인해 JSON 직렬화 과정에서 다음과 같은 무한 순환이 발생했던 것입니다:
Post -> comments -> Comment -> post -> comments -> Comment -> ...
이 문제를 해결하기 위한 몇 가지 방법을 검토해보았습니다.
@JsonManagedReference와 @JsonBackReference 사용
public class Post {
@JsonManagedReference
private List<Comment> comments;
}
public class Comment {
@JsonBackReference
private Post post;
}
간단하지만, 엔티티에 표현 계층의 책임이 포함되는 단점이 있었습니다.
@JsonIgnore 사용
public class Comment {
@JsonIgnore
private Post post;
}
마찬가지로 간단하지만, 유연성이 떨어지는 해결책이었습니다.
DTO 패턴 사용
엔티티와 표현 계층을 명확히 분리할 수 있는 방법이었습니다.
여러 방안을 검토한 끝에, DTO 패턴을 사용하기로 결정했습니다. 이유는 다음과 같습니다:
@Getter
@Builder
public class PostDTO {
private Long id;
private String title;
private String content;
private List<CommentDTO> comments;
public static PostDTO from(Post post) {
return PostDTO.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.comments(post.getComments().stream()
.map(CommentDTO::from)
.collect(Collectors.toList()))
.build();
}
}
서비스 계층도 다음과 같이 수정했습니다:
@Transactional
public PostDTO getPostById(Long id) {
Post post = postRepository.findPostById(id);
post.countViews();
return PostDTO.from(post);
}
DTO 패턴을 적용하면서 조회수 증가 로직도 함께 고민했습니다. 트래픽이 많아질 경우를 대비해 다음과 같은 최적화 방안도 고려해보았습니다:
하지만 현재 서비스 규모에서는 단순한 구현으로도 충분하다고 판단하여, 기본적인 트랜잭션 처리만 유지하기로 했습니다.
이번 경험을 통해 몇 가지 교훈을 얻었습니다: