Post

Layered Architecture: Application(UseCase)과 Domain을 분리해야 하는 이유

Layered Architecture: Application(UseCase)과 Domain을 분리해야 하는 이유

시스템을 설계하고 레이어드 아키텍처(Layered Architecture)를 적용하다 보면 흔히 다음과 같은 의문이 든다.

“애플리케이션 서비스 코드만 봐도 위에서 아래로 순차적으로 흐름이 다 보이는데, 굳이 Application(UseCase) 계층과 Domain 계층을 따로 나눠야 할까?”
“어차피 애플리케이션에서 수량 맞춰서 매도 주문 날리고 롤백 처리 다 해줬는데, 굳이 도메인 모델에 require 같은 불변식을 중복으로 걸어둬야 할까?”

이러한 의문은 도메인을 단순히 ‘데이터를 담는 DTO/엔티티’나 ‘포트 인터페이스 모음집’ 정도로 생각하는 빈약한 도메인 모델(Anemic Domain Model) 관점에서 비롯된다.

하지만 특히 실제 자본(돈)이 집행되는 트레이딩/금융 시스템에서는 이 둘의 경계가 무너지는 순간, 시스템의 복잡도와 장애 비용이 기하급수적으로 증가한다. Application과 Domain을 분리해야 하는 본질적인 이유와 두 계층의 역할 차이를 정리해 본다.


1. 두 계층의 본질적인 관심사 차이

두 계층의 책임은 “무엇(What & Rule)”“어떻게(How & Orchestration)”의 차이로 요약된다.

1
2
3
4
5
6
7
Runners / Controller / Scheduler (트리거 & 진입점)
   │
   ▼
Application Layer (유스케이스 오케스트레이션 / 실행 흐름 조율)
   │
   ▼
Domain Layer (순수 비즈니스 규칙, 계산 공식, 도메인 불변식, 포트 계약)
구분Domain 계층 (비즈니스 본질)Application 계층 (유스케이스 실행)
핵심 질문“무엇이 비즈니스적으로 참이고 정상 상태인가?”“이 비즈니스 시나리오를 현실에서 어떻게 완수할 것인가?”
주요 역할• 비즈니스 개념 및 상태 표현 (Order, Position, Balance)
• 순수 계산 공식 (괴리율 계산, 김프 보정, 수수료 산출)
• 비즈니스 불변식(Invariant)과 제약조건 강제
• 외부 시스템 연동에 필요한 계약(Port 인터페이스) 정의
• 비즈니스 시나리오(유스케이스) 순서 제어
• 외부 I/O 포트(거래소, DB, 캐시)와 도메인 객체의 협업 조율
• 비동기 스레드/코루틴 제어, 병렬 실행
• 분산 환경에서의 보상 트랜잭션(롤백), 재시도, 알림 발송
외부 종속성완전한 순수 영역 (프레임워크, HTTP, 특정 DB 종속 없음)도메인 포트를 통해 인프라와 협력 (구체 구현체에는 직접 의존 X)

2. 도메인 내부의 협업 vs 애플리케이션의 순차 호출

“도메인은 단순 포트만 정의하고, 순차적인 호출은 다 애플리케이션이 하는 것 아닌가?”라는 생각이 들 수 있다. 하지만 도메인 내부에서도 도메인 객체들끼리 활발하게 서로의 메서드를 순차 호출하여 비즈니스 로직을 구성한다.

차이점은 “그 호출이 I/O 워크플로우인가, 아니면 순수 비즈니스 규칙의 협업인가”에 있다.

(1) 도메인 내부에서의 메서드 호출 (Business Collaboration)

외부 I/O와 무관하게, 비즈니스 규칙을 계산하거나 도메인 모델을 도출하기 위해 다른 도메인 객체/서비스의 메서드를 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Domain Service 내부: 순수 비즈니스 계산을 위해 여러 도메인 메서드를 연쇄 호출
class IntegratedDisparityCalculator(
    private val disparityCalculator: DisparityCalculator,
) {
    fun calculateCoin(coin: String, marketSnapshots: List<MarketSnapshot>): IntegratedDisparityInfo? {
        // 1. 호가창 기반으로 매수/매도 견적을 계산 (Domain 호출)
        val buyQuote = disparityCalculator.quote(orderBook, TradeType.BID, tradingAmountKrw)
        val sellQuote = disparityCalculator.quote(orderBook, TradeType.ASK, tradingAmountKrw)

        // 2. 방향성 괴리율 계산 및 USDT 역프리미엄 보정 공식 호출 (Domain 호출)
        val rawPercent = disparityCalculator.calculate(sellMarket.sellQuote, buyMarket.buyQuote).percent
        val usdtAdjustment = calculateUsdtAdjustment(buyMarket, sellMarket)
        
        // 3. 최적 차익 진입 방향 모델 도출
        return Direction(...)
    }
}

여기서 일어나는 순차 호출은 “호가를 분석하고 -> 괴리율을 계산하고 -> 김프를 보정하여 -> 최적 방향을 결정한다”는 순수한 비즈니스 규칙의 조합이다.

(2) 애플리케이션 계층에서의 순차 호출 (Use Case Pipeline)

애플리케이션 계층의 순차 호출은 외부 인프라 I/O와 도메인 규칙을 엮어 하나의 현실적인 유스케이스를 완성하는 파이프라인이다.

1
2
3
4
5
6
7
1. [I/O] 분산 락 획득 (Redis)
2. [I/O] 거래소로부터 최신 잔고/호가 조회 (Exchange Port)
3. [Domain] 도메인 정책에 질문: "현재 스프레드가 진입 기준 이상인가?" (ArbitragePolicy)
4. [I/O] 진입 가능 시, 현물 매수 & 선물 숏 병렬 주문 전송 (Exchange Port)
5. [I/O] 한쪽 다리 실패 시 보상 트랜잭션(롤백 주문) 실행
6. [I/O] 최종 체결 내역 DB 저장 (Persistence Port)
7. [I/O] 슬랙/텔레그램 모니터링 알림 발송

애플리케이션은 “절차와 I/O 실행”을 관장할 뿐, “괴리율을 어떻게 계산하는지”, “어떤 상태가 델타 뉴트럴 헷지인지” 같은 비즈니스 수식을 직접 품지 않는다.


3. “애플리케이션에 다 있는데 왜 도메인에 require를 걸어야 하는가?”

가장 흔한 의문이다.
“애플리케이션 코드에서 이미 현물 매수 수량만큼 매도하도록 롤백 코드를 다 짜놨는데, 굳이 도메인 객체에 require(original.quantity == fallback.quantity)를 왜 또 명시해야 하는가?”

금융/트레이딩 시스템에서 이것은 자본을 보호하는 최후의 방화벽 역할을 한다.

이유 1: 애플리케이션의 버그로부터 시스템을 보호 (Fail-Fast)

애플리케이션 계층은 비동기 제어(코루틴, 스레드), 타임아웃, 재시도, 외부 API 예외 등으로 인해 코드의 복잡도가 매우 높고 수정이 빈번하게 일어나는 영역이다.

만약 나중에 다른 작업자가 애플리케이션 코드를 수정하다가 실수를 했다고 가정해 보자:

  • 소수점 절사(scale) 실수로 10.001 ETH를 매수 체결했는데 롤백 주문에서 10.000 ETH만 매도함.
  • 실수로 매수 체결 건에 대해 반대 매매가 아닌 또 다른 ‘매수’ 주문을 넣어버림.

만약 도메인 객체가 아무런 검증 없는 단순 DTO라면?

  • 잔여 0.001 ETH가 시장에 무방비(Naked)로 노출된 상태임에도, 시스템은 “롤백 완료(Reverted)”로 정상 처리한다.
  • DB에도 ‘정상 원복’으로 기록되어 대시보드에는 아무 이상이 없는 것처럼 보이지만, 며칠 뒤 코인 가격이 폭락했을 때 잔고가 털리고 나서야 버그를 알게 된다.

하지만 도메인 모델에 불변식이 선언되어 있다면:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Domain Model
sealed interface SpotFutureRecoveryOutcome {
    data class Reverted(
        val original: TradeResult,
        val fallback: TradeResult,
    ) : SpotFutureRecoveryOutcome {
        init {
            require(original.exchangeType == fallback.exchangeType)
            require(original.coin == fallback.coin)
            require(original.quantity.compareTo(fallback.quantity) == 0) { "수량이 일치하지 않으면 원복이 아닙니다." }
            require(original.tradeType != fallback.tradeType) { "매매 방향이 반대여야 원복입니다." }
        }
    }
}

수량이 0.0000001이라도 틀어지거나 거래 방향이 반대가 아니면 그 즉시 Exception을 던지며 비정상 상태(Invalid State)의 객체가 시스템 내에 생성되는 것 자체를 차단한다. (Make invalid states unrepresentable)

이유 2: 단일 진실 공급원 (Single Source of Truth)

시스템이 발전하면 주문과 롤백을 수행하는 유스케이스는 하나가 아니다. 차익거래, 펀딩비 파밍, 신규 상장 감시, 청산 방지 등 수많은 유스케이스가 추가된다.

  • 각 애플리케이션 서비스마다 “원복 검증 로직”을 각자 작성한다면? 누군가는 수량 체크를 빼먹고, 누군가는 잘못된 룰을 적용해 비즈니스 규칙의 파편화가 발생한다.
  • “원복이란 무엇인가”에 대한 정의를 도메인 모델 내부에 강제해 두면, 어떤 애플리케이션 서비스가 개발되더라도 도메인 객체를 생성하는 순간 동일한 비즈니스 규칙의 보호를 받는다.

이유 3: 살아있는 문서 (Living Documentation)

도메인에 불변식이 없고 애플리케이션에만 로직이 녹아 있다면, 신규 입사자나 동료는 “이 시스템에서 ‘원복’이란 정확히 어떤 조건이어야 하는가?”를 확인하기 위해 수백 줄짜리 비동기/네트워크 예외 처리 코드를 뒤져가며 머릿속으로 역산해야 한다.

반면 도메인 모델의 init { require(...) } 4줄만 보면, 비즈니스를 잘 모르는 사람도 “동일 거래소, 동일 코인, 100% 동일 수량, 반대 매매 방향이 성립해야만 원복 상태구나”라는 비즈니스 개념을 즉시 이해할 수 있다.


4. 롤백(Rollback)을 바라보는 두 계층의 역할 분담

결국 ‘롤백’이라는 하나의 기능도 두 계층이 명확하게 역할을 나누어 갖는다.

  • 도메인의 책임 (규칙과 기준):
    • “우리 시스템은 델타 뉴트럴(1:1 헷지)을 유지해야 한다.”
    • “한쪽 다리만 체결된 것은 위험한 불변식 위반이다.”
    • “원래 체결된 수량과 정확히 동일한 수량으로 반대 매매가 체결되어야만 ‘원복(Reverted)’으로 인정한다.”
  • 애플리케이션의 책임 (실행과 오케스트레이션):
    • “외부 거래소 API는 단일 DB 트랜잭션이 없으니 분산 보상 트랜잭션을 실행해야 한다.”
    • “네트워크 응답이 깨졌거나 타임아웃이면 모호하므로, 중복 주문 방지를 위해 자동 롤백을 중단하고 즉시 알림을 쏜다.”
    • “선물 한도 초과 에러면 현물 매도를 호출하고, 단순 네트워크 에러면 1초 간격으로 선물 주문을 3회 재시도한다.”
    • “도메인의 ‘원복’ 조건을 달성하기 위해 필요한 모든 I/O 호출을 차례대로 집행한다.”

5. 결론

  • 애플리케이션(UseCase)“비즈니스 목표를 완수하기 위해 외부 I/O와 싸우며 작업을 지휘하는 오케스트라 지휘자”다.
  • 도메인(Domain)“지휘자가 혹시라도 실수를 하거나 예기치 못한 상황이 발생했을 때, 결코 타협할 수 없는 비즈니스 규칙과 자본 안전성을 지켜내는 엄격한 심사관”이다.

“애플리케이션이 코드를 잘 짰겠지”라는 믿음에만 의존하는 시스템은 코드베이스가 커지고 수정이 반복되는 순간 치명적인 결함을 드러낸다.
Application과 Domain을 명확히 분리하고 도메인에 엄격한 불변식을 부여하는 것은, 단순한 코드 미학이 아니라 복잡한 분산 환경에서 시스템의 정합성과 자본을 지키는 가장 견고한 안전장치다.

This post is licensed under CC BY 4.0 by the author.