JPA 순환 참조 (Circular reference)

김도비·2024년 11월 20일
post-thumbnail

실무에서 JPA를 사용하던 중, A Entity와 C Entity를 M:N 관계로 엮어야 하는 일이 생겼다.

인프런으로 JPA 공부할 때 영한 센세가 M:N 관계는 중간 테이블 만들어서 분리하는 것이 좋다고 하셨는데, 실무에서 활용해 볼 기회가 되었다!


중간 역할을 해줄 B Entity를 생성했고 A, B, C를 아래와 같이 연결했다.

@Entity
@Data
public class A {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT COMMENT 'A 일련번호'")
    private Long aId;
    
    @OneToMany(mappedBy = "a", fetch = FetchType.LAZY)
    private Set<B> b = new HashSet<>();
}

@Entity
@Data
public class B {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT COMMENT 'B 일련번호'")
    private Long bId;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "a_id")
    private A a;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "c_id")
    private C c;
}

@Entity
@Data
public class C {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT COMMENT 'C 일련번호'")
    private Long cId;
    
    @OneToMany(mappedBy = "c", fetch = FetchType.LAZY)
    private Set<B> b = new HashSet<>();
}

영한 센세 말씀대로 중간 테이블도 만들고, 지연 로딩으로도 설정했으니 잘 작동하겠지 당연히!?


하지만 스프링 부트 실행하니 JPA 쿼리가 끝없이 콘솔에 찍히고 있었고
결국 StackOverFlowException과 마주치게 되었다.

로그를 보니 equals(), hashCode(), toString() 등이 보였고, Entity는 A, B, C 번갈아가며 연속으로 찍혀있었다.

이게 뭐람...


검색해보니 JPA에서 양방향으로 연결된 Entity를 그대로 조회하는 경우,
서로의 정보를 순환하면서 조회하다가 StackOverFlow가 발생
하게 된다고 한다.

이를 순환 참조(Circular reference)라고 한다.


순환 참조 (Circular reference)


순환 참조란, 참조하는 대상이 서로 물려 있어서 참조할 수 없게 되는 현상을 말한다.

Spring Boot는 REST API 구현 시, @RequestBody나 @ResponseBody가 붙은 객체를 가져와
Jackson의 ObjectMapper를 사용해 직렬화, 역직렬화 한다.

Jackson은 Entity의 getter를 호출하고,
직렬화를 이용해 JSON 형태로 객체 변환 후 Entity를 View로 전달한다.

이 getter를 호출하는 과정에서 순환 참조가 발생해 StackOverFlowException과 마주하게 된다.



StackOverFlow 해결 방법


@JsonIgnore

  • 객체가 Json 형태로 변환될 때 완전히 무시
  • 특정 필드나 getter/setter 메서드를 직렬화에서 제외시킬 때 사용
public class User {
	public int id;
    public String name;
    @JsonIgnore
    public List<Item> userItems;
}


{
	"id" : 1,
    "name" : kkk
}

@JsonManagedReference와 @JsonBackReference

@JsonManagedReference

  • 정상적으로 직렬화
  • @OneToMany와 같은 "자식" 관계에서 사용하며, 직렬화 시 자식 객체 포함

@JsonBackReference

  • 직렬화하지 않도록 막음
  • @ManyToOne과 같은 "부모" 관계에서 사용하며, 직렬화 시 부모 객체를 포함시키지 않고 참조만
@Entity
@Data
public class A {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT COMMENT 'A 일련번호'")
    private Long aId;
    
    @JsonManagedReference
    @OneToMany(mappedBy = "a", fetch = FetchType.LAZY)
    private Set<B> b = new HashSet<>();
}

@Entity
@Data
public class B {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT COMMENT 'B 일련번호'")
    private Long bId;
    
    @JsonBackReference
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "a_id")
    private A a;
    
    @JsonBackReference
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "c_id")
    private C c;
}

@Entity
@Data
public class C {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "BIGINT COMMENT 'C 일련번호'")
    private Long cId;
    
    @JsonManagedReference
    @OneToMany(mappedBy = "c", fetch = FetchType.LAZY)
    private Set<B> b = new HashSet<>();
}

@JsonIgnoreProperties

  • 필드나 메서드에 적용하는 @JsonIgnore와 달리, 클래스에 적용
  • 클래스의 여러 필드를 한번에 무시하고 싶을 때 사용
@Entity
@JsonIgnoreProperties({"children"})
public class Parent {
    @Id
    private Long id;
    
    @OneToMany(mappedBy = "parent")
    private List<Child> children;
}

@Entity
public class Child {
    @Id
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "parent_id")
    private Parent parent;
}

DTO 사용

  • Entity 자체를 return 하지 않고, DTO 객체를 만들어 필요한 데이터만 옮겨 담아 return하는 방법
  • 가장 안전하고 추천되는 방식

매핑 재설정

  • 양방향 매핑이 꼭 필요한지 다시 한 번 생각해보기
  • 만약 양쪽에서 접근할 필요가 없다면 단방향 매핑으로 변경

실무에서 DTO를 사용하지 않아
어쩔 수 없이 @JsonManagedReference와 @JsonBackReference를 적용했지만
가장 좋은 방법은 DTO로 return 하는 것이다!

profile
Java Backend

0개의 댓글