Gradle의 Multi Project 적용하기

Glen·2023년 4월 24일
0

배운것

목록 보기
11/37

개요

자동차 경주 미션을 콘솔에서 웹으로 구현하는 과정에서, 콘솔 어플리케이션은 스프링을 사용하지 않고, 직접 의존성 주입을 통해 실행했었다.

콘솔 어플리케이션은 스프링을 사용하지 않기 때문에 스프링이 제공해 주는 JdbcTemplate를 사용을 못 하니 데이터베이스를 사용하지 않는 Repository를 사용하였다.

하지만 콘솔 어플리케이션도 데이터베이스에 게임 기록을 저장하고 싶어, 콘솔에도 스프링을 사용하였는데 여러 문제점이 발생하였다.

최종적으로 Gradle이 제공하는 Multi Project로 문제점을 해결하였고, 이를 적용하기까지 과정을 소개한다.

본문

스프링을 사용하지 않는 콘솔 어플리케이션

public class ConsoleApplication {  
    public static void main(String[] args) {  
        NumberGenerator randomNumberGenerator = new RandomNumberGenerator();  
        RacingCarRepository memoryRacingCarRepository = new MemoryRacingCarRepository();  
        RacingCarService racingCarService = new RacingCarService(memoryRacingCarRepository, randomNumberGenerator);  
        ConsoleRacingCarController consoleRacingCarController = new ConsoleRacingCarController(racingCarService);  
        consoleRacingCarController.run();  
    }  
}

다음과 같이 콘솔 어플리케이션은 스프링을 사용하지 않고 실행하였다.

하지만 데이터베이스를 사용하는 것이 아닌, 메모리에 결과를 저장하기 때문에 재시작하면 기록이 사라지는 문제가 있었다.

따라서 콘솔 어플리케이션을 사용해도 데이터베이스에 게임의 기록이 저장되도록 스프링을 사용하도록 코드를 변경하였다.

스프링을 사용하는 콘솔 어플리케이션

@Component // 스프링 빈으로 등록
public class ConsoleRacingCarController {  
    private final RacingCarService racingCarService;  
  
    public ConsoleRacingCarController(RacingCarService racingCarService) {  
        this.racingCarService = racingCarService;  
    }
    ...
}
@SpringBootApplication
public class ConsoleRacingCarApplication implements CommandLineRunner {  
    private final ConsoleRacingCarController consoleRacingCarController;  
  
    public ConsoleRacingCarApplication(ConsoleRacingCarController consoleRacingCarController) {  
        this.consoleRacingCarController = consoleRacingCarController;  
    }  
  
    public static void main(String[] args) {  
        SpringApplication.run(ConsoleRacingCarApplication.class, args);  
    }  
  
    @Override  
    public void run(String... args) throws Exception {  
        consoleRacingCarController.run();  
    }  
}

CommandLineRunner를 구현하여 run() 메소드를 구현하면 스프링이 로드될 때 run() 메소드의 코드를 실행할 수 있다.

이렇게 하여 스프링을 사용하여 콘솔 어플리케이션을 구동할 수 있었다.

하지만 문제점이 발생했다.

첫 번째로 콘솔 어플리케이션이 실행될 때는 웹 어플리케이션이 실행될 필요가 없다.

build.gradleorg.springframework.boot:spring-boot-starter-web 종속성을 추가했으므로, 스프링이 실행되면 웹 서버가 시작된다.

두 번째로 이 상태로 컴파일하거나 테스트를 실행하면 다음과 같은 예외가 발생한다.

Found multiple @SpringBootConfiguration annotated classes ...

해당 예외는 @SpringBootConfiguration 에너테이션이 설정된 클래스가 여러 개 있으면 발생한다.

우선 첫 번째 문제를 해결하려면 build.gradle에 있는 종속성을 제거할 수는 없으니 @ComponentScan 에너테이션의 설정 중 excludeFilters를 사용하여 웹에 관한 스프링 빈의 등록을 제외했다.

@SpringBootApplication  
@ComponentScan(excludeFilters = @Filter(  
        type = FilterType.ASSIGNABLE_TYPE,  
        classes = {WebRacingCarApplication.class, WebRacingCarController.class}  
))  
public class ConsoleRacingCarApplication implements CommandLineRunner {
    ...
}

그리고 두 번째 문제를 해결하려면 콘솔 어플리케이션과 웹 어플리케이션 둘 중 하나에 붙어있는 @SpringBootConfiguration 에너테이션을 제거해야 했다.

이번 미션의 구현 사항은 웹이 우선이므로 콘솔을 주석처리 하여 해당 문제를 해결하였지만, 프로그램을 실행할 때마다 주석을 지우고 다는 행위가 맘에 들지 않았다.

새로운 에너테이션을 만들어 주석을 한 줄만 달면 되게 설정도 해봤지만 결국 프로그램의 실행을 선택할 때 소스 코드를 수정해야 한다는 것은 변하지 않았다.

@Target(ElementType.TYPE)  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
@SpringBootApplication  
@ComponentScan(excludeFilters = @Filter(  
        type = FilterType.ASSIGNABLE_TYPE,  
        classes = {WebRacingCarApplication.class, WebRacingCarController.class}  
))  
public @interface ConsoleSpringBootApplication {  
}
@ConsoleSpringBootApplication  
public class ConsoleRacingCarApplication implements CommandLineRunner {
    ...
}

MVC 패턴을 적용하여 계층별로 분리하는 것은 좋았지만, 결과적으로 어플리케이션의 실행은 특정 View에 종속되어 독립적으로 선택하여 실행할 수 없었다.

어떻게 하면 도메인, 비즈니스 로직 같은 핵심 로직만 분리하고 스프링의 특정 기능을 사용하며 기능에 종속되지 않고 어플리케이션을 실행시킬 수 있을까?

Gradle의 Multi Project

자동차 경주 게임의 대략적인 프로젝트 구조는 다음과 같다.

.
├── controller
│   └── ConsoleController
│   └── WebController
├── service
└── repository

ConsoleControllerWebController는 표현하는 View만 다르고 나머지 의존성은 같다.

따라서 비즈니스 로직을 담당하는 Service와 데이터베이스를 담당하는 Repository 계층을 공통으로 두고, WebConsole 부분을 분리하면 된다.

어떻게 한 프로젝트 안에서 이러한 계층별로 분리할 수 있을까?

답은 바로 Gradle이 제공하는 Multi Project 기능을 사용하면 된다.

콘솔과 웹이 공통으로 사용하는 기능들은 core라는 모듈로 묶고, 웹은 web, 콘솔은 console로 묶을 수 있다.

.
├── core
│   ...
│   └── service
│   └── repository
│   └── build.gradle
├── console
│   ...
│   └── controller
│   └── build.gradle
├── web
│   ...
│   └── controller
│   └── build.gradle
├── build.gradle
└── settings.gradle

위의 모듈에서 설정한 gradle의 설정 정보는 다음과 같다.

root 디렉터리의 settings.gradle

rootProject.name = 'jwp-racingcar'  
include 'module-core'  
include 'module-web'  
include 'module-console'

include와 추가하려는 프로젝트의 폴더명을 작성하면 Gradle이 해당 디렉터리를 인식할 수 있다.

TMI: Gradle은 프로젝트 네이밍에 Kebab Case를 권장한다.

root 디렉터리의 build.gradle

plugins {  
    id 'java'  
    id 'org.springframework.boot' version '2.7.9'  
    id 'io.spring.dependency-management' version '1.0.15.RELEASE'  
}  
  
jar {  
    enabled = false  
}  
  
bootJar {  
    enabled = false  
}
  
allprojects {  
    version '0.0.1'  
    repositories {  
        mavenCentral()  
    }  
}  
  
subprojects {  
    apply plugin: 'java'  
    apply plugin: 'org.springframework.boot'  
    apply plugin: 'io.spring.dependency-management'  
  
    dependencies {  
        implementation 'org.springframework.boot:spring-boot-starter'  
        testImplementation 'org.springframework.boot:spring-boot-starter-test'  
    }  
  
    tasks.named('test') {  
        useJUnitPlatform()  
    }  
}

루트 프로젝트의 jar, bootJarenabledfalse로 설정했다.

왜냐하면 어플리케이션의 실행은 web, console이 하기 때문이다.

TMI
jar, bootJar는 Gradle에서 제공하는 것이 아닌 스프링이 제공하는 Gradle 스크립트이다.
bootJar는 build 시 java -jar로 실행할 수 있는 jar 파일을 생성한다.
jar는 build 시 -plain 이 붙은 jar 파일을 생성하는데, 해당 jar 파일은 소스 코드의 클래스 파일과 리소스 파일만 포함한다.

기존 build.gradle에서 볼 수 없는 새로운 구문이 보일 것이다.

allProject는 해당 프로젝트와 하위 프로젝트에 공통적인 기능을 작성할 수 있다.

subprojects는 하위 프로젝트에만 공통적인 기능을 작성할 수 있다.

Core 프로젝트의 build.gradle

plugins {  
    id 'java-library'  
}  
  
bootJar {  
    enabled = false  
}
  
dependencies {    
    api 'org.springframework.boot:spring-boot-starter-jdbc'  
    runtimeOnly 'com.h2database:h2'  
}

마찬가지로 core또한 실행하지 않기 때문에, bootJarenabledfalse로 설정한다.

하지만 jarenabledfalse로 하면 안 된다.

나머지 프로젝트가 core를 의존하기 때문이다.

여기서 plugins에서 java-library로 선언했다.

왜냐하면 dependenciesapi로 제공하기 위해서이다.

이에 관한 자세한 내용은 링크를 참고하기를 바란다.

consoleweb이 공통적인 기능을 사용하기 위해 jdbc와 데이터베이스 의존성을 추가했다.

Console 프로젝트의 build.gradle

jar {  
    enabled = false  
}

dependencies {  
    implementation project(':module-core')  
}

제일 간단하다.

콘솔은 웹을 사용하지 않기 때문에 core의 기능만 사용한다.

또한 실행하기 위한 프로젝트이므로 jarenabledfalse로 설정한다.

Web 프로젝트의 build.gradle

jar {  
    enabled = false  
}

dependencies {  
    implementation 'org.springframework.boot:spring-boot-starter-web'  
    implementation project(':module-core')  
}

제일 간단하다 2

웹은 core의 기능과 스프링의 웹 기능만 사용하기 때문에 web 의존성을 추가한다.

콘솔과 같이 실행이 목적이므로 jarenabledfalse로 설정한다.

결론

더 이상 콘솔 어플리케이션엔 @ComponentScan을 추가할 필요가 없다.

또한, 테스트를 실행하거나 컴파일할 때 소스 코드를 수정할 필요도 없다.

전체 프로젝트를 빌드 시 다음과 같은 결과물이 나온다.

.
├── core
│   └── build
│       └── libs
│           └── module-core-0.0.1-plain.jar
├── console
│   └── build
│       └── libs
│           └── module-console-0.0.1.jar
└── web
    └── build
        └── libs
            └── module-web-0.0.1.jar

웹 어플리케이션을 실행시키고 싶을땐 java -jar module-web-0.0.1.jar

콘솔 어플리케이션을 실행시키고 싶을땐 java -jar module-console-0.0.1.jar

해당 명령어를 통해 소스 코드를 수정하지 않고 원하는 View의 프로젝트를 실행할 수 있게 됐다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글