
개요
Kotlin은 널 안전성(null-safety), 표현력 높은 타입 시스템, 코루틴을 통한 비동기 처리, 그리고 멀티플랫폼 지원 등으로 널리 사용됩니다. 이 글은 안정적인 개념들을 요약하고, 실제 설계에서 마주치는 트레이드오프를 설명한 뒤, 코루틴과 Flow를 이용한 실무 예제를 제공합니다.
핵심 개념 정리
널 안전성 (Null-safety)
Kotlin의 타입 시스템은 nullable(T?)과 non-nullable(T)을 구분합니다. 컴파일러 수준의 검사로 널 참조로 인한 런타임 예외를 줄여줍니다. 그러나 API 경계에서는 다음과 같은 고려가 필요합니다:
- Java 상호운용 시 매개변수/반환값에 대한 널어빌리티 정보를 잃을 수 있음(어노테이션으로 보완 가능).
- 널 가능 타입을 지나치게 남발하면 호출부에서 과도한 널 처리 코드가 생김.
- 강제 언래핑(
!!)은 런타임 NPE를 유발하므로 가능한 사용을 피해야 함.
코루틴과 Flow
코루틴은 경량 스레드 기반 비동기 처리를 제공하고, 구조적 동시성(structured concurrency)을 통해 수명과 취소를 관리합니다. Flow는 비동기 스트림을 표현하며, 백프레셔와 연산자 조합으로 데이터 파이프라인을 구성할 수 있습니다.
데이터/시일드/값 클래스
data class는 불변 상태 모델링과 편한 구조분해를 돕고, sealed class/ sealed interface는 도메인 이벤트와 결과 타입을 안전하게 표현합니다. 값 클래스(value/inline class)는 런타임 오버헤드를 줄이는 데 유리하지만 플랫폼별 제약(직렬화, 상호운용성 등)을 확인해야 합니다.
자바 상호운용성
Kotlin은 JVM에서 Java 코드와 거의 투명하게 연동됩니다. 하지만 널 어노테이션, SAM 변환, 예외 처리 관습 등에서 설계 결정이 필요합니다. Java API를 그대로 래핑하는 경우 Kotlin 측에서 더 안전한 API(예: non-null 타입, sealed 결과 타입)를 제공하는 것이 좋습니다.
설계상 트레이드오프
아래는 Kotlin을 사용하면서 자주 마주치는 트레이드오프와 그에 따른 고려사항입니다.
명시적 널 처리 vs 편의성
널 안전성을 엄격히 지키면 런타임 오류를 줄일 수 있지만, 호출자 코드에서 널 체크가 많아져 가독성이 떨어질 수 있습니다. 설계 방안으로는 도메인 경계에서 널을 제거하고, 내부적으로는 nullable을 다루는 명확한 규칙을 두는 것이 좋습니다.
코루틴: 취소·예외 전파 vs 단순성
코루틴은 취소(취소 토큰)와 예외 전파 모델이 있으므로 복잡한 동시성 로직을 안전하게 관리할 수 있습니다. 반면, 잘못된 취소 설계는 리소스 누수나 불완전한 정리(cleanup)를 초래합니다. suspend 함수와 코루틴 스코프를 명확하게 분리하고, 필요한 곳에서 withContext로 컨텍스트를 전환하세요.
멀티플랫폼: 코드 재사용 vs 플랫폼 API 접근
Kotlin Multiplatform을 통해 공통 비즈니스 로직을 공유할 수 있지만, 플랫폼 고유 기능(네트워크, 파일 I/O, UI)은 각 플랫폼별 expect/actual이나 플랫폼 모듈로 분리해야 합니다. 공유 가능한 경계와 플랫폼 의존성 분리를 설계하는 것이 핵심입니다.
실용 예제: 코루틴 + Flow로 데이터 파이프라인 만들기
다음 예제는 페이지 단위 API를 비동기적으로 가져와서 소비자에게 Flow로 제공하는 간단한 패턴입니다. 예제는 오류를 Result 계열로 감싸 표현하고, 백프레셔와 취소를 고려합니다.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// 도메인 모델
data class Item(val id: Int, val payload: String)
// 안전한 결과 표현 (sealed로 패턴 매칭 가능)
sealed class RepoResult<out T> {
data class Success<T>(val value: T) : RepoResult<T>()
data class Error(val throwable: Throwable) : RepoResult<Nothing>()
}
// 간단한 Repository: 페이지 단위로 항목을 비동기적으로 제공
class ItemRepository(private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO) {
// 외부에 제공할 Flow: 소비자는 collect로 아이템을 받음
fun itemsFlow(): Flow<RepoResult<List<Item>>> = flow {
var page = 1
while (true) {
// fetchPage는 suspend 함수로 가정
val pageItems = try {
fetchPage(page)
} catch (e: Throwable) {
emit(RepoResult.Error(e))
break
}
if (pageItems.isEmpty()) break
emit(RepoResult.Success(pageItems))
page++
}
}
.flowOn(ioDispatcher) // 네트워크/디스크 작업은 IO 디스패처에서 실행
.buffer() // 소비가 느릴 때 생산을 약간 버퍼링하여 성능 향상 가능
// 예시: 실제 구현은 네트워크 호출 등
private suspend fun fetchPage(page: Int): List<Item> {
delay(200) // 시뮬레이션(비동기 작업)
if (page > 3) return emptyList()
return List(5) { idx -> Item(id = (page - 1) * 5 + idx, payload = "p${page}-i$idx") }
}
}
// 소비 예시 (테스트나 애플리케이션 진입점에서)
fun main() = runBlocking {
val repo = ItemRepository()
val job = launch {
repo.itemsFlow()
.onEach { result ->
when (result) {
is RepoResult.Success -> println("Got ${result.value.size} items")
is RepoResult.Error -> println("Error: ${'$'}{result.throwable.message}")
}
}
.catch { e -> println("Flow error: ${'$'}e") } // Flow 내부 예외 처리
.collect()
}
// 예: 필요한 시점에 취소
delay(500)
job.cancelAndJoin()
println("Collector stopped")
}
예제에서 고려해야 할 트레이드오프
- buffer(): 성능 향상과 메모리 사용 증가 사이의 균형. 소비가 매우 느릴 때는 버퍼가 커지면 메모리 문제가 생길 수 있음.
- flowOn(IO): I/O는 적절한 디스패처에서 실행해야 함. 그러나 스레드 오버헤드와 컨텍스트 전환 비용을 고려해야 함.
- 에러 처리: Flow 내부에서 예외를 던지면 스트림이 종료될 수 있음.
try/catch로 개별 페이지 실패를 처리할지, 스트림 전체 실패로 볼지 설계에 따라 결정. - 취소: 호출자가 취소하면 하위 작업도 즉시 취소되도록 코루틴 컨텍스트와 연동해야 함. 리소스 정리는
try { } finally { }또는use패턴으로 보장.
테스트 및 유지보수 관점
비동기 코드를 테스트할 때는 디스패처를 테스트용 디스패처로 주입하고, suspend 함수와 Flow의 시나리오를 단위 테스트로 검증하세요. sealed 결과 타입을 사용하면 테스트에서 모든 케이스를 강제적으로 검사하기 쉬워집니다.
마무리
Kotlin은 표현력과 안전성을 높이면서도 실무에서 다양한 트레이드오프를 제공합니다. 널 안전성·코루틴·멀티플랫폼을 설계 경계에서 명확히 구분하고, API 경계에서의 타입 설계와 비동기 흐름의 취소·오류 전략을 문서화하면 유지보수성과 안정성을 높일 수 있습니다.