
1.Throwable과 Exception, Error
Throwable은 예외와 오류의 최상위 클래스이다.
예외와 오류의 차이점은 복구 가능성으로, 예외에 대해서는 개발자가 적절한 처리를 통해 정상적인 실행을 유지할 수 있으나 오류는 그렇지 않다. 이 때문에 이 둘을 구분하여 개발자가 핸들링할 수 없는 오류에 대해서는 처리를 하지 않도록 설계되었다.
2. Kotlin에는 checked exception이 없다
Java에서는 RuntimeException의 서브클래스가 아닌 Exception들을 checked exception이라 부르며, 이에 대한 처리를 컴파일러 수준에서 강제한다. 하지만 Kotlin은 Jvm 기반으로 설계되었음에도 checked exception을 지원하지 않는데, 그 이유가 뭘까?
💡Kotlin의 예외 클래스는 Java 예외 클래스와 동일한 계층 구조를 가지지만, checked exception에 대한 예외 처리를 강제하지 않도록 설계되었다.
(1) 불필요한 보일러플레이트 코드가 증가한다
이 문제는 예외 처리를 강제하는 것이 해당 예외에 대한 유효한 처리를 보장하지는 않는다는 점에서 출발한다.
public void readFile() {
try {
FileReader file = new FileReader("test.txt");
} catch (IOException e) {
e.printStackTrace(); // 의미 없는 예외 처리
}
}
위 코드에서는 try-catch문으로 감싸고, catch 블럭에서 별다른 처리 없이 e.printStackTrace()를 호출하고 있다.
발생한 예외를 다시 throw할 수도 있지만, 그대로 던질 경우 호출부에서 또다시 해당 checked exception에 대한 처리가 강제된다.
이걸 회피하려면 RuntimeException으로 감싸야 하는데, 호출부에서 특정 예외 타입에 대한 정확한 예외 처리 로직을 작성하기 어려워진다.
(2) 습관적 예외 처리가 발생한다
예외 처리가 강제되는 빈도가 높아질수록 개발자는 습관적으로 try-catch 문으로 로직을 감싸는 코드 패턴에 적응하게 되는데,
우선 예외를 잡는다는 점에서 프로그램의 안정성은 향상되지만 발생한 문제의 원인을 파악하기 어려워지는 문제가 발생한다.
(3) 컴파일러의 최적화가 제한될 수 있다.
try-catch 문을 포함하는 메서드는 Code attribute 내부에 정상 실행 경로와 예외 처리 경로의 정보를 담고 있는 '예외 테이블'이 포함되어 있으며, 이 정보는 클래스가 로드될 때 JVM의 메소드 영역에 저장된다.
🎈예외 테이블의 구성 요소
(1) from: try 블럭이 시작되는 바이트코드의 엔트리 넘버
(2) to: try 블럭이 종료되는 바이트코드의 엔트리 넘버
(3) target: try가 발생했을 때 핸들러 시작 엔트리 넘버
(4) type: 핸들러가 처리할 예외 타입
실제 코드 디컴파일을 통해 알아 보자.
// 예시 코드 (Kotlin)
fun calc() {
val a: Int = 1
val b: Int = 10
var result: Int = 0
try { // line 5
result = a + b
} catch (e: NumberFormatException) {
result = 0
}
} // line 10
// 디컴파일된 코드
public final static calc()V
TRYCATCHBLOCK L0 L1 L2 java/lang/NumberFormatException // 예외 테이블
L3
LINENUMBER 2 L3
ICONST_1
ISTORE 0
L4
LINENUMBER 3 L4
BIPUSH 10
ISTORE 1
L5
LINENUMBER 4 L5
ICONST_0
ISTORE 2
L0
LINENUMBER 5 L0
NOP
L6
LINENUMBER 6 L6
ILOAD 0
ILOAD 1
IADD
ISTORE 2
L1
GOTO L7
L2
LINENUMBER 7 L2
FRAME FULL [I I I] [java/lang/NumberFormatException]
ASTORE 3
L8
LINENUMBER 8 L8
ICONST_0
ISTORE 2
L7
LINENUMBER 10 L7
FRAME SAME
RETURN
L9
LOCALVARIABLE e Ljava/lang/NumberFormatException; L8 L7 3
LOCALVARIABLE a I L4 L9 0
LOCALVARIABLE b I L5 L9 1
LOCALVARIABLE result I L0 L9 2
MAXSTACK = 2
MAXLOCALS = 4
TRYCATCHBLOCK 예외 테이블 구성은 아래와 같다.
- try 블럭의 시작 엔트리 L0(line 5)
- 종료 엔트리 L1(line 10)
- 핸들러의 시작 엔트리 L2(line 7)
- 처리할 예외 타입 NumberFormatException
이 처럼 바이트코드에서 정상 실행 경로와 예외 처리 경로가 명확히 구분되어 있다.
컴파일러는 정상 경로에 대해서는 여러 최적화 기법을 공격적으로 적용할 수 있지만, try-catch 블록이 존재하면 예외 발생 시 실행될 핸들러 코드도 함께 고려해야 하므로 일부 최적화가 제한될 수 있다.
3. 이펙티브 코틀린 - 결과 부족이 발생할 경우 null과 Failure를 사용하라
적절한 결과를 반환하지 못하는 상황에서는 예외를 throw하기 보다 null/sealed class를 사용해야 한다. 예외는 특별한 상황을 나타내고 처리되어야 하므로 정보를 전달하기 위한 방법으로 사용하는 것은 적절하지 않기 때문이다. 추가적인 이유로는,
- 예외가 전파되는 과정을 추적하기 어렵다
- Kotlin의 모든 예외는 Unchecked 예외로, 예외가 제대로 처리되지 않고 무시될 수 있다.
- 컴파일러가 할 수 있는 최적화가 제한된다
// 예시 코드
sealed class CommonResponse {
data class Success<T>(val code :Int, val body : T) : CommonResponse()
data class Failed(val code : Int, val message : String) : CommonResponse()
}
위처럼 sealed class를 사용하면 when 문을 통해 모든 결과 케이스를 반드시 처리하게 되어 명확한 실패 처리 및 로직의 가독성을 확보할 수 있다.
4. 이펙티브 코틀린 - 사용자 정의 오류보다는 표준 오류를 사용하라
이펙티브 코틀린에서는 표준 예외의 사용을 권장하고 있다.
표준 예외들은 이미 많은 개발자들에게 익숙하기 때문에 대부분의 개발자가 쉽게 이해할 수 있기 때문이다.
하지만 도메인 모듈과 같은 고수준 모듈에서는 사용자 정의 예외를 사용해서 비즈니스 규칙에 대한 예외 상황을 명확하게 표현하는 것 또한 상황에 따라 좋은 방법이 될 수 있다.
오픈 소스처럼 불특정 다수에게 노출되는 경우에는 표준 예외를 사용해서 쉽게 이해하고 사용할 수 있도록 하고, 내부 도메인 모듈은 사용자 정의 예외를 사용하는 등으로 정책을 잘 정하는게 중요할 것 같다.
5. 모듈 별 예외 처리 전략
이처럼 실용성을 중요시 여기는 Kotlin은 모든 예외를 Unchecked 예외로 사용하여 자유도를 높이지만, 느슨한 제약으로 인해 발생할 수 있는 잠재적 문제들에 대해서는 개발자에게 책임을 부여한다.
초기에는 checked exception을 도입하지 않은 것에 대한 우려가 많았으나, 결과적으로 Kotlin 개발팀의 선택은 현명했다. 불필요한 예외 처리가 줄어들면서 Kotlin의 특장점인 가독성과 간결성을 강화할 수 있었다.
'개발 > Kotlin' 카테고리의 다른 글
참조 객체로 GC에 관여하기 (feat.WeakReference) (1) | 2025.03.19 |
---|---|
[Kotlin] sealed class, enum class 바이트 코드 뜯어보기 (1) | 2025.03.11 |
[이펙티브 코틀린] 프로퍼티는 동작이 아니라 상태를 나타내야 한다 (0) | 2025.02.07 |
[Kotlin] 타입 소거와 Star projection (0) | 2024.06.27 |
[Kotlin] 테스트 코드 (2) - TDD/BDD와 디자인 패턴 (0) | 2023.07.02 |

1.Throwable과 Exception, Error
Throwable은 예외와 오류의 최상위 클래스이다.
예외와 오류의 차이점은 복구 가능성으로, 예외에 대해서는 개발자가 적절한 처리를 통해 정상적인 실행을 유지할 수 있으나 오류는 그렇지 않다. 이 때문에 이 둘을 구분하여 개발자가 핸들링할 수 없는 오류에 대해서는 처리를 하지 않도록 설계되었다.
2. Kotlin에는 checked exception이 없다
Java에서는 RuntimeException의 서브클래스가 아닌 Exception들을 checked exception이라 부르며, 이에 대한 처리를 컴파일러 수준에서 강제한다. 하지만 Kotlin은 Jvm 기반으로 설계되었음에도 checked exception을 지원하지 않는데, 그 이유가 뭘까?
💡Kotlin의 예외 클래스는 Java 예외 클래스와 동일한 계층 구조를 가지지만, checked exception에 대한 예외 처리를 강제하지 않도록 설계되었다.
(1) 불필요한 보일러플레이트 코드가 증가한다
이 문제는 예외 처리를 강제하는 것이 해당 예외에 대한 유효한 처리를 보장하지는 않는다는 점에서 출발한다.
public void readFile() {
try {
FileReader file = new FileReader("test.txt");
} catch (IOException e) {
e.printStackTrace(); // 의미 없는 예외 처리
}
}
위 코드에서는 try-catch문으로 감싸고, catch 블럭에서 별다른 처리 없이 e.printStackTrace()를 호출하고 있다.
발생한 예외를 다시 throw할 수도 있지만, 그대로 던질 경우 호출부에서 또다시 해당 checked exception에 대한 처리가 강제된다.
이걸 회피하려면 RuntimeException으로 감싸야 하는데, 호출부에서 특정 예외 타입에 대한 정확한 예외 처리 로직을 작성하기 어려워진다.
(2) 습관적 예외 처리가 발생한다
예외 처리가 강제되는 빈도가 높아질수록 개발자는 습관적으로 try-catch 문으로 로직을 감싸는 코드 패턴에 적응하게 되는데,
우선 예외를 잡는다는 점에서 프로그램의 안정성은 향상되지만 발생한 문제의 원인을 파악하기 어려워지는 문제가 발생한다.
(3) 컴파일러의 최적화가 제한될 수 있다.
try-catch 문을 포함하는 메서드는 Code attribute 내부에 정상 실행 경로와 예외 처리 경로의 정보를 담고 있는 '예외 테이블'이 포함되어 있으며, 이 정보는 클래스가 로드될 때 JVM의 메소드 영역에 저장된다.
🎈예외 테이블의 구성 요소
(1) from: try 블럭이 시작되는 바이트코드의 엔트리 넘버
(2) to: try 블럭이 종료되는 바이트코드의 엔트리 넘버
(3) target: try가 발생했을 때 핸들러 시작 엔트리 넘버
(4) type: 핸들러가 처리할 예외 타입
실제 코드 디컴파일을 통해 알아 보자.
// 예시 코드 (Kotlin)
fun calc() {
val a: Int = 1
val b: Int = 10
var result: Int = 0
try { // line 5
result = a + b
} catch (e: NumberFormatException) {
result = 0
}
} // line 10
// 디컴파일된 코드
public final static calc()V
TRYCATCHBLOCK L0 L1 L2 java/lang/NumberFormatException // 예외 테이블
L3
LINENUMBER 2 L3
ICONST_1
ISTORE 0
L4
LINENUMBER 3 L4
BIPUSH 10
ISTORE 1
L5
LINENUMBER 4 L5
ICONST_0
ISTORE 2
L0
LINENUMBER 5 L0
NOP
L6
LINENUMBER 6 L6
ILOAD 0
ILOAD 1
IADD
ISTORE 2
L1
GOTO L7
L2
LINENUMBER 7 L2
FRAME FULL [I I I] [java/lang/NumberFormatException]
ASTORE 3
L8
LINENUMBER 8 L8
ICONST_0
ISTORE 2
L7
LINENUMBER 10 L7
FRAME SAME
RETURN
L9
LOCALVARIABLE e Ljava/lang/NumberFormatException; L8 L7 3
LOCALVARIABLE a I L4 L9 0
LOCALVARIABLE b I L5 L9 1
LOCALVARIABLE result I L0 L9 2
MAXSTACK = 2
MAXLOCALS = 4
TRYCATCHBLOCK 예외 테이블 구성은 아래와 같다.
- try 블럭의 시작 엔트리 L0(line 5)
- 종료 엔트리 L1(line 10)
- 핸들러의 시작 엔트리 L2(line 7)
- 처리할 예외 타입 NumberFormatException
이 처럼 바이트코드에서 정상 실행 경로와 예외 처리 경로가 명확히 구분되어 있다.
컴파일러는 정상 경로에 대해서는 여러 최적화 기법을 공격적으로 적용할 수 있지만, try-catch 블록이 존재하면 예외 발생 시 실행될 핸들러 코드도 함께 고려해야 하므로 일부 최적화가 제한될 수 있다.
3. 이펙티브 코틀린 - 결과 부족이 발생할 경우 null과 Failure를 사용하라
적절한 결과를 반환하지 못하는 상황에서는 예외를 throw하기 보다 null/sealed class를 사용해야 한다. 예외는 특별한 상황을 나타내고 처리되어야 하므로 정보를 전달하기 위한 방법으로 사용하는 것은 적절하지 않기 때문이다. 추가적인 이유로는,
- 예외가 전파되는 과정을 추적하기 어렵다
- Kotlin의 모든 예외는 Unchecked 예외로, 예외가 제대로 처리되지 않고 무시될 수 있다.
- 컴파일러가 할 수 있는 최적화가 제한된다
// 예시 코드
sealed class CommonResponse {
data class Success<T>(val code :Int, val body : T) : CommonResponse()
data class Failed(val code : Int, val message : String) : CommonResponse()
}
위처럼 sealed class를 사용하면 when 문을 통해 모든 결과 케이스를 반드시 처리하게 되어 명확한 실패 처리 및 로직의 가독성을 확보할 수 있다.
4. 이펙티브 코틀린 - 사용자 정의 오류보다는 표준 오류를 사용하라
이펙티브 코틀린에서는 표준 예외의 사용을 권장하고 있다.
표준 예외들은 이미 많은 개발자들에게 익숙하기 때문에 대부분의 개발자가 쉽게 이해할 수 있기 때문이다.
하지만 도메인 모듈과 같은 고수준 모듈에서는 사용자 정의 예외를 사용해서 비즈니스 규칙에 대한 예외 상황을 명확하게 표현하는 것 또한 상황에 따라 좋은 방법이 될 수 있다.
오픈 소스처럼 불특정 다수에게 노출되는 경우에는 표준 예외를 사용해서 쉽게 이해하고 사용할 수 있도록 하고, 내부 도메인 모듈은 사용자 정의 예외를 사용하는 등으로 정책을 잘 정하는게 중요할 것 같다.
5. 모듈 별 예외 처리 전략
이처럼 실용성을 중요시 여기는 Kotlin은 모든 예외를 Unchecked 예외로 사용하여 자유도를 높이지만, 느슨한 제약으로 인해 발생할 수 있는 잠재적 문제들에 대해서는 개발자에게 책임을 부여한다.
초기에는 checked exception을 도입하지 않은 것에 대한 우려가 많았으나, 결과적으로 Kotlin 개발팀의 선택은 현명했다. 불필요한 예외 처리가 줄어들면서 Kotlin의 특장점인 가독성과 간결성을 강화할 수 있었다.
'개발 > Kotlin' 카테고리의 다른 글
참조 객체로 GC에 관여하기 (feat.WeakReference) (1) | 2025.03.19 |
---|---|
[Kotlin] sealed class, enum class 바이트 코드 뜯어보기 (1) | 2025.03.11 |
[이펙티브 코틀린] 프로퍼티는 동작이 아니라 상태를 나타내야 한다 (0) | 2025.02.07 |
[Kotlin] 타입 소거와 Star projection (0) | 2024.06.27 |
[Kotlin] 테스트 코드 (2) - TDD/BDD와 디자인 패턴 (0) | 2023.07.02 |