서로 다른 Bean 들이 서로 참조를 맞물리게 주입되면서 생기는 현상
(beanA에서 beanB를 참조하게 될 때, beanB에서도 beanA를 참조해야 하는 경우에 발생)
(순환 참조가 발생하는 상황)
순환참조는 맞물리는 DI(Dependency Injection)상황에서
스프링이 어느 스프링 Bean을 먼저 생성할지 결정하지 못하기 때문에 발생
따라서, 순환참조 문제도 DI를 하는 방법 3가지 상황(생성자 주입방식, Setter주입, 필드 주입방식)에서 발생할 수 있다.
통합 테스트 시 or 배포 이후에야 문제를 발견할 수도 있다.
개발할 당시에는 문제가 없었는데 통합 테스트를 진행하거나 배포 이후에 문제가 생긴다면, 의존하는 컴포넌트에 영향일 가능성이 높다.
의존되는 컴포넌트들이 무엇이고 이것들이 어떻게 달라질지 예상한다면 이런 상황을 방지하는 것이 가능하겠지만,
컴포넌트가 순환참조를 일으킨다면 예상하기 어렵다.
연쇄적인 문제 발생
컴포넌트 간의 명확한 경계가 사라지고, 연쇄적으로 변경에 의한 영향이 발생할 수 있다.
↓
이로 인하여, 개발과 유지보수 속도에 영향을 끼치고, 예상치 못한 문제점을 만들어 낼 가능성도 높다.
↓
이 후에 컴포넌트들을 분리해내도 어려워진다.
↓
컴포넌트를 분리해내기 어렵다면, 단기적으로 테스트하기가 어려워질 것이다.
↓
장기적으로 협업하기 어려워지고, 개발 아키텍처(DDD나 MSA와 같은)를 구성할 수 없게 된다.
@Component
public class BeanA {
private BeanB beanB;
public void BeanA(BeanB beanB){
this.beanB = beanB;
}
}
@Component
public class BeanB {
private BeanA beanA;
public void BeanB(BeanA beanA){
this.beanA = beanA;
}
}
애플리케이션을 구동하면,
스프링 컨테이너(IOC)는 BeanA 빈을 생성해야하므로, BeanB 을 주입하기 위해 BeanB 을 찾는다.
그러나, BeanB 을 생성하기 위해서는 BeanA가 필요하게되어 무한 반복(BeanA ⇌ BeanB )이 생기게 된다.
생성자 주입방식과는 '순환참조가 발생하는 시점' 이 다르다.
즉,
애플리케이션 구동 당시에는 필요한 의존성이 없을 경우에는 null 상태로 유지(= 순환참조문제 발생 X)
순환참조를 일으킬 수 있는 메서드를 호출하는 시점(실제로 사용하는 시점)에서 순환참조 문제가 발생
@Component
@Slf4j
public class BeanA {
private BeanB beanB;
@Autowired
public void setBeanB(BeanB beanB){
this.beanB = beanB;
}
public void run(){
beanB.run();
}
public void call(){
log.info("called BeanA");
}
}
@Component
@Slf4j
public class BeanB {
private BeanA beanA;
@Autowired
public void setBeanA(BeanA beanA){
this.beanA = beanA;
}
public void run(){
log.info("Called BeanB");
beanA.call();
}
}
생성자 주입방식과는 '순환참조가 발생하는 시점' 이 다르다.
즉,
애플리케이션 구동 당시에는 필요한 의존성이 없을 경우에는 null 상태로 유지(= 순환참조문제 발생 X)
순환참조를 일으킬 수 있는 메서드를 호출하는 시점(실제로 사용하는 시점)에서 순환참조 문제가 발생
@Component
@Slf4j
public class BeanA {
@Autowired
private BeanB beanB;
public void run(){
beanB.run();
}
public void call(){
log.info("called BeanA");
}
}
@Component
@Slf4j
public class BeanB {
@Autowired
private BeanA beanA;
public void run(){
log.info("Called BeanB");
beanA.call();
}
}
해당 어노테이션으로 순환을 끊도록 한다.
@Component
public class BeanA {
private BeanB beanB;
public void BeanA(BeanB beanB){
this.beanB = beanB;
}
}
@Component
public class BeanB {
private BeanA beanA;
public void BeanB(@Lazy BeanA beanA){
this.beanA = beanA;
}
}
주의!
문제점 1
Spring 에서는 @Lazy 사용이 초기화를 지연시킨다.
이 때문에, 애플리케이션은 스프링 빈이 잘못 구성되어 있는 문제를 발견하지 못하고 있다가
나중에 빈이 초기화되는 시점에서야 발견하게 된다.
문제점 2
해당 빈이 초기화가 되는 시점에 JVM 의 힙 메모리의 공간이 충분한지도 불분명하다.
이 때문에, 빈이 생성될 인스턴스가 저장될 메모리 공간이 없을 수 있다.
@Lazy 의 문제점이 명확하므로,
근본적으로 순환참조가 되지 않도록 설계를 해야한다.
양방향 맵핑이 꼭 필요한지 다시 한번 고려해보고,
양쪽에서 접근할 필요가 없다면 단방향 맵핑을 하면 자연스레 순환참조가 해결된다.
주 원인은 양방향 매핑이기도 하지만, 더 정확하게는 entity 자체를 response로 리턴한 데에 있다.
따라서, entity 자체를 return하지 말고
dto 객체를 만들어, 필요한 데이터만 옮겨 담아 client로 리턴하면 된다.
인터페이스를 통해서, 순환 참조를 발생시키는 해당 의존의 방향을 역전시킨다.
(DIP 예시)
Entities가 Authorizer에게 의존하게 되어 순환참조가 발생할 경우
직접적으로 의존하지 않고, User가 필요로 하는 기능을 인터페이스로 만들고, 이를 Authorizer에서 구현하도록 해서
의존 방향을 역전시킨다.
인터페이스를 통해서, 순환 참조를 발생시키는 해당 의존의 방향을 우회한다.
A ↔ B 간에 상호 참조가 발생했을 때, A → C ← B와 같은 구조를 만들어 낼 수도 있다.
생성 패턴을 사용하여, C → A, C → B → A 와 같은 방향을 갖게 할 수도 있다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_category_id", nullable = false)
@JsonIgnore
private ProductCategory productCategory;
@OneToMany(mappedBy = "product", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JsonManagedReference
private Set<ProductUploadFile> productUploadFiles = new LinkedHashSet<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_category_id", nullable = false)
@JsonBackReference
private ProductCategory productCategory;
두 어노테이션은 본질적으로 순환참조를 방어한다.
@JsonManagedReference
@JsonBackReference
순환참조에 대한 문제를 해결하고자 한다면
@JsonManagedReference, @JsonBackReference 를 추가해주자.
참고: 스프링 순환 참조(spring circular reference)
참고: 순환참조는 뭐가 문제일까?
참고: [JPA] @JsonIgnore, @JsonManagedReference, @JsonBackReference
참고: [JPA] JSON 직렬화 순환 참조 해결하기
참고: JPA에서 순환참조를 해결하는 방법 | @JsonIgnore, @JsonManagedReference, @JsonBackReference