본문 바로가기
BackEnd/Project

[RealPJ] Ch01. Spring Multi Module 개념 및 실습

by 개발 Blog 2024. 8. 30.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

Multi Module

이번 시간에 다룰 내용들 

  • API / Common 모듈 생성
  • API 모듈이 Common 모듈의 클래스를 참조 및 사용하는 방법
  • IT 회사에서 사용하는 Exception 핸들링 컨벤션
  • Multi Module 구조에서 DB 연동
  • Multi Module 구조에서 Gradle을 사용한 빌드 및 배포 방법

멀티 모듈 이란

  • 필요한 기능별로 Module을 생성하여, 레고를 조립하듯 필요한 Module을 조립하는 방식이다.
  • N개의 Module이 조립되어 있는 프로젝트를 Multi Module 프로젝트라고 한다.
  • 예시: 로그인 Module, 인증 Module, DB 엔티티 Module 등.

왜 사용 할까

  • API 서버와 Batch 서버 등에서 동일한 DB Entity를 사용할 때, 중복된 Entity를 Module 화하여 사용하기 위해 Multi Module 프로젝트를 사용한다.
  • 독립적으로 관리할 경우, 중복 관리의 리스크가 증가한다.

Exception핸들링

  • 언어 또는 프레임워크에서 발생한 Exception은 반드시 Custom 하게 Wrapping 하여 처리해야 한다.
  • @RestControllerAdvice 어노테이션을 사용하여 모든 예외를 클라이언트와 사전에 정의된 값으로 재정의한다.
  • 예시: NPE(Null Pointer Exception) 발생 시, Error Code를 4001로 내리는 방식.

Multi Module 구조에서 Gradle을 사용한 배포

  • Multi Module 구조에서는 원하는 Module만 골라서 빌드 & 배포가 가능하다.
  • 빌드 툴로는 Gradle 또는 Maven을 사용하며, 최근에는 주로 Gradle이 사용된다.
  • Gradle을 사용하여 빌드 & 배포를 하려면 Gradle 문법을 이해해야 한다.
  • 예시: ./gradlew clean :module-api:buildNeeded --stacktrace --info --refresh-dependencies -x test

실습

순서

1. API 모듈 생성

2. Common 모듈 생성

3. API와 Common 모듈 의존성 설정

4. Excpetion 핸들링

5. DB 연동

6. Gradle을 사용하여 빌드 및 배포

 

1. API 모듈 생성

 

멀티 모듈에서 루트 프로젝트는 특별한 의존성이 필요 없으며, 루트 프로젝트에 소스 코드를 포함할 필요가 없으므로 src 폴더를 삭제한다.

 

다음으로 API 모듈을 생성한다.

 

API 모듈은 실제로 서버를 띄우는 목적을 가지기 때문에 Lombok과 Spring Web 두 가지 의존성을 추가한다.

 

module-api 프로젝트에서 필요 없는 파일(static, templates 등)을 삭제한다.

 

2. Common 모듈 생성

이어서 common 모듈을 생성한다.

 

Common 모듈은 서버를 띄우는 용도가 아니기 때문에 application.properties 파일을 삭제한다.

 

여기까지 두 개의 모듈(API, Common)을 생성한 후, 루트 프로젝트에 두 모듈을 명시해 준다.

 

settings.gradle

rootProject.name = 'multimodule'

include 'module-api'
include 'module-common'

 

3. API와 Common 모듈 의존성 설정

API 모듈에서 Common 모듈을 참조하기 위해 build.gradle 파일에 다음과 같이 의존성을 추가한다.

implementation project(':module-common') // root project -> settings.gradle에 선언한 값과 동일해야 함
  • module-common은 루트 프로젝트의 settings.gradle 파일에 정의된 모듈명과 일치해야 한다.

 

그래들을 새로고침 하면 다음과 같은 에러가 발생한다.

  • 에러 발생 원인: module-api 모듈에 별도의 settings.gradle 파일이 존재하여 문제가 발생한 것이다.
  • 해결: 각 모듈(module-api 및 module-common)의 settings.gradle 파일을 삭제한다.

모든 설정이 완료되면 서버를 구동하여 API 모듈과 Common 모듈 간의 의존성이 정상적으로 동작하는지 확인한다.

 

1) 순수한 Java 클래스

Common 모듈에 enums 패키지를 생성하고 CodeEnum 클래스를 추가한다.

 

API 모듈에서 Common 모듈의 CodeEnum을 참조하는 코드를 작성한다.

package dev.be.moduleapi.controller;

import dev.be.moduleapi.service.DemoService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class DemoController {

    private final DemoService demoService;

    @GetMapping("/save")
    public String save() {
        return demoService.save();
    }

    @GetMapping("/find")
    public String find() {
        return demoService.find();
    }
}
  • API 모듈에 DemoController와 DemoService 클래스를 생성하여 /save 및 /find 엔드포인트를 만든다.
package dev.be.moduleapi.service;

import dev.be.modulecommon.enums.CodeEnum;
import org.springframework.stereotype.Service;

@Service
public class DemoService {

    public String save() {
        System.out.println(CodeEnum.SUCCESS.getCode());
        return "save";
    }

    public String find() {
        return "find";
    }
}

 

서버를 구동하고 터미널에서 curl 명령어를 사용하여 /save API 요청을 보낸다.

응답으로 save가 반환되며, 서버 로그에서 CodeEnum.SUCCESS.getCode()의 값 0000이 출력되는 것을 확인한다.

 

이로써 API 모듈에서 Common 모듈의 순수한 Java 객체를 정상적으로 참조하여 사용할 수 있음을 확인했다.

 

2) Spring Bean 

Common 모듈에서 CommonDemoService라는 Service를 작성한다.

package dev.be.modulecommon.service;

import org.springframework.stereotype.Service;

@Service
public class CommonDemoService {

    public String commonService() {
        return "commonService";
    }
}

 

이후 api 모듈의 serviec에서 common 모듈 쪽에 있는 Bean을 주입받기 위해 아래와 같이 코드를 추가한다.

@Service
@RequiredArgsConstructor
public class DemoService {

    private final CommonDemoService commonDemoService;

    public String save() {
       ...
        System.out.println(commonDemoService.commonService());
    }
    
    ...
}

 

이제 출력값을 보기 위해 서버를 실행시킨다.

 

그러나, API 모듈에서 Bean 주입 실패 오류가 발생한다. 이는 Spring의 컴포넌트 스캔 범위가 제한되어 있기 때문이다.

즉, moduleapi 패키지의 하위에 있는 Bean들만 등록이 된 것이다.

 

common 모듈에서도 dev.be까지는 동일하지만 modulecommon이라는 패스는 moduleapi 패키지에서 찾을 수 없다.

따라서 common 모듈의 하위에 있는 CommonDemoService는 빈으로 등록되지 않은 것이다.

 

이를 해결하기 위해 API 모듈의 ModuleApiApplication 클래스를 be 패키지로 이동시킨다.

 

서버를 재구동한 후, API 모듈에서 Common 모듈의 CommonDemoService를 성공적으로 참조할 수 있음을 확인한다.

 

이처럼 멀티 모듈을 설정할 때 순수한 자바 같은 경우에는 크게 문제가 될 포인트가 없지만, Spring Bean이라는 개념이 들어오면서 컴포넌트 스캔이 굉장히 중요해지고 정상적으로 일어나야지만 다른 모듈에서 또 다른 모듈이 있는 Bean을 주입받아 사용할 수 있다는 것을 주의해야 한다.

 

3) 실무에서 사용하는 모듈 간 Component 스캔 방법

애플리케이션의 위치를 변경해서 컴포넌트 스캔을 하는 방법 외에도 다른 방법이 있다.

바로 @SpringBootApplication 어노테이션에 있는 scanBasePackages 속성을 활용하는 것이다.

package dev.be.moduleapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication (
		scanBasePackages = {"dev.be.moduleapi", "dev.be.modulecommon"}
)
public class ModuleApiApplication {

	public static void main(String[] args) {
		SpringApplication.run(ModuleApiApplication.class, args);
	}

}

이렇게 설정하면, Spring Boot는 dev.be.moduleapi와 dev.be.modulecommon 패키지에 있는 Bean들을 스캔하여 자동으로 컨텍스트에 등록한다.

 

서버를 실행 후 테스트를 하면, 정상적으로 원하는 결과를 확인할 수 있다.

 

이 방법의 장점은, 필요한 패키지들만 선택적으로 스캔할 수 있어, 애플리케이션이 커지더라도 불필요한 Bean 스캔을 피할 수 있다는 점이다.

 

4. Excpetion 핸들링

이번에는 앞서 정의한 CodeEnum의 code 값을 활용하여 module-api에서 예외를 어떻게 핸들링하는지 살펴본다. 서버와 클라이언트가 통신할 때 규약에 따라 특정 포맷으로 데이터를 주고받아야 하며, 이 포맷을 먼저 정의하는 것으로 시작한다.

 

1) CommonResponse 클래스 생성

module-api 모듈의 response 패키지에 공통된 응답 규약을 정의하는 CommonResponse 클래스를 생성한다.

package dev.be.moduleapi.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import dev.be.modulecommon.enums.CodeEnum;
import lombok.*;

@Getter
@Setter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
@NoArgsConstructor
@AllArgsConstructor
public class CommonResponse<T> {
    private String returnCode;
    private String returnMessage;
    private T info;

    public CommonResponse(CodeEnum codeEnum) {
        setReturnCode(codeEnum.getCode());
        setReturnMessage(codeEnum.getMessage());
    }

    public CommonResponse(T info) {
        setReturnCode(CodeEnum.SUCCESS.getCode());
        setReturnMessage(CodeEnum.SUCCESS.getMessage());
        setInfo(info);
    }

    public CommonResponse(CodeEnum codeEnum, T info) {
        setReturnCode(codeEnum.getCode());
        setReturnMessage(codeEnum.getMessage());
        setInfo(info);
    }
}
  • 이 클래스는 서버와 클라이언트 간 통신에서 사용할 응답 형식을 정의하며, 다양한 상황에 맞춰 코드와 메시지, 정보를 반환할 수 있다.

2) CustomException 클래스 생성

예외 처리 시, 객체에 담아 반환하기 위해 CustomException 클래스를 생성한다.

package dev.be.moduleapi.exception;

import dev.be.modulecommon.enums.CodeEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CustomException extends RuntimeException {
    private String returnCode;
    private String returnMessage;

    public CustomException(CodeEnum codeEnum) {
        super(codeEnum.getMessage());
        setReturnCode(codeEnum.getCode());
        setReturnMessage(codeEnum.getMessage());
    }
}
  • 이 클래스는 CodeEnum을 받아 커스텀 예외를 생성하며, 해당 예외가 발생했을 때 클라이언트에게 정의된 코드와 메시지를 전달한다.

3) 예외를 발생시키는 Controller와 Service 생성

예외 발생 테스트를 위해 DemoController와 DemoService에 예외를 발생시키는 메서드를 추가한다.

@RestController
@RequiredArgsConstructor
public class DemoController {

  ...
  
  @GetMapping("/exception")
    public String exception() {
        return demoService.exception();
    }
}

--------------------------
@Service
@RequiredArgsConstructor
public class DemoService {

    private final CommonDemoService commonDemoService;

    ...
    
    public String exception(){
        if (true) {
            throw new CustomException(CodeEnum.UNKNOWN_ERROR);
        }
        return "exception";
    }
}
  • 이 코드는 특정 조건에서 CustomException을 발생시키며, exception 엔드포인트로 요청 시 예외가 발생하도록 설계되었다.

4) GlobalExceptionHandler 클래스 생성

예외 발생 시, 커스텀 예외를 잡아 처리할 GlobalExceptionHandler 클래스를 생성한다.

package dev.be.moduleapi.exceptionhandler;

import dev.be.moduleapi.exception.CustomException;
import dev.be.moduleapi.response.CommonResponse;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public CommonResponse handleCustomException(CustomException e) {
        return CommonResponse.builder()
                .returnCode(e.getReturnCode())
                .returnMessage(e.getReturnMessage())
                .build();
    }
    
       @ExceptionHandler(Exception.class)
    public CommonResponse handleException(Exception e) {
        return CommonResponse.builder()
                .returnCode(CodeEnum.UNKNOWN_ERROR.getCode())
                .returnMessage(CodeEnum.UNKNOWN_ERROR.getMessage())
                .build();
    }
}
  • 이 클래스는 @RestControllerAdvice를 사용해 모든 컨트롤러에서 발생하는 예외를 처리하며, CustomException과 기타 예외를 각각 처리한다. 이를 통해 예외 상황에서 클라이언트에게 일관된 포맷으로 응답을 보낼 수 있다.

이처럼, 실제 IT 회사에서는 예외를 커스텀 예외 클래스에 매핑하고, 해당 예외를 서비스에서 throw 한 후 RestControllerAdvice를 통해 처리하는 방식으로 Exception을 핸들링한다. 이는 예외 발생 시 예외 상황에 맞는 코드와 메시지를 클라이언트에게 전달함으로써, 보다 명확하고 일관된 통신 규약을 유지할 수 있게 해 준다.

 

5. DB 연동

Spring Boot 멀티 모듈 프로젝트에서 DB 연동을 설정하고 테스트하는 방법에 대해 설명한다.

 

먼저, build.gradle 파일에 MySQL과 JPA 관련 의존성을 추가한다.

id 'java-library'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.mysql:mysql-connector-j'

이 설정에서 api 키워드를 사용하기 위해 플러그인에 java-library를 추가했으며, 데이터베이스는 MySQL을, ORM은 JPA를 사용한다.

 

엔티티 설정

JPA를 사용하기 위해 엔티티를 먼저 정의한다. Member라는 엔티티를 아래와 같이 생성했다.

package dev.be.modulecommon.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String name;
}

 

리포지토리 설정

이 엔티티를 관리하기 위해 리포지토리를 생성한다.

package dev.be.modulecommon.repositories;

import dev.be.modulecommon.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}
  • 이제 DB에 연결할 수 있는 최소한의 설정을 완료했다.

데이터베이스 설정

서버를 실행시키면, DataSource 설정 오류가 발생한다. 이는 데이터베이스 연결 정보를 설정하지 않았기 때문이다.

 

application.yml에 다음과 같이 설정을 추가한다.

spring:
  main:
    allow-bean-definition-overriding: true
  datasource:
    url: jdbc:mysql://localhost:3306/multiTest
    username: multitester
    password: 1234
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    database: mysql
    database-platform: org.hibernate.dialect.MySQLDialect
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        use_sql_comments: true
        jdbc:
          time_zone: Asia/Seoul
  • 설정을 추가한 후 서버를 다시 실행하면 데이터베이스와의 연결이 성공적으로 이루어진다.

서비스 구현 및 테스트

MemberRepository를 사용하여 데이터를 저장하고 조회하는 기능을 구현한다.

package dev.be.modulecommon.repositories;

import dev.be.modulecommon.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}
package dev.be.moduleapi.service;

import org.springframework.stereotype.Service;

import dev.be.moduleapi.exception.CustomException;
import dev.be.modulecommon.domain.Member;
import dev.be.modulecommon.enums.CodeEnum;
import dev.be.modulecommon.repositories.MemberRepository;
import dev.be.modulecommon.service.CommonDemoService;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class DemoService {

    private final MemberRepository memberRepository;

    public String save() {
        memberRepository.save(Member.builder()
                .name(Thread.currentThread().getName())
                .build());
        return "save";
    }

   ...
}

 

엔티티 스캔 및 리포지토리 활성화

최종적으로, 메인 애플리케이션에서 엔티티와 리포지토리를 스캔할 경로를 명시해야 한다.

@EntityScan("dev.be.modulecommon.domain")
@EnableJpaRepositories(basePackages = "dev.be.modulecommon.repositories")
public class ModuleApiApplication {

	public static void main(String[] args) {
		SpringApplication.run(ModuleApiApplication.class, args);
	}

}

 

서버를 실행하면 정상적으로 DB에 데이터가 저장되는 것을 확인할 수 있다.

DB 연동 설정 시, ddl-auto: create 설정은 테이블을 매번 드롭하고 새로 생성하는 방식이다. 이 설정은 개발 환경에서 유용할 수 있지만, 실무 환경에서는 매우 주의해야 한다. 운영 서버에서 create를 사용하면 기존 데이터가 모두 삭제되고 새로운 테이블이 생성되기 때문에 중요한 데이터가 손실될 수 있다.

 

이러한 위험성을 피하기 위해 운영 환경에서는 ddl-auto 설정을 none으로 설정하는 것을 강력히 추천한다. none 설정은 Hibernate가 스키마를 변경하지 않도록 하여, 실수로 데이터베이스 구조가 변경되는 일을 방지할 수 있다.

 

curl 명령을 사용하여 실제 DB에 값이 저장되고 조회되는 것을 확인할 수 있다.

처음에는 DB에 아무 값도 없어서 DB size: 0으로 조회되지만, save 요청을 보낸 후에는 DB size: 1로 값이 저장된 것을 확인할 수 있다.

 

이 과정을 통해 Spring Boot 멀티 모듈 프로젝트에서 DB를 연동하고 사용하는 방법을 학습했다. 또한, 데이터베이스 연결 및 설정에서 필요한 애노테이션들이 어떤 역할을 하는지 확인할 수 있었다.

 

6. Gradle을 사용하여 빌드 및 배포

이번에는 스프링 멀티 모듈 프로젝트를 Gradle을 사용하여 빌드하고, 빌드된 결과물인 JAR 파일을 이용해서 실제로 서버를 띄워보는 과정까지 진행한다.

 

우선, Gradle로 빌드를 하는 이유는, 로컬 환경에서 개발할 때는 IntelliJ의 도움을 받아서 서버를 띄울 수 있지만, 실제 운영 서버 같은 경우는 JAR 파일을 넘겨서 실행시켜야 하기 때문이다. 따라서 Gradle과 같은 빌드 툴을 사용해서 JAR 파일을 만들어야 한다.

 

멀티 모듈 프로젝트에서는 추가적으로 설정해줘야 하는 것이 있다. module-common 모듈의 build.gradle에서 다음 옵션을 추가한다.

tasks.bootJar { enabled = false } // xxx.jar
tasks.jar {enabled = true} // xxx-plain.jar , 기본값이 true다.

 

module-common 모듈은 다른 모듈에서 참조하는 모듈이기 때문에 실행 가능한 JAR 파일을 만들 필요가 없다. 따라서 bootJar는 비활성화하고, 기본 jar 파일만 생성하도록 설정한다. 이때 생성되는 xxx-plain.jar 파일은 의존성을 포함하지 않고, 클래스와 리소스만 포함하기 때문에 서버를 실행시킬 수 없다.

 

Gradle로 빌드하는 명령어는 다음과 같다.

./gradlew clean :module-api:buildNeeded --stacktrace --info --refresh-dependencies -x test

 

이 명령어는 module-api 모듈과 그에 필요한 모든 모듈을 빌드하는데, 테스트 코드는 제외한다. 명령어를 실행시키면 build 폴더가 생성되고, plain.jar 파일이 생성된다.

 

 

반면, module-api 모듈은 build.gradle에 추가 설정이 없기 때문에 두 개의 JAR 파일이 생성된다.

module-api-0.0.1-SNAPSHOT.jar과 module-api-0.0.1-SNAPSHOT-plain.jar 파일이다.

 

jar파일로 서버를 실행시키려면 먼저 jar파일이 있는 곳으로 이동해야 한다.

서버를 실행시키려면 다음 명령어를 사용한다.

java -jar module-api-0.0.1-SNAPSHOT.jar

이 명령어로 IntelliJ가 아닌 JAR 파일로 서버를 띄울 수 있다. 

 

터미널에서 curl 명령어로 서버의 API를 테스트하면 정상적으로 응답이 오는 것을 확인할 수 있다.

 

마지막으로, 다음 명령어로 빌드 폴더를 삭제한다:

./gradlew clean

 

여기까지 그래들을 사용해서 빌드하고, 서버를 띄워보는 작업까지 해봤다.