이 글에서 얻는 것

  • Java의 고질적인 NullPointerException이 Kotlin에서 어떻게 시스템적으로 사라지는지 봅니다.
  • data class, extension function, Scope Functions으로 코드가 얼마나 간결해지는지 체험합니다.
  • ThreadCoroutine의 메모리/비용 차이를 이해하고, Structured Concurrency를 배웁니다.
  • Kotlin + Spring Boot 통합 시 주의할 점을 정리합니다.

0) 왜 Kotlin인가?

많은 자바 개발자가 “굳이?“라고 묻지만, 한 번 써보면 돌아가기 힘듭니다.

Java에서 Kotlin으로 넘어간 대표 사례

  • Google: Android 공식 언어 (2019~)
  • Spring Framework: Kotlin 1st-class 지원 (Spring 5+)
  • Gradle: Kotlin DSL이 Groovy를 대체하는 추세
  • Coupang, LINE, Kakao: 서버 사이드에서도 적극 도입

핵심 이점 3가지

영역Java의 문제Kotlin의 해결
안전성모든 참조가 nullable, 런타임 NPE타입 시스템에서 Null 분리, 컴파일 타임 차단
간결성Boilerplate 코드 (getter/setter/equals)data class, 타입 추론, 확장 함수
비동기Thread 기반, Callback HellCoroutines: 동기 코드처럼 비동기 작성

1) Null Safety — 10억 불짜리 실수 해결

Java에서는 모든 것이 Null일 수 있어서 방어 코드가 필수였습니다. Kotlin은 타입 시스템 레벨에서 Null 가능성을 분리합니다.

// Kotlin: Non-null 타입 (기본)
var name: String = "Alice"
// name = null  // ❌ 컴파일 에러!

// Kotlin: Nullable 타입 (? 붙임)
var nullableName: String? = "Alice"
nullableName = null  // ✅ 가능

Null 처리 연산자 정리

val user: User? = findUser(id)

// 1. Safe Call (?.)
val city = user?.address?.city  // null이면 전파, NPE 없음

// 2. Elvis Operator (?:)
val cityName = user?.address?.city ?: "Unknown"  // null이면 기본값

// 3. Not-null Assertion (!!) — 최후의 수단
val forceCity = user!!.address.city  // null이면 NPE 발생! 가급적 쓰지 마세요

// 4. Safe Cast (as?)
val str: String? = value as? String  // 캐스팅 실패 시 null

// 5. let과 조합 — Null이 아닐 때만 실행
user?.let { u ->
    println("${u.name}은(는) ${u.address?.city}에 살고 있습니다")
}

Java 코드와 비교

// Java: 방어 코드 지옥
String city = "Unknown";
if (user != null) {
    Address address = user.getAddress();
    if (address != null) {
        String c = address.getCity();
        if (c != null) {
            city = c;
        }
    }
}
// Kotlin: 한 줄
val city = user?.address?.city ?: "Unknown"

2) Data Class — Lombok이 필요 없다

Java의 지루한 DTO/VO 생성을 한 줄로 끝냅니다.

// Kotlin
data class User(val name: String, val age: Int)

data class는 컴파일러가 자동 생성합니다:

  • equals() / hashCode() — 프로퍼티 기반 비교
  • toString()User(name=Alice, age=25) 형태
  • copy() — 불변 객체의 일부만 변경한 복사본
  • componentN() — 구조 분해 지원
val user = User("Alice", 25)

// copy: 불변 객체 변형
val older = user.copy(age = 26)  // User(name=Alice, age=26)

// 구조 분해 (Destructuring)
val (name, age) = user
println("$name is $age years old")  // Alice is 25 years old

// Map에서 구조 분해
mapOf("a" to 1, "b" to 2).forEach { (key, value) ->
    println("$key = $value")
}

3) Scope Functions — let, run, apply, also, with

Kotlin의 가장 혼란스러우면서도 강력한 기능입니다. 차이를 명확히 정리합니다.

함수객체 참조반환값주 용도
letit람다 결과Null 체크 후 변환
runthis람다 결과객체 설정 + 결과 계산
applythis객체 자체객체 초기화/설정 (Builder 패턴)
alsoit객체 자체부수 효과 (로깅, 검증)
withthis람다 결과Non-null 객체의 여러 메서드 호출

실전 예시

// let: Null 체크 + 변환
val length: Int? = name?.let { it.trim().length }

// apply: 객체 초기화 (Builder 대체)
val config = HttpClient().apply {
    connectTimeout = Duration.ofSeconds(5)
    readTimeout = Duration.ofSeconds(10)
    followRedirects = true
}

// also: 부수 효과 (체이닝 중간에 로깅)
val user = userRepository.findById(id)
    .also { log.debug("Found user: $it") }
    .orElseThrow { UserNotFoundException(id) }

// run: 객체 컨텍스트에서 계산
val greeting = user.run {
    "안녕하세요, ${name}님! ${age}세이시군요."
}

// with: Non-null 객체의 다중 호출
with(StringBuilder()) {
    append("Hello, ")
    append(user.name)
    append("!")
    toString()  // 반환
}

선택 가이드

Null 체크 필요?
  └─ Yes → let
  └─ No → 반환값이 객체 자체?
            └─ Yes → apply (설정) / also (로깅/검증)
            └─ No  → run / with

4) Sealed Class — When의 진가

sealed class는 상속 가능한 하위 타입을 같은 파일 내로 제한합니다. when과 조합하면 컴파일러가 모든 케이스를 검증합니다.

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
    data object Loading : ApiResult<Nothing>()
}

// when: else가 필요 없음! 컴파일러가 모든 케이스 커버를 강제
fun <T> handleResult(result: ApiResult<T>): String = when (result) {
    is ApiResult.Success -> "Data: ${result.data}"
    is ApiResult.Error   -> "Error ${result.code}: ${result.message}"
    is ApiResult.Loading -> "Loading..."
    // 새 하위 타입 추가 시 → 여기서 컴파일 에러 발생! 누락 방지
}

실무 활용: 도메인 이벤트

sealed class OrderEvent {
    data class Created(val orderId: Long, val items: List<Item>) : OrderEvent()
    data class Paid(val orderId: Long, val amount: Money) : OrderEvent()
    data class Shipped(val orderId: Long, val trackingNo: String) : OrderEvent()
    data class Cancelled(val orderId: Long, val reason: String) : OrderEvent()
}

fun processEvent(event: OrderEvent) = when (event) {
    is OrderEvent.Created   -> inventoryService.reserve(event.items)
    is OrderEvent.Paid      -> paymentService.confirm(event.amount)
    is OrderEvent.Shipped   -> notificationService.sendTracking(event.trackingNo)
    is OrderEvent.Cancelled -> inventoryService.release(event.orderId)
}

Java 비교: Java 17의 sealed interface + switch 패턴 매칭과 유사하지만, Kotlin이 훨씬 먼저 도입했고 문법이 더 깔끔합니다.


5) Coroutines — 스레드는 무겁다

Thread vs Coroutine 비용

항목OS ThreadCoroutine
메모리~1MB (커널 스택)~수 KB
생성 비용높음 (syscall)매우 낮음 (객체 생성)
컨텍스트 스위칭OS 스케줄러 (비용 높음)유저 스페이스 (비용 낮음)
동시 실행 수수천 개가 한계수십만 개 가능
// 10만 개 코루틴 — 문제 없음
runBlocking {
    repeat(100_000) {
        launch {
            delay(1000)
            print(".")
        }
    }
}
// Thread로 같은 걸 하면? → OutOfMemoryError 💥

suspend 함수 — 비동기의 핵심

// suspend 함수: "여기서 잠시 멈출 수 있어요"
suspend fun fetchUser(id: Long): User {
    // 네트워크 호출 중 스레드를 점유하지 않음!
    return httpClient.get("https://api.example.com/users/$id")
}

// 호출 코드: 동기처럼 보이지만 비동기로 동작
suspend fun getUserProfile(id: Long): Profile {
    val user = fetchUser(id)            // 네트워크 대기
    val orders = fetchOrders(user.id)    // 네트워크 대기
    return Profile(user, orders)
}

Structured Concurrency — 코루틴의 생명주기 관리

코루틴은 반드시 CoroutineScope 안에서 실행됩니다. 부모가 취소되면 자식도 전부 취소됩니다.

suspend fun loadDashboard(): Dashboard = coroutineScope {
    // 두 작업을 병렬로 실행
    val userDeferred = async { fetchUser(userId) }
    val ordersDeferred = async { fetchOrders(userId) }
    
    // 둘 다 완료될 때까지 대기
    Dashboard(
        user = userDeferred.await(),
        orders = ordersDeferred.await()
    )
    // 하나가 실패하면? → 다른 하나도 자동 취소!
}

Dispatcher — 어떤 스레드에서 실행할까?

Dispatcher용도스레드 풀
Dispatchers.IO네트워크, 파일, DB64개 이상 (탄력적)
Dispatchers.DefaultCPU 집약 (정렬, 파싱)CPU 코어 수
Dispatchers.MainUI 업데이트 (Android)메인 스레드 1개
Dispatchers.Unconfined테스트용호출 스레드 그대로
suspend fun processData() {
    withContext(Dispatchers.IO) {
        // DB 조회 (IO 스레드)
        val data = repository.findAll()
    }
    withContext(Dispatchers.Default) {
        // 무거운 계산 (CPU 스레드)
        data.map { transform(it) }
    }
}

6) Extension Functions — 확장 함수

상속받지 않고도 기존 클래스에 기능을 추가할 수 있습니다.

// String 클래스에 기능 추가
fun String.lastChar(): Char = this[this.length - 1]

println("Kotlin".lastChar())  // 'n'

// 실무: 도메인 확장
fun Money.toKRW(): String = "₩${String.format("%,.0f", this.amount)}"

val price = Money(15000.0)
println(price.toKRW())  // ₩15,000

실무 팁: 확장 함수 vs 유틸 클래스

// ❌ Java 스타일: 유틸 클래스
object StringUtils {
    fun isEmail(s: String): Boolean = s.matches(EMAIL_REGEX)
}
StringUtils.isEmail(input)

// ✅ Kotlin 스타일: 확장 함수
fun String.isEmail(): Boolean = matches(EMAIL_REGEX)
input.isEmail()  // 더 자연스러움

주의: 확장 함수는 **정적 디스패치(static dispatch)**입니다. 실제로 클래스를 수정하는 것이 아니라, 컴파일 시 정적 메서드로 변환됩니다. 따라서 **다형성(Polymorphism)**이 필요하면 인터페이스를 사용하세요.


7) Kotlin + Spring Boot — 실전 통합

기본 설정 주의사항

// 1. open 문제: Spring은 CGLIB 프록시로 클래스를 상속하는데,
//    Kotlin 클래스는 기본이 final
// 해결: kotlin-spring 플러그인 (자동으로 open 처리)

// build.gradle.kts
plugins {
    kotlin("plugin.spring") version "2.0.0"  // 필수!
    kotlin("plugin.jpa") version "2.0.0"     // JPA 엔티티용
}

// 2. JPA 엔티티: no-arg 생성자 필요
// kotlin-jpa 플러그인이 자동으로 no-arg 생성자 추가

@Entity
data class User(
    @Id @GeneratedValue
    val id: Long = 0,      // 기본값으로 no-arg 대응
    val name: String,
    val email: String
)

Controller 예시

@RestController
@RequestMapping("/api/users")
class UserController(
    private val userService: UserService  // 생성자 주입 (자동)
) {
    @GetMapping("/{id}")
    suspend fun getUser(@PathVariable id: Long): ResponseEntity<User> {
        val user = userService.findById(id)
            ?: return ResponseEntity.notFound().build()
        return ResponseEntity.ok(user)
    }
    
    @PostMapping
    suspend fun createUser(@Valid @RequestBody request: CreateUserRequest): ResponseEntity<User> {
        val user = userService.create(request)
        return ResponseEntity.status(CREATED).body(user)
    }
}

WebFlux + Coroutines

Spring WebFlux에서 Mono/Flux 대신 코루틴을 직접 사용할 수 있습니다:

// Mono/Flux 방식 (리액티브 스트림)
fun findById(id: Long): Mono<User> = ...

// Coroutine 방식 (더 읽기 쉬움)
suspend fun findById(id: Long): User? = ...
fun findAll(): Flow<User> = ...  // Flow = 코루틴 버전 Flux

8) Java → Kotlin 마이그레이션 체크리스트

한 번에 전환하지 말고, 점진적으로 마이그레이션합니다.

단계별 전략

단계대상리스크
1단계테스트 코드거의 없음 — 여기서 Kotlin에 익숙해지기
2단계유틸/헬퍼 클래스낮음 — 의존하는 코드 적음
3단계새로운 기능낮음 — Java/Kotlin 혼용 가능
4단계DTO/Request/Response보통 — data class 활용
5단계Service/Repository보통~높음 — 핵심 로직
6단계기존 코드 일괄 변환높음 — IntelliJ 자동 변환 후 리뷰 필수

주의할 함정

// 1. Java interop: Platform Type
val result: String = javaMethod()  // Java 반환값은 String! (Platform Type)
// null이 올 수 있는데 Non-null로 받으면 → 런타임 NPE!
// 해결: nullable로 받기
val result: String? = javaMethod()

// 2. data class + JPA: equals/hashCode 주의
// JPA의 지연 로딩 프록시에서 문제 발생 가능
// 해결: id만으로 equals/hashCode 정의
@Entity
class User(
    @Id @GeneratedValue val id: Long = 0,
    var name: String
) {
    override fun equals(other: Any?) = other is User && id == other.id
    override fun hashCode() = id.hashCode()
}

// 3. companion object ≠ static
class MyService {
    companion object {
        private val log = LoggerFactory.getLogger(MyService::class.java)
        // Java에서 접근: MyService.Companion.getLog() 😱
        // 해결: @JvmStatic 어노테이션
    }
}

📚 9. 연관 학습


요약

개념핵심
Null Safety? 타입 + Safe Call + Elvis로 컴파일 시점 NPE 99% 차단
data classequals/hashCode/toString/copy 자동 생성, Lombok 불필요
Scope Functionslet(null 체크), apply(초기화), also(로깅) — 상황별 사용
Sealed Classwhen에서 모든 케이스 컴파일 검증, 도메인 이벤트에 강력
Coroutines경량 비동기, Structured Concurrency로 생명주기 자동 관리
Extension Functions기존 클래스 확장, 유틸 클래스 대체
Spring 통합kotlin-spring/jpa 플러그인 필수, WebFlux + Coroutine 조합
마이그레이션테스트 → 유틸 → 신규 기능 → DTO → Service 순서로 점진적