Skip to content

Kotlin 에서 Design by Contract 적용하기

Published: at 오전 07:14

Table of contents

Open Table of contents

Design by Contract란 무엇인가?

Design by Contract(계약에 의한 설계, DbC)는 소프트웨어 컴포넌트 사이의 상호작용을 계약의 형태로 명세하고 준수하는 설계 방법입니다. 마치 현실에서 서비스 공급자와 고객 사이에 계약을 맺듯이, 소프트웨어에서도 **클라이언트(호출자)**와 공급자(메서드 또는 클래스) 간에 지켜야 할 조건들을 명시하는 것입니다.

이 계약에는 다음과 같은 핵심 요소들이 포함됩니다 :

이처럼 계약을 통해 각 컴포넌트가 무엇을 요구하고 무엇을 보장하는지 명확히 하면, 코드의 의도를 분명히 드러내고 오류를 조기에 방지할 수 있습니다 . 클라이언트는 사전조건을 만족시켜야 함으로써 메서드가 엉뚱한 입력을 처리하지 않아도 되고, 메서드는 사후조건을 보증하여 클라이언트에 결과에 대한 신뢰를 제공합니다. 불변조건은 클래스의 무결성을 지켜 시스템이 일관된 상태를 유지하게 합니다. 이러한 계약 개념은 Eiffel 언어의 창시자인 버트란드 마이어에 의해 1980년대에 처음 제시되었으며 , 이후 소프트웨어의 정확성과 안정성을 높이기 위한 철학으로 여러 언어와 프레임워크에 영향을 주었습니다.

Kotlin에서의 DfC: require,check, assert 활용

Kotlin은 Eiffel처럼 언어 차원의 DbC 구문을 제공하지는 않지만, 표준 라이브러리의 표준 함수들을 이용해 계약 개념을 부분적으로 구현할 수 있습니다.

대표적으로 require, check, assert 함수가 그러한 도구입니다. 이들은 코드에 명시적인 조건 검증을 추가하여 잘못된 상황에서 **빠르게 실패(fail-fast)**하도록 돕습니다. 각 함수의 역할과 차이를 살펴보겠습니다.

require

예를 들어 아래 코드는 나이를 설정하는 함수에 음수 값이 전달되지 않도록 요구합니다:

fun setAge(age: Int) {
    require(age >= 0) { "나이는 0 이상이어야 합니다 (전달된 값: $age)" }
    // ... 나이를 설정하는 로직 ...
}

위 예시에서 require 조건을 만족하지 못하면 즉시 예외가 던져지며, 이후 코드 실행을 막습니다. require는 잘못된 함수 인자에 대한 방어적 프로그래밍을 간결하게 해주며, 실패 시 메시지를 통해 어떤 계약이 어겨졌는지 명확히 표현합니다. 또한 requireNotNull과 같이 널(null) 값에 특화된 버전도 제공되어, 인자로 전달된 값이 null이 아님을 확인하는 데 사용할 수 있습니다.

check

예를 들어 쇼핑 카트 객체를 처리하는 함수에서, 결제를 진행하기 전에 카트가 비어있지 않음을 확인하려면 다음처럼 사용할 수 있습니다:

fun processPayment(cart: Cart) {
    check(cart.items.isNotEmpty()) { "카트가 비어있어서 결제 처리 불가" }
    // ... 결제 처리 로직 ...
}

assert

예를 들어, 논리적으로 발생할 수 없는 상황을 방어하는 코드는 다음과 같을 수 있습니다:

fun divide(a: Int, b: Int): Int {
    assert(b != 0) { "0으로 나누기 발생 - 논리 오류" }
    return a / b
}

위 코드에서 b가 0이 아닌지 assert로 확인하여, 개발자의 논리적 가정이 어긋나면 즉시 프로그램이 실패하도록 합니다. 자바의 assert 키워드는 기본적으로 실행 시 비활성화되어(prod 환경에서 무시됨) 성능에 영향을 주지 않지만, Kotlin의 assert는 일반 함수로 구현되어 항상 평가됩니다. 따라서 Kotlin에서 assert는 사실상 check와 유사하게 동작하지만 AssertionError를 던진다는 점이 다릅니다. 일반적으로 assert는 디버깅 단계에서만 활성화되는 검증에 사용하는 것이 권장되며, 외부 입력보다는 개발자의 오류를 잡아내는 용도로 구분해서 쓰는 것이 좋습니다.

요약하면, require는 잘못된 입력값에 대한 방어, check는 잘못된 상태에 대한 검사, assert는 논리적 가정의 확인 용도로 구분됩니다. 이러한 구분은 코드의 의미를 더욱 분명히 드러내주며, 예외 유형도 달라 유지보수 시 원인 파악에 도움이 됩니다 (예를 들어 IllegalArgumentException vs IllegalStateException). Kotlin의 표준 함수들을 활용하면 언어 차원의 DbC 지원이 없더라도 비슷한 효과를 얻을 수 있으며, 코드에 계약의 의도를 문서화할 수 있습니다.

커스텀 헬퍼로 DfC 보완하기

Kotlin 표준 라이브러리에는 사후조건을 직접 명시하는 전용 함수가 없지만, 쉽게 커스터마이징하여 보완할 수 있습니다. 예를 들어 함수의 결과값에 대한 조건을 검증하는 ensure 함수를 직접 만들어 사용할 수 있습니다. ensure는 작업이 끝난 후 **결과가 기대하는 조건을 만족하는지 확인(사후조건 검사)**하는 용도로 쓸 수 있습니다.

Kotlin의 고차함수와 제네릭을 이용하면 다음과 같이 구현할 수 있습니다:

inline fun <T> T.ensure(predicate: (T) -> Boolean, lazyMessage: () -> String): T {
    if (!predicate(this)) {
        throw IllegalStateException(lazyMessage())
    }
    return this  // 조건 만족 시 그대로 값 반환
}

위 ensure 함수는 어떤 객체에든 적용할 수 있는 제네릭 확장 함수입니다. 조건식 predicate로 현재 객체(this)가 만족해야 할 사후 조건을 받고, 실패 시 lazyMessage를 평가하여 예외 메시지로 사용합니다.

예를 들어, 두 수의 비율을 계산한 뒤 결과가 0.0부터 1.0 사이의 값이어야 한다는 계약을 다음처럼 표현할 수 있습니다:

fun computeRatio(x: Int, y: Int): Double {
    require(y != 0) { "y는 0이 될 수 없습니다" }            // 사전조건: 0으로 나눌 수 없음
    val result = x.toDouble() / y.toDouble()
    return result.ensure({ it in 0.0..1.0 }) {             // 사후조건: 결과는 0~1 범위
        "비율 계산 오류: 결과 ${it}은 0~1 범위를 벗어납니다"
    }
}

위 코드에서는 먼저 require로 입력값에 대한 계약을 확인하고, 계산 후 ensure로 출력에 대한 계약을 검증합니다. 이렇게 하면 함수의 계약을 코드로 명시함으로써 잘못된 사용이나 구현 버그를 빠르게 드러낼 수 있습니다. ensure는 결국 내부적으로 check와 동일하게 IllegalStateException을 던지지만, 의미적으로 사후조건 검증을 표현하기 때문에 코드의 가독성과 의도를 높여줍니다.

이외에도 확장 함수를 활용해 다양한 검증 헬퍼를 만들 수 있습니다. 예를 들어 컬렉션에 대해서 List<T>.ensureNotEmpty(message) 같은 확장 함수를 만들어 컬렉션 불변식을 검증하거나, 도메인 모델 객체에 특화된 검증 함수를 제공하는 것도 가능합니다. 이러한 헬퍼들을 잘 활용하면 코드에서 계약 조건을 반복적으로 쓰는 것을 피하고, 좀 더 읽기 쉬운 형태로 DfC 원칙을 적용할 수 있습니다.

다만, 커스텀 검증 함수를 남용하는 것은 피하는 것이 좋습니다. 너무 복잡한 논리를 헬퍼에 숨기기보다는, 필요한 경우 간단한 require/check를 직접 사용하는 편이 코드 흐름을 이해하는 데 도움이 될 때도 있습니다. 중요한 것은 메서드의 중요한 계약 사항(전제 조건, 결과 보장, 부작용 등)을 코드에 명확히 드러내어 문서화와 방어로직을 겸비하는 것입니다.

DfC가 대부분의 언어에 내장되지 않은 이유 (Reddit 의견 요약)

Design by Contract의 개념은 소프트웨어 품질에 도움이 되지만, 정작 대부분의 프로그래밍 언어에서는 언어 차원의 지원이 드뭅니다. Eiffel이나 D 같은 일부 언어가 계약 기능을 내장했고 .NET에서 Code Contracts 라이브러리가 시도되기도 했지만, 주류 언어에서는 표준으로 자리잡지 못했습니다.

왜 그럴까요? 프로그래밍 언어 커뮤니티의 논의를 살펴보면, 몇 가지 현실적인 이유들이 거론됩니다:

요약하면, 표현력의 한계, 개발 난이도와 비용, 성능 고려, 기존 관습 등의 이유로 DbC는 일반적인 프로그래밍 언어에 널리 채택되지 않았습니다. *“계약에 의한 설계가 이상적으로 좋지만, 현실적으로는 엄격한 계약을 강제하기보다 필요한 부분에만 부분적으로 적용하는 편”*이라는 것이 많은 개발자의 생각인 듯합니다. 물론 학계와 일부 산업에서는 점진적 검증이나 정형 명세 등의 연구가 진행되어, 미래에는 계약 개념이 더 쓰기 쉽게 발전할 가능성도 있습니다. 하지만 현재로서는 대부분의 언어가 언어 차원의 DbC보다는 개발자 재량으로 assert나 예외를 사용하는 방식에 머물러 있습니다.

Kotlin에서 실용적으로 DfC 적용하기

비록 Kotlin이 언어 자체로 Design by Contract를 지원하지는 않지만, 앞서 살펴본 도구들을 활용하면 DbC의 정신을 충분히 실용적으로 적용할 수 있습니다. 마지막으로, Kotlin과 같은 언어에서 계약에 의한 설계를 활용하는 몇 가지 팁을 정리해보겠습니다:

마지막으로 가장 중요한 점은 사고방식의 전환입니다. Design by Contract는 기능을 구현하기 전에 “무엇을 가정하고, 무엇을 보장할 것인가?“를 먼저 자문하게 합니다. Kotlin에서는 문법으로 강제되진 않지만, 개발자가 이러한 계약 개념을 염두에 두고 require/check 등을 습관화한다면 자연스럽게 **견고하고 자기 문서화(self-documenting)**된 코드를 작성할 수 있을 것입니다. 이는 중급 개발자에게 코드의 신뢰성을 높이는 실용적인 팁이며, 작은 방어적 코드들이 모여 큰 품질 향상을 이끌 수 있다는 것을 기억해야 합니다. 계약에 의한 설계를 완벽하게 언어가 지원하지는 않아도, 우리는 충분히 그 철학을 차용하여 현실적인 범위에서 활용할 수 있습니다. **“코드는 개발자와 프로그램 간의 계약”**이라는 마음가짐으로, Kotlin 코드를 한층 더 믿음직스럽게 만들어 보세요.