Chapter 5. 순환 참조
이 부분은 Chapter4에서도 의존성 전이를 얘기하면서 다루기도 했고, 실제로 Entity 매핑을 하면서도 발생한 문제여서 잘 알고 있었다.
그래서 조금은 순환 참조가 뭐인지 정리하기보단.. 해결방안을 위주로 가볍게 작성해 볼 예정이다.
JPA를 활용하며 가장 대표적으로 볼 수 있는 순환 참조는 다음과 같은 양방향 매핑이다.
@Data
@NoArgsConstructor
@Entity(name = "team")
class TeamEntity {
@Id
private String id;
@OneToMany(mappedBy = "myTeam") // Team -> Member
private List<MemberJpaEntity> members;
}
@Data
@NoArgsConstructor
@Entity(name = "member")
class MemberEntity {
@Id
private String id;
@ManyToOne // Member -> Team
@JoinColumn(name = "my_team_id")
private TeamEntity myTeam;
}
JPA의 양방향 매핑은 순환 참조이다.
순환 참조가 좋지 않음을 알지만 JPA에서 이런 실수가 유독 많이 보인다. 양방향 매핑이라는 개념이 순환 참조의 면죄부처럼 사용되고 있는 건 아닌가 싶다. 하지만 양방향 매핑이라고 순환 참조가 아닌 것은 아니다.
@Service
public TeamService {
@Autowired
private MemberService memberService; // TeamService -> MemberService
}
@Service
public MemberService {
@Autowired
private TeamService teamService; // MemberService -> TeamService
}
이 또한 순환 참조의 예시이다. 순환 참조는 소프트웨어 설계에서 피해야 하는 안티패턴으로, 순환 참조가 발생하는 것은 서로 강하게 의존한다는 의미이다. 사실상 하나의 컴포넌트라는 의미이며, 책임이 제대로 구분되어 있지 않다는 의미이다. 따라서 순환 참조가 있는 컴포넌트는 SOLID 하지도 않다.
5.1 순환 참조의 문제점
5.1.1 무한 루프
순환 참조가 발생하면, 객체들이 서로를 참조하는 구조 때문에 어떤 메서드 호출이 무한히 반복될 위험이 있다. 예를 들어, 한 객체가 다른 객체의 상태를 참조하는 동안, 그 객체 또한 다시 처음 객체의 상태를 참조하는 상황이 발생하면, 의도치 않은 무한 루프로 이어져 프로그램이 멈추거나 성능 저하를 일으킬 수 있다.
5.1.2 시스템 복잡도
순환 참조가 있으면 어떤 객체에 접근할 수 있는 접근 경로가 너무 많아진다. 접근 경로가 많다는 것은 소프트웨어 설계에서 좋은 말이 아니다. 이는 어떤 객체를 수정할 때 개발자들이 만들어낸 온갖 다양한 접근 경로를 모두 고려해야 한다는 뜻이다. 따라서 이는 곧 복잡도를 높아지게 만드는 원인이 된다. 따라서 도메인 모델들에 단일 진입점을 만들어서 필요한 객체가 있을 때 단방향으로 접근하도록 하는 것이 좋다.
메모리 누수
순환 참조의 문제점을 찾다 보면 순환 참조가 메모리 누수를 유발한다고 한다.
참조 횟수 계산 방식(reference counting)을 사용하는 GC에서 순환 참조는 메모리 누수를 유발할 수 있다. 참조 횟수 계산 방식은 객체의 참조가 생성되거나 제거될 때마다 객체의 참조 횟수를 증가시키거나 감소시키는 방식을 말한다. 만약 객체의 참조 횟수가 0이면 그 객체는 더 이상 사용되지 않는 것으로 간주하고 메모리에서 삭제 대상이 된다. 하지만 순환 참조가 발생하면 서로가 서로를 참조하고 있기 때문에 참조 횟수는 항상 0보다 크다. 따라서 GC는 순환 참조 된 객체를 삭제 대상으로 인식하지 못해서 영원히 메모리 영역에 남아 이러한 문제가 발생한다.
하지만 JVM을 사용하는 자바 개발자들은 반은 맞고 반은 틀리다. JVM은 더 똑똑하다. JVM 환경에서는 기본 GC가 참조 횟수 계산 방식만 쓰는 것이 아니라 마크-앤드-스위프라는 개선된 알고리즘을 사용한다. 모든 루트 객체부터 시작해서 참조되는 모든 객체를 따라가 각 객체를 마킹한다. 그리고 이 단계에서 마킹되지 않은 객체를 메모리에서 제거한다. "루트 객체로부터의 접근 가능성"을 고려한다면 서로 순환 참조를 하더라도 그 객체들이 루트로부터 접근 가능하지 않다면 삭제 대상이 된다. 따라서 이를 JVM을 사용하는 개발자에게 순환 참조의 문제점으로 말할 순 없다.
5.2 순환 참조를 해결하는 방법
5.2.1 불필요한 참조 제거
양방향 참조가 꼭 필요한지 재고해 본다는 의미이다. 꼭 필요하지 않은 참조를 제거하거나 필요에 따라 한쪽이 다른 한쪽의 식별자를 갖고 있도록 하는 간접 참조 형태로 바꾸는 것이 좋다.
@OneToMany(mappedBy = "myTeam") // Team -> Member
private List<MemberJpaEntity> members;
위의 예시처럼 TeamEntity가 굳이 모든 팀원의 목록을 가지고 있는 것은 과하기도 하며, n+1 문제를 발생시킬 위험도 있다. 만약 팀원의 목록이 필요하다면 MemberRepository.findByTeamId(teamId);로 찾으면 된다. 그리고 이런 메서드가 있다면 굳이 TeamService가 MemberService를 의존할 필요도 없다.
5.2.2 간접 참조 활용
@Data
@NoArgsConstructor
@Entity(name = "team")
class TeamEntity {
@Id
private String id;
@OneToMany(mappedBy = "myTeam") // Team -> Member
private List<MemberJpaEntity> members;
}
@Data
@NoArgsConstructor
@Entity(name = "member")
class MemberEntity {
@Id
private String id;
@Column(name = "my_team_id") // 단지 식별자만 참조 == 간접 참조
private long myTeamId;
}
기존처럼 TeamEntity를 참조해서 직접 참조하는 것이 아닌, 참조 객체의 식별값을 이용해서 참조하도록 바꾼다는 의미이다. 따라서 직접 참조가 사라지므로 불필요한 참조를 제거한다는 첫 번째 방법과도 유사한 방법이다.
한 방 쿼리보다 단순한 쿼리
객체 간의 불필요한 의존 관계를 제거하라고 하면 SQL 쿼리가 여러 번 발생할 수 있지 않냐는 질문이 같이 온다.
맞다. 간접 참조를 사용하면 SQL 쿼리 몇 줄이 더 추가될 수 있따. 하지만 짧은 쿼리가 몇 줄 추가되는 것은 생각보다 큰 문제는 아니다. 생각보다 데이터베이스는 더 빠른 속도로 동작하기 때문이다. 게다가 간접 참조에 사용하는 식별자는 보통 기본키로 인덱싱 되어있고, 시스템 곳곳 마련된 다양한 캐싱 장치가 속도를 높여주기도 한다. 오히려 참조 관계가 복잡해서 데이터베이스에서 복잡한 쿼리가 실행된다면 캐싱 장치를 활용하기도 어려워진다. 따라서 긴 쿼리를 한 번 사용하는 것 보다 짧은 쿼리를 여러 번 사용하는 편이 빠를 수도 있다.
약간의 중복이 발생하더라도 단순함은 굉장한 무기가 될 수 있다. 가독성과 유지보수성이 높아질 수도 있으며, 최적화에 더 유리하고 버그도 더 적어진다.
5.2.3 공통 컴포넌트 분리
양쪽 서비스에 있던 공통 기능을 하나의 컴포넌트로 분리한다. 그 후 양쪽 서비스가 공통 컴포넌트에 의존하도록 바꾸면 순환 참조가 없어진다. 이 방법은 대부분의 컴포넌트 간 순환 참조 문제를 해결할 수 있을 정도로 강력하다.
이 방법은 공통 기능을 분리하는 과정에서 책임 분배가 적절하게 재조정된다는 장점이 있다. 컴포넌트의 기능적 분리는 결과적으로 과하게 부여됐던 책임을 분산하며, 그 결과 기능적 응집도를 높이는 효과를 가져온다. 각 컴포넌트의 역할과 책임이 명확히 구분된다.
5.2.4 이벤트 기반 시스템 사용
서비스를 공통 컴포넌트로도 분리할 수 없다면 이벤트 기반 프로그래밍을 시스템에 적용할 수 있다. 시스템에 이벤트 기반 프로그래밍을 적용한다는 것은 시스템 설계를 다음과 같이 변경한다는 것이다.
- 시스템에서 사용할 중앙 큐를 만든다.
- 필요에 따라 컴포넌트들이 중앙 큐를 구독하게 한다.
- 컴포넌트들은 자신의 역할을 수행하던 중 다른 컴포넌트에 시켜야 할 일이 있다면 큐에 이벤트를 발행한다.
- 이벤트가 발행되면 큐를 구독하고 있는 컴포넌트들이 반응한다.
- 컴포넌트들은 이벤트를 확인하고 자신이 처리해야 하는 이벤트라면 이를 읽어 처리한다.
- 컴포넌트들은 자신이 처리하지 않아도 되는 이벤트라면 무시한다.
이 구조에서 서비스는 더 이상 서로를 상호 참조하지 않는다. 대신 이벤트와 이벤트 큐에 의존한다. 이벤트와 이벤트 큐가 인터페이스이자 곧 메시지가 된다. 이벤트 기반 시스템은 객체 간의 통신을 이벤트로 이뤄지게 해서 결합을 느슨하게 만들어 순환 참조를 피할 수 있게 도와준다. 이벤트 기반 시스템은 컴포넌트들의 상호 의존성을 끊어내면서도 시스템 설계를 단순하게 만들어 준다.
public class EventB extends ApplicationEvent {
public EventB(Object source) {
super(sourse);
}
}
@Componnent
@RequiredArgsConstructor
public ServiceA {
private final ApplicationEventPublisher eventPublisher;
public void doSomething() {
eventPublisher.publishEvent(new EventB(this));
}
}
@Componnent
public ServiceB {
@EventListenr
public void handlerEventB(EventB eventB) {
// 이벤트 처리 로직
}
}
serviceA.doSomething()을 호출하면 serviceB.shandlerEventB()가 연쇄적으로 실행된다. 이때 ApplicationEventPublisher를 이용해 이벤트를 발행하면 스프링은 발행된 이벤트 타입을 확인한다. 그리고 나서 @EventListenr 애너테이션으로 지정된 메서드 중 매개변수 타입이 일치하는 메서드를 호출한다.
이벤트 기반 시스템은 반드시 스프링의 도움이 있어야만 적용할 수 있는 기법은 아니다. 중앙화된 큐만 존재한다면 어떤 환경에서든 적용 가능하며, 이벤트 큐에 쌓인 이벤트를 어떻게 처리하느냐에 따라 동기 또는 비동기 처리 방식으로도 구성할 수 있다. 이런 방식의 프로그래밍을 이벤트 기반 프로그래밍(EDP, Event-Driven Programming)라 한다.
예를 들어, 이벤트 큐를 전역 변수처럼 사용하는 것이 아니라 Kafka 같은 메시지 시스템을 활용해 이벤트 큐를 중앙 시스템 인프라 형태로 구성할 수도 있다. 이렇게 되면 이벤트 기반 시스템이 단일 서버에서만 동작하는 것이 아니라 멀티 서버에서 동작할 수 있다. 여러 서버가 이벤트를 이용해 통신할 수 있다. 이러한 설계 방식으로 멀티 시스템을 구성하는 방식을 이벤트 기반 아키텍처(EDA, Event-Driven Architecture)라고 한다. 이 설계 방식은 시스템 각각이 곧 기능이 되는 MSA 환경에서 자주 선택되는 전략이기도 하다.
다만 기존에 운영 중인 시스템에 이벤트 기반 설계를 도입하려 한다면 신중하게 접근해야 한다. 이벤트 기반 시스템은 단순한 기술 도입이 아니라 전체 아키텍처의 근간을 바꾸는 작업이기 때문이다. 이미 개발이 완료된 프로젝트에 무리하게 적용하면 오히려 설계의 일관성이 깨질 위험도 존재한다. 따라서 먼저 순환 참조 같은 문제를 다른 방법으로 해결해 본 후, 필요성이 충분히 검증되었을 때 이벤트 기반으로 전환하는 것이 바람직하다.
5.3 양방향 매핑
양방향 매핑이라는 개념이 순환 참조라는 죄악의 면죄부처럼 사용되고 있다. (양방향 매핑이라고 순환 참조가 아닌 것은 아니다.)
JPA에 양방향 매핑이라는 개념이 있는 것은 맞지만 그 말이 곧 양방향 매핑을 적극적으로 사용해도 된다는 의미는 아니다.
순환 참조는 어떻게 해서든 없애는 것이 좋으며, 대부분 없앨 수 있다. 순환 참조를 사용하는 데는 신중을 기해야 하며, 같은 맥락으로 양방향 매핑도 신중을 기해야 한다.
양방향 매핑은 도메인 설계를 하다가 '어쩔 수 없이' 나오는 순환 참조 문제를 사용하는 것이 바람직하다.
양방향 매핑을 사용하지 않아도 얼마든지 개발이 가능하다. JPA는 수단일 뿐이다. 따라서 수단인 JPA로 시스템 설계가 영향을 받아서는 안된다. 우리는 순환 참조가 없는 도메인 먼저 구성해야 한다. 그리고 그 다음에 JPA를 연동하는 방식으로 개발해야 한다. JPA는 애플리케이션의 핵심이 아니다.
양방향 매핑에 대한 다른 시각
하이버네이트에서는 양방향 매핑을 하이버네이트를 사용하는 모범 사례로 소개한다. 그 이유는 SQL 쿼리를 만들기 쉽기 때문이다. 하지만 이는 순환 참조에 해당하므로 가급적 피하는 것이 좋다.
물론 양방향 매핑을 무조건 사용하지 말라는 것은 아니다. 좋은 상황도 있다. 예를 들어 도메인 객체와 영속성 객체를 분리한다면 도메인 객체는 순환 참조를 만들지 않되 영속성 객체는 쿼리를 쉽게 만들기 위해 양방향 매핑을 사용할 수도 있다. 하지만 ORM을 표방하는 JPA의 특성상 대부분의 개발자는 JPA 엔티티를 도메인 객체로 사용하는 경향이 있기 때문에 양방향 매핑이 모범 사례가 되긴 어렵다.
연관관계의 주인
연관관계의 주인은 객체지향이나 데이터베이스 개념이 아니라, ORM에서 발생한 패러다임 불일치를 해결하기 위해 만들어진 개념이다. 그래서 이 개념이 만들어진 배경에 관해 설명하려면 사실 객체지향에는 양방향 참조라는 개념이 없다는 것을 미리 알고 있어야 한다. 객체지향에는 사실 완전한 의미이의 양방향 참조가 존재하지 않는다. 단방향 참조가 양쪽으로 존재할 뿐이다. 즉, 클래스 상으로는 양방향을 참조하고 있을지 몰라도 실제로는 양방향이 아닐 수도 있다.
Team teamA = new Team();
Team teamB = new Team();
Member memberA = new Member();
teamA.addMember(memberA);
memberB.setMyTeam(teamB);
하지만 반대로 관계형 데이터베이스에서는 실제로 양방향 관계가 있다. 즉, 객체지향과 데이터베이스에는 관계를 표현하는 데 있어서 패러다임 불일치가 발생한다. 그래서 데이터베이스 상에 하나로 표현되는 개념을 객체지향으로 끌고 와서 두 개로 표현하려 하니 여러 부작용이 생겼던 것이다. 따라서 이러한 불일치를 해소하기 위해 어쩔 수 없이 관계의 소유를 정해야 했고, 그 결과로 관계를 소유하는 쪽과 소유하지 않는 쪽으로 나눠야만 했던 것이다.
따라서 연관관계의 주인이라는 개념은 그 자체가 모호하고 받아들이기 어렵다. 하지만 애초에 순환 참조를 만들지 않으면 연관 관계의 주인이 누구인지 신경 쓸 필요가 없어진다.
5.4 상위 수준의 순환 참조
순환 참조는 객체 간에만 발생하는 문제가 아니다. 패키지, 모듈, 시스템과 같은 더 상위 수준에서도 발생할 수 있으며, 이는 객체 간 순환보다 훨씬 더 큰 문제를 일으킬 수 있다.
자바에서 패키지는 단순히 네임스페이스를 구분하기 위한 수단이지만, 구조적으로 잘 설계된 패키지는 모듈처럼 독립적인 단위로 활용될 수 있다. 이처럼 잘 설계된 패키지는 하나의 퍼즐 조각처럼, 어디에든 유연하게 결합되고, 필요하다면 다른 조각으로 교체할 수 있어야 한다.
하지만 패키지나 모듈, 시스템 사이에 순환 참조가 생기면 분리 가능성과 유연성이 심각하게 제한된다. 따라서 개발자는 클래스 수준을 넘어서 상위 수준에서도 순환 참조가 발생하지 않도록 각별히 주의해야 한다. 순환 참조는 대표적인 안티패턴이다. 일시적으로 편리해 보일 수 있지만, 장기적으로는 유지보수, 확장, 테스트 등 다양한 측면에서 큰 장애물이 된다.
1부 객체지향을 마치며
Chapter 5에서 다룬 순환 참조는 사실 JPA를 사용하며 양방향 매핑 덕에 이전부터 잘 알고 있던 문제였다. 그래서 가볍게 개념을 정리하면서 서비스나 더 높은 계층의 의존 관계의 문제도 다시 한번 생각해 볼 수 있는 시간이었다.
Chapter 5까지 마치며 객체지향을 다룬 1부가 끝났다.
결국 객체란 책임을 부여받고, 협력하며, 역할을 수행하는 존재다. 여기서 말하는 VO도, DTO도, DAO도, Entity도 모두
객체가 어떤 책임을 지고 어떻게 협력하느냐의 관점에서 해석된다. 그리고 이러한 객체지향의 본질을 따르기 위해서는
단순한 구현보다 더 중요한, 책임의 명확한 분리와 의존성의 효과적인 조율이 필요하다. 이 책은 그런 근본적인 사고를, 아주 차분하게 처음부터 되짚어주는 책이었다.
어떻게 보면, 막연히 알고 있던 객체지향을 본질부터 다시 생각하게 해 준 책이었다는 생각이 든다. 이제 2부에서는 Spring과 객체지향 설계에 대한 내용을 다룬다. 곧 정리로 돌아올 예정이다.. 커밍쑨..🫨
'책책책 책을 읽어요 > 자바,스프링 개발자를 위한 실용주의 프로그래밍' 카테고리의 다른 글
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 4. SOLID (0) | 2025.04.08 |
---|---|
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 3. 행동 (2) | 2025.04.06 |
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 2. 객체의 종류 (0) | 2025.03.16 |
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 1. 절차지향과 비교하기 (2) | 2025.03.15 |