Spring Boot 애플리케이션 실행 중 아래와 같은 오류 로그가 출력되었다.
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.
처음엔 단순한 서비스 로직이었다. A 서비스에서 B 서비스를 호출하고, B 서비스에서 다시 A 서비스를 참조했다.
뭐, 흔한 의존 관계라고 생각했는데... 실행하자마자 스택 오버플로우!
"이거 뭐야? 디버거 돌려볼까?"
아뿔싸, 로그는 끝없이 찍히고 있었다.
A → B 호출 → B에서 A 호출 → A에서 B 호출 → B에서 A 호출 → (∞ 반복)
순환 참조. 그때는 몰랐다. 이게 그렇게 흔한 문제인 줄은.
검색을 해보니 @Lazy
를 쓰면 해결될 수도 있다고 했다.
"오! 간단하네!" 라고 생각하고 적용했는데...
"이게 뭐지? 해결은커녕, 서비스 실행이 더 꼬이네?"
그제서야 깨달았다.
순환 참조는 단순히 '지연 로딩'한다고 해결되는 게 아니었다.
다시 돌아가서 천천히 상황을 분석해보자。。。。。
MSA 프로젝트를 진행하면서 서비스 간 의존성 설계를 소홀히 한 문제가 발생했다.
게시글 삭제 시, 해당 게시글에 달린 댓글도 함께 삭제해야 하는 기능을 구현하는 과정에서 문제가 드러났다.
InfraService에서 다른 InfraService를 직접 호출하는 구조로 인해 순환 참조(Circular Dependency)가 발생했다.
처음에는 @Lazy
나 인터페이스를 활용한 우회적인 방법을 고려했지만, 프로젝트 마감이 얼마 남지 않은 상황에서 근본적인 해결책이 필요했다.
이전 학습할때 이벤트 기반 설계(Event-Driven Architecture, EDA)를 활용한 경험이 떠올랐고, 이를 적용하면 순환 참조 문제를 해결할 수 있을 것이라 판단했다.
결국 ApplicationEventPublisher
를 사용하여 게시글 삭제 이벤트(SubsBoardDeletedEvent
)를 발행하고, 리스너에서 댓글 삭제 로직을 처리하는 방식으로 해결했다. 이를 통해 서비스 간 직접적인 의존성을 줄이고, 더 유연한 구조로 개선할 수 있었다.
private final ApplicationEventPublisher eventPublisher;
...
eventPublisher.publishEvent(new SubsBoardDeletedEvent(subsBoard.getSubsCode()));
SubsBoardDeletedEvent
가 발행되면, @EventListener
가 붙은 메서드에서 댓글 삭제 로직을 실행하도록 구성했다.이벤트를 사용하면 서비스 A
가 서비스 B
를 직접 호출하는 대신 이벤트를 발행하고, 서비스 B
는 이를 감지하여 필요한 로직을 수행할 수 있다. 이를 통해 불필요한 의존성을 제거할 수 있다.
Spring의 ApplicationEventPublisher
를 사용하여 SubsBoardDeletedEvent
를 발행하면, 이벤트 리스너(@EventListener)가 이를 감지하여 필요한 로직을 수행한다.
이 방식의 핵심은 서비스 A가 서비스 B를 직접 호출하는 것이 아니라 이벤트를 통해 비동기적으로 의사소통한다는 점이다. 이를 통해 순환 참조 문제를 방지하면서도 서비스 간의 결합도를 낮출 수 있다.
예제 코드
// SubsBoard 삭제 시 이벤트 발행
public void deleteSubsBoard(Long subsBoardId) {
subsBoardRepository.deleteById(subsBoardId);
applicationEventPublisher.publishEvent(new SubsBoardDeletedEvent(subsBoardId));
}
// 이벤트 리스너에서 처리
@EventListener
public void handleSubsBoardDeleted(SubsBoardDeletedEvent event) {
commentService.removeCommentsBySubsBoardId(event.getSubsBoardId());
}
이처럼 이벤트를 활용하면 서비스 간의 의존성을 줄이면서도 필요한 동작을 수행할 수 있다.
추가적으로, 이벤트 발행과 리스너가 올바르게 동작하는지 테스트 코드를 작성하여 정확히 이벤트가 발행되고 처리되는지 검증하는 과정도 중요하다고 생각이 들었다.
@Lazy
로 순환 참조를 해결하려 했지만 실패한 이유해결책을 찾던 중 @Lazy
를 고려했지만, 이는 단순히 객체 초기화를 지연할 뿐 순환 참조 문제를 근본적으로 해결하지 못했다.
🤬 처음에는 @Lazy
를 사용해 호출을 지연시키면 해결될 거라 생각했지만, 이 어노테이션을 무분별하게 사용하면 오히려 더 꼬이는 느낌이 들었다. 그리고 애초에 내가 겪은 순환 참조 문제는 @Lazy
로 해결할 수 있는 종류의 문제가 아니었다.
@Lazy
란?Spring에서 빈(Bean)의 초기화 시점을 늦추는 데 사용하는 어노테이션이다.
필요할 때까지 객체를 생성하지 않음으로써 불필요한 리소스 사용을 줄이고, 애플리케이션 성능을 최적화하는 데 도움이 될 수 있다.
하지만, 순환 참조를 해결하려고 @Lazy
를 사용하는 것은 바람직하지 않다. 오히려 새로운 문제가 발생할 가능성이 높다.
@Lazy
를 사용했을 때 발생한 문제점@Lazy
는 단순히 객체 생성 시점을 늦출 뿐, 근본적으로 의존성 문제를 해결하지 않는다. @Lazy
를 사용하면 해당 객체는 실제로 필요할 때까지 초기화되지 않음. NullPointerException
이 발생할 수도 있다. @Lazy
때문에 발생하는지, 다른 원인이 있는지 파악하기 어려워진다. @Lazy
로 인해 객체가 실제로 필요할 때까지 생성되지 않으므로, 특정 시점에서 초기화가 한꺼번에 이루어지면서 성능 저하가 발생할 가능성이 있다. @Lazy
는 문제를 근본적으로 해결하는 것이 아니라, 단순히 덮어두는 임시 방편에 불과하다.
@Lazy
는 순환 참조 해결책이 아니다!
순환 참조가 발생하는 자체가 "설계 문제"일 가능성이 높다.
따라서, 아래와 같은 방법으로 구조를 개선하는 것이 더 바람직하다.
ServiceA
가 ServiceB
의 특정 메서드만 필요하다면, ServiceB
의 일부 기능을 인터페이스로 추출하여 ServiceA
에 주입하면 된다. ✨ 결론 :
@Lazy
는 순환 참조 문제를 해결하는 도구가 아니다.
단순히 "객체 생성 시점을 늦추는 역할"을 할 뿐, 서비스 간 강한 결합 자체를 해결하지는 않는다.
🚨 따라서, @Lazy
를 무작정 적용하는 것은 바람직하지 않으며, 의존성 구조를 개선하는 것이 근본적인 해결책이다.
🔥 순환 참조가 발생했다면?
👉 @Lazy
로 해결하려 하지 말고, 서비스 간 결합도를 낮추는 방향으로 설계를 다시 점검하는 것이 최선이라고 생각한다。
찾아 보다가 어떤 글을 발견했다 . "가장 확실한 방법은 DTO로 바꿔서 순환구조에 빠지지 않도록 바꿔서 response로 반환하는 방법이다." 이 문장을 보았는데 맞긴하다. 근데 프론트에서 보여지는 부분이라면 그렇게 하는게 맞지만, 그저 기능 로직중 하나라면 숨어있는 로직 이다 보니까 이벤트 리스너를 발행하는 방법으로 접근하였다.
N:1 연관관계를 같는 엔티티에서 양방향 참조 하려다가 발생하였다고 하시는데 나는 어떻게 보면 비슷한 경우 이지만 구체적인 부분은 의존성 주입 시점에 순환 참조가 일어나는 경우라고 볼 수 있다.
이분은 N:1 연관관계에서 두 엔티티가 양방향으로 참조할 때의 해결책인
@JsonManagedReference와 @JsonBackReference를 사용하여 직렬화 문제를 해결하거나, 연관관계를 비즈니스 로직에서 필요에 따라 관리를 하였다.
하지만 나는 의존성 주입 시점의 순환 참조이기 때문에
서비스의 책임을 재조정하거나, 인터페이스를 통해 의존성을 주입하는 방식이나 @Lazy 어노테이션을 사용하여 순환 참조 문제를 해결할 수 있다. 하지만 신중하게 사용하기.
private final ApplicationEventPublisher eventPublisher;
이걸 사용해서 애플리케이션의 특정 이벤트를 발행하였다.
이벤트는 보통 비즈니스 로직의 상태 변경, 사용자 행동, 시스템의 중요한 변화 등을 나타낸다.
예를 들어, 데이터베이스에 새로운 레코드가 추가되었을 때 이벤트를 발행할 수 있다.
대체 적으로 알림 같은 경우 말하는것이다.
이제 리스너 등록 및 처리단계
이다.
이벤트를 발행한 후, 해당 이벤트를 처리할 리스너를 등록해야한다.
리스너는 @EventListener 어노테이션이 붙은 메서드 또는ApplicationListener 인터페이스를 구현한 클래스에서 정의할 수 있다.
이들 리스너는 이벤트가 발행될 때 자동으로 호출되어, 이벤트에 대한 처리를 수행한다.
게시글삭제시 댓글 삭제 할 수 있ㄷ는 중간객체인 SubsBoardDeletedEvent를 생성하여 호출해주고,
Component 어노테이션을 사용함으로써 Bean으로 인식 시키고, @EventListener 어노테이션이 붙은 다른 도메인을 참조해야하는 Infra서비스를 호출 하였다.
JPA 조회를 통해 관련 게시글의 댓글을 가져오고, 삭제 메소드를 호출하는 방식으로 구현했다.
이 과정에서 댓글 삭제 메소드에 댓글 DTO를 넘겨줘야 했기 때문에 해당 방식을 선택했다.
이렇게 함으로써 게시판 서비스 → 이벤트 객체 → 이벤트 리스너 → 인프라 서비스 → 댓글 서비스 순으로 호출되면서 순환 참조를 방지할 수 있었다.
원래는 인프라 서비스를 만들어 모든 기능을 통합하려 했지만, 예상치 못한 순환 참조 문제가 발생했다.
로그를 분석한 결과, 의존성이 다음과 같이 꼬이는 문제가 있었다.
1️⃣ 초기 설계:
2️⃣ 문제 발생:
이러한 문제를 해결하기 위해 이벤트 기반 설계를 적용했다.
이벤트 객체를 통해 서비스 간 직접적인 의존성을 줄이고, 더 유연한 구조를 만들었다.
➡ 결과적으로, 핸들러를 활용한 이벤트 방식으로 순환 참조를 우회할 수 있었으며, 인프라 서비스 간의 강한 결합도를 낮출 수 있었다.
1️⃣ 순환 참조 해결 → 서비스 간 직접 호출을 없애 무한 루프 방지
2️⃣ 결합도 감소 → 이벤트 기반으로 독립성 강화, 변경 영향 최소화
3️⃣ 확장성 및 유지보수성 향상 → 핸들러만 수정하면 기능 추가 가능
4️⃣ 비동기 처리로 성능 최적화 → 불필요한 대기 시간 감소, 처리 속도 향상
이걸 미리 알았더라면 더 나은 코드로 접근할 수 있었을까ㅣ..
이벤트 핸들러로 순환 참조를 해결하는 것은 근본적인 해결책이 아니다.
단순히 구조를 바꾸는 것에 불과하며, 결국 데이터가 필요할 때는 다른 서비스를 참조해야 하는 문제가 남는다.
즉, 핸들러를 사용한다고 해서 서비스 간 강한 의존성이 사라지는 것은 아니다.
✔ 결국 데이터가 필요하면 다른 서비스를 조회해야 한다.
✔ 핸들러는 직접 호출을 피하는 방식일 뿐, 근본적인 결합도를 없애는 것은 아니다.
1️⃣ 서비스의 역할과 책임을 명확히 분리해야 한다.
→ 순환 참조가 발생하는 것은 서비스 간 경계가 명확하지 않거나, 한 서비스가 너무 많은 역할을 하고 있다는 신호일 가능성이 크다.
2️⃣ 도메인 모델을 재설계해야 한다.
→ 서비스 간 데이터 공유가 필요하다면, 데이터를 복제하여 관리하거나, 조회 전용 서비스로 분리하는 방식을 고려할 수 있다.
3️⃣ 이벤트를 사용하더라도 데이터 동기화 방식을 고려해야 한다.
→ 단순히 @EventListener
를 사용하여 해결하는 것이 아니라, 데이터 동기화 또는 캐싱 전략을 적용하여 서비스 간 결합도를 줄이는 방향으로 접근해야 한다.
핸들러를 사용한다고 해서 순환 참조가 사라지는 것은 아니다.
핸들러는 단순히 직접적인 호출을 우회하는 방법일 뿐, 근본적인 해결책이 되지는 않는다.
서비스 경계를 명확히 하고 의존성을 재설계하는 것이 더 중요하며,
이벤트 기반 접근 방식도 데이터 동기화와 결합도를 고려하지 않으면 한계가 있다.
이번 문제를 해결하면서 이벤트 기반 설계의 필요성을 더욱 깊이 이해할 수 있었다.
앞으로는 설계 단계에서부터 의존성 관계를 신중하게 고려하고,
만약 같은 문제가 다시 발생한다면 퍼사드 패턴이나 다른 설계적 접근을 먼저 검토해볼 것이다.
---> 근데 진짜 터졌다。。또 좀만 더 고민해볼걸。。 2편에서 걔속。。