Member의 엔티티는 InventoryProduct 도메인에 해당하는 InventoryProducts를 멤버로 가지고 있다. InventoryProducts를 통해 사용자의 프로필 장비를 조회할 수 있다.
F12 서비스에서 다음과 같은 프로필을 구현할 때 사용자의 회원정보와 그 사용자의 프로필 장비를 같이 보여주기 때문에 멤버 변수로 의존하게 됐다.
즉 Member를 조회할 때 InventoryProduct를 함께 조회해서 위와 같은 프로필을 만들기 용이하기 위해 Member가 InventoryProduct에 의존한다.
그리고 Member의 서비스 로직에서 사용자들의 프로필을 만들 때 해당 회원들의 인벤토리 장비도 조회해와야 한다.아래가 사용자들의 프로필을 만드는 서비스 로직이다. Member를 조회해오고 그에 해당하는 InventoryProduct를 조회해서 조립하고 있다. 이렇게 Member와 InventoryProduct를 따로 조회해서 조립하면 InventoryProductRepository에 의존성이 생긴다.
이렇게 생긴 의존성은 MemberRepository에서 Member를 조회할 때 Left join 기반으로 한 fetch join으로 연관된 엔티티를 한번에 읽어오게 하면 서비스 로직에서 InventoryProductRepository에 의존하지 않고 회원들의 인벤토리 장비를 가져올 수 있다.
여기서 한가지 포인트를 알고가자. fetch join으로 한번에 같이 조회해서 서비스 로직에서 InventoryProduct 의존성을 제거할 수 있다. 하지만 Member 엔티티에서 InventoryProduct를 멤버 변수로 의존하게 된다. 또 MemberRepository에서 InventoryProduct를 알게 된다. Member 엔티티에서 InventoryProduct 의존성을 제거하면 MemberService에서 Member를 조회해올 때 InventoryProduct를 한번에 조회할 수 없고 InventoryProductRepository에 의존하게 된다.
Member 입장에서 보면 프로필을 만드는 로직 때문에 서비스 레이어 혹은 엔티티에서 InventoryProduct에 의존하게 된다.
InventoryProduct의 상황
회원이 리뷰를 남긴 제품을 의미한다.인벤토리 장비가 프로필 장비인 지 구분할 수 있다. 리뷰를 남긴 제품은 자동으로 회원의 인벤토리 장비가 된다. InventoryProduct는 DB 테이블로 생각하면 Member와 Product의 다대다 매핑 테이블 역할도 한다.
엔티티 상으로는 Member와 Product에 의존한다. Product 의존성은 InventoryProduct가 대부분 장비의 정보를 함께 조회해서 멤버 변수로 가져도 괜찮다. 하지만 Member가 문제다. InventoryProduct는 Member를 사용하지 않는데 멤버 변수로 갖고 있다. 이런 불필요한 직접 참조는 id로 간접 참조하면 쉽게 해결할 수 있다.
문제는 서비스 레이어에서 Member에 의존하고 있다는 점이다. 특정 사용자id로 해당 사용자의 인벤토리 장비를 조회해야 하는 경우 사용자 Id가 유효한 지 확인해야 한다. 그래서 다음과 같은 의존이 생긴다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
@Service publicclassInventoryProductService { // 생략... public InventoryProductsResponse findByMemberId(final Long memberId) { validateMember(memberId); final List<InventoryProduct> inventoryProducts = inventoryProductRepository.findWithProductByMemberId(memberId); return InventoryProductsResponse.from(inventoryProducts); }
privatevoidvalidateMember(final Long memberId) { if (!memberRepository.existsById(memberId)) { thrownewMemberNotFoundException(); } } }
이런 의존을 AOP로 분리할 수는 있을 것 같다. 하지만 그렇게 하면 서비스 로직이 여러군데 퍼지는 것 같고 실수할 여지가 많을 것 같다. 혹은 DIP를 적용해서 MemberValidator라는 인터페이스를 InventoryProduct 패키지에 두고, MemberValidator의 구현체는 Member에 두고 빈 등록하여 InventoryService는 MemberValidator 인터페이스를 주입받아서 사용하면 Member에게 의존하지 않고 Member가 InventoryProduct에 의존하게 할 수 있다.
하지만 InventoryProduct이 Member에 의존하는게 잘못된 걸까? 의미 상 InventoryProduct는 특정 회원의 인벤토리 장비이다. InventoryProduct의 로직 상 Member에게 의존해서 협력하는 게 자연스럽고, 이 의존을 없애거나 역전하기 위해 복잡도를 높이는 게 오히려 코드를 이해하는 데 어려울 수 있다.
해결책
현재까지 문제점을 정리하자면 다음과 같다. Member는 프로필 관련 기능 때문에 InventoryProduct를 의존할 수 밖에 없다. (서비스 레이어 혹은 엔티티) InventoryProduct는 Member 의존성을 다양한 방법으로 제거할 수 는 있지만 의미 상 의존하는게 자연스럽고 의존을 제거하면 복잡도가 높아질 것 같다.
그렇다면 Member에서 프로필 관련 기능을 아예 다른 패키지로 추출하면 어떨까? profile 이라는 새로운 패키지를 도입해서 프로필 관련 기능을 모을 수 있지 않을까?
프로필 관련 로직을 profile에 모으면 다음과 같이 의존성이 그려진다.
한번 로직을 모아보자!
도메인
먼저 프로필 기능에 필요한 도메인 클래스를 만들어보자. 프로필은 특정 대상이 팔로잉하는지 알려줄 수 있어야 하고, 대표장비와 회원정보를 알려줄 수 있어야 한다.
여기서 중요한 점은 프로필의 의미이다. 프로필은 아래 그림을 보면 알 수 있듯이, 특정 회원의 회원정보와 대표장비 그리고 보는 사람이 팔로우 했는지를 알려주는 개념이다. 즉 프로필은 보는 사람에 따라 팔로우 여부를 상대적으로 가지게 된다는 사실을 명심하자.
publicProfileService(final MemberRepository memberRepository, final InventoryProductRepository inventoryProductRepository, final FollowingRepository followingRepository) { this.memberRepository = memberRepository; this.inventoryProductRepository = inventoryProductRepository; this.followingRepository = followingRepository; }
public PagedProfilesResponse findBySearchConditions(@Nullablefinal Long loggedInId, final ProfileSearchRequest profileSearchRequest, final Pageable pageable) { final Slice<Member> slice = findBySearchConditions(profileSearchRequest, pageable); if (slice.isEmpty()) { return PagedProfilesResponse.empty(); } finalProfilesprofiles= createProfiles(loggedInId, slice.getContent()); return PagedProfilesResponse.of(slice.hasNext(), profiles); } //생략... }
의존성을 정리하고 나면,,
이제 Member는 회원 정보 관리와 팔로우 관련 기능만 수행한다. InventoryProduct는 인벤토리 장비 추가, 삭제, 대표장비 수정 관련 기능만 수행하게 된다. Profile은 Member와 InventoryProduct를 기반으로 프로필 생성하는 기능을 수행하게 된다.
하지만 Product 도메인은 서비스 레이어에서 여러 도메인들에 의존한다. 특히 Product 엔티티가 제거되면 해당 장비에 대한 리뷰와 해당 장비를 인벤토리 장비 설정한 내역도 모두 제거해야 된다. 그래서 ProductService에서는 다음과 같이 다른 도메인의 레포지토리를 의존한 경우가 있었다.
지금까지 파악한 문제는 InventoryProduct, Review는 Product와 조회되는 일이 많고 Product 검증해야 하는 로직 때문에 Product에 의존하고 있었다. Product는 장비 삭제할 때 해당하는 InvnentoryProduct와 Review를 제거해야 해서 의존성이 생겼었다.
이런 문제는 Product 삭제 시, 이벤트를 발행해서 해당 이벤트를 처리하는 이벤트 리스너를Review와 InvnetoryProduct에 추가하는 방식으로 해결했다.
이벤트 구현
product 패키지에 장비가 삭제됐음을 알리는 이벤트를 만들어본다. ApplicationEvent를 상속해야 한다.