사내 프로젝트를 처음 맡았을 때 싱글 모듈로 구조를 잡아 시작했고,
패키지 내 응집도가 견고해졌을 시점에 멀티 모듈로 분리를 하여 멀티 모듈 시스템이 만들어졌습니다.
그래서 현재는 지속 성장 가능한 시스템을 만들기 위해 모듈 마다 클린 아키텍처 개념을 포함시켜 개선해 나가고 있습니다.
클린 아키텍처와 멀티 모듈을 적용했으나 실제 구현 단계에 들어갔을 때, 수많은 난관이 발생했고 질문도 많이 떠올랐습니다.
모듈이 가지는 모델 주도권을 어떻게 가져가고 공유할 수 있는지, 모듈마다 참조는 어디까지 하면 좋을지 등등 여러 고민이 발생했습니다.
이런 개념을 바로잡고자 문서를 작성하게 되었습니다.
소스 코드 방향은 외부에서 내부로
클린 아키텍처를 적용하는 이유를 찾아보면 내부를 최대한 안정적으로 유지하고, 외부를 변경 시켜야할 때는 내부를 건드리지 않고도 대응할 수 있도록 하기 위함이라고 소개합니다.
우리 시스템은 사실 거대한 시스템도 아니고 큰 변화가 당장은 발생하지는 않을 것이라고 생각합니다.
그런데도 적용한 가장 큰 이유는 레이어간 방향을 컨벤션화할 수 있게 도움을 주는 장치라고 생각했습니다.
이러한 장치는 코드 리뷰나 협업 시 제 3자가 코드를 빠르게 찾아갈 수 있게 도와주고 코드를 추가할 때 클래스가 어디에 위치하면 좋을지 판단을 내릴 수 있게합니다.


그렇다면 모듈 의존 관계에서의 방향은?
복잡하지 않은 싱글 모듈에서는 좋은 사례들을 통하여 어느정도 제어를 할 수 있었습니다.
그런데 멀티 모듈을 적용하면서 경계가 명확하게 나눠지게되었고, 모듈간 필요한 데이터를 어떻게 참조하면 좋을지 고민을 하게되었습니다.
모듈 내 레이어를 간소화시켜 소개하겠습니다.

Contract 모듈, Member 모듈이 있습니다.
Contract 모듈은 Member 정보를 필요로 하므로 Member 모듈을 의존하게 됩니다.
그런데 위 그림처럼 경계를 넘어서 다른 모듈의 레이어를 마음껏 침범하면 의존하는 모듈의 변경사항으로 부터 자유롭지 못한 상황이 발생하게 될뿐더러 다른 모듈의 레이어 방향이 역전되는 상황이 발생합니다.
그러므로 위 의존 방향은 옳지 않다고 판단하였습니다.
타 모듈 애플리케이션 레이어간 의존 ❌

위 문제를 회피하고자 모듈 내 Application 서비스 간에 데이터를 주고 받는 상황이 발생할 수 있습니다.
그러나 위 상황도 문제가 있습니다.
- 우리의 Application 레이어는 한 모듈이 처리할 수 있는 사용자 액션(유즈케이스), 비즈니스 레이어이며 모듈간 데이터를 제공하는 레이어로 보기에는 애매하다고 판단하였습니다.
- 모델을 제공하는 곳이 변경되면 마찬가지 의존하는 모듈의 변경사항이 크게 발생합니다.
- 제공되는 모델을 그대로 사용했을 때 순응하기 어려운 상황이 발생할 수 있습니다.
통합 레이어 사용 ✅
모듈간 통신이 가능한 통합 레이어를 사용하는 방법입니다.

DDD에서 소개하는 모델 공유 패턴 중 일부를 사용하였습니다.
DDD - 도메인 주도 설계 첫걸음, 전략적 설계 - 2
이전 글에서 DDD가 무엇인지 간략히 알아보았다.그중에서도 바운디드 컨텍스트에 대한 개념을 살펴보았는데,이번 글에서는 각 바운디드 컨텍스트 간에 모델을 어떻게 공유하고 상호작용하는지
noose.tistory.com
OHS(Open Host Service): 범용적인 API를 제공, 다른 한쪽에서 제공된 규격으로 사용할 수 있다.
ACL(Anticoruption Layer): 충돌 방지 계층, 한 시스템에서 다른 시스템으로 변환시켜 중재한다.
- OHS/ACL 개념을 적용하면 Contract 에서 처리하는 ‘사용자’ 개념과 Member 에서 처리하는 ‘사용자’개념이 다른 상황에서 변경에 대한 최소한의 영향을 받도록 설계할 수 있고 각 도메인에 맞게 모델을 사용할 수 있게됩니다.
- OHS가 제공하는 규격이 수정된다 하여도 ACL 변환 레이어에서만 에러가 발생하므로 영향도를 줄여볼 수 있습니다.
- 타 레포지토리 분리, 시스템 분리가 발생하여 내부 통신이 불가능하여도 ACL 레이어에서만 변경 사항(HTTP 통신, gRPC 통신)이 발생하므로 영향도를 줄여볼 수 있습니다.
성숙한 시스템

완층 지대(ACL)를 두고 개발을 한다면 다른 모듈의 의존도를 최소화하면서 개발을 진행할 수 있습니다.
단기적으로 봤을 때, 시스템 유지보수성에 정말 이점이 있을까? 하는 의문이 들 수 있습니다.
그러나 장기적 관점에서 보면, 유지보수성과 확장성을 확보하기 위해서는 이 정도의 노력은 필수라는 점이 증명되어왔습니다.
실제로 우리가 멀티 모듈로 분리하는 과정과 리팩토링 같은 업무를 진행할 때 이러한 준비가 되어있지 않아 어려움이 많았습니다.
그래서 빠르고 정확하게 만들기 위해서는 올바르게 만들며 J 곡선을 그릴 수 있는 방향으로 나아가는 것입니다.
OHS/ACL 간단한 코드 예제
- Feasibility 모듈에서는 Member 이메일만 가지고 있는 상황
- 견적서 이력 조회시 Member 이름이 추가로 필요하다.
OHS

- Member 모듈에서 타 모듈에서 사용할 수 있도록 표준 규격을 공표한다.
/**
* Member 모듈의 OHS
*/
@Transactional(readOnly = true)
@Service
class MemberIntegrationService(
private val jdslRepository: JdslRepository,
) {
fun getMembers(emails: List<String>): List<MemberDto> {
return jdslRepository.findAll {
selectNew<MemberDto>(
path(MemberEntity::id),
path(MemberEntity::name),
path(MemberEntity::email),
).from(entity(MemberEntity::class))
.whereAnd(
path(MemberEntity::email).`in`(emails)
)
}
}
fun getMember(email: String): MemberDto? {
return jdslRepository.findOne {
selectNew<MemberDto>(
path(MemberEntity::id),
path(MemberEntity::name),
path(MemberEntity::email),
).from(entity(MemberEntity::class))
.whereAnd(
path(MemberEntity::email).eq(stringLiteral(email))
)
}
}
}
ACL

- OHS에서 제공하는 모델을 받아 Feasibility 모듈에서 사용하는 도메인 모델(FeasibilityMember)로 변환한다.
// Member ACL
@Component
class MemberFinder(
private val memberIntegrationService: MemberIntegrationService,
) {
fun getMembers(emails: List<String>): List<FeasibilityMember> {
val members = memberIntegrationService.getMembers(emails)
return members.map {
FeasibilityMember( // OHS에서 제공하는 규격을 Feasibility 모듈에서 사용할 수 있는 모델로 변환한다.
id = it.id,
email = it.email,
name = it.name,
)
}
}
}
ACL을 통한 변환된 모델 활용
@Transactional(readOnly = true)
@Service
class EstimateAuditReader(
...
private val memberFinder: MemberFinder, // ACL 주입
) : FindEstimateHistory {
// 이력 목록 조회 유즈케이스
override fun all(estimateId: Int): List<EstimateRevisionResponse> {
val revisions = 이력조회()
val emails = revisions.map { it.entity.modifiedBy }
// ACL 레이어에 사용자 정보 요청
val emailToMember: Map<String, FeasibilityMember> = memberFinder.getMembers(emails)
.associateBy { it.email }
return revisions.map {
val entity = it.entity
EstimateRevisionResponse(
releaseVersion = entity.releaseVersion,
progressState = entity.progressState,
modifiedAt = entity.modifiedAt,
modifiedByEmail = entity.modifiedBy,
modifiedByName = emailToMember[entity.modifiedBy]?.name ?: "",
modifiedReason = entity.updateReason
)
}
}
}