순환 참조, 한 번쯤은 다 겪잖아요? (그렇죠..?) 1편.. 😭😭

나나's Brain·2025년 3월 4일
0

개념Study

목록 보기
12/21
post-thumbnail

🔥 2024년 9월 15일, 생애 첫 부끄러운 순환 참조 문제 발생

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를 사용했을 때 발생한 문제점

1️⃣ 순환 참조 문제를 해결하지 못함

  • @Lazy는 단순히 객체 생성 시점을 늦출 뿐, 근본적으로 의존성 문제를 해결하지 않는다.
  • 즉, "A가 B를 참조하고, B가 A를 참조하는" 문제 자체는 여전히 남아있다.

2️⃣ 의존성 주입 시점이 예측 불가능해짐

  • @Lazy를 사용하면 해당 객체는 실제로 필요할 때까지 초기화되지 않음.
  • 이로 인해 의존성 주입이 예기치 않은 시점에 이루어질 수 있으며, 예상치 못한 NullPointerException이 발생할 수도 있다.

3️⃣ 디버깅이 어려워짐

  • 언제, 어디서 객체가 생성되는지 명확하지 않아 디버깅이 복잡해진다.
  • 순환 참조 문제가 @Lazy 때문에 발생하는지, 다른 원인이 있는지 파악하기 어려워진다.

4️⃣ 예상치 못한 지연과 성능 저하 발생 가능

  • @Lazy로 인해 객체가 실제로 필요할 때까지 생성되지 않으므로, 특정 시점에서 초기화가 한꺼번에 이루어지면서 성능 저하가 발생할 가능성이 있다.

5️⃣ 설계상의 문제를 감춤

  • 순환 참조는 근본적으로 "서로 강하게 의존하는 두 객체가 존재한다"는 것을 의미함.
  • @Lazy는 문제를 근본적으로 해결하는 것이 아니라, 단순히 덮어두는 임시 방편에 불과하다.
  • 장기적으로 유지보수성과 확장성을 저하시킬 수 있음.

🚀 그러면 어떻게 해결해야 할까?

@Lazy는 순환 참조 해결책이 아니다!
순환 참조가 발생하는 자체가 "설계 문제"일 가능성이 높다.

따라서, 아래와 같은 방법으로 구조를 개선하는 것이 더 바람직하다.

✅ 1. 의존성 방향 재설계 (서비스 결합도 낮추기)

  • A와 B가 서로 직접 참조하는 방식이 아니라, 의존성 흐름을 단방향으로 정리하는 것이 가장 좋은 해결책이다.
  • 예를 들어, A가 B를 참조해야 한다면, B는 A를 직접 참조하지 않도록 분리한다.

✅ 2. 인터페이스 분리 (DI 방식 변경)

  • 인터페이스를 도입해 직접적인 의존성을 줄이는 방법도 고려할 수 있다.
  • 예를 들어, ServiceAServiceB의 특정 메서드만 필요하다면, ServiceB의 일부 기능을 인터페이스로 추출하여 ServiceA에 주입하면 된다.

✅ 3. 이벤트 기반 아키텍처 활용

  • A와 B가 서로 직접 호출하는 것이 아니라, 이벤트를 발행하고 구독하는 방식(Event Listener, @Async 등)으로 의존성을 약화할 수 있다.

✅ 4. 퍼사드(Facade) 패턴 적용

  • A와 B가 서로 직접 의존하는 대신, 중간에 퍼사드(Facade) 역할을 하는 서비스를 만들어 간접적으로 의존하도록 만들 수 있다.
  • 이렇게 하면 서로 직접 참조하지 않으면서도 원하는 기능을 사용할 수 있음.

✨ 결론 : @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편에서 걔속。。

profile
"로컬에선 문제없었는데…?"

0개의 댓글