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 모듈도 생성해봅시다

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 모듈은 추후에 사용됩니다.
'개발 > Spring' 카테고리의 다른 글
| [Spring 실전] 3. 인증 (모바일) (0) | 2025.06.19 |
|---|---|
| [Spring 실전] 2. 인증 (웹) (0) | 2025.06.19 |
| Itext 를 이용하여 PDF 에 QR코드 넣기 (0) | 2025.03.11 |
| [Swagger] Request가 Map인 경우 Controller 작성법 (0) | 2023.11.01 |
| Querydsl 에서 datetime과 date 비교하기 (0) | 2023.07.10 |