Kotlin에서 Optional 다루기

더 간결하고 안전하게

  • Java의 Optional<T>은 null 참조를 안전하게 다루기 위해 도입되었지만, Kotlin은 자체적인 Nullable 타입 시스템( T? )과 다양한 연산자를 통해 이보다 더 간결하고 직관적으로 null 처리를 가능하게 한다. Optional을 Kotlin Style로 다루는 여러 가지 방법을 알아본다

Kotlin의 Null 타입 ( T? )과 엘비스 연산자 ( ?: ) 활용

  • Kotlin은 기본적으로 모든 타입이 Non-null이며, null을 허용하려면 타입 뒤에 ?를 붙여 명시해야 한다. (예: String?, User?) 이 Nullable 타입을 이용하면 Optional 없이도 존재하지 않는 값에 대한 처리를 깔끔하게 할 수 있다

Java Optional 스타일

interface UserRepository: JpaRepository<User, Long> {
    fun findByName(name: String): Optional<User> // Optional 반환
}

@Service
class UserService(private val userRepository: UserRepository) {
    @Transactional
    fun deleteUser(name: String) {
        val user = userReposiroty.findByName(name).orElseThrow(::IllegalArgumentException) // Optional.orElseThrow 사용

        userRepository.delete(user)
    }
}

Kotlin-style (Nullable 타입 및 엘비스 연산자)

interface UserRepository: JpaRepository<User, Long> {
    fun findByName(name: String): User? // Nullable 타입 반환
}

@Service
class UserService(private val userRepository: UserRepository) {
    @Transactional
    fun deleteUser(name: String) {
        // findByName이 null을 반환하면 IllegalArgumentException 발생
        val user = userReposiroty.findByName(name) ?: throw IllegalArgumentException()

        userRepository.delete(user)
    }
}
  • findByName(name) 메서드가 User 객체를 찾지 못해 null을 반환하면, 엘비스 연산자 (?:)에 의해 IllegalArgumentException이 발생하여 user 변수에는 절대로 null이 할당되지 않음을 보장한다

반복되는 예외 처리를 위한 유틸리티 함수 – fail()

  • 특정 예외 (IllegalArgumentException 등)가 반복적으로 발생하는 경우, 이를 처리하는 코드를 매번 작성하는 것을 비효율적이다. 이럴 때는 전용 유틸리티 함수를 만들어서 코드를 간결하게 만들 수 있다

ExceptionUtils.kt

package com.exmaple.utils // 적절한 패키지명 사용

fun fail(): Nothing {
    throw IllegalArgumentException()
}

// Default Parameter를 사용할 수도 있다
fun fail(message: String = "부적절한 인수 값입니다"): Nothing {
    throw IllegalArgumentException(message)
}
  • Nothing 타입은 함수가 절대 성공적으로 반환되지 않음을(항상 예외를 던지거나 무한 루프에 빠짐) 컴파일러에게 알려준다

fail() 적용

@Service
class BookService(
    private val bookRepository: BookRepository,
    private val userRepository: UserRepository,
    private val userLoanHistoryRepository: UserLoanHistoryRepository,
) {

    @Transactional
    fun loanBook(request: BookLoanRequest) {
        val book = bookRepository.findByName(request.bookName) ?: fail()

        // 그 밖에 로직

        val user = userRepository.findByName(request.userName) ?: fail()
        user.loanBook(book)
    }

    @Transactional
    fun returnBook(request: BookReturnRequest) {
        val user = userRepository.findByName(request.userName) ?: fail()

        user.returnBook(request.bookName)
    }
}
  • fail() 함수를 사용함으로써 반복되는 throw IllegalArgumentException() 코드를 줄이고 가독성을 높일 수 있다

Java 라이브러리의 Optional을 Kotlin스럽게 다루기 (확장 함수)

  • JpaRepository의 findById 처럼 개발자가 직접 제어할 수 없는 Java 라이브러리(CrudRepository Interface 등)가 Optional을 반환하는 경우가 있다. 이때 Kotlin의 확장 함수(Extension Function)을 사용하여 Optional을 T?나 T로 변환하여 활용할 수 있다
  • 스프링 프레임워크는 Kotlin과 CrudRepository를 함께 사용할 때는 대비하여 CrudRepositoryExtension을 제공한다
import org.springframework.data.repository.CrudRepository
import java.util.Optional

// CrudRepository의 findById가 반환하는 Optional<T>를 T?로 변환
fun <T, ID> CrudRepository<T, ID>.findByIdOrNull(id: ID): T? = findById(id).orElse(null)
  • 이 확장 함수를 사용하면 findById가 반환하는 Optional<T>을 마치 CrudRepository의 메서드인 것처럼 호출하여 T? 타입으로 직접 받을 수 있다

findByIdOrNull 적용

@Service
class UserService(private val userRepository: UserRepository) {
    @Transactional
    fun updateUserName(request: UserUpdateRequest) {
        // 기존: userRepository.findById(request.id).orElseThrow(::IllegalArgumentException)

        val user = userRepository.findByIdOrNull(request.id) ?: fail()

        iser.updateName(request.name)
    }
}

확장 함수를 이용한 완전한 Optional 제거 (findByIdOrThrow)

  • 여기서 한 단계 더 나아가, findByIdOrNull과 fail()을 조합하여 Nullable이 아닌 T 타입을 직접 반환하며, null일 경우 예외를 던지는 확장 함수를 만들 수 있다

ExceptionUtils.kt

import org.springframework.data.repository.CrudRepository
import org.springframework.data.repository.findByIdOrNull

fun fail(): Nothing {
    throw IllegalArgumentException()
}

// CrudRepository의 findById 결과가 null이면 예외를 던지는 확장 함수
fun <T, ID> CrudRepository<T, ID>.findByIdOrThrow(id: ID): T {
    return this.findByIdOrNull(id) ?: fail()
}
  • findByIdOrThrow 함수는 반환 타입이 T로 선언되어 있어, 이 함수가 호출된 후에는 절대로 null이 아님을 컴파일러가 보장한다

findByIdOrThrow 적용

import com.group.libraryapp.util.findByIdOrThrow
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class UserService(
    private val userRepository: UserRepository,
) {

    @Transactional
    fun updateUserName(request: UserUpdateRequest) {
        val user = userRepository.findByIdOrThrow(request.id)
        user.updateName(request.name)
    }

}
  • Kotlin의 확장 함수를 활용하면 기존 Java 라이브러리의 제약사항을 우회하고, 코틀린 타입 시스템을 적극적으로 활용하여 더욱 간결하고 type-safe한 코드를 작성할 수 있다. 이는 개발 생산성을 높이고 런타임 NPE 발생 가능성을 줄여준다

출처 – 실전! 코틀린과 스프링 부트로 도서관리 애플리케이션 개발하기 (Java 프로젝트 리팩토링)