사실 객체의 참조과 서로 있는 객체라면 발생하지, 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 패턴이 더 적합합니다.