Kotlin

[Kotlin 세미나] 실패/예외 대응 방식

[하마] 이승현 (wowlsh93@gmail.com) 2021. 11. 22. 13:54

1. 디폴트 값으로 대처 

interface Transfer {
    fun moveTo(id : String)
}

interface Asset{
    val id: String
    val name: String
    val data: String
}

class Token(
    override val id: String,
    override val name: String,
    override val data: String) : Asset,Transfer{
    override fun moveTo(id: String) {
        TODO("Not yet implemented")
    }

}
class NFT(
    override val id: String,
    override val name: String,
    override val data: String) : Asset,Transfer {
    override fun moveTo(id: String) {
        TODO("Not yet implemented")
    }

}

class Account(val id: String, val name: String?): Transfer {
    val assets = mutableListOf<Asset>()
    override fun moveTo(id: String) {
        TODO("Not yet implemented")
    }
}

먼저 이런 코드가 있다고 가정 해 본다.

@Test
fun setUp() {
    val account = Account("1", null)
    val name = account.name?:"default_name"
    Assertions.assertEquals("default_name", name )
}


해당 Account의 name을 가져왔을 때 만약 null이라면 "default_name"을 넣어 줄 수 있다.
많은 경우 이렇게 처리 할 수 있을 것이다. 특히 설정값을 처리할 때 많이 사용되는데 특별히 설정된 값이 없을 경우 디폴트 값을 넣어주는 경우가 그럴 것이며 굉장히 처리하기 쉬운 경우이다.

2. nullable  

그리고 위에 보듯이 특정 값이 없을 경우 우리는 null을 리턴 할 수 있다.  비교적 일상적으로 벌어지는 문제이다.
즉 어떤 컬렉션에 특정 값이 없을 경우엔 null을 리턴하는게 정상이며 이후에 어떻게 처리 할 지는 사용자의 몫이다.

class Bank{
    val accounts = mutableMapOf<String, Account>()
    fun createAccount(account: Account){
        accounts.put(account.id, account)
    }
    
    fun getAccount(id: String): Account? {
        return accounts.get(id) 
    }
}

위에서 getAccount를 호출할 경우 해당 정보가 없을 수 도 있는데, 이것은 예외라고 보긴 어려울 것이다. (기술적으로 예외로 처리할 수도 있지만, 나는 그렇게 처리하지 않는다. 이유가 너무 명확하기 때문이다.) 

 @Test
 fun getAccount() {
   val bank = Bank()
   bank.createAccount(Account("1", "hama"))
   val result = bank.getAccount("1")?.name

  Assertions.assertEquals("hama", result)
}

만약 "2"를 넣어서  null을 리턴한다면 아무것도 처리하지 않으며, "1"을 넣는다면 name을 출력 할 것이다.  
? 는 null이 아닐 경우만 처리하라는 의미로, 위의 코드에서 ? 없이 name을 코딩 할 순 없다.
근데 위의 코드는 resultnull이 담길 수 있으므로 위험 할 수 있다. ?: 엘비스 연산자로 디폴트값을 넣거나 예외를 전파하거나 무엇인가 해야한다. NPE를 막아주는게 아니라 주의를 줄 뿐이다.

 val bank = Bank()
    bank.createAccount(Account("1", "hama"))
    bank.getAccount("1")?.let {
        val result = it.name
        println(result)
    }

적어도 let을 사용해 이렇게 하면 null을 전파하진 않는다.

3. Execption으로 전파 

null로 처리하게 된 다면, 호출당한 함수내부에서 어떤 이유로 null이 리턴 됬는지 궁금 할 수가 있다.일반적으로 예상되는 경우라면 호출한 측에서 이유를 작성 할 수도 있지만,  즉 예외가 왜 일어 났는지 잘 모를 수 있는 경우라면 호출당한 함수에서 이유를 알려주는 것이 일반적이다. 

class Bank{
    val accounts = mutableMapOf<String, Account>()
    fun createAccount(account: Account){
        accounts.put(account.id, account)
    }
    fun getAccount(id: String): Account? {
        return accounts.get(id)
    }

    fun dipositToken(accountId: String, token: Token){
        accounts.get(accountId)?.dipositToken(token)
    }

만약 위의 dipositToken을 호출했는데 accountId에 해당하는 계좌가 없으면 아무일도 안 일어날 것이다. 뭐 이렇게 되도 상관없을 수도 있겠다. 일관성이 깨지는 것 같은 큰 문제는 일어나지 않았으니깐, 대신 이 함수를 호출한 측에서는 diposit이 잘 됬는지 아닌지 알수가 없다. 잘 됬다고 생각하고 다음 단계를 밟을 수도 있는데 그렇게 되면 문제가 된다. 따라서 이 경우에는 리턴 값으로 문제 상황을 알리거나 예외를 전파해야하는데, 예외로 전파하는것을 추천한다. 나는 command 행동에서 문제가 생기면 예외를 던지고, query행동에서문제가 생기면 null (일반적인 상황일때) 혹은 예외(진짜 예외상황일때)를 전파하는 컨벤션을 갖는다.

@Throws(NoAccountException::class)
fun createToken(accountId: String, token: Token){
    accounts.get(accountId)?.createToken(token) ?: throw NoAccountException("there is no $accountId in our bank")
}

이렇게 예외를 던저주고 함수에 명시해 둔다. (호출하는 클라이언트에 대한 배려) 

@Test
fun dipositToken() {
    Assertions.assertThrows(NoAccountException::class.java, {
        bank.createToken("2", exception.Token("token_1", "opusm", 1000))
    })
}

Junit5를 통해서 테스트를 성공한다. 

 

4. Result 

1.5 버전 부터 Result<T> 을 사용 할 수 있다. 함수안에서 일어나는 일의 결과를 성공,실패로 단순히 알 수있는 Boolean과 다른점은 성공 했을 경우의 타입과 실패했을 경우의 예외 타입 및 메세지를 포함 시켜서 보다 풍부한 결과를 알고 싶을 때 사용한다. Result<T>구현은 유틸리티성 메소드가 굉장히 많이 포함되어 있다. 5번의 runCatching에서 더 설명한다.

import java.lang.IllegalArgumentException


data class Passport (val name: String, val age: Int)
data class EntranceData(val purposeCode: Int, val jobCode : Int, val passport: Passport)

data class PassportException(override val message: String?) : IllegalArgumentException(message)
data class JobException(override val message: String?) : IllegalArgumentException(message)


enum class Nation(val exportValid : List<ExportCheck>, val importValid : List<ImportCheck>) {
    KOREA(listOf(ExportCheck.Passport, ExportCheck.Job),listOf(ImportCheck.Passport) ),
    JAPAN(listOf(ExportCheck.Passport),listOf(ImportCheck.Passport)),
    USA(listOf(ExportCheck.Passport),listOf(ImportCheck.Passport));

    enum class ExportCheck {
        Passport {
            override fun check(data : EntranceData) : Result<String> {
                return if(data.passport.age > 10){
                    Result.success("Export passport ok")
                }
                else {
                    Result.failure(PassportException("reason why ~~"))
                }
            }
        },
        Job {
            override fun check(data : EntranceData) : Result<String>{
                return if(data.jobCode == 1){
                    Result.success("Export Job ok")
                }
                else {
                    Result.failure(JobException("reason why ~~"))
                }
            }
        };

        abstract fun check(data : EntranceData): Result<String>
    }

    enum class ImportCheck {
        Passport {
            override fun check(data : EntranceData) : Result<String>{
                return if(data.passport.age > 10){
                    Result.success("Export passport ok")
                }
                else {
                    Result.failure(PassportException("reason why ~~"))
                }
            }
        },
        Job {
            override fun check(data : EntranceData) : Result<String>{
                return if(data.jobCode > 1){
                    Result.success("Export Job ok")
                }
                else {
                    Result.failure(JobException("reason why ~~"))
                }
            }
        };

        abstract fun check(data : EntranceData): Result<String>
    }

    companion object {
        fun getNumberOfNations() = values().size
    }
}

fun tradeTest(entranceData: EntranceData, nation: Nation): List<Result<String>> {
  return nation.exportValid.map{
       it.check)
   }
}

fun main(args: Array<String>) {

    val korea = Nation.KOREA;
    val data = EntranceData(1,1, Passport("john",3))

    val results = tradeTest(data,korea)

    results.forEach { result ->
        result.onSuccess {
            println(it)
        }
        .onFailure {
            println(it.message)
        }
    }

}

5. runCatching 

runCatching은 Result<T>를 사용하는데 도움이 될 수 있다.

account)

fun withrawToken(id: String, balance: Int): Int {
    val token = assets.find { it -> it.id == id  } as Token
    require(token.balance > balance )
    token.balance = token.balance - balance
    return balance
}

bank)

fun withrawToken(accountId: String, tokenId: String, balance: Int): Int{
    val result = kotlin.runCatching {
        accounts.get(accountId)?.withrawToken(tokenId, balance)
    }

    return result.getOrDefault(0)!!
}

test)

@Test
fun withrawToken() {
    bank.createToken("1", exception.Token("token_1", "opusm", 1000))
    val result = bank.withrawToken("1", "token_1", 5000)
    Assertions.assertEquals(0, result)
}


아래와 같이 runCatching 문은 좀 더 다양한 방식으로 처리 될 수 있다. 

fun withrawToken(accountId: String, tokenId: String, balance: Int):String{
    return  kotlin.runCatching { accounts.get(accountId)?.withrawToken(tokenId, balance)}
    .onFailure { println(" withraw token failure") } // 특정 예외를 구분 할 수도 있다.
    .onSuccess { println(" withraw token success") }
    .mapCatching { it -> it.toString() }
    .getOrDefault("0") // getOrNull, getOrThrows
}

/////// 아래와 같이 특정 예외별로 구분해서 처리 할 수도 있다.
getOrElse {
    when(it) {
        is LowBalanceException -> "LowBalanceException"
        is IllegalStateException -> "IllegalStateException"
        is NullPointerException -> "null"
        else -> throw it
    }
}

 

전체 코드)

package exception

import exception.bankexception.LowBalanceException
import exception.bankexception.NoAccountException
import kotlin.jvm.Throws


interface Transfer {
    fun moveTo(id : String)
}

interface Asset{
    val id: String
    val name: String
}

enum class AssetKind{
    Token,
    NFT
}

data class Token(
    override val id: String,
    override val name: String,
    var balance: Int) : Asset,Transfer{
    override fun moveTo(id: String) {
        TODO("Not yet implemented")
    }

}
data class NFT(
    override val id: String,
    override val name: String) : Asset,Transfer {
    override fun moveTo(id: String) {
        TODO("Not yet implemented")
    }

}

data class Account(val id: String, val name: String?): Transfer {
    val assets = mutableListOf<Asset>()
    override fun moveTo(id: String) {
        TODO("Not yet implemented")
    }

    fun createToken(token: Token) {
        assets.add(token)
    }

    fun withrawToken(id: String, balance: Int): Int {
        val token = assets.find { it -> it.id == id  } as Token
        require(token.balance > balance )
        token.balance = token.balance - balance
        return balance
    }
}

class Bank{
    val accounts = mutableMapOf<String, Account>()
    fun createAccount(account: Account){
        accounts.put(account.id, account)
    }
    fun getAccount(id: String): Account? {
        return accounts.get(id)
    }

    @Throws(NoAccountException::class)
    fun createToken(accountId: String, token: Token){
        accounts.get(accountId)?.createToken(token) ?: throw NoAccountException("there is no $accountId in our bank")
    }

    fun withrawToken(accountId: String, tokenId: String, balance: Int):Int{
        return  kotlin.runCatching { accounts.get(accountId)?.withrawToken(tokenId, balance)}
        .onFailure { println(" withraw token failure") }
        .onSuccess { println(" withraw token success") }
        .getOrDefault(0)!!
    }
}

 




자바와 체크 예외 

- 어떤 예외에 대해 명시적으로 메서드 시그니처에 포함시켜서 좀 더 처리를 강제한다.
-
체크드예외는 클래스 못찾는거, 파일 못찾는거 IO예외, SQL예외 같은거...즉 언제든지 자신의 탓이 아닌데도 문제가 발생할 수 있고, 메모리가 없는것처럼 아예손놓고 있지 않아도 될 만한것
- 오류Error는 메모리나 HDD가 없는 것 처럼 멀 할 수 없는 경우를 말한다. 그냥 끝내는게 최선
-
런타임예외는 Error긴 한데 좀 다른 유행이다. 근데 런타임예외는 체크드예외인 Exception 아래에 있다.의도는 개발자의 실수에 의한 예외를 말한다. 널문제, 잘못된인자나 프로그램 상태 같은거. 즉 개발자의 탓이고 멍청하게 프로그램을 사용했으니 그냥 연산 중단 되버려지만 Error처럼 무조건 끝내지 말고복구를 시도 해 볼 순 있다 정도
-
문자열에서 데이터추출시 URL은 체크예외인 MalformedURLExcetion 던지지만
  Integer.parseInt
는 언체크예외인 NumberFormatException 던짐. ???
-
자바 8 람다나 함수형 인터페이스는 체크예외를 전파할 수 없게 됨. 개발자들 체크예외 사용 포기
-
그냥 모두 RuntimeException으로 다루자. (코틀린)

자바의 Optional

1. 자바 8이전에는 예외를 던지거나 null을 반환하는 것
2. 
두 방법 모두 허점이 있는데 예외는 진짜 예외적인 상황(?)에서 사용해야 하며, 예외를 생성할 때 스택트레이싱 전체를 캡쳐하므로 비용도 많이 든다. null을 반환하면 이런 문제는 없지만 그것을 처리 안하면 원래 오류가 아니라 그냥 어디선가 갑자기 NullPointerException이 튀어 나올 수 있다.
3. Optional
은 예외를 반드시 사용할 필요 없는 곳에 null대신 사용하기 좋다.
4. illegalArgumentException
을 던지는 곳에 Optional을 던지는게 더 나은 경우가 많다. Optional은 검사를 강제하므로 검사예외와도 비슷한데 더 간단하고 함수형 처럼 체이닝 하기도 좋다.
6. 
다만 컬렉션,스트림,배열,옵셔널 같은 컨테이너 타입은 옵셔널로 감싸면 안된다.
7. 
물론 그냥 null을 던지는게 성능상은 더 유리하다.