- Published on
Kotlin+Ktor MSA에서 DDD 모듈 경계 깨짐 잡는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
MSA에서 서비스가 쪼개졌다고 해서 DDD 경계가 자동으로 지켜지지는 않습니다. Kotlin+Ktor 조합은 개발 속도가 빠른 대신, 모듈 분리와 의존성 규칙을 초기에 강제하지 않으면 domain이 infrastructure에 끌려가거나, application이 웹 프레임워크 타입에 오염되는 일이 매우 흔합니다. 이런 “경계 깨짐”은 단기적으로는 편하지만, 시간이 지나면 변경 비용이 폭증하고 서비스 간 계약(Contract)도 흔들립니다.
이 글에서는 Kotlin+Ktor 기반 MSA에서 자주 터지는 DDD 모듈 경계 문제를 유형별로 짚고, Gradle 멀티 모듈 설계, 의존성 방향 고정, 테스트 전략, 정적 분석(아키텍처 테스트)으로 경계를 “깨지지 않게” 만드는 방법을 다룹니다.
또한 운영 환경에서 문제가 증폭되는 경우가 많아, 배포 파이프라인과 런타임 관측 관점도 함께 언급합니다. 관련해서는 장애 대응 경험을 정리한 글인 Kubernetes OOMKilled 진단과 메모리 누수 추적 실전도 참고하면 좋습니다.
DDD 모듈 경계가 깨지는 대표 증상
1) domain이 프레임워크 타입에 오염됨
- 엔티티/밸류 오브젝트가
@Serializable,@Entity,@JsonProperty같은 애노테이션을 달고 있음 - 도메인 모델이 Ktor의
ApplicationCall,HttpStatusCode같은 타입을 직접 참조
결과적으로 도메인은 “순수한 비즈니스 규칙”이 아니라 “웹/DB에 맞춘 데이터 구조”가 됩니다.
2) application이 infrastructure를 직접 호출
- 유스케이스가 JPA/Exposed/Redis 클라이언트를 직접 호출
- 트랜잭션, 메시지 발행, 외부 API 호출이 유스케이스 내부에 뒤섞임
이 경우 테스트가 어려워지고, 인프라 교체 비용이 커집니다.
3) 모듈은 나뉘었는데 의존성 방향이 뒤집힘
domain모듈이infrastructure모듈에 의존domain이application에 의존(유스케이스를 도메인이 호출)
모듈 경계는 “폴더 구조”가 아니라 “컴파일 의존성”으로 강제해야 합니다.
4) Bounded Context 경계가 코드에서 사라짐
order서비스에서user의 내부 테이블/모델을 직접 가져다 씀- 공통 모듈(
common)에 모든 걸 넣고 서비스들이 전부 참조
공통 모듈은 처음엔 편하지만, 결국 “모든 것이 서로를 참조하는 거대한 결합”으로 변질되기 쉽습니다.
권장 모듈 구조: Ktor MSA에서 현실적인 기본형
Kotlin+Ktor에서는 아래 형태가 실무에서 가장 무난합니다.
:domain- 엔티티, 밸류 오브젝트, 도메인 서비스, 도메인 이벤트, 리포지토리 인터페이스
:application- 유스케이스(서비스), 트랜잭션 경계, 포트(입출력 인터페이스), DTO(유스케이스 입력/출력)
:presentation- Ktor 라우팅, 컨트롤러(핸들러), 요청/응답 매핑
:infrastructure- DB 구현체, 메시징 구현체, 외부 API 클라이언트, 설정/어댑터
:bootstrap혹은:app- Ktor 엔트리포인트, DI wiring, 모듈 조립
핵심은 의존성 방향을 단방향으로 고정하는 것입니다.
presentation->application->domaininfrastructure->application및domain의 인터페이스 구현bootstrap이presentation,application,infrastructure를 조립
본문에서는 MDX 렌더링 이슈를 피하기 위해 화살표도 인라인 코드로 표기했습니다.
Gradle 멀티 모듈에서 의존성 방향 강제
가장 먼저 할 일은 “가능한 의존성을 아예 못 걸게” 만드는 것입니다.
settings.gradle.kts 예시
rootProject.name = "order-service"
include(
":domain",
":application",
":presentation",
":infrastructure",
":app"
)
application/build.gradle.kts 예시
plugins {
kotlin("jvm")
}
dependencies {
implementation(project(":domain"))
// 유스케이스 레벨에서 필요한 공통 유틸은 여기서만 제한적으로
// implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:...")
testImplementation(kotlin("test"))
}
domain/build.gradle.kts 예시
plugins {
kotlin("jvm")
}
dependencies {
// domain은 최대한 비워두는 게 목표
// 프레임워크 의존성 금지
testImplementation(kotlin("test"))
}
infrastructure/build.gradle.kts 예시
plugins {
kotlin("jvm")
}
dependencies {
implementation(project(":application"))
implementation(project(":domain"))
// DB, 메시징, 외부 API 클라이언트는 infra에만 둔다
// implementation("org.jetbrains.exposed:exposed-core:...")
// implementation("io.ktor:ktor-client-core:...")
}
이렇게만 해도 domain에서 DB 라이브러리를 끌어다 쓰는 실수를 상당 부분 차단할 수 있습니다.
포트와 어댑터로 “경계 넘는 코드”를 구조적으로 격리
DDD 경계 깨짐의 본질은 “도메인/유스케이스가 구체 구현을 알게 되는 것”입니다. 이를 해결하는 가장 실용적인 패턴은 포트-어댑터(헥사고날)입니다.
domain: 리포지토리 인터페이스(포트)
package com.example.order.domain
data class OrderId(val value: String)
data class Order(
val id: OrderId,
val amount: Long
)
interface OrderRepository {
fun findById(id: OrderId): Order?
fun save(order: Order)
}
여기에는 DB, 트랜잭션, Ktor가 등장하면 안 됩니다.
application: 유스케이스는 포트에만 의존
package com.example.order.application
import com.example.order.domain.Order
import com.example.order.domain.OrderId
import com.example.order.domain.OrderRepository
data class CreateOrderCommand(
val id: String,
val amount: Long
)
class CreateOrderUseCase(
private val orderRepository: OrderRepository
) {
fun execute(cmd: CreateOrderCommand) {
val order = Order(
id = OrderId(cmd.id),
amount = cmd.amount
)
orderRepository.save(order)
}
}
유스케이스에서 transaction {} 같은 것이 보이기 시작하면 경계가 깨질 확률이 큽니다. 트랜잭션은 보통 application 레벨에서 추상화하거나, infrastructure에서 AOP/Interceptor 형태로 감싸는 방식이 안전합니다.
infrastructure: 구현체(어댑터)
package com.example.order.infrastructure
import com.example.order.domain.Order
import com.example.order.domain.OrderId
import com.example.order.domain.OrderRepository
class InMemoryOrderRepository : OrderRepository {
private val store = mutableMapOf<String, Order>()
override fun findById(id: OrderId): Order? = store[id.value]
override fun save(order: Order) {
store[order.id.value] = order
}
}
실제로는 DB 구현이 들어가겠지만, 핵심은 구현이 infra에만 존재하고, application은 인터페이스로만 접근한다는 점입니다.
presentation(Ktor): 요청/응답 매핑은 여기서 끝내기
package com.example.order.presentation
import com.example.order.application.CreateOrderCommand
import com.example.order.application.CreateOrderUseCase
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
@kotlinx.serialization.Serializable
data class CreateOrderRequest(val id: String, val amount: Long)
fun Route.orderRoutes(createOrderUseCase: CreateOrderUseCase) {
post("/orders") {
val req = call.receive<CreateOrderRequest>()
createOrderUseCase.execute(CreateOrderCommand(req.id, req.amount))
call.respond(mapOf("status" to "ok"))
}
}
중요한 규칙은 다음입니다.
@Serializable같은 직렬화 애노테이션은presentationDTO에만 둔다- 도메인 엔티티를 그대로
call.respond(...)로 내보내지 않는다 - Ktor 타입은
presentation에서만 사용한다
“공통 모듈”을 만들 때 경계가 무너지는 이유와 대안
MSA에서 common 모듈은 양날의 검입니다. 특히 다음이 들어가기 시작하면 위험합니다.
- 모든 서비스가 공유하는
Entity,DTO,ErrorCode - 각 서비스의 내부 도메인 모델
대안은 보통 아래 중 하나입니다.
- 진짜 공통만: 로깅, 트레이싱, 코루틴 디스패처, 에러 포맷의 최소 공통 규약 정도만 공유
- 계약은 스키마로: OpenAPI/AsyncAPI/Protobuf 같은 스키마를 단일 소스로 관리하고, 각 서비스는 생성된 타입만 사용
- “공유 커널(Shared Kernel)”은 매우 작게, 변경 프로세스(승인/버전)까지 포함해서 운영
경계가 깨지면 런타임 장애로 번지기도 합니다. 예를 들어 공통 모델이 비대해져 메모리 사용량이 증가하거나, 직렬화 정책이 꼬여 GC 압박이 커지는 식입니다. 이런 유형의 런타임 문제는 Kubernetes OOMKilled 진단과 메모리 누수 추적 실전에서 소개한 방식으로 원인 추적이 가능합니다.
아키텍처 테스트로 “깨진 경계”를 CI에서 차단
사람이 리뷰로만 잡기에는 한계가 있습니다. 경계는 테스트로 자동 검증하는 편이 훨씬 강력합니다.
Kotlin/JVM에서는 ArchUnit을 많이 씁니다. (Kotlin 친화 DSL도 있지만, 기본 ArchUnit만으로도 충분합니다.)
ArchUnit 예시: domain은 presentation/infrastructure를 몰라야 함
package com.example.order.arch
import com.tngtech.archunit.core.domain.JavaClasses
import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
import kotlin.test.Test
class ArchitectureTest {
private val classes: JavaClasses = ClassFileImporter()
.importPackages("com.example.order")
@Test
fun `domain should not depend on presentation or infrastructure`() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("..presentation..", "..infrastructure..")
.check(classes)
}
}
이 테스트가 CI에서 돌면, 누군가 domain에서 Ktor 타입을 import하는 순간 빌드가 깨집니다.
Gradle에 테스트 의존성 추가
dependencies {
testImplementation("com.tngtech.archunit:archunit-junit5:1.2.1")
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
}
tasks.test {
useJUnitPlatform()
}
Ktor DI 조립 지점(app)에서 경계를 유지하는 방법
Ktor는 Spring처럼 강한 컨테이너가 기본 내장된 형태가 아니라, 조립을 개발자가 선택합니다. 그래서 app 모듈이 “모든 것을 연결하는 유일한 곳”이 되도록 만드는 게 중요합니다.
presentation은 유스케이스 인터페이스만 받는다infrastructure는 포트 구현체를 제공한다app에서 구현체를 생성해 주입한다
app에서 wiring 예시
package com.example.order.app
import com.example.order.application.CreateOrderUseCase
import com.example.order.infrastructure.InMemoryOrderRepository
import com.example.order.presentation.orderRoutes
import io.ktor.server.application.*
import io.ktor.server.routing.*
fun Application.module() {
val repo = InMemoryOrderRepository()
val createOrderUseCase = CreateOrderUseCase(repo)
routing {
orderRoutes(createOrderUseCase)
}
}
이 방식의 장점은 단순함입니다. 서비스가 커지면 Koin 같은 DI를 붙일 수 있지만, DI 프레임워크를 붙이는 순간에도 “조립은 app에서만”이라는 규칙은 유지해야 합니다.
경계 깨짐을 유발하는 실전 함정 5가지
1) 도메인에 예외를 HTTP로 매핑
도메인 예외를 HttpStatusCode.BadRequest 같은 것으로 표현하면 도메인이 웹을 알게 됩니다.
- 도메인: 의미 있는 예외(예:
InsufficientBalance) - presentation: 예외를 캐치해 HTTP로 변환
2) 도메인 모델을 DB 스키마에 맞춰 설계
도메인 모델이 테이블 컬럼에 종속되면, 비즈니스 변화보다 DB 제약이 설계를 지배합니다. 매핑 비용이 들더라도 “도메인 모델 우선”을 지키는 편이 장기적으로 이득입니다.
3) 이벤트 발행을 유스케이스에 박아 넣기
유스케이스에서 Kafka 프로듀서를 직접 호출하면 경계가 깨집니다.
- application:
EventPublisher포트만 호출 - infrastructure: Kafka 구현체 제공
4) common 모듈에 DTO를 몰아넣기
특히 “요청/응답 DTO”는 각 서비스의 presentation에 두는 것이 안전합니다. 공통 DTO는 결국 서비스 간 결합을 강화합니다.
5) 테스트에서만 경계를 깨는 코드
테스트 편의 때문에 domain 테스트에서 infrastructure 구현을 끌어다 쓰는 경우가 있습니다. 이 또한 의존성을 오염시킬 수 있으니, 테스트 픽스처도 모듈 경계를 따라가게 설계하세요.
운영 관점: 경계가 깨지면 장애가 더 커지는 이유
경계가 깨진 시스템은 보통 다음 특성을 가집니다.
- 변경이 한 서비스에 국한되지 않고 연쇄 반응
- 배포 단위가 커지고 롤백이 어려움
- 리소스 사용량(메모리, 커넥션, 스레드)이 예측 불가
특히 MSA에서는 서비스가 많아질수록 “한 번의 잘못된 공유”가 전체에 퍼집니다. 장애가 나면 원인 추적도 어려워집니다. 이런 상황에서 CI 단계부터 아키텍처 테스트로 차단하는 것이 비용 대비 효과가 매우 큽니다.
또한 CI에서 Docker 기반 테스트나 정적 분석을 돌릴 때 러너 권한/환경 문제로 파이프라인이 흔들리는 경우가 있는데, 그런 이슈는 GitLab CI Runner에서 Docker 권한 오류 해결 가이드처럼 별도로 안정화해두는 편이 좋습니다. 아키텍처 규칙이 좋아도 CI가 자주 깨지면 결국 규칙이 무력화됩니다.
체크리스트: Kotlin+Ktor DDD 경계 고정 규칙
domain은 프레임워크 의존성 0에 가깝게 유지presentationDTO와domain모델을 분리(매핑 계층 유지)- 유스케이스는 포트(인터페이스)만 호출
- 구현체는
infrastructure로 밀어 넣기 - 조립은
app단일 지점에서 수행 - ArchUnit 같은 아키텍처 테스트를 CI에서 강제
common모듈은 최소화하고, 계약 공유는 스키마 중심으로
마무리
Kotlin+Ktor로 MSA를 만들 때 DDD 모듈 경계는 “의식”이 아니라 “장치”로 지켜야 합니다. 멀티 모듈로 컴파일 의존성을 끊고, 포트-어댑터로 구현을 격리하고, ArchUnit으로 규칙을 자동 검증하면 경계가 깨지는 대부분의 문제를 초기에 차단할 수 있습니다.
경계가 잘 지켜진 서비스는 기능 추가 속도뿐 아니라 운영 안정성도 함께 올라갑니다. 특히 서비스 수가 늘어날수록 이 차이는 기하급수적으로 벌어집니다.