Exceptions - Kotlin 공식문서 파헤치기

동키·2025년 3월 10일

Kotlin

목록 보기
3/10
post-thumbnail

Exceptions

예외는 프로그램 실행 중 발생할 수 있는 오류를 처리하여 프로그램이 예기치 안하게 종료되는 것을 방지합니다.
즉, 예외 처리를 통해 오류 발생 시 프로그램의 흐름을 제어할 수 있으므로, 코드가 더욱 예측 가능해지고 안정적으로 동작할 수 있습니다.

예외 처리의 두 가지 주요 작업

Throwing exceptions(예외 던지기)

프로그램 실행 도중 문제가 발생하면, 해당 상황을 알리기 위해 예외를 던집니다.
throw Exception("예외 발생")와 같이 예외를 던집니다.

Catching exceptions(예외 잡기)

위에서 던진 예외를 포착하여 적절히 처리합니다.

try {
    // 문제가 발생할 수 있는 코드
} catch (e: Exception) {
    // 예외 처리 코드: 로그 출력, 사용자에게 알림, 대체 로직 실행 등
}

Exception hierarchy

예외의 계층 구조에 대해 알아보겠습니다.
Throwable

Throwable(최상위 클래스)

Kotlin 예외 계층의 최상위 클래스 입니다. 모든 예외와 오류는 이 클래스를 상속받습니다.
ErrorException 을 하위 클래스로 가집니다.

Error

애플리케이션이 자체적으로 복구하기 어려운 심각한 문제를 나타냅니다.
예를 들어, OutOfMemoryError , StackOverflowError 같은 오류는 시스템 자원 고갈이나 재귀 호출의 과다 등으로 발생하며, 일반적으로 개발자가 직접 처리하지 않습니다.

즉 메모리 고갈이나 재귀 호출의 폭주 같은 문제로, 애플리케이션이 불안정한 상태에 빠지게 됩니다.

Exception

애플리케이션에서 처리할 수 있는 상태를 나타냅니다.
예외 상황에 대해 개발자가 적절한 조치를 취할 수 있도록 설계되어 있습니다.

Exception 클래스의 하위 클래스에는 다양한 예외(RuntimeException, IOException)들이 포함됩니다.

이번에는 Exception의 하위의 주요 서브타입들에 대해 알아보겠습니다.

RuntimeException

RuntimeException

expect open class RuntimeException : Exception

프로그램의 로직 오류나 충분한 검사 부족으로 인해 발생하는 예외들을 의미합니다.

  • ArithmeticException: 0으로 나누는 연산 등 산술 계산 오류

  • IndexOutOfBoundsException: 배열, 리스트와 같이 index로 접근하는 자료구조에서 잘못된 index 접근 참조

  • NumberFormatException: 문자열을 숫자로 변환하려고 할 때, 그 문자열의 형식이 올바르지 않아 발생하는 예외

RuntimeException을 상속받은 더 많은 타입들은 RuntimeException을 확인하실 수 있습니다.

IOException

expect open class IOException : Exception

입력 / 출력 작업 중에 발생하는 예외를 다룹니다.
파일 읽기 / 쓰기, 넽워크 통신 등에서 발생할 수 있는 예외 상황을 나타냅니다.
이 경우 예외 처리를 통해 적절한 오류 메시지를 표시하거나 복구 로직을 구현할 수 있습니다.

IOException에 대한 자세한 내용은 IOException에서 확인 하실 수 있습니다.


Throw exceptions(예외 던지기)

kotlin에서는 throw 키워드를 사용하여 예외를 더질 수 있습니다.
예외는 객체로 취급되며, 예외를 던지다는 것은 코드 싫애 중 오류가 발생했음을 나타냅니다.

예외를 던질 때, 문제의 원인을 파악할 수 있도록 메시지를 포함할 수 있습니다.

val cause = IllegalStateException("Original cause: illegal state")
if (userInput < 0) {
    throw IllegalArgumentException("Input must be non-negative", cause)
}

위 코드는 userInput이 음수일 때 IllegalArgumentException 에러를 던집니다.


exception with precondition

kotlin은 특정 조건을 자동으로 검사하고, 조건이 만족되지 않을 때 적절한 예외를 던지는 precondition 함수를 제공합니다.
이는 코드의 가독성을 높이고 불필요한 if문을 줄여줍니다.

세 에러 모두 RuntimeError를 상속받습니다!

require()

함수의 인자나 입력값의 유효성을 검사할 때 사용합니다.
만약 조건이 만족되지 않으면 IllegalArgumentException를 던집니다.

fun getIndices(count: Int): List<Int> {
	require(count >= 0) {"Count must be non - nagative. You set count to $count"}
    return List(count) {it + 1}
}

fun main() {
	print(getIndices(-1))
 }

변수에 대해 스마트 캐스팅이 이루어져 null 체크 후 non-nullable 타입으로 안전하게 사용도 가능합니다.

fun printNonNullString(str: String?) {
    // null 체크
    require(str != null)
    // null 이 아닐 시
    println(str.length)
}

check()

객체나 변수의 상태(state)가 올바른지 검사할 때 사용합니다.
조건이 false일 경우 IllegalStateException을 던집니다.

fun getStateValue(): String {
	val state = checkNotNull(someState) { "State must be set beforehand!" }
    check(state.isNotEmpty()) {"State must be non-empty"}
    return state
}

fun main() {
	var someState: String? = null
    someState = "non-empty-state"
    print(getStateValue())
}

check또한 위와 마찬가지로 null 체크에 사용될 수 있습니다.

fun printNonNullString(str: String?) {
    check(str != null)
    println(str.length)
}

error()

코드 실행 중 논리적으로 도달해서는 안 되는 상황이 발생했을 때, 명시적으로 예외를 던지기 위해 사용합니다.
주로 when 구문과 같이 모든 경우를 처리했어야 하는 곳에서, 처리되지 않은 경우를 잡아내기 위해 사용됩니다.
IllegalStateException를 던집니다.

class User(val name: String, val role: String)

fun processUserRole(user: User) {
	when(user.role) {
    	"admin" -> println("${user.name} is an admin.")
        "editor" -> println("${user.name} is an editor.")
        "viewer" -> println("${user.name} is a viewer.")
         else -> error("Undefined role: ${user.role}")
    }
}


fun main() {
    val user1 = User("Alice", "admin")
    processUserRole(user1)
    // Alice is an admin.

    // This throws an IllegalStateException
    val user2 = User("Bob", "guest")
    processUserRole(user2)
}

Handle exceptions using try-catch(예외 잡기)

위에서 열심히 에러를 던졌으니 이번에는 잡아보겠습니다.
프로그램 실행 중 예외가 발생하면 정상적인 실행 흐름이 중단됩니다.
이때 try와 catch 키워드를 사용하면, 예외 상황을 처리하여 안정성을 유지할 수 있습니다.

try{
	// 예외를 던질 수 있는 코드
} catch (e: SomeException) {
	// 예외 처리 코드
}

바로 예시를 확인해볼가요?

fun main() {
	val num: Int = try {
    	count()
    } catch (e: ArithmeticException) {
    	-1
    }
    println("Result: $sum")
}
fun count(): Int {
	val a = 0
    return 10 / a
}
// Result: -1

count() 함수가 ArithmeticException()를 발생시키면 catch 블록이 실행되어 -1이 할당됩니다.

여러 catch 핸들러 사용하기

하나의 try 블록에 대해 여러 catch 블록을 사용할 수 있습니다.
이때 예외 타입에 따라 적절한 catch 블록이 실행되도록, 구체적인 예외 타입부터 -> 일반적인 예외 타입 순서로 배치하는 것이 중요합니다.
즉, 상속 관계에서 하위 클래스(더 구체적인 예외)부터 처리하고, 이후 상위 클래스를 처리해야 합니다.

open class WithdrawalException(message: String) : Exception(message)
class InsufficientFundsException(message: String) : WithdrawalException(message)

fun processWithdrawal(amount: Double, availableFunds: Double) {
	if (amount > availableFunds) {
    	throw InsufficientFundsException("Insufficient funds for the withdrawal.")
    }
    if (amount < 1 || amount % 1 != 0.0) {
    	throw WithdrawalException("Invalid withdrawal amount.")
    }
    println("Withdrawal processed")
}

fun main() {
	val availableFunds = 500.0
    val withdrawalAmount = 500.5

    try {
        processWithdrawal(withdrawalAmount.toDouble(), availableFunds)
    } catch (e: InsufficientFundsException) {
        println("Caught an InsufficientFundsException: ${e.message}")
    } catch (e: WithdrawalException) {
        println("Caught a WithdrawalException: ${e.message}")
    }
}
// Caught an InsufficientFundsException: Insufficient funds for the withdrawal.

위 코드에서 processWithdrawal 함수는 인출 금액이 사용 가능한 자금보다 많으면 InsufficientFundsException을, 인출 금액이 유효하지 않으면 WithdrawalException을 발생시킵니다.

왜 구체적인 예외부터 잡아야 할가?

만약 위에서 catch 순서를 바꾼다면 어떤 일이 일어날가요?

 try {
        processWithdrawal(withdrawalAmount.toDouble(), availableFunds)
    } catch (e: WithdrawalException) {
        println("Caught a WithdrawalException: ${e.message}")
    }
    catch (e: InsufficientFundsException) {
        println("Caught an InsufficientFundsException: ${e.message}")
    } 
//Caught a WithdrawalException: Insufficient funds for the withdrawal.

InsufficientFundsException 예외가 잡혀야 하는데 불구하고 WithdrawalException 예외가 잡혀버렸습니다.
이렇게 예외는 구체적인 예외 -> 일반적인 예외로 잡아야 세부적인 예외 처리가 가능해집니다.


finally

finally 블록은 try 블록이 정상적으로 완료되었든, 예외가 발생했든 상관없이
항상 실행되는 코드를 포함합니다.

fun divideOrNull(a: Int): Int {
    
    try {
        val b = 44 / a
        println("try block: Executing division: $b")
        return b
    }
    
    catch (e: ArithmeticException) {
        println("catch block: Encountered ArithmeticException $e")
        return -1
    }
    finally {
        println("finally block: The finally block is always executed")
    }
}

fun main() {
        divideOrNull(0)
}
//catch block: Encountered ArithmeticException java.lang.ArithmeticException: / by zero
//finally block: The finally block is always executed

언제 finally가 활용될 수 있을가요?

try {
    resource.use()
} finally {
    resource.close()
}

println("End of the program")

예외 처리를 하지 않고 단순히 리소스를 정리할 필요가 있을 때 사용됩니다.


Custom Exception

kotlin에서는 내장 Exception 클래스를 확장하는 클래스를 정의하여 커스텀 예외를 만들 수 있습니다.
이렇게 하면 Application의 요구사항에 맞는 보다 구체적인 에러 타입을 생성할 수 있습니다.

class MyException : Exception("My message")

메시지를 지정하지 않아도 되지만, 보통 에러 상황을 설명할 수 있도록 메시지를 포함하는 것이 좋습니다.

예외는 상태를 가지는 객체(stateful object)로, 생성 당시의 stack trace 등 구체적인 정보를 포함

그러므로 객체 선언을 사용하여 예외를 생성하지 말고, 필요할 때마다 새 인스턴스를 생성해야 합니다.

기존의 예외 클래스를 상속바다 커스팀 예외를 만들 수도 있습니다.

class NumberTooLargeException : ArithmeticException("My message")

커스텀 예외를 상속받을 수 있도록 만들기

kotlin의 클래스는 기본적으로 final이기 때문에, 커스텀 예외 클래스를 상속하려면 open으로 선언해야 합니다.

open class MyCustomException(message: String) : Exception(message)
class SpecificCustomException : MyCustomException("Specific error message")

class NegativeNumberException : Exception("Parameter is less than zero.")
class NonNegativeNumberException : Exception("Parameter is a non-negative number.")

fun myFunction(number: Int) {
    if (number < 0) throw NegativeNumberException()
    else if (number >= 0) throw NonNegativeNumberException()
}

fun main() {
    // 매개변수 값에 따라 다른 예외가 발생합니다.
    myFunction(1)
}

예외 계층 구조 설계

복잡한 애플리케이션에서는 다양한 에러 상황을 처리하기 위해 예외 계층 구조를 만드는 것이 좋습니다.
이를 통해 코드가 더 명확해지고, 각 예외에 대해 세부적인 처리를 할 수 있습니다.

계층 구조를 만들기 위해 추상 클래스(abstract class)나 sealed 클래스를 기반으로 공통 기능을 가진 예외 베이스 클래스를 만들 수 있습니다.

// 계정 관련 에러의 기본 베이스 클래스로 sealed 클래스 사용
sealed class AccountException(message: String, cause: Throwable? = null) : Exception(message, cause)

// 구체적인 예외 클래스 1: 잘못된 계정 자격 증명
class InvalidAccountCredentialsException : AccountException("Invalid account credentials detected")

// 구체적인 예외 클래스 2: API 키 만료 예외 (옵션 매개변수 사용 가능)
class APIKeyExpiredException(
	message: String = "API key expired",
    cause: Throwable? = null
) : AccountException(message, cause)

AccountException을 기본으로 하여 계정 관련 오류를 구분하기 위해 하위 예외들을 정의했습니다.
옵션 매개변수를 활용하면, 예외 발생 시 추가적인 메시지나 원인을 전달할 수 있어 보다 세밀한 오류 처리가 가능합니다.

Nothing 타입

kotlin에서는 모든 표현식이 타입을 갖습니다. throw IllegalArgumentException() 표현식의 타입은 Nothing입니다.

Nothing은 모든 다른 타입의 서브타입인 내장 타입으로, 바텀(bottom)타입이라고도 불립니다.
이는 Nothing을 반환 타입이나 제네릭 타입으로 사용할 수 있으며, 다른 타입이 기대되는 곳에서도 타입 오류를 발생 시키지 않는다는 의미입니다.

Nothing은 함수나 표현식이 정상적으로 완료되지 않는 상황을 나타내기 위해 사용됩니다.
아직 구현되지 않은 함수나 항상 예외를 던지도록 설계된 함수임을 명시적으로 표시할 수 있습니다.

컴파일러와 코드를 읽는 이에게 의도를 명확하게 전달할 수 있습니다.

만약 컴파일러가 함수 시그니처에서 Nothing 타입을 추론하면 경고를 발생시키는데, 반환 타입을 명시적으로 Nothing으로 정의하면 이러한 경고를 제거할 수 있습니다.

class Person(val name: String?)

fun fail(message: String): Nothing {
    throw IllegalArgumentException(message)
    // 이 함수는 절대로 정상적으로 반환되지 않습니다.
    // 항상 예외를 던집니다.
}

fun main() {
    val person = Person(name = null)
    // 만약 person.name이 null이라면, fail("Name required") 함수가 호출되어 예외를 던집니다.
    // 그렇지 않으면, person.name의 값이 s에 할당됩니다.
    val s: String = person.name ?: fail("Name required")
    println(s)
}

공통 예외 클래스

kotlin의 예외들은 모두 RuntimeException 클래스를 상속받습니다.
몇 가지 대표적인 예를 보겠습니다.

ArithmeticException

산술 연산이 불가능할 때 발생(0으로 나누는 경우)

IndexOutOfBoundsException

배열, 리스트, 문자열 등의 인덱스가 범위를 벗어날 때 발생

val myList = mutableListOf(1, 2, 3)
myList.removeAt(3)  // IndexOutOfBoundsException 발생

// 안전한 대안
val myList = listOf(1, 2, 3)
// IndexOutOfBoundsException 대신 null 반환
val element = myList.getOrNull(3)
println("Element at index 3: $element")

NoSuchElementException

컬렉션에 존재하지 않는 요소에 접근할 때 발생

val emptyList = listOf<Int>()
val firstElement = emptyList.first()  // NoSuchElementException 발생

// 안전한 대안
val emptyList = listOf<Int>()
// NoSuchElementException 대신 null 반환
val firstElement = emptyList.firstOrNull()
println("First element in empty list: $firstElement")

NumberFormatException

문자열을 숫자형으로 변환하려고 할 때, 문자열의 형식이 올바르지 않으면 발생합니다.

val string = "This is not a number"
val number = string.toInt() // NumberFormatException 발생

// 안전한 대안
val nonNumericString = "not a number"
// NumberFormatException 대신 null 반환
val number = nonNumericString.toIntOrNull()
println("Converted number: $number")

NullPointerException

null 값을 가진 객체 참조를 사용하려 할 때 발생합니다.
Kotlin의 null 안전 기능이 있음에도 불구하고, !! 연산자를 사용하거나 Java와 상호작용할 때 발생 가능

val text: String? = null
println(text!!.length)  // NullPointerException 발생
profile
오늘 하루도 화이팅

0개의 댓글