오류처리 설계 철학과 분류 체계

Exception Handling의 4대 설계 원칙과 DDD 기반 분류


학습 목표

  • Exception Handling의 4대 설계 원칙 이해
  • DDD 관점에서의 3계층 오류 분류 체계 구축
  • MSA 환경에서 오류 전파 문제 해결을 위한 Error Translation Pattern 적용
  • 항공 발권 시스템 도메인 기반 실무 예시 학습

이론적 기초

Exception Handling의 4대 설계 원칙

1. Separation of Concerns (관심사 분리)

핵심 개념: 오류처리 로직은 비즈니스 로직과 분리되어야 한다.

Martin Fowler의 원칙:

  • 비즈니스 로직에 try-catch가 난무하면 코드 가독성 저하
  • 횡단 관심사(Cross-cutting Concerns)로 처리해야 함
// ❌ 안티패턴: 비즈니스 로직과 오류처리 혼재
class BookingService {
    fun createBooking(request: BookingRequest): BookingResponse {
        try {
            val flight = flightService.findById(request.flightId)
                ?: throw RuntimeException("Flight not found") // 비즈니스 예외를 기술적 예외로 처리
            
            if (flight.availableSeats < request.passengerCount) {
                throw RuntimeException("Not enough seats") // 도메인 규칙을 일반 예외로 처리
            }
            
            val payment = paymentService.processPayment(request.paymentInfo)
            return createBookingEntity(flight, request, payment)
        } catch (Exception e) {
            // 모든 예외를 동일하게 처리 - 정보 손실
            logger.error("Booking failed", e)
            throw RuntimeException("Booking failed")
        }
    }
}
 
// ✅ 모범 사례: 관심사 분리된 설계
class BookingService {
    fun createBooking(request: BookingRequest): BookingResponse {
        // 비즈니스 로직에만 집중
        val flight = flightService.findById(request.flightId)
            ?: throw FlightNotFoundException(request.flightId)
        
        flight.validateSeatAvailability(request.passengerCount) // 도메인 객체가 검증 담당
        
        val payment = paymentService.processPayment(request.paymentInfo)
        
        return bookingRepository.save(
            Booking.create(flight, request.passengers, payment)
        ).toResponse()
    }
    // 오류처리는 @ControllerAdvice에서 일괄 처리
}

2. Fail Fast vs Fail Safe 전략

Fail Fast 원칙 (Jim Shore):

  • 문제를 빨리 발견할수록 수정 비용이 낮아짐
  • 입력 검증, 전제 조건 확인에 적용

Fail Safe 원칙 (MSA 환경):

  • 부분적 실패를 허용하여 전체 시스템 가용성 확보
  • 외부 서비스 연동, 부가 기능에 적용
// Fail Fast: 입력 검증 - 즉시 실패
class BookingRequest {
    val flightId: String
    val passengerCount: Int
    val passengers: List<Passenger>
    
    init {
        require(flightId.isNotBlank()) { "항공편 ID는 필수입니다" }
        require(passengerCount > 0) { "승객 수는 1명 이상이어야 합니다" }
        require(passengers.size == passengerCount) { "승객 정보 수가 일치하지 않습니다" }
    }
}
 
// Fail Safe: 외부 서비스 - 우아한 성능 저하
@Service
class NotificationService {
    
    @CircuitBreaker(name = "notification", fallbackMethod = "fallbackSendBookingConfirmation")
    fun sendBookingConfirmation(booking: Booking) {
        emailService.sendConfirmation(booking) // 실패 가능하지만 핵심 기능 아님
    }
    
    fun fallbackSendBookingConfirmation(booking: Booking, ex: Exception): Unit {
        // 예약은 성공했지만 알림만 실패 - 비즈니스에 영향 없음
        asyncNotificationQueue.enqueue(booking)
        logger.warn("Notification failed, queued for retry: bookingId=${booking.id}", ex)
    }
}

3. Exception Safety Guarantee

C++의 Exception Safety 개념을 Java/Kotlin에 적용:

  1. Basic Guarantee: 자원 누수 없음
  2. Strong Guarantee: 연산이 성공하거나 원래 상태 유지
  3. No-throw Guarantee: 예외 발생하지 않음
// Strong Guarantee 적용 예시
@Transactional
class BookingService {
    fun createBookingWithPayment(request: BookingRequest): BookingResponse {
        val savedBooking = bookingRepository.save(
            Booking.create(request.flightId, request.passengers, BookingStatus.PENDING)
        )
        
        try {
            val payment = paymentService.processPayment(request.paymentInfo)
            savedBooking.confirmWithPayment(payment)
            return savedBooking.toResponse()
        } catch (PaymentException e) {
            // 결제 실패 시 예약도 롤백 (Strong Guarantee)
            savedBooking.cancel()
            throw BookingPaymentFailedException("결제 실패로 인한 예약 취소", e)
        }
    }
}

4. Error Recovery Strategy

// 복구 전략별 분류
enum class ErrorRecoveryStrategy {
    FAIL_FAST,      // 즉시 실패
    RETRY,          // 재시도
    FALLBACK,       // 대안 실행
    IGNORE,         // 무시 (로그만)
    CIRCUIT_BREAK   // 회로 차단
}
 
// 오류 유형별 복구 전략 매핑
class ErrorRecoveryMapper {
    fun getRecoveryStrategy(exception: Exception): ErrorRecoveryStrategy {
        return when (exception) {
            is ValidationException -> ErrorRecoveryStrategy.FAIL_FAST
            is TemporaryException -> ErrorRecoveryStrategy.RETRY
            is ExternalServiceException -> ErrorRecoveryStrategy.FALLBACK
            is NonCriticalException -> ErrorRecoveryStrategy.IGNORE
            is RepeatedFailureException -> ErrorRecoveryStrategy.CIRCUIT_BREAK
            else -> ErrorRecoveryStrategy.FAIL_FAST
        }
    }
}

DDD 기반 3계층 오류 분류

Eric Evans의 Domain-Driven Design 관점

Domain Layer Exceptions - 비즈니스 규칙 위반

/**
 * 도메인 계층 예외
 * - 비즈니스 규칙 위반
 * - 도메인 전문가와 소통 가능한 언어
 * - 복구 불가능 (비즈니스 로직 수정 필요)
 */
sealed class BookingDomainException(
    message: String,
    val errorCode: BookingErrorCode,
    val context: Map<String, Any> = emptyMap()
) : DomainException(message) {
    
    /**
     * 좌석 부족 예외
     * 비즈니스 규칙: 가용 좌석보다 많은 예약 불가
     */
    class InsufficientSeatsException(
        requestedSeats: Int, 
        availableSeats: Int
    ) : BookingDomainException(
        message = "요청 좌석($requestedSeats)이 가용 좌석($availableSeats)을 초과합니다",
        errorCode = BookingErrorCode.INSUFFICIENT_SEATS,
        context = mapOf(
            "requestedSeats" to requestedSeats,
            "availableSeats" to availableSeats
        )
    )
    
    /**
     * 예약 상태 오류
     * 비즈니스 규칙: 상태 전이 규칙 위반
     */
    class InvalidBookingStatusException(
        currentStatus: BookingStatus, 
        requiredStatus: BookingStatus
    ) : BookingDomainException(
        message = "예약 상태가 올바르지 않습니다. 현재: $currentStatus, 필요: $requiredStatus",
        errorCode = BookingErrorCode.INVALID_BOOKING_STATUS,
        context = mapOf(
            "currentStatus" to currentStatus,
            "requiredStatus" to requiredStatus
        )
    )
    
    /**
     * 항공편 운항 취소
     * 비즈니스 규칙: 취소된 항공편은 예약 불가
     */
    class FlightCancelledException(
        flightId: String,
        cancellationReason: String
    ) : BookingDomainException(
        message = "항공편이 취소되어 예약할 수 없습니다: $cancellationReason",
        errorCode = BookingErrorCode.FLIGHT_CANCELLED,
        context = mapOf(
            "flightId" to flightId,
            "cancellationReason" to cancellationReason
        )
    )
}

Application Layer Exceptions - 유스케이스 실행 실패

/**
 * 애플리케이션 계층 예외
 * - 유스케이스 실행 중 발생하는 문제
 * - 도메인 서비스 협력 실패
 * - 외부 시스템 연동 실패
 */
sealed class BookingApplicationException(
    message: String,
    cause: Throwable? = null,
    val retryable: Boolean = false
) : ApplicationException(message, cause) {
    
    /**
     * 결제 처리 실패
     * 외부 결제 시스템과의 연동 실패
     */
    class PaymentProcessingException(
        cause: Throwable,
        val paymentId: String? = null
    ) : BookingApplicationException(
        message = "결제 처리 중 오류가 발생했습니다",
        cause = cause,
        retryable = true // 일시적 실패 가능성
    )
    
    /**
     * 외부 시스템 통신 실패
     * 항공사 시스템, 재고 시스템 등
     */
    class ExternalSystemException(
        systemName: String,
        operation: String,
        cause: Throwable
    ) : BookingApplicationException(
        message = "외부 시스템 통신 실패: $systemName.$operation",
        cause = cause,
        retryable = true
    )
    
    /**
     * 동시성 제어 실패
     * 낙관적 잠금 실패 등
     */
    class ConcurrencyException(
        resourceType: String,
        resourceId: String,
        cause: Throwable
    ) : BookingApplicationException(
        message = "동시 접근으로 인한 처리 실패: $resourceType($resourceId)",
        cause = cause,
        retryable = true
    )
}

Infrastructure Layer Exceptions - 기술적 문제

/**
 * 인프라 계층 예외
 * - 데이터베이스, 네트워크, 파일시스템 등
 * - 기술적 문제로 인한 실패
 * - 일반적으로 재시도 가능
 */
sealed class BookingInfrastructureException(
    message: String,
    cause: Throwable? = null
) : InfrastructureException(message, cause) {
    
    /**
     * 데이터베이스 연결 실패
     */
    class DatabaseConnectionException(
        cause: Throwable
    ) : BookingInfrastructureException(
        message = "데이터베이스 연결에 실패했습니다",
        cause = cause
    )
    
    /**
     * 네트워크 타임아웃
     */
    class NetworkTimeoutException(
        endpoint: String,
        timeoutMillis: Long,
        cause: Throwable
    ) : BookingInfrastructureException(
        message = "네트워크 타임아웃: $endpoint (${timeoutMillis}ms)",
        cause = cause
    )
    
    /**
     * 직렬화/역직렬화 실패
     */
    class SerializationException(
        dataType: String,
        cause: Throwable
    ) : BookingInfrastructureException(
        message = "데이터 직렬화 실패: $dataType",
        cause = cause
    )
}

MSA 오류 전파 문제 해결

문제 상황: 오류 전파의 혼란

Service A (Booking) 
    ↓ calls
Service B (Inventory) 
    ↓ calls  
Service C (External Airline API)
    ↓ throws
"AIRLINE_API_RATE_LIMIT_EXCEEDED_CODE_429_RETRY_AFTER_60_SECONDS"
    ↑ propagates
"InventoryServiceUnavailableException: External API rate limited"
    ↑ propagates  
"BookingFailedException: Unable to check seat availability"

해결책: Anti-Corruption Layer Pattern

/**
 * 오류 번역 계층
 * Bounded Context 경계에서 외부 오류를 내부 도메인 언어로 번역
 */
@Component
class ExternalServiceErrorTranslator {
    
    private val logger = LoggerFactory.getLogger(javaClass)
    
    /**
     * 재고 서비스 오류 번역
     * 외부 서비스의 기술적 오류 → 도메인 의미 있는 오류
     */
    fun translateInventoryServiceError(exception: Exception): BookingDomainException {
        logger.debug("Translating inventory service error", exception)
        
        return when (exception) {
            // 외부 서비스 일시적 장애 → 도메인의 일시적 불가 상태
            is InventoryServiceUnavailableException -> 
                BookingDomainException.SystemTemporarilyUnavailableException(
                    "좌석 정보를 일시적으로 확인할 수 없습니다. 잠시 후 다시 시도해주세요."
                )
            
            // 좌석 부족 → 도메인의 비즈니스 규칙 위반
            is InventoryInsufficientSeatsException ->
                BookingDomainException.InsufficientSeatsException(
                    requestedSeats = exception.requestedSeats,
                    availableSeats = exception.availableSeats
                )
            
            // Rate Limit → 도메인의 일시적 제한 상태
            is InventoryRateLimitException ->
                BookingDomainException.TemporaryRestrictionException(
                    "현재 많은 요청이 몰려 처리가 지연되고 있습니다. 잠시 후 다시 시도해주세요."
                )
            
            // 알 수 없는 오류 → 일반적인 도메인 오류로 변환
            else -> BookingDomainException.ExternalServiceException(
                message = "좌석 정보를 확인할 수 없습니다",
                originalException = exception
            )
        }
    }
    
    /**
     * 결제 서비스 오류 번역
     */
    fun translatePaymentServiceError(exception: Exception): BookingDomainException {
        return when (exception) {
            is PaymentInsufficientFundsException ->
                BookingDomainException.PaymentFailedException(
                    "잔액이 부족하여 결제할 수 없습니다"
                )
            
            is PaymentCardExpiredException ->
                BookingDomainException.PaymentFailedException(
                    "카드 유효기간이 만료되었습니다"
                )
            
            is PaymentServiceTimeoutException ->
                BookingDomainException.SystemTemporarilyUnavailableException(
                    "결제 처리 중 일시적인 문제가 발생했습니다. 잠시 후 다시 시도해주세요."
                )
            
            else -> BookingDomainException.PaymentFailedException(
                "결제 처리 중 오류가 발생했습니다"
            )
        }
    }
}
 
/**
 * 오류 번역을 적용한 서비스
 */
@Service
class BookingService(
    private val inventoryService: InventoryService,
    private val paymentService: PaymentService,
    private val errorTranslator: ExternalServiceErrorTranslator
) {
    fun createBooking(request: BookingRequest): BookingResponse {
        try {
            val availableSeats = inventoryService.getAvailableSeats(request.flightId)
            // ... 비즈니스 로직
        } catch (e: InventoryException) {
            // 외부 서비스 예외를 도메인 예외로 번역
            throw errorTranslator.translateInventoryServiceError(e)
        } catch (e: PaymentException) {
            // 결제 서비스 예외를 도메인 예외로 번역  
            throw errorTranslator.translatePaymentServiceError(e)
        }
    }
}

Error Context Enrichment

/**
 * 오류 컨텍스트 수집기
 * 디버깅과 모니터링을 위한 상황 정보 수집
 */
@Component
class ErrorContextCollector {
    
    fun enrichErrorContext(
        exception: Exception,
        request: HttpServletRequest
    ): ErrorContext {
        return ErrorContext(
            // 분산 추적
            traceId = MDC.get("traceId") ?: generateTraceId(),
            spanId = MDC.get("spanId"),
            
            // 요청 정보
            endpoint = "${request.method} ${request.requestURI}",
            userAgent = request.getHeader("User-Agent"),
            clientIp = getClientIp(request),
            
            // 사용자 정보 (민감정보 제외)
            userId = getCurrentUserId(),
            userType = getCurrentUserType(),
            
            // 시스템 정보
            serviceVersion = getServiceVersion(),
            environment = getEnvironment(),
            hostname = getHostname(),
            
            // 오류 정보
            errorType = exception.javaClass.simpleName,
            errorMessage = exception.message,
            rootCause = ExceptionUtils.getRootCause(exception).javaClass.simpleName,
            
            // 타임스탬프
            timestamp = Instant.now(),
            
            // 커스텀 컨텍스트
            customContext = extractCustomContext(exception)
        )
    }
}

실무 적용 체크리스트

Domain Layer 설계 체크

  • 도메인 전문가가 이해할 수 있는 예외 명칭인가?
  • 비즈니스 규칙 위반이 명확히 표현되는가?
  • 오류 컨텍스트 정보가 충분한가?

Application Layer 설계 체크

  • 재시도 가능한 오류가 구분되는가?
  • 외부 시스템 오류가 적절히 번역되는가?
  • 부분 실패 시나리오가 고려되는가?

Infrastructure Layer 설계 체크

  • 일시적 장애와 영구적 장애가 구분되는가?
  • 리소스 정리가 보장되는가?
  • 모니터링 정보가 충분한가?

Datadog 연동 고려사항

Custom Metrics 설계

@Component
class BookingErrorMetrics(
    private val meterRegistry: MeterRegistry
) {
    fun recordDomainError(exception: BookingDomainException) {
        Counter.builder("booking.domain.errors")
            .tag("error.code", exception.errorCode.name)
            .tag("error.category", exception.errorCode.category)
            .register(meterRegistry)
            .increment()
    }
    
    fun recordApplicationError(exception: BookingApplicationException) {
        Counter.builder("booking.application.errors")
            .tag("retryable", exception.retryable.toString())
            .tag("system", extractSystemName(exception))
            .register(meterRegistry)
            .increment()
    }
}

이해도 검증 질문

이론 이해 확인

  1. Separation of Concerns: 현재 시스템에서 비즈니스 로직과 오류처리가 뒤섞여 있는 부분을 3가지 예시로 들어보세요.

  2. Fail Fast vs Fail Safe: 항공 발권 시스템에서 Fail Fast를 적용해야 할 부분 3가지, Fail Safe를 적용해야 할 부분 3가지를 설명해보세요.

  3. 3계층 분류: 다음 오류들을 Domain/Application/Infrastructure 중 어느 계층에 속하는지 분류하고 이유를 설명해보세요:

    • 마일리지 부족으로 인한 업그레이드 실패
    • Redis 연결 타임아웃
    • 외부 항공사 API 응답 지연
    • 이미 취소된 예약 건에 대한 환불 요청

실무 적용 확인

  1. Error Translation: 현재 연동하고 있는 외부 서비스 하나를 선택해서, 그 서비스의 오류를 어떻게 도메인 오류로 번역할지 설계해보세요.

  2. 중복 오류 메시지: 현재 겪고 있는 “노운 이슈 전파” 문제의 구체적인 예시 하나를 들어보세요. 어떤 메시지가 여러 서비스를 거치면서 변질되고 있나요?


다음 단계 미리보기

2단계에서는 이 이론을 바탕으로:

  • 실제 Exception Hierarchy 클래스 설계
  • Error Code 체계 구축
  • i18n 메시지 처리
  • 직렬화/역직렬화 고려사항

을 다룰 예정입니다.


참조 자료

핵심 개념

  • Domain-Driven Design - Eric Evans (Chapter 14: Maintaining Model Integrity)
  • Release It! - Michael Nygard (Chapter 5: Stability Patterns)
  • Clean Architecture - Robert Martin (Chapter 22: The Clean Architecture)

표준 문서

  • RFC 7807: Problem Details for HTTP APIs
  • Spring Boot Error Handling: 공식 문서

추가 학습 자료

  • Resilience4j 문서: Circuit Breaker Pattern
  • Datadog APM: Error Tracking and Analysis
  • OpenTelemetry: Distributed Tracing Standards

관련 문서