
예외는 프로그램 실행 중 발생할 수 있는 오류를 처리하여 프로그램이 예기치 안하게 종료되는 것을 방지합니다.
즉, 예외 처리를 통해 오류 발생 시 프로그램의 흐름을 제어할 수 있으므로, 코드가 더욱 예측 가능해지고 안정적으로 동작할 수 있습니다.
프로그램 실행 도중 문제가 발생하면, 해당 상황을 알리기 위해 예외를 던집니다.
throw Exception("예외 발생")와 같이 예외를 던집니다.
위에서 던진 예외를 포착하여 적절히 처리합니다.
try {
// 문제가 발생할 수 있는 코드
} catch (e: Exception) {
// 예외 처리 코드: 로그 출력, 사용자에게 알림, 대체 로직 실행 등
}
예외의 계층 구조에 대해 알아보겠습니다.

Kotlin 예외 계층의 최상위 클래스 입니다. 모든 예외와 오류는 이 클래스를 상속받습니다.
Error 와 Exception 을 하위 클래스로 가집니다.
애플리케이션이 자체적으로 복구하기 어려운 심각한 문제를 나타냅니다.
예를 들어, OutOfMemoryError , StackOverflowError 같은 오류는 시스템 자원 고갈이나 재귀 호출의 과다 등으로 발생하며, 일반적으로 개발자가 직접 처리하지 않습니다.
즉 메모리 고갈이나 재귀 호출의 폭주 같은 문제로, 애플리케이션이 불안정한 상태에 빠지게 됩니다.
애플리케이션에서 처리할 수 있는 상태를 나타냅니다.
예외 상황에 대해 개발자가 적절한 조치를 취할 수 있도록 설계되어 있습니다.
Exception 클래스의 하위 클래스에는 다양한 예외(RuntimeException, IOException)들이 포함됩니다.
이번에는 Exception의 하위의 주요 서브타입들에 대해 알아보겠습니다.

expect open class RuntimeException : Exception
프로그램의 로직 오류나 충분한 검사 부족으로 인해 발생하는 예외들을 의미합니다.
ArithmeticException: 0으로 나누는 연산 등 산술 계산 오류
IndexOutOfBoundsException: 배열, 리스트와 같이 index로 접근하는 자료구조에서 잘못된 index 접근 참조
NumberFormatException: 문자열을 숫자로 변환하려고 할 때, 그 문자열의 형식이 올바르지 않아 발생하는 예외
RuntimeException을 상속받은 더 많은 타입들은 RuntimeException을 확인하실 수 있습니다.
expect open class IOException : Exception
입력 / 출력 작업 중에 발생하는 예외를 다룹니다.
파일 읽기 / 쓰기, 넽워크 통신 등에서 발생할 수 있는 예외 상황을 나타냅니다.
이 경우 예외 처리를 통해 적절한 오류 메시지를 표시하거나 복구 로직을 구현할 수 있습니다.
IOException에 대한 자세한 내용은 IOException에서 확인 하실 수 있습니다.
kotlin에서는 throw 키워드를 사용하여 예외를 더질 수 있습니다.
예외는 객체로 취급되며, 예외를 던지다는 것은 코드 싫애 중 오류가 발생했음을 나타냅니다.
예외를 던질 때, 문제의 원인을 파악할 수 있도록 메시지를 포함할 수 있습니다.
val cause = IllegalStateException("Original cause: illegal state")
if (userInput < 0) {
throw IllegalArgumentException("Input must be non-negative", cause)
}
위 코드는 userInput이 음수일 때 IllegalArgumentException 에러를 던집니다.
kotlin은 특정 조건을 자동으로 검사하고, 조건이 만족되지 않을 때 적절한 예외를 던지는 precondition 함수를 제공합니다.
이는 코드의 가독성을 높이고 불필요한 if문을 줄여줍니다.

세 에러 모두 RuntimeError를 상속받습니다!
함수의 인자나 입력값의 유효성을 검사할 때 사용합니다.
만약 조건이 만족되지 않으면 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)
}
객체나 변수의 상태(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)
}
코드 실행 중 논리적으로 도달해서는 안 되는 상황이 발생했을 때, 명시적으로 예외를 던지기 위해 사용합니다.
주로 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)
}
위에서 열심히 에러를 던졌으니 이번에는 잡아보겠습니다.
프로그램 실행 중 예외가 발생하면 정상적인 실행 흐름이 중단됩니다.
이때 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이 할당됩니다.
하나의 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 블록은 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")
예외 처리를 하지 않고 단순히 리소스를 정리할 필요가 있을 때 사용됩니다.
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을 기본으로 하여 계정 관련 오류를 구분하기 위해 하위 예외들을 정의했습니다.
옵션 매개변수를 활용하면, 예외 발생 시 추가적인 메시지나 원인을 전달할 수 있어 보다 세밀한 오류 처리가 가능합니다.
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 클래스를 상속받습니다.
몇 가지 대표적인 예를 보겠습니다.
산술 연산이 불가능할 때 발생(0으로 나누는 경우)
배열, 리스트, 문자열 등의 인덱스가 범위를 벗어날 때 발생
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")
컬렉션에 존재하지 않는 요소에 접근할 때 발생
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")
문자열을 숫자형으로 변환하려고 할 때, 문자열의 형식이 올바르지 않으면 발생합니다.
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")
null 값을 가진 객체 참조를 사용하려 할 때 발생합니다.
Kotlin의 null 안전 기능이 있음에도 불구하고, !! 연산자를 사용하거나 Java와 상호작용할 때 발생 가능
val text: String? = null
println(text!!.length) // NullPointerException 발생