Kim-Baek 개발자 이야기

Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result로 안전한 코드 만들기 본문

개발/java basic

Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result로 안전한 코드 만들기

김백개발자 2025. 12. 28. 13:49
반응형

이 글을 읽으면: Java의 복잡한 예외 처리를 넘어 Kotlin의 우아한 에러 핸들링 방법을 배울 수 있습니다. 실무에서 바로 쓸 수 있는 안전하고 읽기 쉬운 에러 처리 패턴을 실전 예제로 마스터하세요.


📌 목차

  1. 들어가며 - 예외 처리, 왜 중요할까?
  2. Java vs Kotlin 예외 처리
  3. try-catch-finally 기본
  4. runCatching - 함수형 예외 처리
  5. Result 타입 완벽 활용
  6. Custom Exception 설계
  7. 실전 에러 핸들링 패턴
  8. 마무리 - 다음 편 예고

들어가며 - 예외 처리, 왜 중요할까?

실제 프로젝트에서 겪은 사고

2024년 8월, 월요일 오전 10시

상황: 쇼핑몰 서비스 전체 다운
원인: 결제 API에서 NullPointerException 발생
      → try-catch 없이 예외가 그대로 전파
      → 전체 서버 500 에러
영향: 30분간 서비스 중단, 매출 손실 약 500만원
교훈: "설마 null일까?"는 절대 금물

개발자의 착각 vs 현실

개발자 생각 현실

"이 API는 항상 성공할 거야" → 네트워크 끊김
"데이터베이스는 항상 있어" → DB 서버 다운
"파일은 항상 있을 거야" → 권한 없음
"사용자는 올바른 값을 입력해" → "나이: -100살"

예외 처리를 안 하면 생기는 일

// ❌ 위험한 코드 (실제 있었던 사례)
fun getUserEmail(userId: String): String {
    val user = database.findUser(userId)  // null일 수 있음
    return user.email  // 💥 NPE 발생 → 서버 다운
}

// ✅ 안전한 코드
fun getUserEmail(userId: String): String {
    return try {
        val user = database.findUser(userId)
        user?.email ?: "이메일 없음"
    } catch (e: Exception) {
        println("에러 발생: ${e.message}")
        "이메일 없음"
    }
}

이 글에서 배울 것

  1. try-catch를 언제, 어떻게 쓸까? (기본기)
  2. runCatching으로 코드 간결하게 만들기 (실용 팁)
  3. Result 타입으로 에러를 값처럼 다루기 (고급 패턴)
  4. 실무에서 바로 쓸 수 있는 패턴 (실전 예제)

Java vs Kotlin 예외 처리

Java의 체크 예외 - 강제하지만 귀찮다

Java의 철학: "모든 예외를 명시해라"

// Java - 체크 예외 강제
public String readFile(String path) throws IOException {
    FileReader reader = new FileReader(path);  // throws 필수
    // ...
}

// 호출하는 쪽도 강제로 처리
public void process() {
    try {
        String content = readFile("data.txt");
    } catch (IOException e) {  // 강제로 처리해야 함
        e.printStackTrace();
    }
}

장점과 단점

장점: 예외 처리를 잊어버릴 수 없음
단점: 너무 귀찮아서 빈 catch 블록 남발

// 실제로 자주 보는 코드 (좋지 않음)
try {
    riskyOperation();
} catch (Exception e) {
    // TODO: 나중에 처리하기
}

Kotlin의 언체크 예외 - 자유롭지만 책임감 필요

Kotlin의 철학: "예외는 선택이다"

// Kotlin - throws 불필요
fun readFile(path: String): String {
    return File(path).readText()  // 예외 발생 가능하지만 명시 안 해도 됨
}

// 호출하는 쪽도 자유
fun process() {
    val content = readFile("data.txt")  // try-catch 선택사항
}

왜 이렇게 바뀌었을까?

Bruce Eckel (Thinking in Java 저자)의 말:
"체크 예외는 좋은 아이디어였지만, 
 실제로는 개발자들이 빈 catch 블록만 만들었다.
 차라리 필요할 때만 처리하게 하자."

그럼 Kotlin에서는 어떻게?

// 필요한 곳에만 처리
fun importantOperation() {
    try {
        riskyOperation()
    } catch (e: Exception) {
        logger.error("중요한 작업 실패", e)
        // 실제 복구 로직
    }
}

// 안 중요한 곳은 그냥 전파
fun minorOperation() {
    riskyOperation()  // 예외 그냥 던짐
}

try-catch-finally 기본

가장 기본적인 형태

"뭔가 잘못될 수 있다면, try로 감싸라"

fun divide(a: Int, b: Int): Int {
    return try {
        a / b
    } catch (e: ArithmeticException) {
        println("0으로 나눌 수 없습니다!")
        0  // 기본값 반환
    }
}

fun main() {
    println(divide(10, 2))  // 5
    println(divide(10, 0))  // 0으로 나눌 수 없습니다! → 0
}

실무 예제: 파일 읽기

fun readUserData(filename: String): String {
    return try {
        File(filename).readText()
    } catch (e: FileNotFoundException) {
        println("파일을 찾을 수 없습니다: $filename")
        "{}"  // 빈 JSON 반환
    } catch (e: IOException) {
        println("파일 읽기 실패: ${e.message}")
        "{}"
    }
}

왜 여러 개의 catch를 쓸까?

비유: 병원 응급실

- FileNotFoundException → "환자가 안 왔어요" (파일 없음)
- IOException → "치료 중 문제 발생" (읽기 오류)  
- Exception → "알 수 없는 증상" (기타 오류)

각각 다르게 대응해야 하니까 분리!

try는 표현식이다 - 값을 반환할 수 있다

Java와의 차이점

// Java - 변수 선언 후 할당
String result;
try {
    result = readFile();
} catch (Exception e) {
    result = "기본값";
}
// Kotlin - 바로 할당 가능
val result = try {
    readFile()
} catch (e: Exception) {
    "기본값"
}

실무 활용: API 호출

fun fetchUserName(userId: String): String {
    return try {
        api.getUser(userId).name
    } catch (e: NetworkException) {
        "네트워크 오류"
    } catch (e: Exception) {
        "알 수 없는 사용자"
    }
}

// 사용
val name = fetchUserName("user123")
println("안녕하세요, $name님!")

finally - 무조건 실행되는 코드

"성공하든 실패하든, 이건 꼭 해야 해"

fun processFile(filename: String) {
    val file = File(filename)
    var reader: BufferedReader? = null
    
    try {
        reader = file.bufferedReader()
        val content = reader.readText()
        println(content)
    } catch (e: IOException) {
        println("파일 읽기 실패")
    } finally {
        reader?.close()  // 무조건 실행 (자원 해제)
        println("정리 완료")
    }
}

finally가 필요한 상황

  1. 파일 닫기 - 열었으면 꼭 닫아야
  2. DB 연결 해제 - 연결했으면 꼭 끊어야
  3. Lock 해제 - 잠갔으면 꼭 풀어야
  4. 로그 남기기 - 성공/실패 상관없이 기록

더 나은 방법: use 함수

// Kotlin의 use - 자동으로 닫아줌
fun processFile(filename: String) {
    File(filename).bufferedReader().use { reader ->
        val content = reader.readText()
        println(content)
    }  // use 블록 끝나면 자동으로 close()
}

runCatching - 함수형 예외 처리

왜 runCatching이 생겼을까?

문제 상황

// 여러 단계의 예외 처리 - 지저분함
fun processUser(userId: String): String {
    try {
        val user = try {
            fetchUser(userId)
        } catch (e: NetworkException) {
            return "네트워크 오류"
        }
        
        val profile = try {
            fetchProfile(user.id)
        } catch (e: Exception) {
            return "프로필 로드 실패"
        }
        
        return profile.displayName
    } catch (e: Exception) {
        return "알 수 없는 오류"
    }
}

runCatching으로 해결

fun processUser(userId: String): String {
    return runCatching {
        val user = fetchUser(userId)
        val profile = fetchProfile(user.id)
        profile.displayName
    }.getOrElse { 
        "오류 발생: ${it.message}" 
    }
}

runCatching 기본 사용법

성공/실패를 Result로 감싸준다

fun divide(a: Int, b: Int): Result<Int> {
    return runCatching { a / b }
}

fun main() {
    val success = divide(10, 2)
    println(success.isSuccess)  // true
    println(success.getOrNull()) // 5
    
    val failure = divide(10, 0)
    println(failure.isFailure)  // true
    println(failure.getOrNull()) // null
}

getOrElse - 실패하면 기본값

fun getUserAge(userId: String): Int {
    return runCatching {
        api.getUser(userId).age
    }.getOrElse { 0 }  // 실패하면 0
}

getOrDefault - 더 명확하게

fun getUserEmail(userId: String): String {
    return runCatching {
        database.findUser(userId).email
    }.getOrDefault("이메일 없음")
}

체이닝 - map, mapCatching

성공했을 때만 변환하기

fun getUserDisplayName(userId: String): String {
    return runCatching {
        fetchUser(userId)
    }.map { user ->
        "${user.name} (${user.age}세)"
    }.getOrElse { 
        "알 수 없는 사용자" 
    }
}

실무 예제: API 호출 → 파싱 → 변환

fun fetchAndProcessData(url: String): List<String> {
    return runCatching {
        // 1단계: API 호출
        http.get(url).body
    }.mapCatching { json ->
        // 2단계: JSON 파싱
        JsonParser.parse(json)
    }.map { data ->
        // 3단계: 변환
        data.map { it.toString() }
    }.getOrElse { 
        emptyList()  // 어느 단계든 실패하면 빈 리스트
    }
}

onSuccess, onFailure - 부가 작업

성공/실패 시 로깅

fun processPayment(amount: Int) {
    runCatching {
        paymentGateway.charge(amount)
    }.onSuccess { receipt ->
        logger.info("결제 성공: ${receipt.id}")
        sendEmail("결제 완료", receipt)
    }.onFailure { error ->
        logger.error("결제 실패: ${error.message}")
        alertAdmin(error)
    }
}

체이닝 활용

fun registerUser(email: String, password: String) {
    runCatching {
        validateEmail(email)
        validatePassword(password)
        database.createUser(email, password)
    }.onSuccess { user ->
        sendWelcomeEmail(user)
        logger.info("회원가입 성공: ${user.id}")
    }.onFailure { error ->
        when (error) {
            is ValidationException -> logger.warn("검증 실패: ${error.message}")
            is DatabaseException -> logger.error("DB 오류: ${error.message}")
            else -> logger.error("알 수 없는 오류", error)
        }
    }
}

Result 타입 완벽 활용

Result가 뭐길래?

비유: 택배 상자

일반 함수: "물건만 주세요"
  → 물건 또는 예외를 던짐
  
Result 함수: "택배 상자를 주세요"
  → 상자 안에 물건 또는 실패 메모가 들어있음
  → 상자를 열어봐야 알 수 있음

코드로 보면

// 일반 함수
fun divide(a: Int, b: Int): Int {
    if (b == 0) throw Exception("0으로 나눌 수 없음")
    return a / b
}

// Result 함수  
fun safeDivide(a: Int, b: Int): Result<Int> {
    return if (b == 0) {
        Result.failure(Exception("0으로 나눌 수 없음"))
    } else {
        Result.success(a / b)
    }
}

Result의 장점

1. 예외를 값처럼 다룰 수 있다

val result1 = safeDivide(10, 2)
val result2 = safeDivide(10, 0)

// 값을 모아서 처리
val results = listOf(result1, result2)
val successes = results.filter { it.isSuccess }
println("성공한 것만: ${successes.size}개")

2. 타입으로 실패 가능성을 명시

// 이 함수는 실패할 수 있다는 게 타입에 드러남
fun fetchUser(id: String): Result<User>

// 호출하는 쪽에서 알 수 있음
val userResult = fetchUser("123")
// "아, 이 함수는 실패할 수 있구나"

3. 함수형 스타일로 처리 가능

fetchUser("123")
    .map { it.name }                    // 성공 시 이름만
    .map { it.uppercase() }             // 대문자로
    .getOrElse { "UNKNOWN" }            // 실패 시 기본값

실전 예제: 다단계 작업

문제: 사용자 정보 → 주문 내역 → 총액 계산

data class User(val id: String, val name: String)
data class Order(val userId: String, val amount: Int)

fun fetchUser(id: String): Result<User> = runCatching {
    // DB에서 사용자 조회
    User(id, "규철")
}

fun fetchOrders(userId: String): Result<List<Order>> = runCatching {
    // DB에서 주문 조회
    listOf(
        Order(userId, 10000),
        Order(userId, 20000)
    )
}

fun getTotalAmount(userId: String): Result<Int> {
    return fetchUser(userId)
        .mapCatching { user ->
            fetchOrders(user.id).getOrThrow()
        }.map { orders ->
            orders.sumOf { it.amount }
        }
}

fun main() {
    val total = getTotalAmount("user123")
    
    total.onSuccess { amount ->
        println("총 주문액: ${amount}원")
    }.onFailure { error ->
        println("조회 실패: ${error.message}")
    }
}

Custom Exception 설계

왜 커스텀 예외를 만들까?

일반 예외의 문제점

// ❌ 나쁜 예 - 뭐가 문제인지 모름
throw Exception("오류 발생")

// ✅ 좋은 예 - 명확함
throw UserNotFoundException("사용자를 찾을 수 없습니다: $userId")
throw InsufficientBalanceException("잔액 부족: 필요 ${amount}원")

기본 커스텀 예외

// 기본 형태
class UserNotFoundException(message: String) : Exception(message)

class InvalidEmailException(
    val email: String,
    message: String = "올바르지 않은 이메일: $email"
) : Exception(message)

// 사용
fun findUser(email: String): User {
    if (!email.contains("@")) {
        throw InvalidEmailException(email)
    }
    
    return database.find(email) 
        ?: throw UserNotFoundException("이메일: $email")
}

계층 구조 설계

실무 예제: 결제 시스템

// 최상위 예외
sealed class PaymentException(message: String) : Exception(message)

// 세부 예외
class InsufficientBalanceException(
    val required: Int,
    val current: Int
) : PaymentException("잔액 부족: ${required}원 필요, ${current}원 보유")

class PaymentGatewayException(
    val gatewayName: String,
    cause: Throwable
) : PaymentException("$gatewayName 오류: ${cause.message}")

class InvalidAmountException(
    val amount: Int
) : PaymentException("잘못된 금액: $amount")

// 사용
fun processPayment(userId: String, amount: Int) {
    when {
        amount <= 0 -> throw InvalidAmountException(amount)
        !hasEnoughBalance(userId, amount) -> {
            val balance = getBalance(userId)
            throw InsufficientBalanceException(amount, balance)
        }
    }
    
    try {
        paymentGateway.charge(amount)
    } catch (e: Exception) {
        throw PaymentGatewayException("토스페이", e)
    }
}

// 호출
fun checkout(userId: String, amount: Int) {
    try {
        processPayment(userId, amount)
        println("결제 성공!")
    } catch (e: PaymentException) {
        when (e) {
            is InsufficientBalanceException -> {
                println("잔액 ${e.current}원 부족합니다")
                println("${e.required - e.current}원 충전하세요")
            }
            is InvalidAmountException -> {
                println("금액을 확인하세요")
            }
            is PaymentGatewayException -> {
                println("결제사 오류. 잠시 후 다시 시도하세요")
            }
        }
    }
}

실전 패턴 모음

패턴 1: 재시도 로직

"3번까지는 다시 시도해보자"

fun <T> retry(
    times: Int = 3,
    delay: Long = 1000,
    block: () -> T
): Result<T> {
    repeat(times - 1) { attempt ->
        runCatching { block() }
            .onSuccess { return Result.success(it) }
            .onFailure { 
                println("시도 ${attempt + 1} 실패, ${delay}ms 후 재시도")
                Thread.sleep(delay)
            }
    }
    
    // 마지막 시도
    return runCatching { block() }
}

// 사용
fun fetchData(): String {
    return retry(times = 3, delay = 1000) {
        api.getData()  // 네트워크 요청
    }.getOrElse { "기본 데이터" }
}

패턴 2: Fallback 체인

"A 안 되면 B, B 안 되면 C"

fun getUserName(userId: String): String {
    return runCatching {
        // 1순위: 캐시
        cache.get(userId)
    }.recoverCatching {
        // 2순위: DB
        database.find(userId).name
    }.recoverCatching {
        // 3순위: 외부 API
        api.fetchUser(userId).name
    }.getOrElse {
        // 최후: 기본값
        "알 수 없는 사용자"
    }
}

패턴 3: 검증 체인

"여러 검증을 한 번에"

class ValidationException(message: String) : Exception(message)

fun validateSignup(
    email: String,
    password: String,
    age: Int
): Result<Unit> {
    return runCatching {
        require(email.contains("@")) { 
            "이메일 형식이 올바르지 않습니다" 
        }
        require(password.length >= 8) { 
            "비밀번호는 8자 이상이어야 합니다" 
        }
        require(age >= 14) { 
            "만 14세 이상만 가입 가능합니다" 
        }
    }
}

// 사용
fun signup(email: String, password: String, age: Int) {
    validateSignup(email, password, age)
        .onSuccess {
            createAccount(email, password)
            println("가입 완료!")
        }
        .onFailure { error ->
            println("가입 실패: ${error.message}")
        }
}

마무리 - 다음 편 예고

오늘 배운 것 ✅

  • try-catch-finally - 기본 예외 처리
  • 체크 예외가 없는 Kotlin - 자유롭지만 책임감 필요
  • runCatching - 함수형 스타일로 간결하게
  • Result 타입 - 예외를 값처럼 다루기
  • Custom Exception - 명확한 에러 메시지
  • 실전 패턴 - 재시도, Fallback, 검증

다음 편에서 배울 것 📚

14편: 코루틴 기초 | launch, async, suspend로 비동기 정복하기

  • 코루틴이 뭐길래?
  • launch vs async 차이
  • suspend 함수 만들기
  • CoroutineScope와 Job
  • 실전 비동기 패턴

핵심 정리

예외 처리 3원칙

  1. 예상 가능한 건 try-catch로 처리
  2. 함수형으로 하고 싶으면 runCatching
  3. 명확한 에러는 Custom Exception

💬 댓글로 알려주세요!

  • 예외 처리로 고생한 경험이 있나요?
  • runCatching vs try-catch 중 뭘 선호하시나요?
  • 이 글이 도움이 되셨나요?

 

반응형
Comments