티스토리 뷰

 

 

들어가기 전

 

현재 작성된 코드들은 GitHub 에 저장되어 있습니다.

이해가 안되는 부분이 있다면 번갈아 가며 확인해주시면 됩니다.

 

 

 

 

개발 환경

 

Spring Boot 3.4.5
Java 17
IntelliJ
MacOS

 

 

 

 

왜 멀티 모듈을 선택했나?

 

실무에서 진행하는 프로젝트는 멀티 모듈로 되어 있으며 개발을 진행 중입니다.

하지만 세팅해본 적은 없으며 그냥 있으니까 얼레벌레 개발만하고 "원래 이랬으니까~" 라는 생각으로 살아 왔었습니다.

 

하지만 이렇게 가다간 도태 될 것같아서 크기가 큰 프로젝트라면 언젠간 마주 할 것 같아서 이번 기회에 공부해보려고 합니다.

멀티 모듈은 아래와 같은 장점을 가지기 있으며 앞으로 협업을 진행하면 확실이 체감 할 것 같습니다.

거의 대부분의 회사가 멀티 모듈로 진행 중이였네..

 

  • 도메인 로직 분리 : 핵심 로직(domain)을 외부 레이어(api, batch, infra 등)와 격리
  • 재사용성 : 여러 서비스에서 공통 모듈(common, domain, external 등) 활용 가능
  • 역할 분리 : 팀별 책임 경계를 명확히 할 수 있음

 

 

 

 

프로젝트 모듈 구조

 

저는 아래와 같은 모듈로 잡았습니다.

도움은 받았지만 저도 생각을 해보니 실무에서는 최소 이렇게 4개는 쓰겠구나라는 생각이 들었습니다.

multimodule-project
│
├── build.gradle (루트)
├── settings.gradle
│
├── domain           # JPA Entity, Repository 등 핵심 비즈니스 로직
│   └── build.gradle
│
├── api              # Web, Controller, Service 등 외부 노출 계층
│   └── build.gradle
│
├── common     		# Util Java 코드
│   └── build.gradle
│
├── external     	# 외부 시스템 연동
│   └── build.gradle

 

  • Domain 모듈 : Domain(Entity), Enum, Repository, 핵심 비즈니스 로직(Service)
  • Api 모듈 : Controller 및 service, Advice, Config, 필터, 인터셉터, 스케쥴러
  • Common 모듈 : ErrorCode, Exception, 공통 응답, Util, Enum 등 모든 모듈에서 사용하는 순수 Java 코드
  • External 모듈 : 외부 시스템 연동 (S3, AI, Redis 등)

 

 

 

멀티 모듈 프로젝트는 어떻게 만드는 건데?

 

  1. https://start.spring.io/ 에서 프로젝트를 의존성 없이 생성해서 IntelliJ로 Open합니다.
  2. ROOT에 있는 src 폴더를 직접 오른쪽 마우스를 눌러 삭제합니다.
  3. ROOT 프로젝트에서 오른쪽 마우스를 클릭한 후 new → module 을 선택하여 모듈 명을 정합니다.

모듈 생성
[그림1] 멀티 모듈 생성 3번 행동
멀티 모듈 프로젝트
[그림2] 멀티 모듈 생성 완료

 

Tip) 지금은 실행이 안됩니다 왜냐? main() 메서드가 없기 때문입니다.

 

 

 

 

Gradle 의존성 추가

 

Root → common → external → domain → api 순으로 Gradle을 작성하려고 합니다.

domain 모듈은 common 모듈을 의존합니다.

external 모듈은 common 모듈을 의존합니다.

api 모듈은 common, domain, external 모듈을 의존합니다.

 

Root의 build.gradle

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.4.5' apply false
	id 'io.spring.dependency-management' version '1.1.7' apply false
}

group = 'com.aoxx'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

allprojects {
	repositories {
		mavenCentral()
	}
}

// 모든 모듈 공통
subprojects {
	apply plugin: 'java'
	apply plugin: 'io.spring.dependency-management'

	dependencies {
		// 모든 모듈에서 사용할 공통 의존성
		implementation 'org.springframework.boot:spring-boot-starter' // Spring Boot 기본 스타터
		compileOnly 'org.projectlombok:lombok'  // Lombok
		annotationProcessor 'org.projectlombok:lombok' // Lombok 어노테이션 프로세서

		testImplementation 'org.springframework.boot:spring-boot-starter-test' // Spring Boot 테스트
	}

	tasks.named('test') {
		useJUnitPlatform()  // JUnit 5를 사용할 수 있도록 설정
	}
}

 

Root의 settings.gradle

rootProject.name = 'multi-module'
include 'api'
include 'common'
include 'external'
include 'domain'

 

Common 모듈의 build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

bootJar { enabled = false }
jar { enabled = true }

dependencies {
}

test {
}

 

External 모듈의 build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

bootJar { enabled = false }
jar { enabled = true }

dependencies {
	implementation project(':common') // common 모듈을 의존함
}

test {
}

 

domain 모듈의 build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

bootJar { enabled = false }
jar { enabled = true }

dependencies {
    implementation project(':common') // common 모듈을 의존함

    // Spring Data JPA 의존성 추가
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'com.h2database:h2'  // 테스트 시에만 필요
}

test {
}

 

Api 모듈의 build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.5'
    id 'io.spring.dependency-management' version '1.1.7'
}

bootJar { enabled = true }
jar { enabled = false }

dependencies {
    implementation project(':common')   // common 모듈을 의존함
    implementation project(':domain')   // domain 모듈을 의존함
    implementation project(':external')  // external 모듈을 의존함

    implementation 'org.springframework.boot:spring-boot-starter-web'

    // Application 있는 곳에 h2 db 세팅
    implementation 'com.h2database:h2'
}

test {
}

 

 

 

 

중간에 jar와 bootJar는 뭐야?

jar

  • 일반적인 Java 라이브러리 형태로 패키징
  • 실행용이 아니며 main()이 없음.
  • 다른 모듈이 의존할 수 있는 라이브러리 역할을 함.
  • 즉, Common, Domain, External 모듈은 라이브러리 역할을 하려고 합니다.

bootJar

  • Spring Boot 애플리케이션 실행이 가능한 패키징 형태
  • main() 메서드가 포함되어 있음.
  • 종속된 모든 라이브러리를 포함한 독립 실행형 JAR를 생성함.
  • 즉, Api 모듈이 Spring Boot 애플리케이션을 실행 책임을 가집니다.

 

위에 gradle에 한개만 다른 모듈이 있습니다. api 모듈입니다.

Api 모듈은 이 프로젝트를 실행시키는 책임을 가진 모듈로 두었기 때문에 bootJar = true 가 되어있습니다.

 

그래서 아래처럼 Api 모듈안에 Application 클래스를 만들어 @SpringBootApplication 을 입력하였습니다.

그리고 Bean Scan도 해당 위치부터가 아니고 원래 패키지로 잡기 위해 기본 패키지 경로로 옵션을 추가 하였습니다.

 

SpringApplication 등록
[그림3] SpringApplication 등록

 

 

 

 

도메인 모듈 단위 테스트 전략

 

도메인 모듈의 비즈니스 로직의 단위 테스트만 진행할 것이기 때문에 도메인 모듈에서 테스트코드를 작성해보려고 합니다.

User Entity

@Table(name = "USER_INFO")
@Getter
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    protected User() {}
    public User(String name) {
        this.name = name;
    }
}

Tip) H2 사용 시 user는 예약어입니다. user말고 다른 이름으로 변경하세요.

 

UserRepository

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    // 이름으로 찾는 커스텀 메서드
    Optional<User> findByName(String name);
}

 

UserRepositoryTest

  • @DataJpaTest를 사용하면 @SpringBootTest와는 달리 빈을 다 등록하지 않고 JPA 환경에 필요한 것들만 가지고 테스트 하게 됩니다.
  • @DataJpaTest@Component@ConfigurationProperties빈은 스캔되지 않기 때문입니다.
  • 그래서 @DataJpaTest를 사용하였습니다.
@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    @DisplayName("사용자를 저장하고 이름으로 조회한다")
    void saveAndFindByName() {
        // given
        User user = new User("홍길동");

        // when
        userRepository.save(user);
        Optional<User> found = userRepository.findByName("홍길동");

        // then
        Assertions.assertThat(found).isPresent();
        Assertions.assertThat(found.get().getName()).isEqualTo("홍길동");
    }

    @Test
    @DisplayName("없는 이름으로 조회하면 Optional.empty()를 반환한다")
    void findByNameNotExists() {
        // when
        Optional<User> result = userRepository.findByName("없는사람");

        // then
        Assertions.assertThat(result).isEmpty();
    }
}
 

 

그런데 웬걸 왜 계속 빈을 못찾는다고 에러 메시지가 나옵니다.

생성해둔 기본 코드와 테스트 코드의 패키지 경로도 같은데!!!!!!! 왜!!!!

 

Could not autowire. No beans of 'UserRepository' type found 발생
[그림4] Could not autowire. No beans of 'UserRepository' type found 발생

 

근데 이게 웬걸 Could not autowire. No beans of 'UserRepository' type found. 라는 에러가 발생합니다.
그냥 무시하고 아 그냥 경고겠지하고 테스트 코드를 실행해보면 막 무서운 에러가 발생합니다.
 
테스트 코드 실행 실패
[그림5] 테스트 코드 실행 실패
 

 

Unable to find a @SpringBootConfiguration by searching packages upwards from the test. You can use @ContextConfiguration, @SpringBootTest(classes=...) or other Spring Test supported mechanisms to explicitly declare the configuration classes to load. Classes annotated with @TestConfiguration are not considered. 

 

이 에러는 @SpringBootApplication과 같은 Spring Context를 불러오는 포인트가 없어서 발생하는 에러입니다.

 

이해가 되지 않았다면 이해시켜보도록 하겠습니다.

보통 @SpringBootTest 또는 @DataJpaTest를 쓰면 스프링이 자동으로 시작 클래스(Main 메서드)를 기준으로 설정을 읽어 들이는데 Domain 모듈에는 그 시작시키는 클래스가 없어서 못찾고 있는 것입니다.

 
 
Test에 @SpringBootApplication 설정
[그림6] Test에 @SpringBootApplication 설정
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TestConfiguration {
}

 

이 클래스를 만들고 세팅해 두었다면 이제 편안한 테스트 성공을 맛보시면 됩니다.

 

Domain 모듈 테스트 성공
[그림7] Domain 모듈 테스트 성공

 

 

 

 

Api 모듈로 기동했을 때 다른 모듈 의존성 확인해보기

1) 터미널에서의존성 트리 확인

프로젝트 루트에서 아래 명령어를 작성해보시면 됩니다.

./gradlew :api:dependencies

Api 모듈은 Common, Domain, External 모듈을 의존하기 때문에 아래 처럼 있어야합니다.

 

API 모듈 의존성 확인
[그림8] API 모듈 의존성 확인

 

2) jar 패키징 확인해보기

api 모듈을 빌드 하면 api.jar생성되는데 루트 경로에 아래와 같은 명령어를 작성해주시면 됩니다.

jar tf ./api/build/libs/api.jar

이거 하면 jar에 있는 많은 의존성이 올라오는데

Api 모듈은 Common, Domain, External 모듈을 의존하기 때문에 아래 처럼 있어야합니다.

 

api 모듈 jar 내부
[그림9] api 모듈 jar 내부

 

 

 

 

프로젝트 전체 빌드

 

프로젝트 루트에서 빌드해보려고 합니다. 전체를 기동시키기 위해 !

./gradlew build --console=plain
  • --console=plain 옵션은 빌드되는 것을 console에 출력하는 옵션입니다.

 

전체 프로젝트 빌드
[그림10] 전체 프로젝트 빌드

 

이제 잘 되는 것을 확인 하였고 개발을 진행하면 됩니다!!!!!!!

 

 

 

 

마무리

 

Spring Boot 멀티 모듈을 설계해보니 재사용성, 유지보수성에서 엄청난 이점을 가질 것이라고 예상됩니다.

거의 모든 회사에서 이러한 방법으로 사용하고 있는데 알아두면 너무 좋을 것 같습니다.

모듈 간 책임을 어떻게 오염을 안시키고 이끌고 갈지 고민이 되네요 모두 수고하셨습니다.

감사합니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/06   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함