코틀린 베이직 KB 20 예외 — 체크 예외가 없다
코틀린 베이직 · 20/26

KB 20 예외 — 체크 예외가 없다

gabury1고친 사람 github-actions[bot]

0. 자바는 컴파일을 막고, 코틀린은 그냥 돌린다

파일 하나를 읽어 찍는 세 줄입니다. 자바 개발자라면 이 코드를 보자마자 손이 먼저 try 를 치려고 할 겁니다. 그 try 를 빼고 코틀린으로 써 봤습니다.

Kotlin
import java.nio.file.Files
import java.nio.file.Path

fun main() {
    val text = Files.readString(Path.of("hello.txt"))
    println(text)                  // hello
}
  • fun main() — 프로그램이 시작하는 함수입니다. 클래스 없이 파일 바로 아래에 둡니다
  • val text = ... — 다시 대입할 수 없는 변수입니다. 타입을 안 적으면 오른쪽 값에서 알아냅니다
  • Files.readString — 자바 표준 라이브러리의 메서드를 코틀린에서 그대로 부른 것입니다. 파일 내용을 문자열 하나로 읽습니다

hello.txt 에는 hello 다섯 글자만 넣어 뒀습니다. First.kt 로 저장해 코틀린 컴파일러 kotlinc 로 컴파일하고 kotlin 명령으로 돌렸습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션이고, 실행할 때의 -cp out 은 클래스를 찾을 경로 목록(classpath)에 out 을 넣으라는 옵션입니다.

터미널
$ kotlinc First.kt -d out
$ kotlin -cp out FirstKt

경고 하나 없이 컴파일됐고 hello 를 찍었습니다. FirstKt 는 컴파일러가 지은 클래스 이름입니다. 파일 바로 아래 쓴 함수를 톱레벨 함수라고 부르는데, 이런 함수는 파일 이름 + Kt 클래스의 static 메서드로 들어갑니다.

똑같은 세 줄을 자바로 옮겼습니다.

Java
import java.nio.file.Files;
import java.nio.file.Path;

public class First {
    public static void main(String[] args) {
        String text = Files.readString(Path.of("hello.txt"));
        System.out.println(text);
    }
}
터미널
$ javac -d jout First.java
First.java:6: error: unreported exception IOException; must be caught or declared to be thrown
        String text = Files.readString(Path.of("hello.txt"));
                                      ^
1 error

javac 의 메시지는 영어로 받으려고 -J-Duser.language=en 을 붙여 돌렸고, 이 편의 명령 줄에서는 그 옵션을 뺐습니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.

에러는 「IOException 을 잡거나(catch) 던진다고 선언하라」는 뜻입니다. Files.readString 이 선언부에 throws IOException 을 달고 있어서, 자바는 부르는 쪽이 이 예외를 처리할 계획을 세웠는지 컴파일할 때 따집니다. 이렇게 컴파일러가 처리를 강제하는 예외를 체크 예외(checked exception) 라고 부릅니다.

코틀린에는 이 강제가 없습니다. 그럼 파일이 없으면 어떻게 될까요. 파일 이름만 nope.txt 로 바꾼 Missing.kt 를 돌렸습니다.

터미널
$ kotlinc Missing.kt -d out
$ kotlin -cp out MissingKt
Exception in thread "main" java.nio.file.NoSuchFileException: nope.txt
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
...
	at MissingKt.main(Missing.kt:5)
	at MissingKt.main(Missing.kt)

컴파일은 역시 통과했고, 실행에서 NoSuchFileException 이 터졌습니다. IOException 의 하위 클래스입니다. 가운데 호출 경로 여덟 줄은 ... 로 줄였습니다.

예외가 사라진 게 아니라 컴파일러의 검사만 사라졌습니다. 코틀린 공식 문서도 모든 예외를 언체크 예외로 다룬다고 적습니다(코틀린 문서 「Exceptions」). 언체크 예외는 컴파일러가 처리를 강제하지 않는 예외로, 자바의 RuntimeException 계열이 여기 듭니다.

이 편은 그 차이에서 나오는 일을 차례로 봅니다. 1절은 던지고 잡는 문법, 2절은 try 를 값으로 쓰는 법, 3절은 자원을 닫는 use 입니다. 4절은 자바가 코틀린 함수를 부를 때 생기는 구멍, 5절은 검사를 한 줄로 쓰는 함수, 6절은 예외의 비용입니다.

1. throws 가 없다 — 던지고 잡는 문법은 자바와 같다

체크 예외가 없으니 함수가 「이 예외를 던진다」고 선언할 필요도 없습니다. 이 절에서는 자바 버릇대로 throws 를 써서 컴파일러 반응을 보고, 던지고 잡는 코드를 돌려 봅니다.

throws 를 쓰면 문법 오류다

Kotlin
import java.io.IOException
// ↓ 컴파일 오류
fun load() throws IOException {
}
터미널
$ kotlinc Throws.kt -d out
Throws.kt:3:1: error: function 'load' must have a body.
fun load() throws IOException {
^^^^^^^^^^
Throws.kt:3:12: error: syntax error: Expecting a top level declaration.
fun load() throws IOException {
           ^^^^^^
...

같은 줄에 에러가 다섯 개 났는데 앞의 둘만 옮겼습니다. 컴파일러는 throws 를 뜻 있는 낱말로 읽지 못합니다. fun load() 에서 선언이 끝난 것으로 보고, 뒤에 남은 throws 를 문법에 없는 글자로 처리했습니다. 코틀린 문법에는 throws 절이 아예 없습니다.

던지고 잡기

throws 가 없을 뿐, 예외를 던지고 잡는 문법은 자바와 거의 같습니다. Throw.kt 입니다.

Kotlin
import java.io.IOException

fun load(name: String): String {
    if (name.isEmpty()) {
        throw IOException("empty name")
    }
    return "data of $name"
}

fun main() {
    try {
        println(load("a.txt"))
        println(load(""))
    } catch (e: IOException) {
        println("caught: ${e.message}")
    } finally {
        println("finally")
    }
}
  • fun load(name: String): String — 인자는 이름 뒤에 콜론, 그 뒤에 타입을 쓰고, 반환 타입은 괄호 뒤 콜론에 씁니다
  • throw IOException("empty name") — 앞에 new 가 없습니다. 코틀린은 객체를 만들 때 new 를 쓰지 않습니다
  • catch (e: IOException) — 변수 이름이 먼저, 타입이 뒤입니다. 자바의 catch (IOException e) 와 순서가 반대입니다
  • "$name"·"${e.message}" — 문자열 템플릿입니다. 문자열 안에 값을 끼웁니다. e.message 는 자바의 e.getMessage() 입니다

찍히는 순서를 보려고 출력은 코드 바로 아래 둡니다.

터미널
$ kotlinc Throw.kt -d out
$ kotlin -cp out ThrowKt
data of a.txt
caught: empty name
finally

첫 호출은 값을 찍었고, 둘째 호출이 던진 예외는 catch 가 받았고, finally 는 마지막에 돌았습니다. IOException 을 던지는 함수인데 선언부에 아무 표시가 없고, 그래도 잡는 쪽은 아무 문제 없이 잡습니다.

여러 예외를 한 catch 로 — | 가 없다

자바 7 부터 쓰던 catch (A | B e) 는 코틀린에 없습니다. 써 보면 첫 에러가 Multi.kt:4:39: error: syntax error: Expecting ')'. 입니다. | 를 만나자 괄호가 닫혀야 한다고 본 것입니다.

대신 넓은 타입으로 받고 when 으로 가릅니다. when 은 자바의 switch 와 비슷한 분기 문법이고, is 는 자바의 instanceof 입니다.

Kotlin
try {
    println(risky(n))
} catch (e: Exception) {
    when (e) {
        is IllegalArgumentException,
        is IllegalStateException -> println("bad: ${e.message}")
        else -> throw e
    }
}

risky 는 0 이면 IllegalArgumentException, 1 이면 IllegalStateException 을 던지고 그 밖에는 받은 수를 돌려주는 함수입니다. n 을 0, 1, 2 로 돌렸더니 bad: zero·bad: one·2 를 찍었습니다. 둘을 한 가지로 묶었고, 나머지 예외는 else -> throw e 로 다시 던집니다.

정리하면, 코틀린에서 바뀐 것은 선언뿐입니다. throw·try·catch·finally 는 자바와 같게 쓰고, throws 절과 | 로 묶는 catch 만 없습니다.

2. try 를 식으로 쓰기

코틀린의 try 는 값을 돌려줍니다. 앞 편 KB 08 문과 식 — if 와 try 가 값을 돌려준다 에서 본 내용을 한 줄로 줄이면, try 블록이나 catch 블록의 마지막 줄이 try 전체의 값이 됩니다. 이 절은 그 성질을 예외 처리에 어떻게 쓰는지만 봅니다.

설정값으로 받은 문자열을 포트 번호로 바꾸되, 숫자가 아니면 기본값 8080 을 쓰는 함수입니다.

Kotlin
fun port(s: String): Int =
    try {
        s.toInt()
    } catch (e: NumberFormatException) {
        8080
    }

fun main() {
    println(port("9000"))   // 9000
    println(port("abc"))    // 8080
    val p = "abc".toIntOrNull() ?: 8080
    println(p)              // 8080
}
  • fun port(s: String): Int = try { ... } — 본문 중괄호 대신 = 뒤에 식 하나를 두는 함수입니다. 그 식의 값이 반환값입니다
  • s.toInt() — 문자열을 정수로 바꿉니다. 숫자가 아니면 자바의 Integer.parseInt 와 같은 NumberFormatException 을 던집니다
  • toIntOrNull() — 숫자가 아니면 예외 대신 null 을 돌려줍니다
  • ?: — 엘비스 연산자입니다. 왼쪽이 null 이면 오른쪽 값을 씁니다

TryExpr.kt 로 돌렸습니다.

터미널
$ kotlinc TryExpr.kt -d out
$ kotlin -cp out TryExprKt

자바라면 int port; 를 먼저 선언하고 try 와 catch 안에서 각각 대입해야 했습니다. 코틀린은 try 전체가 값이라 대입할 곳이 하나뿐입니다.

같은 결과를 내는 줄이 하나 더 있습니다. 마지막의 toIntOrNull() ?: 8080 은 예외를 아예 거치지 않습니다. 둘 중 무엇을 고를지는 6절에서 비용과 함께 다시 봅니다.

정리하면, try 식은 예외가 나면 대신 쓸 값을 한곳에 적는 도구입니다. 실패를 null 로 돌려주는 함수가 따로 있으면 그쪽이 더 짧습니다.

3. use — try-with-resources 대신

파일·소켓·DB 연결처럼 다 쓰면 닫아야 하는 것을 자원이라고 부릅니다. 자바는 try (Res a = ...) 꼴의 try-with-resources 로 닫기를 맡깁니다. 코틀린에는 그 문법이 없고, 표준 라이브러리 함수 use 가 같은 일을 합니다.

이 절에서는 열고 닫을 때 한 줄씩 찍는 가짜 자원을 만들어, 두 개를 겹쳐 열었을 때 닫히는 순서를 봅니다.

Kotlin
class Res(val name: String) : AutoCloseable {
    init { println("open $name") }
    override fun close() = println("close $name")
}

fun main() {
    Res("a").use { a ->
        Res("b").use { b ->
            println("work ${a.name}${b.name}")
        }
    }
}
  • class Res(val name: String) — 괄호 안이 생성자 인자이자 프로퍼티입니다. name 이 필드와 게터가 됩니다
  • : AutoCloseable — 자바의 implements AutoCloseable 입니다. use 는 이 인터페이스를 구현한 객체에만 붙습니다
  • init { ... } — 객체가 만들어질 때 도는 블록입니다. 여기서는 열렸다는 표시를 찍습니다
  • .use { a -> ... } — 중괄호는 람다(이름 없는 함수를 값으로 넘긴 것)이고, a 는 그 람다가 받는 인자, 곧 자원 자신입니다. 블록이 끝나면 use 가 close() 를 부릅니다

찍히는 순서가 이 절의 본론이라 출력을 바로 아래 둡니다.

터미널
$ kotlinc Use.kt -d out
$ kotlin -cp out UseKt
open a
open b
work ab
close b
close a

나중에 연 b 가 먼저 닫혔습니다. b 의 use 블록이 a 의 블록 안에 들어 있으니, 안쪽 블록이 먼저 끝나 b 부터 닫힙니다.

같은 자원을 자바 try-with-resources 로 한 줄에 둘 열었습니다.

Java
try (Res a = new Res("a"); Res b = new Res("b")) {
    System.out.println("work " + a.name + b.name);
}

Twr.java 에 Res 를 정적 중첩 클래스로 넣어 돌렸더니 다섯 줄이 코틀린과 한 글자도 다르지 않았습니다. 자바도 선언한 반대 순서로 닫습니다.

차이는 모양입니다. 자바는 괄호 하나에 자원을 나란히 적고, 코틀린은 use 를 겹쳐 씁니다. 자원이 셋이면 코틀린은 세 겹이 됩니다.

블록 안에서 예외가 나도 닫힌다

use 가 쓸모 있는 까닭은 예외가 났을 때입니다. 블록 안에서 일부러 던져 봤습니다.

Kotlin
try {
    Res("a").use {
        println("work")
        throw IllegalStateException("boom")
    }
} catch (e: IllegalStateException) {
    println("caught ${e.message}")
}

use 에 넘긴 람다에서 인자 이름을 안 적으면, 그 인자는 it 이라는 이름으로 쓸 수 있습니다. 여기서는 자원을 쓰지 않아 이름도 필요 없었습니다.

터미널
$ kotlinc UseFail.kt -d out2
$ kotlin -cp out2 UseFailKt
open a
work
close a
caught boom

close a 가 caught boom 보다 먼저 찍혔습니다. 예외가 use 를 빠져나가기 전에 close() 가 불렸고, 그다음에 바깥 catch 가 받았습니다. 자바 try-with-resources 가 해 주던 일과 같습니다.

정리하면, use 는 블록이 끝나면 정상이든 예외든 close() 를 불러 주는 함수입니다. 여러 자원은 겹쳐 쓰고, 안쪽부터 닫힙니다.

4. 자바가 코틀린 함수를 부를 때 — @Throws

코틀린끼리는 체크 예외가 없어도 불편할 일이 없습니다. 문제는 자바 쪽입니다. 자바 컴파일러는 여전히 체크 예외를 따지는데, 코틀린 함수는 무엇을 던지는지 선언하지 않습니다. 이 절에서는 IOException 을 던지는 코틀린 함수를 자바에서 불러, 두 컴파일러가 어긋나는 모습을 봅니다.

코틀린 쪽 Loader.kt 에 같은 일을 하는 함수를 둘 뒀습니다. 하나는 표시 없이, 하나는 @Throws 를 달았습니다.

Kotlin
import java.io.IOException

fun load(name: String): String {
    if (name.isEmpty()) throw IOException("empty name")
    return "data of $name"
}

@Throws(IOException::class)
fun loadChecked(name: String): String {
    if (name.isEmpty()) throw IOException("empty name")
    return "data of $name"
}
  • @Throws(...) — 코틀린 표준 라이브러리의 어노테이션입니다. 자바에서 보이는 메서드 선언에 throws 를 붙여 달라는 표시입니다
  • IOException::class — 클래스 자체를 값으로 가리키는 문법입니다. 자바의 IOException.class 에 해당합니다
  • 톱레벨 함수라 자바에서는 LoaderKt.load(...) 처럼 파일 이름 + Kt 클래스를 거쳐 부릅니다

@Throws 가 없으면 — 잡을 수가 없다

자바 개발자가 할 법한 일을 그대로 했습니다. 파일을 읽는 함수라니 IOException 을 잡습니다.

Java
try {
    System.out.println(LoaderKt.load(""));
} catch (IOException e) {
    System.out.println("caught " + e.getMessage());
}
터미널
$ kotlinc Loader.kt -d out
$ javac -cp out -d jout CatchPlain.java
CatchPlain.java:7: error: exception IOException is never thrown in body of corresponding try statement
        } catch (IOException e) {
          ^
1 error

「try 본문에서 IOException 이 던져질 일이 없다」는 에러입니다. javac 는 load 의 선언에 throws IOException 이 없으니 그 예외가 나올 수 없다고 판단했습니다. 자바는 던질 수 없는 체크 예외를 잡는 catch 를 에러로 봅니다.

그렇다고 try 를 지우면 어떻게 될까요. LoaderKt.load("") 한 줄만 부르는 NoCatch.java 는 경고 없이 컴파일됐습니다. 실행은 이렇습니다.

터미널
$ javac -cp out -d jout NoCatch.java
$ java -cp out:jout:kotlin-stdlib.jar NoCatch
Exception in thread "main" java.io.IOException: empty name
	at LoaderKt.load(Loader.kt:4)
	at NoCatch.main(NoCatch.java:3)

kotlin-stdlib.jar 는 코틀린 표준 라이브러리입니다. 코틀린이 컴파일한 클래스는 이것 없이 돌지 않아서, java 로 돌릴 때는 classpath 에 직접 넣습니다.

자바 코드에서 체크 예외가 선언 없이 튀어나왔습니다. 자바 컴파일러의 규칙대로라면 있을 수 없는 일인데, JVM(자바 가상 머신)은 실행할 때 체크 예외인지 따지지 않으니 그대로 올라왔습니다. 자바 쪽은 잡으려 하면 컴파일 에러, 안 잡으면 실행에서 터지는 꼴입니다.

@Throws 를 달면 — 자바 규칙이 돌아온다

loadChecked 를 부르는 쪽으로 바꿨습니다. catch (IOException e) 로 감싼 CatchChecked.java 는 컴파일됐고 caught empty name 을 찍었습니다. 반대로 try 없이 부른 NoCatchChecked.java 는 javac 에서 이 에러로 막혔습니다.

NoCatchChecked.java:3: error: unreported exception IOException; must be caught or declared to be thrown

0절에서 Files.readString 을 불렀을 때와 같은 에러입니다. @Throws 가 붙은 코틀린 함수는 자바에게 throws IOException 이 달린 평범한 자바 메서드로 보입니다. 네 경우를 모으면 이렇습니다.

자바 쪽 호출 @Throws 없음 (load) @Throws 있음 (loadChecked)
catch (IOException e) 로 감쌈 컴파일 에러 is never thrown 컴파일됨, caught empty name
감싸지 않음 컴파일됨, 실행에서 IOException 컴파일 에러 unreported exception

공식 문서도 같은 상황을 예로 들고 @Throws 를 해법으로 제시합니다(코틀린 문서 「Checked exceptions」).

정리하면, 자바가 부를 코틀린 함수가 체크 예외를 던진다면 @Throws 를 단다가 규칙입니다. 코틀린에서만 부르는 함수에는 달 필요가 없습니다.

5. require·check·error — 검사를 한 줄로

자바에서 메서드 첫머리에 이런 줄을 자주 씁니다. if (amount <= 0) throw new IllegalArgumentException("..."); 코틀린 표준 라이브러리는 이 모양을 함수 셋으로 줄여 둡니다. 이 절에서는 셋을 한 번씩 실패시켜 어떤 예외가 나오는지 봅니다.

Kotlin
class Account(var open: Boolean) {
    var balance = 100

    fun withdraw(amount: Int) {
        require(amount > 0) { "amount must be positive: $amount" }
        check(open) { "account is closed" }
        balance -= amount
    }
}
  • require(조건) { 메시지 } — 조건이 거짓이면 IllegalArgumentException 을 던집니다. 부르는 쪽이 잘못된 인자를 넘겼다는 뜻입니다
  • check(조건) { 메시지 } — 조건이 거짓이면 IllegalStateException 을 던집니다. 객체가 지금 그 일을 할 상태가 아니라는 뜻입니다
  • { "..." } — 메시지를 문자열이 아니라 람다로 넘깁니다. 검사가 실패할 때만 이 람다가 돌아 메시지 문자열을 만듭니다
  • var open·var balance — 다시 대입할 수 있는 프로퍼티입니다

셋을 차례로 실패시키려고, 받은 코드를 돌리고 예외가 나면 찍기만 하는 작은 함수를 뒀습니다.

Kotlin
fun show(block: () -> Unit) {
    try {
        block()
    } catch (e: Exception) {
        println(e)
    }
}

fun main() {
    show { Account(true).withdraw(-5) }
    show { Account(false).withdraw(10) }
    show { error("unknown command: rm") }
}
  • block: () -> Unit — 인자 없이 불리고 돌려주는 값이 없는 함수를 받는다는 타입입니다. Unit 은 자바의 void 에 해당합니다. 함수 타입은 뒤에 나올 람다 편에서 자세히 다룹니다
  • show { ... } — 마지막 인자가 람다면 괄호 없이 중괄호만 붙여 부를 수 있습니다
  • error("...") — 조건 없이 바로 IllegalStateException 을 던집니다. 여기까지 오면 안 되는 분기에 씁니다
터미널
$ kotlinc Check.kt -d out
$ kotlin -cp out CheckKt
java.lang.IllegalArgumentException: amount must be positive: -5
java.lang.IllegalStateException: account is closed
java.lang.IllegalStateException: unknown command: rm

음수를 넘긴 첫 호출은 require 에, 닫힌 계좌의 둘째 호출은 check 에 걸렸습니다. 예외 이름이 전부 java.lang 입니다. 코틀린이 새로 만든 예외가 아니라 자바 개발자가 늘 던지던 그 예외들입니다.

함수 던지는 예외 누구의 잘못인가 자바로 쓰면
require IllegalArgumentException 부르는 쪽이 넘긴 인자 if (!조건) throw new IllegalArgumentException(...)
check IllegalStateException 객체의 지금 상태 if (!조건) throw new IllegalStateException(...)
error IllegalStateException 오면 안 되는 분기 throw new IllegalStateException(...)

requireNotNull — 검사하고 값까지 돌려받는다

null 일 수 있는 값을 검사할 때는 requireNotNull 이 편합니다. 검사를 통과하면 null 이 아닌 타입으로 값을 돌려줍니다.

Kotlin
fun greet(name: String?): String {
    val n = requireNotNull(name) { "name is required" }
    return "hi " + n.uppercase()
}

fun main() {
    println(greet("kim"))   // hi KIM
    println(greet(null))
}

String? 는 null 도 담을 수 있는 문자열 타입입니다. 코틀린은 이런 값에 uppercase() 같은 메서드를 바로 못 부르게 막는데, requireNotNull 이 돌려준 n 은 null 이 아닌 String 이라 그대로 부를 수 있습니다.

둘째 호출은 이렇게 끝났습니다.

터미널
$ kotlinc NotNull.kt -d out
$ kotlin -cp out NotNullKt
hi KIM
Exception in thread "main" java.lang.IllegalArgumentException: name is required
	at NotNullKt.greet(NotNull.kt:2)
	at NotNullKt.main(NotNull.kt:8)
	at NotNullKt.main(NotNull.kt)

어느 줄에서 무슨 검사가 실패했는지가 메시지와 줄 번호로 바로 나옵니다. null 을 모르고 더 끌고 가다 엉뚱한 곳에서 NullPointerException 을 만나는 것보다 원인에 가깝습니다.

정리하면, 인자는 require, 상태는 check, 올 수 없는 분기는 error 입니다. 셋 다 자바에서 늘 쓰던 예외를 던지는 줄을 한 줄로 줄여 줄 뿐입니다.

6. 성능 — 예외는 만들 때 호출 경로를 기록한다

2절에서 같은 결과를 내는 두 방법을 봤습니다. try { s.toInt() } catch ... 와 s.toIntOrNull() ?: 8080 입니다. 결과는 같지만, 실패할 때 하는 일의 양이 다릅니다. 이 절은 그 차이가 어디서 오는지와 언제 신경 써야 하는지를 봅니다.

예외 객체가 만들어질 때 무엇이 딸려 오는지 찍어 봤습니다.

Kotlin
fun main() {
    try {
        "x".toInt()
    } catch (e: NumberFormatException) {
        println(e.stackTrace.size)      // 5
    }
    val r = runCatching { "x".toInt() }
    println(r.exceptionOrNull())
    println("x".toIntOrNull())      // null
}
  • e.stackTrace — 자바의 e.getStackTrace() 입니다. 예외가 만들어진 순간의 호출 경로를 한 줄씩 담은 배열입니다
  • runCatching { ... } — 블록을 돌리고 결과를 Result 라는 상자에 담아 돌려줍니다. 성공하면 값이, 실패하면 예외가 들어 있습니다
  • exceptionOrNull() — 상자에 든 예외를 꺼냅니다. 성공이었다면 null 입니다

예외를 찍는 둘째 줄이 길어서 출력은 아래에 싣습니다.

터미널
$ kotlinc Cost.kt -d out
$ java -cp out:kotlin-stdlib.jar CostKt
5
java.lang.NumberFormatException: For input string: "x"
null

첫 줄의 5 는 호출 경로가 다섯 층 기록됐다는 뜻입니다. main 에서 toInt 를 거쳐 예외를 만든 메서드까지입니다. 자바의 Throwable 은 만들어질 때 fillInStackTrace() 를 불러 이 기록을 채웁니다(자바 21 Javadoc 「Throwable」). 스레드의 호출 스택을 거슬러 올라가며 층(스택 프레임)마다 메서드 이름과 줄 번호를 모으는 일이라, 평범한 객체 하나를 만드는 것보다 품이 많이 듭니다.

호출이 깊을수록 기록할 층도 늘어납니다. 여기서는 main 바로 아래라 다섯 층이었지만, 웹 프레임워크처럼 호출이 여러 겹 쌓인 코드 안에서 만들어진 예외는 그 겹을 전부 기록합니다.

둘째 줄을 봅니다. runCatching 은 예외를 없애 주지 않습니다. 안에서 toInt() 가 예외를 만들어 던지고, runCatching 이 그것을 잡아 상자에 담았을 뿐입니다. 쓰는 모양은 결과 타입이지만 드는 품은 try-catch 와 같습니다.

셋째 줄의 toIntOrNull() 은 숫자가 아니라는 사실을 null 로 돌려줍니다. 예외 객체가 만들어지지 않으니 호출 경로를 기록할 일도 없습니다.

언제 신경 쓰나

예외 하나의 품이 문제가 되는 것은 실패가 흔한 곳에서 반복될 때입니다. 사용자 입력 수만 줄을 돌며 숫자인지 가리는데 절반이 숫자가 아니라면, 그 절반마다 호출 경로를 기록합니다. 이런 곳에서 「숫자가 아닐 수도 있다」는 예외가 아니라 흔한 결과라서 null 로 받는 편이 맞습니다.

반대로 실패가 드문 곳은 신경 쓰지 않아도 됩니다. 설정 파일을 시작할 때 한 번 읽다 실패하는 것, 요청 하나를 처리하다 드물게 DB 연결이 끊기는 것에는 예외가 맞는 도구입니다. 예외가 던져지지 않는 한 try 블록을 두는 것 자체는 비용이 거의 없습니다.

flowchart TD
    A[값이 없거나 실패할 수 있다] --> B{흔하게 일어나나}
    B -->|흔하다 · 입력 검증 · 조회 결과 없음| C["null 을 돌려준다<br/>toIntOrNull · firstOrNull"]
    B -->|드물다 · 버그나 장애| D["예외를 던진다<br/>require · check · throw"]

firstOrNull 은 컬렉션에서 조건에 맞는 첫 원소를 찾고, 없으면 예외 대신 null 을 돌려주는 함수입니다. 표준 라이브러리에는 이렇게 OrNull 로 끝나는 짝이 여럿 있어서, 예외를 던지는 함수를 try 로 감싸기 전에 먼저 찾아볼 만합니다.

정리하면, 예외는 만들 때 호출 경로를 기록하는 비싼 객체입니다. 「없을 수도 있는 값」은 예외 대신 null 로 받고, runCatching 은 모양만 결과 타입일 뿐 예외 비용은 그대로라는 것을 기억해 두면 됩니다.

7. 한 장 요약

코틀린에는 체크 예외가 없다. 예외는 그대로 던져지고 컴파일러의 강제만 사라졌다. 그래서 자바가 부를 함수에는 @Throws 를 달아 자바 컴파일러에게 알려 줘야 한다.

하는 일 자바 (JDK 21) 코틀린 확인한 방법
체크 예외를 던지는 메서드 부르기 잡거나 throws 선언. 안 하면 unreported exception 그냥 부른다. 실패는 실행에서 양쪽 컴파일 · 실행
던진다고 선언 throws IOException 문법에 없음. 쓰면 구문 에러 컴파일 에러
던지기 · 잡기 throw new E(...) · catch (E e) throw E(...) · catch (e: E) 실행
여러 예외를 한 번에 catch (A | B e) 없음. catch (e: Exception) + when 컴파일 에러 · 실행
실패하면 대신 쓸 값 변수 선언 후 가지마다 대입 val v = try { ... } catch ... { 기본값 } 실행
자원 닫기 try-with-resources, 반대 순서로 닫음 .use { } 를 겹쳐 씀, 안쪽부터 닫음 양쪽 실행
자바가 코틀린 함수의 예외 잡기 — @Throws(E::class) 없으면 catch 가 is never thrown javac 에러 · 실행
인자 · 상태 검사 if (...) throw new IllegalArgumentException require · check · error · requireNotNull 실행
없을 수도 있는 값 try-catch 로 감쌈 toIntOrNull() 같은 OrNull 함수. runCatching 은 예외 비용 그대로 실행

관련 항목

예외를 나누는 두 갈래와 그 뿌리

예외 · 체크 예외 · 언체크 예외 · RuntimeException · Throwable · Error

이 편에서 던지고 받은 자바 표준 예외

IOException · NoSuchFileException · NumberFormatException · IllegalArgumentException · IllegalStateException · NullPointerException

예외를 던지고 받는 문법

throw · try-catch · finally · 멀티 catch · try 식 · when 식

자원을 닫는 방법

try-with-resources · AutoCloseable · use 함수 · init 블록

자바와 코틀린 사이에서 예외 선언을 잇는 것

@Throws · throws 절 · 자바 상호 운용 · 톱레벨 함수 · kotlin-stdlib

인자와 상태를 검사하는 표준 함수

require · check · error 함수 · requireNotNull · 사전 조건 · Nothing 타입

예외 대신 실패를 값으로 돌려주는 방식

toIntOrNull · firstOrNull · 널 가능 타입 · 엘비스 연산자 · runCatching · Result 타입

예외의 비용을 이루는 것

스택 트레이스 · fillInStackTrace · 호출 스택 · 스택 프레임 · 예외를 흐름 제어에 쓰기

이 편에 쓰인 언어와 도구

코틀린 · 자바 · JDK · 컴파일러 · 가상 머신 · 람다 · 함수 타입 · 문자열 템플릿