이전 글에서 예외를 값으로 다루는 접근법을 소개했다. Result 패턴과 Railway-Oriented Programming이 어떤 원리인지, 왜 필요한지를 다뤘다.

글을 쓰고 나서 드는 생각이 있었다. 원리를 아는 것과 실제 서비스에 적용하는 것 사이에는 꽤 큰 간극이 있다. 특히 스프링 기반 서비스에서 @ExceptionHandler가 이미 잘 동작하고 있는데 왜 굳이 Result를 도입해야 하는가?

마침 사내에서 에러 처리에 대한 논의가 있었다. 알 수 없는 에러가 발생하여 대응이 어렵거나 에러 로그를 남기는 기준조차 사람마다 달라 에러 식별도 어려운 등 실제 문제를 해결해야 했다. 코드 레벨의 예외 처리를 넘어 조직이 에러를 어떻게 분류하고 대응할지에 대한 합의가 필요하다.

이 글은 스프링 기반 서비스에서 에러 처리 전략을 세우려는 팀을 대상으로 한다. Result 패턴의 문법보다는 에러를 분류하고 핸들링하고 전파하는 전체 과정에 초점을 맞춘다. 에러도 도메인 모델이나 API처럼 설계의 대상이라는 컨셉이 이 글의 바탕이다.

에러 처리의 핵심은 결국 두 가지다. 경우의 수를 빠짐없이 파악하고 각각을 적절하게 핸들링하는 것. 고객이 장바구니의 상품을 주문하는 서비스를 예제로 아래의 과정을 처음부터 끝까지 따라간다.

  1. 파악 - 이 API에서 발생할 수 있는 모든 실패를 빠짐없이 열거한다.
  2. 분류 - 예측된 비즈니스 케이스(BusinessError)와 인프라 장애(InfraException)를 나눈다.
  3. 핸들링 - 비즈니스 에러는 값(Result)으로 인프라 장애는 예외(throw)로 처리한다.
  4. 전파 - 서비스 경계에서 에러를 번역하고 API 응답과 문서로 호출자에게 전달한다.
  5. 관찰가능성(observability) - 로그 레벨과 알림 정책을 분리하여 봐야 할 에러만 보이게 한다.

1. 경우의 수 파악

에러 처리의 출발점은 패턴 선택도 라이브러리 도입도 아니다. **"이 API에서 발생할 수 있는 모든 실패를 빠짐없이 열거하기"**가 먼저다.

예제로 사용할 서비스를 소개한다. 고객이 장바구니의 상품을 주문하는 placeOrder API다. 고객 정보를 확인하고 주문 상태를 검증하고 결제를 요청한다. 이 API가 어떤 실패를 맞닥뜨릴 수 있는지 흐름을 따라가며 하나씩 짚어본다.

  1. 고객을 조회했으나 고객 정보가 없거나 비활성 고객이다.
  2. 주문 상태를 확인했더니 이미 처리 중이다.
  3. 신규 주문 접수가 중단되어 있다.
  4. 장바구니가 비어 있다.
  5. PG사 통신 오류 / 재고 시스템 오류 / 결제 로직 오류 등 결제 실행 시 장애가 발생했다.
  6. 어디서든 발생 가능한 예상하지 못한 버그 (NPE, DB Connection 오류 등).

이렇게 나열하면 총 7가지 실패 시나리오가 보인다. 이 목록이 곧 이 API의 에러 계약이다. 에러 계약이란 이 API가 호출자에게 반환할 수 있는 모든 실패 케이스의 명세다. 함수 시그니처가 입출력 타입을 약속하듯 에러 계약은 실패 시나리오를 약속한다. 클라이언트는 성공 응답 하나와 이 7가지 실패 중 하나를 받을 수 있다.

경우의 수를 파악했으면 다음은 각각의 성격을 판단할 차례다.


2. 분류

열거한 실패들은 성격이 다르다. "고객이 비활성 상태다"는 도메인 규칙에 의한 예측된 거절이고 "PG사가 타임아웃을 냈다"는 외부 시스템의 일시적 장애다. 같은 "실패"지만 원인도 다르고 대응 방법도 다르다. 이 성격 차이에 따라 분류하면 크게 두 가지로 나뉜다.

실패 시나리오분류이유
고객 없음/비활성BusinessError고객 상태에 따른 예측된 케이스
주문 이미 처리 중BusinessError상태 규칙 위반
기능 비활성화BusinessError설정에 따른 예측된 케이스
장바구니 비어있음BusinessError데이터 상태에 따른 예측된 케이스
PG사 통신 오류InfraException외부 시스템 일시적 장애
재고 시스템 오류InfraException인프라 장애
결제 로직 오류InfraException코드 버그로 인한 오류

"결제 로직 오류"의 분류는 실무에서 가장 경계가 모호한 지점이다. "잔액 부족"이나 "한도 초과"는 BusinessError지만 "결제 데이터 직렬화 실패"는 코드 버그다. 위 5번 항목의 세 가지 결제 오류 중 이 "결제 로직 오류"를 코드에서는 PaymentLogicException으로 표현한다. 비즈니스 규칙 위반이 아닌 결제 모듈 내부의 기술적 오류다. 분류가 애매한 경우, **"클라이언트가 요청을 수정하면 성공할 수 있는가?"**를 기준으로 판단하면 된다. "잔액 부족"은 충전 후 재요청하면 성공하므로 BusinessError다. "결제 데이터 직렬화 실패"는 클라이언트가 뭘 바꾸든 서버 코드가 수정되기 전까지 같은 결과이므로 InfraException이다.

그리고 목록에 없는 것 — 예상하지 못한 NPE나 ClassCastException — 은 Unknown으로 분류한다.

분류성격
BusinessError예측된 비즈니스 케이스. 도메인 규칙 위반
InfraException원인을 아는 인프라 장애. 비즈니스 케이스는 아니지만 예측된 실패
Unknown예상하지 못한 버그

분류 기준은 단순하다. 예측된 비즈니스 케이스면 BusinessError, 원인을 아는 인프라 장애면 InfraException, 나머지는 Unknown.

여기서 중요한 점은 BusinessError든 InfraException이든 둘 다 예측된 실패라는 거다. 비즈니스 케이스냐 아니냐의 차이일 뿐 호출자에게 전달되어야 하는 경우의 수라는 점에서는 같다. 진짜 "예측 못한" 건 Unknown뿐이고 그것도 fallback으로 잡는다.

왜 BusinessError와 InfraException을 나누는가

분류 자체는 핸들러를 나누는 것만으로도 가능하다. BusinessException -> 400, InfraException -> 500으로 분리하면 에러 채널이 섞이는 문제는 해결된다.

그런데 핵심은 그게 아니다. BusinessError를 예외(throw)가 아닌 값(Result)으로 처리하는 것이 분류의 진짜 목적이다.

"고객의 비활성 상태"는 예외적 상황이 아니다. 고객의 정상적인 상태 범위 안에 있는 예측된 비즈니스 케이스다. 이런 실패를 throw로 처리하면 호출자가 이를 빠뜨려도 컴파일러가 잡아주지 않는다. Result로 반환하면 호출자는 성공과 실패를 반드시 처리해야 한다. 에러가 정상 비즈니스 플로우 안에 녹아든다.

반면 "DB가 죽었다"는 비즈니스 케이스가 아니다. 서비스 코드에서 할 수 있는 것도 없고 공통 핸들러가 일괄 처리하면 된다. 이런 에러까지 Result로 감싸면 서비스 코드가 인프라 에러 처리로 오염된다.

정리하면 비즈니스 케이스인가 아닌가가 분류 기준이고 분류에 따라 **처리 메커니즘(Result vs throw)**이 달라진다.

BusinessError  -> Result로 값 처리, 호출자가 반드시 처리
InfraException -> 예외로 throw, 공통 핸들러가 일괄 처리
Unknown        -> 예외로 throw, fallback 핸들러

3. 핸들링

분류가 끝났으면 각각을 실제 코드로 표현해보자.

에러 타입 설계

BusinessError는 인터페이스로 정의한다.

interface ResultError {
    val code: String
    val msg: String
}

interface BusinessError : ResultError

도메인별 에러가 이를 구현한다.

data class CustomerNotFoundError(
    override val code: String = "CUSTOMER_NOT_FOUND",
    override val msg: String = "고객 정보를 찾을 수 없습니다.",
) : BusinessError

data class OrderNotReadyError(
    val orderStatus: String,
    override val code: String = "ORDER_NOT_READY",
    override val msg: String = "주문 가능한 상태가 아닙니다: $orderStatus",
) : BusinessError

code는 클라이언트가 에러별 분기 처리에 사용하는 고정 코드다. 클래스 이름이 아니라 별도의 문자열 코드를 두면 내부 리팩토링(클래스 이름 변경, 패키지 이동 등)이 API 계약에 영향(breaking change)을 주지 않는다.

InfraException은 RuntimeException을 상속한다.

open class InfraException(
    message: String,
    override val code: String = "INFRA_ERROR",
    open val module: String = "unknown",
    open val retryable: Boolean = false,
    open val retryAfterSeconds: Long? = null,
) : RuntimeException(message), ResultError {
    override val msg: String get() = message ?: "인프라 오류"
}

module은 "어디서 터졌는가"를 식별하는 필드다. 왜 필요한지는 전파 절에서 다룬다.

retryable은 일시적 장애와 영구적 장애를 구분한다. PG사 통신 오류는 재시도하면 복구될 수 있고 결제 로직 버그는 재시도해도 같은 결과가 나온다.

// 주문 도메인의 인프라 예외 계층
open class PaymentExecutionException(
    message: String,
    override val code: String = "PAYMENT_EXECUTION_ERROR",
    retryable: Boolean = false,
    retryAfterSeconds: Long? = null,
) : InfraException(message, module = "payment", retryable = retryable, retryAfterSeconds = retryAfterSeconds)

class PaymentGatewayException(message: String = "PG사 통신 중 오류가 발생했습니다.") :
    PaymentExecutionException(message, code = "PAYMENT_GATEWAY_ERROR", retryable = true, retryAfterSeconds = 30)

class InventoryException(message: String = "재고 시스템 오류가 발생했습니다.") :
    PaymentExecutionException(message, code = "INVENTORY_ERROR")

class PaymentLogicException(message: String = "결제 데이터 처리 중 오류가 발생했습니다.") :
    PaymentExecutionException(message, code = "PAYMENT_LOGIC_ERROR")

Result 구현

다음은 전 글에서 소개한 Result 패턴의 실제 구현이다.

sealed class Result<out E : ResultError, out T> {
    data class Success<out T>(val value: T) : Result<Nothing, T>()
    data class Failure<out E : ResultError>(val error: E) : Result<E, Nothing>()
}

Kotlin 표준 라이브러리의 kotlin.Result는 에러 타입이 Throwable로 고정되어 있다. Result<E, T>는 에러 타입을 제네릭으로 열어서 BusinessError 같은 도메인 에러 계층을 사용할 수 있다.

Failure가 발생하면 이후 연산(map, flatMap)은 건너뛰고 실패가 끝까지 전달된다. try-catch와 달리 타입이 이 흐름을 강제한다.

// 성공 값 변환 — Failure면 스킵
inline fun <E : ResultError, T, R> Result<E, T>.map(transform: (T) -> R): Result<E, R>

// 다음 Result 연산 체이닝 — Failure면 스킵
inline fun <E : ResultError, T, R> Result<E, T>.flatMap(transform: (T) -> Result<E, R>): Result<E, R>

// 실패 시 복구 시도
inline fun <E : ResultError, T> Result<E, T>.recoverWith(recover: (E) -> Result<E, T>): Result<E, T>
Success(customer) -> map -> flatMap -> Success(결과)
Failure(에러)     -> map -> flatMap -> Failure(에러)  ← 중간 연산 스킵

명령형 바인딩 — resultOf. Railway 연산은 강력하지만 검증이 여러 단계일 때 flatMap 중첩이 깊어지면 읽기 어렵다. resultOf { } 블록은 명령형 스타일을 유지하면서 Result의 타입 안전성을 가져간다.

fun <E : ResultError, T> resultOf(block: ResultScope<E>.() -> T): Result<E, T>

class ResultScope<E : ResultError> {
    fun ensure(condition: Boolean, error: () -> E)              // 조건 실패 -> Failure
    fun <T : Any> ensureNotNull(value: T?, error: () -> E): T  // null -> Failure
}

내부적으로 ResultBindException을 사용하지만 fillInStackTrace()를 오버라이드해서 스택 트레이스를 생성하지 않는다. 이전 글에서 다뤘던 try-catch 비용의 대부분이 스택 트레이스 생성이었는데 이를 제거하면 사실상 제어 흐름 분기 비용만 남는다.

트레이드오프가 있다. 스택 트레이스를 생성하지 않으므로 resultOf 블록 안에서 ensure 실패가 발생했을 때 "어디서 실패했는지"를 스택 트레이스로 추적할 수 없다. 다만 ResultBindException은 제어 흐름용이지 진짜 예외가 아니다. ensure 실패는 반환된 Failure의 에러 타입(CustomerNotFoundError, OrderNotReadyError 등)으로 실패 지점을 식별하도록 설계되어 있다. 스택 트레이스 대신 에러 타입이 디버깅 정보를 담는 셈이다.

서비스 코드 — Before & After

에러 타입과 Result를 갖췄으니 1장에서 파악한 실패 시나리오를 실제 서비스 코드에 적용해보자.

리팩토링 전의 원본 코드를 먼저 보자.

public void placeOrder(PlaceOrderRequest request) {
    // 1. null 비교 + return
    Customer customer = customerClient.getCustomer(customerId);
    if (customer == null) return;

    // 2. boolean 상태 비교 + return
    OrderProgress progress = getProgress(orderId);
    if (!progress.isOrderable()) return;

    // 3. boolean 설정 비교 + return
    if (!orderAcceptable) return;

    // 4. 빈 컬렉션 비교 + return
    List<OrderItem> items = getItems(orderId);
    if (items.isEmpty()) return;

    // 5. 예외 전파 (try-catch 없음)
    paymentClient.requestPayment(orderId, items);
}

실패를 네 가지 다른 방식(null 비교, boolean, 빈 컬렉션, 예외 전파)으로 표현하고 1~4번 실패는 조용히 return한다. 어떤 에러가 있는지 자체가 보이지 않는다. 호출자는 왜 실패했는지 알 수 없고 새로운 실패 시나리오가 추가되어도 전달되지 않는다.

물론 Java에서도 throw 기반으로 개선할 수 있다.

public OrderCompletedEvent placeOrder(PlaceOrderRequest request) {
    Customer customer = customerClient.getCustomer(customerId);
    if (customer == null) throw new CustomerNotFoundException();
    if (!customer.isActive()) throw new CustomerNotActiveException();

    OrderProgress progress = getProgress(orderId);
    if (!progress.isOrderable()) throw new OrderNotReadyException(progress.getStatus());

    if (!orderAcceptable) throw new FeatureDisabledException("신규 주문 접수");

    List<OrderItem> items = getItems(orderId);
    if (items.isEmpty()) throw new EmptyCartException();

    paymentClient.requestPayment(orderId, items);
    return new OrderCompletedEvent(customerId, orderId, items.size());
}

이렇게 하면 에러가 명확히 드러나고 @ExceptionHandler에서 일괄 처리할 수 있다. throw 기반도 충분히 좋은 접근이다. 다만 이전 글에서 다뤘듯이 unchecked exception은 호출자가 catch를 빼먹어도 컴파일되고 checked exception은 처리를 강제하지만 무의미한 try-catch 남발과 합성 불가라는 설계상의 문제가 따른다. Result는 반환값으로 실패를 강제하면서 이 두 가지 문제를 피한다.

Result + resultOf로 바꾸면 다음과 같다.

예제 코드는 헥사고날 아키텍처를 따른다. Port는 도메인이 외부에 요청하는 인터페이스이고 Adapter는 그 인터페이스의 실제 구현이다. CustomerQueryPort는 "고객을 조회한다"는 계약이고 이를 HTTP 클라이언트로 구현하든 DB로 구현하든 서비스 코드는 바뀌지 않는다.

@Service
class OrderService(
    private val customerQueryPort: CustomerQueryPort,
    private val orderStatusQueryPort: OrderStatusQueryPort,
    private val orderItemQueryPort: OrderItemQueryPort,
    private val paymentExecutionPort: PaymentExecutionPort,
    @param:Value("\${order.auto-order-enabled:true}")
    private val orderAcceptable: Boolean = true,
) {

    fun placeOrder(request: PlaceOrderRequest): Result<BusinessError, OrderCompletedEvent> = resultOf {
        // 1. 고객 검증
        val customer = ensureNotNull(
            customerQueryPort.getCustomer(request.customerId)
        ) { CustomerNotFoundError() }
        ensure(customer.isActive) { CustomerNotFoundError() }

        // 2. 주문 상태 확인
        val progress = orderStatusQueryPort.getProgress(request.orderId)
        ensure(progress.isOrderable) { OrderNotReadyError(progress.status.name) }

        // 3. 기능 활성화 확인
        ensure(orderAcceptable) { FeatureDisabledError("신규 주문 접수") }

        // 4. 주문 상품 조회
        val items = orderItemQueryPort.getItems(request.orderId)
        ensure(items.isNotEmpty()) { EmptyCartError() }

        // 5. 결제 실행
        paymentExecutionPort.execute(request.orderId, items)
            .retry(PaymentGatewayException::class, maxRetries = 3) {
                paymentExecutionPort.execute(request.orderId, items)
            }
            .recover(InventoryException::class) { cachedResult(request.orderId) }
            .getOrThrow()

        OrderCompletedEvent(
            customerId = request.customerId,
            orderId = request.orderId,
            itemCount = items.size,
        )
    }
}

1장에서 열거했던 7가지 실패가 코드에 그대로 드러난다. 1~4번 검증은 ensure/ensureNotNull로 통일되었고 검증 실패 시 어떤 에러인지 타입으로 바로 알 수 있다. 반환 타입 Result<BusinessError, OrderCompletedEvent>가 이 함수의 에러 계약을 시그니처에 명시한다.

5번 결제 실행은 에러 유형에 따른 후속 처리를 보여준다.

  • PaymentGatewayException -> 3회 재시도
  • InventoryException -> 캐시에서 복구
  • PaymentLogicException -> 복구 불가, getOrThrow()로 예외 전파

참고: PaymentExecutionPort가 Result를 반환하는 것은 retry/recover 같은 합성 기능을 보여주기 위한 데모 용도다. 실무에서 인프라 재시도는 resilience4j 등으로 처리하고 포트는 성공하거나 throw하는 것이 앞서 말한 원칙에 맞다.

Result와 throw 혼용의 비용

이 접근에는 피할 수 없는 트레이드오프가 있다. 한 함수 안에 두 가지 에러 전파 메커니즘이 공존한다. resultOf { } 블록은 Result를 반환하지만 그 안에서 getOrThrow()는 예외를 던진다. 팀원은 "이 에러는 Result로 처리해야 하나, throw해야 하나?"를 매번 판단해야 한다.

원칙은 명확하다. 비즈니스 케이스면 Result, 인프라 장애면 throw - 하지만 실무에서는 경계가 애매한 경우가 반드시 있다. 이 인지 부하를 받아들일 수 있는지는 팀의 상황에 달렸다. throw 기반으로 통일하고 @ExceptionHandler에서 분류하는 방식이 더 단순하고 많은 팀에서 충분히 잘 동작한다. Result를 도입할 때는 "호출자가 실패를 빠뜨리지 않도록 강제하는 것"이 그 복잡성 비용을 정당화할 만큼 중요한지를 먼저 판단해야 한다.

Java에서 비슷하게 구현하려면

Kotlin에는 sealed class, 확장 함수, 수신 객체(ResultScope)가 있어서 Result 패턴을 자연스럽게 구현할 수 있다. 이런 언어 기능이 없는 Java에서는 어떤 선택지가 있을까.

방법 1: vavr의 Either — Result와 가장 유사하지만 bind()가 없어서 flatMap 중첩을 피할 수 없다.

getCustomer(id).flatMap(customer ->
    getProgress(orderId).flatMap(progress ->
        getItems(orderId).flatMap(items ->
            // 깊어짐...
        )
    )
);

방법 2: checked exception — 실패가 시그니처에 드러나고 명령형 스타일이라 중첩 없이 쓸 수 있다. 다만 합성(map/recover)이 안 되고 catch 블록이 반복된다.

Customer getCustomer(...) throws CustomerNotFoundException;
// 호출자가 try-catch를 강제당함 — 실패 시그니처 명시라는 목적은 Result와 동일

방법 3: Go 스타일 응답 래퍼 — 결과와 에러를 하나의 객체에 담아 반환한다. 단순하지만 에러 체크를 빼먹어도 컴파일러가 잡아주지 않는다.

Response<Customer> response = getCustomer(id);
if (response.getError() != null) { return Response.error(response.getError()); }
// error 체크를 안 해도 컴파일 됨 — response.getData()가 null일 수 있음

4. 전파

에러를 핸들링했으면 이제 호출자에게 전달해야 한다. 단일 서비스에서는 간단하지만 마이크로서비스 환경에서는 에러가 서비스 경계를 넘어야 한다.

서비스 경계에서 에러 번역하기

이 때 서비스 경계에서 처리는 외부 시스템의 모델이 내부 도메인을 오염시키면 안 된다는 설계 원칙을 기반으로 한다.

헥사고날 아키텍쳐에서는 외부 의존성을 어댑터에서 포트의 언어로 변환하도록 한다. DDD에서는 오염 방지 계층(Anti-Corruption Layer)를 두어 외부 Bounded Context의 모델을 내부 도메인 언어로 번역하도록 한다.

에러도 마찬가지다. 외부 시스템의 raw exception이 서비스 경계를 넘어서는 안 된다. 그대로 전파하면 장애가 전이된다.

결제 모듈이 던지는 원본 예외(PgApiException, InventorySystemException)는 우리 도메인의 언어가 아니다. 이를 그대로 전파하면 두 가지 문제가 생긴다.

  1. GlobalExceptionHandler에서 Exception으로 잡혀 UNKNOWN_ERROR로 처리된다. module 정보가 빠지고 장애 원인 추적이 어려워진다.
  2. 외부 시스템의 예외 구조가 바뀌면 우리 서비스 코드에 영향을 준다. 서비스 경계가 깨진다.

그래서 어댑터 계층에서 외부 예외를 도메인의 InfraException으로 변환한다.

object PaymentExceptionClassifier {
    fun classify(e: Exception): PaymentExecutionException = when (e) {
        is PgApiException -> PaymentGatewayException("PG사 통신 오류: ${e.message}")
        is InventorySystemException -> InventoryException("재고 시스템 오류: ${e.message}")
        else -> InventoryException("결제 모듈 알 수 없는 오류: ${e.message}")
    }
}

어댑터에서 아래와 같이 사용한다.

@Component
class PaymentExecutionAdapter(
    private val paymentClient: PaymentClient,
) : PaymentExecutionPort {

    override fun execute(orderId: OrderId, items: List<OrderItem>): Result<PaymentExecutionException, Unit> {
        return try {
            paymentClient.requestPayment(orderId.value, items.map { it.productName })
            Unit.asSuccess()
        } catch (e: Exception) {
            PaymentExceptionClassifier.classify(e).asFailure()
        }
    }
}

서비스 경계에서 에러를 번역한다. 외부 시스템의 예외를 우리 도메인의 InfraException으로 변환하면서 module, code, retryable 같은 메타데이터를 부여한다. 이 정보가 호출 체인을 타고 올라가 최종 응답에 실린다.

API 응답으로 내리기

서비스 내부에서 에러를 처리했으면 최종적으로 적절한 HTTP 응답으로 바꿔야 한다.

BusinessError의 경우 toResponseEntity() 확장 함수로 400 응답을 반환한다.

fun <T> Result<BusinessError, T>.toResponseEntity(): ResponseEntity<Any> = when (this) {
    is Result.Success -> ResponseEntity.ok(value)
    is Result.Failure -> {
        log.warn("[BUSINESS] {}: {}", error.code, error.msg)
        ResponseEntity.status(HttpStatus.BAD_REQUEST).body(
            ErrorResponse(error = error.code, message = error.msg)
        )
    }
}

InfraException의 경우 GlobalExceptionHandler에서 500/503 응답하게 한다. moduleretryable 정보에 따라 로그 레벨과 응답 헤더가 달라진다.

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(InfraException::class)
    fun handleInfraException(ex: InfraException): ResponseEntity<ErrorResponse> {
        logWithModule(ex.module, ex)

        val status = if (ex.retryable) HttpStatus.SERVICE_UNAVAILABLE
                     else HttpStatus.INTERNAL_SERVER_ERROR
        val headers = HttpHeaders()
        if (ex.retryable) {
            ex.retryAfterSeconds?.let { headers.set("Retry-After", it.toString()) }
        }

        return ResponseEntity.status(status).headers(headers).body(
            ErrorResponse(error = ex.code, module = ex.module, message = ex.message ?: "인프라 오류가 발생했습니다.")
        )
    }

    @ExceptionHandler(Exception::class)
    fun handleUnexpectedException(ex: Exception): ResponseEntity<ErrorResponse> {
        logWithModule("unknown", ex)
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
            ErrorResponse(error = "UNKNOWN_ERROR", module = "unknown", message = "예상하지 못한 오류가 발생했습니다.")
        )
    }
}

컨트롤러는 이 둘을 조합한다.

@RestController
@RequestMapping("/api/orders")
class OrderController(
    private val orderService: OrderService,
    private val orderAlertPolicy: OrderAlertPolicy,
) {

    @PostMapping("/place")
    fun placeOrder(@RequestBody request: PlaceOrderRequest): ResponseEntity<Any> {
        return orderService.placeOrder(request)
            .onFailure { orderAlertPolicy.alertIfNeeded(it) }
            .toResponseEntity()
    }
}

에러 응답 설계

응답 바디는 에러 분류를 반영한다.

{
  "error": "PAYMENT_GATEWAY_ERROR",
  "module": "payment",
  "message": "PG사 통신 중 오류가 발생했습니다."
}
필드설명비고
error에러 코드. 클라이언트 분기 처리용API 계약으로 유지
module담당 모듈. 모니터링/팀 라우팅용InfraException만 포함
message사용자 표시용 메시지

BusinessError에는 module이 없다. "고객이 비활성 상태다"는 특정 모듈이 고장 난 게 아니다. 어느 팀에 라우팅할 문제가 아니라 호출자가 요청을 수정해야 하는 문제다.

상태 코드 전략:

에러HTTP 상태이유
BusinessError400예측된 비즈니스 케이스
InfraException(retryable)503 + Retry-After일시적 장애, 재시도 가능
InfraException500인프라 장애
Unknown500예상하지 못한 버그

BusinessError를 400 하나로 통일한 이유는 다음과 같다. CustomerNotFoundError를 404, OrderNotReadyError를 409 등으로 세분화할 수도 있지만 상태 코드만으로는 클라이언트가 구체적인 대응을 결정하기 어렵기 때문이다. 409를 받아도 "뭐가 충돌인지"는 결국 error 필드를 봐야 한다. 같은 정보를 두 곳에 중복하는 셈이므로 400으로 통일하고 error 필드로 분기하는 방식을 선택했다.

이 선택에는 트레이드오프가 있다. HTTP 상태 코드는 클라이언트 분기 처리만을 위한 것이 아니다. 프록시, CDN, API 게이트웨이 같은 중간 인프라가 상태 코드를 기반으로 동작한다. 예를 들어 404는 캐시 가능하고, 429는 rate limiting 시그널이다. 이런 시맨틱을 400으로 뭉개면 중간 인프라가 활용할 수 있는 정보를 잃는다. 팀의 인프라 구성에 따라 상태 코드를 세분화하는 것이 더 나은 선택일 수 있다.

type이나 severity 필드도 두지 않았다. 에러 분류는 HTTP 상태 코드(400/500/503)로 이미 드러나고 구체적인 에러는 error 필드로 구분된다. 400은 예상된 실패, 500은 시스템 이상, 503은 일시적 장애로 분류하여 상태 코드 자체가 심각도 역할을 한다.

마이크로서비스에서의 module

단일 서비스에서는 module이 내부 식별용이지만 마이크로서비스 환경에서는 의미가 달라진다.

클라이언트 -> API Gateway -> 주문 서비스 -> 결제 서비스 -> PG사

PG사가 타임아웃을 내면 결제 서비스는 알 수 있다. 하지만 주문 서비스는? 최종 클라이언트는? 호출 체인이 길어질수록 "어디서 터진 건지" 파악이 어려워진다.

에러 응답에 module이 포함되어 있으면 호출 체인의 어느 지점에서 터졌든 최종 호출자는 무엇이(error), 어디서(module), 어떻게 대응해야 하는지(상태 코드 + Retry-After)를 판단할 수 있다.

전체 흐름을 정리하면 다음과 같다.

PG사 API timeout
  -> PaymentClient: PgApiException 발생
  -> PaymentExceptionClassifier: PaymentGatewayException(module="payment", retryable=true)으로 분류
  -> PaymentExecutionAdapter: Result.Failure로 변환
  -> OrderService: retry 3회 시도 -> 여전히 실패 -> getOrThrow()로 예외 전파
  -> GlobalExceptionHandler: 503 + Retry-After: 30 + module="payment" 응답
  -> 호출자: module로 원인 식별, Retry-After로 재시도 판단

Swagger로 에러 계약 문서화

1장에서 파악한 에러 목록은 API 문서에 반영되어야 한다. BusinessError든 InfraException이든 예측된 실패는 모두 호출자가 알아야 하는 경우의 수다.

@ApiErrors([
    CustomerNotFoundError::class,    // BusinessError -> 400
    OrderNotReadyError::class,       // BusinessError -> 400
    FeatureDisabledError::class,     // BusinessError -> 400
    EmptyCartError::class,           // BusinessError -> 400
    PaymentGatewayException::class,  // InfraException(retryable) -> 503
    InventoryException::class,       // InfraException -> 500
    PaymentLogicException::class,    // InfraException -> 500
])
@PostMapping("/place")
fun placeOrder(@RequestBody request: PlaceOrderRequest): ResponseEntity<Any>

@ApiErrors에 에러 클래스만 나열하면 상속 관계를 보고 400/500/503을 자동 분류한다. 에러 클래스의 기본 생성자에서 codemsg를 읽어 example을 생성하므로, 에러 타입을 추가하면 문서가 자동으로 업데이트된다.

1장에서 열거한 7가지 실패 시나리오가 여기까지 왔다. 파악 -> 분류 -> 핸들링 -> 전파 -> 문서화. 에러 계약이 코드에서 시작해 API 문서까지 일관되게 이어진다.


5. 관찰가능성(observability)

에러를 핸들링하고 전파했다. 이제 운영 환경에서 어떻게 모니터링할지 정해야 한다.

로그 레벨 전략

에러로그 레벨이유
BusinessErrorWARN예측된 케이스. 에러율 지표에 포함되면 안 됨
InfraExceptionERROR + MDC시스템 이상. 모니터링 대상
UnknownERROR + MDC버그. 즉시 조치 필요

InfraExceptionmodule 정보를 MDC에 기록한다. Grafana나 OpenSearch에서 errorModule = "payment"로 필터링하면 결제 관련 인프라 오류만 볼 수 있다.

private fun logWithModule(module: String, ex: Exception) {
    MDC.put("errorModule", module)
    try {
        log.error("[{}] {}", module, ex.message, ex)
    } finally {
        MDC.remove("errorModule")
    }
}

어떤 에러를 볼지는 코드가 아니라 대시보드에서 결정한다. 모니터링 대상은 수시로 바뀔 수 있으므로 코드 배포 없이 조정할 수 있어야 한다.

알림과 에러 분류의 분리

많은 팀에서 에러 알림을 로그 기반으로 운영한다. 애플리케이션이 log.error()를 남기면 로그 수집기가 이를 감지하고 슬랙 등의 채널로 알림을 발송하는 구조다. 설정이 간단하고 별도 코드 없이 인프라 레벨에서 동작한다는 장점이 있다. 하지만 심각한 문제가 있다.

에러 분류와 알림이 결합되어 있다.

log.error()를 찍으면 무조건 알림이 간다. "이 에러는 알림을 보내야 하고, 이 에러는 무시해도 된다" 같은 선별이 불가능하다. 결과적으로 두 가지 중 하나가 벌어진다.

  1. 중요한 에러만 log.error()로 남긴다 - BusinessError까지 log.error()로 올리게 되고 에러율 지표가 왜곡된다.
  2. 에러 로그를 있는 그대로 유지한다 - 알림 채널이 노이즈로 넘치고 진짜 중요한 알림이 묻힌다.

해결은 간단하다. 알림을 로그 레벨이 아닌 정책으로 결정한다.

먼저 BusinessError를 sealed interface로 정의한다.

sealed interface BusinessError : ResultError

data class CustomerNotFoundError(...) : BusinessError
data class OrderNotReadyError(...) : BusinessError
data class FeatureDisabledError(...) : BusinessError
data class EmptyCartError(...) : BusinessError

그러면 알림 정책에서 when이 exhaustive check를 강제한다.

@Component
class OrderAlertPolicy(
    private val alertPublisher: AlertPublisher,
) {

    fun alertIfNeeded(error: BusinessError) {
        when (error) {
            is EmptyCartError -> alertPublisher.send("[WARN] ${error.msg}")
            is OrderNotReadyError -> alertPublisher.send("[WARN] ${error.msg}")
            is CustomerNotFoundError -> { }  // 알림 불필요
            is FeatureDisabledError -> { }   // 알림 불필요
        }
        // else 없음 -> 새 에러 타입이 추가되면 컴파일 에러
    }
}

else를 쓰면 새 에러 타입이 추가되었을 때 조용히 무시된다. sealed interface를 사용하면 새 에러 타입을 추가한 개발자가 알림 여부를 컴파일 타임에 결정하도록 강제한다. Result로 에러 처리를 강제하는 것과 같은 원리다.

같은 400 응답이라도 OrderNotReadyError(주문이 이미 처리 중인데 또 요청이 왔다)는 운영팀이 인지해야 하고 CustomerNotFoundError(고객 정보가 없다)는 정상 흐름이다. 이런 판단은 에러 타입이 아니라 정책에서 결정해야 유연하다.


마치며

이 글에서 다룬 내용은 사실 당연한 이야기다. 에러를 분류하고 각각에 맞게 처리하고 전파하고 관찰한다. 그런데 막상 이렇게 정리된 걸 본 기억이 별로 없다. 왜일까? 혹시 굳이 생각할 필요가 없기 때문은 아닐까?

에러 처리를 대충 해도 기능은 돌아간다. 장애가 터지기 전까지는 아무도 모른다. 핵심 기능 결함처럼 즉각적인 피드백이 없다. 잘못된 에러 설계에 따른 비용은 마치 빚에 대한 이자처럼 뒤늦게 청구된다.

터져도 "로그가 부족했네" "알림이 안 왔네"로 끝나지 "에러 설계가 잘못됐다"로 귀결되는 경우는 드물다. 프레임워크가 기본적인 건 다 해주니 되긴 되니까 넘어간다. 그리고 에러는 주인이 없다. 도메인 모델과 API는 각 담당자가 설계하는데 에러는 모든 기능에 걸쳐 있으니 아무도 전체를 설계하지 않는다. 중요하지 않은 건 아닌데 자연스럽게 안 보이게 된다.

에러를 던지냐 값으로 반환하냐 같은 건 오히려 부차적인 문제다. 인식이 먼저다.

에러에 대한 조직의 일관적인 멘탈 모델이 있다면 분류, 표현, 전파, 관찰가능성은 자연스럽게 따라온다. 에러도 도메인 모델이나 API처럼 모델링과 디자인의 대상이 된다.

이 에러는 누구의 문제이고 어떻게 대응해야 하는가. 나부터도 다음에 try-catch를 치기 전에 한 번쯤 멈춰서 생각해보려 한다.

예제 코드는 GitHub에서 확인할 수 있다.