Kotlin, JVM 위에서 뭐가 달라지나
Kotlin은 결국 JVM 바이트코드로 컴파일되는 언어다. 그래서 Java와 런타임 성능은 크게 다르지 않다. 다른 건 언어 설계다.
이 글은 항목별로 감각만 짚는다.
- 언어 : 기본 문법 · null-safety · data class · extension function · scope function · sealed class
- 비동기 : coroutine · suspend function · Dispatcher · Flow
1. Kotlin 기본 문법
Java와 다른 인상은 첫 줄부터 온다.
fun greet(name: String): String {
return "Hello, $name"
}
// 한 줄이면 이렇게도 됨
fun greet(name: String) = "Hello, $name"변수 선언.
val name = "sangeok" // 불변 (Java의 final)
var age = 30 // 가변세미콜론 없음, 타입 추론, 표현식 지향(if, when, try가 값을 반환)이 인상적이다.
val grade = when (score) {
in 90..100 -> "A"
in 80..89 -> "B"
else -> "C"
}클래스는 기본이 final이고, 상속하려면 명시적으로 open을 붙여야 한다.
open class Base
class Child : Base()2. null-safety
Kotlin에서 가장 유명한 특징.
val a: String = "hi" // null 불가
val b: String? = null // null 가능
a.length // OK
b.length // 컴파일 에러
b?.length // OK, b가 null이면 결과도 null
b!!.length // 강제 unwrap (null이면 NPE)
b ?: "default" // 엘비스: null이면 오른쪽 값Java의 Optional과 결정적으로 다른 지점.
Optional : "런타임 값을 감싸는 컨테이너" — 필드/파라미터로 남발하면 오히려 무거워짐
Kotlin ? : "타입 시스템의 표시" — 런타임 오버헤드 없음
한 번 익숙해지면, NPE가 어디서 터질 수 있는지 코드만 봐도 보인다.
3. data class
값 객체 전용 클래스.
data class User(val id: Long, val name: String, val age: Int)컴파일러가 자동으로 만들어주는 것들.
- equals / hashCode
- toString
- copy(...) (부분 수정 후 새 객체 반환)
- componentN() (구조 분해에 사용)
copy가 특히 유용하다.
val u1 = User(1, "sangeok", 30)
val u2 = u1.copy(age = 31) // name/ id는 그대로, age만 변경구조 분해.
val (id, name, age) = u1Java 16+의 record와 비교하면.
Java record : "값을 담기만 하는 최소한의 불변 클래스"
Kotlin data : var 필드도 되고 copy()로 부분 수정도 됨 — 훨씬 유연
4. Extension Function
Java에는 없는, "남의 클래스에 메서드를 붙이는" 문법.
fun String.isEmail(): Boolean =
contains("@") && contains(".")
"sangeok@example.com".isEmail() // true내부적으로는 static 메서드로 컴파일된다. 그래서 런타임 비용이 없다.
호출부 : s.isEmail()
바이트코드 : StringExtKt.isEmail(s) ← 사실은 static 호출
Java의 StringUtils.isEmail(s)와 실질적으로 같은 코드지만, 호출부의 자연스러움이 다르다.
주의할 점.
- 오버라이드가 아니라 "정적 디스패치"라서, 다형성이 적용되지 않음
- 같은 이름의 실제 멤버 함수가 있으면 그쪽이 우선
5. Scope Function
객체를 잠깐 다루는 컨텍스트를 만드는 함수 5종.
| 함수 | this / it | 반환 값 | 자주 쓰는 곳 |
|---|---|---|---|
let | it | 람다 결과 | null 체크 후 처리 |
run | this | 람다 결과 | 객체 설정 + 값 계산 |
with | this | 람다 결과 | 객체를 파라미터로 (확장 X) |
apply | this | 자기 자신 | 빌더 스타일 세팅 |
also | it | 자기 자신 | 부수 작업 (로깅 등) |
전형적인 예.
val user = User(1, "sangeok", 30).apply {
// 여기서 this == user, 자기 자신을 반환
println("created: $name")
}
user?.let {
// it == user, null이 아닐 때만 실행
sendWelcomeMail(it)
}
val summary = user.run {
// this == user, 계산 결과를 반환
"$name is $age"
}규칙 감각
자기 자신을 돌려주고 싶다 → apply / also
람다 결과를 돌려주고 싶다 → let / run / with
람다 안에서 프로퍼티를 편하게 쓰고 싶다 → this 계열 (run/apply/with)
람다 안에서 다른 변수와 이름 충돌 피하고 싶다 → it 계열 (let/also)
6. sealed class
"상속을 이 파일 안에서만 허용" 하는 봉인된 계층.
sealed class Result
data class Success(val value: String) : Result()
data class Failure(val error: Throwable) : Result()
object Loading : Result()이걸 when으로 다루면 컴파일러가 모든 케이스를 검사해 준다.
fun render(result: Result) = when (result) {
is Success -> println(result.value)
is Failure -> println(result.error)
Loading -> println("loading...")
// else 없어도 컴파일러가 만족함 (exhaustive)
}Java의 sealed class(15+)와 유사하지만, Kotlin이 훨씬 오래 다듬어져 있다.
용도.
- 도메인 이벤트 (OrderCreated / OrderPaid / OrderCancelled)
- API 응답 상태 (Success / Failure / Loading)
- 유한한 상태 표현
7. Coroutine
Kotlin의 대표 무기.
문제 인식부터.
Java의 Thread : OS 스레드 = 무겁다 (스택 1MB 정도)
Java의 CompletableFuture : 콜백 지옥은 줄지만 코드가 무거움
Java 21 Virtual Thread : 스레드가 값싸짐 (좋은 방향)
Kotlin 코루틴 : 언어 문법으로 비동기를 순차 코드처럼 쓰게 해 줌
기본 예시.
suspend fun loadUser(id: Long): User {
val profile = async { userApi.get(id) }
val orders = async { orderApi.findByUser(id) }
return User(profile.await(), orders.await())
}핵심 감각.
- 코루틴은 "언제든 멈추고 다시 시작할 수 있는 함수"
- 스레드보다 훨씬 가벼움 (수만 개 띄워도 OK)
- 동기 코드처럼 보이지만, 실제로는 논블로킹
시작하는 방법.
CoroutineScope(Dispatchers.IO).launch {
val user = loadUser(1L)
println(user)
}8. suspend function
suspend는 "이 함수는 중간에 멈출 수 있음"이라는 표시.
suspend fun fetchUser(id: Long): User { ... }규칙.
- suspend 함수는 다른 suspend 함수 안이나 코루틴 안에서만 호출 가능
- 일반 함수 안에서 그냥 부르면 컴파일 에러
컴파일러가 이 함수를 상태 머신으로 변환한다. 겉으로는 순차 코드지만, 내부적으로는 "여기서 멈추고 나중에 여기서부터 재개" 하는 코드로 바뀐다.
// 이렇게 쓰지만
suspend fun a() {
val x = fetch1() // 1단계
val y = fetch2() // 2단계
save(x, y) // 3단계
}
// 컴파일러는 대충 이렇게 만든다
// 상태 = { start, after-fetch1, after-fetch2 }
// 각 상태를 만나면 스레드를 놓고, 재개될 때 다음 상태부터 진행그래서 스레드를 붙잡지 않고도 "순차적으로 읽히는 비동기 코드"가 된다.
9. Dispatcher
코루틴이 어느 스레드에서 실행될지를 결정하는 것.
Dispatchers.Default // CPU 바운드 작업 (기본 스레드 풀)
Dispatchers.IO // 블로킹 IO (DB, 네트워크)
Dispatchers.Main // UI (Android)
Dispatchers.Unconfined // 실행 컨텍스트 고정 X (특수 목적)사용 예.
withContext(Dispatchers.IO) {
dbRepository.findAll() // 블로킹 IO는 IO로 옮겨서
}핵심 감각.
- CPU를 많이 쓰는 계산 → Dispatchers.Default
- DB, 파일, 네트워크 요청 → Dispatchers.IO
- 잘못된 Dispatcher에서 블로킹 작업을 하면
기본 코루틴 풀을 다 잡아먹어서 다른 코루틴이 못 돎
10. Flow
비동기 스트림. 코루틴 세계의 Stream이라 생각하면 편하다.
fun tickerFlow(): Flow<Int> = flow {
for (i in 1..5) {
delay(1000)
emit(i)
}
}
// 소비
tickerFlow()
.filter { it % 2 == 0 }
.map { "tick: $it" }
.collect { println(it) }특징.
- Cold Stream: collect 를 걸어야 emit이 시작됨
- suspend 기반이라 스레드를 붙잡지 않음
- 연산자 조합이 컬렉션과 비슷 (map, filter, flatMap, ...)
Hot 변종.
StateFlow : 항상 "최신 값 하나"를 들고 있음 (상태 관리)
SharedFlow : 여러 컬렉터에게 방송, 버퍼링 가능
Java 진영의 대응.
Java Reactive Streams (Publisher/Subscriber) / Reactor(Flux, Mono)
목표는 비슷하지만, Kotlin Flow는 coroutine + suspend + 언어 문법의 통합체라 읽고 쓰는 감각이 더 자연스럽다.
정리
- Kotlin은 JVM 언어지만, null-safety·데이터 표현·확장·비동기에서 Java와 결이 다르다
- 언어 : null 안전성이 타입에,
data class·extension·scope function·sealed class로 표현력 확보 - 비동기 : coroutine + suspend + Dispatcher + Flow로 스레드를 붙잡지 않는 순차 코드
- Java와 상호운용성이 좋아서 부분 도입도 현실적이다