[Spring] 유사 Modular Monolithic Architecture 적용기 (1)
기존 멀티 모듈 구조
아키텍처구조_최종_진짜최종.spring
프로젝트 아키텍처 구조 지옥
velog.io
제가 진행중인 개인 프로젝트는 멀티 모듈 구조로 구성되어 있습니다.
멀티 모듈 구조를 채택한 이유는 위 포스팅에 나와 있는 것 처럼 재사용이 주 목적이였습니다.
예전에 진행했던 프로젝트에서 유저 애플리케이션과, 관리자 애플리케이션이 분리 되어 있었는데 이런 경우 도메인 클래스,
Entity 클래스, Repository 등 중복되는 클래스가 생기게 되며 하나 하나 복사 붙여넣기로 관리하기 힘들었던 경험이 있었기 때문입니다.
프로젝트에서도 추후 관리자 API를 만들 생각이 있었기에, 그리고 좀 더 유지보수가 쉬워지도록 멀티 모듈 구조로 전환하기로 했습니다.
- qc-application : 유저가 사용하는 API를 제공하는 Spring Boot Application
- qc-core : 도메인 클래스, 도메인 로직, 엔티티 클래스, 레포지토리가 포함되어 있는 공통 모듈
- qc-config : Github Submodule을 이용하여 서버 설정에 필요한 환경변수
- qc-external-pg-api: 외부 PG 솔루션 API를 이용하여 결제를 처리하는 모듈
- qc-lock-manager : Redis + Redisson을 이용하여 분산락을 처리하는 모듈
- qc-social-api : KAKAO, NAVER, GOOGLE OAUTH API를 통해 소셜 서비스의 인증을 처리하는 모듈
- qc-logging : Request, Response 로깅 및 ELK 스택 처리
기존 멀티 모듈 구조는 위와 같습니다. 얼핏보면 레이어를 기준으로 분리된 것 처럼 보이기도 합니다.
qc-core 모듈의 경우 내부적으로 도메인 별로 패키지가 분리되어 있습니다.
각 도메인 별로 도메인 클래스, JpaEntity, Repository등이 존재합니다.
qc-application은 Spring 애플리케이션이 실행되는 모듈로 HTTP API를 제공하는 모듈입니다.
내부적으로는 비즈니스 도메인에 따라 패키지가 분리되어 있습니다.
문제 결제 API를 처리하기 위해서 qc-application은 qc-core의 도메인 클래스들을 적절하게 조합해서 비즈니스 로직을 구성합니다.
추후에 만약 관리자 API를 위해 qc-admin-application 모듈을 만든다면 qc-admin-application 모듈은 qc-core를 재사용하여
관리자를 위한 비즈니스 로직을 구성하고 API를 제공하면 됩니다.
기존 멀티 모듈 구조의 문제점
최근 며칠동안 비즈니스 로직의 테스트 코드를 작성하면서 도메인 간 의존성이 너무 많이 꼬여있는 것 같다고 느끼게 되었습니다.
비즈니스 로직의 테스트 코드를 작성하게 되는 경우 해당 비즈니스가 의존하고 있는 도메인을 적절하게 Mocking하거나 처리해줘야
합니다. 그렇기에 비즈니스 로직을 작성할 때에는 못 느꼈던 복잡한 의존성 관계가 테스트 코드를 작성하는 시점에 느껴질 수 있습니다.
실제로 문제 결제와 관련 된 비즈니스 로직을 처리하는 QuestionPaymentService의 경우 User, Question, UserPoint, Creator
도메인에 의존성을 가지고 있음을 알 수 있습니다.
또다른 예시로는 게시글과 관련된 PostService의 경우 User, Creator, Question, UserQuestion 도메인 의존성을 가지고 있습니다.
물론 결제는 복잡한 도메인이 맞고, 게시글 도메인 또한 문제 구매 여부에 따라 권한이 부여되는 약간 복잡한 비즈니스 로직이 있습니다.
복잡한 비즈니스 로직을 처리해야할 수록 다양한 도메인에 의존성을 가지는 것은 당연하지만, 이러한 의존성을 쉽게 끊어 낼 수 있는 구조는 되어야한다고 생각했습니다. 그렇게 저는 의존성이 많다는 것이 문제라기보단 의존하는 방식에 문제가 있다고 느끼게 되었습니다.
기존 멀티 모듈 구조에서 qc-application은 qc-core 모듈을 의존하고 있습니다.
그런데 qc-core는 모든 도메인을 포함하고 있기에 qc-application 또한 모든 도메인을 다룰 수 있는 모듈이 됩니다.
그런데 이런 만능 모듈을 사용하다 보면 나도 모르게 나중에 끊어내기 어려운 의존성 관계를 만들게 됩니다.
예시로 Querydsl을 통해 게시글 정보를 조회하는 Repository의 구현 코드입니다. 게시글 조회에 필요한 데이터는 작성자 정보,
문제 정보이기 때문에 자연스럽게 join을 사용하면서 Question 도메인과, User 도메인의 Qclass를 참조하게 됩니다.
그런데 만약 추후에 MSA로 전환하게 된다면 그리고 유저 정보 DB와 게시글 DB가 분리된다면 위와 같은 코드는 사용할 수 없게 됩니다.
물론 MSA 전환과 DB 분리는 발생하지 않는다 하더라도 끊어내기 어려운 의존성은 나중에 유지보수가 힘들지 않을까라는 생각이 들게
되었습니다. 또한 의존성이 많다는 것은 그만큼 책임도 많다는 의미일수도 있습니다.
위 코드의 경우도 Post Repository는 진짜 순수 Post 도메인과 관련 된 데이터만을 조회하는 책임을 가져야 하지만, Join을 통해
아예 다른 Question, User 도메인을 join 합니다.
그래서 저는 기존 레이어 기준으로 분리된 멀티 모듈 구조를 도메인 별로 분리된 멀티 모듈 구조로 전환하기로 하였습니다.
도메인 별로 모듈을 구성하게 되면 지금 당장 관리 포인트는 늘어나지만, 도메인이 가져야 할 최소한의 책임을 가질 수 있도록 할 수 있고
추후에 MSA 분리를 하는 경우 쉽게 전환할 수 있을 것 같다는 생각이 들었기 때문입니다.
Modular Monolithic Architecture
Modular Monolithic Architecture는 일반적으로 MSA 전 단계라고도 불립니다.
Spring 환경이라면 실제로 런타임에는 Spring Container에 각 도메인별 모듈들이 모두 로딩되어 하나로 동작하지만
코드를 작성할 때에는 별도의 모듈에 위치하고 있으며 각 모듈들은 API 인터페이스를 통해 통신합니다.
만약 Review 도메인에서 Booking 관련 정보가 필요하다면 Booking 모듈의 API를 이용하여 정보를 조회합니다.
간단하게 정리하면 도메인 별로 모듈을 구성하여 도메인에 관련된 비즈니스 로직을 처리하고, 다른 도메인에 의존해야 한다면 API를
통해 통신하는 아키텍처 입니다.
저는 Modular Monolithic Architecture 구조를 최종적인 목표로 정하고 일단은 1차적으로 기존 qc-core 모듈을 도메인 별로
분리하기로 했습니다.
외부 도메인 Querydsl Qclass 의존성 제거하기
위에서 언급된 것처럼 기존에는 ARepository에서 관련된 데이터를 한번에 조회하기 위해 다른 도메인 B, C의 Qclass를 사용하여
한번에 조회했습니다. (PostRepository -> User, Question)
하지만 이런 아얘 다른 도메인의 Qclass 의존성은 관리하기 힘들다는 것을 위에서 느낄 수 있었습니다.
그렇기에 qc-core 각 도메인 Repository 구현체에서 외부 도메인의 Qclass를 먼저 제거하기로 하였습니다.
override fun findByUserId(userId: Long): List<CartItemDetail> {
return jpaQueryFactory.select(
Projections.constructor(
CartItemDetail::class.java,
cartItemEntity.id,
questionEntity.id,
questionEntity.questionContentEntity.title,
questionEntity.questionContentEntity.thumbnail,
userEntity.userInformationEntity.name,
questionEntity.questionContentEntity.subject,
questionEntity.questionContentEntity.price
)
)
.from(cartItemEntity)
.where(cartItemEntity.userId.eq(userId))
.leftJoin(questionEntity).on(questionEntity.id.eq(cartItemEntity.questionId))
.leftJoin(creatorEntity).on(creatorEntity.id.eq(questionEntity.creatorId))
.leftJoin(userEntity).on(userEntity.uid.eq(creatorEntity.userId))
.orderBy(cartItemEntity.id.desc())
.fetch()
}
만약 장바구니 도메인의 Repository에서 Querydsl을 통해 아얘 다른 도메인 Question, User를 join 하고 있었다면
override fun findByUserId(userId: Long): List<CartItem> {
return jpaQueryFactory.select(cartItemEntity)
.from(cartItemEntity)
.where(cartItemEntity.userId.eq(userId))
.orderBy(cartItemEntity.id.desc())
.fetch()
.stream()
.map(CartItemEntity::toModel)
.toList()
}
이제는 순수 장바구니 도메인만을 반환합니다.
하지만 이렇게 되면 join을 하지 못하기 때문에 장바구니에 담은 상품에 대한 정보는 얻을 수 없습니다.
fun getCartItemDetails(userId: Long): List<CartItemDetail> {
val cartItems = cartItemRepository.findByUserId(userId)
val questions = questionRepository.findByQuestionIdIn(cartItems.map { it.questionId })
val questionMap = questions.associateBy { it.id }
val creators = creatorRepository.findByIdIn(questions.map { it.creatorId })
val creatorMap = creators.associateBy { it.id }
val creatorUserMap = userRepository.findByUidIn(creators.map { it.userId }).associateBy { it.uid }
val cartItemDetails = mutableListOf<CartItemDetail>()
for (cartItem in cartItems) {
val question = questionMap.getValue(cartItem.questionId)
val creator = creatorMap.getValue(question.creatorId)
val creatorUser = creatorUserMap.getValue(creator.userId)
cartItemDetails.add(
CartItemDetail(
cartItem.id,
question.id,
question.title,
question.thumbnail,
creatorUser.userInformation.name,
question.subject,
question.price,
)
)
}
return cartItemDetails
}
비즈니스 로직을 포함하고 있는 장바구니 API 모듈에서 Question, Creator, User 모듈을 사용해 데이터를 직접 조합하도록 합니다.
최종적으로 위 형태와 같습니다. 기존에는 단순히 CartRepository에서 Querydsl을 통해 4개의 도메인과 관련된 테이블을 한번에
조회 했다면 이제는 각 도메인 별로 별도로 데이터를 조회해서 조합하는 방식으로 변경되었습니다.
위 방식으로 변경하고 나게 되면 기존에는 Join을 통해 한번에 데이터를 조회했는데 이제는 따로 조회를 하고 데이터를 직접 가공하니까
DB 통신으로 인한 오버 헤드가 발생할 수 있다는 생각이 들 수 있습니다.
그런데 프로젝트 처음에는 단일 DB로 사용되고 있었기 때문에 join이 가능했던 것일뿐, 일반적으로 규모가 커지다보면 도메인 별로
DB가 분리되는 것이 일반적일 수 있습니다. 물론 규모에 따라 다르긴 하겠지만요.
저의 사이드 프로젝트 / 소규모 프로젝트면 단일 DB로 충분할 수 있습니다.
하지만 단일 DB의 경험은 지금까지 계속 해왔고 프로젝트를 점점 고도화 하는 과정에서 나중에 DB도 도메인 별로 분리할 수 있습니다.
그렇게 되면 결국 join은 사용하지 못하게 됩니다.
그리고 장바구니 비즈니스 로직처럼 서로 다른 도메인 간의 데이터를 조합하는 통신에서 발생하는 오버헤드는 비정규화를 통해 관련 정보도 같이 저장하는 방식, CQRS 패턴을 도입하는 방식, 관련 데이터 캐싱 방식처럼 다양한 방향으로 풀어나갈 수 있습니다.
이외에도 여러가지 트레이드 오프 요소가 있겠지만 지금은 도메인 별 모듈분리에 중점을 두었습니다.
참고로 프로젝트에서 아예 Querydsl join을 사용하지 않는 것은 아닙니다.
예를 들어 Question 도메인 모듈은 문제 정보 도메인과 유저가 구매한 문제라는 도메인 question,userquestion을 포함하고 있습니다.
override fun getUserQuestions(questionFilter: QuestionFilter): List<UserQuestionContent> {
val parent = QQuestionCategoryEntity("parent")
val child = QQuestionCategoryEntity("child")
return jpaQueryFactory.select(
Projections.constructor(
UserQuestionContent::class.java,
questionEntity.id,
questionEntity.creatorId,
questionEntity.questionContentEntity.title,
parent.title,
child.title,
questionEntity.questionContentEntity.thumbnail,
questionEntity.questionContentEntity.questionLevel,
questionEntity.questionContentEntity.fileUrl,
questionEntity.questionContentEntity.explanationUrl
)
)
.from(userQuestionEntity)
.where(userQuestionEntity.userId.eq(questionFilter.userId))
.leftJoin(questionEntity).on(questionEntityJoinCondition(questionFilter))
.leftJoin(child).on(child.id.eq(questionEntity.questionContentEntity.questionCategoryId))
.leftJoin(parent).on(parent.id.eq(child.parentId))
.offset(questionFilter.pagingInformation.offset.toLong())
.limit(questionFilter.pagingInformation.size.toLong())
.fetch()
}
그리고 만약 유저가 자신이 구매한 문제를 조회 해야 한다면 UserQuestionRepository의 getUserQuestions를 사용하게 되는데
여기에서는 questionEntity를 join 합니다.
제목에서도 나와있듯이 외부 도메인의 Qclass 의존성을 제거하는 것이지, 동일한 도메인의 Qclass의 의존성은 제거하지 않습니다.
따라서 Question과 UserQuestion 도메인은 동일한 모듈에 있는 도메인이며 Question, UserQuestion 데이터는 하나의 DB에서
관리될 수 있기 때문에 Join이 가능하도록 하였습니다.
그렇게 여러 도메인의 Repository에서 외부 도메인의 Qclass 의존성을 제거하는 작업을 진행했습니다.
도메인 별 모듈 구성
Qclass 분리 작업이 완료 된 이후 최소한의 단위로 도메인을 분리하였습니다.
도메인이 많다보니 qc-domain이라는 Parent Gradle Module을 만들고 그 내부에 Child Gradle Module을 만들었습니다.
도메인의 범위는 너무 많은 역할, 책임을 가지지 않도록 최대한 작게 가져가려고 했습니다.
각 모듈들은 일반적으로 기존 멀티 모듈 구조의 qc-core 모듈과 동일하게 domain 클래스, JpaEntity, Repository로 구성됩니다.
기존의 qc-core 그리고 현재 qc-domain은 재사용이 중점이기에 API 모듈에서 재사용 될 클래스들로 구성하는데 중점을 두었습니다.
공통 모듈
여러 도메인 모듈, API 모듈에서 공통적으로 사용될 수 있는 클래스들을 모아둔 모듈입니다.
API 모듈들에서는 Spring Security의 인증 객체 정보를 사용해야하는 경우가 있으며, 페이징 처리를 위한 객체, event 발행 등
공통적으로 사용되는 클래스들로 구성되어 있습니다.
이러한 공통 모듈은 점점 커지게 되어 일명 GOD 모듈이 될 수 있기에 정말 여러 곳에서 필요한 클래스들만 담도록 하고 있습니다.
API 모듈
qc-api 모듈은 기존 멀티 모듈 구조에서 qc-application과 비슷한 역할을 합니다.
API를 처리하기 위한 비즈니스 로직을 가지고 있으며 도메인 모듈들을 사용하여 API 요청을 위한 비즈니스 로직을 처리합니다.
기존 qc-application 모듈은 모든 비즈니스 도메인 영역의 API를 처리하고 있었기에 꽤 무거운 모듈이었습니다.
따라서 qc-api 모듈도 qc-domain 모듈처럼 상위 모듈이 내부적으로 또 여러 개의 하위 모듈을 가지는 구조를 가지도록 하였습니다.
api 모듈은 핵심 기능으로 분리하였습니다. 예를 들어 인증을 위한 로그인, 리프레시는 auth-api, 크리에이터 정보 조회 관련은
creator-api, 결제는 qc-payment-api, 상품 조회, 구매, 리뷰는 qc-store-api 등 api 모듈은 큰 범위의 범주로 분리하였습니다.
api 모듈은 controller를 통해 API를 제공하며 service는 implement를 조합하여 비즈니스 로직을 처리합니다.
qc-api-container는 모듈 이름 처럼 Spring Container입니다. qc-api-container는 각 api 모듈들을 로딩해서
Spring Container에 로딩합니다. 왜냐하면 각 api 모듈들은 자체적으로 Spring Application은 아니기 때문입니다.
또한 qc-api-container는 모든 api 모듈들이 공통적으로 사용해야하는 filter 및 인증 처리, Exception Handler, 로깅 관련 설정
클래스들이 포함되어 있습니다.
qc-api-container 모듈에서 filter, security를 설정하면 모든 api 모듈에서 현재 요청의 인증 객체를 사용할 수 있게 됩니다.
간단하게 표현하면 qc-api-container는 Spring Container로 동작하며 컴포넌트 스캔을 통해 각 api 모듈들에 있는
Controller를 Spring Application에 올려 요청을 처리하는 방식이라고 할 수 있습니다.
Event 모듈
프로젝트에서는 문제 결제 후처리, 결제 실패 후처리, 리뷰 통계, 크리에이터 통계 등 특정 비즈니스 로직이 AWS SNS + SQS 조합으로
비동기 이벤트로 처리되는 부분이 있습니다. 기존에는 단순히 Spring Container의 역할을 하던 qc-application 모듈이 다 처리했지만
변경 된 구조에서는 어느 모듈이 이벤트를 처리해야 할 지, AWS SNS, SQS 관련 설정은 누가 가지고 있어야 할 지 애매하게 되었습니다.
일단은 qc-event라는 모듈을 별도로 분리하였고 이 모듈이 AWS SNS, SQS 관련 설정을 가지고 있도록 하였습니다.
따라서 이벤트를 발행하는 로직을 포함하는 API 모듈은 qc-event 모듈을 통해 이벤트를 발행합니다.
이벤트 발행도 이벤트 객체라는 인터페이스가 필요하기 때문에 qc-event에는 발행되는 이벤트 객체의 대한 정의가 model 패키지에
존재합니다. 따라서 추후에 새로운 이벤트가 추가 된다면 qc-event 모듈에 추가 됩니다.
그리고 아직까지 고민중인 부분이 있는데 SQS를 수신해서 로직을 처리하는 listener는 어디에 위치해야 하는가 입니다.
일단은 qc-event에서 임시로 처리하도록 해두었지만, 만족스러운 설계는 아니라고 생각이 듭니다.
예시로 문제를 구매했을 때 QuestionPaymentEvent가 발행됩니다.
QuestionPaymentEvent가 발행되면 문제 판매량, 크리에이터 판매량 통계 업데이트가 진행 됩니다.
문제의 통계 정보를 관리하는 도메인은 QuestionMetadata입니다.
사실 이 통계 데이터는 조회와 관련 된 API 모듈에서 정렬을 위해 사용되는 것이 주 사용 목적입니다.
이 QuestionMetadata를 직접적으로 관리하거나 수정하는 API 모듈은 없기에 자연스럽게 QuestionMetadata 도메인을 가지고 있는 question-domain 모듈에서 SQS을 수신해서 처리해야할 것 같은 느낌이 듭니다.
그러나 일반적으로 domain 모듈들은 순수한 도메인 로직을 가지고 있어야 하는 모듈이라고 정의됩니다.
따라서 AWS의 SQS Listener의 의존성을 domain 모듈이 가지게 된다면 이것은 도메인 모듈인가 라는 의문이 들 수 있습니다.
(저는 아직까지도 잘 모르겠네요. 사실 그렇게 순수하게 도메인 모듈을 만들지도 않았습니다.)
어찌되었던 문제 구매로 인해 판매 통계가 업데이트 되는 것을 도메인 모듈이 처리하기 보단, 그냥 별도의 EventListener 모듈을 만드는게 좋다고 생각이 들고 있긴 합니다. 그래서 추후에 별도의 모듈로 분리할 예정입니다.
일단은 임시로 qc-event에서 처리하도록 해야겠습니다.
아직 멀었다.
이렇게 일단 1차적인 도메인 별 모듈 분리는 마무리 되었습니다. 그런데 지금 구조는 도메인 모듈 qc-domain과 qc-api가 분리되어 있는데 이렇게 되니 결국은 여러 도메인 모듈이 필요한 api 모듈에서는 여러 도메인을 의존하게 됩니다.
따라서 아직까지는 Modular Monolithic Architecture라기 보단 api 모듈이 파사드 패턴처럼 필요한 도메인 로직들을 조합해서 처리하는 것에 더 가깝습니다.
따라서 qc-api와 qc-domain을 분리하지 않고 API도 제공하면서 도메인 모듈을 가지고 있는 형태로 변경되어야 할 것 같습니다.
예를 들어 qc-store-api가 장바구니 관련 API를 대신 제공하지 않고 qc-cart 모듈이 직접 장바구니와 관련된 API를 제공하며
다른 모듈이 사용할 수 있도록 Internal API도 제공하는 방식으로요.
이렇게 api 모듈과 도메인 모듈을 분리하지 않는 구조에 대해서 좀 더 고민하고 생각해보면서 2차 작업을 진행 해봐야겠습니다.