계약에 의한 설계를 Kotlin DSL로 구현할 수 있도록 돕는 오픈소스 라이브러리
신뢰 할 수 있는 코드를 작성하고 싶다는 팀원들의 니즈가 있었고, 그중에 계약에 의한 설계(Design by Contract)라는 개념을 알게 되었습니다. 기존의 Guava, Contract4j와 같은 라이브러리들은 사전 조건만 제공한다던가, 컴파일 타임 안정성이 부족하다는 아쉬움이 있었습니다. 이를 해결하기 위해 Kotlin의 DSL 문법을 사용하여 사용자가 더욱 쉽게 계약에 의한 설계를 지키며 코드를 작성할 수 있는 라이브러리를 개발하고자 했습니다.
아래와 같이 Gradle 설정을 추가하면 Contract4K 라이브러리를 사용할 수 있습니다:
plugins { kotlin("jvm") version "2.0.21" // AspectJ Post-Compile Weaving 플러그인 id("io.freefair.aspectj.post-compile-weaving") version "8.4" } kotlin { jvmToolchain(21) } repositories { mavenCentral() // JitPack: GitHub에 호스팅된 라이브러리를 받아오기 위해 필요합니다. maven { url = uri("https://jitpack.io") } } dependencies { // Contract4K AOP weaving 의존성 aspect("com.github.monorail-team:contract4k:v1.0.0") // AspectJ 런타임 implementation("org.aspectj:aspectjrt:1.9.21") // Kotlin 리플렉션 implementation(kotlin("reflect")) }
// 1) 도메인 모델 data class Order(val id: Long?, val amount: Int) // 2) 계약서 정의 object ApproveOrderContract : Contract4KDsl<Pair<Order, Unit>, Order> { override fun validatePre(input: Pair<Order, Unit>) = conditions { "주문 금액은 1 이상이어야 합니다" means { input.first.amount >= 1 } } } // 3) 서비스 사용 class OrderService { @Contract4kWith(ApproveOrderContract::class) fun placeOrder(order: Order): Order = order } // 4) 실행 예시 fun main() { OrderService().placeOrder(Order(null, 0)) // → Validation failed with 1 errors: // - 주문 금액은 1 이상이어야 합니다 }
Contract4KDsl<I, O>
은 "계약서" 역할을 하는 DSL 진입점입니다.
제네릭 파라미터:
I
: 메서드 호출 시점의 입력값 타입 (파라미터가 여러 개면 and
연산자를 사용해 묶음)O
: 메서드 실행 결과 타입주요 메서드:
interface Contract4KDsl<I, O> { /** ① 사전(pre) 조건 검사 — 비즈니스 로직 실행 전 */ fun validatePre(input: I) /** ② 불변식(invariant) 검사 — 로직 중에도 항상 지켜져야 할 조건 */ fun validateInvariant(input: I, output: O) /** ③ 사후(post) 조건 검사 — 로직 실행 후 결과 검증 */ fun validatePost(input: I, result: O) }
@Service class OrderService { @Contract4kWith(ApproveOrderContract::class) fun placeOrder(...) = … }
Contract4K 의 핵심은 "메시지" means { 조건 } 형태의 Kotlin DSL 로 원하는 검증 로직을 깔끔하게 작성할 수 있다는 점입니다.
아래처럼 사전(pre), 불변(invariant), 사후(post) 3단계로 나누어 블록 안에 조건을 선언하면, AOP 가 자동으로 해당 단계에서 실행해 줍니다.
object ApproveOrderContract : Contract4KDsl<Pair<Order, Customer>, Order> { // ① 사전(pre) 조건: 메서드 진입 직전에 실행 override fun validatePre(input: Pair<Order, Customer>) = conditions { // 방법 1 val (order, customer) = input "주문 객체는 null일 수 없습니다" means { order isNot nil } "고객 객체는 null일 수 없습니다" means { customer isNot nil } //방법 2 "주문 객체는 null일 수 없습니다" means { input.first isNot nil } "고객 객체는 null일 수 없습니다" means { input.second isNot nil } } // ② 불변(invariant) 조건: 비즈니스 로직 중에도 유지되어야 할 제약 override fun validateInvariant(input: Pair<Order, Customer>, output: Order) = conditions { "주문 ID는 항상 존재해야 합니다" means { output.id isNot nil } } // ③ 사후(post) 조건: 메서드 종료 후 최종 상태 검증 override fun validatePost(input: Pair<Order, Customer>, result: Order) = conditions { "최종 상태는 COMPLETED 여야 합니다" means { result.status == "COMPLETED" } } }
ConditionBuilder 에서 자주 쓰이는 주요 헬퍼 함수:
숫자 검사
between(range: IntRange)
order.amount between (1..10_000)
is positive
/ isNot negative
count is positive balance isNot negative
컬렉션 검사
hasCountInRange(range: IntRange)
list hasCountInRange (1..5)
hasNoDuplicates()
items hasNoDuplicates()
allSatisfy { predicate }
users allSatisfy { it.isActive }
문자열 검사
hasExactLength(length: Int)
password hasExactLength 8
doesNotStartWith(prefix: String)
token doesNotStartWith "ERR_"
날짜·시간 검사
isBefore(other: Temporal)
startDate isBefore endDate
isAfter(other: Temporal)
dueDate isAfter now
ValidationException
RuntimeException
을 상속하며, 메시지에 어떤 조건이 왜 실패했는지 한눈에 보여 줍니다.try { orderService.placeOrder(invalidOrder, customer) } catch (e: ValidationException) { println(e.message) // → Validation failed with 1 error: // - 주문 금액은 1 이상이어야 합니다. }
ErrorCode
[ERROR_CODE] 메시지
형태로 표시됩니다.meansAnyOf { … }
여러 조건 중 하나만 만족해도 OK인 그룹화
conditions { meansAnyOf { "A 상품이 포함되어야 합니다" means { "A" in order.items } "B 상품이 포함되어야 합니다" means { "B" in order.items } } }
meansAllOf { … }
모든 조건을 동시에 만족해야 하는 그룹화
conditions { meansAllOf { "금액은 양수여야 합니다" means { order.amount > 0 } "고객 나이는 18세 이상이어야 합니다" means { customer.age >= 18 } } }
자주 쓰이는 조건을 ConditionGroup
으로 정의하고, 여러 계약서에서 재사용 가능
object CommonCustomerConditions : ConditionGroup<Pair<Order, Customer>> { override fun apply(builder: ConditionBuilder, input: Pair<Order, Customer>) { val (_, customer) = input "고객 이름은 비어 있으면 안 됩니다" means { customer.name isNot nil} "고객 나이는 0 초과여야 합니다" means { customer.age > 0 } } } conditions { applyGroup(input, CommonCustomerConditions) // 추가 커스텀 조건... }
예외가 아닌 경고로만 처리
softConditions { "장기 미이용 고객입니다" means { daysSinceLastLogin > 365 } }
조건에 수정 제안 추가
conditions { "주문 금액은 1,000원 이상이어야 합니다" quickFix "금액을 1,000원 이상으로 설정하세요" means { order.amount >= 1_000 } }
means(code, message) { … }
또는 quickFix(code, message, fix) means { … }
사용
conditions { means( code = "ERR_INVALID_AMOUNT", message = "주문 금액은 1 이상이어야 합니다" ) { order.amount >= 1 } quickFix( code = "ERR_NULL_ORDER", message = "주문 객체는 null일 수 없습니다", fixMessage = "올바른 주문 객체를 전달하세요" ) means { order != null } }