새소식

Language/Kotlin

[Effective Kotlin] 1장 안전성 : Item 6번 ~ 10번

  • -
제목 처럼 사용자 정의 오류를 사용하기 보단, 가능한 표준 라이브러리를 사용하는 것이 좋다.

 

 

✅ 표준 라이브러리를 권장하는 이유

  • 많은 개발자들이 더 쉽고 빠르게 이해할 수 있다. 그러므로 재사용성도 더 좋아집니다.

 

✅ 표준 라이브러리 예외

  • IllegalArgumentException과 IllegalStateException : require와 check를 사용해 throw 할 수 있는 예외다.
  • IndexOutOfBoundsException : 인덱스 파라미터의 값이 범위를 벗어났을때 나타냅니다. 보통 ArrayList.get(Int)를 사용할 때 throw 된다.
  • ConcurrentModificationException : 동시 수정을 금지했는데, 발생했을때 나타낸다.
  • UnSupportedOperationException : 사용자가 사용하고자 하는 메서드가 현재 객체에서는 사용할 수 없다는 것을 나타낸다.
  • NoSuchElementException : 사용자가 사용하려고 했던 요소가 존재하지 않음을 나타낸다.

UnSupportedOperationException
클래스 내부에 사용할 수 없는 메서드를 일부러 두는 경우도 있다. 예를 들어 listOf에는 사용하면 무조건적으로 오류가 발생하는 메서드들이 있다. 이는 “listOf로 만들어진 컬렉션은 immutable이므로 조작할 수 없습니다.” 라고 명시적으로 알려주기 위한 목적으로 사용된다.

 


✅ 함수가 원하는 결과를 만들어 낼 수 없는 상황 & 처리 메커니즘

 

▪️ 함수가 결과를 만들어 낼 수 없는 상황

  • 서버로 부터 데이터를 읽어 들이는 과정에서 인터넷 연결 중단
  • 조건에 맞는 요소 탐색 실패
  • 텍스트 파싱시 타입 불일치

 

▪️ 처리 메커니즘

  • null 또는 실패를 나타나는 sealed 클래스를 리턴
  • 예외 throw

 

▪️ 예외는 예외적인 상황이 발생했을 때 사용하는 것이 좋은 이유

  • 예외는 정보 전달하는 방법으로 사용되서는 안된다. 예외는 특별한 상황을 나타내고 처리되어야 한다.
  • 많은 개발자가 예외가 전파되는 과정을 제대로 추적하지 못한다.
  • 코틀린의 모든 예외는 unchecked 예외이다. 따라서 사용자가 예외를 처리하지 않을 수도 있으며, 이와 관련된 내용은 문서에 제대로 드러지 않는다.
  • 예외는 예외적인 상황을 처리하기 위해 만들어졌으므로 명시적인 테스트 만큼 빠르게 동작하지 않는다.
  • try-catch 블록 내부에 코드를 배치하면, 컴파일러가 할 수 있는 최적화가 제한된다.
checked: 사용자가 반드시 처리하게 강제되는 예외
unChecked: 처리하지 않아도 실행에 문제가 없는 예외

 

 

▪️ null 또는 실패를 나타나는 sealed 클래스를 사용하면 좋은 이유

  • 오류를 표현할 때 명시적이고, 효율적이며 간단하게 처리할 수 있다.

 

정리

  • 충분히 예측할 수 있는 범위의 오류는 null과 Failure를 사용
  • 예측하기 어려운 예외적인 범위의 오류는 예외를 throw

 

✅ 예시

inline fun <reified T> String.readObjectOrNull(): T? {
    //...
    if (incorrectSign) {
        return null
    }
    //...
    return result
}

inline fun <reified T> String.readObject(): Result<T> {
    //...
    if (incorrectSign) {
        return Failure(JsonParsingException())
    }
    //...
    return Success(result)
}

sealed class Result<out T>
class Success<out T>(val result: T): Result<T>()
class Failure(val throwable: Throwable): Result<Nothing>()

class JsonParsingException: Exception()

 

  • Elvis연산자를 사용한 null 체크
val age = userText.readObjectOrNull<Person>()?.age ?: -1

 

  • when 표현식을 사용한 null 체크
val person = userText.readObjectOrNull<Person>()
val age = when(person) {
    is Success -> person.age
    is Failure -> -1
}
data class Person (
    val name: String,
    val age: Int
)

fun main() {
    val userText = "테스트!"
    val person = userText.readObject<Person>()
    val age = when(person) {
        is Success -> person.result.age
        is Failure -> -1
    }
}

 

✅ null 값과 Sealed result 클래스 차이점

  • 추가적인 정보를 전달해야 한다면 sealed result 사용
  • 그렇지 않으면 null을 사용

 


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

 

✅ nullable 타입 처리 방법 3가지

null은 최대한 명확한 의미를 갖는 것이 좋습니다. 이는 nullable 값을 처리해야 하기 때문입니다.

  • ?. , 스마트 캐스팅, Elvis 연산자 등을 활용해서 안전하게 저리
  • 오류를 throw
  • 함수 또는 프로퍼리를 리팩터링해서 nullable타입이 나오지 않게 바꾼다.

 

✅ "?." , 스마트 캐스팅, Elvis 연산자 등을 활용해서 안전하게 저리

  • 스마트 캐스팅
printer?.print() // safe call
if (printer != null) printer.print() // 스마트 캐스팅

 

  • Elvis 연산자
val printerName1 = printer?.name ?: "Unnamed"
val printerName2 = printer?.name ?: return
val printerName3 = printer?.name ?: throw Error("Printer must be named")

 

  • 코틀린 규약 기능
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) } // if문 null 체크를 통한 not null로 스마트 캐스팅된 news 컬렉션
}

 

✅ 오류를 throw 하기

이전 printer 예제에서 printer가 null이 되리라 예상하지 못했다면, print 메소드가 호출되지 않아서 이상할 것입니다. 이는 개발자가 오류를 찾기 어렵게 만듭니다. 그러한 부분에서 오류를 강제로 발생시켜 개발자에게 알려주는 것이 좋습니다. 오류를 강제로 발생시킬 때는 throw, !!, requireNotNull, checkNotNull 등을 활용합니다.

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

 

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

nullable을 처리하는 가장 간단한 방법은 not-null assertion(!!)을 사용하는 것입니다. 그러나 !!를 사용하면 자바에서 nullable을 처리할 때 발생할 수 있는 똑같은 문제가 발생한다. 어떤 대상이 null이 아니라고 생각하고 다루면, NPE가 발생하기 때문입니다. !! 는 타입이 nullable이지만, null이 나오지 않는다는 것이 확실한 상황에서 많이 사용됩니다.

일반적으로 !! 연산자 사용을 피하는 것을 권장합니다. !! 연산자를 아예 사용하지 못하게 하는 정책도 있습니다.
!! 연산자를 사용하면 미래에 코드가 변화하는 과정에서 어느 시점에 오류가 발생할 가능성을 염두해 둬야 합니다.

 

✅ 의미 없는 nullability 피하기

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

  • nullability를 피하는 방법
    1. 클래스에서 nullability에 따라 여러 함수를 만들어서 제공할 수도 있다. ex) List<T>의 get, getOrNull 함수가 있다.
    2. 어떤 값이 클래스 생성 이후에 확실하게 설정된다는 보장이 있다면, lateinit 프로퍼티와 notNull 델리게이트를 사용하면 된다.
    3. 빈 컬렉션 대신 null을 리턴하지 마라
    4. nullable enum과 none enum 값은 완전히 다른 의미이다. null enum은 별도로 처리해야 하지만, none enum 정의에 없으므로 필요한 경우에 사용하는 쪽에서 추가해서 활용할 수 있다.

 

Nullable Enum 처리

State enum 타입을 nullable로 처리하는 경우입니다. 이 경우, enum 값이 null일 수 있으므로, null을 별도로 체크하고 처리해야 합니다.

enum class State {
    ACTIVE, INACTIVE, PENDING
}

fun processStateNullable(state: State?) {
    when (state) {
        State.ACTIVE -> println("활성 상태")
        State.INACTIVE -> println("비활성 상태")
        State.PENDING -> println("대기 중")
        null -> println("상태 정보 없음")
    }
}

// 사용 예
processStateNullable(State.ACTIVE)
processStateNullable(null)

 

None Enum 처리

이번에는 State enum에 None 값을 추가하여, '값이 없음'을 명시적으로 표현하는 방법입니다. 이 경우, 모든 상태는 State enum의 인스턴스이며, null을 별도로 처리할 필요가 없습니다.

enum class StateWithNone {
    ACTIVE, INACTIVE, PENDING, NONE// 'None' 값 추가
}

fun processStateWithNone(state: StateWithNone) {
    when (state) {
        StateWithNone.ACTIVE -> println("활성 상태")
        StateWithNone.INACTIVE -> println("비활성 상태")
        StateWithNone.PENDING -> println("대기 중")
        StateWithNone.NONE -> println("상태 정보 없음")// 명시적으로 'None' 처리
    }
}

// 사용 예
processStateWithNone(StateWithNone.ACTIVE)
processStateWithNone(StateWithNone.NONE)

 

✅ lateinit 프로퍼티와 notNull 델리게이트

클래스가 클래스 생성 중에 초기화할 수 없는 프로퍼티를 가질 수 있으며, 이러한 프로퍼티는 사용 전에 반드시 초기화해서 사용해야 합니다.

이러한 코드에 대한 바람직한 해결책은 나중에 속성을 사용하기 전에 반드시 초기화될 거라고 예상되는 상황에서 lateinit 한정자를 사용하는 것입니다.

 

초기화 전에 값을 사용하려고 하면 예외가 발생하며, 처음 사용하기 전에 반드시 초기화될 거라고 예상되는 경우에만 사용해야 합니다.

lateinit은 nullable과 비교해서 다음과 같은 차이가 있습니다.

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

lateinit을 사용할 수 없는 경우도 있습니다.

기본(Primitive) 타입은 lateinit으로 선언할 수 없으며, Int, Long, Double, Boolean과 같은 기본 타입과 연결된 타입으로 프로퍼티를 초기화해야 하는 경우는 lateinit을 사용할 수 없습니다.

이런 경우에는 Delegates, notNull을 사용하며, 프로퍼티 위임(property delegation)을 사용할 수도 있습니다.

class Rectangle {
    lateinit var area: Area
    fun initArea(param: Area): Unit {
        this.area = param
    }
}
var nonNullString: String by Delegates.notNull<String>()
nonNullString = "Hello World"
println("Non null value is: ${nonNullString}")
nonNullString = null  // 컴파일 에러, non-null 타입에 null을 넣을 수 없음

 

 


더이상 필요하지 않을 때, close 메서드를 사용해서 명시적으로 닫아야 하는 리소스가 있습니다.

  • InputStream과 OutputStream
  • java.sql.Connection
  • java.io.Reader(FileReader, BufferedReader, CSSParser)
  • java.new.Socket과 java.util.Scanner

이러한 리소스들은 AutoCloseable을 상속받는 Closeable 인터페이스를 구현하고 있습니다.

// Closeable
public interface Closeable extends AutoCloseable {
    void close() throws IOException;
}
// AutoCloseable
public interface AutoCloseable {
    void close() throws Exception;
}

이러한 모든 리소스는 최종적으로 리소스에 대한 레퍼런스가 없어질 때, 가비지 컬렉터가 처리합니다. 하지만 굉장히 느리며 비용이 많이 들어갑니다. 따라서 더이상 필요하지 않다면, 명시적으로 close 메서드를 호출해 주는 것이 좋습니다.

 

 

✅ 정통적 방식 try-finally

fun countCharactersInFile(path: String): Int {
    val reader = BufferedReader(FileReader(path))
    try {
        return reader.lineSequence().sumBy { it.length }
    } finally {
        reader.close()
    }
}

리소스를 닫을 때 예외가 발생할 수 있는데, 이러한 예외를 따로 처리하지 않기 때문에 좋은 코드가 아닙니다.

또한 try-finally를 사용하여 처리하면, try 블록이나 finllay 블록 내부에서 오류가 발생하면 둘 중 하나만 전파됩니다.

 

 

✅ use 함수

  • use 함수는 모든 Closeable 객체에 사용할 수 있다.
  • use 함수를 사용하면 Closeable/AutoCloseable을 구현한 객체를 쉽고 안전하게 처리할 수 있다.
  • 람다 형태로 사용 가능하다.
  • 파일을 한 줄씩 처리할 때 useLines 사용한다.
fun countCharactersInFile(path: String): Int {
    val reader = BufferedReader(FileReader(path))
    reader.use {
        return reader.lineSequence().sumBy { it.length }
    }
}

 

 

 

 


  • 코드를 안전하게 만드는 가장 궁극적인 방법은 다양한 종류의 테스트를 하는 것이다.
  • 이러한 종류의 테스트는 개발자의 관점에서 애플리케이션 내부적으로 올바르게 작동하는지 확인하는 것이 아니라, 사용자의 관점에서 애플리케이션 외부적으로 제대로 작동하는지 확인하는 것이 목표이다.
  • 단위 테스트는 개발자가 작성하며, 개발자에게 유용하다.

 

✅ 일반적인 단위 테스트 확인 내용

  • 일반적인 유스 케이스
    • 요소가 사용될 거라고 예상되는 일반적인 방법을 테스트한다.
  • 일반적인 오류 케이스와 잠재적인 문제
    • 제대로 동작하지 않을 거라고 예상되는 일반적인 부분, 과거에 문제가 발생했던 부분 등을 테스트한다.
  • 에지 케이스와 잘못된 아규먼트
    • Int의 경우 Int.MAX_VALUE를 사용하는 경우, nullable의 경우 'null' 또는 'null 값으로 채워진 객체'를 사용하는 경우를 의미한다. 피보나치 수는 양의 정수로만 구할 수 있다. 음의 정수 등을 넣으면 아규먼트 자체가 잘못된 것이다.

 

✅ TDD

TDD(Test Driven Development)는 개발 전에 테스트를 먼저 작성하고, 테스트를 통과시키는 것을 목적으로 하나하나 구현해 나가는 방식입니다.

단위 테스트의 장점을 정리해보면 다음과 같습니다.

  • 테스트가 잘 된 요소는 신뢰할 수 있다. (요소에 대한 신뢰는 요소를 더욱 자신감 있게 활용할 수 있다.)
  • 테스트가 잘 만들어져 있다면, 리펙터링하는 것이 두렵지 않다. 테스트가 있으므로, 리펙터링했을 때 버그가 생기는지 쉽게 확인할 수 있다.
  • 수동으로 테스트하는 것보다 단위 테스트로 확인하는 것이 빠르다. 빠른 속도의 피드백 루프가 만들어지므로, 개발의 전체적인 속도가 빨라지며 버그를 빨리 찾을 수 있으므로 버그를 수정하는 비용도 줄어든다.

하지만 다음과 같은 단점도 있습니다.

  • 단위 테스트를 만드는 데 시간이 걸린다. 다만 장기적으로 좋은 단위 테스트는 '디버깅 시간'과 '버그를 찾는 데 소모되는 시간'을 줄여야 한다.
  • 테스트를 활용할 수 있게 코드를 조정해야 한다. 변경하기 어렵기는 하지만, 이러한 변경을 통해 잘 정립된 아키텍처를 사용하는 것이 강제 된다.
  • 좋은 단위 테스트를 만드는 작업이 꽤 어렵다. 남은 개발 과정에 대한 확실한 이해가 필요하다. 잘못 만들어진 단위 테스트는 득보다 실이 크다. 단위 테스트를 제대로 하려면, 올바르게 단위 테스트를 하는 방법을 배워야 한다.

 

✅ 숙련된 코틀린 개발자가 되려면

  • 단위 테스트와 관련된 기술을 습득하자.
    • 복잡한 부분
    • 계속해서 수정이 일어나고 리팩터링이 일어날 수 있는 부분
    • 비즈니스 로직 부분
    • 공용 API 부분
    • 문제가 자주 발생하는 부분
    • 수정해야 하는 프로덕션 버그

 

✅ 정리

가장 중요한 것은 애플리케이션이 진짜로 올바르게 동작하는지 확인하는 것!
개발 과정에서 가장 효율적으로 활용할 수 있는 테스트는 단위 테스트!

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.