
개요
Kotlin은 정적 타입을 가진 현대적 JVM 언어로 시작했지만, 지금은 JVM, JavaScript, Native를 포함한 멀티플랫폼 생태계를 제공합니다. 본 글에서는 1) 안정적이고 널리 쓰이는 핵심 개념, 2) 플랫폼별·설계적 트레이드오프, 3) 코루틴 기반의 실전 예제를 중심으로 설명합니다. 새로운 기능이나 측정값을 주장하지 않고, 장기적으로 안정된 개념과 실제 적용에서 고려해야 할 점들만 다룹니다.
핵심 개념 정리
아래는 Kotlin을 이해하는 데 중요한 언어적·런타임 특성입니다.
널 안전성 (Null-safety)
타입 시스템에서 nullable(예: String?)과 non-null(String)을 분리합니다. 플랫폼 타입(자바 코드와 상호작용할 때 생성되는 타입)은 컴파일 시점에 완전한 보증을 제공하지 못하므로, 외부 API를 사용할 때는 주의가 필요합니다.
코루틴 (Coroutines)
코루틴은 경량 스레드 기반의 비동기/동시성 모델을 제공합니다. suspend 함수, Structured Concurrency(스코프 기반 생명주기 관리), Flow(reactive 스트림 API) 등으로 표현됩니다. 코루틴은 협조적 취소와 디버깅을 위한 도구(디버거, 디버깅 프로브)를 제공하지만, 동시성 버그는 여전히 존재할 수 있습니다.
데이터 클래스, sealed 클래스·인터페이스
data class는 불변성/값 타입 스타일의 편의성을 주고, sealed 클래스(또는 sealed interface)는 닫힌 계층(exhaustive when 처리)을 만들 때 유용합니다.
값 클래스(Value/Inline classes)
오버헤드를 줄이기 위해 래퍼를 런타임에서 제거할 수 있습니다. 단, 제약(예: 특정 상황에서 박싱 발생 가능)이 있으므로 레퍼런스와 프리미티브 표현의 차이를 이해해야 합니다.
멀티플랫폼 지원
Kotlin Multiplatform은 비즈니스 로직을 공유하고 플랫폼별 구현을 분리하도록 설계되었습니다. 공유 코드는 순수 Kotlin으로 작성하고, 플랫폼별 API(예: UI, 네이티브 라이브러리)는 expect/actual 패턴으로 분리합니다.
트레이드오프 (언제/어떻게 선택할지)
언어 선택과 설계에서 자주 맞닥뜨리는 고려사항들입니다.
JVM vs Native vs JS
- JVM: 안정적이고 풍부한 라이브러리·툴링·JIT 성능을 제공합니다. 시작 시간과 메모리 사용량이 중요하지 않은 서버 애플리케이션에 적합합니다.
- Native: 작은 바이너리와 빠른 시작 시간을 원할 때 유리하지만, 플랫폼 간 동시성 모델과 런타임 특성이 달라질 수 있고 디버깅·도구 지원이 JVM만큼 성숙하지 않을 수 있습니다.
- JS: 프런트엔드와의 통합을 위해 유용하지만 JavaScript 생태계 및 런타임 제약을 이해해야 합니다.
코루틴의 장단점
장점: 경량, 명시적 취소, structured concurrency로 안전한 라이프사이클 관리 가능. 단점: cooperative(협조적) 모델이기 때문에 블로킹 코드(특히 플랫폼별 네이티브 환경)와 섞이면 문제가 생기며, 스택 트레이스 해석과 동시성 버그 추적이 까다로울 수 있습니다.
Null 안전성과 플랫폼 타입
Kotlin의 널 안전성은 큰 장점이지만, Java 상호운용 시 플랫폼 타입에서는 런타임 NPE가 발생할 수 있습니다. Java API를 감쌀 때는 명시적 검사나 어댑터를 두는 것이 안전합니다.
컴파일/빌드 시간 & 툴링
Kotlin 코틀린 DSL, Gradle 통합은 강력하지만, 큰 멀티모듈 프로젝트에서 컴파일 시간이 느려질 수 있습니다. 빌드 캐시와 incremental compilation 설정을 적극 활용하세요.
실전 예제: 코루틴과 Flow로 안전한 프로듀서-컨슈머 구현
아래 예제는 Kotlin 코루틴과 Flow를 사용해 데이터를 생산하고, 소비 측에서 취소/예외 처리를 안전하게 수행하는 간단한 패턴을 보여줍니다. 이 코드는 JVM 환경의 예시이며, kotlinx.coroutines 라이브러리가 필요합니다.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// 생산자: 1..10 숫자를 200ms 간격으로 발행하는 Flow
fun numberProducer(): Flow = flow {
for (i in 1..10) {
delay(200) // 비동기 지연 (비차단)
emit(i)
}
}
// 소비자: Flow를 수집하며 중간 연산과 취소 처리를 수행
fun main() = runBlocking {
// SupervisorJob을 사용해 자식 실패가 다른 자식에 영향을 주지 않도록 할 수 있음
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val job = scope.launch {
try {
numberProducer()
.onStart { println("수집 시작") }
.map { value ->
// 중간 연산: 비동기 작업(예: 네트워크 요청) 대신 간단한 변환
"value-${value * 10}"
}
.filter { s ->
// 예: 홀수만 처리
s.endsWith("0") // 항상 true지만 예시용 필터
}
.collect { item ->
println("처리: $item")
if (item.contains("50")) {
// 조건부로 취소: 구조화된 취소를 통해 안전하게 정리
println("중단 조건 충족 -> 취소 요청")
cancel() // 현재 코루틴을 취소
}
}
} catch (e: CancellationException) {
println("수집이 취소되었습니다: ${e.message}")
// 필요한 정리 작업을 수행
} catch (e: Throwable) {
println("수집 중 예외 발생: ${e.message}")
} finally {
println("수집 종료/정리 완료")
}
}
// 1.5초 후 외부에서 전체 scope를 취소해도 안전하게 닫힘
delay(1500)
println("외부에서 스코프 취소 요청")
scope.cancel()
// 모든 작업이 끝날 때까지 대기
job.join()
println("프로그램 종료")
}
예제 설명:
- Flow는 cold stream이기 때문에 collect를 호출해야 동작합니다.
- Structured Concurrency: scope와 job을 통해 라이프사이클을 관리하므로 외부에서 취소하면 내부 작업이 안전하게 종료됩니다.
- 취소는 cooperative입니다. suspend 포인트(예: delay, emit 등)에서만 즉시 적용되므로 블로킹 API를 호출할 경우 withContext(Dispatchers.IO)로 오프로드하거나 적절한 래핑이 필요합니다.
언제 Kotlin을 선택해야 하는가
다음과 같은 경우 Kotlin이 좋은 선택이 될 가능성이 큽니다:
- JVM 기반 서버 개발에서 더 안전하고 표현력 있는 언어를 원할 때
- 공유 비즈니스 로직을 여러 플랫폼(Android, iOS, JS)에서 재사용하고 싶을 때 (멀티플랫폼)
- 비동기 흐름을 명확하게 표현하고 구조화된 동시성 모델을 활용하고 싶을 때
반대로, 플랫폼별 성능 튜닝이 극단적으로 중요한 경우(예: 작은 임베디드, 매우 엄격한 메모리 제약), 또는 런타임/라이브러리 의존성을 최소화해야 할 때는 다른 선택지도 고려해야 합니다.
결론
Kotlin은 널 안전성, 표현력 있는 타입 시스템, 코루틴을 통한 현대적 동시성 모델, 그리고 멀티플랫폼 지원을 제공하는 실용적인 언어입니다. 하지만 플랫폼별 차이와 런타임·빌드 트레이드오프를 이해하고 설계를 진행하는 것이 중요합니다. 위 예제처럼 코루틴과 Flow를 적절히 활용하면 안전하고 가독성 높은 비동기 코드를 작성할 수 있습니다.