오류처리 설계 철학과 분류 체계
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에 적용:
- Basic Guarantee: 자원 누수 없음
- Strong Guarantee: 연산이 성공하거나 원래 상태 유지
- 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()
}
}이해도 검증 질문
이론 이해 확인
-
Separation of Concerns: 현재 시스템에서 비즈니스 로직과 오류처리가 뒤섞여 있는 부분을 3가지 예시로 들어보세요.
-
Fail Fast vs Fail Safe: 항공 발권 시스템에서 Fail Fast를 적용해야 할 부분 3가지, Fail Safe를 적용해야 할 부분 3가지를 설명해보세요.
-
3계층 분류: 다음 오류들을 Domain/Application/Infrastructure 중 어느 계층에 속하는지 분류하고 이유를 설명해보세요:
- 마일리지 부족으로 인한 업그레이드 실패
- Redis 연결 타임아웃
- 외부 항공사 API 응답 지연
- 이미 취소된 예약 건에 대한 환불 요청
실무 적용 확인
-
Error Translation: 현재 연동하고 있는 외부 서비스 하나를 선택해서, 그 서비스의 오류를 어떻게 도메인 오류로 번역할지 설계해보세요.
-
중복 오류 메시지: 현재 겪고 있는 “노운 이슈 전파” 문제의 구체적인 예시 하나를 들어보세요. 어떤 메시지가 여러 서비스를 거치면서 변질되고 있나요?
다음 단계 미리보기
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
관련 문서
- MSA 환경 오류처리 마스터 과정 (전체 커리큘럼)
- Step 1 연습문제