사실 객체의 참조과 서로 있는 객체라면 발생하지, JPA와 직접적인 연관은 없다.

양방향 참조

1. toString() 메서드 구현 시 무한루프

@Entity
public class Parent {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<>();
    
    @Override
    public String toString() {
        return "Parent{id=" + id + ", name='" + name + "', children=" + children + "}";
    }
}

@Entity
public class Child {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @ManyToOne
    private Parent parent;
    
    @Override
    public String toString() {
        return "Child{id=" + id + ", name='" + name + "', parent=" + parent + "}";
    }
}

이 경우 Parent의 toString()이 Child의 toString()을 호출하고, Child의 toString()이 다시 Parent의 toString()을 호출하는 무한루프가 발생합니다.

2. equals()와 hashCode() 구현 시 무한루프

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "user")
    private Set<Post> posts = new HashSet<>();
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) && Objects.equals(posts, user.posts);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(id, posts);
    }
}

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    
    @ManyToOne
    private User user;
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Post post = (Post) o;
        return Objects.equals(id, post.id) && Objects.equals(user, post.user);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(id, user);
    }
}

User의 equals() 메서드가 Post의 equals()를 호출하고, Post의 equals()가 다시 User의 equals()를 호출하는 무한루프가 발생합니다.

3. JSON 직렬화 시 무한루프

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @ManyToOne
    private Team team;
}

위 엔티티들을 Jackson 같은 JSON 라이브러리로 직렬화할 때 Team → Members → Team → … 무한루프가 발생합니다.

4. DTO 변환 과정에서의 무한루프

public TeamDTO convertToDTO(Team team) {
    TeamDTO dto = new TeamDTO();
    dto.setId(team.getId());
    dto.setName(team.getName());
    dto.setMembers(team.getMembers().stream()
                      .map(this::convertToMemberDTO)
                      .collect(Collectors.toList()));
    return dto;
}

public MemberDTO convertToMemberDTO(Member member) {
    MemberDTO dto = new MemberDTO();
    dto.setId(member.getId());
    dto.setName(member.getName());
    dto.setTeam(convertToDTO(member.getTeam())); // 무한루프 발생
    return dto;
}

팀을 DTO로 변환하면서 멤버를 변환하고, 멤버를 변환하면서 다시 팀을 변환하는 무한루프가 발생합니다.

5. 엔티티 복사/클론 과정에서의 무한루프

public Order cloneOrder(Order original) {
    Order clone = new Order();
    clone.setId(original.getId());
    // 다른 속성들 복사
    
    // 고객 정보 복사
    if (original.getCustomer() != null) {
        clone.setCustomer(cloneCustomer(original.getCustomer()));
    }
    
    return clone;
}

public Customer cloneCustomer(Customer original) {
    Customer clone = new Customer();
    clone.setId(original.getId());
    // 다른 속성들 복사
    
    // 주문 정보 복사
    if (original.getOrders() != null) {
        for (Order order : original.getOrders()) {
            clone.addOrder(cloneOrder(order)); // 무한루프 발생
        }
    }
    
    return clone;
}

Order를 복제하면서 Customer를 복제하고, Customer를 복제하면서 다시 Order를 복제하는 무한루프가 발생합니다.

6. 재귀적 연관관계에서의 무한루프

@Entity
public class Comment {
    @Id @GeneratedValue
    private Long id;
    private String content;
    
    @ManyToOne
    private Comment parent;
    
    @OneToMany(mappedBy = "parent")
    private List<Comment> replies = new ArrayList<>();
}

위와 같은 자기참조 구조에서 toString(), equals(), hashCode(), JSON 직렬화 등을 구현할 때 모두 무한루프가 발생할 수 있습니다.

7. Fetch Join과 같은 JPQL 쿼리에서의 무한루프

// 무한 순환 참조 가능성이 있는 JPQL
@Query("SELECT p FROM Parent p JOIN FETCH p.children c JOIN FETCH c.parent")
List<Parent> findAllWithChildrenAndTheirParents();

이러한 쿼리는 JPA에서 직접적인 무한루프를 유발하지는 않지만, 결과를 처리하는 과정에서 위에서 언급한 다른 상황들과 결합하여 무한루프가 발생할 수 있습니다.

8. 양방향 연관관계에서 캐스케이드 작업 시 무한루프

영속성 전이(cascade)와 함께 양방향 연관관계를 사용할 때, 엔티티 저장이나 삭제 과정에서 상호 참조로 인한 무한루프가 발생할 수 있습니다.

해결책

1. JSON 직렬화 시 Jackson 어노테이션 사용

방법 1: @JsonIgnore 사용

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    
    // getter와 setter
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @ManyToOne
    @JsonIgnore // 이 속성은 JSON 직렬화에서 제외됨
    private Team team;
    
    // getter와 setter
}

방법 2: @JsonManagedReference와 @JsonBackReference 사용

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "team")
    @JsonManagedReference // 부모 쪽에서는 정상적으로 직렬화
    private List<Member> members = new ArrayList<>();
    
    // getter와 setter
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @ManyToOne
    @JsonBackReference // 자식 쪽에서는 직렬화에서 제외
    private Team team;
    
    // getter와 setter
}

방법 3: @JsonIdentityInfo 사용

@Entity
@JsonIdentityInfo(
    generator = ObjectIdGenerators.PropertyGenerator.class,
    property = "id"
)
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
    
    // getter와 setter
}

@Entity
@JsonIdentityInfo(
    generator = ObjectIdGenerators.PropertyGenerator.class,
    property = "id"
)
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @ManyToOne
    private Team team;
    
    // getter와 setter
}

2. DTO 변환 시 순환 참조 끊기

// DTO 클래스들
public class TeamDTO {
    private Long id;
    private String name;
    private List<MemberDTO> members;
    
    // getter와 setter
}

public class MemberDTO {
    private Long id;
    private String name;
    private Long teamId;  // 팀 객체 대신 ID만 포함
    private String teamName;  // 필요한 팀 정보만 포함
    
    // getter와 setter
}

// 변환 로직
public class EntityToDTOConverter {
    
    public TeamDTO convertTeamToDTO(Team team) {
        TeamDTO dto = new TeamDTO();
        dto.setId(team.getId());
        dto.setName(team.getName());
        
        if (team.getMembers() != null) {
            // 멤버를 변환할 때 팀 정보를 다시 포함하지 않음
            dto.setMembers(team.getMembers().stream()
                .map(this::convertMemberToDTOWithoutTeam)
                .collect(Collectors.toList()));
        }
        
        return dto;
    }
    
    // 팀 정보 없이 멤버만 변환
    private MemberDTO convertMemberToDTOWithoutTeam(Member member) {
        MemberDTO dto = new MemberDTO();
        dto.setId(member.getId());
        dto.setName(member.getName());
        
        // 팀 전체 객체 대신 필요한 정보만 포함
        if (member.getTeam() != null) {
            dto.setTeamId(member.getTeam().getId());
            dto.setTeamName(member.getTeam().getName());
        }
        
        return dto;
    }
    
    public MemberDTO convertMemberToDTO(Member member) {
        MemberDTO dto = new MemberDTO();
        dto.setId(member.getId());
        dto.setName(member.getName());
        
        // 팀 전체 객체 대신 필요한 정보만 포함
        if (member.getTeam() != null) {
            dto.setTeamId(member.getTeam().getId());
            dto.setTeamName(member.getTeam().getName());
        }
        
        return dto;
    }
}

해결 방법들의 장점

Jackson 어노테이션 방식:

  • @JsonIgnore: 가장 간단하지만 한쪽 방향의 정보만 직렬화됨
  • @JsonManagedReference/@JsonBackReference: 부모-자식 관계를 명확히 표현하며 양방향 참조 문제 해결
  • @JsonIdentityInfo: 객체를 처음에는 완전히 직렬화하고, 이후 참조 시에는 ID만 사용하여 순환 참조 방지

DTO 변환 방식:

  • 더 세밀한 제어 가능
  • 필요한 정보만 선택적으로 포함 가능
  • 순환 참조 문제를 근본적으로 해결
  • API 응답 구조를 엔티티 구조와 분리 가능

두 방식은 상황에 따라 함께 사용할 수도 있습니다. 간단한 경우에는 Jackson 어노테이션이 편리하고, 복잡한 데이터 변환이 필요한 경우에는 DTO 패턴이 더 적합합니다.