pooney
article thumbnail

프로젝트를 진행하면서 Gradle 멀티 모듈을 통한 프로젝트를 구성을 하는데 매번 구성하던 방식이 아닌 DDD관점에서 구성하면서 공부한 내용을 적어 보려고 합니다. 

 

 

 

 

 

 DDD 아키텍처 

 

 

DDD는( Domain Driven Desig) 는 비즈니스 중심의 모델링으로 개발하는 기법입니다. 

보통 비즈니스 로직이 있는 시스템에서는 코드가 비즈니스 의미를 잘 표현해야 하고 복잡한 도메인을 명확하게 정의하고 구조화하는 개념입니다. 

 

 

 

 

DDD에서 자주 언급되는 계층은 Presentation → Application → Domain → Infrastructure 으로 이루어져 있습니다. 
아래는 제가 생각하는 각 계층의 역할을 간단하게 적어 봤습니다. 

 

 

[Presentation] 
- 사용자 인터페이스 역할로 흔히 사용자의 요청 수신, 응답반환 하는 계층 입니다. 
ex) Controller, Request dto, Response dto


[application]
- 비즈니스 로직을 관리 하고 각종 서비스 조율,조합 하는 계층입니다. 
ex) service , processor, manage, facade 


[domain]
- 도메인 관련 규칙 및 엔티티 도메인 서비스들 관리하는 계층입니다. 
ex) entity, repository(interface)

[infrastructure]
- 외부 연동 및 영속석 관련 계층입니다. 
ex)  jpa, redis, kafka, image uploader, repository(구현체)

 

[common]

- 계층과 독립적인 어떠한 계층과도 종속적이지 않은 계층 

ex) 공통 dto, config, type, util

 

 

 

 

간단하게 디자인에 대한 개념을 확인했는데 이것을 어떻게 멀티 모듈에 녹여내야 할지 막막 했습니다.  멀티 모듈을 구성하면서 디자인에 맞는 모듈간에 의존성, 패키지, 각 역할을 하는 클래스에 대한 위치들 정하는게 어려웠습니다.  

 

 

 

[필수 조건]
- spring boot, gradle 사용한다.
- Jpa , QueryDsl을 사용한다.
- 외부연동을 한다(OpenFeign 등)
- 각 모듈의 역할이 명확히 나눈다.

 

 

 

 

우선 각역할에 맞는 멀티모듈을 구성하기 위해 gradle 멀티모듈로 구성하려 고합니다.  일반적으로 멀티 모듈로 구성하는 건 많은 블로그를 통해 다 아실텐데요.  저는 아래와 같은 형태로 구성했습니다.  참고로 멀티모듈 구성방법은 생략하겠습니다. 

 

 

 

 

 

 

 

 

모듈간에 의존 관계를 그림으로 작성해봤습니다. 

 

 

모듈 의존 관계 그림

 

 

 

각 모듈의 역할은 위에서 정의한 내용과 동일 합니다. 대신 저는 명칭을 presentation에서 api로 변경을 해서 사용을 했습니다. 

그럼 멀티모듈은 구성을 했고 그럼 나머지 spring boot 관련 설정을 해야하는데  혹시 @SpringBootApplication 에대해서 생각 해보 신 적 있을까요? 


해당 어노테이션은 @ComponentScan 을 갖고 있는데 해당 어노테이션은 별다른 설정을 하지 않았다면 해당 어노테이션이 붙은 클래스를 base package로 정하고 하위 패키지들 스캔을 하면서 Spring Container에 bean으로 등록하는 작업을 진행합니다. 


만약 범위를 벗어나는 스캔을 하고 싶다면 직접 base package를 설정 할 수 있습니다

 

@SpringBootApplication(scanBasePackages = {"com.text.api"})  
public class ModuleApiApplication {  
    public static void main(String[] args) {  
        SpringApplication.run(ModuleApiApplication.class, args);  
    }  
}

 

 

 

이러한 기능 때문에 대부분은 패키지 구조에 대해서는 생각을 하지 않고 위와 같은 설정을 많이 하실텐데요. 저렇게 위의 설정을 했을 때 문제가 되는 점은 아래와 같습니다. 

 

 

 

  • 패키지 스캔 범위가 되지 않아서 bean으로 등록되지 않는 경우도 발생 할 수 있음 
  • 패키지 간 이동이 어렵고 리팩토링이 어려움
  • 계층 구조 / 모듈 분리 의도를 파악하기 힘듬
  • 스캔의 범위를 파악하기 쉬움 

 

 

 

 

하지만 멀티 모듈에서 스캔을 위해서 사용을 하게되는데요. 하지만 이것을 해결 할 수 있는 방법이 있습니다. 

 

 

 

방법은 각 모듈의 패키지를 통일 하는 것입니다.   

 

 

api 모듈 패키지 구조

 

 

 

 

infra 모듈 패키지 구조

 

 

 

 

 

보시면 api , infra 모듈의 패키지를 com.example.{모듈명} 로 두고 있는데요.  이유는 api 모듈의 ModuleApiApplication에 @SpringBootAplication이 존재 하기 떄문입니다. 때문에 basePackges가 "com.example" 이 되고 해당 하위의 패키지를 탐색하게 됩니다. 

 

 

 

 

 

 

 

여기서 문득 "다른 모듈에 있는 것이 어떻게 bean으로 등록 될 수 있지?" 라는 의문을 가질 수 있습니다.  그이유는 classPath 입니다. 

classPath는 그러면 무엇일까요?

 

 

classPath는 자바 어플리케이션이 실행 시 jvm이 클래스를 찾는 경로 입니다. 즉 컴파일 되고 만들어진 .class 파일을  ClassLoader가 어디서 찾을지에 대한 경로 라고 보시면 됩니다. 

 

 

아래의 그림은 gradle build를 진행하면 생기는 output 디렉토리 입니다.  해당 디렉토리에는 컴파일 된 결과의 jar, class  파일들이  생성 되는 것을 확인 할 수 있습니다.

 

 

 

 

 

 

 

 

 

 

여기서는 "classes.java.main.com.example.ModuleApiApplication.class" 가 생기는 것을 확인 할 수 있습니다.  

 

 

배포나 운영환경에서는 jar로 패키징 후 실행하겠지만 intellj를 사용하고 계시다면 jar로 실행하지 않고  아래와 같이 실행 하고 있을 것입니다. 

 

 

java -cp <여러 module의 build/classes 디렉토리 포함한 classpath> com.example.ModuleApiApplication 

 

 

 

 

 

 

 

 

참고로 api, infra, domain 모듈 관계에서 api 모듈을 실행한다고 했을때 api 모듈에 있는 gradle의 정의된 모듈관계를 읽고 해당 모듈을 먼저 빌드를 진행합니다. 때문에 연관된 모듈의 build 디렉토리는 다 만들어지고 아래와 같이 classPath를 설정해서 동작을 하게 됩니다. 

 

 

java -cp \
  ./domain/build/classes/java/main:\
  ./infra/build/classes/java/main:\
  ./application/build/classes/java/main:\
  ./api/build/classes/java/main:\
  ./libs/* \
  com.example.ModuleApiApplication

 

 

 

 

이과정에서 basePacages가 중요한 역할을 하게됩니다.  각 모듈별 아래와 같이 구성되어 있다고 보겠습니다. 

 

 

[domain]
 -  /java/main/com/example/domain/Meber.java
[infra]
 - /java/main/com/example/infra/MemberJpaRepository.java
[api]
 - java/main/com/example/ModuleApiApplication.java

 

 

 

이걸 빌드하게 되면 아래와 같은 구조 생성이 됩니다.  

 

[domain]
 - build/classes/java/main/com/example/domain/Order.class
[infra]
 - build/classes/java/main/com/example/infra/OrderJpaRepository.class
[api]
 - build/classes/java/main/com/example/ModuleApiApplication.class

 

 

 

그런데 여기서 ModuleApiApplication.class에는 @SpringBootApplication에 존재하기 때문에 root package인 example 를 기준으로 component 스캔을 하게 됩니다. 이때 위에서 설명한 classPath에 설정된 경로를 기준으로 탐색을 하게 됩니다. 여기서는 Member, MemberJpaRepository가 rootPackage가 같음으로  스캔의 범위가 포함되어 bean으로 등록 됩니다. 

 

 

 

 

여기서 좀더 깊게 들어가보겠습니다. 만약 entity 클래스트가 domain모듈에 있고 jpa 관련 repositoty,querydsl, config등이 infra 모듈에 적용을 해야하는 상황일때는 어떻게 될까요?  흔히 QClass를 생성을 해야하는데 이것은 보통 entitiy 클래스가 있는 모듈을 스캔을 하게됩니다. 물론 다른 모듈을 대상으로 스캔도 가능합니다.  

 

 

보통 경로 수정은 가능하지만 기본적으로 "build/generated/querydsl" 에 QClass 가 생성이됩니다. 이경우 import가 불가능한데요. 이유는 classPath의 기본설정은 "build/classes/java/main" 이기 때문에 다른 경로에 있는 것을 읽을 수가 없기 때문인데요 이것을 해결 하는 방법이 souceSet을 설정하는 것입니다. 

 

 

domain build.gradle

sourceSets {
    main {
        java {
            srcDirs += 'build/generated/querydsl'
        }
    }
}

 

 

 

sourceSet 
Gradle이 어떤 소스코드를 컴파일 대상으로 삼을 지 지정하는 것입니다. 

 

 

위설정을 했을 경우 "build/generated/querydsl" 클래스 로더가 찾는 경로이 classPath에 추가를 하여 import가 가능해지는 것입니다.

 

sourceSet 어디서 컴파일할 소스코드를 찾을 지
classPath 컴파일되고 난 .class 파일을 실행할때 어디서 찾을지 

 

 

 

동작 순서

 

  1. domain 모듈에서 build/generated/querydsl 을 sourceSet에 추가 
  2. 모듈 의존성에의 해서 domain이 먼저 빌드 및 컴파일됨
  3. 컴파일 시 QClass는 .class로 변환되어 build/class/... 에위치함
  4. gradle은 domain 모듈을 infra의 dependency로 묶으면서 해당 .class 들을 infra classPath에 포함시킴
  5. infra 모듈에서 import 및 bean을 사용 할 수 있게 됨

 

 각 모듈의 build 디렉토리에는 자신의 빌드 결과물만 담습니다. domain의 .class 파일은 infra에 복사되지 않고 Gradle이 빌드 및 실행 시 domain의 build/class/... 를 classPath에 동적으로 연결시켜서 동작을합니다.

 

 

 

이젠 멀티 모듈의 동작원리는 알았고 저는 common을 제외한 모듈을 아래와 같이 설정 했습니다. (참고 모듈 의존 관계 그림)

 

domain 모듈

 

 

 

 

api 모듈

 

 

 

 

 

presentation 모듈

 

infra 모듈

 

 

 

 

 

모듈들은 위에 정의한 DDD 아키텍처 기반으로 가고 infra 모듈과 도메인 모듈을 중심적으로 설명하려 하고 특히 jpa 중심적으로 가려합니다. 

 

특히  jpa를 사용하면 JpaRepository를 많이 사용하실텐데요 이경우 jpa 구현체를 쉽게 만들어줘서 편리함을 제공합니다. 또한 Querydsl를 사용하면서 Querydsl interface로 아래와 같이 상속 받고 있을 것입니다. 

 

public interface MemberRepository extends JpaRepository<Member,Long> , MemberQuery {

}

 

 

 

저의 설계에 있어서 도메인 모듈의 경우 entity와 그와 관련된 service, repository등에 해당하는 interface가 존재를 해야하고 그와 관련된 구현체를 infra모듈에서 구현하는것이 목표인데요.

 

 

이렇게 하는 이유는 구현체를 언제든 변경이 가능할 수 있어야 하기 때문입니다. 즉 도메인 관련된 정의는 변경되지 않지만 이것 구현하는 것들은 언제는 변경이 이루어 질 수 있기 때문입니다. 

 

 

 

예를들면 지금은 rdbms를 사용하지만 mongo db, redis 등 다양하게 구현부가 달라질 수 있는 상황이 벌어질 수 있습니다. 

 

 

그러면 어떻게 해야할까요? 물론 crudReposotory등을 상속받아서 해결 하는 방법도 있지만 여러 문제로 구현체를 직접 만든것이 아닌 JpaRepository를 상속 받아서  사용 하고 싶은경우가 많을 거 같은데요. 그래서 저는 아래와 같은 구성을 했습니다. 

 

 

 

domain 모듈 

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "member")
public class Member {
}

 

public interface MemberRepository {
    Member findById(String memberId);
}

 

 

 

 

 

infra 모듈

public interface JpaMemberRepository extends JpaRepository<Member,Long> , MemberQuery {

}
@RequiredArgsConstructor
@Repository
public class MemberRepositoryImpl implements MemberRepository {
    private final JpaMemberRepository jpaMemberRepository;

    @Override
    public Member findById(String memberId) {
        return jpaMemberRepository.findMemberById(memberId);
    }

}

 

 

 

 

이렇게 설정 했을 경우 entity모듈에 정의된 interface로 정의를 해야하며, infra 모듈에서 사용하는 구현체로 사용을 하게 됩니다. 

만약 Jpa에서 jdbc, redis 등으로 변경을 해야 하는 경우에는 구현체 repository 만 변경하면 해결이 됩니다. 즉 domain 모듈에는 변경 없이 infra 모듈에서 모든 처리가 가능하게 되는 구조로 이루어집니다. 

 

 

물론 다른 더 좋은 설계가 있겠지만, 나름의 고안(?) 을하고 만들어진 것을 알아 주셨으면 합니다.

 

 

 

 

다른 모듈들은 크게 다르지 않아서 설명은 생략하겠습니다. 혹시 더 좋은 방향이 있으시다면 조언 해주셨으면 합니다.

 

 

profile

pooney

@pooney

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!