Theme:

"코틀린의 null safety나 스마트 캐스트가 편리한데, 내가 만든 유틸 함수에서는 왜 스마트 캐스트가 동작하지 않을까요? 컴파일러에게 '이 함수는 이런 보장을 합니다'라고 알려줄 수는 없을까요?"

코틀린의 contractcontext receivers는 컴파일러에게 추가 정보를 전달하여 더 나은 타입 추론과 안전성을 제공하는 기능입니다. contract는 함수의 동작에 대한 보장을, context receivers는 함수가 실행될 수 있는 환경에 대한 제약을 표현합니다.

Contract — 함수의 동작을 컴파일러에게 알리기

문제: 커스텀 함수에서 스마트 캐스트가 안 되는 이유

KOTLIN
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 — 반환값과 조건의 관계

KOTLIN
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 효과

KOTLIN
// 타입 체크
@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으로 스마트 캐스트
}
KOTLIN
// 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 등이 이 계약을 사용합니다.

KOTLIN
@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를 사용합니다.

KOTLIN
// 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)  // ✅ 스마트 캐스트 동작
}
KOTLIN
// check 함수도 동일한 패턴
fun validate(age: Int?) {
    check(age != null) { "나이가 null입니다" }
    println(age + 1) // ✅ Int로 스마트 캐스트
}

Context Receivers — 실행 환경을 타입으로 제한하기

Context receivers는 함수가 특정 컨텍스트에서만 호출 가능하도록 제한하는 기능입니다.

참고: Context receivers는 실험적 기능이며, 향후 "context parameters"로 대체될 수 있습니다. -Xcontext-receivers 컴파일러 옵션이 필요합니다.

기본 문법

KOTLIN
// 이 함수는 LoggingContext가 스코프에 있을 때만 호출 가능
context(LoggingContext)
fun processOrder(order: Order) {
    log("주문 처리 시작: ${order.id}") // LoggingContext의 멤버
    // ...
    log("주문 처리 완료")
}

interface LoggingContext {
    fun log(message: String)
}

트랜잭션 컨텍스트 예제

KOTLIN
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 작업을 수행하는 것을 원천 차단합니다.

여러 컨텍스트 조합

KOTLIN
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는 비슷해 보이지만 의미가 다릅니다.

KOTLIN
// 확장 함수 — "이 객체에 대해" 동작
fun LoggingContext.process(order: Order) { /* ... */ }

// context receiver — "이 컨텍스트 안에서" 동작
context(LoggingContext)
fun process(order: Order) { /* ... */ }

확장 함수는 수신 객체에 메서드를 "추가"하는 느낌이고, context receiver는 함수의 실행 환경을 "요구"하는 느낌입니다.

실무 활용 사례

사례 1: 검증 컨텍스트

KOTLIN
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

KOTLIN
context(TestContext)
fun User.shouldBeActive() {
    assert(this.isActive) { "사용자가 활성 상태여야 합니다" }
    log("검증 통과: 사용자 ${this.id} 활성 상태")
}

Context Receivers의 한계와 대안

한계

  1. 실험적 기능: API가 변경될 수 있습니다
  2. 자바 호환성: context receiver가 있는 함수를 자바에서 호출하기 어렵습니다
  3. 디버깅: 컨텍스트가 암시적이라 코드 추적이 어려울 수 있습니다
  4. 컴파일러 지원: IDE 지원이 완전하지 않을 수 있습니다

대안 패턴: 스코프 함수 활용

context receiver 대신 인터페이스와 스코프 함수를 조합할 수 있습니다.

KOTLIN
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

  • 함수가 특정 컨텍스트에서만 호출 가능하도록 제한합니다
  • 트랜잭션, 로깅, 검증 등의 컨텍스트를 타입 안전하게 표현합니다
  • 실험적 기능이므로, 프로덕션에서는 스코프 함수 + 확장 함수 조합을 대안으로 고려하세요

두 기능 모두 "컴파일러에게 더 많은 정보를 주어 코드를 안전하게 만든다"는 코틀린의 철학을 따릅니다.

댓글 로딩 중...