개발/Spring

[Spring 실전] 1. 멀티모듈

희묭 2025. 6. 18. 11:01
반응형

Spring 프로젝트를 생성하는 시점에서 멀티모듈을 고려하는 이유는 재사용성과 개발 및 배포의 효율성을 높이기 위해서입니다.

사람마다 다르지만 저는 모듈을 기본 6(+@)가지로 나누는데

  • Common : Enum, Response, Exception 등의 공통소스 
  • DataSource : 데이터베이스 관련 소스
  • Frontend : 프론트엔드소스
  • API : Controller, 기본적인 Service 소스
  • User (Securiy) : CSRF, JWT, CORS 등 보안관련 소스
  • ServiceLogic : 비즈니스 중심의 Service 소스

여기에 필요에따라서는 API서비스가 여러개로 나눠질수 있고

웹소켓, MQ, mongo, 모바일용 등등이 들어갈수 있지만 기본적인 구성은 위 6개입니다

 

프로젝트생성

최상단에서는 공통으로 사용될 jpa와 lombok 을 추가하겠습니다

프로젝트를 생성한뒤에는 src를 삭제합니다

그리고 root 의 build.gradle 을 다음과같이 수정합니다

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


subprojects {
    apply plugin: 'java'
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

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

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

    repositories {
        mavenCentral()
    }

    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    }

}

 

 

간단하게 API 모듈을 먼저 붙여보겠습니다

demo-api 라는 모듈을 생성한뒤 src, git관련, build.gradle 을 남기고 모두 지워줍니다

그리고 build.gradle 은 다음과같이 수정합니다

description = "demo-api"

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

 

intellij 를 사용한다면 프로젝트와 모듈 수정과정에서 gradle 구성이 올바르게 반영 안될수 있습니다

다음과같이 정리해줍니다

 

 

이제 DataSource 를 붙여서 API 서버를 기동해보겠습니다.

Domain 모듈을 생성하고 build.gradle 에 다음과같이 세팅해줍니다

description = "demo-domain"

dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'

    annotationProcessor("com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta")
    annotationProcessor('jakarta.persistence:jakarta.persistence-api')
    annotationProcessor('jakarta.annotation:jakarta.annotation-api')
}

 

resources 에는 application-domain.yml 을 작성해줍니다

spring:
  jpa:
    open-in-view: false
    hibernate.ddl-auto: none
    properties:
      hibernate.default_batch_fetch_size: ${chunkSize:1000}
      hibernate.connection.provider_disables_autocommit: true
      hibernate.jdbc.batch_size: ${chunkSize:1000}
      hibernate.order_inserts: true
      hibernate.order_updates: true
      hibernate.format_sql: true

---

spring:
  config.activate.on-profile: local
  jpa:
    hibernate:
      ddl-auto: none
  datasource:
    url: jdbc:mariadb://192.168.100.64:12001/qqq
    username: root
    password: rootroot
    driver-class-name: org.mariadb.jdbc.Driver
    hikari:
      connection-timeout: 3000
      max-lifetime: 58000  # 58s
      maximum-pool-size: 10
      auto-commit: false
      data-source-properties:
        connectTimeout: 3000
        socketTimeout: 60000
        useUnicode: true
        characterEncoding: utf-8
        rewriteBatchedStatements: true

 

API 쪽도 다음과같이 수정해줍니다

description = "demo-api"

dependencies {
    implementation project(":demo-domain")

    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
}
server:
  port: 8080

spring:
  profiles:
    include:
      - domain

 

실행환경을 local 로 하고 api 모듈을 실행하면 정상적으로 서버가 올라가는것을 확인하실수있습니다

 

API 모듈에서 공통으로 사용될 common 모듈도 생성해봅시다

common 모듈구조

package com.example.common.exception;

import com.example.common.response.ApiResponse;
import com.example.common.response.ApiResponseCode;
import com.example.common.response.ApiResponseGenerator;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class ApiExceptionHandler {

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<?>> handleException(Exception e, HttpServletRequest request) {

        String message = e.getMessage();

        if(e.getClass() == MethodArgumentNotValidException.class) {
            Map<String, String> parm = new HashMap<>();
            var fields = ((MethodArgumentNotValidException) e).getBindingResult().getFieldErrors();
            for (var field : fields) {
                parm.put(field.getField(), field.getDefaultMessage());
            }
            var apiResponse = ApiResponseGenerator.fail(ApiResponseCode.BUSINESS_ERROR, message, parm);
            return new ResponseEntity<>(apiResponse, HttpStatus.INTERNAL_SERVER_ERROR);

        }else if(e.getClass() == BusinessParmException.class){
            var parm = ((BusinessParmException) e).getErros();
            var apiResponse = ApiResponseGenerator.fail(ApiResponseCode.BUSINESS_ERROR, message, parm);
            return new ResponseEntity<>(apiResponse, HttpStatus.INTERNAL_SERVER_ERROR);
        }else{
            var apiResponse = ApiResponseGenerator.fail(ApiResponseCode.SYSTEM_ERROR, message);
            return new ResponseEntity<>(apiResponse, HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
}
package com.example.common.exception;

public class BusinessException extends RuntimeException {

    public BusinessException(String message) {
        super(message);
    }

    public BusinessException(String message, Throwable cause) {
        super(message, cause);
    }

}
package com.example.common.exception;

import java.util.Map;

public class BusinessParmException extends RuntimeException {

    Map<String,String> erros;

    public Map<String, String> getErros() {
        return erros;
    }

    public BusinessParmException(String message, Map<String,String> erros) {
        super(message);
        this.erros = erros;
    }

    public BusinessParmException(String message, Throwable cause) {
        super(message, cause);
    }

}
package com.example.common.response;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
@NoArgsConstructor
public class ApiResponse<T> {

    private String code;

    private String message;

    private T data;

    ApiResponse(ApiResponseCode apiResponseCode){
        this(apiResponseCode, null, null);
    }

    ApiResponse(ApiResponseCode apiResponseCode, String responseMessage, T data){
        this.code = apiResponseCode.getCode();
        this.message = ( responseMessage == null ? apiResponseCode.getDefaultMessage() : responseMessage );
        this.data = data;
    }
}
package com.example.common.response;

import lombok.ToString;

@ToString
public enum ApiResponseCode {

    SUCCESS                     ("2000", "OK"),
    SYSTEM_ERROR                ("1001", "시스템 오류"),
    SYSTEM_PERMISSION_ERROR     ("1002", "시스템 권한 오류"),
    SYSTEM_STATUS_ERROR         ("1003", "시스템 상태 이상"),
    RESOURCE_NOT_FOUND          ("4004", "해당 리소스가 없음"),
    BUSINESS_ERROR              ("4005", "요구사항에 맞지 않음"),
    BAD_REQUEST_ERROR           ("9000", "부적절한 요청 오류"),
    UNAUTHORIZED_ERROR          ("9001", "인증 오류"),
    UNKNOWN_ERROR               ("9999", "알 수 없는 오류");

    private final String code;

    private final String defaultMessage;

    ApiResponseCode(String code, String defaultMessage) {
        this.code = code;
        this.defaultMessage = defaultMessage;
    }

    public String getCode() {
        return this.code;
    }

    public String getDefaultMessage() {
        return this.defaultMessage;
    }

}
package com.example.common.response;

public class ApiResponseGenerator {

    private ApiResponseGenerator() {

    }

    public static ApiResponse<Void> success(){
        return new ApiResponse<>(ApiResponseCode.SUCCESS);
    }

    public static <D> ApiResponse<D> success(D data){
        return new ApiResponse<>(ApiResponseCode.SUCCESS, "success", data);
    }

    public static ApiResponse<Void> fail(){
        return new ApiResponse<>(ApiResponseCode.UNKNOWN_ERROR);
    }

    public static ApiResponse<Void> fail(ApiResponseCode code){
        return new ApiResponse<>(code);
    }

    public static ApiResponse<Void> fail(ApiResponseCode code, String msg){
        return new ApiResponse<>(code, msg, null);
    }

    public static <D> ApiResponse<D> fail(ApiResponseCode code, String msg, D data){
        return new ApiResponse<>(code, msg, data);
    }

}
package com.example.common.response;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

import java.util.Collections;
import java.util.List;

@Getter
@Setter
@ToString
@NoArgsConstructor
public class PageResponse<T> {

    private int pageSize;

    private int pageNumber;

    private int totalPageNumber;

    private Long totalSize;

    private List<T> list;

    public PageResponse(int pageSize, int pageNumber, int totalPageNumber, Long totalSize, List<T> list) {
        this.pageSize = pageSize;
        this.pageNumber = pageNumber;
        this.totalPageNumber = totalPageNumber;
        this.totalSize = totalSize;
        this.list = list;
    }

    public static <T> PageResponse <T> empty(PagingOption pagingOption) {
        return new PageResponse<>(pagingOption.getPageSize(), pagingOption.getPageNumber() + 1, 0, 0L, Collections.emptyList());
    }

}
package com.example.common.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter
@ToString
public class PagingOption {

    @Setter(AccessLevel.NONE)
    @Getter(AccessLevel.NONE)
    public static final PagingOption DEFAULT = new PagingOption();

    @Schema(description = "한페이지당 보여줄 건수", example = "20")
    private int pageSize;

    @Schema(description = "현재 페이지", example = "1")
    private int pageNumber;

    public PagingOption() {
        this.pageSize = 20;
        this.pageNumber = 1;
    }

    public PagingOption(int pageSize, int pageNumber) {
        this.pageSize = pageSize;
        this.pageNumber = pageNumber;
    }

}

 

common 모듈은 추후에 사용됩니다.

반응형