ServiceA → ServiceB → ServiceC → ServiceA처럼 의존이 고리를 이루는 상태.
무엇이 나쁜가
- 어디서부터 읽어야 할지 알 수 없다
- 단위 테스트를 만들 수 없다 (하나를 만들려면 전부가 필요하다)
- 변경 영향이 고리 전체로 퍼진다
- 모듈 분리가 불가능해진다
주입 방식이 결과를 가른다
-
생성자 주입 — 순환이 있으면 기동 자체가 실패한다
- 설계 결함이 배포 전에 드러난다
-
필드 주입 — 리플렉션으로 나중에 채우므로 순환이 있어도 통과한다
- 결함이 조용히 살아남는다
필드 주입이 위험한 진짜 이유가 이것이다. 편해서가 아니라 문제를 감추기 때문이다.
# Spring Boot 2.6부터 순환 참조는 기본 금지다
# 굳이 켜려면 (권장하지 않는다)
spring.main.allow-circular-references=true
끊는 방법
-
① 고차 함수로 낮추기
- 객체 의존 대신 필요한 '함수' 만 파라미터로 받는다
- Function<Integer,Integer> 는 ServiceB 전체보다 훨씬 약한 결합
-
② 의존을 상위로 올리기
- A 와 B 를 아는 상위 컴포넌트(Handler·Facade) 가 조립한다
-
③ 공통 부분을 제3의 모듈로 추출
- A 와 B 가 함께 쓰는 것을 C 로 빼면 고리가 끊긴다
-
④ 이벤트로 뒤집기
- A 가 B 를 직접 부르지 않고 이벤트를 발행한다
완전 제거가 어렵다면
의존 자체를 없앨 수 없는 경우도 있다. 그때는 가능한 한 작게 유지한다 — 클래스 전체가 아니라 메서드 하나만 의존하도록 좁힌다.
면접 함정
- ❌
allow-circular-references=true로 해결 → 문제를 덮는 것이지 푸는 것이 아니다. - ❌ "필드 주입이 코드가 짧아서 좋다" → 순환을 감추고 테스트에서 교체도 어렵다.
어떻게 발견하나
-
생성자 주입으로 바꿔 본다 — 기동이 실패하면 순환이 있다
-
정적 분석 도구 (ArchUnit · jdepend · SonarQube)
-
모듈로 쪼개 본다 — 컴파일이 안 되면 순환이다
-
ArchUnit 예
- slices().matching("com.example.(*)..").should().beFreeOfCycles()
- 테스트로 순환을 CI 에서 막는다
왜 오래된 코드에 잘 생기나
- 처음엔 A → B 단방향이었다
- 어느 날 B 에서 A 의 기능이 필요해졌다
- 가장 쉬운 해결: B 에 A 를 주입한다
- 고리가 닫힌다
각 단계는 합리적인데 결과는 얽힌 그래프다 그래서 '한 번에 생긴 문제' 가 아니라 '누적된 결정' 이다
계층 규칙으로 예방한다
의존 방향을 미리 정하고 지킨다
- controller → service → repository
같은 계층끼리(service ↔ service) 의 참조를 금지하면 순환이 구조적으로 생길 수 없다 서비스 간 협력이 필요하면 상위(파사드·오케스트레이터) 로 올린다
이벤트로 뒤집을 때의 대가
A 가 B 를 직접 부르는 대신 이벤트를 발행하면 의존은 끊긴다
다만
-
흐름 추적이 어려워진다 (누가 이 이벤트를 받는지 코드에서 안 보인다)
-
트랜잭션 경계가 복잡해진다
-
순서 보장이 필요하면 더 어렵다
-
결합을 끊는 대가로 가시성을 잃는다. 남발하지 않는다