오류 해결

[Spring Boot/JPA] 계층형 대댓글 설계 시 발생하는 무한 순환 참조(Infinite Recursion) 원인 및 DTO 분리를 통한 해결

ohs020105 2026. 4. 2. 15:33

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으로 변환(직렬화)하기 시작했습니다.

  1. Comment(부모)를 JSON으로 변환한다.
  2. 부모 안의 children(자식 리스트)을 JSON으로 변환한다.
  3. 자식 안의 parent(부모)를 다시 JSON으로 변환한다.
  4. 부모 안의 children을 또다시... (무한 반복)

결국 부모와 자식이 서로를 핑퐁처럼 계속 호출하다가 메모리가 터져버린 것입니다.

3.  해결을 위한 고민: @JsonIgnore가 정답일까?

이 문제를 구글링해보면 가장 흔하게 나오는 해결책은 양방향 연관관계 중 한 곳에 @JsonIgnore나 @JsonManagedReference / @JsonBackReference를 붙여 직렬화를 끊어내는 것입니다.

하지만 저는 이 방법을 선택하지 않았습니다. 그 이유는 다음과 같습니다.

  1. 관심사의 분리(Separation of Concerns) 위배: 엔티티(Entity)는 데이터베이스 테이블과 매핑되는 핵심 도메인 객체입니다. JSON 직렬화라는 프리젠테이션(뷰) 계층의 로직을 처리하기 위해 핵심 도메인인 엔티티에 @JsonIgnore 같은 어노테이션이 덕지덕지 붙는 것은 객체지향적이지 않다고 판단했습니다.
  2. 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를 제공하는 것이 백엔드 개발자의 진짜 역할이라는 것을 배운 소중한 경험이었습니다.