[Spring] 스프링 순환참조
스프링의 순환참조 상황을 알아보자
📌 서론
스프링으로 개발을 하다 보면 정말 가끔씩 발생하는 오류가 있다. 바로 circular references (순환참조)다.
스프링 빈을 등록하면서 2가지의 클래스가 서로를 의존하면 이러한 문제가 발생한다.
서버가 실행되면 스프링 컨테이너가 빈을 등록하는 과정에서 의존성 주입을 하는데 이 과정에서 서로 의존하는 두 빈이 서로를 기다리며 무한루프에 빠지게 되는 것이다.
순환참조라는 이름만 봐도 순환해서 참조한다는 의미이므로 생각해 보면 간단하게 한쪽의 참조만 끊어주면 해결될 것으로 보인다. 그러나 가끔은 서로의 로직을 사용해야 하는 경우가 있을 수도 있다. 이때는 어떻게 문제를 해결해야 할까?
이런 상황에 문제를 해결하는 여러 가지 방법이 존재한다.
1. @Lazy 어노테이션을 통해 빈 등록을 지연로딩 시켜서 순환참조를 해결한다.
2. 응용 서비스 클래스를 만들어서 제3자의 조율자 역할을 하도록 한다.
3. Event기반 아키텍처를 적용해서 이벤트 리스너에서 메서드를 호출하도록 한다.
지금부터 이 방법들을 대해서 간단하게 소개하겠다.
1. 순환참조 문제 상황을 알아보자
순환 참조 문제란?
- 순환 참조 문제는 두 개 이상의 빈(Bean)이 서로를 참조할 때 발생하는 문제다. 예를 들어, ServiceA가 ServiceB를 참조하고 ServiceB가 다시 ServiceA를 참조하는 경우가 있다. 이렇게 되면 Spring이 빈을 생성하는 과정에서 두 빈이 서로를 기다리며 무한 루프에 빠질 수 있다.
예시로 보는 순환 참조 문제
- 아래의 예시에서 ServiceA와 ServiceB는 서로를 참조하고 있다. Spring이 ServiceA와 ServiceB를 생성하려고 하면 순환 참조 문제가 발생한다.
@RequiredArgsConstructor
@Service
public class ServiceA {
private final ServiceB serviceB;
public void methodA() {
serviceB.methodB();
}
}
@RequiredArgsConstructor
@Service
public class ServiceB {
private final ServiceA serviceA;
public void methodB() {
serviceA.methodA();
}
}
서버 실행하기
- 서버를 실행해 보면 아래와 같이 순환참조가 발생하고 있다는 로그가 나오면서 실행조차 안된다.
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| serviceA defined in file [/java/main/com/example/circle/service/test/ServiceA.class]
↑ ↓
| serviceB defined in file [/java/main/com/example/circle/service/test/ServiceB.class]
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
혹시 인터페이스를 사용해서 의존성을 주입하면 해결될까?
- 인터페이스를 사용하면 각 서비스는 다른 서비스의 구체적인 구현체가 아닌 인터페이스에 의존하게 되어 직접적인 클래스 간의 의존성을 피할 수 있을 수도 있지 않을까? 이런 궁금증이 생겨서 직접 시도해 봤다.
인터페이스 정의
- 먼저, ServiceAInterface와 ServiceBInterface라는 두 개의 인터페이스를 정의한다.
public interface ServiceAInterface {
void methodA();
}
public interface ServiceBInterface {
void methodB();
}
서비스 구현
- 이제 ServiceA와 ServiceB가 각자의 인터페이스를 구현한다.
- 코드를 보면 서로 메서드에서 다른 서비스의 메서드를 호출하고 있다.
@RequiredArgsConstructor
@Service
public class ServiceA implements ServiceAInterface {
private final ServiceBInterface serviceB;
@Override
public void methodA() {
serviceB.methodB();
}
}
@RequiredArgsConstructor
@Service
public class ServiceB implements ServiceBInterface {
private final ServiceAInterface serviceA;
@Override
public void methodB() {
serviceA.methodA();
}
}
의존성 주입
- 이제 Spring은 각 서비스의 인터페이스를 통해 의존성을 주입하게 된다. 이렇게 하면 Spring이 빈을 생성할 때 실제 구현체가 아닌 인터페이스를 통해 주입받게 될 것이다.
서버 실행하기
- 이전과 동일하게 순환참조 로그가 발생한다.
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| serviceA defined in file [/java/main/com/example/circle/service/test/ServiceA.class]
↑ ↓
| serviceB defined in file [/java/main/com/example/circle/service/test/ServiceB.class]
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.
2. 그럼 단일 참조로 해결하면 되는 것 아닌가?
한쪽에서만 다른 쪽을 바라보는 경우
- 한 서비스만 다른 서비스를 참조하고, 그 반대는 참조하지 않는 경우에는 순환 참조 문제가 발생하지 않는다. 순환 참조 문제는 두 개 이상의 빈이 서로를 참조할 때 발생하기 때문에, 한쪽에서만 참조하는 경우에는 순환 참조 문제가 없다. 예시를 통해 알아보자.
인터페이스 정의
public interface ServiceAInterface {
void methodA();
}
public interface ServiceBInterface {
void methodB();
}
서비스 구현
- 여기서 ServiceA만 ServiceB를 참조하고, ServiceB는 ServiceA를 참조하지 않는 경우를 보자.
- 주석 처리로 기존에 적용된 의존성 주입 코드를 제거했다.
@RequiredArgsConstructor
@Service
public class ServiceA implements ServiceAInterface {
private final ServiceBInterface serviceB;
@Override
public void methodA() {
serviceB.methodB();
}
}
@RequiredArgsConstructor
@Service
public class ServiceB implements ServiceBInterface {
// private final ServiceAInterface serviceA;
@Override
public void methodB() {
// serviceA.methodA();
}
}
결론
- 한쪽에서만 다른 쪽을 참조하는 경우에는 순환 참조 문제가 발생하지 않는다. 이는 구조적으로 단순한 의존성 관계를 가지므로, Spring이 빈을 생성하고 주입하는 과정에서 문제가 발생하지 않는다. 따라서, 가능하다면 서비스 간의 의존성을 한쪽 방향으로 유지하는 것이 순환 참조 문제를 피하는 좋은 방법 중 하나다.
근데 의존성을 단방향으로 유지하기 어렵다면??
3. 인터페이스와 @Lazy를 사용하여 순환참조 해결하기
@Lazy 어노테이션 사용하기
- 순환 참조 문제를 해결하기 위해 @Lazy 어노테이션을 사용할 때는 한쪽 서비스의 생성자에만 @Lazy 어노테이션을 걸어주면 된다. 이는 @Lazy 어노테이션을 사용한 빈이 실제로 필요할 때까지 초기화를 미루기 때문에, Spring이 빈을 생성할 때 발생하는 순환 참조 문제를 해결할 수 있다.
@Service
public class ServiceA implements ServiceAInterface {
private final ServiceBInterface serviceB;
@Autowired
public ServiceA(@Lazy ServiceBInterface serviceB) {
this.serviceB = serviceB;
}
@Override
public void methodA() {
serviceB.methodB();
}
}
@Service
public class ServiceB implements ServiceBInterface {
private final ServiceAInterface serviceA;
// 여기서는 @Lazy를 적지 않았다. (한쪽에서만 적어주면 된다.)
@Autowired
public ServiceB(ServiceAInterface serviceA) {
this.serviceA = serviceA;
}
@Override
public void methodB() {
serviceA.methodA();
}
}
동작 원리
- 빈 생성 과정: Spring이 ServiceA를 생성하려고 할 때, ServiceBInterface를 주입받는다. 이때 @Lazy 어노테이션 덕분에 ServiceB는 실제로 필요할 때까지 초기화되지 않는다.
- 지연 로딩(@Lazy): ServiceA의 methodA 메서드를 호출할 때 serviceB.methodB()가 호출되면, 그제야 ServiceB가 초기화된다.
- 순환 참조 문제 해결: 이 방식으로 순환 참조 문제를 해결할 수 있다.
단, @Lazy 어노테이션을 남용하면 예상치 못한 지연 초기화로 인해 성능 문제가 발생할 수 있으므로, 필요한 경우에만 사용하는 것이 좋다.
4. 응용 서비스 클래스로 순환참조 해결하기
응용 서비스(Application Service)를 사용하기
- 이 방식은 서비스 간의 직접적인 의존성을 피하고, 비즈니스 로직을 조율하는 방법이다. 이는 순환 참조 문제를 해결하고, 비즈니스 로직을 중앙에서 관리할 수 있게 해 준다.
- 응용 서비스는 여러 서비스를 조율하고 비즈니스 로직을 한 곳에서 관리하는 역할을 한다.
@RequiredArgsConstructor
@Service
public class ApplicationService {
private final ServiceAInterface serviceA;
private final ServiceBInterface serviceB;
public void serviceLogic() {
serviceA.methodA();
serviceB.methodB();
}
}
동작 원리
- 중앙 집중화: 응용 서비스는 비즈니스 로직을 중앙에서 관리한다. ServiceA와 ServiceB가 서로를 직접 참조하지 않고 응용 서비스를 통해 간접적으로 상호작용한다.
- 순환 참조 방지: 응용 서비스가 두 서비스를 조율하기 때문에 순환 참조 문제가 발생하지 않는다.
- 유지보수성 향상: 비즈니스 로직이 응용 서비스에 집중되어 있으므로, 로직 변경 시 한 곳에서 관리할 수 있어 유지보수성이 향상된다.
적합한 상황
- 비즈니스 로직이 명확하게 분리될 수 있는 경우
- 여러 서비스가 복잡한 비즈니스 로직을 수행할 때, 응용 서비스가 이들을 조율할 수 있다.
- 예: 주문 처리, 결제 처리, 사용자 인증 등 여러 서비스를 조율하는 작업이 필요한 경우.
- 중앙 집중화된 비즈니스 로직 관리
- 비즈니스 로직을 한 곳에서 관리하고, 서비스 간의 의존성을 조율할 필요가 있는 경우.
- 예: 특정 비즈니스 프로세스가 여러 서비스에 걸쳐 있는 경우.
장점
- 중앙 집중화: 비즈니스 로직을 응용 서비스에서 중앙 집중적으로 관리할 수 있다.
- 명확한 책임 분리: 응용 서비스는 조율 역할을 하고, 각 서비스는 자신의 책임을 다한다.
- 유지보수 용이: 비즈니스 로직이 한 곳에 집중되어 있어, 변경 시 한 곳에서 관리할 수 있다.
단점
- 단일 책임 원칙 위반 가능성: 응용 서비스가 너무 많은 역할을 맡게 되면 단일 책임 원칙을 위반할 수 있다.
- 복잡성 증가: 모든 로직이 응용 서비스로 집중되면, 응용 서비스 자체가 복잡해질 수 있다.
5. 이벤트 기반 아키텍처(EDA)로 순환참조 해결하기
이벤트 기반 아키텍처(EDA)
- 이벤트 기반 아키텍처는 서비스 간의 직접적인 호출을 피하고, 이벤트를 통해 비즈니스 로직을 처리하여 서비스 간의 결합도를 낮춘다. 이를 통해 순환 참조 문제를 해결할 수 있다. 이 방법은 서비스가 서로의 메서드를 직접 호출하지 않고, 이벤트를 발행하고 리스너를 통해 이벤트를 처리하는 방식으로 동작한다.
적합한 상황
- 서비스 간의 느슨한 결합이 필요한 경우
- 목적: 서비스 간의 직접적인 의존성을 최소화하고, 순환 참조 문제를 해결.
- 예: 마이크로서비스 아키텍처에서 서비스 간의 통신을 이벤트로 처리하여 서로 직접 참조하지 않게 함.
- 확장성과 유연성이 중요한 경우
- 목적: 시스템이 확장 가능하고 유연하게 변화할 수 있어야 하는 경우, 이벤트 리스너만 수정하여 새로운 기능을 추가하거나 기존 기능을 변경할 수 있음.
- 예: 새로운 기능을 추가하거나, 기존 기능을 변경할 때 이벤트 리스너만 수정하면 되는 경우.
장점
- 순환 참조 문제 해결: 서비스 간의 직접적인 호출을 피하고, 이벤트를 통해 간접적으로 상호작용하여 순환 참조 문제를 해결할 수 있다.
- 느슨한 결합: 서비스 간의 결합도가 낮아지고, 독립적으로 변경할 수 있다.
- 확장성: 새로운 이벤트와 리스너를 추가하여 기능을 확장하기 용이하다.
- 유연성: 비즈니스 로직이 이벤트 리스너에 분산되어 있어 유연하게 변경할 수 있다.
단점
- 복잡성 증가: 이벤트와 리스너가 많아지면 시스템의 복잡도가 증가할 수 있다.
- 디버깅 어려움: 이벤트 기반 아키텍처는 추적이 어려울 수 있다.
이벤트 정의
- 이벤트는 필요한 정보를 담고 있는 객체로 정의한다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MethodACalledEvent {
private String source;
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MethodBCalledEvent {
private String source;
}
이벤트 리스너 정의
- 이벤트 리스너는 특정 이벤트가 발행되었을 때 실행할 로직을 정의한다.
@RequiredArgsConstructor
@Component
public class EventListenerBean {
private final ServiceAInterface serviceA;
private final ServiceBInterface serviceB;
@EventListener
public void handleMethodACalledEvent(MethodACalledEvent event) {
// ServiceA가 호출된 이벤트를 처리하는 로직
System.out.println("Handled MethodA Event: " + event.getSource());
// 여기서 서비스 B의 메서드를 호출하여도 새로운 이벤트를 발행하지 않도록 설계
serviceB.performTask();
}
@EventListener
public void handleMethodBCalledEvent(MethodBCalledEvent event) {
// ServiceB가 호출된 이벤트를 처리하는 로직
System.out.println("Handled MethodB Event: " + event.getSource());
// 여기서 서비스 A의 메서드를 호출하여도 새로운 이벤트를 발행하지 않도록 설계
serviceA.performTask();
}
}
인터페이스에 performTask() 로직 추가
- 기존의 서비스 로직과 다른 점은 기존에는 다른 클래스의 메서드를 내부에서 바로 접근해서 호출했었는데 그 로직을 따로 performTask()로 뽑아내서 메서드로 선언하고 이벤트 리스너에서 이 메서드를 호출해서 처리하도록 한다. (이벤트 리스너가 기존 로직을 계속 호출하면 이벤트가 무한으로 발생하는 것을 방지하기 위함)
public interface ServiceAInterface {
void methodA();
void performTask();
}
public interface ServiceBInterface {
void methodB();
void performTask();
}
서비스 로직 작성
- 서비스는 다른 서비스를 직접 호출하는 대신, 이벤트를 발행한다.
@RequiredArgsConstructor
@Service
public class ServiceA implements ServiceAInterface {
private final ApplicationEventPublisher eventPublisher;
@Override
public void methodA() {
// ServiceA 로직
eventPublisher.publishEvent(new MethodACalledEvent("ServiceA triggered eventA"));
}
@Override
public void performTask() {
// ServiceA의 추가 작업 로직
System.out.println("ServiceA performing task...");
}
}
@RequiredArgsConstructor
@Service
public class ServiceB implements ServiceBInterface {
private final ApplicationEventPublisher eventPublisher;
@Override
public void methodB() {
// ServiceB 로직
eventPublisher.publishEvent(new MethodBCalledEvent("ServiceB triggered eventB"));
}
@Override
public void performTask() {
// ServiceB의 추가 작업 로직
System.out.println("ServiceB performing task...");
}
}
동작 원리 및 설계 원칙
- 이벤트 발행: ServiceA와 ServiceB는 각각의 메서드에서 이벤트를 발행한다.
- 이벤트 리스너: EventListenerBean은 발행된 이벤트를 리스닝하여, 이벤트가 발생할 때마다 정의된 로직을 실행한다.
- 순환 방지: 이벤트 리스너에서 다른 서비스의 메서드를 호출할 때, 해당 메서드가 새로운 이벤트를 발행하지 않도록 설계한다. 이를 통해 무한 이벤트 순환을 방지한다.
결론
- 이벤트 기반 아키텍처를 통해 서비스 간의 직접적인 호출을 피하고, 이벤트를 통해 간접적으로 상호작용함으로써 순환 참조 문제를 해결할 수 있다. 이벤트 리스너에서 다른 서비스의 메서드를 호출할 때, 새로운 이벤트를 발행하지 않도록 설계하여 무한 이벤트 순환을 방지하는 것이 중요하다.