5. 순환 참조
순환 참조는 두 개 이상의 객체나 컴포넌트가 서로를 참조함으로써 의존 관계에 사이클이 생긴 상황을 말한다. 예를 들어 객체 A가 객체 B를 참조하고, 객체 B가 다시 객체 A를 참조하는 양방향 참조는 대표적인 순환 참조의 예다. 이러한 순환 참조는 소프트웨어 설계에서 자주 볼 수 있는 대표적인 안티패턴 중 하나이다.
JPA를 사용하다보면 @OneToMany와 @ManyToOne을 이용해 양방향 매핑(bidirectional mapping)을 적용하는 사례를 많이 접할 수 있다. JPA의 양방향 매핑은 순환 참조다.
Team과 Member의 예제로도 많이 등장하는 양방향 매핑은 순환 참조라는 죄악의 면죄부처럼 사용되고 있는 거 같기도 하다. 순환 참조의 문제는 엔티티에서만 생기는 것이 아니라 서비스 컴포넌트에서도 생길 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Service class TeamService { @Autowired private MemberService memberService; } @Service class MemberService { @Autowired private TeamService teamService; } | cs |
TeamService 컴포넌트가 MemberService 컴포넌트를 참조하고, MemberService 컴포넌트가 TeamService 컴포넌트를 참조한다. 즉 순환 참조가 발생하였다. (우리 프로젝트 코드에도 왠지 이런 순환 참조가 있는 Service 컴포넌트가 있을 것 같기도... 살펴보고 리팩터링 해야겠다)
순환 참조가 발생한다는 것은 서로에게 강하게 의존한다는 의미이다. 사실상 하나의 컴포넌트라는 의미이며, 책임이 제대로 구분돼 있지 않다는 의미이다. 따라서 순환 참조가 있는 컴포넌트는 SOLID하지 않는다.
5.1 순환 참조의 문제점
4.1.1 무한 루프
순환 참조로 인해 무한 루프가 만들어지는 상황은 쉽게 상상할 수 있다. 객체 A가 객체 B의 메서드를 호출하고 객체 B가 객체 A의 또 어떤 메서드를 호출해서 무한 루프가 만들어지는 것이다. 이런 메서드 호출 무한 루프야 개발자가 주의만 한다면 발생하지 않는 거 아니야 할 수도 있지만, 단순히 getter만 있는 코드에도 이미 무한 루프를 만들어 낼 수 있는 잠재적인 버그가 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | @Data @Entity class Team { @Id private Long id; @Column private String name; @OneToMany private List<Member> members; } @Data @Entity class Member { @Id private Long id; @Column private String name; @OneToMany private Team myTeam; } | cs |
위와 같은 순환 참조가 있는 객체를 JSON 형식으로 직렬화해야 한다고 가정해보자.
1 2 3 4 5 6 7 8 9 10 | // 객체 할당 Team team = new Team(1, "developerGroup", new ArrayList<>()); team.getMembers().add(new Member(1, "foobar", team)); team.getMembers().add(new Member(2, "hello", team)); // 직렬화 실행 ObjectMapper objectMapper = new ObjectMapper(); String result = objectMapper.writeValueAsString(team); System.out.println(result); | cs |
위와 같은 객체는 직렬화가 불가능하다.
Team 객체 입장에서는 Member 객체 리스트를 직렬화하려고 하는데, Member 객체는 Team 객체를 참조하고 있으니 직렬화 과정에서 무한 루프에 빠지게 된다. 따라서 순환 참조가 있는 객체를 직렬화하려 하면 프로그램은 StackOverflow를 발생시킨다.
물론 도저히 방법이 없는 것은 아니다. Jackson처럼 훌륭한 라이브러리에서는 이런 상황을 대비해 @JsonIdentityInfo 혹은 @JsonIgnore와 같은 애노테이션을 제공한다. 하지만 이런 방법은 직렬화/역직렬화 상황에서만 해결하는 것이지 무한 루프가 언제 어디서 위험을 가져올 지는 모르는 일이다.
순환 참조로 인해 발생할 잠재적 위험을 안고 갈 이유가 없다. 이 같은 순환 참조 문제를 해결하는 방법은 바로 순환 참조를 없애는 것이다.
5.1.2 시스템 복잡도
순환 참조는 시스템의 복잡도를 높인다.
앞서 보았던 Team - Member 관계에서 '팀 내 모든 구성원의 월급을 합산해 주세요'라는 요구사항이 추가되었다고 가정했을 때 아래와 같은 코드가 작성될 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | @Data @Entity class Team { @Id private Long id; @Column private String name; @OneToMany private List<Member> members; } @Data @Entity class Member { @Id private Long id; @Column private String name; @OneToMany private Team myTeam; @Column private int salary; public int calculateTeamMemberTotalSalary() { int result = 0; for (Member member : myTeam.getMembers()) { result += member.salary; } return result; } } | cs |
보다시피 Member 클래스에 소속된 팀의 전체 월급이 얼마인지 알 수 있는 메서드가 추가됐다. 팀원이 팀 내 전체 구성원의 월급을 알 수 있는 시스템이라니 현실 세계에선 벌어질 수 없는 살짝 이상한 상황이다.
이런 코드 안짜면 되는거 아냐! 라고 할 수 있지만 중요한 것은 잘못된 설계로 인해 이런 코드가 만들어질 수도 있다라는 사실이다. 시스템의 복잡도가 높아진다는 것은 단순히 코드의 복잡도가 증가한다는 것만 의미하는 것이 아니라 이러한 개발자가 겪는 논리적 혼란도 포함하는 말이다.
Team 클래스에서 List<Member>를 없앤다면 '팀 내 모든 구성원의 월급을 모두 합산해 주세요'라는 요구사항이 왔을 때, Team 객체만으로는 팀원의 월급을 확인할 수 없으니 TeamService 컴포넌트에서 구현을 하면된다.
순환 참조가 있으면 어떤 객체에 접근할 수 있는 접근 경로가 너무 많아진다. 소프트웨어 설계에서 접근 경로가 많다는 것은 의존 관계가 복잡하게 얽혀 있다는 것을 의미한다. 이는 어떤 객체를 수정할 때 개발자들이 만들어낸 온갖 창의적인 접근 경로를 모두 고려해야만 한다는 뜻이다. 따라서 접근 경로가 많아진다는 것은 복잡도가 높아지는 원인이 된다. 그러므로 가능한 한 도메인 모델들에 단일 진입점을 만들어서 필요한 객체가 있을 때 단방향으로 접근하도록 만드는 것이 좋다.
메모리 누수
순환 참조 문제점 중으로 메모리 누수 이야기도 자주 꼽는데, 이는 JVM을 사용하는 자바 개발자들에게 반은 맞고 반은 틀린 말이다. 참조 횟수 계산 방식(reference counting)을 사용하는 GC(garbage collector)에서 순환 참조는 메모리 누수를 유발한다. 참조 횟수 계산 방식은 객체의 참조가 생성되거나 제거될 때마다 객체의 참조 횟수를 증가시키거나 감소시키는 방식이다. 객체의 참조 횟수가 0이되면 그 객체는 더 이상 사용되지 않는 것으로 판단하고 메모리에서 삭제대상이 된다. 그런데 순환 참조 상황에서는 서로가 서로를 참조하고 있기 때문에 이들의 참조 횟수는 항상 0보다 크게 남아 GC 대상에 들어가지 않고 영원히 메모리 영역에 남아있게 된다.
그러나 JVM 환경에서 사용되는 기본 GC는 마크 앤드 스위프(mark-and-sweep)라는 조금 더 개선된 알고리즘을 사용하여 위와 같은 문제를 방지한다. 루트 객체부터 시작하여 참조되는 모든 객체를 마킹하고, 마킹되지 않은 객체는 메모리에서 제거한다.
5.2 순환 참조를 해결하는 방법
5.2.1 불필요한 참조 제거
서로 참조가 필요 없는 녀석들은 즉 참조 없이 문제를 해결할 수 있는 녀석들의 참조는 없애는 것이 맞다.
5.2.2 간접 참조 활용
순환 참조를 제거하는 데 간접 참조를 활용할 수도 있다.
5.2.3 공통 컴포넌트 분리
만약 서비스 같은 컴포넌트에 순환 참조가 있고, 그것이 각 컴포넌트의 설정상 필수적이라면, 양쪽 서비스에 있던 공통 기능을 하나의 컴포넌트로 분리하는 것이다. 그러고 나서 양쪽 서비스가 공통 컴포넌트에 의존하도록 바꾸면 순환 참조가 없어진다.
이 방법의 또 다른 장점으로는 공통 기능을 분리하는 과정에서 책임 분배가 적절하게 재조정된다는 점이다. 컴포넌트의 기능적 분리는 결과적으로 과하게 부여됐던 책임을 분산하며, 그 결과 기능적 응집도를 높이는 효과를 가져온다. 이 과정을 통해 전체 시스템 설계가 단일 책임 원칙에 더욱 부합하는 설계로 진화한다. 각 컴포넌트의 역할과 책임이 명확히 구분되는 것이다.
5.2.4 이벤트 기반 시스템 사용
서비스를 공통 컴포넌트로도 분리할 수 없다면 이벤트 기반 프로그래밍을 시스템에 적용할 수 있다.
- 시스템에서 사용할 중앙 큐를 만든다.
- 필요에 따라 컴포넌트들이 중앙 큐를 구독하게 한다.
- 컴포넌트들은 자신의 역할을 수행하던 중 다른 컴포넌트에 시켜야 할 일이 있다면 큐에 이벤트를 발행한다.
- 이벤트가 발행되면 큐를 구독하고 있는 컴포넌트들이 반응한다.
- 컴포넌트들은 이벤트를 확인하고 자신이 처리해야 하는 이벤트라면 이를 읽어 처리한다.
- 컴포넌트들은 자신이 처리하지 않아도 되는 이벤트라면 무시한다.
이 구조에서 서비스는 더 이상 서로를 상호 참조하지 않는 대신 이벤트와 이벤트 큐에 의존한다. 이벤트와 이벤트 큐가 인터페이스이자 곧 메시지가 되는 것이다.
5.3 양방향 매핑
앞서 말했듯이 양방향 매핑은 순환 참조이다. 순환 참조는 어떻게 해서든 없애는 것이 좋으며, 대부분 없앨 수 있다. 양방향 매핑을 사용하지 않아도 얼마든지 개발이 가능하다.
5.4 상위 수준의 순환 참조
순환 참조는 객체뿐만 아니라 패키지 사이나 시스템 수준에서도 발생할 수 있는 문제다. 그리고 이러한 순환 참조 문제가 패키지나 시스템 수준에서 발생한다면 이는 객체 간 순환 참조보다 더 큰 문제를 야기할 수 있다.
(우테코 레벨 4 미션중 멀티 모듈 개발을 하면서, 상위 수준(모듈)의 순환 참조는 서비스의 존망과도 연관되어 있음을 경험했다)
패키지나 시스템, 모듈 수준에서 순환 참조가 발생하면 분리와 유연성이 제한된다. 따라서 개발자는 클래스뿐만 아니라 이러한 상위 수준에서 발생하는 순환 참조를 방지하기 위해 주의해야 한다.