KOTLIN · 심층 가이드
Kotlin 예외처리 완전 정리
checked exception이 없는 언어에서 실패를 어디까지 값으로 표현하고 어느 계층에서 던질지, 그 기준을 세우는 13개 주제를 다룹니다.
Kotlin에는 checked exception이 없습니다. 컴파일러가 "이 함수는 실패할 수 있다"고 알려 주지 않으므로, 실패 가능성은 전적으로 API 설계자가 타입이나 문서로 드러내야 합니다. 자바가 강제하던 안전망이 사라진 자리를 무엇으로 채울지가 이 주제의 전부라고 봐도 됩니다. 던질지, Result로 감쌀지, sealed 계층으로 나눌지를 함수 단위가 아니라 계층 단위로 정해 두면 코드베이스가 일관됩니다.
try-catch-finally & 커스텀 예외로 기본 동작을 확인한 다음 require와 check를 보면, 인수 검증과 상태 검증을 구분해서 던지는 습관이 생깁니다. 이 둘은 프로그래머 실수를 빠르게 드러내는 용도라 잡아서 복구할 대상이 아닙니다. 반대로 복구 대상인 실패는 runCatching & Result와 커스텀 예외 계층에서 다룹니다. 외부 시스템이 상대라면 재시도 패턴과 서킷 브레이커까지 이어서 읽는 편이 실전에 가깝습니다.
runCatching은 Throwable을 전부 잡습니다. OutOfMemoryError도, 그리고 코루틴 안이라면 취소 신호인 CancellationException까지 삼킵니다. 취소된 코루틴이 계속 다음 단계로 진행하는 버그가 여기서 나옵니다. 코루틴 안에서 쓸 때는 잡은 예외가 취소인지 확인해 다시 던지거나, 애초에 대상 예외를 좁혀 try/catch로 처리하는 편이 안전합니다. 재시도 루프에 runCatching을 넣을 때는 특히 그렇습니다.
01try-catch-finally & 커스텀 예외
Kotlin의 예외 처리. 모든 예외가 unchecked입니다(checked exception 없음).
Kotlin code
// 커스텀 예외
class ValidationException(
message: String,
val field: String
) : IllegalArgumentException(message)
class NotFoundException(val id: Int)
: RuntimeException("ID $id를 찾을 수 없습니다")
// try는 표현식 — 값을 반환
fun parseInt(s: String): Int? =
try { s.toInt() }
catch (e: NumberFormatException) { null }
fun findUser(id: Int): String {
if (id <= 0) throw ValidationException("ID는 양수", "id")
if (id > 100) throw NotFoundException(id)
return "User#$id"
}
fun main() {
// try-catch-finally
try {
println(findUser(0))
} catch (e: ValidationException) {
println("검증 오류 [${e.field}]: ${e.message}")
} catch (e: NotFoundException) {
println("없음: ${e.message}")
} finally {
println("항상 실행")
}
// try 표현식
val n1 = parseInt("42") // 42
val n2 = parseInt("abc") // null
println("$n1, $n2")
}Kotlin에는 checked exception이 없습니다. @Throws(IOException::class) 어노테이션은 Java 상호운용을 위해 선언만 하는 것이며, Kotlin 내부에서는 강제되지 않습니다.
catch (e: Exception)으로 모든 예외를 잡으면 CancellationException도 잡힙니다. 코루틴에서는 CancellationException을 다시 throw해야 코루틴이 정상 취소됩니다.
02runCatching & Result
함수형 스타일의 에러 처리. Result 타입으로 성공/실패를 명시적으로 표현합니다.
Kotlin code
// runCatching — try-catch를 Result로 감쌈
fun divide(a: Int, b: Int): Result<Int> =
runCatching { a / b }
// fold — 성공/실패 처리
fun main() {
divide(10, 2)
.fold(
onSuccess = { println("결과: $it") },
onFailure = { println("에러: ${it.message}") }
)
divide(10, 0)
.fold(
onSuccess = { println("결과: $it") },
onFailure = { println("에러: ${it.message}") }
)
// map / mapCatching — 체이닝
val result = runCatching { "42".toInt() }
.map { it * 2 }
.getOrDefault(0)
println(result) // 84
// getOrElse
val value = runCatching { "abc".toInt() }
.getOrElse { -1 }
println(value) // -1
// 여러 연산 체이닝
runCatching { fetchData() }
.mapCatching { parseData(it) }
.onSuccess { println("완료: $it") }
.onFailure { println("실패: ${it.message}") }
}
fun fetchData() = "raw-data"
fun parseData(s: String) = s.uppercase()runCatching은 Error 타입 예외(OutOfMemoryError 등)도 잡습니다. 치명적 에러를 무시하지 않도록 isFailure 체크 시 예외 타입을 확인하세요.
Android에서 Result를 ViewModel의 LiveData나 StateFlow에 담으면 제네릭 중첩 문제가 생길 수 있습니다. 별도 sealed class를 사용하는 것이 더 명확합니다.
03Arrow 함수형 라이브러리
Either, Option, IO 모나드로 함수형 에러 처리
Kotlin code
<span class="cm">// Arrow 함수형 라이브러리 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Arrow 함수형 라이브러리") }KOTLIN 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
04Result 타입 활용
Kotlin 표준 Result 타입으로 성공/실패를 명시적으로 표현합니다. 예외 대신 반환값으로 에러를 전달합니다.
Kotlin code
fun parseNumber(input: String): Result<Int> =
runCatching { input.trim().toInt() }
fun divide(a: Int, b: Int): Result<Double> =
if (b == 0) Result.failure(ArithmeticException("0으로 나눌 수 없음"))
else Result.success(a.toDouble() / b)
fun main() {
// 기본 사용
val result = parseNumber("42")
println(result.getOrDefault(0)) // 42
// 체이닝
val chained = parseNumber("10")
.mapCatching { divide(100, it).getOrThrow() }
.map { "결과: %.2f".format(it) }
println(chained.getOrElse { "오류: ${it.message}" })
// fold 패턴
parseNumber("abc").fold(
onSuccess = { println("성공: $it") },
onFailure = { println("실패: ${it.message}") }
)
// recover: 실패 복구
val recovered = parseNumber("xyz")
.recover { 0 }
println("복구: ${recovered.getOrNull()}")
}mapCatching은 변환 중 예외를 자동으로 Result.failure로 감싸줍니다. 안전한 체이닝에 유용합니다.
getOrThrow()를 무분별하게 사용하면 Result의 안전성 이점이 사라집니다. fold나 getOrElse를 선호하세요.
05require와 check
require는 인수 검증, check는 상태 검증에 사용합니다. 조건 불충족 시 설명적 예외를 던집니다.
Kotlin code
class BankAccount(val owner: String, initialBalance: Int) {
init {
require(owner.isNotBlank()) { "소유자 이름은 필수입니다" }
require(initialBalance >= 0) { "초기 잔액은 0 이상이어야 합니다: $initialBalance" }
}
var balance: Int = initialBalance
private set
fun deposit(amount: Int) {
require(amount > 0) { "입금액은 양수여야 합니다: $amount" }
balance += amount
}
fun withdraw(amount: Int) {
require(amount > 0) { "출금액은 양수여야 합니다: $amount" }
check(balance >= amount) { "잔액 부족: 잔액=$balance, 출금=$amount" }
balance -= amount
}
}
fun main() {
val account = BankAccount("김철수", 10000)
account.deposit(5000)
println("잔액: ${account.balance}")
account.withdraw(3000)
println("잔액: ${account.balance}")
try { account.withdraw(50000) }
catch (e: IllegalStateException) { println("오류: ${e.message}") }
try { account.deposit(-100) }
catch (e: IllegalArgumentException) { println("오류: ${e.message}") }
}require는 IllegalArgumentException을, check는 IllegalStateException을 던집니다. 의미에 맞게 구분하세요.
require와 check의 메시지를 생략하면 디버깅이 어렵습니다. 항상 관련 값을 포함한 설명적 메시지를 제공하세요.
06커스텀 예외 계층 (Custom Exception Hierarchy)
sealed class로 도메인별 예외 계층을 설계합니다. 예외의 구조적 처리와 exhaustive 검사를 지원합니다.
Kotlin code
sealed class AppException(message: String, cause: Throwable? = null)
: Exception(message, cause) {
class NetworkException(
message: String,
val statusCode: Int,
cause: Throwable? = null
) : AppException(message, cause)
class ValidationException(
val field: String,
val reason: String
) : AppException("검증 실패 - $field: $reason")
class AuthException(
message: String
) : AppException(message)
}
fun handleError(e: AppException): String = when (e) {
is AppException.NetworkException ->
"네트워크 오류(${e.statusCode}): ${e.message}"
is AppException.ValidationException ->
"입력 오류[${e.field}]: ${e.reason}"
is AppException.AuthException ->
"인증 오류: ${e.message}"
}
fun main() {
val errors = listOf(
AppException.NetworkException("타임아웃", 504),
AppException.ValidationException("email", "형식 불일치"),
AppException.AuthException("토큰 만료"),
)
errors.forEach { println(handleError(it)) }
}sealed class 예외는 when에서 모든 분기를 강제하므로 새 예외 타입 추가 시 처리 누락을 컴파일 타임에 잡을 수 있습니다.
예외 계층이 너무 깊으면 복잡해집니다. 2-3단계를 넘지 않도록 하고, 정말 다른 처리가 필요한 경우에만 하위 타입을 추가하세요.
07예외 vs 반환값 선택 기준
예외와 반환값(Result/Either) 중 적절한 에러 처리 방식을 선택하는 기준을 다룹니다.
Kotlin code
// 예외 사용: 예측 불가능한 실패, 프로그래밍 오류
fun readConfig(path: String): Map<String, String> {
require(path.isNotEmpty()) { "경로가 비어있습니다" }
// 파일이 없으면 예외 (인프라 문제)
return mapOf("key" to "value")
}
// Result 사용: 예측 가능한 실패, 비즈니스 규칙
fun validateAge(input: String): Result<Int> = runCatching {
val age = input.toInt()
require(age in 1..150) { "유효하지 않은 나이: $age" }
age
}
// sealed class 사용: 여러 실패 케이스
sealed class LoginResult {
data class Success(val token: String) : LoginResult()
data class WrongPassword(val attempts: Int) : LoginResult()
data object AccountLocked : LoginResult()
data object UserNotFound : LoginResult()
}
fun login(id: String, pw: String): LoginResult = when {
id != "admin" -> LoginResult.UserNotFound
pw != "1234" -> LoginResult.WrongPassword(1)
else -> LoginResult.Success("token-xyz")
}
fun main() {
// 각 방식의 처리
when (val result = login("admin", "wrong")) {
is LoginResult.Success -> println("토큰: ${result.token}")
is LoginResult.WrongPassword -> println("비밀번호 오류 (${result.attempts}회)")
LoginResult.AccountLocked -> println("계정 잠금")
LoginResult.UserNotFound -> println("사용자 없음")
}
}일반적으로: 프로그래밍 오류 → 예외, 비즈니스 규칙 → sealed class/Result, 외부 시스템 → Result/try-catch로 구분합니다.
비즈니스 로직에서 예외를 제어 흐름으로 남용하면 성능이 저하되고 코드가 복잡해집니다. 예측 가능한 실패에는 반환값을 사용하세요.
08실패 안전 패턴 (Fail-Safe)
실패 시에도 시스템이 안전하게 동작하도록 폴백과 기본값 전략을 설계합니다.
Kotlin code
class ResilientService {
private val cache = mutableMapOf<String, String>()
// 폴백 체인
suspend fun getData(key: String): String {
return tryPrimary(key)
?: tryCache(key)
?: tryFallback(key)
?: getDefault(key)
}
private suspend fun tryPrimary(key: String): String? = runCatching {
// 네트워크 호출 시뮬레이션
if (Math.random() > 0.5) throw RuntimeException("네트워크 오류")
"서버 데이터: $key".also { cache[key] = it }
}.getOrNull()
private fun tryCache(key: String): String? =
cache[key]?.also { println("캐시 히트: $key") }
private fun tryFallback(key: String): String? =
runCatching { "폴백 데이터: $key" }.getOrNull()
private fun getDefault(key: String): String =
"기본값: $key"
}
fun main() = kotlinx.coroutines.runBlocking {
val service = ResilientService()
repeat(5) {
val result = service.getData("item-$it")
println(result)
}
}폴백 체인은 우선순위 순서로 데이터 소스를 시도합니다. 각 단계를 독립적으로 테스트할 수 있어 유지보수가 쉽습니다.
폴백이 자동으로 성공하면 사용자가 데이터가 오래된 것을 모를 수 있습니다. 폴백 사용 시 경고나 표시를 함께 제공하세요.
09로깅 전략 (Logging Strategy)
구조화된 로깅으로 에러 추적을 효율화합니다. 로그 레벨과 컨텍스트 정보를 체계적으로 관리합니다.
Kotlin code
enum class LogLevel { DEBUG, INFO, WARN, ERROR }
class Logger(private val tag: String) {
private var minLevel = LogLevel.DEBUG
fun setLevel(level: LogLevel) { minLevel = level }
private fun log(level: LogLevel, message: String, error: Throwable? = null) {
if (level.ordinal < minLevel.ordinal) return
val time = java.time.LocalDateTime.now().toString().take(19)
val entry = "[$time][${level.name}][$tag] $message"
println(entry)
error?.let { println(" 원인: ${it.message}") }
}
fun debug(message: String) = log(LogLevel.DEBUG, message)
fun info(message: String) = log(LogLevel.INFO, message)
fun warn(message: String, e: Throwable? = null) = log(LogLevel.WARN, message, e)
fun error(message: String, e: Throwable) = log(LogLevel.ERROR, message, e)
// 구조화된 컨텍스트
fun withContext(vararg pairs: Pair<String, Any>): String =
pairs.joinToString(", ") { "${it.first}=${it.second}" }
}
fun main() {
val log = Logger("UserService")
log.info("서비스 시작")
log.debug("설정 로드 완료")
try {
throw RuntimeException("DB 연결 실패")
} catch (e: Exception) {
log.error(
"사용자 조회 실패 - ${log.withContext("userId" to "U001", "retry" to 3)}",
e
)
}
}로그 메시지에 관련 컨텍스트(ID, 파라미터, 재시도 횟수 등)를 포함하면 문제 추적이 훨씬 쉬워집니다.
ERROR 레벨에서 스택 트레이스 없이 메시지만 남기면 원인 추적이 어렵습니다. 예외 객체를 항상 함께 전달하세요.
10재시도 패턴 (Retry Pattern)
일시적 실패에 대해 지수 백오프로 재시도하는 패턴입니다. 최대 횟수와 타임아웃을 설정합니다.
Kotlin code
import kotlinx.coroutines.*
class RetryConfig(
val maxRetries: Int = 3,
val initialDelay: Long = 100,
val maxDelay: Long = 5000,
val factor: Double = 2.0
)
suspend fun <T> withRetry(
config: RetryConfig = RetryConfig(),
block: suspend (attempt: Int) -> T
): T {
var currentDelay = config.initialDelay
repeat(config.maxRetries) { attempt ->
try {
return block(attempt + 1)
} catch (e: Exception) {
println("시도 ${attempt + 1} 실패: ${e.message}")
if (attempt == config.maxRetries - 1) throw e
delay(currentDelay)
currentDelay = (currentDelay * config.factor).toLong()
.coerceAtMost(config.maxDelay)
}
}
throw IllegalStateException("도달 불가")
}
var callCount = 0
suspend fun unreliableApi(): String {
callCount++
if (callCount < 3) throw RuntimeException("서버 오류 ($callCount)")
return "성공!"
}
fun main() = runBlocking {
val result = withRetry(RetryConfig(maxRetries = 5)) { attempt ->
println("시도 $attempt...")
unreliableApi()
}
println("결과: $result")
}지수 백오프에 coerceAtMost로 최대 지연을 제한하면 무한히 긴 대기를 방지합니다.
모든 예외에 재시도하면 안 됩니다. 인증 오류(401)나 잘못된 요청(400) 같은 영구적 실패에 재시도는 무의미합니다.
11서킷 브레이커 (Circuit Breaker)
연속 실패 시 호출을 차단하여 시스템을 보호합니다. CLOSED → OPEN → HALF_OPEN 상태 전이를 관리합니다.
Kotlin code
import kotlinx.coroutines.*
class CircuitBreaker(
private val failureThreshold: Int = 3,
private val resetTimeout: Long = 5000
) {
enum class State { CLOSED, OPEN, HALF_OPEN }
private var state = State.CLOSED
private var failureCount = 0
private var lastFailureTime = 0L
suspend fun <T> execute(block: suspend () -> T): T {
return when (state) {
State.OPEN -> {
if (System.currentTimeMillis() - lastFailureTime > resetTimeout) {
state = State.HALF_OPEN
tryExecute(block)
} else {
throw RuntimeException("서킷 OPEN - 요청 차단")
}
}
State.HALF_OPEN -> tryExecute(block)
State.CLOSED -> tryExecute(block)
}
}
private suspend fun <T> tryExecute(block: suspend () -> T): T = try {
val result = block()
reset()
result
} catch (e: Exception) {
recordFailure()
throw e
}
private fun reset() { state = State.CLOSED; failureCount = 0 }
private fun recordFailure() {
failureCount++
lastFailureTime = System.currentTimeMillis()
if (failureCount >= failureThreshold) {
state = State.OPEN
println("서킷 OPEN! ($failureCount회 실패)")
}
}
}
fun main() = runBlocking {
val cb = CircuitBreaker(failureThreshold = 2)
repeat(5) { i ->
runCatching {
cb.execute { throw RuntimeException("API 오류 $i") }
}.onFailure { println("호출 $i: ${it.message}") }
}
}HALF_OPEN 상태에서 성공하면 CLOSED로 복구하여 정상 트래픽을 허용합니다. 자동 복구 메커니즘입니다.
서킷 브레이커의 임계값을 너무 낮게 설정하면 일시적 오류에도 서킷이 열립니다. 서비스 특성에 맞게 조절하세요.
12그레이스풀 셧다운 (Graceful Shutdown)
애플리케이션 종료 시 진행 중인 작업을 안전하게 완료하고 리소스를 정리하는 패턴입니다.
Kotlin code
import kotlinx.coroutines.*
class AppServer {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val activeJobs = mutableListOf<Job>()
fun handleRequest(id: Int) {
val job = scope.launch {
println("요청 $id 처리 시작")
delay(1000) // 작업 시뮬레이션
println("요청 $id 처리 완료")
}
activeJobs.add(job)
job.invokeOnCompletion { activeJobs.remove(job) }
}
suspend fun shutdown(timeout: Long = 5000) {
println("셧다운 시작 (활성 작업: ${activeJobs.size})")
// 새 요청 거부 (scope 취소하지 않고 플래그로)
println("새 요청 거부 중...")
// 진행 중인 작업 완료 대기
withTimeoutOrNull(timeout) {
activeJobs.forEach { it.join() }
} ?: run {
println("타임아웃! 남은 작업 강제 취소")
scope.cancel()
}
println("리소스 정리 중...")
// DB 연결 종료, 파일 닫기 등
delay(200)
println("셧다운 완료")
}
}
fun main() = runBlocking {
val server = AppServer()
repeat(5) { server.handleRequest(it) }
delay(300)
server.shutdown(timeout = 3000)
}Runtime.getRuntime().addShutdownHook에서 셧다운 로직을 호출하면 JVM 종료 시 자동으로 정리됩니다.
셧다운 타임아웃 없이 무한 대기하면 서버가 종료되지 않을 수 있습니다. 항상 타임아웃을 설정하고 초과 시 강제 종료하세요.
13runCatching 활용
runCatching으로 예외를 Result로 래핑하여 함수형으로 처리합니다. 체이닝과 복구 패턴을 다룹니다.
Kotlin code
data class User(val name: String, val email: String)
fun parseUser(json: String): User {
val parts = json.split(",")
require(parts.size == 2) { "잘못된 형식" }
return User(parts[0].trim(), parts[1].trim())
}
fun validateEmail(user: User): User {
require(user.email.contains("@")) { "유효하지 않은 이메일" }
return user
}
fun main() {
val inputs = listOf("김철수, kim@test.com", "이영희", "박지성, invalid")
inputs.forEach { input ->
val result = runCatching { parseUser(input) }
.mapCatching { validateEmail(it) }
.map { "✓ ${it.name} (${it.email})" }
.recover { "✗ 오류: ${it.message}" }
println(result.getOrNull())
}
// onSuccess/onFailure 패턴
runCatching { parseUser("홍길동, hong@test.com") }
.onSuccess { println("파싱 성공: $it") }
.onFailure { println("파싱 실패: ${it.message}") }
.mapCatching { validateEmail(it) }
.onSuccess { println("검증 성공: $it") }
.onFailure { println("검증 실패: ${it.message}") }
}onSuccess/onFailure는 부수 효과(로깅 등)를 위한 것이며, Result를 변경하지 않고 그대로 반환합니다.
recover 블록에서 다시 예외를 던지면 Result.failure가 됩니다. 복구 로직에서는 안전한 기본값을 반환하세요.
정리하며
- checked exception이 없으므로 실패 가능성은 반환 타입이나 문서로 직접 드러냅니다
- require·check로 걸리는 조건은 복구 대상이 아니라 즉시 실패시켜야 할 버그입니다
- runCatching은 CancellationException까지 잡으므로 코루틴 안에서는 재던짐이 필요합니다
- 재시도는 고정 간격 대신 지수 백오프와 지터, 그리고 중단 조건을 함께 정의합니다
더 깊이 들어가고 싶다면 Kotlin 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.