Split View: Kotlin 완전 가이드 2025: Spring Boot 백엔드부터 Android, Multiplatform, Coroutines까지
Kotlin 완전 가이드 2025: Spring Boot 백엔드부터 Android, Multiplatform, Coroutines까지
목차
1. 2025년 Kotlin의 위상
Kotlin은 2025년 JVM 생태계에서 가장 빠르게 성장하는 언어 중 하나입니다. Google이 Android 개발의 공식 언어로 채택한 이후, 이제는 서버 사이드, 멀티플랫폼, 데이터 사이언스까지 영역을 확장하고 있습니다.
Kotlin 채택 현황
| 영역 | 상태 | 주요 사용처 |
|---|---|---|
| Android | 공식 1순위 언어 | 새 프로젝트 95%+ Kotlin |
| Spring Boot | 공식 지원 (1st class) | 코루틴 + WebFlux 통합 |
| Ktor | Kotlin 네이티브 서버 | 경량 마이크로서비스 |
| KMP | Stable (1.9.20+) | iOS/Android 비즈니스 로직 공유 |
| Compose Multiplatform | Beta → Stable | Android/Desktop/Web UI 공유 |
| 데이터 사이언스 | 성장 중 | Kotlin DataFrame, KotlinDL |
왜 Kotlin인가
- 간결성: Java 대비 40% 적은 코드
- 안전성: 타입 시스템 수준의 null 안전성
- 상호운용성: 기존 Java 코드와 100% 호환
- 코루틴: 구조적 동시성으로 비동기 처리 단순화
- 멀티플랫폼: 하나의 코드베이스로 여러 플랫폼 지원
2. Kotlin vs Java: 비교와 마이그레이션
문법 비교
// Java
public class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
// Kotlin - 한 줄로 동일한 기능
data class User(val name: String, val age: Int)
핵심 차이점
| 기능 | Java | Kotlin |
|---|---|---|
| Null 안전성 | Optional, 어노테이션 | 타입 시스템 내장 (?/!!) |
| Data 클래스 | Record (Java 16+) | data class (1.0부터) |
| 확장 함수 | 불가 | 네이티브 지원 |
| 코루틴 | Project Loom (Virtual Threads) | suspend/launch/async |
| Sealed 타입 | sealed (Java 17+) | sealed class/interface |
| 스마트 캐스트 | instanceof + 캐스팅 | 자동 캐스팅 |
| 기본 매개변수 | 오버로딩 필요 | 기본값 지원 |
| 문자열 템플릿 | Java 21+ STR | 기본 지원 |
| 스코프 함수 | 없음 | let, run, apply, also, with |
Java에서 Kotlin으로 마이그레이션
// 1단계: build.gradle.kts에 Kotlin 추가
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.spring") version "2.0.0"
}
// 2단계: Java 파일을 Kotlin으로 변환 (IntelliJ: Ctrl+Alt+Shift+K)
// 3단계: Kotlin 관용구(idiom) 적용
// 4단계: 코루틴 도입
마이그레이션 전략:
- 새 파일은 Kotlin으로 작성
- 테스트부터 Kotlin으로 전환
- Data 클래스 우선 변환
- 서비스 레이어 점진적 전환
- 코루틴 도입은 마지막 단계
3. Kotlin 핵심 언어 기능
Null 안전성 (Null Safety)
// Nullable 타입 선언
var name: String? = null
// 안전한 호출 연산자
val length = name?.length // null이면 null 반환
// 엘비스 연산자
val length = name?.length ?: 0 // null이면 기본값
// 스마트 캐스트
if (name != null) {
println(name.length) // 자동으로 String으로 캐스팅
}
// let 스코프 함수
name?.let { nonNullName ->
println(nonNullName.length)
}
Data Class
data class User(
val id: Long,
val name: String,
val email: String,
val role: Role = Role.USER // 기본값
) {
// copy로 불변 객체 수정
fun promote() = copy(role = Role.ADMIN)
}
// 구조 분해 선언
val (id, name, email) = user
Sealed Class / Sealed Interface
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
// when 표현식에서 완전성 보장
fun handleResult(result: Result<User>) = when (result) {
is Result.Success -> println("User: ${result.data}")
is Result.Error -> println("Error: ${result.exception.message}")
is Result.Loading -> println("Loading...")
// else 불필요 - 모든 케이스 처리됨
}
확장 함수 (Extension Function)
// String에 새 메서드 추가
fun String.toSlug(): String =
lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
.trim('-')
// 사용
val slug = "Hello World 2025!".toSlug() // "hello-world-2025"
// 컬렉션 확장
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null
// 제네릭 확장
inline fun <reified T> Any.castOrNull(): T? = this as? T
위임 (Delegation)
// 클래스 위임
interface Repository {
fun findAll(): List<User>
}
class CachedRepository(
private val delegate: Repository
) : Repository by delegate {
private val cache = mutableMapOf<String, List<User>>()
override fun findAll(): List<User> =
cache.getOrPut("all") { delegate.findAll() }
}
// 프로퍼티 위임
class UserPreferences {
var theme: String by Delegates.observable("light") { _, old, new ->
println("Theme changed: $old -> $new")
}
val expensiveValue: String by lazy {
computeExpensiveValue()
}
}
DSL (Domain Specific Language)
// HTML DSL 예제
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
class HTML {
fun body(init: Body.() -> Unit) { /* ... */ }
}
class Body {
fun p(text: String) { /* ... */ }
fun div(init: Div.() -> Unit) { /* ... */ }
}
// 사용
val page = html {
body {
p("Hello, Kotlin DSL!")
div {
p("Nested content")
}
}
}
// 실전: Gradle Kotlin DSL
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
}
4. Coroutines 딥다이브
기본 개념
코루틴은 경량 스레드처럼 동작하는 비동기 처리 메커니즘입니다. 실제 OS 스레드를 차단하지 않고 중단(suspend)했다가 재개할 수 있습니다.
import kotlinx.coroutines.*
fun main() = runBlocking {
// launch: 결과를 반환하지 않는 코루틴
val job = launch {
delay(1000L)
println("World!")
}
// async: 결과를 반환하는 코루틴
val deferred = async {
delay(1000L)
42
}
println("Hello,")
job.join()
println("Answer: ${deferred.await()}")
}
suspend 함수
// suspend 함수는 코루틴 안에서만 호출 가능
suspend fun fetchUser(id: Long): User {
return withContext(Dispatchers.IO) {
// 네트워크/DB 작업
userRepository.findById(id)
}
}
suspend fun fetchUserWithPosts(id: Long): UserWithPosts {
return coroutineScope {
// 병렬 실행
val userDeferred = async { fetchUser(id) }
val postsDeferred = async { fetchPosts(id) }
UserWithPosts(
user = userDeferred.await(),
posts = postsDeferred.await()
)
}
}
구조적 동시성 (Structured Concurrency)
suspend fun processOrder(orderId: Long) = coroutineScope {
// 자식 코루틴이 모두 완료되어야 반환
val inventory = async { checkInventory(orderId) }
val payment = async { processPayment(orderId) }
// 하나라도 실패하면 나머지도 취소됨
try {
val inventoryResult = inventory.await()
val paymentResult = payment.await()
completeOrder(orderId, inventoryResult, paymentResult)
} catch (e: Exception) {
cancelOrder(orderId)
throw e
}
}
Flow (리액티브 스트림)
import kotlinx.coroutines.flow.*
// Cold Flow - 구독 시 실행
fun numberFlow(): Flow<Int> = flow {
for (i in 1..10) {
delay(100)
emit(i)
}
}
// 연산자 체이닝
suspend fun processNumbers() {
numberFlow()
.filter { it % 2 == 0 }
.map { it * it }
.catch { e -> println("Error: $e") }
.collect { value -> println(value) }
}
// StateFlow (UI 상태 관리)
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadUser(id: Long) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val user = repository.fetchUser(id)
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
// SharedFlow (이벤트 브로드캐스트)
class EventBus {
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
suspend fun emit(event: Event) {
_events.emit(event)
}
}
취소와 타임아웃
// 코루틴 취소
val job = launch {
repeat(1000) { i ->
println("Processing $i")
delay(500) // 취소 지점
}
}
delay(2500)
job.cancelAndJoin() // 취소 후 완료 대기
// 타임아웃
val result = withTimeoutOrNull(3000L) {
fetchDataFromNetwork()
} ?: defaultValue
// 취소 불가능한 작업
suspend fun saveToDb(data: Data) {
withContext(NonCancellable) {
// 취소되어도 반드시 실행
database.save(data)
}
}
5. Spring Boot + Kotlin
프로젝트 설정
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.3.0"
id("io.spring.dependency-management") version "1.1.5"
kotlin("jvm") version "2.0.0"
kotlin("plugin.spring") version "2.0.0"
kotlin("plugin.jpa") version "2.0.0"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3")
testImplementation("io.mockk:mockk:1.13.10")
}
WebFlux + Coroutines
@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {
// suspend 함수로 비동기 엔드포인트
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: Long): ResponseEntity<UserResponse> {
val user = userService.findById(id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(user.toResponse())
}
// Flow로 스트리밍 응답
@GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun getAllUsers(): Flow<UserResponse> =
userService.findAll().map { it.toResponse() }
@PostMapping
suspend fun createUser(
@Valid @RequestBody request: CreateUserRequest
): ResponseEntity<UserResponse> {
val user = userService.create(request)
return ResponseEntity
.created(URI.create("/api/users/${user.id}"))
.body(user.toResponse())
}
}
@Service
class UserService(private val userRepository: UserRepository) {
suspend fun findById(id: Long): User? =
userRepository.findById(id)
fun findAll(): Flow<User> =
userRepository.findAll().asFlow()
@Transactional
suspend fun create(request: CreateUserRequest): User {
val user = User(
name = request.name,
email = request.email
)
return userRepository.save(user)
}
}
Spring Data R2DBC
interface UserRepository : CoroutineCrudRepository<User, Long> {
suspend fun findByEmail(email: String): User?
@Query("SELECT * FROM users WHERE name LIKE :pattern")
fun searchByName(pattern: String): Flow<User>
@Query("SELECT COUNT(*) FROM users WHERE active = true")
suspend fun countActive(): Long
}
@Table("users")
data class User(
@Id val id: Long? = null,
val name: String,
val email: String,
val active: Boolean = true,
val createdAt: LocalDateTime = LocalDateTime.now()
)
테스팅: MockK + Kotest
class UserServiceTest : FunSpec({
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository)
test("findById should return user when exists") {
// Given
val user = User(id = 1, name = "Kim", email = "kim@test.com")
coEvery { userRepository.findById(1L) } returns user
// When
val result = userService.findById(1L)
// Then
result shouldNotBe null
result!!.name shouldBe "Kim"
coVerify(exactly = 1) { userRepository.findById(1L) }
}
test("findById should return null when not exists") {
coEvery { userRepository.findById(any()) } returns null
val result = userService.findById(999L)
result shouldBe null
}
test("create should save and return user") {
val request = CreateUserRequest("Park", "park@test.com")
val savedUser = User(id = 1, name = "Park", email = "park@test.com")
coEvery { userRepository.save(any()) } returns savedUser
val result = userService.create(request)
result.id shouldBe 1
result.name shouldBe "Park"
}
})
6. Ktor: Kotlin 네이티브 서버
프로젝트 구성
// build.gradle.kts
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.serialization") version "2.0.0"
id("io.ktor.plugin") version "2.3.11"
}
dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
implementation("io.ktor:ktor-server-websockets-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm")
}
라우팅과 플러그인
fun main() {
embeddedServer(Netty, port = 8080) {
configureSerialization()
configureRouting()
configureAuth()
}.start(wait = true)
}
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
fun Application.configureRouting() {
routing {
route("/api") {
userRoutes()
postRoutes()
}
}
}
fun Route.userRoutes() {
val userService = UserService()
route("/users") {
get {
val users = userService.findAll()
call.respond(users)
}
get("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: throw BadRequestException("Invalid ID")
val user = userService.findById(id)
?: throw NotFoundException("User not found")
call.respond(user)
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.create(request)
call.respond(HttpStatusCode.Created, user)
}
}
}
WebSocket
fun Application.configureWebSocket() {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(60)
maxFrameSize = Long.MAX_VALUE
}
routing {
val connections = Collections.synchronizedSet<Connection>(mutableSetOf())
webSocket("/chat") {
val connection = Connection(this)
connections += connection
try {
for (frame in incoming) {
frame as? Frame.Text ?: continue
val text = frame.readText()
connections.forEach {
it.session.send("${connection.name}: $text")
}
}
} finally {
connections -= connection
}
}
}
}
7. Android + Jetpack Compose
Compose 기본
@Composable
fun UserListScreen(
viewModel: UserListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(title = { Text("Users") })
}
) { padding ->
when (val state = uiState) {
is UiState.Loading -> LoadingIndicator(Modifier.padding(padding))
is UiState.Success -> UserList(
users = state.data,
modifier = Modifier.padding(padding)
)
is UiState.Error -> ErrorMessage(
message = state.message,
onRetry = viewModel::retry,
modifier = Modifier.padding(padding)
)
}
}
}
@Composable
fun UserList(users: List<User>, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(users, key = { it.id }) { user ->
UserCard(user = user)
}
}
}
@Composable
fun UserCard(user: User) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = user.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
상태 관리와 Navigation
// ViewModel
@HiltViewModel
class UserListViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState<List<User>>>(UiState.Loading)
val uiState: StateFlow<UiState<List<User>>> = _uiState.asStateFlow()
init { loadUsers() }
private fun loadUsers() {
viewModelScope.launch {
_uiState.value = UiState.Loading
userRepository.getUsers()
.onSuccess { users ->
_uiState.value = UiState.Success(users)
}
.onFailure { error ->
_uiState.value = UiState.Error(error.message ?: "Unknown error")
}
}
}
fun retry() = loadUsers()
}
// Navigation (Type-safe)
@Serializable
sealed class Screen {
@Serializable
data object UserList : Screen()
@Serializable
data class UserDetail(val userId: Long) : Screen()
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.UserList) {
composable<Screen.UserList> {
UserListScreen(
onUserClick = { userId ->
navController.navigate(Screen.UserDetail(userId))
}
)
}
composable<Screen.UserDetail> { backStackEntry ->
val screen: Screen.UserDetail = backStackEntry.toRoute()
UserDetailScreen(userId = screen.userId)
}
}
}
8. Kotlin Multiplatform (KMP)
프로젝트 구조
shared/
src/
commonMain/ ← 공유 코드
androidMain/ ← Android 구현
iosMain/ ← iOS 구현
androidApp/ ← Android 앱
iosApp/ ← iOS 앱 (Swift)
expect / actual 패턴
// commonMain - 공통 인터페이스 정의
expect class PlatformContext
expect fun getPlatformName(): String
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// androidMain - Android 구현
actual typealias PlatformContext = Context
actual fun getPlatformName(): String = "Android ${Build.VERSION.SDK_INT}"
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver =
AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
// iosMain - iOS 구현
actual class PlatformContext
actual fun getPlatformName(): String = UIDevice.currentDevice.systemName()
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver =
NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
공유 비즈니스 로직
// commonMain
class UserRepository(
private val api: UserApi,
private val db: UserDatabase,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend fun getUsers(forceRefresh: Boolean = false): List<User> =
withContext(dispatcher) {
if (!forceRefresh) {
val cached = db.getUsers()
if (cached.isNotEmpty()) return@withContext cached
}
val users = api.fetchUsers()
db.saveUsers(users)
users
}
fun observeUsers(): Flow<List<User>> =
db.observeUsers()
}
// Ktor 기반 공유 HTTP 클라이언트
class UserApi(private val client: HttpClient) {
suspend fun fetchUsers(): List<User> =
client.get("https://api.example.com/users").body()
}
// 공유 HttpClient 설정
expect fun httpClient(): HttpClient
// androidMain
actual fun httpClient(): HttpClient = HttpClient(OkHttp) {
install(ContentNegotiation) { json() }
}
// iosMain
actual fun httpClient(): HttpClient = HttpClient(Darwin) {
install(ContentNegotiation) { json() }
}
Compose Multiplatform
// commonMain - 공유 UI
@Composable
fun App() {
MaterialTheme {
var users by remember { mutableStateOf<List<User>>(emptyList()) }
LaunchedEffect(Unit) {
users = repository.getUsers()
}
UserListScreen(users)
}
}
// Android
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { App() }
}
}
// iOS (SwiftUI)
// ContentView.swift
// struct ContentView: View {
// var body: some View {
// ComposeView().ignoresSafeArea()
// }
// }
9. 고급 기능
Inline / Reified
// inline 함수: 람다 호출 오버헤드 제거
inline fun <T> measureTime(block: () -> T): Pair<T, Long> {
val start = System.nanoTime()
val result = block()
val duration = System.nanoTime() - start
return result to duration
}
// reified: 런타임에 타입 정보 유지
inline fun <reified T> String.parseJson(): T =
Json.decodeFromString<T>(this)
// 사용
val user = """{"name":"Kim","age":30}""".parseJson<User>()
// crossinline: 비지역 반환 금지
inline fun runSafe(crossinline block: () -> Unit) {
try { block() } catch (e: Exception) { /* handle */ }
}
Contracts
import kotlin.contracts.*
// 컴파일러에게 함수 동작을 알려주는 계약
fun String?.isNotNullOrEmpty(): Boolean {
contract {
returns(true) implies (this@isNotNullOrEmpty != null)
}
return this != null && this.isNotEmpty()
}
// 사용 - 스마트 캐스트 활성화
fun greet(name: String?) {
if (name.isNotNullOrEmpty()) {
println("Hello, $name!") // name이 String으로 스마트 캐스트
}
}
Context Receivers (실험적)
context(LoggerContext, TransactionContext)
fun transferMoney(from: Account, to: Account, amount: BigDecimal) {
log("Transfer $amount from ${from.id} to ${to.id}")
transaction {
from.withdraw(amount)
to.deposit(amount)
}
}
Value Classes
@JvmInline
value class UserId(val value: Long)
@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "Invalid email" }
}
}
// 컴파일 시 Long/String으로 인라인되어 오버헤드 없음
fun findUser(id: UserId): User? = repository.findById(id.value)
// 타입 안전성 확보
val userId = UserId(42L)
val email = Email("user@example.com")
// findUser(email) // 컴파일 에러!
10. 테스팅
Kotest
// BehaviorSpec (BDD 스타일)
class UserServiceBehaviorSpec : BehaviorSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
Given("a user exists in the database") {
val user = User(1, "Kim", "kim@test.com")
coEvery { repository.findById(1L) } returns user
When("findById is called with the user's ID") {
val result = service.findById(1L)
Then("it should return the user") {
result shouldBe user
}
}
}
Given("no user exists") {
coEvery { repository.findById(any()) } returns null
When("findById is called") {
val result = service.findById(999L)
Then("it should return null") {
result shouldBe null
}
}
}
})
// Property-based testing
class StringExtensionTest : StringSpec({
"toSlug should produce valid slugs" {
checkAll(Arb.string(1..100)) { input ->
val slug = input.toSlug()
slug shouldNotContain " "
slug shouldMatch Regex("[a-z0-9-]*")
}
}
})
MockK
// 코루틴 모킹
coEvery { repository.findById(any()) } returns mockUser
coVerify { repository.save(match { it.name == "Kim" }) }
// 확장 함수 모킹
mockkStatic("com.example.ExtensionsKt")
every { "test".toSlug() } returns "test-slug"
// 오브젝트 모킹
mockkObject(DateUtils)
every { DateUtils.now() } returns fixedDate
Turbine (Flow 테스팅)
class UserViewModelTest : FunSpec({
test("loadUsers should emit Loading then Success") {
val viewModel = UserViewModel(mockRepository)
viewModel.uiState.test {
viewModel.loadUsers()
awaitItem() shouldBe UiState.Loading
val success = awaitItem()
success.shouldBeInstanceOf<UiState.Success<List<User>>>()
success.data.size shouldBe 3
cancelAndIgnoreRemainingEvents()
}
}
})
코루틴 테스트
class CoroutineTest : FunSpec({
test("structured concurrency cancellation") {
runTest {
var completed = false
val job = launch {
try {
delay(10_000)
completed = true
} catch (e: CancellationException) {
// 정리 작업
throw e
}
}
advanceTimeBy(5_000)
job.cancel()
job.join()
completed shouldBe false
}
}
})
11. 인터뷰 질문과 퀴즈
인터뷰 질문 15선
Q1: Kotlin의 null 안전성은 어떻게 동작하나요?
Kotlin은 타입 시스템 수준에서 nullable과 non-nullable 타입을 구분합니다. String은 null이 될 수 없고, String?은 null 가능합니다. 안전 호출 연산자(?.), 엘비스 연산자(?:), non-null 단언(!!) 등의 도구를 제공합니다. 컴파일러가 null 체크를 강제하여 런타임 NullPointerException을 방지합니다.
Q2: data class와 일반 class의 차이점은?
data class는 equals(), hashCode(), toString(), copy(), componentN() 함수를 자동 생성합니다. 주 생성자에 하나 이상의 매개변수가 있어야 하며, abstract/open/sealed/inner가 될 수 없습니다. DTO(Data Transfer Object)나 값 객체에 적합합니다.
Q3: sealed class의 사용 사례를 설명해주세요.
sealed class는 제한된 클래스 계층을 정의합니다. when 표현식에서 모든 하위 타입을 처리하면 else가 불필요하여 컴파일 타임 안전성을 보장합니다. API 응답 상태(Success/Error/Loading), 네비게이션 이벤트, UI 상태 표현에 이상적입니다.
Q4: 코루틴과 스레드의 차이점은?
코루틴은 스레드보다 훨씬 가볍습니다. 하나의 스레드에서 수천 개의 코루틴을 실행할 수 있습니다. 코루틴은 협력적 멀티태스킹(중단점에서 양보)이고, 스레드는 선점적 멀티태스킹입니다. 구조적 동시성으로 생명주기 관리가 용이합니다.
Q5: launch와 async의 차이점은?
launch는 Job을 반환하며 결과값이 없는 fire-and-forget 방식입니다. async는 Deferred<T>를 반환하며 await()로 결과를 받을 수 있습니다. 결과가 필요 없으면 launch, 병렬 작업 결과가 필요하면 async를 사용합니다.
Q6: Flow와 Channel의 차이점은?
Flow는 cold 스트림으로 구독자가 있을 때만 실행됩니다. Channel은 hot 스트림으로 구독자 유무와 관계없이 데이터를 전송합니다. Flow는 선언적이고 변환이 쉽고, Channel은 명령적이며 코루틴 간 통신에 사용됩니다.
Q7: Spring Boot에서 Kotlin을 사용할 때 주의할 점은?
kotlin-spring 플러그인으로 클래스를 open으로 만들어야 합니다(Spring AOP 프록시). kotlin-jpa 플러그인으로 no-arg 생성자를 추가합니다. Jackson의 kotlin-module로 data class 직렬화를 지원합니다. 코루틴 사용 시 WebFlux + kotlinx-coroutines-reactor가 필요합니다.
Q8: Ktor와 Spring Boot의 선택 기준은?
Ktor는 경량, Kotlin 네이티브, DSL 기반 설정이 장점입니다. 마이크로서비스나 Lambda 등 경량 환경에 적합합니다. Spring Boot는 방대한 생태계, 엔터프라이즈 기능, 자동 설정이 강점입니다. 대규모 애플리케이션이나 기존 Spring 인프라가 있을 때 유리합니다.
Q9: Jetpack Compose의 리컴포지션(recomposition) 최적화 방법은?
remember로 값 캐싱, derivedStateOf로 파생 상태 관리, Stable/Immutable 어노테이션으로 안정성 표시, key로 리스트 아이템 식별, 람다 안정화(remember로 감싸기), 상태 읽기 범위 최소화 등을 사용합니다.
Q10: Kotlin Multiplatform에서 플랫폼별 의존성은 어떻게 관리하나요?
expect/actual 메커니즘을 사용합니다. commonMain에서 expect 선언을 하고, 각 플랫폼(androidMain/iosMain)에서 actual 구현을 제공합니다. 의존성은 각 소스셋의 dependencies 블록에서 관리합니다.
Q11: inline 함수와 reified 타입 매개변수의 관계는?
JVM의 타입 소거로 인해 일반 함수에서는 제네릭 타입 정보를 런타임에 알 수 없습니다. inline 함수는 호출 지점에 코드가 인라인되므로 reified 키워드로 타입 정보를 유지할 수 있습니다. is T 체크나 T::class 접근이 가능해집니다.
Q12: Kotlin의 위임(delegation)을 설명해주세요.
클래스 위임(by 키워드)은 인터페이스 구현을 다른 객체에 위임합니다. 데코레이터 패턴을 보일러플레이트 없이 구현합니다. 프로퍼티 위임(lazy, observable, map)은 getter/setter 로직을 재사용합니다.
Q13: 구조적 동시성이 왜 중요한가요?
부모 코루틴이 취소되면 모든 자식 코루틴이 자동 취소됩니다. 자식 코루틴 중 하나가 실패하면 형제 코루틴도 취소됩니다. 코루틴의 생명주기가 스코프에 바인딩되어 메모리 누수와 리소스 누수를 방지합니다.
Q14: value class의 사용 사례와 제약사항은?
value class는 타입 안전성을 제공하면서 런타임 오버헤드가 없습니다. UserId, Email 등 래퍼 타입에 적합합니다. 제약: 하나의 프로퍼티만 가능, 인터페이스 구현 가능하지만 상속 불가, var 프로퍼티 불가, === 비교 불가입니다.
Q15: Kotlin DSL은 어떻게 동작하나요?
수신 객체가 있는 람다(lambda with receiver)를 기반으로 합니다. T.() -> Unit 형태로 스코프 내에서 T의 멤버를 직접 호출할 수 있습니다. 이를 통해 Gradle 빌드 스크립트, Ktor 라우팅, HTML 빌더 등 영역 특화 언어를 만듭니다.
퀴즈 5선
Q1: 다음 코드의 출력은?
fun main() = runBlocking {
val result = coroutineScope {
val a = async { delay(100); 1 }
val b = async { delay(200); 2 }
a.await() + b.await()
}
println(result)
}
정답: 3
두 async 코루틴이 병렬로 실행됩니다. a는 100ms 후 1을 반환하고, b는 200ms 후 2를 반환합니다. 총 소요 시간은 약 200ms이며, 결과는 1 + 2 = 3입니다. coroutineScope는 모든 자식이 완료될 때까지 기다립니다.
Q2: sealed interface vs sealed class의 차이점은?
정답:
sealed class는 상태(프로퍼티)를 가질 수 있고 단일 상속만 가능합니다. sealed interface는 상태를 가질 수 없지만 다중 구현이 가능합니다. sealed interface를 사용하면 하위 클래스가 다른 클래스를 상속하면서 sealed 계층에 속할 수 있어 더 유연합니다. Kotlin 1.5부터 sealed interface가 도입되었습니다.
Q3: 다음 코드에서 문제점을 찾으세요.
class MyViewModel : ViewModel() {
fun loadData() {
GlobalScope.launch {
val data = repository.fetchData()
_state.value = data
}
}
}
정답:
GlobalScope를 사용하면 ViewModel이 소멸되어도 코루틴이 계속 실행됩니다. 메모리 누수와 불필요한 작업이 발생합니다. viewModelScope.launch를 사용해야 ViewModel 소멸 시 자동 취소됩니다. 구조적 동시성 원칙을 위반한 코드입니다.
Q4: let, run, apply, also, with의 차이점은?
정답:
| 함수 | 수신 객체 참조 | 반환 값 | 사용 사례 |
|---|---|---|---|
| let | it | 람다 결과 | null 체크 후 변환 |
| run | this | 람다 결과 | 객체 설정 + 결과 계산 |
| apply | this | 수신 객체 | 객체 초기화 |
| also | it | 수신 객체 | 부수 효과(로깅 등) |
| with | this | 람다 결과 | 이미 있는 객체의 메서드 호출 |
Q5: StateFlow와 SharedFlow의 차이점은?
정답:
StateFlow는 현재 상태값을 가지며 새 구독자에게 즉시 최신 값을 전달합니다. 항상 초기값이 필요하고 중복 값은 무시합니다. SharedFlow는 상태가 없고 이벤트 브로드캐스트에 사용됩니다. replay 파라미터로 버퍼링을 제어합니다. UI 상태는 StateFlow, 일회성 이벤트(네비게이션, 토스트)는 SharedFlow가 적합합니다.
참고 자료
- Kotlin 공식 문서 - 언어 레퍼런스와 튜토리얼
- Kotlin Coroutines 가이드 - 공식 코루틴 문서
- Spring Boot Kotlin 지원 - 스프링 코틀린 가이드
- Ktor 공식 문서 - Ktor 프레임워크 가이드
- Jetpack Compose - Android Compose 공식 문서
- Kotlin Multiplatform - KMP 공식 가이드
- Kotest 프레임워크 - Kotlin 테스트 프레임워크
- MockK - Kotlin 모킹 라이브러리
- Turbine - Flow 테스팅 라이브러리
- Kotlin 코루틴 디자인 문서 - 코루틴 설계 철학
- Android Developers - Kotlin - Android Kotlin 가이드
- Compose Multiplatform - JetBrains Compose Multiplatform
- Kotlin KEEP - Kotlin Evolution and Enhancement Process
- kotlinx.serialization - 직렬화 라이브러리
- Arrow - Kotlin 함수형 프로그래밍 라이브러리
- SQLDelight - KMP SQL 라이브러리
Kotlin Complete Guide 2025: From Spring Boot Backend to Android, Multiplatform & Coroutines
Table of Contents
1. Kotlin in 2025
Kotlin is one of the fastest-growing languages in the JVM ecosystem in 2025. Since Google adopted it as the official language for Android development, it has expanded into server-side, multiplatform, and even data science.
Kotlin Adoption Status
| Domain | Status | Primary Use |
|---|---|---|
| Android | Official primary language | 95%+ new projects in Kotlin |
| Spring Boot | Official support (1st class) | Coroutines + WebFlux integration |
| Ktor | Kotlin-native server | Lightweight microservices |
| KMP | Stable (1.9.20+) | Shared iOS/Android business logic |
| Compose Multiplatform | Beta to Stable | Shared Android/Desktop/Web UI |
| Data Science | Growing | Kotlin DataFrame, KotlinDL |
Why Kotlin
- Conciseness: 40% less code compared to Java
- Safety: Null safety at the type system level
- Interoperability: 100% compatible with existing Java code
- Coroutines: Simplified async processing with structured concurrency
- Multiplatform: Support multiple platforms from a single codebase
2. Kotlin vs Java: Comparison and Migration
Syntax Comparison
// Java
public class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
@Override
public String toString() { /* ... */ }
}
// Kotlin — same functionality in one line
data class User(val name: String, val age: Int)
Key Differences
| Feature | Java | Kotlin |
|---|---|---|
| Null Safety | Optional, annotations | Built into type system (?/!!) |
| Data Classes | Record (Java 16+) | data class (since 1.0) |
| Extension Functions | Not possible | Native support |
| Coroutines | Project Loom (Virtual Threads) | suspend/launch/async |
| Sealed Types | sealed (Java 17+) | sealed class/interface |
| Smart Casts | instanceof + casting | Automatic casting |
| Default Parameters | Overloading required | Default values supported |
| String Templates | Java 21+ STR | Built-in support |
| Scope Functions | None | let, run, apply, also, with |
Migrating from Java to Kotlin
// Step 1: Add Kotlin to build.gradle.kts
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.spring") version "2.0.0"
}
// Step 2: Convert Java files to Kotlin (IntelliJ: Ctrl+Alt+Shift+K)
// Step 3: Apply Kotlin idioms
// Step 4: Introduce coroutines
Migration strategy:
- Write new files in Kotlin
- Convert tests to Kotlin first
- Convert data classes as priority
- Gradually convert the service layer
- Introduce coroutines as the final step
3. Core Kotlin Language Features
Null Safety
// Nullable type declaration
var name: String? = null
// Safe call operator
val length = name?.length // returns null if name is null
// Elvis operator
val length = name?.length ?: 0 // default value if null
// Smart cast
if (name != null) {
println(name.length) // automatically cast to String
}
// let scope function
name?.let { nonNullName ->
println(nonNullName.length)
}
Data Class
data class User(
val id: Long,
val name: String,
val email: String,
val role: Role = Role.USER // default value
) {
// Modify immutable object with copy
fun promote() = copy(role = Role.ADMIN)
}
// Destructuring declaration
val (id, name, email) = user
Sealed Class / Sealed Interface
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
// Exhaustive when expression
fun handleResult(result: Result<User>) = when (result) {
is Result.Success -> println("User: ${result.data}")
is Result.Error -> println("Error: ${result.exception.message}")
is Result.Loading -> println("Loading...")
// no else needed — all cases handled
}
Extension Functions
// Add new method to String
fun String.toSlug(): String =
lowercase()
.replace(Regex("[^a-z0-9\\s-]"), "")
.replace(Regex("\\s+"), "-")
.trim('-')
// Usage
val slug = "Hello World 2025!".toSlug() // "hello-world-2025"
// Collection extension
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null
// Generic extension
inline fun <reified T> Any.castOrNull(): T? = this as? T
Delegation
// Class delegation
interface Repository {
fun findAll(): List<User>
}
class CachedRepository(
private val delegate: Repository
) : Repository by delegate {
private val cache = mutableMapOf<String, List<User>>()
override fun findAll(): List<User> =
cache.getOrPut("all") { delegate.findAll() }
}
// Property delegation
class UserPreferences {
var theme: String by Delegates.observable("light") { _, old, new ->
println("Theme changed: $old -> $new")
}
val expensiveValue: String by lazy {
computeExpensiveValue()
}
}
DSL (Domain Specific Language)
// HTML DSL example
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
class HTML {
fun body(init: Body.() -> Unit) { /* ... */ }
}
class Body {
fun p(text: String) { /* ... */ }
fun div(init: Div.() -> Unit) { /* ... */ }
}
// Usage
val page = html {
body {
p("Hello, Kotlin DSL!")
div {
p("Nested content")
}
}
}
// Real-world: Gradle Kotlin DSL
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
}
4. Coroutines Deep Dive
Core Concepts
Coroutines are an asynchronous processing mechanism that behaves like lightweight threads. They can suspend and resume without blocking actual OS threads.
import kotlinx.coroutines.*
fun main() = runBlocking {
// launch: coroutine that doesn't return a result
val job = launch {
delay(1000L)
println("World!")
}
// async: coroutine that returns a result
val deferred = async {
delay(1000L)
42
}
println("Hello,")
job.join()
println("Answer: ${deferred.await()}")
}
suspend Functions
// suspend functions can only be called from coroutines
suspend fun fetchUser(id: Long): User {
return withContext(Dispatchers.IO) {
// Network/DB operations
userRepository.findById(id)
}
}
suspend fun fetchUserWithPosts(id: Long): UserWithPosts {
return coroutineScope {
// Parallel execution
val userDeferred = async { fetchUser(id) }
val postsDeferred = async { fetchPosts(id) }
UserWithPosts(
user = userDeferred.await(),
posts = postsDeferred.await()
)
}
}
Structured Concurrency
suspend fun processOrder(orderId: Long) = coroutineScope {
// Parent returns only when all children complete
val inventory = async { checkInventory(orderId) }
val payment = async { processPayment(orderId) }
// If one fails, the other is cancelled too
try {
val inventoryResult = inventory.await()
val paymentResult = payment.await()
completeOrder(orderId, inventoryResult, paymentResult)
} catch (e: Exception) {
cancelOrder(orderId)
throw e
}
}
Flow (Reactive Streams)
import kotlinx.coroutines.flow.*
// Cold Flow — executes on subscription
fun numberFlow(): Flow<Int> = flow {
for (i in 1..10) {
delay(100)
emit(i)
}
}
// Operator chaining
suspend fun processNumbers() {
numberFlow()
.filter { it % 2 == 0 }
.map { it * it }
.catch { e -> println("Error: $e") }
.collect { value -> println(value) }
}
// StateFlow (UI state management)
class UserViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadUser(id: Long) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val user = repository.fetchUser(id)
_uiState.value = UiState.Success(user)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
// SharedFlow (event broadcast)
class EventBus {
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
suspend fun emit(event: Event) {
_events.emit(event)
}
}
Cancellation and Timeouts
// Coroutine cancellation
val job = launch {
repeat(1000) { i ->
println("Processing $i")
delay(500) // cancellation point
}
}
delay(2500)
job.cancelAndJoin() // cancel and wait for completion
// Timeout
val result = withTimeoutOrNull(3000L) {
fetchDataFromNetwork()
} ?: defaultValue
// Non-cancellable work
suspend fun saveToDb(data: Data) {
withContext(NonCancellable) {
// Must execute even when cancelled
database.save(data)
}
}
5. Spring Boot + Kotlin
Project Setup
// build.gradle.kts
plugins {
id("org.springframework.boot") version "3.3.0"
id("io.spring.dependency-management") version "1.1.5"
kotlin("jvm") version "2.0.0"
kotlin("plugin.spring") version "2.0.0"
kotlin("plugin.jpa") version "2.0.0"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3")
testImplementation("io.mockk:mockk:1.13.10")
}
WebFlux + Coroutines
@RestController
@RequestMapping("/api/users")
class UserController(private val userService: UserService) {
// suspend function for async endpoint
@GetMapping("/{id}")
suspend fun getUser(@PathVariable id: Long): ResponseEntity<UserResponse> {
val user = userService.findById(id)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(user.toResponse())
}
// Flow for streaming response
@GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun getAllUsers(): Flow<UserResponse> =
userService.findAll().map { it.toResponse() }
@PostMapping
suspend fun createUser(
@Valid @RequestBody request: CreateUserRequest
): ResponseEntity<UserResponse> {
val user = userService.create(request)
return ResponseEntity
.created(URI.create("/api/users/${user.id}"))
.body(user.toResponse())
}
}
@Service
class UserService(private val userRepository: UserRepository) {
suspend fun findById(id: Long): User? =
userRepository.findById(id)
fun findAll(): Flow<User> =
userRepository.findAll().asFlow()
@Transactional
suspend fun create(request: CreateUserRequest): User {
val user = User(
name = request.name,
email = request.email
)
return userRepository.save(user)
}
}
Spring Data R2DBC
interface UserRepository : CoroutineCrudRepository<User, Long> {
suspend fun findByEmail(email: String): User?
@Query("SELECT * FROM users WHERE name LIKE :pattern")
fun searchByName(pattern: String): Flow<User>
@Query("SELECT COUNT(*) FROM users WHERE active = true")
suspend fun countActive(): Long
}
@Table("users")
data class User(
@Id val id: Long? = null,
val name: String,
val email: String,
val active: Boolean = true,
val createdAt: LocalDateTime = LocalDateTime.now()
)
Testing: MockK + Kotest
class UserServiceTest : FunSpec({
val userRepository = mockk<UserRepository>()
val userService = UserService(userRepository)
test("findById should return user when exists") {
// Given
val user = User(id = 1, name = "Kim", email = "kim@test.com")
coEvery { userRepository.findById(1L) } returns user
// When
val result = userService.findById(1L)
// Then
result shouldNotBe null
result!!.name shouldBe "Kim"
coVerify(exactly = 1) { userRepository.findById(1L) }
}
test("findById should return null when not exists") {
coEvery { userRepository.findById(any()) } returns null
val result = userService.findById(999L)
result shouldBe null
}
test("create should save and return user") {
val request = CreateUserRequest("Park", "park@test.com")
val savedUser = User(id = 1, name = "Park", email = "park@test.com")
coEvery { userRepository.save(any()) } returns savedUser
val result = userService.create(request)
result.id shouldBe 1
result.name shouldBe "Park"
}
})
6. Ktor: Kotlin-Native Server
Project Configuration
// build.gradle.kts
plugins {
kotlin("jvm") version "2.0.0"
kotlin("plugin.serialization") version "2.0.0"
id("io.ktor.plugin") version "2.3.11"
}
dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
implementation("io.ktor:ktor-server-auth-jvm")
implementation("io.ktor:ktor-server-auth-jwt-jvm")
implementation("io.ktor:ktor-server-websockets-jvm")
implementation("io.ktor:ktor-server-status-pages-jvm")
}
Routing and Plugins
fun main() {
embeddedServer(Netty, port = 8080) {
configureSerialization()
configureRouting()
configureAuth()
}.start(wait = true)
}
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
fun Application.configureRouting() {
routing {
route("/api") {
userRoutes()
postRoutes()
}
}
}
fun Route.userRoutes() {
val userService = UserService()
route("/users") {
get {
val users = userService.findAll()
call.respond(users)
}
get("/{id}") {
val id = call.parameters["id"]?.toLongOrNull()
?: throw BadRequestException("Invalid ID")
val user = userService.findById(id)
?: throw NotFoundException("User not found")
call.respond(user)
}
post {
val request = call.receive<CreateUserRequest>()
val user = userService.create(request)
call.respond(HttpStatusCode.Created, user)
}
}
}
WebSocket
fun Application.configureWebSocket() {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(60)
maxFrameSize = Long.MAX_VALUE
}
routing {
val connections = Collections.synchronizedSet<Connection>(mutableSetOf())
webSocket("/chat") {
val connection = Connection(this)
connections += connection
try {
for (frame in incoming) {
frame as? Frame.Text ?: continue
val text = frame.readText()
connections.forEach {
it.session.send("${connection.name}: $text")
}
}
} finally {
connections -= connection
}
}
}
}
7. Android + Jetpack Compose
Compose Basics
@Composable
fun UserListScreen(
viewModel: UserListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(title = { Text("Users") })
}
) { padding ->
when (val state = uiState) {
is UiState.Loading -> LoadingIndicator(Modifier.padding(padding))
is UiState.Success -> UserList(
users = state.data,
modifier = Modifier.padding(padding)
)
is UiState.Error -> ErrorMessage(
message = state.message,
onRetry = viewModel::retry,
modifier = Modifier.padding(padding)
)
}
}
}
@Composable
fun UserList(users: List<User>, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(users, key = { it.id }) { user ->
UserCard(user = user)
}
}
}
@Composable
fun UserCard(user: User) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = user.email,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
State Management and Navigation
// ViewModel
@HiltViewModel
class UserListViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState<List<User>>>(UiState.Loading)
val uiState: StateFlow<UiState<List<User>>> = _uiState.asStateFlow()
init { loadUsers() }
private fun loadUsers() {
viewModelScope.launch {
_uiState.value = UiState.Loading
userRepository.getUsers()
.onSuccess { users ->
_uiState.value = UiState.Success(users)
}
.onFailure { error ->
_uiState.value = UiState.Error(error.message ?: "Unknown error")
}
}
}
fun retry() = loadUsers()
}
// Navigation (Type-safe)
@Serializable
sealed class Screen {
@Serializable
data object UserList : Screen()
@Serializable
data class UserDetail(val userId: Long) : Screen()
}
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Screen.UserList) {
composable<Screen.UserList> {
UserListScreen(
onUserClick = { userId ->
navController.navigate(Screen.UserDetail(userId))
}
)
}
composable<Screen.UserDetail> { backStackEntry ->
val screen: Screen.UserDetail = backStackEntry.toRoute()
UserDetailScreen(userId = screen.userId)
}
}
}
8. Kotlin Multiplatform (KMP)
Project Structure
shared/
src/
commonMain/ <- Shared code
androidMain/ <- Android implementation
iosMain/ <- iOS implementation
androidApp/ <- Android app
iosApp/ <- iOS app (Swift)
expect / actual Pattern
// commonMain — define common interface
expect class PlatformContext
expect fun getPlatformName(): String
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// androidMain — Android implementation
actual typealias PlatformContext = Context
actual fun getPlatformName(): String = "Android ${Build.VERSION.SDK_INT}"
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver =
AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
// iosMain — iOS implementation
actual class PlatformContext
actual fun getPlatformName(): String = UIDevice.currentDevice.systemName()
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver =
NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
Shared Business Logic
// commonMain
class UserRepository(
private val api: UserApi,
private val db: UserDatabase,
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend fun getUsers(forceRefresh: Boolean = false): List<User> =
withContext(dispatcher) {
if (!forceRefresh) {
val cached = db.getUsers()
if (cached.isNotEmpty()) return@withContext cached
}
val users = api.fetchUsers()
db.saveUsers(users)
users
}
fun observeUsers(): Flow<List<User>> =
db.observeUsers()
}
// Ktor-based shared HTTP client
class UserApi(private val client: HttpClient) {
suspend fun fetchUsers(): List<User> =
client.get("https://api.example.com/users").body()
}
// Shared HttpClient configuration
expect fun httpClient(): HttpClient
// androidMain
actual fun httpClient(): HttpClient = HttpClient(OkHttp) {
install(ContentNegotiation) { json() }
}
// iosMain
actual fun httpClient(): HttpClient = HttpClient(Darwin) {
install(ContentNegotiation) { json() }
}
Compose Multiplatform
// commonMain — shared UI
@Composable
fun App() {
MaterialTheme {
var users by remember { mutableStateOf<List<User>>(emptyList()) }
LaunchedEffect(Unit) {
users = repository.getUsers()
}
UserListScreen(users)
}
}
// Android
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { App() }
}
}
// iOS (SwiftUI)
// ContentView.swift
// struct ContentView: View {
// var body: some View {
// ComposeView().ignoresSafeArea()
// }
// }
9. Advanced Features
Inline / Reified
// inline function: eliminates lambda call overhead
inline fun <T> measureTime(block: () -> T): Pair<T, Long> {
val start = System.nanoTime()
val result = block()
val duration = System.nanoTime() - start
return result to duration
}
// reified: preserves type information at runtime
inline fun <reified T> String.parseJson(): T =
Json.decodeFromString<T>(this)
// Usage
val user = """{"name":"Kim","age":30}""".parseJson<User>()
// crossinline: prohibits non-local returns
inline fun runSafe(crossinline block: () -> Unit) {
try { block() } catch (e: Exception) { /* handle */ }
}
Contracts
import kotlin.contracts.*
// Contracts tell the compiler about function behavior
fun String?.isNotNullOrEmpty(): Boolean {
contract {
returns(true) implies (this@isNotNullOrEmpty != null)
}
return this != null && this.isNotEmpty()
}
// Usage — enables smart cast
fun greet(name: String?) {
if (name.isNotNullOrEmpty()) {
println("Hello, $name!") // name smart-cast to String
}
}
Context Receivers (Experimental)
context(LoggerContext, TransactionContext)
fun transferMoney(from: Account, to: Account, amount: BigDecimal) {
log("Transfer $amount from ${from.id} to ${to.id}")
transaction {
from.withdraw(amount)
to.deposit(amount)
}
}
Value Classes
@JvmInline
value class UserId(val value: Long)
@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "Invalid email" }
}
}
// Inlined to Long/String at compile time — zero overhead
fun findUser(id: UserId): User? = repository.findById(id.value)
// Type safety guaranteed
val userId = UserId(42L)
val email = Email("user@example.com")
// findUser(email) // Compile error!
10. Testing
Kotest
// BehaviorSpec (BDD style)
class UserServiceBehaviorSpec : BehaviorSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
Given("a user exists in the database") {
val user = User(1, "Kim", "kim@test.com")
coEvery { repository.findById(1L) } returns user
When("findById is called with the user's ID") {
val result = service.findById(1L)
Then("it should return the user") {
result shouldBe user
}
}
}
Given("no user exists") {
coEvery { repository.findById(any()) } returns null
When("findById is called") {
val result = service.findById(999L)
Then("it should return null") {
result shouldBe null
}
}
}
})
// Property-based testing
class StringExtensionTest : StringSpec({
"toSlug should produce valid slugs" {
checkAll(Arb.string(1..100)) { input ->
val slug = input.toSlug()
slug shouldNotContain " "
slug shouldMatch Regex("[a-z0-9-]*")
}
}
})
MockK
// Coroutine mocking
coEvery { repository.findById(any()) } returns mockUser
coVerify { repository.save(match { it.name == "Kim" }) }
// Extension function mocking
mockkStatic("com.example.ExtensionsKt")
every { "test".toSlug() } returns "test-slug"
// Object mocking
mockkObject(DateUtils)
every { DateUtils.now() } returns fixedDate
Turbine (Flow Testing)
class UserViewModelTest : FunSpec({
test("loadUsers should emit Loading then Success") {
val viewModel = UserViewModel(mockRepository)
viewModel.uiState.test {
viewModel.loadUsers()
awaitItem() shouldBe UiState.Loading
val success = awaitItem()
success.shouldBeInstanceOf<UiState.Success<List<User>>>()
success.data.size shouldBe 3
cancelAndIgnoreRemainingEvents()
}
}
})
Coroutine Testing
class CoroutineTest : FunSpec({
test("structured concurrency cancellation") {
runTest {
var completed = false
val job = launch {
try {
delay(10_000)
completed = true
} catch (e: CancellationException) {
// cleanup
throw e
}
}
advanceTimeBy(5_000)
job.cancel()
job.join()
completed shouldBe false
}
}
})
11. Interview Questions and Quizzes
Top 15 Interview Questions
Q1: How does Kotlin's null safety work?
Kotlin distinguishes between nullable and non-nullable types at the type system level. String cannot be null, while String? can be null. It provides tools like safe call operator (?.), Elvis operator (?:), and non-null assertion (!!). The compiler enforces null checks to prevent runtime NullPointerExceptions.
Q2: What's the difference between a data class and a regular class?
A data class automatically generates equals(), hashCode(), toString(), copy(), and componentN() functions. It must have at least one parameter in the primary constructor and cannot be abstract, open, sealed, or inner. It's ideal for DTOs and value objects.
Q3: Explain use cases for sealed classes.
Sealed classes define a restricted class hierarchy. When all subtypes are handled in a when expression, no else branch is needed, ensuring compile-time safety. They're ideal for API response states (Success/Error/Loading), navigation events, and UI state representation.
Q4: What's the difference between coroutines and threads?
Coroutines are much lighter than threads. Thousands of coroutines can run on a single thread. Coroutines use cooperative multitasking (yielding at suspension points), while threads use preemptive multitasking. Structured concurrency makes lifecycle management easier.
Q5: What's the difference between launch and async?
launch returns a Job and is fire-and-forget with no result. async returns a Deferred<T> and its result can be obtained with await(). Use launch when no result is needed, and async when you need results from parallel operations.
Q6: What's the difference between Flow and Channel?
Flow is a cold stream that only executes when subscribed. Channel is a hot stream that transmits data regardless of subscribers. Flow is declarative with easy transformations, while Channel is imperative and used for inter-coroutine communication.
Q7: What should you watch out for when using Kotlin with Spring Boot?
The kotlin-spring plugin is needed to make classes open (for Spring AOP proxies). The kotlin-jpa plugin adds no-arg constructors. Jackson's kotlin-module supports data class serialization. For coroutines, WebFlux + kotlinx-coroutines-reactor is required.
Q8: How do you choose between Ktor and Spring Boot?
Ktor excels at being lightweight, Kotlin-native, and DSL-based configuration. It's suitable for microservices and lightweight environments like Lambda. Spring Boot has a vast ecosystem, enterprise features, and auto-configuration. It's better for large applications or existing Spring infrastructure.
Q9: How do you optimize recomposition in Jetpack Compose?
Use remember for value caching, derivedStateOf for derived state management, Stable/Immutable annotations for stability marking, key for list item identification, lambda stabilization (wrapping with remember), and minimizing state read scope.
Q10: How do you manage platform-specific dependencies in Kotlin Multiplatform?
Use the expect/actual mechanism. Declare expect in commonMain and provide actual implementations in each platform (androidMain/iosMain). Dependencies are managed in the dependencies block of each source set.
Q11: What's the relationship between inline functions and reified type parameters?
Due to JVM type erasure, generic type information isn't available at runtime in regular functions. Inline functions have their code inlined at the call site, so the reified keyword can preserve type information. This enables is T checks and T::class access.
Q12: Explain Kotlin delegation.
Class delegation (by keyword) delegates interface implementation to another object, implementing the decorator pattern without boilerplate. Property delegation (lazy, observable, map) reuses getter/setter logic.
Q13: Why is structured concurrency important?
When a parent coroutine is cancelled, all child coroutines are automatically cancelled. If one child fails, sibling coroutines are also cancelled. Coroutine lifecycle is bound to scope, preventing memory leaks and resource leaks.
Q14: What are the use cases and limitations of value classes?
Value classes provide type safety with zero runtime overhead. They're ideal for wrapper types like UserId and Email. Limitations: only one property allowed, can implement interfaces but not inherit, no var properties, no === comparison.
Q15: How do Kotlin DSLs work?
They're based on lambdas with receivers. The T.() -> Unit form allows calling T's members directly within the scope. This enables building domain-specific languages like Gradle build scripts, Ktor routing, and HTML builders.
5 Quizzes
Q1: What's the output of this code?
fun main() = runBlocking {
val result = coroutineScope {
val a = async { delay(100); 1 }
val b = async { delay(200); 2 }
a.await() + b.await()
}
println(result)
}
Answer: 3
Both async coroutines run in parallel. a returns 1 after 100ms, b returns 2 after 200ms. Total time is about 200ms, and the result is 1 + 2 = 3. coroutineScope waits until all children complete.
Q2: What's the difference between sealed interface and sealed class?
Answer:
A sealed class can have state (properties) but only supports single inheritance. A sealed interface cannot have state but supports multiple implementation. With sealed interfaces, subclasses can inherit from other classes while belonging to the sealed hierarchy, offering more flexibility. Sealed interfaces were introduced in Kotlin 1.5.
Q3: Find the problem in this code.
class MyViewModel : ViewModel() {
fun loadData() {
GlobalScope.launch {
val data = repository.fetchData()
_state.value = data
}
}
}
Answer:
Using GlobalScope means the coroutine continues running even after the ViewModel is destroyed. This causes memory leaks and unnecessary work. Use viewModelScope.launch instead, which automatically cancels when the ViewModel is destroyed. This code violates the structured concurrency principle.
Q4: What are the differences between let, run, apply, also, and with?
Answer:
| Function | Object Reference | Return Value | Use Case |
|---|---|---|---|
| let | it | Lambda result | Null check + transform |
| run | this | Lambda result | Object setup + compute result |
| apply | this | Receiver object | Object initialization |
| also | it | Receiver object | Side effects (logging, etc.) |
| with | this | Lambda result | Calling methods on existing object |
Q5: What's the difference between StateFlow and SharedFlow?
Answer:
StateFlow holds a current state value and immediately delivers the latest value to new subscribers. It always requires an initial value and ignores duplicate values. SharedFlow has no state and is used for event broadcasting. Buffering is controlled via the replay parameter. StateFlow is suitable for UI state, while SharedFlow is ideal for one-time events (navigation, toasts).
References
- Kotlin Official Documentation - Language reference and tutorials
- Kotlin Coroutines Guide - Official coroutines documentation
- Spring Boot Kotlin Support - Spring Kotlin guide
- Ktor Official Documentation - Ktor framework guide
- Jetpack Compose - Android Compose official docs
- Kotlin Multiplatform - KMP official guide
- Kotest Framework - Kotlin test framework
- MockK - Kotlin mocking library
- Turbine - Flow testing library
- Kotlin Coroutines Design Document - Coroutine design philosophy
- Android Developers - Kotlin - Android Kotlin guide
- Compose Multiplatform - JetBrains Compose Multiplatform
- Kotlin KEEP - Kotlin Evolution and Enhancement Process
- kotlinx.serialization - Serialization library
- Arrow - Kotlin functional programming library
- SQLDelight - KMP SQL library