5.1 순환참조
5.2 순환참조를 해결하는 방법
순환 참조가 나쁘다는 것은 알았고, 어떻게 하면 해결할 수 있을까요? 가장 확실한 방법은 순환 참조 자체를 만들지 않는 것입니다.
5.2.1 불필요한 참조 제거
불필요한 참조를 제거한다는 것은 양방향 참조가 꼭 필요한지 재고해 본다는 의미입니다. 꼭 필요하지 않은 참조를 제거하거나 필요에 따라 관계를 표현하긴 해야 한다면 한쪽이 다른 한쪽의 식별자를 갖고 있게 해서 간접 참조 형태로 관계를 바꾸는 것입니다. 만약 예로 TeamJpaEntity, MemberJpaEntity 클래스가 양방향 매핑으로 순환 참조가 있다고 가정합시다.
TeamJpaEntity - @OneToMany MemberJpaEntity, MemberJpaEntity - @ManyToOne TeamJpaEntity 로 구성되어있습니다. 프로젝트마다 다르겠지만, TeamJpaEntity 클래스가 팀원 목록을 모두 갖고 있는 것이 과할 수 있죠. TeamJpaEntity 클래스 객체를 Jpa로 불러올 때 'n+1 쿼리'가 발생할 수 있습니다.
그래서 TeamJpaEntity 클래스에서 members 변수를 제거했다고, 가정하면 TeamJpaEntity.findByTeamId(teamId) 같은 메서드로 호출해 팀원 목록을 가져오면 됩니다. 사실 TeamJpaEntity 클래스가 findByMemberId 같은 메서드를 지원한다면 TeamService 컴포넌트는 MemberService 컴포넌트에 의존할 필요가 없죠.
n+1 쿼리 - 데이터베이스에서 필요한 정보를 한 번에 가져오는 대신 여러 추가 쿼리로 데이터를 요청해 가져오는 상황을 의미합니다. 예로 부모 객체를 가져올 때 이에 속하는 자식 객체들은 모두 JOIN해서 한번에 가져오길 원할 수 있습니다. 하지만 ORM은 이 동작을 수행할 때 부모 객체를 가져오는 쿼리하나, n개의 자식 객체를 가져오는 쿼리 n개를 만들어 데이터베이스에 요청할 수 있습니다. 이는 개발자와 다른 의도의 동작이고, 부하와 성능 저하를 초래할 수 있는 문제이기 때문에 경계해야 합니다.
5.2.2 간접 참조 활용
MemberJpaEntity 클래스가 갖는 TeamJpaEntity 클래스로의 참조를 없애고, myTeamId 변수를 뒀다면 TeamJpaEntity.findById(teamId) 같은 메서드로 팀 정보를 불러올 수 있죠. 간접 참조를 활용한다는 의미는 기존에 직접 참조하던 것을 참조 객체의 식별값을 이ㅛㅇ해 참조하도록 바꾼다는 의미입니다.
*한 방 쿼리보다 단순한 쿼리
'이러한 방식을 활용해 객체 간의 불필요한 의존 관계를 제거하세요' 라는 조언에 따라오는 질문으로 '그럼 SQL 쿼리가 여러 번 발생할 수 있는 것 아닌가요?' 가 있습니다. 간접 참조를 사용하면 시스템에 SQL 쿼리 몇 줄이 더 추가될 수 있지만, 짧은 쿼리가 몇 줄 추가 되는 것은 생각보다 큰 문제가 없습니다. 간접 참조에 사용되는 식별자는 보통 기본키로 인덱싱 되어 있고, 시스템 곳곳에 마련된 다양한 캐싱 장치가 쿼리 속도를 높여주기도 합니다. 하지만 참조 관계가 복잡해서 쿼리가 복잡하게 실행된다면, 캐싱 장치를 활용하기도 어렵습니다.
5.2.3 공통 컴포넌트 분리
만약 서비스 같은 컴포넌트에 순환 참조가 있고, 필수적이라면 어떻게 해결할까요? 가장 간단한 방법으로 공통 컴포넌트를 분리하는 방법이 있습니다. 즉, 양쪽 서비스에 있는 공통 기능을 하나의 컴포넌트로 분리하는 것입니다. 양쪽 서비스가 공통 컴포넌트에 의존하도록 바꾸면 순환 참조를 없앨 수 있죠. 또 다른 장점으로는 공통 기능을 분리하는 과정에서 책임 분배가 적절하게 재조정된다는 점입니다. 컴포넌트의 기능적 분리는 결과적으로 과하게 부여됐던 책임을 분산하며, 기능적 응집도를 높이는 효과를 가져옵니다. 전체 시스템 설계가 SRP에 더욱 부합하는 설계로 진화되고, 각 컴포넌트의 역할과 책임이 명확히 구분되는 것입니다.
5.2.4 이벤트 기반 시스템 사용
만약 서비스를 공통 컴포넌트로 분리할 수 없다면 이벤트 기반 프로그래밍을 시스템에 적용할 수 있습니다. 이벤트 큐를 바라보는 구조는 아래와 같습니다.
1. 시스템에서 사용할 중앙 큐를 만듭니다.
2. 필요에 따라 컴포넌트들이 중앙 큐를 구독하게 합니다.
3. 컴포넌트들은 자신의 역할을 수행하던 중 다른 컴포넌트에 시켜야 할 일이 있다, 큐에 이벤트를 발행합니다.
4. 이벤트가 발행되면 큐를 구독하고 있는 컴포넌트들이 반응합니다.
5. 컴포넌트들은 이벤트을 확인하고, 자신이 처리해야 하는 이벤트라면 이를 읽어 처리합니다.
6. 컴포넌트들은 자신이 처리하지 않아도 되는 이벤트라면 무시합니다.
이 구조에서 서비스는 더 이상 서로 참조하지 않지만, 이벤트와 이벤트 큐에 의존합니다. 이벤트와 이벤트 큐가 인터페이스이자 곧 메시지가 되는 것이죠. 이벤트 기반 시스템은 객체 간의 통신을 이벤으로 이뤄지게 해서 결합을 느슨하게 만들어 순환 참조를 피할 수 있게 도와줍니다. 이 시스템은 컴포넌트들의 상호 의존성을 끊으며, 시스템 설계를 단순하게 만들어줍니다.
직접 구현하는 것도 좋지만, 스프링을 이용하면 이벤트 시스템을 쉽게 구현할 수 있습니다. ApplicationEvent, ApplicationEventPublisher, EventListener 등을 이용할 수 있죠. 또한, 이벤트 큐에 쌓인 이벤트를 어떻게 처리하느냐에 따라 동기 처리 또는 비동기 처리로 만들 수도 있습니다. 이러한 방식을 (EDP : Event - driven Programming)이라고 합니다.
조금 더 나아가, 이벤트 큐를 전역 변수로 두고 사용하는 것이 아니라 카프카 같은 메시지 시스템을 이용해 구현한다면 어떨까요? 이벤트 큐로 '중앙 시스템 인프라'를 이용하는 것입니다. 이는 이벤트 기반 시스템이 단일 서버에서만 동작하는 것이 아니라 멀티 서버에서 동작하게 할 수 있을 것입니다. 여러 서버가 이벤트를 이용해 통신이 가능하죠.
이러한 설계 방식으로 멀티 시스템을 구성하는 것을 가리켜 (EDA : Event - Driven Architecture)라고 합니다. 이 설계 방식은 시스템 각각이 곧 기능이 되는 MSA 환경에서 자주 선택되는 전략이기도 합니다. 다만 기존에 운영하던 시스템이 있다면 이를 적용할지 여부에 대해 조심스럽게 접근해야 합니다.
왜냐하면 이벤트 기반 시스템은 설계의 근간을 바꾸는 것이기 때문입니다. 오히려 설계의 일관성이 깨질 수 있고, 먼저 소개했던 5.2.1 ~ 5.2.3 방법을 적용해보는 것을 추천합니다.
5.3 양방향 매핑
앞서 '양방향 매핑이라는 개념이 순한 참조라는 죄악의 면죄부처럼 사용되고 있다'라고 얘기했습니다. JPA에 양방향 매핑이라는 개념이 있는 것은 맞지만 그게 곧 양방향 매핑을 적극적으로 사용해도 된다는 의미는 아닙니다. 다시 강조하면, 순환 참조는 어떻게 해서든 없애는 것이 좋으며, 순환 참조를 사용하는데는 신중해야 하고, 양방향 매핑도 마찬가지입니다. 양방향 매핑이라고 해서 순환 참조가 아닌 것은 아닙니다. 그렇다면 양방향 매핑이 왜 존재할까요? 이렇게 생각해보죠.
양방향 매핑은 도메인 설계를 하다가 '어쩔 수 없이' 나오는 순환 참조 문제에 사용하는 것이 바람직합니다.
JPA는 수단일 뿐이고, 양방향 매핑을 사용하지 않아도 얼마든지 개발이 가능합니다. 수단인 JPA로 인해 시스템 설계가 영향을 받아서는 안되죠.
*양방향 매핑에 대한 다른 시각 - 하이버네이트 4.3 버전 문서에서 양방향 매핑을 하이버네이트를 사용하는 모범 사례로 소개하죠. 이유를 읽어보면, 'SQL 쿼리를 만들기 쉽기 때문'라는 것을 확인할 수 있습니다. 하지만 이 양방향 매핑은 순환 참조에 해당하고 피하는 것이 좋습니다. 좋은 상황도 분명 있지만, 예로 도메인 객체와 영속성 객체를 분리한다면 도메인 객체는 순환 참조를 만들지 않되 영속성 객체는 쿼리를 쉽게 만들기 위해 양방향 매핑을 사용할 수 있습니다. 이런 경우 모범 사례가 될 수 있죠. 하지만 ORM을 표방하는 JPA의 특성상 대부분의 개발자는 JPA 엔티티를 도메인 객체로 사용하려는 경향이 있고, 그러한 이유로 대부분의 경우 양방향 매핑이 JPA를 사용하는 모범 사례가 되기는 어렵습니다.
* 연관관계의 주인 - 양방향 매핑을 얘기하면 '연관관계의 주인'이라는 개념이 필수이죠. 이의 출처는 ORM에서 시작되어, 오롯이 객체지향과 데이터베이스의 패러다임 불일치 문제를 해결하기 위해 나온 개념입니다. 이 개념이 만들어진 배경에 관해 설명하려면 사실 객체지향에서는 양방향 참조라는 개념이 없다는 것을 미리 알아야합니다. 객체지향에는 사실 완전한 의미의 양방향 참조는 존재하지 않습니다. 단방향 참조가 양쪽으로 존재하는 것이죠. 하지만 RDB는 실제로 양방향 관계가 있습니다.
5.4 상위 수준의 순환 참조
순환 참조는 객체뿐만 아니라 패키지, 시스템 수준에서 발생이 가능하죠. 예로 서로 다른 회사에서 만든 시스템이 양방향으로 API 호출을 받는 상황이라고 하죠. 이 두 시스템이 인터페이스 같은 추상 계층 없이 직접 의존 형태로 코드가 작성돼 있으면, 다시 말해 비즈니스 로직에 다른 회사에서 만든 시스템을 호출하는 코드가 하드 코딩 형태라면, 갑자기 한 회사가 시스템 서비스를 중단하면 다른 시스템도 중단해야 할지 모릅니다.
자바에서 패키지는 네임스페이스를 구분짓고 클래스를 계층적으로 구분하기 위해 만들어진 것이지만 패키지를 잘 구성한다면 모듈 시스템처럼 만들 수 있습니다. 즉 잘 만들어진 패키지는 그 자체로 분리해서, 새로운 서비스를 만들 수 있을 정도로 독립적입니다. 그래서 우리는 패키지, 모듈, 시스템에서 발생하는 순환 참조를 경계하고 독립된 무언가를 만들 수 있어야 합니다.
패키지나 시스템, 모듈 수준에서 순환 참조가 발생하면 분리와 유연성이 제한됩ㄴ다. 클래스뿐만 아니라 상위 수준에서 발생하는 순환 참조 또한 주의해야 하며, 순환 참조는 명백한 안티패턴입니다.
CRC 카드
CRC 카드를 아시나요? 생김새는 단순한데, 먼저 클래스를 하나 만들기로 했다면, 사각형 종이를 준비하고, 공간별로(Class name, Responsiblity, Collaborator) 필요한 내용을 적습니다. 이렇게 하면 하나의 클래스가 완성이죠. 사실 객체지향을 이에 적용하면, 상단에는 클래스의 이름이, 왼쪽에는 객체가 맡아야 할 책임이, 이에 필요한 협력 객체는 오른쪽에 있씁니다. 객체지향의 핵심 원리인 역할-책임-협력이 모두 있죠.
추가로 CRC 첫번째 Class를 애로사항으로 보기도 하죠. 클래스는 객체지향의 핵심이 아니기 때문입니다. Class-Responsibility-Collaborator만으로 객체지향의 핵심원리 3가지 중 '역할'이 강조되지 않습니다. 현대에 와서는 CRC를 Candidate-Responsibility-Collaborator로 해석하기도 합니다. Candidate는 후보라는 뜻인데, 대상을 '어떤 역할을 수행할 후보'라고 보는 것입니다.
실용주의
객체지향의 핵심 가치 중요성을 계속 강조했습니다. 하지만 실제로 개발하며 중요한 것은 구현 능력입니다. 우리가 만들려고 하는 것이 '객체지향적으로 완벽한 소프트웨어'가 아니라 '동작하는 소프트웨어'이기 때문입니다. 소프트웨어의 본질은 문제를 해결하는 것이고, 객체지향은 수단일 뿐입니다.
모든 코드를 객체지향으로 만들라는 의미도 아닙니다. 분명 가독성이 어렵거나, 더 돌아갈 수 있기 때문이죠. 즉, 어떤 코드에서는 객체지향을 적용하는 것이 좋고, 어떤 코드에서는 절차지향을 적용하는 것이 좋습니다. 개발 과정에서 개발자가 마주하는 일은 결국 트레이드오프이며, 실용적인 방법을 찾아 적용하는 일 입니다. 엄격한 원칙주의자보다는 느슨한 실용주의자가 백배 천배 낫고, 객체지향과 절차지향 역시 기법일 뿐이며, 적절히 섞어 사용해야 합니다.
'독서 > 자바,스프링 개발자를 위한 실용주의 프로그래밍' 카테고리의 다른 글
8. 레이어드 아키텍처 (0) | 2024.11.10 |
---|---|
[6-11 중] 6. 안티패턴 (2) | 2024.11.09 |
4. SOLID (0) | 2024.11.09 |
2. 객체의 종류 (0) | 2024.11.05 |
[1-5 상] 1. Object-Oriented (0) | 2024.11.05 |