expect/actual은 공통 코드에서 플랫폼 의존 API를 선언할 수 있도록 하는 Kotlin Multiplatform 메커니즘입니다. expect 키워드는 commonMain에서 함수, 클래스 또는 속성의 계약을 생성하고, actual 키워드는 각 플랫폼에 대한 구체적인 구현을 제공합니다. 컴파일러는 모든 목적 플랫폼에서 각 expect 선언에 해당하는 actual 구현이 있는지 확인합니다. JetBrains, 2025에 따르면, 이 메커니즘은 80% KMM 프로젝트에서 플랫폼 비즈니스 로직 구현에 사용됩니다.
주요 포인트
expect/actual은 플랫폼 지향 프로그래밍을 구현하기 위한 Kotlin Multiplatform의 선언적 메커니즘입니다. 공통 모듈에서 API를 한 번 설명하고 (expect) 각 플랫폼별로 별도로 구현할 수 있습니다 (actual). 인터페이스와 달리, expect/actual은 가상 호출을 만들지 않습니다 — 컴파일러가 컴파일 시점에 expect와 actual 선언을 연결하여 동적 디스패치의 오버헤드를 제거합니다.
expect/actual의 역사는 2017년 Kotlin Multiplatform이 도입되면서 시작되었습니다. 초기에는 메커니즘이 expect/actual declarations이라고 불리우며 실험적이었습니다. Kotlin 1.2에서 expect 애노테이션이 추가되었고, Kotlin 1.3에서 expect/actual이 클래스와 함수에 대해 안정적으로 변경되었습니다. 시간이 지나면서 메커니즘이 확장되었습니다: Kotlin 1.6은 컴페니언 객체에 대한 expect/actual 지원을 추가하였고, Kotlin 1.7은 enum 클래스에 대해, Kotlin 2.0은 typealias에 대해 추가했습니다.
expect/actual의 주요 특징은 컴파일 시점 안전성입니다. 개발자가 commonMain에 expect 선언을 추가했지만 iOS에 대한 actual 구현을 잠란 경우, 컴파일러가 오류를 발생시킵니다. 이를 통해 리플렉션이나 플랫폼 코드의 동적 로딩을 사용하는 접근 방식에서 흐한 런타임 오류를 막을 수 있습니다.
expect/actual의 메커니즘은 source set 레벨에서 작동합니다 — Kotlin Multiplatform의 모듈 시스템입니다. 모든 플랫폼에서 사용 가능한 공통 코드는 commonMain source set에 위치합니다. 플랫폼 의존 코드는 iosMain, androidMain, macosMain 등에 위치합니다. commonMain에서 expect 키워드가 API를 선언하고, 플랫폼 source set에서 actual 키워드가 구현을 제공합니다. 컴파일러는 코드 생성 단계에서 이들을 연결하여 목적 플랫폼에 대한 expect 함수 호출을 해당하는 actual 구현로 대체합니다.
일반적인 KMM 프로젝트에서 source set 계층구조는 다음과 같습니다: commonMain은 expect 선언을 포함하고, iosMain 그리고 androidMain은 actual 구현을 포함합니다. iOS로 컴파일할 때는 iosMain에서 actual이 사용되고, Android로 컴파일할 때는 androidMain에서 actual이 사용됩니다. Source set은 중간단계일 수 있으며 (예: 특정 아키텍처에 대한 iosArm64Main), 이를 통해 다양한 기기에 대한 구현을 세분화할 수 있습니다.
// commonMain — expect declaration
expect fun getPlatformName(): String
// androidMain — actual for Android
actual fun getPlatformName(): String = "Android"
// iosMain — actual for iOS
actual fun getPlatformName(): String = "iOS"
Kotlin 컴파일러는 expect/actual을 다룰 때 여러 가지 조건을 확인합니다. 각 활성 플랫폼에 대해 각 expect 선언에 actual 구현이 있어야 합니다. actual 선언의 서명은 expect 서명과 일치해야 합니다 (@OptionalExpectation 애노테이션이 이 요구사항을 완화할 수 있습니다). 액세스 수식자, 반환 타입 그리고 매개변수가 동일해야 합니다. 컴파일러는 expect와 actual 선언 사이의 순환 의존성이 없는지도 확인합니다.
expect/actual은 여러 유형의 선언을 지원합니다. 가장 많이 사용되는 것은 플랫폼 작업에 대한 expect/actual 함수, 네이티브 구현이 필요한 객체에 대한 expect/actual 클래스, 그리고 상수와 설정에 대한 expect/actual 속성입니다. 각 유형은 자체적인 사용 규칙과 제한이 있습니다.
Expect/actual 함수는 가장 간단하고 일반적인 유형입니다. 시간 획득, 파일 읽기, HTTP 요청 보내기 같은 플랫폼 API를 호출하는 데 사용됩니다. Expect/actual 클래스는 네이티브 코드와 직접 상호작용하는 객체를 만드는 데 사용됩니다 (예: 카메라, 위치 정보, 키 저장소에 액세스). Expect/actual 속성(val)은 플랫폼 상수에 적합합니다 — OS 이름, SDK 버전, 시스템 디렉토리 경로.
| 선언 유형 | 키워드 | 사용 예 |
|---|---|---|
| 함수 | expect fun / actual fun | 고유한 기기 식별자 가져오기 |
| 클래스 | expect class / actual class | SecureStorage 액세스 (Keychain / EncryptedSharedPreferences) |
| 속성 | expect val / actual val | 현재 플랫폼 (iOS / Android) |
| Enum 클래스 | expect enum / actual enum | 사용 가능한 앱 권한 목록 |
| Typealias | expect typealias / actual typealias | 플랫폼 특정 네트워크 응답 타입 |
모든 Kotlin 구조물이 expect/actual과 함께 사용될 수 있는 것은 아닙니다. expect 선언은 본문을 가질 수 없습니다 — 오직 서명만 가능합니다. expect 클래스는 매개변수가 있는 생성자를 가질 수 없습니다 (빈 기본 생성자가 필요합니다). enum expect/actual의 경우 expect와 actual 모두에서 모든 상수가 동일해야 합니다. Expect 속성은 val (아닌 var)이어야 합니다. 플랫폼 속성에 대해 공통 모듈에서 상태를 저장하는 것은 의미가 없기 때문입니다.
간단한 함수부터 완전한 클래스까지 expect/actual의 실용적인 예를 살펴보겠습니다. 기본 사례는 UI에서 사용하기 위해 플랫폼 이름을 가져오는 것입니다. 더 복잡한 예는 네이티브 저장소 액세스와 플랫폼 스레드 처리를 포함합니다.
// commonMain — expect class for secure storage
expect class PlatformStorage {
fun save(key: String, value: String)
fun get(key: String): String?
fun remove(key: String)
}
// androidMain — actual on Android
actual class PlatformStorage {
private val prefs = AppContext.getSharedPreferences("secure", 0)
actual fun save(key: String, value: String) { prefs.edit().putString(key, value).apply() }
actual fun get(key: String): String? = prefs.getString(key, null)
actual fun remove(key: String) { prefs.edit().remove(key).apply() }
}
이 예에서 expect 클래스 PlatformStorage는 간단한 키-값 저장소의 계약을 정의합니다. Android에서는 구현이 SharedPreferences를 사용하고, iOS에서는 Keychain 또는 NSUserDefaults를 사용합니다. expect/actual 덩분에 commonMain의 비즈니스 로직은 플랫폼 구현에 대한 지식 없이 save/get/remove를 호출합니다.
// iosMain — actual on iOS with Keychain
actual class PlatformStorage {
actual fun save(key: String, value: String) {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key,
kSecValueData to value.encodeToByteArray()
)
SecItemAdd(query, null)
}
actual fun get(key: String): String? {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key,
kSecReturnData to true
)
val result = mutableMapOf<String, Any>()
return if (SecItemCopyMatching(query, result) == errSecSuccess)
result[kSecValueData]?.toString()
else null
}
actual fun remove(key: String) {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key
)
SecItemDelete(query)
}
}
expect/actual API를 설계할 때 몇 가지 원칙을 따르는 것이 좋습니다. expect 선언의 수를 최소화하십시오 — 공통 코드가 많을수록 유지보수가 간단해집니다. expect/actual은 플랫폼간에 진짜로 차이가 있는 API에만 사용하십시오. 나머지 코드에는 팩토리 또는 의존성 주입을 통한 인터페이스를 사용하십시오. 이를 통해 테스트가 간단해집니다.
expect 선언은 한 파일에 매치하는 대신 주제별 모듈로 그룹화하는 것이 좋습니다. 예를 들어 Storage.kt는 저장소에 대한 expect 선언을, Platform.kt는 OS를 다루는 expect 함수를, Analytics.kt는 분석 expect 클래스를 위한 것입니다. 이를 통해 KMM 프로젝트의 플랫폼 표면을 더 쉽게 파악할 수 있습니다. 각 actual 파일은 해당 source set에 있어야 합니다: androidMain, iosMain, desktopMain 등.
actual이 공통 코드를 사용하는 expect fun과 actual fun을 통한 기본 구현은 흔한 안티패턴입니다. 플랫폼 구현이 기본과 다르지 않다면 expect/actual이 필요 없습니다. 그런 경우 commonMain에서 간단한 함수를 사용하십시오. 또한 단순한 getter에는 expect/actual을 피하십시오 — 상수와 함께 expect val을 사용하십시오.
expect/actual 코드의 적절한 구조는 프로젝트 가독성에 국히 중요합니다. 각 expect/actual 모듈은 단일 입구 점을 가쥘야 합니다. 조직 예: commonMain/kotlin/com/project/platform은 expect 선언을 포함하고, androidMain/kotlin/com/project/platform은 Android에 대한 actual을, iosMain/kotlin/com/project/platform은 iOS에 대한 actual을 포함합니다. 파일 및 패키지 이름은 expect와 actual에서 동일해야 함으로써 개발자가 해당 구현을 빠르게 찾을 수 있습니다.
플랫폼 팩토리가 있는 인터페이스는 expect/actual의 주요 대체 방법입니다. expect 클래스 대신 commonMain에서 인터페이스를 선언하고 플랫폼 모듈에서 구체적인 클래스를 만들 수 있습니다. 팩토리 또는 DI 컨테이너가 런타임에 올바른 구현을 제공합니다. 이 접근 방식은 인터페이스를 목어쓸 수 있으므로 테스트에 더 적합합니다.
의존성 주입(Koin, Kodein)은 더 유연하지만 더 낮은 성능의 접근 방식입니다. DI 컨테이너는 각 플랫폼별로 별도로 구성되며 공통 코드에 플랫폼 의존성을 제공합니다. expect/actual과 달리, 주입은 런타임에 발생하여 테스트를 위해 구현을 바꾼 수 있습니다. 반면, DI 구성 오류는 컴파일 시점이 아닌 런타임에서만 감지됩니다.
| 접근 방식 | 컴파일 시점 확인 | 테스트 유연성 | 런타임 오버헤드 |
|---|---|---|---|
| expect/actual | 완전 | 낮음 (actual은 목어쓸 수 없음) | 제로 (컴파일 시점 바인딩) |
| 인터페이스 + 팩토리 | 부분적 | 높음 (목어쓸 수 있음) | 최소 (가상 호출) |
| 의존성 주입 | 없음 (런타임) | 높음 | 중간 (DI 프록시) |
expect/actual과 대체 방법 사이의 선택은 문맥에 따라 달라집니다. 성능이 중요한 코드(게임 엔진, 실시간 처리)의 경우 제로 오버헤드로 인해 expect/actual이 더 좋습니다. 비즈니스 로직(리포지토리, 유스 케이스)의 경우 테스트를 간단하게 하기 위해 DI가 있는 인터페이스를 사용하는 것이 좋습니다. 결합 접근 방식 — 저위 플랫폼 작업에는 expect/actual, 비즈니스 로직 계층에는 인터페이스 — 가 대부분의 제품 KMM 프로젝트에서 사용됩니다.
자주 묻는 질문
expect/actual은 가상 호출 없이 컴파일 시점에 구현을 바인딩하고, 인터페이스는 런타임에 바인딩합니다. expect/actual은 모든 플랫폼에 대한 구현을 보장하고, 인터페이스는 런타임 확인이 필요합니다.
네, expect enum은 Kotlin 1.7부터 지원됩니다. expect와 actual enum의 모든 상수가 일치해야 합니다. 다른 플랫폼에서 상수 값이 다른 경우 컴파일 오류가 발생합니다.
컴파일러가 actual 구현이 없는 각 플랫폼에 대해 오류를 발생시킵니다. 모든 expect 선언에 해당하는 actual 구현이 추가될 때까지 프로젝트가 빌드되지 않습니다.
아니요, expect와 actual은 다른 source set에 있어야 합니다. expect는 commonMain 또는 중간 source set에, actual은 플랫폼 source set에 있어야 합니다. expect와 actual을 동일한 source set에 두면 컴파일 오류가 발생합니다.
expect/actual을 테스트하려면 플랫폼 테스트 source set과 함께 commonTest를 사용하십시오. commonTest에서 expect 테스트를 작성하고 각 플랫폼에 대한 actual 테스트를 작성합니다. 통합 테스트는 각 목적 플랫폼에서 별도로 실행됩니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.