본문 바로가기

MSA

Kotlin으로 직접 구현하며 배우는 MSA - 1. Gradle Kotlin DSL로 모듈러 모놀리스 시작하기

✨ 공부하며 성장하는 기록 공간입니다.


아직 부족한 점이 많고 배워야 할 것도 많지만, 그만큼 하나씩 알아가는 과정에서 큰 재미를 느끼고 있습니다.

이 블로그는 제가 공부한 내용을 정리하고, 직접 구현하며 겪은 고민과 시행착오를 기록하기 위한 공간입니다.

틀리거나 부족한 내용이 있을 수 있습니다. 잘못된 내용이나 더 나은 방향이 있다면 언제든지 편하게 지적해 주세요 😁


해당 포스팅 시리즈에서는 Kotlin/Spring 기반으로 MSA를 이해해보는 과정을 다룰 예정입니다.

단순히 기술을 사용하는 방법만 정리하기보다는, 초기 프로젝트 구축부터 시작해 특정 문제가 왜 발생했는지, 그 문제를 해결하기 위해 어떤 기술과 구조가 등장했는지, 그리고 실제 개발 과정에서는 어떻게 적용할 수 있는지를 함께 공부해보려고 합니다. 가능한 한 복잡한 개념도 쉽게 풀어내고, 제가 직접 이해한 방식으로 정리해보겠습니다.

 

이전 글에서는 이 시리즈를 왜 시작하는지, 그리고 어떤 방향으로 진행할지 정리했다.

이번 글부터는 실제 프로젝트를 만들어보려고 한다.

 

이번 글에서 할 일

이번 글의 목표는 아주 단순하다.

Kotlin + Spring Boot + Gradle Kotlin DSL 기반으로 초기 프로젝트를 만들고, 다음 두 개의 모듈을 구성한다.

common
commerce-monolith

각 모듈의 역할은 다음과 같다.

 

모듈 이름 모듈 역할
common 여러 모듈에서 공통으로 사용할 응답, 에러 모델 관리
commerce-monolith 처음에 실행할 단일 Spring Boot 애플리케이션

 

왜 모듈러 모놀리스로 시작할까?

일반적인 모놀리식 구조는 하나의 애플리케이션 안에 모든 기능이 들어간다.

예를 들면 다음과 같다.

commerce-monolith
 ├── member
 ├── product
 ├── order
 ├── payment
 └── notification

이 구조는 처음에는 개발하기 쉽다.

하나의 애플리케이션만 실행하면 되고, 디버깅도 쉽고, 배포도 단순하다.

하지만 기능이 많아지고 도메인 간 의존성이 커지면 점점 복잡해진다.

그렇다고 해서 처음부터 물리적으로 서비스를 다 나누는 것도 좋은 선택은 아닐 수 있다.

그래서 이번 프로젝트에서는 중간 지점으로 시작한다.

하나의 애플리케이션으로 시작하되, 내부 구조는 나중에 분리할 수 있도록 모듈과 패키지 경계를 의식한다.

 

즉, 처음에는 모놀리식이지만, 아무렇게나 섞어 만들지는 않는다.

초기 프로젝트 구조

이번 단계에서 만들 구조는 다음과 같다.

mini-commerce
 ├── settings.gradle.kts
 ├── build.gradle.kts
 ├── common
 │   └── build.gradle.kts
 └── commerce-monolith
     └── build.gradle.kts

 

build.gradle.kts

전체 프로젝트 공통 설정, 플러그인 버전 선언

plugins {
    kotlin("jvm") version "2.3.21" apply false
    kotlin("plugin.spring") version "2.3.21" apply false
    id("org.springframework.boot") version "4.1.0" apply false
    id("io.spring.dependency-management") version "1.1.7" apply false
}

allprojects {
    group = "com.minicommerce"
    version = "0.0.1-SNAPSHOT"

    repositories {
        mavenCentral()
    }
}

settings.gradle.kts

해당 프로젝트가 어떠한 모듈을 통해서 실행되는지 정의하는 파일

plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "mini-commerce"

include("common")
include("commerce-monolith")

 

해당 설정은 전체 프로젝트에 대한 설정이므로, 각 애플리케이션 설정은 commerce-monolith/build.gradle.kts 같은 하위 모듈에 따로 설정해주어야 한다.

 

위 두 설정 파일에 대한 설명.

더보기

.kts는 kotlin script라는 의미로, Gradle 설정을 Groovy가 아니라 Kotlin DSL로 작성하는 방식이다.
Gradle 공식 문서도 Kotlin DSL이 IDE 자동완성, 리팩토링, 문서 확인 등에 유리하다고 설명한다. 

 

build.gradle.kts

1. plugins { ... }

여기는 이 프로젝트에서 사용할 Gradle 플러그인들의 버전만 미리 선언하는 곳.

 
kotlin("jvm") version "2.3.21" apply false
 
  • Kotlin/JVM 프로젝트를 만들 때 쓰는 플러그인.
  • Kotlin 코드를 JVM 바이트코드로 컴파일하게 해준다.
  • kotlin("jvm")은 Kotlin DSL에서 쓰는 축약 표현이고, 실제로는 Kotlin JVM Gradle plugin을 적용하는 의미.
    • Kotlin Gradle plugin은 JVM 프로젝트에서 Kotlin 컴파일을 Gradle 빌드에 연결하는 역할을 한다.

kotlin("plugin.spring")

 
kotlin("plugin.spring") version "2.3.21" apply false
 

Kotlin 클래스는 기본적으로 final

 
// kotlin으로 사용할 시
class UserService

// Java로는
public final class UserService

// 위 두개가 같은 의미
 
 
  • 그런데 Spring은 AOP, 프록시, 트랜잭션 처리 등을 위해 클래스를 상속하거나 프록시로 감싸야 할 때가 존재.
  • 그래서 Kotlin에서는 Spring 컴포넌트 클래스를 자동으로 open처럼 다룰 수 있게 해주는 플러그인이 필요.
    • Kotlin 공식 문서도 Spring 같은 프레임워크가 open 클래스를 필요로 하는 경우가 있어서 all-open 계열 플러그인이 이를 처리한다고 설명한다.
  • 이 플러그인을 쓰면 이런 클래스를 매번 직접 open으로 쓰지 않아도 된다.
 
@Service
class UserService {
}

 

org.springframework.boot

 
id("org.springframework.boot") version "4.1.0" apply false
 
  • Spring Boot 애플리케이션을 Gradle로 빌드하고 실행하게 해주는 플러그인.

제공하는 기능

- bootRun으로 Spring Boot 앱 실행
- bootJar로 실행 가능한 jar 생성
- Spring Boot 의존성 관리와 연동
 
  • Spring Boot Gradle Plugin은 실행 가능한 jar/war 패키징
  • Spring Boot 애플리케이션 실행
  • dependency management 지원을 제공.
    • 현재 공식 문서 기준으로 Spring Boot Gradle Plugin은 Gradle 8.14 이상 또는 Gradle 9.x를 요구한다.

io.spring.dependency-management

 
id("io.spring.dependency-management") version "1.1.7" apply false
 
  • 의존성 버전을 관리하는 플러그인.
  • 버전은 Spring Boot 쪽 BOM이 맞춰줌.

apply false

 
kotlin("jvm") version "2.3.21" apply false
 

의미

  • 플러그인의 버전은 루트에서 관리.
  • 루트 프로젝트에서는 실제로 적용하지 않고, 필요한 하위 모듈에서 가져다 쓰겠다
 

루트에는 “버전 선언”,

 
plugins {
    kotlin("jvm") version "2.3.21" apply false
    id("org.springframework.boot") version "4.1.0" apply false
}
 

하위 모듈에서 실제 적용한다.

 
plugins {
    kotlin("jvm")
    kotlin("plugin.spring")
    id("org.springframework.boot")
}
 

이렇게 하면 여러 모듈이 같은 플러그인 버전을 공유 가능.


2. allprojects { ... }

 
allprojects {
    group = "com.minicommerce"
    version = "0.0.1-SNAPSHOT"

    repositories {
        mavenCentral()
    }
}
 

이건 루트 프로젝트와 모든 하위 모듈에 공통으로 적용되는 설정.

group

 
group = "com.minicommerce"
 

빌드 결과물의 그룹 ID.

version

 
version = "0.0.1-SNAPSHOT"
 

프로젝트 버전

repositories

 
repositories {
    mavenCentral()
}
 

라이브러리를 어디서 받을지 정하는 설정

이후 의존성을 추가할 때 mavenCentral()에서 해당 라이브러리를 탐색

Gradle은 mavenCentral()에서 해당 라이브러리를 찾는다.


3. settings.gradle.kts

“이 프로젝트가 어떤 프로젝트인지”를 Gradle에게 알려주는 파일.

 
plugins {
    id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "mini-commerce"

include("common")
include("commerce-monolith")

rootProject.name

 
rootProject.name = "mini-commerce"
 

프로젝트의 루트 이름

 
./gradlew projects
 

했을 때 루트 프로젝트 이름이 mini-commerce로 나온다.

./gradlew projects 결과

include("common")

멀티모듈 프로젝트에서 하위 모듈을 등록하는 코드
include("common")
include("commerce-monolith")
 

해당 설정을 통해 멀티 모듈에서 Gradle은 아래 경로를 각각 독립적인 하위 프로젝트로 인식.

mini-commerce/common
mini-commerce/commerce-monolith
 

이걸 해줘야 나중에 이런 식으로 모듈 간 의존성을 걸 수 있음.

 
dependencies {
    implementation(project(":common"))
}
 
 

이건 “Kotlin을 Java 21 기준으로 컴파일하겠다”는 의미야.

정리해서 설명하자면,

settings.gradle.kts
= 프로젝트 이름과 모듈 목록을 정한다.

root build.gradle.kts
= 전체 모듈이 공유할 플러그인 버전과 공통 설정을 정한다.

module build.gradle.kts
= 각 모듈이 실제로 Kotlin인지, Spring Boot 앱인지, 어떤 라이브러리를 쓰는지 정한다.

 

common 모듈 만들기

먼저 common 모듈에는 여러 곳에서 사용할 수 있는 공통 응답 모델을 둔다.

초기에는 아주 단순하게 시작한다.

data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val error: ErrorResponse? = null,
)

실제 코드

더보기
data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val error: ErrorResponse? = null,
) {
    companion object {
        fun <T> success(data: T): ApiResponse<T> = ApiResponse(
            success = true,
            data = data,
        )

        fun failure(code: String, message: String): ApiResponse<Nothing> = ApiResponse(
            success = false,
            error = ErrorResponse(code = code, message = message),
        )
    }
}

data class

데이터를 담기 위한 클래스

자바로 비유하자면

public class ApiResponse<T> {
    private final boolean success;
    private final T data;
    private final ErrorResponse error;

    public ApiResponse(boolean success, T data, ErrorResponse error) {
        this.success = success;
        this.data = data;
        this.error = error;
    }

    public boolean getSuccess() {
        return success;
    }

    public T getData() {
        return data;
    }

    public ErrorResponse getError() {
        return error;
    }
}

해당 코드를 더 짧게 빠르게 쓸 수 있는 것이 Kotlin의 장점

`data class`를 사용하면 다음 것들을 자동으로 생성

  • 생성자
  • getter
  • equals()
  • hashCode()
  • toString()
  • copy()

val

val은 한 번 정하면 바꿀 수 없는 값. Java의 final과 동일

변경이 필요한 값에는 var 키워드 사용!

 

T?의 의미

  • Kotlin에서 기본적으로 변수는 null을 가질 수 없음.
  • null을 허용하려면 타입 뒤에 ?를 붙여야 함.
val name: String = null // 불가능
val name: String? = null // 가능

또한 Kotlin에서는 생성자를 기본적으로 제공해주기 때문에 다음과 같이 생성자를 생성한다. 또한 생성자에 파라미터 이름을 바인딩하기도 한다.

// Java
new ApiResponse<>(true, data, null);

// Kotlin
ApiResponse(
    success = true,
    data = data,
)

 

companion object

Java의 static 키워드와 비슷하게 생각.

하위가 가능

ApiResponse.success(data)
ApiResponse.failure("ERROR_CODE", "에러 메시지")

fun

Kotlin에서는 함수가 한 줄짜리 표현식이면 { return ... } 대신 =로 쓸 수 있음

fun <T> success(data: T): ApiResponse<T> = ApiResponse(
    success = true,
    data = data,
)

에러 응답도 최소 형태로 만든다.

data class ErrorResponse(
    val code: String,
    val message: String,
)

이를 만드는 이유는 각 모든 API가 동일한 양식을 가져야 프론트와도 소통하기 편하고 유지보수하기가 편하다.

성공 응답

/health 체크 응답 예시

실패 응답은 나중에 이런 형태로 확장할 수 있다.

{
  "success": false,
  "error": {
    "code": "PRODUCT_NOT_FOUND",
    "message": "상품을 찾을 수 없습니다."
  }
}

아직은 단순하지만, 이 구조가 앞으로 모든 서비스의 응답 기준이 된다.

 

commerce-monolith 모듈 만들기

commerce-monolith는 현재 단계에서 실행 가능한 유일한 애플리케이션이다.

이 모듈은 common 모듈을 의존한다.

dependencies {
    implementation(project(":common"))
}

health checker

해당 어플리케이션에 실행하는 healtch checker를 만든다.

해당 API를 만드는 이유는

  1. 애플리케이션이 정상적으로 실행되는지 확인할 수 있다.
  2. 나중에 Gateway, Load Balancer, Service Discovery를 만들 때 각 서비스의 상태 확인 API로 확장할 수 있다.
  3. 가장 작은 API부터 공통 응답 형식을 적용해볼 수 있다.
@RestController
class HealthController {

    @GetMapping("/health")
    fun health(): ApiResponse<String> = ApiResponse.success("OK")
}

 

마무리

이번 글에서는 Kotlin + Spring Boot + Gradle Kotlin DSL 기반으로 초기 프로젝트를 구성했다.

오히려 의도적으로 MSA가 아닌 상태에서 시작했다.

이유는 MSA를 구조만 보고 따라 하는 것이 아니라, 왜 그런 구조가 필요한지 직접 경험하기 위해서다.

현재 프로젝트는 다음 두 모듈만 가진다.

common
commerce-monolith

그리고 commerce-monolith/health API를 제공한다.

다음 글에서는 이 모놀리식 애플리케이션 안에 첫 번째 도메인인 회원 기능을 추가해볼 예정이다.

현재 구조와 다음 구조

 

다음 글:

Kotlin으로 직접 구현하며 배우는 MSA - 2. 회원, 상품 도메인 만들기

 

 

Kotlin으로 직접 구현하며 배우는 MSA - 2. 회원 도메인 만들기

✨ 공부하며 성장하는 기록 공간입니다.아직 부족한 점이 많고 배워야 할 것도 많지만, 그만큼 하나씩 알아가는 과정에서 큰 재미를 느끼고 있습니다.이 블로그는 제가 공부한 내용을 정리하고,

dev-hiro.tistory.com

 

 

코드 참조