마이크로서비스 아키텍처(MSA) 확산에 따라, 하나의 요청을 처리하기 위해 내부적으로 다수의 외부 API를 호출하는 '멀티홉(Multi-hop)' 구조가 보편화되었다. 성능 최적화를 위해 CompletableFuture나 @Async를 활용한 비동기 호출을 도입하지만, 이때 발생하는 가장 큰 문제는 비동기 스레드에서 발생한 예외가 호출자에게 전달되지 않고 소멸한다는 점이다.
이펙티브 소프트웨어 설계 3장의 원칙을 바탕으로, Kotlin + Spring Boot 환경에서 비동기 예외를 안전하게 가로채고 처리하는 전략을 정리한다.
1. 비동기 예외 처리가 어려운 이유
전형적인 Spring MVC 환경에서는 요청을 처리하는 스레드와 예외가 발생하는 스레드가 일치한다. 따라서 @RestControllerAdvice는 ThreadLocal 기반으로 예외를 쉽게 포착할 수 있다.
그러나 비동기 처리가 개입되면 실행 컨텍스트가 분리된다.

워커 스레드에서 발생한 예외는 호출 스레드로 명시적으로 전달되지 않을 경우 그대로 증발한다. 이는 시스템의 내결함성을 떨어뜨리며, 클라이언트에게 부적절한 응답을 반환하는 원인이 된다.
2. 계층별 예외 처리 전략
예외는 정제(Refine), 전파(Propagate), 집계(Aggregate)의 3단계를 거쳐 처리되어야 한다.
Step 1: Feign ErrorDecoder를 통한 예외 정제
외부 시스템의 HTTP 에러를 도메인 언어로 즉시 변환해야 한다. FeignException을 그대로 방치하면 비동기 스택 트레이스에서 근본 원인을 파악하기 매우 어렵기 때문이다.
class ExternalApiErrorDecoder : ErrorDecoder {
override fun decode(methodKey: String, response: Response): Exception {
return when (response.status()) {
404 -> EntityNotFoundException("대상 자원을 찾을 수 없다. (Status: ${response.status()})")
in 400..499 -> BadRequestException("외부 서비스 요청 오류 발생")
else -> RuntimeException("외부 서비스 서버 오류 발생")
}
}
}
Step 2: CompletableFuture 내부에 예외 캡슐화
비동기 작업 중 발생한 예외를 completeExceptionally를 통해 Future 객체에 명시적으로 담아야 한다. 예외를 던지는 대신 상태로 관리하여 호출자에게 전달하는 방식이다.
fun fetchBookAsync(id: String): CompletableFuture<Book> {
val future = CompletableFuture<Book>()
executor.execute {
try {
val result = feignClient.getBook(id)
future.complete(result)
} catch (ex: Exception) {
// 예외를 Future의 결과값으로 담아 반환한다.
future.completeExceptionally(ex)
}
}
return future
}
Step 3: @Async UncaughtExceptionHandler 설정
결과값이 없는 void 반환형 비동기 메서드의 경우, 예외가 발생해도 포착할 지점이 없다. 이를 해결하기 위해 전역 비동기 예외 처리기를 등록해야 한다.
@Configuration
@EnableAsync
class AsyncConfig : AsyncConfigurer {
override fun getAsyncUncaughtExceptionHandler(): AsyncUncaughtExceptionHandler {
return AsyncUncaughtExceptionHandler { ex, method, params ->
log.error("비동기 실행 중 예외 발생: Method={}, Message={}", method.name, ex.message)
// 모니터링 시스템(Sentry, Slack 등)으로 예외를 전송한다.
}
}
}
3. 최종 통합: RestControllerAdvice 구성
Spring MVC 컨트롤러가 CompletableFuture를 반환하면, 프레임워크는 내부적으로 비동기 처리가 완료될 때까지 대기한 후 결과를 처리한다. 이때 전파된 예외는 CompletionException으로 감싸져 전달된다.
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(CompletionException::class)
fun handleCompletionException(ex: CompletionException): ResponseEntity<ErrorResponse> {
// 원인 예외(cause)를 추출하여 실제 도메인 예외로 처리한다.
val cause = ex.cause ?: ex
return when (cause) {
is EntityNotFoundException -> ResponseEntity.status(404).body(ErrorResponse(cause.message))
else -> ResponseEntity.status(500).body(ErrorResponse("시스템 내부 오류가 발생했다."))
}
}
}
4. 운영 및 SRE 고려사항
- 스레드 풀 분리(Bulkhead): 외부 API 호출용 스레드 풀과 내부 로직용 스레드 풀을 분리하여 특정 지점의 장애가 전체로 확산되는 것을 방지해야 한다.
- 컨텍스트 전파(MDC): 비동기 스레드 전환 시
TraceId등 로그 추적을 위한 데이터가 유실되지 않도록TaskDecorator를 반드시 구현해야 한다. - 타임아웃 설정:
orTimeout()등을 활용하여 비동기 작업이 스레드를 무한정 점유하지 않도록 강제해야 한다.
결론
비동기 환경에서의 예외 처리는 단순히 구문을 작성하는 것을 넘어, 예외를 데이터처럼 취급하고 스택 간에 안전하게 전달하는 설계가 핵심이다. 이펙티브 소프트웨어 설계의 전략들을 활용하여 Kotlin 환경에서 더욱 견고한 비동기 백엔드 아키텍처를 구축해야 한다.
본 문서는 '이펙티브 소프트웨어 설계' 3장 내용을 기반으로 작성되었다.
Powered by Google Gemini 3 (Gemini CLI) - 라모스 문서 작성 테스트
'Kotlin' 카테고리의 다른 글
| Kotlin/Gradle 빌드 시 OOM 발생(?) (부제: Kotlin IR, KAPT를 다시 까보자) (0) | 2025.12.22 |
|---|---|
| Kotlin 제너릭 완전 정복: 클래스 vs 함수, 타입 추론까지 (0) | 2025.10.05 |
| Kotlin 함수형 프로그래밍 완전 정복 (0) | 2025.10.04 |