Kim-Baek 개발자 이야기

Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result 타입 본문

개발/java basic

Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result 타입

김백개발자 2025. 12. 6. 09:02
반응형

Kotlin의 함수형 스타일 예외 처리로 더 안전하고 우아한 코드 작성하기


목차

  1. Java vs Kotlin 예외 처리 비교
  2. 기본 try-catch-finally
  3. try를 Expression으로 사용하기
  4. runCatching - 함수형 예외 처리
  5. Result 타입 활용
  6. Spring Boot에서의 예외 처리
  7. Custom Exception 설계
  8. 실전 패턴과 Best Practices

들어가며

프로젝트에서 외부 API를 호출하는 코드를 작성하다가 예외 처리 때문에 고민이 많았습니다. Java 스타일로 작성하면 try-catch 블록이 중첩되어 코드가 지저분해지고, 예외가 발생했을 때 기본값을 반환하거나 로깅 후 재시도하는 로직을 깔끔하게 처리하기 어려웠습니다.

그러다 Kotlin의 runCatching과 Result 타입을 알게 되었고, 예외 처리 코드가 체이닝 가능하고 읽기 쉬운 함수형 스타일로 바뀌었습니다.

// Before (Java 스타일)
String result = null;
try {
    result = apiService.getData();
} catch (Exception e) {
    logger.error("API 호출 실패", e);
    result = "기본값";
}

// After (Kotlin 스타일)
val result = runCatching { 
    apiService.getData() 
}.onFailure { 
    logger.error("API 호출 실패", it) 
}.getOrDefault("기본값")

오늘은 Kotlin의 다양한 예외 처리 방법과 실전에서 유용한 패턴들을 알아보겠습니다.


1. Java vs Kotlin 예외 처리 비교

1.1 Checked Exception이 없다

Java의 Checked Exception

// Java - 반드시 처리해야 함
public String readFile(String path) throws IOException {
    return Files.readString(Path.of(path));
}

// 호출하는 곳에서 반드시 처리
try {
    String content = readFile("file.txt");
} catch (IOException e) {
    e.printStackTrace();
}

Kotlin은 모든 예외가 Unchecked

// Kotlin - throws 선언 불필요
fun readFile(path: String): String {
    return File(path).readText()  // IOException 발생 가능
}

// 호출하는 곳에서 선택적으로 처리
val content = readFile("file.txt")  // try-catch 강제 안 됨

1.2 왜 Checked Exception을 없앴을까?

문제점

// Java - 의미 없는 예외 처리가 강제됨
try {
    connection.close();
} catch (SQLException e) {
    // 할 수 있는 게 없는데 어쩔 수 없이 처리
}

Kotlin의 철학

  • 대부분의 Checked Exception은 복구 불가능
  • 예외 처리를 강제하면 빈 catch 블록이 생김
  • 필요한 곳에서만 명시적으로 처리

1.3 @Throws 어노테이션

// Java와의 상호 운용성을 위해
@Throws(IOException::class, IllegalArgumentException::class)
fun riskyOperation() {
    // Java에서 호출 시 checked exception처럼 처리됨
}

2. 기본 try-catch-finally

2.1 기본 구조

fun divide(a: Int, b: Int): Int {
    return try {
        a / b
    } catch (e: ArithmeticException) {
        println("0으로 나눌 수 없습니다: ${e.message}")
        0
    } finally {
        println("연산 완료")
    }
}

// 사용
val result = divide(10, 0)  // 0으로 나눌 수 없습니다

2.2 여러 예외 처리

fun parseAndProcess(input: String): Int {
    return try {
        val number = input.toInt()
        100 / number
    } catch (e: NumberFormatException) {
        println("숫자 형식이 아닙니다: ${e.message}")
        -1
    } catch (e: ArithmeticException) {
        println("0으로 나눌 수 없습니다: ${e.message}")
        -2
    } catch (e: Exception) {
        println("알 수 없는 오류: ${e.message}")
        -999
    } finally {
        println("처리 완료")
    }
}

2.3 예외 타입 체크와 스마트 캐스트

fun handleException(e: Exception) {
    when (e) {
        is IOException -> println("IO 오류: ${e.message}")
        is IllegalArgumentException -> println("잘못된 인자: ${e.message}")
        is NullPointerException -> println("Null 참조: ${e.message}")
        else -> println("기타 오류: ${e.message}")
    }
}

// 또는 catch에서 직접 분기
try {
    riskyOperation()
} catch (e: Exception) {
    when (e) {
        is IOException -> handleIoError(e)
        is TimeoutException -> retryOperation()
        else -> throw e  // 처리 못하는 예외는 다시 던짐
    }
}

2.4 finally의 실행 보장

fun processFile(path: String) {
    val file = File(path)
    val reader = file.bufferedReader()
    
    try {
        val content = reader.readText()
        processContent(content)
    } catch (e: IOException) {
        println("파일 읽기 실패: ${e.message}")
    } finally {
        reader.close()  // 예외 발생 여부와 관계없이 실행
    }
}

// 더 나은 방법: use (자동 close)
fun processFile(path: String) {
    File(path).bufferedReader().use { reader ->
        val content = reader.readText()
        processContent(content)
    }  // use가 자동으로 close 호출
}

3. try를 Expression으로 사용하기

3.1 값을 반환하는 try

// Java - try는 statement
String result;
try {
    result = getData();
} catch (Exception e) {
    result = "default";
}

// Kotlin - try는 expression
val result = try {
    getData()
} catch (e: Exception) {
    "default"
}

3.2 복잡한 로직도 깔끔하게

val config = try {
    File("config.json").readText()
        .let { json -> parseConfig(json) }
} catch (e: FileNotFoundException) {
    println("설정 파일 없음, 기본값 사용")
    Config.default()
} catch (e: JsonParseException) {
    println("설정 파일 파싱 실패, 기본값 사용")
    Config.default()
}

3.3 함수에서 바로 반환

fun getUser(id: Long): User {
    return try {
        userRepository.findById(id)
            .orElseThrow { NotFoundException("User not found") }
    } catch (e: NotFoundException) {
        User.anonymous()
    }
}

// 더 간결하게
fun getUser(id: Long): User = try {
    userRepository.findById(id).orElseThrow()
} catch (e: NoSuchElementException) {
    User.anonymous()
}

3.4 when과 조합

val status = when {
    isOnline() -> "온라인"
    else -> try {
        checkConnection()
        "연결 복구"
    } catch (e: Exception) {
        "오프라인"
    }
}

3.5 실전 예제: API 응답 처리

fun fetchUserData(userId: String): UserDto {
    val response = try {
        httpClient.get("https://api.example.com/users/$userId")
    } catch (e: ConnectException) {
        throw ServiceUnavailableException("서버에 연결할 수 없습니다")
    } catch (e: SocketTimeoutException) {
        throw TimeoutException("요청 시간 초과")
    }
    
    return try {
        objectMapper.readValue(response, UserDto::class.java)
    } catch (e: JsonProcessingException) {
        throw BadRequestException("응답 파싱 실패: ${e.message}")
    }
}

4. runCatching - 함수형 예외 처리

4.1 runCatching 기본

// 전통적인 방식
fun parseNumber(input: String): Int? {
    return try {
        input.toInt()
    } catch (e: NumberFormatException) {
        null
    }
}

// runCatching 사용
fun parseNumber(input: String): Int? {
    return runCatching { 
        input.toInt() 
    }.getOrNull()
}

4.2 Result 타입 반환

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

// 사용
val result = divide(10, 2)
if (result.isSuccess) {
    println("결과: ${result.getOrNull()}")
} else {
    println("오류: ${result.exceptionOrNull()?.message}")
}

4.3 체이닝 메서드들

getOrNull()

val result = runCatching { 
    "123".toInt() 
}.getOrNull()  // 성공: 123, 실패: null

getOrDefault()

val result = runCatching { 
    "abc".toInt() 
}.getOrDefault(0)  // 실패 시 0 반환

getOrElse()

val result = runCatching { 
    "abc".toInt() 
}.getOrElse { 
    println("변환 실패: ${it.message}")
    -1 
}

getOrThrow()

val result = runCatching { 
    "123".toInt() 
}.getOrThrow()  // 실패 시 예외 던짐

4.4 onSuccess / onFailure

runCatching { 
    apiService.fetchData() 
}.onSuccess { data ->
    println("데이터 수신: $data")
    cache.save(data)
}.onFailure { exception ->
    logger.error("API 호출 실패", exception)
    sendAlert("API 오류 발생")
}

4.5 map / mapCatching

map - 성공 시에만 변환

val result = runCatching { 
    "42".toInt() 
}.map { 
    it * 2 
}.getOrNull()  // 84

mapCatching - 변환 중 예외 처리

val result = runCatching { 
    "42" 
}.mapCatching { 
    it.toInt() * 2 
}.getOrDefault(0)

4.6 recover / recoverCatching

val result = runCatching { 
    "abc".toInt() 
}.recover { exception ->
    println("오류 발생: ${exception.message}")
    0  // 복구값
}.getOrThrow()  // 0

recoverCatching - 복구 중 예외 가능

val result = runCatching { 
    primaryService.getData() 
}.recoverCatching { 
    fallbackService.getData()  // 예외 발생 가능
}.getOrNull()

4.7 실전 패턴

API 호출 with 재시도

fun fetchWithRetry(maxRetries: Int = 3): Result<String> {
    repeat(maxRetries) { attempt ->
        val result = runCatching { 
            httpClient.get("/api/data") 
        }
        
        if (result.isSuccess) {
            return result
        }
        
        println("시도 ${attempt + 1} 실패, 재시도...")
        Thread.sleep(1000 * (attempt + 1))
    }
    
    return Result.failure(Exception("모든 재시도 실패"))
}

데이터베이스 조회 with 폴백

fun getUser(id: Long): User {
    return runCatching { 
        cache.getUser(id) 
    }.recoverCatching { 
        database.findUser(id) 
    }.onSuccess { user ->
        cache.save(user)
    }.onFailure { 
        logger.error("사용자 조회 실패: $id", it) 
    }.getOrElse { 
        User.guest() 
    }
}

여러 작업 순차 실행

fun processOrder(orderId: Long): Result<Receipt> {
    return runCatching {
        orderRepository.findById(orderId)
    }.mapCatching { order ->
        paymentService.process(order.amount)
    }.mapCatching { payment ->
        inventoryService.decreaseStock(payment.items)
    }.mapCatching { inventory ->
        receiptService.generate(orderId)
    }.onFailure { exception ->
        logger.error("주문 처리 실패: $orderId", exception)
        rollbackOrder(orderId)
    }
}

5. Result 타입 활용

5.1 Result 타입 이해하기

// Result는 성공(Success) 또는 실패(Failure)를 나타내는 sealed class
val success: Result<Int> = Result.success(42)
val failure: Result<Int> = Result.failure(Exception("오류"))

// 값 추출
println(success.getOrNull())  // 42
println(failure.getOrNull())  // null

// 예외 추출
println(success.exceptionOrNull())  // null
println(failure.exceptionOrNull())  // Exception: 오류

5.2 함수 반환 타입으로 사용

// 명시적 오류 처리
fun parseJson(json: String): Result<User> {
    return runCatching {
        objectMapper.readValue(json, User::class.java)
    }
}

// 호출하는 쪽에서 명확하게 처리
val result = parseJson(jsonString)
when {
    result.isSuccess -> {
        val user = result.getOrNull()!!
        println("사용자: ${user.name}")
    }
    result.isFailure -> {
        val exception = result.exceptionOrNull()
        println("파싱 실패: ${exception?.message}")
    }
}

5.3 fold - 성공과 실패 동시 처리

val message = runCatching { 
    fetchData() 
}.fold(
    onSuccess = { data -> "데이터 수신: $data" },
    onFailure = { exception -> "오류 발생: ${exception.message}" }
)

println(message)

실전 예제

fun processUserRequest(userId: Long): ResponseEntity<Any> {
    return runCatching {
        userService.getUser(userId)
    }.fold(
        onSuccess = { user -> 
            ResponseEntity.ok(user) 
        },
        onFailure = { exception ->
            when (exception) {
                is NotFoundException -> 
                    ResponseEntity.notFound().build()
                is ValidationException -> 
                    ResponseEntity.badRequest().body(exception.message)
                else -> 
                    ResponseEntity.status(500).body("서버 오류")
            }
        }
    )
}

5.4 여러 Result 조합하기

모두 성공해야 하는 경우

fun processAll(ids: List<Long>): Result<List<User>> {
    val results = ids.map { id ->
        runCatching { userService.getUser(id) }
    }
    
    // 하나라도 실패하면 실패
    if (results.any { it.isFailure }) {
        val firstError = results.first { it.isFailure }
        return Result.failure(firstError.exceptionOrNull()!!)
    }
    
    // 모두 성공
    val users = results.mapNotNull { it.getOrNull() }
    return Result.success(users)
}

일부만 성공해도 되는 경우

fun fetchMultipleSources(): List<Data> {
    val sources = listOf(
        runCatching { fetchFromSource1() },
        runCatching { fetchFromSource2() },
        runCatching { fetchFromSource3() }
    )
    
    // 성공한 것만 수집
    return sources.mapNotNull { it.getOrNull() }
}

5.5 Result와 Null Safety 조합

fun findUser(id: Long): Result<User?> {
    return runCatching {
        userRepository.findById(id).orElse(null)
    }
}

// 사용
val user = findUser(123)
    .getOrNull()  // Result<User?> -> User?
    ?: User.guest()  // null이면 게스트

6. Spring Boot에서의 예외 처리

6.1 @RestControllerAdvice

@RestControllerAdvice
class GlobalExceptionHandler {
    
    private val logger = LoggerFactory.getLogger(javaClass)
    
    @ExceptionHandler(NotFoundException::class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    fun handleNotFound(ex: NotFoundException): ErrorResponse {
        logger.warn("리소스를 찾을 수 없음: ${ex.message}")
        return ErrorResponse(
            status = HttpStatus.NOT_FOUND.value(),
            message = ex.message ?: "리소스를 찾을 수 없습니다",
            timestamp = LocalDateTime.now()
        )
    }
    
    @ExceptionHandler(ValidationException::class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    fun handleValidation(ex: ValidationException): ErrorResponse {
        logger.warn("입력값 검증 실패: ${ex.message}")
        return ErrorResponse(
            status = HttpStatus.BAD_REQUEST.value(),
            message = ex.message ?: "입력값이 올바르지 않습니다",
            timestamp = LocalDateTime.now()
        )
    }
    
    @ExceptionHandler(Exception::class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    fun handleGeneral(ex: Exception): ErrorResponse {
        logger.error("예상치 못한 오류 발생", ex)
        return ErrorResponse(
            status = HttpStatus.INTERNAL_SERVER_ERROR.value(),
            message = "서버 오류가 발생했습니다",
            timestamp = LocalDateTime.now()
        )
    }
}

data class ErrorResponse(
    val status: Int,
    val message: String,
    val timestamp: LocalDateTime
)

6.2 runCatching in Service Layer

@Service
class UserService(
    private val userRepository: UserRepository,
    private val emailService: EmailService
) {
    fun createUser(request: UserCreateRequest): User {
        // 이메일 중복 체크
        runCatching { 
            userRepository.findByEmail(request.email) 
        }.onSuccess {
            throw DuplicateException("이미 존재하는 이메일입니다")
        }
        
        // 사용자 생성
        val user = User(
            name = request.name,
            email = request.email,
            password = encodePassword(request.password)
        )
        
        val savedUser = userRepository.save(user)
        
        // 이메일 발송 (실패해도 사용자는 생성됨)
        runCatching {
            emailService.sendWelcomeEmail(savedUser.email)
        }.onFailure { exception ->
            logger.error("환영 이메일 발송 실패", exception)
            // 별도 알림 또는 재시도 큐에 추가
        }
        
        return savedUser
    }
}

6.3 외부 API 호출 예외 처리

@Service
class ExternalApiService(
    private val restTemplate: RestTemplate
) {
    private val logger = LoggerFactory.getLogger(javaClass)
    
    fun fetchUserData(userId: String): Result {
        return runCatching {
            restTemplate.getForObject(
                "https://api.example.com/users/$userId",
                ExternalUser::class.java
            ) ?: throw NotFoundException("사용자 데이터 없음")
        }.recoverCatching { exception ->
            logger.error("외부 API 호출 실패", exception)
            
            when (exception) {
                is HttpClientErrorException.NotFound -> 
                    throw NotFoundException("사용자를 찾을 수 없습니다")
                is HttpServerErrorException -> 
                    throw ServiceUnavailableException("외부 서비스 이용 불가")
                is ResourceAccessException -> 
                    throw TimeoutException("요청 시간 초과")
                else -> throw exception
            }
        }
    }
}

6.4 트랜잭션 내 예외 처리

@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val paymentService: PaymentService,
    private val inventoryService: InventoryService
) {
    @Transactional
    fun createOrder(request: OrderCreateRequest): Order {
        // 재고 확인
        val availableStock = runCatching {
            inventoryService.getStock(request.productId)
        }.getOrElse { 
            throw ServiceException("재고 조회 실패") 
        }
        
        if (availableStock < request.quantity) {
            throw InsufficientStockException("재고 부족")
        }
        
        // 결제 처리
        val payment = runCatching {
            paymentService.processPayment(request.paymentInfo)
        }.getOrElse { exception ->
            logger.error("결제 처리 실패", exception)
            throw PaymentFailedException("결제 실패: ${exception.message}")
        }
        
        // 재고 차감
        runCatching {
            inventoryService.decreaseStock(request.productId, request.quantity)
        }.onFailure { exception ->
            // 결제 취소
            paymentService.cancelPayment(payment.id)
            throw exception
        }
        
        // 주문 생성
        val order = Order(
            userId = request.userId,
            productId = request.productId,
            quantity = request.quantity,
            paymentId = payment.id
        )
        
        return orderRepository.save(order)
    }
}

6.5 ResponseEntity와 Result 조합

@RestController
@RequestMapping("/api/users")
class UserController(
    private val userService: UserService
) {
    @GetMapping("/{id}")
    fun getUser(@PathVariable id: Long): ResponseEntity<UserDto> {
        return runCatching {
            userService.getUser(id)
        }.fold(
            onSuccess = { user -> 
                ResponseEntity.ok(UserDto.from(user)) 
            },
            onFailure = { exception ->
                when (exception) {
                    is NotFoundException -> 
                        ResponseEntity.notFound().build()
                    else -> 
                        ResponseEntity.status(500).build()
                }
            }
        )
    }
    
    @PostMapping
    fun createUser(@RequestBody request: UserCreateRequest): ResponseEntity<Any> {
        return runCatching {
            userService.createUser(request)
        }.fold(
            onSuccess = { user ->
                ResponseEntity
                    .created(URI.create("/api/users/${user.id}"))
                    .body(UserDto.from(user))
            },
            onFailure = { exception ->
                val errorResponse = ErrorResponse(
                    message = exception.message ?: "오류 발생",
                    timestamp = LocalDateTime.now()
                )
                
                when (exception) {
                    is DuplicateException -> 
                        ResponseEntity.status(409).body(errorResponse)
                    is ValidationException -> 
                        ResponseEntity.badRequest().body(errorResponse)
                    else -> 
                        ResponseEntity.status(500).body(errorResponse)
                }
            }
        )
    }
}

7. Custom Exception 설계

7.1 계층별 Exception 설계

// 최상위 애플리케이션 예외
open class AppException(
    message: String,
    cause: Throwable? = null
) : RuntimeException(message, cause)

// 도메인별 예외
open class UserException(message: String) : AppException(message)
open class OrderException(message: String) : AppException(message)
open class PaymentException(message: String) : AppException(message)

// 구체적인 예외
class UserNotFoundException(userId: Long) : UserException("사용자를 찾을 수 없습니다: $userId")
class DuplicateEmailException(email: String) : UserException("이미 존재하는 이메일입니다: $email")
class InvalidPasswordException : UserException("비밀번호가 일치하지 않습니다")

class OrderNotFoundException(orderId: Long) : OrderException("주문을 찾을 수 없습니다: $orderId")
class InsufficientStockException(productId: Long) : OrderException("재고가 부족합니다: $productId")

class PaymentFailedException(reason: String) : PaymentException("결제 실패: $reason")
class RefundFailedException(paymentId: String) : PaymentException("환불 실패: $paymentId")

7.2 에러 코드 포함

enum class ErrorCode(val code: String, val message: String) {
    // 사용자 관련
    USER_NOT_FOUND("U001", "사용자를 찾을 수 없습니다"),
    DUPLICATE_EMAIL("U002", "이미 존재하는 이메일입니다"),
    INVALID_PASSWORD("U003", "비밀번호가 일치하지 않습니다"),
    
    // 주문 관련
    ORDER_NOT_FOUND("O001", "주문을 찾을 수 없습니다"),
    INSUFFICIENT_STOCK("O002", "재고가 부족합니다"),
    
    // 결제 관련
    PAYMENT_FAILED("P001", "결제에 실패했습니다"),
    REFUND_FAILED("P002", "환불에 실패했습니다")
}

open class BusinessException(
    val errorCode: ErrorCode,
    val data: Map<String, Any>? = null,
    cause: Throwable? = null
) : RuntimeException(errorCode.message, cause)

// 사용
class UserNotFoundException(userId: Long) : BusinessException(
    errorCode = ErrorCode.USER_NOT_FOUND,
    data = mapOf("userId" to userId)
)

// Exception Handler에서 활용
@ExceptionHandler(BusinessException::class)
fun handleBusinessException(ex: BusinessException): ResponseEntity<ErrorResponse> {
    val response = ErrorResponse(
        code = ex.errorCode.code,
        message = ex.errorCode.message,
        data = ex.data,
        timestamp = LocalDateTime.now()
    )
    
    return ResponseEntity.badRequest().body(response)
}

7.3 컨텍스트 정보 포함

class ValidationException(
    message: String,
    val field: String,
    val rejectedValue: Any?,
    val errors: List<String> = emptyList()
) : AppException(message)

// 사용
throw ValidationException(
    message = "입력값 검증 실패",
    field = "email",
    rejectedValue = "invalid-email",
    errors = listOf(
        "이메일 형식이 올바르지 않습니다",
        "@를 포함해야 합니다"
    )
)

// Exception Handler
@ExceptionHandler(ValidationException::class)
fun handleValidation(ex: ValidationException): ResponseEntity<ValidationErrorResponse> {
    val response = ValidationErrorResponse(
        field = ex.field,
        rejectedValue = ex.rejectedValue.toString(),
        errors = ex.errors,
        timestamp = LocalDateTime.now()
    )
    
    return ResponseEntity.badRequest().body(response)
}

8. 실전 패턴과 Best Practices

8.1 Null vs Exception

Null을 반환하는 경우

// 찾을 수 없는 것이 정상적인 경우
fun findUserByEmail(email: String): User? {
    return userRepository.findByEmail(email)
}

// 호출
val user = findUserByEmail("test@example.com")
if (user != null) {
    // 처리
}

Exception을 던지는 경우

// 반드시 존재해야 하는데 없는 경우 (비정상)
fun getUser(id: Long): User {
    return userRepository.findById(id)
        .orElseThrow { UserNotFoundException(id) }
}

// 호출
try {
    val user = getUser(123)
    // 처리
} catch (e: UserNotFoundException) {
    // 예외 처리
}

8.2 Early Return 패턴

// ❌ 중첩된 try-catch
fun processOrder(orderId: Long): Receipt {
    try {
        val order = orderRepository.findById(orderId)
            .orElseThrow { OrderNotFoundException(orderId) }
        
        try {
            val payment = paymentService.process(order)
            
            try {
                val receipt = receiptService.generate(payment)
                return receipt
            } catch (e: ReceiptException) {
                throw ProcessException("영수증 생성 실패", e)
            }
        } catch (e: PaymentException) {
            throw ProcessException("결제 실패", e)
        }
    } catch (e: OrderNotFoundException) {
        throw ProcessException("주문 없음", e)
    }
}

// ✅ Early Return with runCatching
fun processOrder(orderId: Long): Receipt {
    val order = runCatching {
        orderRepository.findById(orderId).orElseThrow()
    }.getOrElse { 
        throw OrderNotFoundException(orderId) 
    }
    
    val payment = runCatching {
        paymentService.process(order)
    }.getOrElse { 
        throw ProcessException("결제 실패", it) 
    }
    
    return runCatching {
        receiptService.generate(payment)
    }.getOrElse { 
        throw ProcessException("영수증 생성 실패", it) 
    }
}

8.3 재시도 패턴

fun <T> retryOnFailure(
    maxAttempts: Int = 3,
    delayMillis: Long = 1000,
    block: () -> T
): Result<T> {
    repeat(maxAttempts - 1) { attempt ->
        val result = runCatching { block() }
        
        if (result.isSuccess) {
            return result
        }
        
        println("시도 ${attempt + 1} 실패, ${delayMillis}ms 후 재시도")
        Thread.sleep(delayMillis * (attempt + 1))
    }
    
    // 마지막 시도
    return runCatching { block() }
}

// 사용
val data = retryOnFailure(maxAttempts = 3) {
    apiService.fetchData()
}.getOrElse {
    throw ServiceException("모든 재시도 실패", it)
}

8.4 Circuit Breaker 패턴

class CircuitBreaker(
    private val failureThreshold: Int = 5,
    private val timeout: Duration = Duration.ofMinutes(1)
) {
    private var failureCount = 0
    private var lastFailureTime: Instant? = null
    private var state = State.CLOSED
    
    enum class State { CLOSED, OPEN, HALF_OPEN }
    
    fun <T> execute(block: () -> T): Result<T> {
        when (state) {
            State.OPEN -> {
                if (shouldAttemptReset()) {
                    state = State.HALF_OPEN
                } else {
                    return Result.failure(
                        CircuitBreakerException("Circuit breaker is OPEN")
                    )
                }
            }
            State.HALF_OPEN, State.CLOSED -> {
                // 실행 허용
            }
        }
        
        return runCatching { block() }
            .onSuccess {
                reset()
            }
            .onFailure {
                recordFailure()
            }
    }
    
    private fun shouldAttemptReset(): Boolean {
        val lastFailure = lastFailureTime ?: return true
        return Duration.between(lastFailure, Instant.now()) > timeout
    }
    
    private fun recordFailure() {
        failureCount++
        lastFailureTime = Instant.now()
        
        if (failureCount >= failureThreshold) {
            state = State.OPEN
            println("Circuit breaker OPEN")
        }
    }
    
    private fun reset() {
        failureCount = 0
        lastFailureTime = null
        state = State.CLOSED
    }
}

// 사용
val circuitBreaker = CircuitBreaker(failureThreshold = 3)

fun fetchData(): String {
    return circuitBreaker.execute {
        externalApiService.getData()
    }.getOrThrow()
}

8.5 Fallback 체인

fun getUserData(userId: Long): UserData {
    return runCatching {
        // 1차: 캐시에서 조회
        cacheService.getUser(userId)
    }.recoverCatching {
        // 2차: 데이터베이스에서 조회
        println("캐시 미스, DB 조회")
        databaseService.getUser(userId)
    }.recoverCatching {
        // 3차: 외부 API에서 조회
        println("DB 조회 실패, 외부 API 호출")
        externalApiService.getUser(userId)
    }.onSuccess { userData ->
        // 성공 시 캐시 업데이트
        cacheService.save(userId, userData)
    }.getOrElse {
        // 모두 실패 시 기본값
        println("모든 소스 실패, 기본값 반환")
        UserData.default(userId)
    }
}

8.6 로깅 전략

fun <T> Result<T>.logOnFailure(
    logger: Logger,
    message: String
): Result<T> {
    return onFailure { exception ->
        logger.error(message, exception)
    }
}

// 사용
val user = runCatching {
    userService.getUser(userId)
}.logOnFailure(logger, "사용자 조회 실패: $userId")
    .getOrNull()

Matrix 알림 통합

object ExceptionUtils {
    fun sendToMatrix(
        exception: Throwable,
        messageBuilder: () -> String
    ) {
        val message = """
            ⚠️ **예외 발생**
            
            **메시지**: ${messageBuilder()}
            **예외 타입**: ${exception::class.simpleName}
            **상세**: ${exception.message}
            **발생 시각**: ${LocalDateTime.now()}
        """.trimIndent()
        
        matrixClient.sendMessage(message)
    }
}

// 사용
val group = runCatching {
    kassInfraService.createGroup(request)
}.getOrElse { exception ->
    ExceptionUtils.sendToMatrix(exception) {
        "KASS 그룹 생성 실패: ${request.groupName}"
    }
    throw exception
}

8.7 테스트에서의 예외 처리

@Test
fun `사용자 생성 시 이메일 중복이면 예외 발생`() {
    // Given
    val existingEmail = "test@example.com"
    userRepository.save(User(name = "기존", email = existingEmail))
    
    val request = UserCreateRequest(
        name = "신규",
        email = existingEmail
    )
    
    // When & Then
    val exception = assertThrows<DuplicateEmailException> {
        userService.createUser(request)
    }
    
    assertEquals("이미 존재하는 이메일입니다: $existingEmail", exception.message)
}

@Test
fun `외부 API 호출 실패 시 재시도 후 fallback`() {
    // Given
    whenever(externalApi.fetchData())
        .thenThrow(RuntimeException("API 오류"))
    
    // When
    val result = runCatching {
        retryOnFailure(maxAttempts = 2) {
            externalApi.fetchData()
        }
    }
    
    // Then
    assertTrue(result.isFailure)
    verify(externalApi, times(2)).fetchData()
}

마치며

핵심 요약

Kotlin 예외 처리의 특징

  1. Checked Exception 없음: 모든 예외가 Unchecked
  2. Expression으로 사용: try도 값을 반환
  3. runCatching: 함수형 스타일 예외 처리
  4. Result 타입: 성공/실패를 명시적으로 표현

Best Practices

  • ✅ runCatching으로 예외를 Result로 변환
  • ✅ onSuccess/onFailure로 부수 효과 처리
  • ✅ getOrElse로 기본값 제공
  • ✅ Custom Exception 계층 설계
  • ✅ 비즈니스 예외와 기술 예외 분리

주의사항

  • ⚠️ Null 반환 vs Exception 던지기 구분
  • ⚠️ 과도한 try-catch 중첩 지양
  • ⚠️ 예외를 제어 흐름으로 사용하지 말기
  • ⚠️ 의미 없는 catch 블록 지양

언제 무엇을 사용할까?

Null 반환 → 찾을 수 없는 것이 정상적인 경우
Exception → 반드시 있어야 하는데 없는 경우
runCatching → 외부 API, I/O 작업
Result 타입 → 명시적 오류 처리 필요
@RestControllerAdvice → 전역 예외 처리

다음 단계

다음 포스팅에서는 Kotlin 테스트 코드 작성(11번 주제)을 다룰 예정입니다:

  • Mockito-Kotlin 활용법
  • 코루틴 테스트
  • Spring Boot 통합 테스트
  • 테스트 픽스처 패턴

함께 보면 좋은 글

▶ Kotlin과 Spring Boot 완벽 통합 | Annotations, DI, 트랜잭션
▶ Kotlin Null Safety 완벽 가이드 | ?, ?., ?:, let, run
▶ Kotlin 제어 구조와 타입 시스템 | when, Sealed Class


참고 자료

 

반응형
Comments