
개요
이 글은 Kotlin의 핵심 개념과 실무 관점에서 고려해야 할 트레이드오프를 정리하고, 간단한 Kotlin Multiplatform(KMP) 예제를 통해 공용 비즈니스 로직을 작성하고 Android 쪽에서 재사용하는 흐름을 제시합니다. 도구와 라이브러리는 널리 사용되는 kotlinx.coroutines, kotlinx.serialization, Ktor(클라이언트)를 전제로 합니다.
핵심 개념 요약
아래 개념들은 Kotlin을 장기간 안정적으로 사용하는 데 필요한 기반입니다.
널 안전성 (Null-safety)
Kotlin 타입 시스템은 널 가능성을 타입 수준에서 표현합니다. nullable 타입은 물음표 표기(String?)로, 강제 언래핑(!!), 안전 호출(?:) 등을 사용합니다. 이는 런타임 NPE를 줄여주지만, 외부 자바 API와 섞일 때는 설계와 검증이 중요합니다.
데이터 클래스 & 값 클래스
data class는 불변 데이터 모델과 편의 메서드(copy, toString, equals 등)를 자동으로 제공합니다. JVM에서의 inline/value classes(=@JvmInline value class)는 박싱 비용을 줄이고 표현형을 강화하지만 제약(단일 프로퍼티 등)이 있습니다.
코루틴과 Flow
suspend 함수와 kotlinx.coroutines는 비동기 코드를 순차적 스타일로 작성하게 해 줍니다. Flow는 스트림을 표현하기 위한 표준이며 backpressure와 취소(코루틴 컨텍스트 기반)를 자연스럽게 처리합니다. Structured Concurrency(ス코프 기반 자원 관리) 원칙을 따르면 누수와 경쟁 상태를 예방할 수 있습니다.
Kotlin Multiplatform (KMP)
KMP는 공용 비즈니스 로직을 여러 플랫폼(JVM/Android, iOS, JS, Native)에 공유할 수 있게 합니다. expect/actual 패턴으로 플랫폼별 구현을 분리하고, 공용 모듈에서는 코루틴과 직렬화 같은 논리만 작성합니다. 하지만 플랫폼별 런타임 차이, 빌드 복잡도, 네이티브 디버깅 난이도 등의 트레이드오프가 존재합니다.
트레이드오프
아래는 실무에서 자주 부딪히는 선택지와 그에 따른 고려사항입니다.
- 개발 생산성 vs 런타임·배포 크기: Kotlin은 표현력이 높아 생산성을 올려주지만, 멀티플랫폼 빌드(특히 JS/Native)에선 번들 크기와 설정이 늘어날 수 있습니다.
- Java 상호운용성: Kotlin은 Java와 매끄럽게 연동되지만, nullable 어노테이션이 부족한 레거시 Java API는 안전 경계가 필요합니다. 또한 Lombok같은 도구와의 충돌을 주의해야 합니다.
- 비동기 모델 선택: 콜백/Reactive vs Coroutine/Flow. 코루틴은 읽기 쉽고 제어 흐름 처리가 쉬운 반면, 이미 Reactive 시스템(Reactor/RxJava)에 투입된 환경에서는 컨텍스트 전환 비용·학습 비용을 고려해야 합니다.
- 멀티플랫폼 채택 시점: 초기에 MPP 설계를 도입하면 코드 재사용성이 높아지지만, 플랫포옴별 UI/네이티브 인터랙션은 여전히 플랫폼 전용 코드가 필요합니다. 작은 앱에선 오버헤드가 될 수 있습니다.
실용 예제: KMP 공용 모듈에서 Ktor + kotlinx.serialization 사용하기
아래 예제는 KMP 공용 모듈(commonMain)에 네트워크 계층을 두고, 플랫폼별로 HttpClient 엔진을 주입하는 형태입니다. 공용 코드에서는 suspend API와 Flow를 사용해 간단한 레포지토리 패턴을 구현합니다.
공용 모델 및 API 정의 (commonMain)
// commonMain/src/commonMain/kotlin/model.kt
import kotlinx.serialization.Serializable
@Serializable
data class Post(
val id: Int,
val title: String,
val body: String
)
// commonMain/src/commonMain/kotlin/result.kt
sealed class Result<out T> {
data class Success<T>(val value: T) : Result<T>()
data class Error(val throwable: Throwable) : Result()
}
공용 레포지토리 (Ktor 사용)
// commonMain/src/commonMain/kotlin/PostsRepository.kt
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
class PostsRepository(private val client: HttpClient) {
fun fetchPostsFlow(): Flow
플랫폼별 HttpClient 제공 (expect/actual)
// commonMain
expect fun createHttpClient(): HttpClient
// androidMain
actual fun createHttpClient(): HttpClient = HttpClient(Android) {
// 공용 직렬화 설정, 타임아웃 등은 공용 모듈에서 구성하거나
// 플랫폼 엔진 특화 옵션을 여기에 둡니다.
}
// iosMain / jsMain 등에서 각각 actual 구현 제공
Android(Compose)에서 사용하기 — ViewModel 예시
// Android: PostsViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class PostsViewModel(private val repository: PostsRepository) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
init {
load()
}
fun load() {
viewModelScope.launch {
// 한 번만 불러오기
when (val res = repository.fetchPostsOnce()) {
is Result.Success -> _uiState.value = UiState.Success(res.value)
is Result.Error -> _uiState.value = UiState.Error(res.throwable)
}
}
}
}
sealed class UiState {
object Loading : UiState()
data class Success(val posts: List<Post>) : UiState()
data class Error(val throwable: Throwable) : UiState()
}
Compose에서 간단한 UI 바인딩
@Composable
fun PostsScreen(viewModel: PostsViewModel) {
val state by viewModel.uiState.collectAsState()
when (state) {
is UiState.Loading -> Text("Loading...")
is UiState.Success -> LazyColumn {
items((state as UiState.Success).posts) { post ->
Text(post.title)
}
}
is UiState.Error -> Text("Error: ${(state as UiState.Error).throwable.message}")
}
}
설계 팁 및 권장 사항
- 공용 모듈에는 부작용이 적은 순수 비즈니스 로직과 도메인 모델을 두고, 플랫폼별 상호작용(HTTP 엔진, 데이터베이스 드라이버, 로컬 스토리지)은 expect/actual로 분리하세요.
- 코루틴 스코프 관리는 명확하게. UI에서는 viewModelScope/CoroutineScope을 사용하고, 공용 로직에서는 취소 가능한 suspend/Flow API를 노출하세요.
- 테스트 가능한 설계: HttpClient를 주입 가능한 추상화(Iface)로 감싸면 JVM에서 목을 쉽게 만들 수 있습니다.
- 직렬화: kotlinx.serialization을 공용 모듈에 두면 iOS/JS에서도 동일한 모델을 사용 가능하므로 버전·스키마 관리가 편해집니다.
결론
Kotlin은 표현력, 안전성, 비동기 모델의 강력함 덕분에 현재도 안정적인 선택지입니다. Multiplatform을 도입하면 코드 재사용성을 크게 높일 수 있으나, 빌드·배포 복잡도와 플랫폼 간 차이를 고려한 설계가 필요합니다. 코루틴과 Flow를 활용한 명확한 추상화와 플랫폼별 책임 분리는 유지보수성과 테스트 용이성을 높이는 핵심 패턴입니다.