문제 상황
기존 프로젝트는 Gradle의 멀티 모듈을 사용하여 구성되어 있었습니다.
compile 시점의 의존성 구조
runtime 시점의 의존성 구조
왼쪽 사진은 compile 시점의 의존성 구조를 나타내고 화살표의 방향은 의존 방향을 나타냅니다.
오른쪽 사진은 runtime 시점의 의존성 구조를 나타내고 화살표의 방향은 사용 방향을 나타냅니다.
사진 중 API, BATCH, FILE 모듈은 Runnuable한 모듈입니다.
현재 저희 프로젝트는 헥사고날 아키텍처를 채용하고 있고 아키텍처 구조는 아래 사진과 같습니다.
헥사고날 아키텍처 (https://herbertograca.com/tag/explicit-architecture)
헥사고날 아키텍처란 도메인을 중심으로 레이어를 형성하며 외부와 내부를 확실하게 나누는 아키텍처입니다.
이 때 외부와 내부가 통신하기 위해 Port, Adapter를 사용하며 Port는 인터페이스, Adapter는 구현체로 구성합니다.
기존 모듈 구조에서는 이러한 Port들을 전부 APPLICATION 모듈이 갖고 있었기 때문에 여러가지 문제점이 발견되었습니다.
1.
Runnuable 모듈들은 사용하지도 않는 Adapter(그 외 모듈들)들에 대한 의존성을 모두 가지는 문제
2.
APPLICATION 모듈이 점점 커지게 되며 불필요한 코드까지 공유하게 되면 스파게티 코드가 발생할 여지 존재
3.
application.yml과 같은 설정 파일을 각 서버마다 나눌 수 없어 불필요한 설정이 동작할 수 있는 문제
이 문제들을 해결하기 위해 차근차근 APPLICATION을 분리하는 작업을 수행하였습니다.
Gradle의 의존성 키워드
먼저 의존성 구조를 정리하기 위해 gradle에서 사용하는 의존성 키워드들을 살펴보겠습니다.
implementation
•
특정 모듈을 컴파일 및 런타임 시점에 모두 의존하는 것을 의미합니다.
•
프로젝트 빌드 시점에 해당 모듈을 컴파일에 사용하고 빌드된 결과물에도 포함됩니다.
•
해당 모듈을 의존하는 다른 모듈에는 의존성이 공유되지 않습니다.
•
의존하고 있는 모듈이 수정될 시 해당 모듈까지만 함께 재빌드됩니다. (빠름)
api
•
특정 모듈을 컴파일 및 런타임 시점에 모두 의존하는 것을 의미합니다.
•
프로젝트 빌드 시점에 해당 모듈을 컴파일에 사용하고 빌드된 결과물에도 포함됩니다.
•
해당 모듈을 의존하는 다른 모듈에 의존성이 공유됩니다.
•
의존하고 있는 모듈이 수정될 시 해당 모듈을 의존하고 있는 모듈들까지 전부 재빌드됩니다. (느림)
compileOnly
•
특정 모듈을 컴파일 시점에만 의존하는 것을 의미합니다.
•
프로젝트 빌드 시 해당 모듈을 컴파일에만 사용하고 빌드된 결과물엔 포함하지 않습니다.
runtimeOnly
•
특정 모듈을 런타임 시점에만 의존하는 것을 의미합니다.
•
프로젝트 빌드 시 해당 모듈을 프로젝트에 포함시키지 않고 빌드된 결과물에만 포함됩니다.
정리하면 아래와 같은 표로 이해할 수 있습니다.
Gradle 의존성 키워드를 통한 DI(Dependency Injection)
API 모듈이 STORAGE, NOTIFY, OAUTH2, PAYMENT 같은 외부 모듈들을 의존하거나 알 필요가 있을까요?
위 사진처럼 구성하게 되면 빌드되기 전까진 API 모듈은 외부 모듈을 알 수 없고 오로지 APPLICATION 모듈만 의존하게 됩니다.
빌드를 하게 되면 외부 모듈은 APPLICATION 모듈에 대한 의존성을 갖지 않고 API 모듈은 사용하는 모듈들에 대한 의존성을 갖게 됩니다.
그럼 모듈 내부에선 어떻게 동작할까요?
//Service in application module
@Service
@RequiredArgsConstructor
public class UserService {
private final UserPersistencePort userPersistenceAdapter;
}
//Port in application module
public interface UserPersistencePort {
}
//Adapter in storage module
@Repository
public class UserPersistenceAdapter implements UserPersistencePort {
}
Java
복사
위 코드는 Service 클래스에서 Spring의 DI를 통해 Port의 구현체를 주입 받고 있습니다.
모듈 단위에서 DI가 되어야만 구현체를 정상적으로 DI 할 수 있게 됩니다.
//build.gradle in api module
dependencies {
implementation project(':domain')
implementation project(':core')
runtimeOnly project(':storage:jpa')
runtimeOnly project(':storage:elasticsearch')
runtimeOnly project(':storage:redis')
runtimeOnly project(':storage:s3')
runtimeOnly project(':storage:salesmap')
runtimeOnly project(':oauth2:apple')
runtimeOnly project(':oauth2:kakao')
runtimeOnly project(':oauth2:pass')
runtimeOnly project(':oauth2:hecto')
runtimeOnly project(':notify:email')
runtimeOnly project(':notify:fcm')
runtimeOnly project(':notify:slack')
runtimeOnly project(':notify:sms')
runtimeOnly project(':payment')
}
//build.gradle in storage module
dependencies {
compileOnly project(':domain')
compileOnly project(':core')
}
Java
복사
이걸 기반으로 build.gradle에 코드를 수정하였고 모듈 간의 의존성 주입을 구현할 수 있게 되었습니다.
Gradle rebuild 이슈
모듈 간의 DI를 구성하기 위해선 중간 역할을 해줄 APPLICATION 모듈이 꼭 필요했습니다.
하지만 앞서 살펴봤던 문제점들을 해결하기 위해선 APPLICATION 모듈의 분리가 필요했습니다.
그래서 위 사진처럼 각 Runnuable 모듈마다 APPLICATION 모듈을 갖게끔 분리하였습니다.
그 과정에서 여러 Runnuable 모듈에서 함께 사용하던 Port는 코드가 중복되는걸 감수하고 복사+붙여넣기를 하였습니다.
이제 또 다른 문제가 발생하였습니다.
프로젝트의 API 모듈과 BATCH 모듈은 STORAGE 모듈을 함께 사용하고 있습니다.
//build.gradle in storage module
dependencies {
compileOnly project(':api-application')
compileOnly project(':batch-application')
compileOnly project(':domain')
compileOnly project(':core')
}
Java
복사
그러면 위 코드처럼 API, BATCH 각 모듈의 APPLICATION 모듈을 의존해야합니다.
이렇게 되면 API 모듈을 빌드 했을 때 STORAGE 모듈이 BATCH-APPLICATION 모듈도 의존하고 있기 때문에 함께 빌드가 되게됩니다.
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 2 of constructor in Class required a bean of type 'Class' that could not be found.
Action:
Consider defining a bean of type 'Class' in your configuration.
Process finished with exit code 1
Java
복사
그러면 이런 에러를 만나게 됩니다.
이 에러는 Spring이 빈을 주입하기 모호할 때 발생하는 에러로 동일한 이름의 Port가 여러 개 존재해서 생기는 문제입니다.
Core 모듈의 등장
앞선 rebuild 문제를 해결하기 위해 각 Runnuable 모듈이 모듈 안에 application 패키지를 갖게 하였습니다.
그리고 모듈 간의 DI 역할을 위한 중간 모듈로 CORE 모듈을 두고 Port를 위치시켜 모듈끼리 공유할 수 있도록 하였습니다.
이렇게 하여 거대했던 APPLICATION 모듈을 다 분리하고 사라진 중간 모듈 역할도 CORE 모듈로서 대체함으로써 문제들을 해결할 수 있었습니다.