Table of contents
Open Table of contents
Design by Contract란 무엇인가?
Design by Contract(계약에 의한 설계, DbC)는 소프트웨어 컴포넌트 사이의 상호작용을 계약의 형태로 명세하고 준수하는 설계 방법입니다. 마치 현실에서 서비스 공급자와 고객 사이에 계약을 맺듯이, 소프트웨어에서도 **클라이언트(호출자)**와 공급자(메서드 또는 클래스) 간에 지켜야 할 조건들을 명시하는 것입니다.
이 계약에는 다음과 같은 핵심 요소들이 포함됩니다 :
- 사전조건(precondition): 메서드가 호출되기 전에 참이어야 하는 조건입니다. 보통 메서드 입력 값이나 인자에 대한 유효성 조건을 말하며, 클라이언트(호출자)가 이 조건을 만족시킬 의무를 집니다. 예를 들어 “인자는 음수가 아니어야 한다”와 같은 요구사항이 사전조건이 될 수 있습니다.
- 사후조건(postcondition): 메서드가 실행된 후에 만족되어야 하는 조건으로, 메서드의 결과나 출력에 대한 보장 사항을 나타냅니다. 이는 공급자(메서드 구현)가 책임지는 부분으로, 예를 들어 “함수의 반환 값은 양수여야 한다” 같은 것이 사후조건입니다. 클라이언트는 메서드가 이 약속을 지킬 것이라고 기대합니다.
- 클래스 불변조건(invariant): 클래스의 인스턴스가 항상 유지해야 하는 불변의 조건입니다. 객체가 생성된 이후 모든 공개 메서드의 호출 전후로 불변조건이 깨지지 않아야 합니다. 예를 들어 어떤 계좌 객체에서 balance가 음수가 될 수 없다면, 모든 메서드 실행이 끝난 후에도 이 불변조건이 유지되어야 합니다.
이처럼 계약을 통해 각 컴포넌트가 무엇을 요구하고 무엇을 보장하는지 명확히 하면, 코드의 의도를 분명히 드러내고 오류를 조기에 방지할 수 있습니다 . 클라이언트는 사전조건을 만족시켜야 함으로써 메서드가 엉뚱한 입력을 처리하지 않아도 되고, 메서드는 사후조건을 보증하여 클라이언트에 결과에 대한 신뢰를 제공합니다. 불변조건은 클래스의 무결성을 지켜 시스템이 일관된 상태를 유지하게 합니다. 이러한 계약 개념은 Eiffel 언어의 창시자인 버트란드 마이어에 의해 1980년대에 처음 제시되었으며 , 이후 소프트웨어의 정확성과 안정성을 높이기 위한 철학으로 여러 언어와 프레임워크에 영향을 주었습니다.
Kotlin에서의 DfC: require
,check
, assert
활용
Kotlin은 Eiffel처럼 언어 차원의 DbC 구문을 제공하지는 않지만, 표준 라이브러리의 표준 함수들을 이용해 계약 개념을 부분적으로 구현할 수 있습니다.
대표적으로 require
, check
, assert
함수가 그러한 도구입니다. 이들은 코드에 명시적인 조건 검증을 추가하여 잘못된 상황에서 **빠르게 실패(fail-fast)**하도록 돕습니다.
각 함수의 역할과 차이를 살펴보겠습니다.
require
- 주로 **함수의 입력값 검증(사전조건)**에 사용됩니다.
- 조건식이 거짓이면
IllegalArgumentException
을 발생시켜 호출자에게 잘못된 인자 전달을 알립니다.
예를 들어 아래 코드는 나이를 설정하는 함수에 음수 값이 전달되지 않도록 요구합니다:
fun setAge(age: Int) {
require(age >= 0) { "나이는 0 이상이어야 합니다 (전달된 값: $age)" }
// ... 나이를 설정하는 로직 ...
}
위 예시에서 require
조건을 만족하지 못하면 즉시 예외가 던져지며, 이후 코드 실행을 막습니다.
require
는 잘못된 함수 인자에 대한 방어적 프로그래밍을 간결하게 해주며, 실패 시 메시지를 통해 어떤 계약이 어겨졌는지 명확히 표현합니다.
또한 requireNotNull
과 같이 널(null
) 값에 특화된 버전도 제공되어, 인자로 전달된 값이 null
이 아님을 확인하는 데 사용할 수 있습니다.
check
- 주로 **함수 내부의 상태 검증(불변조건 등)**에 사용됩니다.
- 조건 실패 시
IllegalStateException
을 발생시키며, 이것은 “현재 객체나 시스템의 상태가 잘못되었다”는 의미를 전달합니다.
예를 들어 쇼핑 카트 객체를 처리하는 함수에서, 결제를 진행하기 전에 카트가 비어있지 않음을 확인하려면 다음처럼 사용할 수 있습니다:
fun processPayment(cart: Cart) {
check(cart.items.isNotEmpty()) { "카트가 비어있어서 결제 처리 불가" }
// ... 결제 처리 로직 ...
}
- 위 코드의
check
는 카트 객체의 상태가 유효한지(아이템이 존재하는지)를 검증합니다. - 실패 시
IllegalStateException
이 발생하여 프로그램의 논리 오류를 빠르게 드러냅니다. - check는 불변조건을 확인하는 용도로도 쓸 수 있으며, Kotlin 공식 문서에서도 check를 “객체 상태의 유효성 검증”에 사용하도록 권장하고 있습니다.
checkNotNull
은 마찬가지로 널 여부를 검사하여 중요한 값이 누락되지 않았는지 검증하는 데 활용됩니다.
assert
- 내부 논리 검증이나 디버깅 용도로 사용되는 단언문입니다.
- 전달된 조건식이 거짓이면
AssertionError
를 던집니다.
예를 들어, 논리적으로 발생할 수 없는 상황을 방어하는 코드는 다음과 같을 수 있습니다:
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 라이브러리가 시도되기도 했지만, 주류 언어에서는 표준으로 자리잡지 못했습니다.
왜 그럴까요? 프로그래밍 언어 커뮤니티의 논의를 살펴보면, 몇 가지 현실적인 이유들이 거론됩니다:
- 표현의 한계: 많은 소프트웨어 문제는 간단한 논리식으로 계약을 명세하기 어렵습니다. 예를 들어 *파서(parser)*의 계약을 수학적으로 정의한다는 것은 현실적으로 쉽지 않습니다 . 파일시스템, UI, 외부 API처럼 실세계의 복잡한 상태를 다루는 경우, 그 상태를 모두 사전/사후 조건으로 표현하기가 불가능에 가깝습니다. 결과적으로 계약으로 명시하지 못하는 부분이 많아지면서, 언어 차원의 지원 효과가 제한됩니다.
- 사양 작성의 난이도: 설령 계약으로 명세할 수 있는 도메인이라 해도, 프로그램의 모든 동작을 완전하게 명세하는 일은 매우 어렵고 부담됩니다. 작은 예제로는 간단해 보여도, 실제 프로그램에서는 계약을 꼼꼼히 작성하려다 보면 구현보다 사양 작성이 더 힘들어질 수 있습니다. 한 Reddit 사용자는 산업계 검증 컴파일러인 CompCert 사례를 언급하면서, 프로그램 정형 증명에 수 년의 노력과 방대한 코드가 필요한 현실을 지적했습니다. 이렇듯 완벽한 계약을 추구하면 개발 생산성이 급격히 떨어지므로, 현실적인 개발 일정과 맞지 않습니다.
- 실행 성능 및 유지보수: 계약 검사를 빼먹지 않고 다 넣으면 런타임 오버헤드가 증가합니다. Eiffel은 이러한 부담 때문에 계약 검증 코드를 컴파일 옵션으로 끄고 켤 수 있게 했는데, 실제로 검증을 다 켜면 성능 저하가 커서 제품 릴리스 시엔 계약 검사를 빼는 일도 있었다고 합니다. 또한 개발 초기에 모든 계약을 정의해놓고 코딩하는 방식은 현실과 맞지 않다는 지적이 있습니다. 소프트웨어 설계는 보통 점진적으로 발전하고 요구사항을 알아가는 과정이기 때문에, 처음부터 계약을 완벽히 알 수 없고 구현하면서 계약을 수정하게 됩니다. 결국 언어 차원에서 엄격히 지정한 계약보다는, 개발자가 그때그때 assert나 예외로 느슨하게 계약을 표현하는 경향이 많습니다.
- 대안 기법의 존재: DbC가 아니더라도 유닛 테스트나 타입 시스템, 어노테이션 등의 방법으로 충분히 계약과 유사한 효과를 얻을 수 있다고 보는 시각도 있습니다. 실제로 다수의 개발자들은 “명세서처럼 계약을 코드에 넣기보다는, 단위 테스트로 검증하는 방식”에 더 익숙하고 이를 선호해왔습니다. 예를 들어 Reddit 토론에서 *“이미 다들 단위 테스트를 쓰고 있는데, DbC가 그렇게 유용하다면 왜 더 안 쓰일까?”*라는 논지가 있었고, 결국 테스트 코드 + 예외 처리 조합이 DbC의 대중적인 대체재가 되었다는 의견이 나옵니다. D 언어처럼 계약 구문을 제공하는 경우에도 실제로 그 기능을 활발히 활용하는 개발자는 드물었다는 보고가 있으며, 이는 개발자들이 새로운 계약 문법을 배우고 사용하는 수고 대신 기존 익숙한 방법에 머무르는 경향을 보여줍니다.
요약하면, 표현력의 한계, 개발 난이도와 비용, 성능 고려, 기존 관습 등의 이유로 DbC는 일반적인 프로그래밍 언어에 널리 채택되지 않았습니다. *“계약에 의한 설계가 이상적으로 좋지만, 현실적으로는 엄격한 계약을 강제하기보다 필요한 부분에만 부분적으로 적용하는 편”*이라는 것이 많은 개발자의 생각인 듯합니다. 물론 학계와 일부 산업에서는 점진적 검증이나 정형 명세 등의 연구가 진행되어, 미래에는 계약 개념이 더 쓰기 쉽게 발전할 가능성도 있습니다. 하지만 현재로서는 대부분의 언어가 언어 차원의 DbC보다는 개발자 재량으로 assert나 예외를 사용하는 방식에 머물러 있습니다.
Kotlin에서 실용적으로 DfC 적용하기
비록 Kotlin이 언어 자체로 Design by Contract를 지원하지는 않지만, 앞서 살펴본 도구들을 활용하면 DbC의 정신을 충분히 실용적으로 적용할 수 있습니다. 마지막으로, Kotlin과 같은 언어에서 계약에 의한 설계를 활용하는 몇 가지 팁을 정리해보겠습니다:
- 중요 불변조건과 사전조건을 명시적으로 체크: 메서드나 생성자의 최우선 부분에 require를 사용해 입력 조건을 방어하고, 객체의 핵심 불변조건에 대해서는 메서드 끝부분이나 변경 직후에 check 또는 커스텀 ensure로 확인하세요. 이런 fail-fast 전략은 버그를 초기 단계에서 드러내 주며, 오류 발생 시 명확한 메시지로 원인을 파악하기 쉽게 합니다.
- 예외 메시지로 계약 문서화: require나 check에 전달하는 람다 메시지를 활용해 어떤 계약이 위반됐는지 구체적으로 기록하세요. 예를 들어 “ID는 빈 문자열일 수 없습니다” 같은 메시지는 나중에 로그나 예외 추적에서 잘못된 사용 사례를 바로 이해하는 데 도움이 됩니다. 계약 조건 자체도 코드이지만, 친절한 예외 메시지는 추가적인 문서 역할을 합니다.
- 과도한 방어는 지양, 예상 외 상황에 집중: DbC 철학상 개발자의 오류로 간주되는 부분에 방어적 코드를 넣는 것이지, 모든 경우를 다 검증하는 것은 아닙니다. 따라서 정상적인 흐름에서 절대 깨지지 않을 논리까지 이중으로 점검해 오버헤드를 늘릴 필요는 없습니다. 대신 “절대로 발생하면 안 되는 상황”이라면 assert로 체크하여 디버깅 단계에서 잡고, 발생할 수 있는 에러 조건(외부입력 오류 등)은 예외 처리를 통해 처리하는 식으로 균형 잡는 것이 좋습니다.
- 테스트 코드와 병행: Design by Contract 검증이 있다고 해서 테스트를 소홀히 하면 안 됩니다. 계약은 잘못된 사용을 방지하고 오류를 빨리 알리는 수단이지만, 기능의 올바름을 보장하기 위해서는 여전히 테스트가 필요합니다. 계약으로 명세한 조건들이 제대로 동작하는지, 그리고 그 외의 시나리오에서 코드가 기대대로 작동하는지는 자동화된 테스트를 통해 확인해야 합니다. 계약 검증과 테스트는 상호 보완적인 관계입니다.
- 디버그와 운영 환경 전략: Kotlin의 assert는 항상 동작하므로, 필요에 따라 디버그 전용 검증 로직을 구분하는 전략이 필요할 수 있습니다. 예를 들어 development 모드에서만 수행해야 하는 무거운 체크가 있다면, 평소엔 주석 처리하거나 if (BuildConfig.DEBUG) { assert(…) } 같은 식으로 조건부 실행하는 방법도 고려됩니다. Eiffel처럼 자동으로 계약을 껐다 켤 순 없지만, 로그 레벨이나 디버그 플래그를 이용해 상황에 맞게 조절하면 성능과 안정성을 균형 있게 챙길 수 있습니다.
마지막으로 가장 중요한 점은 사고방식의 전환입니다. Design by Contract는 기능을 구현하기 전에 “무엇을 가정하고, 무엇을 보장할 것인가?“를 먼저 자문하게 합니다. Kotlin에서는 문법으로 강제되진 않지만, 개발자가 이러한 계약 개념을 염두에 두고 require/check 등을 습관화한다면 자연스럽게 **견고하고 자기 문서화(self-documenting)**된 코드를 작성할 수 있을 것입니다. 이는 중급 개발자에게 코드의 신뢰성을 높이는 실용적인 팁이며, 작은 방어적 코드들이 모여 큰 품질 향상을 이끌 수 있다는 것을 기억해야 합니다. 계약에 의한 설계를 완벽하게 언어가 지원하지는 않아도, 우리는 충분히 그 철학을 차용하여 현실적인 범위에서 활용할 수 있습니다. **“코드는 개발자와 프로그램 간의 계약”**이라는 마음가짐으로, Kotlin 코드를 한층 더 믿음직스럽게 만들어 보세요.