아키텍처구조_최종_진짜최종.spring

E4ger·2024년 11월 21일
3
post-thumbnail

구조 갈아엎기 시작

진행하고 있는 사이드 프로젝트만큼은 이전에 무지성으로 해왔던 프로젝트들과는 달리
코드를 작성할 때 마다 의도(?)와 생각하는 시간을 가지며 찍어내기용 프로젝트가
되지 않기 위해 노력해왔습니다.

그 때 부터였습니다.

유튜브 알고리즘에 프로젝트 구조 / 개선기등.. 유명 컨퍼런스 영상들이 도배되기 시작했습니다.
(저에게 다양한 생각, 고민을 하게 해주신 윗분들 감사합니다.)

저것이 정답이로구나.
맞지 맞지. 나도 똑같이 생각해.
네카라쿠배 개발자 님들이 맞다시잖아~

저는 발표자분들의 프로젝트 상황을 알지도 못하면서 무한한 공감을 합니다.
그리고 제 프로젝트에 도입하기 시작합니다.

멀티 모듈 전환기

첫번째 단계에선 단일 모듈이었던 제 프로젝트는 멀티 모듈로 전환이 되었습니다.

그렇게 멀티 모듈 전환 후 나머지 리팩토링을 끄적이며 계속해서 스스로 만족해갑니다.
그런데 멀티 모듈로 잘 전환 된 건지는 판단할 수 없습니다. 그냥 다른 분들의 설명을 듣고
제 나름대로 해석해서 진행했기 때문이죠.
(지금 생각해보면 그냥 제대로 따라하지도 못한 것 같습니다. ㅎㅎ)

기분이 좋아진 저는 계속해서 리팩토링을 하고 영상을 보면서 이것 저것 따라합니다.

그러다가 갑자기 제가 멀티 모듈을 전환하기로 했던 가장 큰 계기를 돌이켜봅니다.

곧 도입할 관리자 서버 API에서 어차피 Entity, Repository, Service 같은 코드들이 재사용될 것이기에
qc-core라는 모듈에 도메인 로직, 비즈니스 로직을 모듈화 하였는데요.

위 PostReader는 상품(Question)에 대한 게시글을 조회하는 로직을 가지고 있습니다.
일반 유저는 자신이 구매한 상품에 대한 게시글만 조회 할 수 있기 때문에
QuestionPermissionValidator 클래스를 통해 권한 검증을 처리합니다.

그리고 PostService에서는 PostReader 클래스를 호출하여 처리합니다.

비즈니스 로직을 처리하는 PostReader 클래스와 비즈니스 로직을 실행하는
PostService 클래스 모두 qc-core 모듈에 있습니다.

근데 갑자기 의문이 들었습니다.

관리자는 구매한 문제가 아니더라도 관리 차원에서 모든 상품에 대한 Post를 조회 할 수 있어야 할 것인데요.

현재 만들어둔 PostReader와 PostService는 문제 구매 여부 / 권한 조회를 하고 있기에
그대로 사용할 수는 없고 약간 변형을 해야합니다.

  1. PostReader에 getPostDetailForAdmin 메서드를 따로 만든다.
  2. 기존 함수에 분기문을 만들어 관리자라면 권한 검증을 패스한다.
  3. 권한 검증 코드가 빠진 PostManageService, PostManagerReader를 만들어 사용한다.

2번은 아무리봐도 무리인 것 같습니다.
어떻게 어드민이 사용할 법한 함수 하나 하나에 if(admin) {} 이걸 추가할까요...

따라서 저는 결국 3번이 제일 좋다고 생각하여 3번으로 진행해보았습니다.

위와 같이 ADMIN-API 모듈에서 PostManagerController가 요청을 받으면
Core 모듈에 만들어둔 PostManageService, PostManagerReader를 이용해 요청을 처리합니다.

그럼 Post 관련 클래스는 Core 모듈에 위와 같이 존재하게 되게 됩니다.

네 그렇습니다. 아무리 로직이 비슷하더라도 유저와 어드민을 위한 비즈니스 로직은 분명히 다르기에
하나의 코드, 하나의 모듈로 재사용 되기는 힘듭니다. 결국 PostService를 재사용하는 것이 아닌
위 처럼 PostManageService 클래스를 하나 새로 만든 뒤 사용하겠지요.
qc-core 모듈은 단순히 API모듈, Admin-API 모듈에서 사용할 코드들을 모아둔 뚱뚱한 모듈이 되어버렸습니다.

1차 멀티 모듈작업은 단순히 레이어를 모듈화하여 구분 지은 것 그 이상 그 이하도 아니다.

위 한 문장으로 결론을 내립니다.

진짜 공통인가?

이후 저는 또 다시 유튜브, 구글을 통해 다른 분들의 멀티 모듈 구조를 탐색하며
여러 방식을 적용해보았고 결론을 내렸습니다.

당연하게도 컨퍼런스 같은 곳에서 발표하는 내용은 코드의 재사용도 있겠지만
재사용만을 위한 구조는 아니였을 것 입니다.
해당 서비스 구조에 맞추어서 개발자 분들이 설계한 구조이겠지요.
저는 구조만 어설프게 따라했기에 오히려 역효과가 났고 애초에 옳게 따라 한 게 맞는지도 모르겠습니다.

멀티 모듈 전환 계기가 코드의 재사용이였으니 일단 재사용 측면을 우선으로...

그래서 이번에는 여러 컨퍼런스, 블로그의 의견을 참고하되,
저의 생각과 선택을 포함하여 구조를 다시 설계합니다.

qc-application(유저 API) -> Spring Boot + 유저를 위한 비즈니스 로직
qc-admin(관리자 API) -> Spring Boot + 관리자를 위한 비즈니스 로직
qc-domain -> Domain 클래스, JPA Entity, Repository -> 공통적으로 사용되는 클래스

크게 보면 위와 같습니다.


위 사진은 application 모듈의 Post 패키지 내부입니다.

유저가 게시글을 조회할 때에는 자신이 구매한 상품의 게시글만 조회할 수 있는데요.
따라서 유저가 게시글을 조회하는 비즈니스 로직은
1. 상품을 구매하였는지 검사
2. 게시글 조회
위와 같습니다.

따라서 해당 비즈니스 로직은 유저 application만의 고유 비즈니스 로직이기에 application 모듈
PostService가 처리하게 됩니다.

domain 모듈의 post 패키지 내부입니다.
post와 관련된 도메인 클래스, PostRepository는
application 모듈이던, admin 모듈이던 재사용될 수 있습니다.

이처럼 domain 모듈에 클래스를 생성할 때 이 클래스는 유저 API 모듈, 관리자 API 모듈 두 곳에서
사용되는 것일까?를 생각하였습니다.

예를 들어 Post 도메인 클래스는 유저 API 모듈, 관리자 API 모듈 두 곳에서 사용될 수 있습니다.
일반 유저는 특정 상품에 대한 게시글을 조회하며
관리자는 관리 차원에서 상품에 대한 게시글을 조회하기 때문입니다.
또한 Post 클래스 내부에 있는 순수한 도메인 로직(게시글 수정)또한 두 곳에서 사용될 수 있습니다.

Post 관련 데이터를 DB로부터 조회하는 Repository Interface도 유저 API 모듈, 관리자 API 모듈에서
모두 사용될 것입니다.

// User Application PostService

public List<PostListItem> getPostList(Long userId, Long questionId, PagingInformation pagingInformation) {
    if (!postPermissionChecker.hasPermission(userId, questionId)) {
        throw new CustomException(Error.FORBIDDEN);
    }

    return postRepository.getPostList(questionId, pagingInformation);
}

// Admin Application PostManageService

public List<PostListItem> getPostList(Long questionId, PagingInformation pagingInformation) {
    return postRepository.getPostList(questionId, pagingInformation);
}

위 코드처럼 유저 Application과 관리자 Application은 domain 모듈에 있는
Repository와 도메인 클래스등을 이용해서 각자 상황에 맞게 처리하면 됩니다.

결론적으로 qc-application, qc-admin-application 처럼 API를 제공하는 모듈에서는
각자 상황에 맞는 비즈니스 로직을 직접 처리하도록 하였고
qc-domain 모듈에서는 여러 API 모듈에서 공통적으로 사용될 수 있는 클래스들만 존재하게 하였습니다.

일단 합쳐 (Feat.DB 바뀔일은 없을듯)

SpringBoot + Kotlin 멀티 모듈 구성 - 도메인 모듈 분리 #4 - 제미니의 개발실무

기존에는 qc-storage라는 모듈에서 DB와 관련된 작업을 처리하였는데요.
JPA Entity 클래스가 있으며, Repository의 구현체들이 존재했습니다.

domain(공통) 모듈에 DB 의존성이 같이 묶이게 된다면 나중에 DB가 바뀌어 버리면
굉장히 난감해질 수 있다는 것을 많이 들었기에 당연히 분리 했었습니다.
또한 domain 모듈은 외부와 상관없이 순수해야 한다는 말도 많이 들었습니다...

고민 끝에 qc-domain에서 DB 의존성을 처리하기로 했습니다.

먼저 기존에 qc-storage 모듈은 Repository 구현체가 있었고 도메인 클래스와 매핑될 수 있는
JPA Entity 클래스가 있었는데요.

아직까지 저는 storage 모듈을 분리하였을 때 장점을 느끼지 못했고 또한 단점을 느끼지 못했습니다.
그나마 DB와 관련된 클래스가 모듈로 빠져 있었기에 프로젝트가 조금 깔끔해졌다 정도인 것 같습니다.

만약 추후에 Admin-Application 모듈을 추가한다면 qc-storage 모듈 의존성을 추가해줘야 합니다.
(build.gradle에 한 줄을 적으면 끝나는 것이긴 합니다...)

그리고 이 프로젝트의 DB는 변경되지 않을 것이라고 확신하기에 그냥 domain(공통) 모듈에 합치기로 했습니다.
어찌보면 repository 구현체 또한 여러 모듈에서 사용되는 공통 코드이기 때문입니다.


그렇게 domain(공통) 모듈에 JPA Entity와 Repository 구현체를 포함하게 됩니다.

레이어드 아키텍처 규칙 정하기

지속 성장 가능한 소프트웨어를 만들어가는 방법

항상 레이어드 아키텍처를 사용하여 코딩을 해온 저는 DDD, 헥사고날 아키텍처에 관심이 없었습니다.
그러나 어느 순간 레이어드 아키텍처는 절차지향?.. 트랜젝션 스크립트??.. 라는 별명이 붙여져
나도 빨리 배워야하나 고민했던 순간 저에게 엄청난 깨달음을 준 포스팅입니다.

저는 해당 포스팅을 보기전까지 위 포스팅 내용에 안 좋은 레이어드 아키텍쳐 코드의 예시처럼
Service에 온갖 필요한 Repository들이 주입되어 로직을 수행하는 형태였습니다.

비즈니스 로직이 단순하다면 상관이 없지만 위 코드처럼 복잡한 로직이라면 남이 보았을 때
이해하기 어려울겁니다.

저는 포스팅을 보고 난 이후 Service Layer에서는 Repository들을 직접 의존하지 않고
Implement Layer 계층을 구분지어 실질적인 처리 로직을 작성해야지 라고 마음 먹었습니다.

현재 프로젝트에는 Creator를 구독하는 기능이 있는데요. 구독을 요청하면 내부적으로는
이미 해당 Creator를 구독 중인지, 구독을 하려는 Creator가 정상적인 상태인지 검증하는 로직이 필요합니다.
기존 같았으면 SubscribeService에서 전부 처리했겠지만 그렇게 되면 CreatorRepository를 주입해야겠죠.
따라서 위에서 Service는 구독이라는 로직을 실질적으로 처리하는 SubscribeProcessor를 사용하게 됩니다.

SubscribeProcessor에서는 크리에이터 구독을 처리하기 위해 필요한 의존성들을 주입하고
내부적으로 필요한 구현 로직을 처리합니다.
(이미 해당 Creator를 구독 중인지, 구독을 하려는 Creator가 정상적인 상태인지)

위처럼 Service Layer에서는 단순히 어떤 흐름으로 비즈니스 로직이 처리 되는지 이해하기 쉽고
Implement Layer에서는 실제 비즈니스 로직을 처리하기 위한 코드가 있게 됩니다.

위 코드는 특정 상품(question)에 대한 리뷰를 조회하는데 사용되는 ReviewService 입니다.
정해둔 규칙에 따라 Service 레이어에서는 Repository를 직접 사용하지 않고 Implement Layer를 사용해야합니다.

QuestionReader는 추가적인 로직은 없으며 QuestionReviewRepository 메서드를 반환합니다.

위와 같은 단순한 CRUD 작업을 위해서 무조건 ImplementLayer라는 하나의 계층을 더 만들다보니
하나의 도메인에서는 Appender, Reader, Updater, Remover 등등 4개의 클래스가
필수적
으로 생기게 되었습니다.

물론 복잡한 비즈니스 로직의 구현이라면 분리하는 게 훨씬 좋다는 것을 느꼈지만
복잡하지 않은 비즈니스 로직도 과연 Implement Layer 계층을 만들어야 할까 고민이 되었습니다.

그렇게 다시 저만의 규칙을 만들게 되었습니다.

Service Layer에서 Repository를 주입할 수 있지만 동일한 도메인의 Repository이여야 한다.

PostService -> PostRepository만 주입 가능
ReviewService -> ReviewRepository만 주입 가능
SubscribeService -> SubscribeRepository만 주입 가능

새로운 규칙을 통해 다시 작성한 ReviewService입니다.
getTotal, getQuestionReviews 메서드를 보면 Reader라는 Implement Layer가 아닌
직접 Repository를 이용하여 처리하고 있음을 볼 수 있습니다.

그와 달리 리뷰를 등록하는 register 메서드는 questionReviewRegister라는 Implement Layer를
이용하고 있습니다.

리뷰를 작성하는 세부 로직은 해당 유저가 해당 상품을 구매하였는지, 이미 리뷰를 작성하였는지,
상품이 존재하는지 등 여러 검증 로직이 필요한데요.

해당 유저가 상품을 구매하였는지에 대한 기록은 UserQuestion이라는 도메인 영역,
상품이 존재하는지에 대한 기록은 Question이라는 도메인 영역
따라서 ReviewService에는 QuestionRepository, UserQuestionRepository를 주입할 수 없습니다.

따라서 여러 도메인 영역이 필요한 복잡한 비즈니스 로직은 위와 같이 Implement Layer에서 처리하도록 합니다.


Service 레이어에서 비즈니스 로직을 처리할 때 여러 도메인의 복합적인 처리가 필요하다면
Implement Layer에 해당 로직을 처리하도록 하고
단순히 동일한 도메인의 Repository를 이용하는 로직이라면 Service에서 직접 처리하도록 하였습니다.

최종...

최종적인 프로젝트 구조도 저의 판단과 생각으로만 이루어진 것처럼 말을 하긴 했지만
여러 유튜브 영상과 블로그 포스팅의 영향도 많았습니다...

프로젝트 구조는 여러 인사이트를 참조하되 최종적으로는 내가 판단하자.
그분들에게 정답이였지만 내 프로젝트에선 정답이 아닐 수도 있다.
그리고 완벽하려 하지말자. 모든 것을 만족하는 구조는 없다.
그니까 적당하게 트레이드 오프 하자.

위와 같은 깨달음을 얻게 되었습니다.

이젠 안바뀌겠지...

2개의 댓글

comment-user-thumbnail
2024년 11월 22일

잘봤습니다!! 혹시 해당 프로젝트 레포를 구경할 수 있을까여...? 저도 같은 고민들을 하고 있어서..

1개의 답글