본문 바로가기

Architecture/DDD

[DDD] 외부 Context 통합의 비가용성, DDD와 헥사고날 아키텍처로 다루기

들어가며

Bounded Context 간 통합은 DDD에서 피할 수 없는 현실입니다. 신용 평가 서비스, 결제 시스템, 배송 추적 API... 우리의 시스템은 항상 외부와 연결되어 있습니다.

 

그런데 외부 시스템은 언제든 응답하지 못할 수 있습니다. 네트워크가 끊기거나, 서비스가 점검 중이거나, 응답이 늦어지거나, 요청한 데이터 자체가 없을 수도 있습니다.

 

이 상황을 어떻게 처리하느냐는 단순한 예외 처리 문제가 아닙니다. Vaughn Vernon은 Implementing Domain-Driven Design에서 다음과 같이 말합니다.

비가용성(Unavailability)은 예외적 상황이 아니라 도메인의 정상적인 상태다.

이 포스트에서는 Vernon의 이 관점을 출발점으로, 비가용성을 도메인 모델에서 어떻게 명시적으로 표현하는지 살펴봅니다. 그리고 이 아이디어가 헥사고날 아키텍처의 레이어 분리와 맞물릴 때 각 책임이 어떻게 자연스럽게 나뉘는지까지 함께 다룹니다.

 


안티패턴: 예외(Exception)로 처리하면 무슨 일이 생기나

먼저 흔히 볼 수 있는 잘못된 방식부터 보겠습니다. 외부 신용 평가 서비스를 호출하는 코드입니다.

// ❌ 안티패턴: 예외 기반 처리
@Service
public class EvaluateLoanApplicationService {

    private final ExternalCreditApiClient client;

    public LoanDecision evaluate(EvaluateLoanCommand command) {
        CreditScore score = null;

        try {
            score = client.fetchScore(command.customerId()); // null이 올 수도 있음
        } catch (HttpTimeoutException e) {
            // 어떻게 해야 하지? 일단 로그만 남기자
            log.error("Timeout", e);
        } catch (ServiceUnavailableException e) {
            // 여기도 마찬가지
            log.error("Service down", e);
        }

        if (score == null) {
            // null인 이유가 타임아웃인지, 데이터 없음인지, 서비스 다운인지 구분 불가
            return LoanDecision.rejected(); // 그냥 거절 처리?
        }

        return evaluateWithScore(command, score);
    }
}

이 코드에는 세 가지 문제가 있습니다.

 

첫째, null의 의미가 모호합니다. score가 null인 이유가 타임아웃인지, 해당 고객의 데이터가 없는 것인지, 서비스 자체가 다운된 것인지 구분할 수 없습니다. 케이스마다 다른 비즈니스 규칙이 필요한데도 말입니다.

 

둘째, 비즈니스 로직과 예외 처리가 뒤섞입니다. try-catch 블록이 도메인 의사결정 코드 사이에 끼어들어 코드의 의도가 흐려집니다. 이 서비스가 대출 심사를 하는 것인지, 예외를 처리하는 것인지 한눈에 파악하기 어렵습니다.

 

셋째, 새로운 케이스가 생겼을 때 누락이 쉽습니다. "평가 중(Pending)" 상태가 새로 추가되었을 때 처리 분기를 빠뜨려도 컴파일러는 아무 말도 하지 않습니다.

 

이 세 가지 문제는 모두 같은 원인에서 비롯됩니다. 비가용성을 도메인 밖의 기술적 문제로 취급했기 때문입니다.

 


Vernon의 접근: 비가용성은 도메인 상태다

예외(Exception)는 본래 "예상치 못한 상황"을 표현하기 위한 메커니즘입니다. 그런데 외부 서비스가 응답하지 못하는 것이 정말 예상치 못한 상황일까요?

 

분산 시스템에서 외부 호출의 실패는 언제든 일어날 수 있는, 충분히 예측 가능한 상황입니다. 그럼에도 불구하고 예외로 처리하면 도메인 모델에서 이 상황이 보이지 않게 됩니다. 호출부마다 catch 블록이 흩어지고, 어떤 케이스가 존재하는지는 코드를 샅샅이 뒤져야만 알 수 있습니다.

 

Vernon이 제안하는 핵심은 다음과 같습니다.

외부 Context 호출의 결과는 항상 명시적인 타입으로 반환되어야 하며, 비가용 케이스도 그 타입의 일부여야 한다.

 

비가용 상태를 도메인 모델 안에서 명시적인 값으로 표현하면, 앞서 살펴본 세 가지 문제가 자연스럽게 해소됩니다. null의 의미가 명확해지고, 비즈니스 로직과 예외 처리가 분리되며, 새로운 케이스의 누락을 컴파일 타임에 감지할 수 있게 됩니다.

 

이제 이 아이디어를 코드로 구현해보겠습니다.

 


Enum 기반 명시적 상태 모델링

상태 정의

먼저 외부 신용 평가 서비스 조회 시 발생할 수 있는 모든 결과를 enum으로 정의합니다.

public enum CreditRatingStatus {
    AVAILABLE,           // 정상 조회 성공
    SERVICE_UNAVAILABLE, // 외부 서비스 다운 (일시적 장애)
    TIMEOUT,             // 응답 시간 초과 (일시적 장애)
    UNKNOWN_CUSTOMER,    // 해당 고객의 신용 이력 없음 (영구적 상태)
    RATING_PENDING       // 평가가 아직 진행 중 (Eventual Consistency)
}

각 값이 단순한 에러 코드가 아니라 도메인이 인식하는 상태임을 주목하세요. 일시적 장애와 영구적 상태, 진행 중인 상태를 구분하는 것 자체가 이미 도메인 지식입니다.

 

결과 타입 정의

다음으로, 외부 Context 호출의 결과를 항상 하나의 타입으로 감쌉니다. null을 반환하거나 예외를 던지는 대신, 항상 CreditRatingResult 인스턴스가 반환됩니다.

public class CreditRatingResult {

    private final CreditRatingStatus status;
    private final CreditScore score; // AVAILABLE 상태일 때만 의미 있음

    // 생성자를 private으로 막아 반드시 팩토리 메서드를 통해서만 생성
    private CreditRatingResult(CreditRatingStatus status, CreditScore score) {
        this.status = status;
        this.score = score;
    }

    // 정적 팩토리 메서드로 생성 의도를 명확하게 표현
    public static CreditRatingResult available(CreditScore score) {
        return new CreditRatingResult(CreditRatingStatus.AVAILABLE, score);
    }

    public static CreditRatingResult unavailable(CreditRatingStatus reason) {
        if (reason == CreditRatingStatus.AVAILABLE) {
            throw new IllegalArgumentException("Use available() instead.");
        }
        return new CreditRatingResult(reason, null);
    }

    public boolean isAvailable() {
        return this.status == CreditRatingStatus.AVAILABLE;
    }

    public CreditRatingStatus status() {
        return status;
    }

    public CreditScore score() {
        // AVAILABLE이 아닌 상태에서 점수를 꺼내려 하면 즉시 실패
        // → 잘못된 사용을 런타임에 조기 감지
        if (!isAvailable()) {
            throw new IllegalStateException("Rating is not available: " + status);
        }
        return score;
    }
}

생성자를 private으로 막으면 외부에서 new CreditRatingResult(...)로 직접 생성할 수 없습니다. 반드시 available()이나 unavailable(reason)을 통해야 하며, 이 이름들 자체가 생성 의도를 코드로 표현합니다. 어떤 상태로 만들어지는지가 메서드 이름에서 바로 드러나는 것입니다.

 


헥사고날 아키텍처에서의 위치

도메인 상태 타입을 정의했다면, 이제 이것을 어디에 배치하고 각 레이어가 어떤 책임을 가져가는지가 중요합니다. 이 패턴은 헥사고날 아키텍처의 레이어 분리와 맞물릴 때 진가를 발휘합니다.

 

Port와 결과 타입(CreditRatingResult, CreditRatingStatus)은 도메인 패키지에 위치합니다. 이것이 DIP(의존성 역전 원칙)의 핵심입니다. 인프라가 도메인을 바라보는 것이지, 도메인이 인프라를 알아서는 안 됩니다.

 

Outgoing Port (도메인 패키지)

// 도메인 패키지에 위치 — 인프라가 이 인터페이스를 구현
public interface CreditRatingPort {
    CreditRatingResult fetchRating(CustomerId customerId);
}

 

Application Service

@Service
public class EvaluateLoanApplicationService implements EvaluateLoanApplicationUseCase {

    private final CreditRatingPort creditRatingPort;
    private final LoanApplicationRepository repository;

    @Override
    public LoanDecision evaluate(EvaluateLoanCommand command) {
        CreditRatingResult ratingResult =
            creditRatingPort.fetchRating(command.customerId());

        return switch (ratingResult.status()) {
            case AVAILABLE ->
                evaluateWithScore(command, ratingResult.score());

            case SERVICE_UNAVAILABLE, TIMEOUT ->
                // 일시적 외부 장애 → 보수적 정책으로 심사 보류
                LoanDecision.deferredDueToExternalFailure(command.loanApplicationId());

            case UNKNOWN_CUSTOMER ->
                // 신용 이력 없음 → 추가 서류 요청
                LoanDecision.requireAdditionalDocuments(command.loanApplicationId());

            case RATING_PENDING ->
                // 아직 평가 중 → 재처리 대기
                LoanDecision.pendingExternalRating(command.loanApplicationId());
        };
    }
}

try-catch가 단 한 줄도 없습니다. Application Service는 오직 비즈니스 결정에만 집중하고, 기술적인 세부사항은 전혀 알지 못합니다.

 

Adapter (인프라 레이어)

@Component
public class HttpCreditRatingAdapter implements CreditRatingPort {

    private final ExternalCreditApiClient client;

    @Override
    public CreditRatingResult fetchRating(CustomerId customerId) {
        try {
            CreditApiResponse response = client.get(customerId.value());

            if (response.isPending()) {
                return CreditRatingResult.unavailable(CreditRatingStatus.RATING_PENDING);
            }
            if (response.customerNotFound()) {
                return CreditRatingResult.unavailable(CreditRatingStatus.UNKNOWN_CUSTOMER);
            }

            return CreditRatingResult.available(CreditScore.of(response.score()));

        } catch (HttpTimeoutException e) {
            return CreditRatingResult.unavailable(CreditRatingStatus.TIMEOUT);
        } catch (ServiceUnavailableException e) {
            return CreditRatingResult.unavailable(CreditRatingStatus.SERVICE_UNAVAILABLE);
        }
        // 예외가 Application Layer로 올라가지 않는다
    }
}

Adapter의 책임은 단 하나입니다. 기술적인 예외를 도메인 결과 타입으로 번역하는 것. HTTP 예외, 네트워크 오류, 응답 코드 해석은 모두 이 레이어에서 끝납니다.

 


Java 17+: Sealed Interface로 컴파일 타임 안전성 강화하기

enum 기반 모델링으로도 충분하지만, Java 17 이상이라면 sealed interface와 패턴 매칭을 활용해 한 단계 더 나아갈 수 있습니다.

 

enum과의 결정적인 차이는 각 케이스가 고유한 데이터를 가질 수 있다는 점입니다. 예를 들어 Available 케이스는 CreditScore를 담고, Timeout은 실제 지연 시간(ms)을 담는 식으로 케이스별 컨텍스트를 풍부하게 표현할 수 있습니다.

// permits에 명시된 타입 외에는 구현 불가 — 케이스가 봉인됨
public sealed interface CreditRatingResult
    permits CreditRatingResult.Available,
            CreditRatingResult.ServiceUnavailable,
            CreditRatingResult.Timeout,
            CreditRatingResult.UnknownCustomer,
            CreditRatingResult.RatingPending {

    record Available(CreditScore score) implements CreditRatingResult {}
    record ServiceUnavailable() implements CreditRatingResult {}
    record Timeout(long elapsedMs) implements CreditRatingResult {} // 케이스별 데이터 보유 가능
    record UnknownCustomer() implements CreditRatingResult {}
    record RatingPending() implements CreditRatingResult {}
}

 

Application Service에서는 타입 패턴 매칭으로 각 케이스를 처리합니다.

@Override
public LoanDecision evaluate(EvaluateLoanCommand command) {
    CreditRatingResult result = creditRatingPort.fetchRating(command.customerId());

    return switch (result) {
        case Available r ->
            evaluateWithScore(command, r.score());   // r.score()로 데이터 접근

        case ServiceUnavailable(), Timeout() ->
            // 일시적 외부 장애 → 보수적 정책으로 심사 보류
            LoanDecision.deferredDueToExternalFailure(command.loanApplicationId());

        case UnknownCustomer() ->
            // 신용 이력 없음 → 추가 서류 요청
            LoanDecision.requireAdditionalDocuments(command.loanApplicationId());

        case RatingPending() ->
            // 아직 평가 중 → 재처리 대기
            LoanDecision.pendingExternalRating(command.loanApplicationId());

        // permits에 새로운 타입이 추가되면 이 switch가 컴파일 에러를 냄
        // → 처리 누락이 런타임이 아닌 컴파일 타임에 감지됨
    };
}

sealed interface를 사용하면 새로운 케이스가 permits에 추가될 때 switch 분기를 추가하지 않으면 컴파일 자체가 실패합니다. 버그가 프로덕션에 도달하기 전에 처리를 강제하는 것입니다.

 

Java 버전에 따라 도구를 선택하면 됩니다. Java 17 미만이라면 앞서 살펴본 enum + 결과 타입 조합으로도 충분히 의도를 표현할 수 있습니다.

 


실무 적용 시 고려사항

재시도 정책 (Retry)

TIMEOUT이나 SERVICE_UNAVAILABLE처럼 일시적 장애에는 재시도가 유효합니다. 다만 재시도 로직은 Adapter 내부에서 처리해야 하며, Application Service가 알아야 할 내용이 아닙니다. Application Service가 재시도를 인식하는 순간, 인프라 관심사가 도메인으로 스며드는 것입니다.

 

Resilience4j와 같은 라이브러리를 활용하면 Adapter 레이어에서 깔끔하게 처리할 수 있습니다.

// Adapter 안에서만 재시도 처리 — Application Service는 이를 알 필요 없음
@Retry(name = "creditRating", fallbackMethod = "fallbackResult")
@Override
public CreditRatingResult fetchRating(CustomerId customerId) {
    // HTTP 호출...
}

public CreditRatingResult fallbackResult(CustomerId customerId, Exception e) {
    // 모든 재시도 소진 후 → 도메인 상태로 반환
    return CreditRatingResult.unavailable(CreditRatingStatus.SERVICE_UNAVAILABLE);
}

재시도 횟수, 지연 간격, 지수적 백오프(Exponential Backoff) 전략 등은 별도로 다룰 주제입니다. 여기서 중요한 것은 재시도가 끝난 뒤에도 결국 CreditRatingResult로 귀결된다는 점입니다. Adapter 밖으로는 항상 도메인 상태만 흘러나옵니다.

 

Eventual Consistency 처리

RATING_PENDING 상태는 외부 Context가 아직 데이터를 준비하지 못한 상태입니다. 이 경우 두 가지 전략을 고려할 수 있습니다.

  • 폴링(Polling): 일정 시간 후 동일 요청을 재시도
  • 이벤트 구독: 외부 Context가 평가 완료 이벤트를 발행하면 그때 처리

두 방법 모두 RATING_PENDING이라는 명시적 상태가 도메인 모델에 존재해야 올바르게 설계할 수 있습니다. 이 상태를 null이나 예외로 처리하면 "지금 데이터가 없다"와 "아직 준비 중이다"를 구분할 방법이 없습니다.

 

케이스 분류 기준

비가용 상태를 설계할 때 다음 세 가지 성격으로 분류하면 각 케이스에 맞는 정책을 정의하는 데 도움이 됩니다.

 


정리

이 접근의 핵심을 한 문장으로 정리하면 다음과 같습니다.

Adapter가 기술적 예외를 도메인 상태로 번역하고, Application Service는 그 상태에 따라 도메인 결정에만 집중한다.

 

이 분리가 성립하려면 각 레이어의 책임이 명확해야 합니다.

  • Domain Package: 가능한 결과 케이스를 타입으로 정의하고, Outgoing Port 인터페이스를 소유 — 도메인 언어로 상태와 계약을 표현하며, DIP의 기반이 됩니다
  • Adapter: 기술적 예외 → 도메인 결과 타입으로 번역 — 인프라 세부사항을 도메인으로부터 격리
  • Application Service: status에 따른 비즈니스 의사결정 — try-catch 없이 오직 도메인 로직만

예외 처리가 try-catch로 코드베이스에 흩어지는 대신, 도메인 모델의 명시적인 상태로 표현될 때 코드는 더 읽기 쉬워지고 변경에 더 안전해집니다. 헥사고날 아키텍처는 의존성의 방향을 통제함으로써 이 분리가 자연스럽게 유지되도록 구조적으로 뒷받침합니다.