- Published on
Kotlin 완전 가이드 2025: Spring Boot 백엔드부터 Android, Multiplatform, Coroutines까지
- Authors

- Name
- Youngju Kim
- @fjvbn20031
목차
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 라이브러리