Kotlin

[Effective Kotlin] 8 - 적절하게 null을 처리하라

매운돌 2023. 3. 19. 20:34

프로퍼티가 null이라는 것은 값이 설정되지 않았거나 제거되었음을 나타냅니다.

함수가 null을 리턴한다는 것은 함수에 따라서 여러 의미를 가질 수 있습니다.

하지만, API 사용자를 위해 명확한 의미를 가지게 하는것이 좋습니다.

 

기본적으로 nullable타입은 세 가지 방법으로 처리합니다.

  • ?. 스마트 캐스팅, Elvis 연사자 등을활용해서 안전하게 처리합니다.
  • 오류를 throw합니다.
  • 함수 또는 프로퍼티를 리펙토링해서 nullable 타입이 나오지 않게 바꿉니다.

 

null을 안전하게 처리하기

가장 널리 null을 안전하게 처리하는 방법으로는 안전 호출(safe call)과 스마트 캐스팅이 있습니다.

printer?.print() // 안전 호출
if (printer != null) printer.print() // 스마트 캐스팅

kotlin은 nullable 변수와 관련된 처리를 굉장히 광법위하게 지원합니다.

대표적으로 Elvis연사자가 있으며, Elvis연산자는 오른쪽 return 또는 throw를 포함한 모든 표현식이 허용됩니다.

val printerName1 = printer?.name ?: "Unnamed"
val printerName2 = printer?.name ?: return
val printerName3 = printer?.name ?: thow Error("Printer must be named")

 

또한 많은 객체가 nullable과 관련된 처리를 지원합니다.

예를 들어 컬렉션 처리르 할 때 무언가 없다는 것을 나타낼 때는 null이 아닌 빈 컬렉션을 사용하는 것이 일반적입니다.

Collection<T>?.orEmpty()

스마트 캐스팅은 Kotlin의 규약 기능(contracts feature)을 지원합니다. 이 기능을 사용하면 다음 코드 처럼 스마트 캐스팅을 할 수 있습니다.

println("What is your name?")
val name = readline()
if (!name.isNullOrBlank()) {
	println("Hello ${name.toUpperCase()}")
}

val news: List<News>? = getNews()
if (!news.isNullOrEmpty()) {
	news.forEach{ notifyUser(it) }
}

 

오류 throw하기

만약 null이 나왔을 때 이를 알리지 않고, 코드가 그대로 진행될 경우 다른 개발자들이 오류를 찾기 어렵게 만듭니다. 따라서 다른 개잘바자가 어떤 코드를 보고 선입견처럼 "당연히 그럴 것이다."라고 생각하게 되는 부분이 있고, 그 부분에서 문제가 발생할 경우 개발자에게 오류를 강제로 발생시켜주는 것이 좋습니다.

오류를 강제로 발생시킬 때는 thow, !!, requireNotNull, checkNotNull 등을 활용합니다.

fun process(user: User) {
	requireNotNull(user.name)
    val context = checkNotNull(context)
    val networkService = getNetworkService(context) ?: throw NoInternetConnection()
    networkService.getData { data, userData ->
    	show(data!!, userData!!)
    }
}

 

not-null assertion(!!)과 관련된 문제

Kotlin에서 nullable을 처리하는 가장 간단한 방법은 not-null assertion(!!)을 사용하는 것입니다. 그런데 !!를 사용하면 자바에서 nullable을 처리할 때 발생할 수 있는 문제가 똑같이 발생합니다.

따라서, !!은 사용하기 쉽지만, 좋은 해결 방법이 아닙니다.

  • 예외가 발생할 때, 어떤 설명도 없는 제너릭 예외가 발생합니다.
  • 코드가 짧고 너무 사용하기 쉽다보니 남용하게 되는 문제가 발생합니다.

!!은 nullable이지만, null이 나오지 않는다는 것이 확실한 상황에서 많이 사용됩니다.

 

또한 아래 처럼 변수를 null로 선언하고, !!연산자를 사용하는 방법은 좋은 방법이 아닙니다.

class UserControllerTest {
	
    private var dao: UserDao? = null
    private var controller: UserController? = null
    
    @BeforeEach
    fun init() {
    	dao = mock()
        controller = UserController(dao!!)
    }
    
    @Test
    fun test() {
    	controller!!.doSomething()
    }
}

이렇게 코드를 작성하면, 이후에 프로퍼티를 계속해서 언팩(unpack)해야 하므로 사용하기 귀찮습니다.

또한 해당 프로퍼티가 실제로 이후에 의미 있는 null값을 가질 간으성 자체를 차단해 버립니다.

위와 같은 경우 가장 올바른 방법은 lateinit또는 Delegates.notNull을 사용하는 것입니다.

 

미리에 코드가 어떻게 변화할지는 아무도 알 수 없습니다. !! 연산자를 사용하거나 명시적으로 예외를 발생시키는 형태로 설계하면, 미래의 어느 시점에 해당 코드가  오류를 발생시킬 수 있다는 것을 염두해 둬야 합니다.

또한 명시적 오류는 단순 NPE보다 훨씬 더 많은 정보를 제공해 줄 수 있으므로 !!연산자를 사용하는 것보다는 훨씬 좋습니다.

 

!!연산자가 의미 있는 경우는 매우 드뭅니다. 일반적으로 nullability가 제대로 표현되지 않는 라이브러리를 사용할 때 정도에만 사용해야 합니다. 

즉, Kotlin을 대상으로 설계된 API를 활용한다며느 !!연산자를 사용하는 것을 이상하게 생각해야 합니다.

(대부분의 팀이 !!연산자를 아예 사용하지 못하게 하는 정책을 가지고 있습니다.)

 

의미 없는 nullability 피하기

nullability는 적절하게 처리해야 하므로 추가비용이 발생합니다. 따라서 필요한 경우가 아니라면 nullability 자체를 피하는 것이 좋습니다. 

null은 중요한 메세지를 전달하는 데 사용할 수 있습니다. 따라서 다른 개발자가 보기에 의미가 없을 때는 null을 사용하지 않는게 좋습니다. 그런데 만약 null을 사용했다면, 다른 개발자들이 코드를 작성할 때 위험한 !!연산자를 사용하게 되고, 의미 없이 코드를 더럽히는 예외를 처리해야 합니다.

 

nullability를 피하는 방법은 아래와 같습니다.

  • 클래스에서 nullability에 따라 여러 함수를 만들어 제공할 수도 있습니다.
  • 어떤 값이 클래스 생성 이후에 확실하게 설정된다는 보장이 있다면, lateinit프로퍼티와 notNull 델리게이트를 사용합니다.
  • null대신 빈컬렉션을 사용합니다.
  • nullable enum 대신 None enum값을 사용합니다.

 

lateinit 프러퍼티와 notNull 델리게이트

클래스 생성 중에 초기화할 수 없는 프로퍼티를 가지는 것은 드문 일은 아니지만 분명 존재하는 일입니다. 이러한 프로퍼티는 사용전에 반드시 초기화해서 사용해야 합니다.

 

lateinit 한정자는 프로퍼티가 이후에 설정될 것임을 명시하는 한정자입니다.

class UserControllerTest {
	private lateinit var dao: UserDao
    private lateinit var controller: UserController
    
    @BeforeEach
    fun init() {
    	dao = mock()
        controller = UserController(dao)
    }
    
    @Test
    fun test() {
    	controller.doSomething()
    }
}

lateinit과 nullable은 아래와 같은 차이가 있습니다.

  • !! 연산자로 언팩 (unpack)하지 않아도 됩니다.
  • 이후에 어떤 의미를 나타내기 위해서 null을 사용하고 싶을 때, nullable로 만들 수 있습니다.
  • 프로퍼티가 초기화된 이후에는 초기화되지 않은 상태로 돌아갈 수 없습니다.

lateinit은 프로퍼티가 사용하기 전에 반드시 초기화될 거라고 예상되는 상황에 활용합니다.

라이프 사이클을 갖는 클래스처럼 메서드 호출에 명확한 수서가 있는 경우에 활용됩니다.

 

반대로, lateinit을 사용할 수 없는 경우도 있습니다. JVM에서 Int, Long, Double, Boolean과 같은 기본 타입과 연결된 타입으로 프로퍼티를 초기화해야 하는 경우입니다. 이런 경우에는 약간 느리지만, Delegates.notNull을 사용합니다.

class DoctorActivity: Activity() {
	private var doctorId: Int by Delegates.notNull()
    private var fromNotification: Boolean by Delegates.notNull()
    
    override fun onCreate(saveInstanceState: Bundle?) {
    	super.onCreate(saveInstanceState)
        doctorId.intent.extras.getInt(DOCTOR_ID_ARG)
        fromNotification = intent.extras.getBoolean(FROM_NOTIFICATION_ARG)
    }
}