
plugins {
id 'java-library' // 프로젝트를 자바 라이브러리로 설정 (api 키워드 사용 가능)
id 'maven-publish' // 빌드된 아티팩트를 Maven 저장소에 배포하기 위한 플러그인
id 'org.springframework.boot' version '4.0.4'
id 'io.spring.dependency-management' version '1.1.7'
}
// 의존성 이름이 된다.
group = 'com.github.mimimya'
version = '1.0-SNAPSHOT'
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
bootJar { enabled = false } // 실행 가능한 jar 생성 비활성화 (라이브러리이므로 필요 없음)
jar {
enabled = true
archiveClassifier.set('') // 일반 jar 생성
}
repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' } // 스프링 부트 마일스톤 버전 사용을 위한 저장소
}
ext {
set('springCloudVersion', "2025.1.1")
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
api 'org.springframework.boot:spring-boot-starter-kafka'
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-validation'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api "org.springframework.boot:spring-boot-starter-aop:4.0.0-M2"
api 'org.springframework.cloud:spring-cloud-starter-config'
api 'org.springframework.cloud:spring-cloud-starter-loadbalancer'
api 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
api 'org.springframework.cloud:spring-cloud-starter-openfeign'
api "io.github.resilience4j:resilience4j-spring-boot3:2.2.0"
api 'io.github.resilience4j:resilience4j-circuitbreaker'
api 'io.github.resilience4j:resilience4j-retry'
api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2'
api "com.github.danielwegener:logback-kafka-appender:0.2.0-RC2"
api 'net.logstash.logback:logstash-logback-encoder:7.4'
api 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
publishing {
publications {
gpr(MavenPublication) {
from components.java
artifactId = 'common'
}
}
repositories {
maven {
name = "GitHubPackages" // 저장소 별칭
url = uri("https://maven.pkg.github.com/mimimya/msa-common") // GitHub Packages 저장소 주소
credentials {
username = System.getenv("GPR_USER") // 환경 변수에서 GitHub 사용자 ID
password = System.getenv("GPR_TOKEN") // 환경 변수에서 GitHub Personal Access Token(Classic) 로드
}
}
}
}
// QueryDSL Q클래스 생성 경로 설정
def querydslDir = layout.buildDirectory.dir("querydsl").get().asFile
sourceSets {
main.java.srcDirs += [ querydslDir ]
}
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(querydslDir))
}
clean.doLast {
file(querydslDir).deleteDir()
}
implementation으로 선언된 의존성은 공통 모듈 내부에서만 유효하며, 해당 모듈을 참조하는 하위 프로젝트에서는 접근할 수 없다
프로젝트 전반에 공유될 공통 의존성은 api 방식으로 추가한다.
공통 모듈을 설계하다 보면 "내가 쓴 롬복, 가져다 쓰는 서비스도 편하게 쓰게 api로 뚫어주면 좋지 않을까?"라는 유혹에 빠지기 쉽다. 하지만 이는 의존성 오염(Dependency Pollution)을 유발하는 아주 위험한 설계 방식이다!
결론부터 말하자면, 롬복은 compileOnly로 설정하여 배송 명세서(POM)에서 완전히 제외하는 것이 정석이다.
배송 명세서(POM)의 순수성 유지: api로 선언하여 배포하면, 공통 모듈을 가져다 쓰는 모든 서비스의 의존성 목록에 롬복이 강제로 박제된다. 이는 해당 서비스가 롬복을 쓰고 싶지 않거나 다른 버전을 쓰고 싶어도 강제로 주입받게 되는 '오염' 상태를 만든다.
전파(Propagation)의 확실한 차단: compileOnly는 "나(공통 모듈) 빌드할 때만 잠깐 쓰고, 남한테는 절대 안 보낼게!"라는 선언이다.
Gradle은 배포용 pom.xml을 생성할 때 compileOnly 항목을 슥 삭제해버린다. 덕분에 라이브러리는 군더더기 없이 가볍고 깨끗한 상태로 공유된다.
각 서비스의 독립성 보장: 조금 번거로워 보일지라도, 사용하는 각 마이크로서비스가 자신의 build.gradle에 롬복을 직접 명시하는 것이 MSA 환경에서 서비스 간 결합도를 낮추고 각자의 환경을 지키는 가장 올바른 길이다.
롬복은 요리를 돕는 '조리 도구'일 뿐이다! 요리(라이브러리)를 정성껏 만들어 배달할 때, 조리 도구까지 손님 식탁(api)에 올리는 실수를 범하지 말자. 손님은 자기만의 도구(compileOnly)를 직접 준비하는 것이 매너다!
maven 저장소에 대한 설정을 해준다.
publishing {
publications {
gpr(MavenPublication) {
from components.java
artifactId = 'common'
}
}
repositories {
maven {
name = "GitHubPackages" // 저장소 별칭
url = uri("https://maven.pkg.github.com/mimimya/msa-common") // GitHub Packages 저장소 주소
credentials {
username = System.getenv("GPR_USER") // 환경 변수에서 GitHub 사용자 ID
password = System.getenv("GPR_TOKEN") // 환경 변수에서 GitHub Personal Access Token(Classic) 로드
}
}
}
}
이곳에 써진 url에 따라
Gradle은 https://maven.pkg.github.com 이라는 주소에 가서, mimimya라는 사람의 msa-common이라는 칸에 패키지를 넣어줘"라고 인식하게 된다.
maven.pkg.github.com은 깃헙이 만든 maven 규칙에 따라 패키지를 저장할 수 있는 저장소이다.
GitHub에는 Maven 방식 외에 다른 저장소들도 있다.
npm.pkg.github.com: Node.jsdocker.pkg.github.com: 도커 이미지org.springframework.boot.autoconfigure.AutoConfiguration.imports
resources/META-INF/spring경로에 파일 생성
공통 모듈의 빈(Bean)은 수동으로 등록한다.
추가 설정 클래스를 포함해 수동으로 빈(Bean)을 관리해야 한다면 @Import 어노테이션을 활용한다.
이때 각 서비스 인스턴스별로 등록된 설정을 우선적으로 적용하려면 @ConditionalOnMissingBean을 사용합니다.
설정 시에는 특정 인터페이스 타입을 지정하여 등록한다.
서비스 인스턴스별로 변경이 필요한 경우에 해당 인터페이스를 직접 구현하여 빈으로 등록하면 된다.


권한 Scope로 write:packages와 read:packages가 포함된 토큰을 발급해준다. (Classic 방식 권장)
(Fine-grained personal access tokens로 발급한 토큰의 경우, GitHub Packages로 배포할 때 아직 호환성 문제가 발생할 수 있다.)
export GPR_USER=<GitHub 아이디>
export GPR_TOKEN=<GitHub에서 발급한 토큰>

gradle clean build
gradle publish


해당 GitHub 레포지토리의 [Packages] 탭을 목록에서 배포한 패키지를 확인할 수 있다.

퍼블릭 레포지토리 일 땐
오픈소스 생태계 발전을 위해 GitHub에서 저장 용량이나 대역폭(다운로드 횟수) 제한 없이 무료로 제공한다.
프라이빗 레포지토리 일 때만
로 정해진 사용량을 초과하면 비용이 부과된다.
...
repositories {
mavenCentral()
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/mimimya/msa-common"")
credentials {
username = System.getenv("GPR_USER") // 배포할 때 썼던 ID
password = System.getenv("GPR_TOKEN") // 배포할 때 썼던 PAT(Classic)
}
}
}
...
dependencies {
implementation 'com.github.mimimya:common:1.0-SNAPSHOT'
// 공통 모듈이 api로 보내주지만,
// '테스트 전용 설정'인 testCompileOnly나
// '컴파일 시점에 코드를 생성'하는 도구인 annotationProcessor는 각 서비스에 적어줘야 합니다.
// Querydsl APT
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
// Querydsl JPA
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testRuntimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-kafka-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
...
def querydslDir = layout.buildDirectory.dir("querydsl").get().asFile
sourceSets {
main.java.srcDirs += [ querydslDir ]
}
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(querydslDir))
}
clean.doLast {
file(querydslDir).deleteDir()
}
Querydsl의 핵심인 QClass는 컴파일 시점에 해당 프로젝트의 엔티티(@Entity)를 분석해서 생성된다.
공통 모듈(msa-common)에 있는 엔티티들에 대한 QClass는 공통 모듈 빌드 때 생성되어 패키지에 포함되지만,
가져다 쓰는 서비스(예: 주문 서비스)에서 직접 만드는 엔티티들에 대한 QClass는 해당 서비스가 빌드될 때 새로 만들어져야 하기때문에,
따라서 QClass를 생성하는 경로 설정(querydslDir)과 컴파일 옵션은 서비스마다 필요하다.
배포한 패키지에 springCloud 의존성이 있기때문에, 패키지를 받아오는 프로젝트의 빌드 설정에도 dependencyManagement가 필요함에 유의한다.
ext {
set('springCloudVersion', "2025.1.1")
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
저장소 URL을 https://maven.pkg.github.com/<Organization>/<repo> 처럼 바꾸면 단체의 레포에서도 배포 가능하다.
(단, 각자 해당 패키지에 엑세스 가능한 토큰을 사용해야함)

단순한 CRUD 프로젝트에서는 이 두 계층의 DTO가 거의 똑같이 생겨서 "이걸 왜 굳이 나눠?"라는 의문이 들었다.
그래서 실제 서비스에 나타날 회원가입(User Registration) 예시를 통해 두 DTO의 차이와 내용을 공부해봤다.
이 DTO의 목적은 "외부(클라이언트)와 소통"하는 것이다. HTTP 요청으로 들어온 JSON 데이터를 자바 객체로 바꾸고, 클라이언트에게 예쁜 응답을 주는 역할을 한다.
특징: 웹 전용 어노테이션(@Valid, @NotBlank), Swagger 문서화용 설정(@Schema) 등이 덕지덕지 붙어 있다.
"데이터 형식이 맞는가?", "클라이언트가 보기 편한가?"
// UserSignUpRequest.java (Presentation)
public class UserSignUpRequest {
@NotBlank(message = "이메일은 필수다!")
@Email
private String email;
@NotBlank
@Size(min = 8, max = 16)
private String password;
@NotBlank
private String passwordConfirm; // 비즈니스 로직에는 필요 없지만, 가입 시 확인용!
@NotBlank
private String nickname;
@AssertTrue(message = "약관 동의는 필수다!")
private boolean termsAccepted; // 웹 화면(UI) 종속적인 데이터
}
이 DTO의 목적은 "비즈니스 로직(서비스) 수행"이다. 컨트롤러가 넘겨준 데이터를 받아 서비스 계층이 자기 할 일을 하는 데 필요한 정보만 깔끔하게 담는다.
특징: 웹 관련 어노테이션이 싹 빠진다. 순수한 자바 데이터만 남는다.
관심사: "비즈니스 규칙을 처리하는 데 어떤 데이터가 필요한가?"
// UserCreateCommand.java (Application)
public class UserCreateCommand {
private String email;
private String password; // 암호화 전의 원문 비밀번호
private String nickname;
private UserRole role; // 서비스 내부에서 정하는 기본 권한(USER 등)
// 생성자나 정적 팩토리 메서드로 Presentation DTO를 변환해서 생성
}
현대적인 아키텍처(특히 MSA나 DDD)에서는 시스템의 동작을 딱 두 가지로 나눈다.
Command (쓰기): 시스템의 상태를 바꾸는 작업 (Create, Update, Delete). 데이터를 바꾸기 때문에 아주 조심스럽고 엄격
Query (읽기): 시스템의 상태를 조회하는 작업 (Read).
데이터를 바꾸지 않으니 빠르고 가볍게 처리
'유저 수정'도 그냥 UserUpdateDto라고 안 하고, UpdateUserPasswordCommand, UpdateUserNicknameCommand 처럼 명령을 더 구체적으로 쪼개서 코드를 훨씬 명확하게 관리한다.
프론트엔드: "가입할 때 추천인 코드를 추가하고 싶어요!"
나누지 않았다면, 서비스 로직 코드까지 다 열어서 추천인 코드 필드를 추가하고, 관련 없는 로직들을 다 건드려야 한다. (오염 발생!)
나눴다면, UserSignUpRequest에 필드 하나만 추가하면 끝이다. 서비스 로직(UserCreateCommand)은 바뀔 게 없다면 그대로 두면 된다.
비밀번호 확인(passwordConfirm)이나 약관 동의(termsAccepted)는 오직 "가입 과정"에서만 필요하고, DB에 저장할 데이터가 아니다.
Presentation DTO에는 이 필드들을 넣어서 꼼꼼히 검증하고, 서비스 계층으로 넘길 때는 이 필드들을 버리고 진짜 필요한 데이터만 담은 Application DTO로 변환해서 넘긴다. 서비스 로직이 훨씬 가벼워진다!