CS 기초 노트

Kotlin, JVM 위에서 뭐가 달라지나

2026. 05. 08.
CSLanguageKotlinJVM

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) = u1

Java 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반환 값자주 쓰는 곳
letit람다 결과null 체크 후 처리
runthis람다 결과객체 설정 + 값 계산
withthis람다 결과객체를 파라미터로 (확장 X)
applythis자기 자신빌더 스타일 세팅
alsoit자기 자신부수 작업 (로깅 등)

전형적인 예.

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와 상호운용성이 좋아서 부분 도입도 현실적이다