[Kotlin 세미나] 실패/예외 대응 방식
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을 코딩 할 순 없다.
근데 위의 코드는 result에 null이 담길 수 있으므로 위험 할 수 있다. ?: 엘비스 연산자로 디폴트값을 넣거나 예외를 전파하거나 무엇인가 해야한다. 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을 던지는게 성능상은 더 유리하다.