Java 개발자를 위한 Kotlin 시리즈

이전 문서: 핵심 문법 전환

이 문서에서는 Java에 없는 Kotlin만의 기능들을 다룬다.

Sealed Class

when에서 모든 경우를 처리했는지 컴파일 타임에 검증해준다. 예외 처리나 상태 관리에서 유용하다.

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

// when에서 else가 필요 없다 — 모든 케이스를 다뤘으니까
fun handleResult(result: ApiResult<Post>) = when (result) {
    is ApiResult.Success -> result.data
    is ApiResult.Error -> throw BusinessException(result.message)
    is ApiResult.Loading -> null
}

Java의 enum은 값만 나열할 수 있지만, sealed class는 각 케이스가 서로 다른 데이터를 가질 수 있다. API 응답이나 에러 처리에서 매우 깔끔하다.

Inline 함수와 Reified 타입

Java에서는 제네릭의 타입 정보가 런타임에 지워진다(type erasure). Kotlin의 inline + reified를 쓰면 런타임에도 타입 정보를 유지할 수 있다.

// Java에서는 불가능한 코드
// if (obj instanceof T) { }  // 컴파일 에러

// Kotlin에서는 가능
inline fun <reified T> Any.isType(): Boolean = this is T

42.isType<Int>()      // true
42.isType<String>()   // false

Spring에서 실용적으로 쓰이는 예:

// RestTemplate에서 타입 안전하게 호출
inline fun <reified T> RestTemplate.getForEntity(url: String): ResponseEntity<T> =
    getForEntity(url, T::class.java)

Coroutine (코루틴) — 참고용

코루틴은 Kotlin의 비동기 프로그래밍 솔루션이다. Java의 CompletableFutureThread보다 훨씬 가볍고 직관적이다.

suspend fun fetchPost(id: Long): Post {
    val post = postRepository.findById(id)     // 비동기인데 동기처럼 읽힌다
    val comments = commentRepository.findByPostId(id)
    return post.copy(comments = comments)
}
블로그 프로젝트에서는 안 쓴다

Spring MVC(동기 방식)를 쓰니까 코루틴은 필요 없다. Spring WebFlux(리액티브)를 쓸 때 빛을 발한다. 지금은 "이런 게 있구나" 정도만 알아두면 된다.

Delegation (위임)

클래스 위임

// 인터페이스 구현을 다른 객체에 위임
class PostServiceLogging(
    private val delegate: PostService
) : PostService by delegate {

    // 특정 메서드만 오버라이드
    override fun createPost(request: PostCreateRequest): Long {
        log.info("글 작성 요청: ${request.title}")
        return delegate.createPost(request)
    }
}

데코레이터 패턴을 한 줄로 구현할 수 있다.

프로퍼티 위임

// lazy — 처음 접근할 때 한 번만 초기화
val heavyObject: HeavyThing by lazy {
    HeavyThing()    // 이 블록은 최초 접근 시에만 실행
}

// observable — 값이 바뀔 때마다 콜백
var name: String by Delegates.observable("초기값") { _, old, new ->
    println("$old → $new")
}

by lazy는 Spring에서도 자주 쓴다. 비용이 큰 초기화를 지연시킬 때 유용하다.

연산자 오버로딩

data class Money(val amount: Int, val currency: String) {
    operator fun plus(other: Money): Money {
        require(currency == other.currency) { "통화가 다릅니다" }
        return Money(amount + other.amount, currency)
    }
}

val total = Money(1000, "KRW") + Money(2000, "KRW")
// Money(amount=3000, currency=KRW)

남용하면 코드가 읽기 어려워지니까 직관적인 경우에만 쓴다.

Destructuring (구조 분해)

// data class는 자동으로 구조 분해 가능
data class Post(val id: Long, val title: String)

val (id, title) = Post(1, "제목")

// Map 순회에서 유용
for ((key, value) in map) {
    println("$key: $value")
}

// 람다에서도
posts.forEachIndexed { index, post ->
    println("$index: ${post.title}")
}

Type Alias

긴 타입 이름에 별칭을 붙일 수 있다.

typealias PostId = Long
typealias TagNames = List<String>
typealias Predicate<T> = (T) -> Boolean

fun filterPosts(predicate: Predicate<Post>): List<Post> {
    return posts.filter(predicate)
}

제네릭이 복잡해질 때 가독성을 위해 쓴다.

DSL (Domain Specific Language)

Kotlin의 람다 + 확장 함수를 조합하면 DSL을 만들 수 있다. Spring에서 이걸 적극 활용한다.

// Spring Security DSL 예시
@Bean
fun securityFilterChain(http: HttpSecurity) = http {
    csrf { disable() }
    authorizeHttpRequests {
        authorize("/api/posts/**", permitAll)
        authorize(anyRequest, authenticated)
    }
    sessionManagement {
        sessionCreationPolicy = SessionCreationPolicy.STATELESS
    }
}

Java로 쓰면 체이닝에 .and() 붙여가며 복잡한데, Kotlin DSL로 쓰면 구조가 한눈에 보인다. 이건 다음 문서에서 자세히 다룬다.

자주 하는 실수

1. =====

// == : 값 비교 (Java의 equals)
// === : 참조 비교 (Java의 ==)

val a = "hello"
val b = "hello"
a == b    // true (값이 같다)
a === b   // true (같은 String Pool 참조 — 이건 JVM 최적화)

Java에서 ==으로 문자열 비교하는 실수를 Kotlin에서는 할 수 없다. Kotlin의 ==이 이미 equals()를 호출하니까.

2. Unit vs Nothing

// Unit — Java의 void와 같다
fun printHello(): Unit {
    println("hello")
}

// Nothing — 함수가 절대 정상 종료하지 않는다
fun fail(message: String): Nothing {
    throw BusinessException(message)
}

3. lateinit vs lazy

// lateinit — 나중에 초기화할 var
// null이 아닌 타입에만 사용 가능, primitive 불가
lateinit var service: PostService

// lazy — 처음 접근 시 초기화되는 val
val service: PostService by lazy { PostService() }

Spring에서 @Autowired로 주입받을 때 lateinit을 쓸 수도 있지만, 생성자 주입이 더 좋다. 이것도 다음 문서에서 다룬다.