| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- 티스토리챌린지
- JavaScript
- Effective Java 3
- Sort
- 이펙티브 자바
- 알고리즘정렬
- 자바스크립트
- 스프링 핵심원리
- java
- 스프링핵심원리
- Spring
- k8s
- effectivejava
- kubernetes
- 이차전지관련주
- 오블완
- 스프링
- Kotlin
- ElasticSearch
- 예제로 배우는 스프링 입문
- 김영한
- 클린아키텍처
- 스프링부트
- 엘라스틱서치
- 카카오
- 카카오 면접
- 자바
- 알고리즘
- Effective Java
- 이펙티브자바
- Today
- Total
Kim-Baek 개발자 이야기
Kotlin 예외 처리 완벽 가이드 | try-catch, runCatching, Result 타입 본문
Kotlin의 함수형 스타일 예외 처리로 더 안전하고 우아한 코드 작성하기
목차
- Java vs Kotlin 예외 처리 비교
- 기본 try-catch-finally
- try를 Expression으로 사용하기
- runCatching - 함수형 예외 처리
- Result 타입 활용
- Spring Boot에서의 예외 처리
- Custom Exception 설계
- 실전 패턴과 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 예외 처리의 특징
- Checked Exception 없음: 모든 예외가 Unchecked
- Expression으로 사용: try도 값을 반환
- runCatching: 함수형 스타일 예외 처리
- 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
참고 자료
- Kotlin 공식 문서 - Exception: kotlinlang.org/docs/exceptions
- Kotlin Result 타입: kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/
- Spring Boot Exception Handling: spring.io/guides
- Effective Kotlin by Marcin Moskała
'개발 > java basic' 카테고리의 다른 글
| Kotlin 시작하기 | Java 개발자를 위한 첫 번째 Kotlin 코드 (0) | 2025.12.10 |
|---|---|
| Kotlin 테스트 완벽 가이드 | Mockito-Kotlin, JUnit5, Spring Boot Test (0) | 2025.12.09 |
| Kotlin과 Spring Boot 완벽 통합 가이드 | Annotations, DI, 실전 패턴 (0) | 2025.12.05 |
| Kotlin 제어 구조와 타입 시스템 완벽 가이드 | when, if expression, Enum, Sealed Class (0) | 2025.12.04 |
| Kotlin Extension Functions & String 처리 완벽 가이드 | 기존 클래스 확장하고 문자열 마스터하기 🎨 (0) | 2025.12.03 |
