계약과 컨텍스트 — contract, context receivers로 컴파일러에게 더 많은 정보 주기
"코틀린의 null safety나 스마트 캐스트가 편리한데, 내가 만든 유틸 함수에서는 왜 스마트 캐스트가 동작하지 않을까요? 컴파일러에게 '이 함수는 이런 보장을 합니다'라고 알려줄 수는 없을까요?"
코틀린의 contract와 context receivers는 컴파일러에게 추가 정보를 전달하여 더 나은 타입 추론과 안전성을 제공하는 기능입니다. contract는 함수의 동작에 대한 보장을, context receivers는 함수가 실행될 수 있는 환경에 대한 제약을 표현합니다.
Contract — 함수의 동작을 컴파일러에게 알리기
문제: 커스텀 함수에서 스마트 캐스트가 안 되는 이유
fun isNotNull(value: Any?): Boolean = value != null
fun process(value: String?) {
if (value != null) {
println(value.length) // ✅ 스마트 캐스트 동작
}
if (isNotNull(value)) {
println(value.length) // ❌ 컴파일 에러! 스마트 캐스트 안 됨
}
}
컴파일러는 value != null이라는 직접적인 조건문은 이해하지만, isNotNull() 함수가 true를 반환하면 value가 non-null이라는 것은 알 수 없습니다. contract로 이 정보를 전달할 수 있습니다.
returns implies — 반환값과 조건의 관계
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
fun isNotNull(value: Any?): Boolean {
contract {
returns(true) implies (value != null)
}
return value != null
}
fun process(value: String?) {
if (isNotNull(value)) {
println(value.length) // ✅ 스마트 캐스트 동작!
}
}
returns(true) implies (value != null)은 "이 함수가 true를 반환했다면, value는 null이 아닙니다"라는 뜻입니다.
다양한 contract 효과
// 타입 체크
@OptIn(ExperimentalContracts::class)
fun Any?.isString(): Boolean {
contract {
returns(true) implies (this@isString is String)
}
return this is String
}
val value: Any? = "Hello"
if (value.isString()) {
println(value.length) // ✅ String으로 스마트 캐스트
}
// false 반환 시 조건
@OptIn(ExperimentalContracts::class)
fun requireNotEmpty(list: List<*>?): Boolean {
contract {
returns(true) implies (list != null)
returns(false) implies (list == null)
}
return !list.isNullOrEmpty()
}
callsInPlace — 람다 호출 횟수 보장
callsInPlace는 람다가 몇 번 호출되는지 컴파일러에게 알려줍니다. 코틀린 표준 라이브러리의 run, let, apply 등이 이 계약을 사용합니다.
@OptIn(ExperimentalContracts::class)
inline fun <T> executeOnce(block: () -> T): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return block()
}
// 이 계약 덕분에 val 초기화가 가능
val result: String
executeOnce {
result = "초기화됨" // ✅ EXACTLY_ONCE이므로 val 초기화 허용
}
println(result) // ✅ 컴파일러가 초기화됨을 보장
InvocationKind의 종류는 다음과 같습니다.
| Kind | 의미 |
|---|---|
EXACTLY_ONCE | 정확히 한 번 호출 |
AT_LEAST_ONCE | 최소 한 번 호출 |
AT_MOST_ONCE | 최대 한 번 호출 (안 호출될 수도 있음) |
UNKNOWN | 호출 횟수를 알 수 없음 |
표준 라이브러리에서의 contract 활용
코틀린 표준 라이브러리의 많은 함수가 contract를 사용합니다.
// require 함수의 contract
public inline fun require(value: Boolean) {
contract {
returns() implies value // 함수가 정상 반환하면 value는 true
}
if (!value) throw IllegalArgumentException()
}
// 덕분에 이런 코드가 가능
fun process(name: String?) {
require(name != null) // 이 줄을 지나면 name은 non-null
println(name.length) // ✅ 스마트 캐스트 동작
}
// check 함수도 동일한 패턴
fun validate(age: Int?) {
check(age != null) { "나이가 null입니다" }
println(age + 1) // ✅ Int로 스마트 캐스트
}
Context Receivers — 실행 환경을 타입으로 제한하기
Context receivers는 함수가 특정 컨텍스트에서만 호출 가능하도록 제한하는 기능입니다.
참고: Context receivers는 실험적 기능이며, 향후 "context parameters"로 대체될 수 있습니다.
-Xcontext-receivers컴파일러 옵션이 필요합니다.
기본 문법
// 이 함수는 LoggingContext가 스코프에 있을 때만 호출 가능
context(LoggingContext)
fun processOrder(order: Order) {
log("주문 처리 시작: ${order.id}") // LoggingContext의 멤버
// ...
log("주문 처리 완료")
}
interface LoggingContext {
fun log(message: String)
}
트랜잭션 컨텍스트 예제
interface TransactionContext {
fun <T> execute(sql: String, vararg params: Any?): T
fun commit()
fun rollback()
}
// 이 함수들은 TransactionContext 안에서만 호출 가능
context(TransactionContext)
fun createUser(name: String, email: String): Long {
return execute("INSERT INTO users (name, email) VALUES (?, ?)", name, email)
}
context(TransactionContext)
fun assignRole(userId: Long, role: String) {
execute<Unit>("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", userId, role)
}
// 사용
fun registerUser(name: String, email: String) {
transaction { // TransactionContext를 스코프에 제공
val userId = createUser(name, email)
assignRole(userId, "USER")
commit()
}
}
트랜잭션 밖에서 createUser()를 호출하면 컴파일 에러가 발생합니다. 실수로 트랜잭션 없이 DB 작업을 수행하는 것을 원천 차단합니다.
여러 컨텍스트 조합
context(LoggingContext, TransactionContext)
fun createUserWithLog(name: String, email: String): Long {
log("사용자 생성 시작: $name")
val userId = execute<Long>("INSERT INTO users ...", name, email)
log("사용자 생성 완료: $userId")
return userId
}
확장 함수와의 차이
확장 함수와 context receiver는 비슷해 보이지만 의미가 다릅니다.
// 확장 함수 — "이 객체에 대해" 동작
fun LoggingContext.process(order: Order) { /* ... */ }
// context receiver — "이 컨텍스트 안에서" 동작
context(LoggingContext)
fun process(order: Order) { /* ... */ }
확장 함수는 수신 객체에 메서드를 "추가"하는 느낌이고, context receiver는 함수의 실행 환경을 "요구"하는 느낌입니다.
실무 활용 사례
사례 1: 검증 컨텍스트
interface ValidationContext {
fun addError(field: String, message: String)
val errors: List<ValidationError>
val isValid: Boolean get() = errors.isEmpty()
}
context(ValidationContext)
fun validateEmail(email: String) {
if (!email.contains("@")) {
addError("email", "유효한 이메일이 아닙니다")
}
}
context(ValidationContext)
fun validateAge(age: Int) {
if (age < 0 || age > 150) {
addError("age", "유효한 나이가 아닙니다")
}
}
fun validateUser(email: String, age: Int): List<ValidationError> {
return buildValidation {
validateEmail(email)
validateAge(age)
}.errors
}
사례 2: 테스트 DSL
context(TestContext)
fun User.shouldBeActive() {
assert(this.isActive) { "사용자가 활성 상태여야 합니다" }
log("검증 통과: 사용자 ${this.id} 활성 상태")
}
Context Receivers의 한계와 대안
한계
- 실험적 기능: API가 변경될 수 있습니다
- 자바 호환성: context receiver가 있는 함수를 자바에서 호출하기 어렵습니다
- 디버깅: 컨텍스트가 암시적이라 코드 추적이 어려울 수 있습니다
- 컴파일러 지원: IDE 지원이 완전하지 않을 수 있습니다
대안 패턴: 스코프 함수 활용
context receiver 대신 인터페이스와 스코프 함수를 조합할 수 있습니다.
interface TransactionScope {
fun <T> execute(sql: String, vararg params: Any?): T
}
// 확장 함수로 스코프 제한
fun TransactionScope.createUser(name: String, email: String): Long {
return execute("INSERT INTO users ...", name, email)
}
// with로 스코프 제공
fun registerUser(name: String, email: String) {
withTransaction { // this = TransactionScope
val userId = createUser(name, email)
// ...
}
}
이 패턴은 context receiver와 거의 동일한 효과를 주면서, 실험적 기능에 의존하지 않습니다.
정리
Contract
- 함수의 동작에 대한 보장을 컴파일러에게 전달하여 스마트 캐스트를 가능하게 합니다
returns(true) implies (value != null): 반환값과 조건의 관계callsInPlace(block, EXACTLY_ONCE): 람다 호출 횟수 보장- 표준 라이브러리의
require,check,run,let등이 이미 contract를 사용합니다
Context Receivers
- 함수가 특정 컨텍스트에서만 호출 가능하도록 제한합니다
- 트랜잭션, 로깅, 검증 등의 컨텍스트를 타입 안전하게 표현합니다
- 실험적 기능이므로, 프로덕션에서는 스코프 함수 + 확장 함수 조합을 대안으로 고려하세요
두 기능 모두 "컴파일러에게 더 많은 정보를 주어 코드를 안전하게 만든다"는 코틀린의 철학을 따릅니다.