실패를 코드 밖으로 밀어내지 않기
지금까지는 주로 값이 정상적으로 들어오고, 함수가 의도한 대로 실행되는 상황을 다루었다. 하지만 실전 프로그램은 언제든 실패한다.
파일이 없을 수 있다. 네트워크 연결이 끊길 수 있다. 사용자가 잘못된 값을 입력할 수 있다. 서버가 예상과 다른 응답을 줄 수도 있다. 이런 상황을 "예외적인 일"이라고만 생각하면 코드가 약해진다. 실패는 드물게 일어나는 방해물이 아니라, 프로그램이 반드시 다루어야 하는 흐름 중 하나다.
코틀린에서 실패를 다루는 대표적인 방법은 세 가지다.
예외를 던지고
try-catch-finally로 처리한다.실패 가능성을 값으로 표현한다. 예를 들어
null,Result, sealed class를 사용한다.파일, 스트림 같은 자원은 사용 후 반드시 닫히도록 구조를 만든다.
이 장에서는 예외를 단순히 "에러가 났을 때 쓰는 문법"으로 보지 않는다. 실패를 어디에서 감지하고, 어디까지 전달하며, 어떤 정보로 설명할지 설계하는 방법으로 다룬다.

예외는 실패를 알리는 객체다
코틀린의 예외는 객체다. 예외가 발생하면 현재 실행 흐름이 중단되고, 그 예외를 처리할 수 있는 catch 블록을 찾는다.
fun parseAge(input: String): Int {
return input.toInt()
}
fun main() {
val age = parseAge("twenty")
println(age)
}위 코드는 실행 중 실패한다. "twenty"는 숫자로 바꿀 수 없기 때문이다. 이때 toInt()는 예외를 던진다.
예외를 직접 던질 수도 있다.
fun requirePositive(value: Int) {
if (value <= 0) {
throw IllegalArgumentException("0보다 큰 값이어야 합니다: $value")
}
}throw는 단순한 명령이 아니라 표현식처럼 사용할 수 있다. 이때 throw의 타입은 Nothing이다. Nothing은 "정상적으로 값이 만들어지지 않는다"는 뜻의 특별한 타입이다.
그래서 엘비스 연산자와 함께 자주 사용된다.
fun printUserName(name: String?) {
val nonNullName = name ?: throw IllegalArgumentException("이름은 null일 수 없습니다.")
println(nonNullName.uppercase())
}name이 null이면 예외가 발생하고, null이 아니면 nonNullName은 안전하게 String으로 다룰 수 있다.
try-catch-finally 기본 구조
예외를 처리하려면 try-catch를 사용한다.
fun parseAgeOrDefault(input: String): Int {
return try {
input.toInt()
} catch (e: NumberFormatException) {
0
}
}try 블록에는 실패할 수 있는 코드를 넣는다. catch 블록에는 특정 예외가 발생했을 때 실행할 코드를 넣는다.
여기서 중요한 점은 코틀린의 try가 표현식이라는 것이다. 즉, 값을 만들 수 있다.
val age: Int = try {
input.toInt()
} catch (e: NumberFormatException) {
-1
}try 블록이 성공하면 마지막 표현식인 input.toInt()의 값이 age에 들어간다. 실패하면 catch 블록의 마지막 표현식인 -1이 들어간다.
이 방식은 오류 상황에서도 함수가 값을 반환해야 할 때 유용하다.
fun readDiscountRate(input: String): Double {
return try {
input.toDouble()
} catch (e: NumberFormatException) {
0.0
}
}다만 기본값을 반환하는 것이 항상 좋은 선택은 아니다. 입력이 잘못되었는데 조용히 0.0으로 바꾸면, 나중에 더 찾기 어려운 버그가 될 수 있다. 기본값을 줄 때는 그 값이 비즈니스적으로 안전한지 먼저 확인해야 한다.
catch는 구체적인 예외부터 잡기
하나의 try에 여러 catch를 붙일 수 있다.
fun parsePort(input: String): Int {
return try {
val port = input.toInt()
if (port !in 1..65535) {
throw IllegalArgumentException("포트 범위를 벗어났습니다: $port")
}
port
} catch (e: NumberFormatException) {
8080
} catch (e: IllegalArgumentException) {
8080
}
}catch는 위에서 아래로 검사한다. 따라서 더 구체적인 예외를 먼저 두고, 더 넓은 예외를 나중에 두어야 한다.
try {
// 실패할 수 있는 코드
} catch (e: NumberFormatException) {
// 숫자 변환 실패
} catch (e: Exception) {
// 그 밖의 일반적인 예외
}반대로 Exception을 먼저 잡으면 그 아래의 구체적인 catch는 실행될 기회가 없다.
또한 습관적으로 모든 코드를 다음처럼 감싸는 것은 피해야 한다.
try {
runImportantJob()
} catch (e: Exception) {
println("실패했습니다.")
}이 코드는 무엇이 왜 실패했는지 호출자에게 알려 주지 않는다. 예외를 잡았지만 문제를 해결하지도 않았고, 의미 있는 정보도 남기지 않았다. 이런 코드는 예외를 처리한 것이 아니라 숨긴 것이다.
finally는 마지막 정리 작업을 위한 블록이다
finally는 예외 발생 여부와 관계없이 실행되는 블록이다.
fun runTask() {
try {
println("작업 시작")
riskyOperation()
println("작업 성공")
} catch (e: RuntimeException) {
println("작업 실패: ${e.message}")
} finally {
println("작업 종료")
}
}finally는 주로 정리 작업에 사용한다.
열어 둔 파일 닫기
네트워크 연결 해제하기
잠금 해제하기
임시 상태 되돌리기
하지만 코틀린에서는 파일이나 스트림 같은 자원을 직접 finally에서 닫기보다, 잠시 뒤에 볼 use를 사용하는 편이 더 안전하고 간결하다.
finally에서 주의할 점도 있다. finally 안에서 return을 사용하면 원래 발생한 예외나 반환값을 덮어버릴 수 있다.
fun badExample(): Int {
try {
return 1
} finally {
return 2
}
}이 함수는 1이 아니라 2를 반환한다. 이런 코드는 읽는 사람을 혼란스럽게 만든다. finally는 정리 작업만 하는 곳으로 생각하는 편이 좋다.
코틀린에는 checked exception이 없다
자바에는 checked exception이라는 개념이 있다. 어떤 예외는 반드시 try-catch로 잡거나, 메서드 선언에 던진다고 표시해야 한다.
코틀린은 checked exception을 강제하지 않는다.
import java.io.File
fun readText(path: String): String {
return File(path).readText()
}파일 읽기는 실패할 수 있다. 파일이 없거나, 권한이 없거나, 읽는 도중 문제가 생길 수 있다. 하지만 코틀린 컴파일러는 이 예외를 반드시 잡으라고 강제하지 않는다.
이 방식은 코드를 간결하게 만든다. 동시에 책임도 개발자에게 넘긴다. 컴파일러가 강제하지 않기 때문에, API를 설계할 때 실패를 어떻게 표현할지 더 신중해야 한다.
예외 API를 설계하는 기준
실패를 표현하는 방법에는 여러 가지가 있다. 모든 실패를 예외로 던질 필요는 없고, 모든 실패를 null이나 Result로 바꿀 필요도 없다.
다음 기준으로 나누어 생각하면 좋다.
1. 호출자가 쉽게 복구할 수 없는 문제는 예외로 던진다
예를 들어 함수에 잘못된 인자가 들어온 경우는 보통 예외가 적합하다.
fun withdraw(balance: Int, amount: Int): Int {
require(amount > 0) { "출금액은 0보다 커야 합니다." }
require(balance >= amount) { "잔액이 부족합니다." }
return balance - amount
}require는 조건이 거짓이면 IllegalArgumentException을 던진다. 함수의 인자가 잘못되었음을 알릴 때 자주 사용한다.
객체의 상태가 잘못되었을 때는 check를 사용할 수 있다.
class OrderProcessor {
private var started: Boolean = false
fun start() {
started = true
}
fun process() {
check(started) { "start()를 먼저 호출해야 합니다." }
println("주문 처리 중")
}
}require는 인자 검증에, check는 상태 검증에 어울린다.
2. 정상 흐름에서 자주 일어나는 실패는 값으로 표현한다
사용자 입력이 숫자가 아닐 수 있는 것은 흔한 일이다. 이런 경우 매번 예외를 던지고 잡기보다, 실패를 값으로 표현하는 편이 더 자연스러울 수 있다.
fun parseAgeOrNull(input: String): Int? {
return input.toIntOrNull()
}호출자는 null 여부를 확인하면 된다.
val age = parseAgeOrNull(input)
if (age == null) {
println("나이를 숫자로 입력해 주세요.")
} else {
println("입력한 나이: $age")
}실패 이유가 단순히 "값이 없다" 정도라면 nullable 타입이 충분하다.
3. 실패 이유를 함께 전달해야 한다면 Result를 고려한다
실패했다는 사실뿐 아니라 원인도 전달해야 한다면 Result를 사용할 수 있다.
fun parsePrice(input: String): Result<Int> {
return runCatching {
val price = input.toInt()
require(price >= 0) { "가격은 음수일 수 없습니다." }
price
}
}호출자는 성공과 실패를 명시적으로 나누어 처리할 수 있다.
val result = parsePrice("-100")
result.fold(
onSuccess = { price ->
println("가격: $price")
},
onFailure = { error ->
println("가격을 읽을 수 없습니다: ${error.message}")
}
)Result는 "이 함수는 실패할 수 있다"는 사실을 반환 타입에 드러낸다. 코틀린에는 checked exception이 없으므로, 이런 명시적인 반환 타입이 API 문서 역할을 해 줄 때가 많다.
Result로 성공과 실패 전달하기
Result<T>는 성공하면 T 값을 담고, 실패하면 예외를 담는다.
가장 간단한 생성 방법은 runCatching이다.
fun loadConfig(path: String): Result<String> {
return runCatching {
java.io.File(path).readText()
}
}호출자는 여러 방식으로 결과를 처리할 수 있다.
val text = loadConfig("config.txt").getOrElse { error ->
println("설정 파일을 읽지 못했습니다: ${error.message}")
"default=true"
}getOrElse는 성공하면 값을 꺼내고, 실패하면 대체 값을 만든다.
val text = loadConfig("config.txt").getOrNull()getOrNull은 실패하면 null을 반환한다. 실패 이유가 필요 없다면 간단하지만, 오류 정보를 잃어버릴 수 있다.
loadConfig("config.txt")
.onSuccess { text ->
println("설정 로드 성공: ${text.length}자")
}
.onFailure { error ->
println("설정 로드 실패: ${error.message}")
}onSuccess와 onFailure는 중간에 로그를 남기거나 부수 효과를 실행할 때 유용하다. 다만 이 함수들은 원래의 Result를 그대로 반환한다. 값을 바꾸고 싶다면 map, 실패를 복구하고 싶다면 recover를 사용할 수 있다.
fun loadPort(path: String): Result<Int> {
return loadConfig(path)
.map { text -> text.trim().toInt() }
.recover { 8080 }
}이 코드는 설정 파일을 읽고, 그 내용을 포트 번호로 바꾼다. 중간에 실패하면 기본 포트 8080을 사용한다.
다만 recover를 사용할 때도 주의해야 한다. 복구가 정말 안전한 경우에만 기본값을 주어야 한다. 실패를 덮어버리기 위한 편의 기능으로 쓰면 안 된다.
예외를 삼키지 않기
예외 처리에서 가장 흔한 실수는 예외를 잡고 아무것도 하지 않는 것이다.
try {
saveOrder(order)
} catch (e: Exception) {
// 무시
}이 코드는 위험하다. 주문 저장이 실패했는데도 프로그램은 성공한 것처럼 계속 실행된다. 사용자는 주문이 저장되었다고 믿을 수 있고, 데이터는 실제로 사라질 수 있다.
최소한 다음 중 하나는 해야 한다.
호출자가 알 수 있도록 다시 던진다.
의미 있는 실패 값으로 반환한다.
로그를 남기고 안전한 복구 절차를 실행한다.
사용자에게 이해할 수 있는 메시지를 보여 준다.
예외를 다시 던질 때는 맥락을 추가하는 것이 좋다.
class OrderSaveException(
message: String,
cause: Throwable
) : RuntimeException(message, cause)
fun saveOrderSafely(order: Order) {
try {
saveOrder(order)
} catch (e: java.io.IOException) {
throw OrderSaveException("주문 저장 중 파일 오류가 발생했습니다. orderId=${order.id}", e)
}
}여기서 중요한 점은 원래 예외 e를 cause로 넘긴다는 것이다. 그래야 나중에 실제 원인을 추적할 수 있다. 새로운 메시지만 만들고 원래 예외를 버리면 디버깅이 어려워진다.
Result를 사용할 때도 마찬가지다.
fun saveOrderResult(order: Order): Result<Unit> {
return try {
saveOrder(order)
Result.success(Unit)
} catch (e: java.io.IOException) {
Result.failure(
OrderSaveException("주문 저장에 실패했습니다. orderId=${order.id}", e)
)
}
}성공했을 때 반환할 값이 딱히 없다면 Unit을 사용한다. Unit은 "의미 있는 값은 없지만 정상적으로 끝났다"는 뜻이다.
의미 있는 오류 정보 제공하기
좋은 오류 메시지는 문제 해결에 필요한 정보를 담고, 불필요하게 내부 구현을 노출하지 않는다.
나쁜 메시지:
error조금 나은 메시지:
파일을 읽을 수 없습니다.더 나은 메시지:
설정 파일을 읽을 수 없습니다. path=/app/config.yml오류 메시지에는 다음 정보가 도움이 된다.
어떤 작업을 하다가 실패했는가
어떤 입력이나 식별자와 관련이 있는가
호출자가 다음에 무엇을 해야 하는가
원래 예외는 무엇인가
예를 들어 사용자 등록 API라면 다음처럼 실패를 구분할 수 있다.
class UserRegistrationException(
message: String,
cause: Throwable? = null
) : RuntimeException(message, cause)
fun registerUser(email: String, password: String): Result<User> {
return runCatching {
require(email.contains("@")) { "이메일 형식이 올바르지 않습니다." }
require(password.length >= 8) { "비밀번호는 8자 이상이어야 합니다." }
createUser(email, password)
}.recoverCatching { e ->
throw UserRegistrationException("사용자 등록에 실패했습니다. email=$email", e)
}
}다만 실제 서비스에서는 비밀번호, 토큰, 주민등록번호 같은 민감한 값은 오류 메시지나 로그에 남기면 안 된다. 오류 정보는 자세해야 하지만, 안전해야 한다.
파일과 스트림은 반드시 닫아야 한다
파일, 네트워크 연결, 데이터베이스 연결, 입력 스트림, 출력 스트림은 운영체제 자원을 사용한다. 이런 자원은 사용이 끝나면 닫아야 한다.
직접 닫는 코드는 다음처럼 작성할 수 있다.
import java.io.File
fun readFirstLine(path: String): String? {
val reader = File(path).bufferedReader()
try {
return reader.readLine()
} finally {
reader.close()
}
}이 코드는 예외가 발생해도 finally에서 reader.close()가 실행된다. 하지만 매번 이런 패턴을 직접 쓰면 번거롭고 실수하기 쉽다.
코틀린에서는 use를 사용한다.
use로 자원 자동 정리하기
use는 자원을 사용한 뒤 자동으로 닫아 주는 함수다. 자바의 try-with-resources와 비슷한 역할을 한다.
import java.io.File
fun readFirstLine(path: String): String? {
return File(path).bufferedReader().use { reader ->
reader.readLine()
}
}use 블록 안의 코드가 정상적으로 끝나면 reader를 닫는다. 중간에 예외가 발생해도 reader를 닫는다.
fun countLines(path: String): Int {
return File(path).bufferedReader().use { reader ->
reader.readLines().size
}
}use는 람다를 받는 고차 함수다. 앞에서 배운 람다 문법이 실제 자원 관리에도 쓰이는 것이다.
자원을 여러 개 열어야 한다면 use를 중첩할 수 있다.
fun copyFile(sourcePath: String, targetPath: String) {
File(sourcePath).inputStream().use { input ->
File(targetPath).outputStream().use { output ->
input.copyTo(output)
}
}
}안쪽 output도 닫히고, 바깥쪽 input도 닫힌다. 복사 중 예외가 발생해도 열린 자원은 정리된다.
지연 처리와 use를 함께 쓸 때 주의하기
Sequence처럼 지연 계산되는 값을 자원 밖으로 꺼낼 때는 조심해야 한다.
다음 코드는 위험하다.
fun readLinesLazy(path: String): Sequence<String> {
return File(path).bufferedReader().use { reader ->
reader.lineSequence()
}
}use 블록이 끝나는 순간 reader는 닫힌다. 그런데 lineSequence()는 실제 줄 읽기를 나중에 한다. 결국 호출자가 시퀀스를 순회하려 할 때 이미 파일이 닫혀 있을 수 있다.
이럴 때는 use 안에서 최종 연산까지 끝내야 한다.
fun countNonBlankLines(path: String): Int {
return File(path).bufferedReader().use { reader ->
reader.lineSequence()
.filter { it.isNotBlank() }
.count()
}
}또는 필요한 데이터를 리스트로 만들어 반환한다.
fun readNonBlankLines(path: String): List<String> {
return File(path).bufferedReader().use { reader ->
reader.lineSequence()
.filter { it.isNotBlank() }
.toList()
}
}핵심은 간단하다. 닫혀야 하는 자원을 사용하는 작업은 use 블록 안에서 끝내야 한다.
파일 읽기 예제로 예외와 Result 결합하기
이제 예외 처리, use, Result를 함께 사용해 보자.
data class User(
val id: Long,
val name: String
)
class UserLoadException(
message: String,
cause: Throwable? = null
) : RuntimeException(message, cause)
fun loadUsers(path: String): Result<List<User>> {
return try {
val users = java.io.File(path).bufferedReader().use { reader ->
reader.lineSequence()
.filter { it.isNotBlank() }
.map { line -> parseUser(line) }
.toList()
}
Result.success(users)
} catch (e: Exception) {
Result.failure(UserLoadException("사용자 파일을 읽지 못했습니다. path=$path", e))
}
}
fun parseUser(line: String): User {
val parts = line.split(",")
require(parts.size == 2) { "사용자 데이터 형식이 올바르지 않습니다: $line" }
val id = parts[0].trim().toLong()
val name = parts[1].trim()
require(name.isNotEmpty()) { "사용자 이름은 비어 있을 수 없습니다." }
return User(id, name)
}이 예제에는 몇 가지 중요한 판단이 들어 있다.
첫째, 파일 reader는 use로 닫는다.
둘째, 줄을 읽고 변환하는 작업은 use 블록 안에서 끝낸다. lineSequence()를 밖으로 반환하지 않는다.
셋째, 실패하면 Result.failure로 호출자에게 전달한다.
넷째, UserLoadException으로 맥락을 추가한다. 단순히 NumberFormatException이나 IOException만 전달하면 호출자는 어느 파일을 읽다가 실패했는지 알기 어렵다.
호출자는 다음처럼 처리할 수 있다.
val usersResult = loadUsers("users.csv")
usersResult.fold(
onSuccess = { users ->
println("사용자 ${users.size}명을 불러왔습니다.")
},
onFailure = { error ->
println(error.message)
}
)어디에서 잡고 어디로 전달할까
예외 처리는 "무조건 빨리 잡기"가 아니다. 중요한 기준은 그 위치에서 의미 있게 처리할 수 있는가이다.
다음 함수는 예외를 잡지 않는 편이 낫다.
fun parseUserId(input: String): Long {
return input.toLong()
}이 함수는 문자열을 숫자로 바꾸는 책임만 가진다. 실패했을 때 사용자에게 메시지를 보여 줄지, 기본값을 사용할지, 요청을 거부할지는 이 함수가 알기 어렵다.
반대로 사용자 입력을 처리하는 경계에서는 예외를 잡고 메시지로 바꿀 수 있다.
fun handleUserInput(input: String) {
val result = runCatching {
parseUserId(input)
}
result.fold(
onSuccess = { id -> println("사용자 ID: $id") },
onFailure = { println("사용자 ID는 숫자로 입력해야 합니다.") }
)
}일반적으로 예외는 다음 위치에서 처리하기 좋다.
사용자에게 응답을 만들어야 하는 경계
재시도나 대체 경로를 선택할 수 있는 위치
트랜잭션을 되돌려야 하는 위치
로그를 남기고 프로그램을 안전하게 종료할 수 있는 위치
반대로 낮은 수준의 함수에서 무리하게 모든 예외를 잡으면 호출자가 선택할 수 있는 여지가 사라진다.
예외, null, Result 선택하기
실전에서는 세 방법을 모두 사용한다. 중요한 것은 일관성이다.
null이 어울리는 경우:
fun findUser(id: Long): User?사용자를 찾지 못하는 것이 자연스러운 결과라면 nullable 타입이 간단하다.
Result가 어울리는 경우:
fun loadUserProfile(id: Long): Result<UserProfile>네트워크, 파일, 외부 API처럼 실패 이유가 중요하고 호출자가 처리해야 한다면 Result가 좋다.
예외가 어울리는 경우:
fun calculateDiscountRate(rate: Double): Double {
require(rate in 0.0..1.0) { "할인율은 0.0부터 1.0 사이여야 합니다." }
return rate
}잘못된 인자, 잘못된 상태, 프로그래밍 오류에 가까운 문제는 예외가 적합하다.
한 프로젝트 안에서 다음과 같은 규칙을 정해 두면 코드가 읽기 쉬워진다.
단순히 없을 수 있는 값은 nullable로 표현한다.
복구 가능한 작업 실패는
Result로 표현한다.잘못된 사용법이나 불변식 위반은 예외로 알린다.
예외를 잡을 때는 구체적인 타입을 우선한다.
예외를 변환할 때는 원인을
cause로 보존한다.
사용자 정의 예외 만들기
표준 예외만으로 충분할 때도 많다. 하지만 도메인 맥락이 중요하다면 사용자 정의 예외가 도움이 된다.
class PaymentException(
message: String,
cause: Throwable? = null
) : RuntimeException(message, cause)사용 예시는 다음과 같다.
fun pay(orderId: Long, amount: Int): Result<Unit> {
return try {
require(amount > 0) { "결제 금액은 0보다 커야 합니다." }
requestPayment(amount)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(PaymentException("결제에 실패했습니다. orderId=$orderId", e))
}
}사용자 정의 예외를 만들 때는 이름에 실패한 작업이나 도메인을 드러내는 것이 좋다.
PaymentExceptionOrderSaveExceptionUserLoadExceptionInvalidConfigException
반대로 MyException, CommonException처럼 의미가 넓은 이름은 피하는 편이 좋다. 이름만 봐도 어떤 종류의 실패인지 알 수 있어야 한다.
안전한 자원 관리 체크리스트
파일, 네트워크, 스트림을 다룰 때는 다음을 확인하자.
자원을 열었다면 반드시 닫히는가?
close()를 직접 호출하기보다use를 사용할 수 있는가?지연 계산되는
Sequence를 닫힌 자원 밖으로 내보내지 않았는가?실패했을 때 호출자가 원인을 알 수 있는가?
예외를 잡았다면 실제로 복구했는가, 아니면 숨겼는가?
새 예외로 감쌀 때 원래 예외를
cause로 보존했는가?기본값으로 복구하는 경우, 그 기본값이 정말 안전한가?
이 질문에 답할 수 있으면 예외 처리는 훨씬 안정적이 된다.
정리
코틀린의 예외 처리는 문법보다 설계가 중요하다. try-catch-finally는 실패한 코드를 감싸는 도구이고, try 표현식은 성공과 실패 상황에서 값을 만들 수 있게 해 준다. 하지만 아무 예외나 잡고 조용히 넘어가면 문제는 더 커진다.
코틀린에는 checked exception이 없으므로 실패 가능성을 API에 어떻게 드러낼지 개발자가 결정해야 한다. 단순히 없을 수 있는 값은 nullable 타입으로, 복구 가능한 실패는 Result로, 잘못된 사용법이나 상태 위반은 예외로 표현하면 기준이 선다.
파일과 스트림 같은 자원은 use로 관리하자. 예외가 발생해도 자동으로 닫히기 때문에 실수를 줄일 수 있다. 특히 Sequence처럼 지연 처리되는 값은 use 블록 밖으로 함부로 꺼내지 말아야 한다.
실전 프로그램은 실패하지 않는 코드가 아니라, 실패해도 원인을 설명하고 안전하게 정리하며 다음 행동을 선택할 수 있는 코드가 필요하다. 예외 처리와 자원 관리는 그 기반이 된다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.