1. 문제 상황: 대댓글 조회 시 서버가 뻗어버렸다.
Study-Flow 프로젝트에서 스터디 게시판의 소통을 원활하게 하기 위해 계층형 댓글(대댓글) 기능을 구현하고 있었습니다. 댓글 엔티티(Comment) 내에 자기 자신을 참조하는 부모-자식 관계를 설정하고, 클라이언트(React)에 댓글 목록을 반환하는 API를 테스트하는 순간, 포스트맨(Postman)이 멈추더니 서버 콘솔에 어마어마한 길이의 에러 로그가 쏟아졌습니다.
// 에러 로그
java.lang.IllegalStateException: Cannot call sendError() after the response has been committed
...
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Direct self-reference leading to cycle ...
원인은 바로 무한 ☆순환 참조(Infinite Recursion) ☆ 로 인한 StackOverflow였습니다.
2. 원인 분석: Jackson은 멈추는 법을 모른다.
에러의 원인은 Spring Boot의 기본 JSON 직렬화 라이브러리인 Jackson의 동작 방식과 제 Comment 엔티티 설계의 충돌에 있었습니다.
당시 제 엔티티 설계는 대략 이러했습니다.
@Entity
public class Comment {
@Id @GeneratedValue
private Long id;
private String content;
// 부모 댓글
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Comment parent;
// 자식 댓글 (대댓글)
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> children = new ArrayList<>();
}
Controller에서 List<Comment> 엔티티 자체를 HTTP 응답으로 반환하자, Jackson 라이브러리가 엔티티를 JSON으로 변환(직렬화)하기 시작했습니다.
- Comment(부모)를 JSON으로 변환한다.
- 부모 안의 children(자식 리스트)을 JSON으로 변환한다.
- 자식 안의 parent(부모)를 다시 JSON으로 변환한다.
- 부모 안의 children을 또다시... (무한 반복)
결국 부모와 자식이 서로를 핑퐁처럼 계속 호출하다가 메모리가 터져버린 것입니다.
3. 해결을 위한 고민: @JsonIgnore가 정답일까?
이 문제를 구글링해보면 가장 흔하게 나오는 해결책은 양방향 연관관계 중 한 곳에 @JsonIgnore나 @JsonManagedReference / @JsonBackReference를 붙여 직렬화를 끊어내는 것입니다.
하지만 저는 이 방법을 선택하지 않았습니다. 그 이유는 다음과 같습니다.
- 관심사의 분리(Separation of Concerns) 위배: 엔티티(Entity)는 데이터베이스 테이블과 매핑되는 핵심 도메인 객체입니다. JSON 직렬화라는 프리젠테이션(뷰) 계층의 로직을 처리하기 위해 핵심 도메인인 엔티티에 @JsonIgnore 같은 어노테이션이 덕지덕지 붙는 것은 객체지향적이지 않다고 판단했습니다.
- API 스펙의 취약성: 엔티티를 직접 반환하면 엔티티의 필드명이 바뀔 때 API 스펙(JSON Key)이 통째로 바뀌어 프론트엔드에 치명적인 에러를 유발할 수 있습니다.
4. 💡 최종 해결: DTO 분리를 통한 안전한 파이프라인 구축
따라서, 엔티티를 뷰로 직접 노출하지 않고 API 통신만을 위한 전용 **DTO(Data Transfer Object)**를 설계했습니다. 이때, 일반적인 class 대신 Java 14부터 도입된 record 키워드를 활용했습니다.
record를 선택한 이유는 두 가지입니다. 첫째, DTO는 계층 간 데이터를 전달하는 역할만 하므로 데이터가 중간에 변경되지 않는 **불변성(Immutable)**을 보장하는 것이 안전합니다. 둘째, Lombok의 @Getter나 생성자 같은 보일러플레이트 코드를 획기적으로 줄여 코드가 훨씬 간결해집니다.
[CommentResponseDto.java]
public record CommentResponseDto(
Long id,
String content,
String authorName,
LocalDateTime createdAt,
List<CommentResponseDto> children // 엔티티가 아닌 DTO 리스트로 선언
) {
// Entity -> DTO 변환 로직
public static CommentResponseDto from(Comment comment) {
return new CommentResponseDto(
comment.getId(),
comment.getContent(),
comment.getUser().getNickname(),
comment.getCreatedAt(),
// 자식 댓글(엔티티)들을 순회하며 다시 DTO로 변환하여 매핑 (부모 참조는 제외됨!)
comment.getChildren().stream()
.map(CommentResponseDto::from)
.collect(Collectors.toList())
);
}
}
이제 Service 계층에서 엔티티를 조회한 후, CommentResponseDto.from() 메서드를 통해 안전한 형태의 불변 객체(record)로 변환하여 Controller로 넘겨주게 되었습니다. DTO 구조 자체에 parent 필드가 존재하지 않으므로, Jackson이 직렬화를 수행할 때 무한 참조 에러가 원천적으로 차단됩니다.
5. 부가적인 이점: 고아 객체(Orphan) 처리와 정합성
계층형 구조를 설계하면서 부모 댓글이 삭제될 때 자식 댓글이 공중에 붕 뜨는(고아 객체) 문제도 함께 고려했습니다. 엔티티 설계 시 @OneToMany 옵션에 cascade = CascadeType.ALL과 orphanRemoval = true를 명시하여, 부모 댓글 삭제 시 데이터베이스 수준에서 연관된 자식 대댓글까지 한 번에(안전하게) 삭제되도록 데이터 정합성(Integrity)을 맞출 수 있었습니다.
6. 느낀 점
이번 트러블슈팅을 통해 "왜 수많은 개발자들이 번거로움을 감수하고서라도 Entity와 DTO를 분리하라고 강조하는지" 뼈저리게 체감했습니다. 무작정 동작하는 코드를 짜는 것을 넘어, 각 계층(Layer)의 역할을 명확히 분리하고, 클라이언트(프론트엔드)에게 안전하고 일관된 API를 제공하는 것이 백엔드 개발자의 진짜 역할이라는 것을 배운 소중한 경험이었습니다.