본문으로 건너뛰기
Leo Jung
뒤로 가기

[DDD Intro #19] Layered Architecture: 도메인 계층을 보호하기

페이지 수정

DDD를 구현하려고 하면 곧 아키텍처 이야기를 만나게 된다. Presentation Layer, Application Layer, Domain Layer, Infrastructure Layer 같은 이름이 등장한다. 패키지를 나누고, 의존성 방향을 정하고, 각 계층의 책임을 구분한다.

하지만 계층형 아키텍처의 목적을 단순히 코드를 예쁘게 분류하는 것으로 이해하면 부족하다. DDD에서 계층을 나누는 가장 중요한 이유는 도메인 모델을 보호하기 위해서다.

도메인 모델은 비즈니스 언어와 규칙을 담아야 한다. 그런데 웹 프레임워크, 데이터베이스, 메시징, 외부 API 같은 기술 세부사항은 언제든 그 모델을 오염시킬 수 있다. 계층형 아키텍처는 도메인 모델이 이런 기술적 관심사에 끌려가지 않도록 경계를 만든다.

계층은 책임을 나누기 위한 약속이다

일반적으로 DDD에서 계층은 다음과 같이 나눌 수 있다.

Presentation Layer
Application Layer
Domain Layer
Infrastructure Layer

Presentation Layer는 사용자나 외부 시스템의 요청을 받는다. HTTP Controller, GraphQL Resolver, Message Listener 같은 것들이 여기에 해당한다.

Application Layer는 유스케이스를 조율한다. 요청을 Command로 바꾸고, Repository를 통해 Aggregate를 조회하고, 도메인 객체를 호출하고, 트랜잭션을 관리한다.

Domain Layer는 도메인 모델의 핵심이다. Entity, Value Object, Aggregate, Domain Service, Domain Event, Repository 인터페이스 같은 것들이 여기에 들어갈 수 있다.

Infrastructure Layer는 기술적 구현을 담당한다. 데이터베이스 접근, 외부 API 호출, 메시지 발행, 파일 저장, 이메일 발송 같은 세부사항이 여기에 있다.

이 구분은 절대적인 법칙이 아니다. 프로젝트 규모나 언어, 프레임워크에 따라 다르게 나눌 수 있다. 중요한 것은 각 계층이 어떤 책임을 갖는지 팀이 명확히 이해하는 것이다.

도메인 계층은 중심에 있어야 한다

DDD에서 가장 중요한 계층은 Domain Layer다. 하지만 현실의 코드에서는 도메인 계층이 가장 약한 위치에 놓이기 쉽다.

컨트롤러는 요청 형식을 강요한다. 데이터베이스는 테이블 구조를 강요한다. ORM은 기본 생성자와 프록시 제약을 강요한다. 외부 API는 자신의 응답 모델을 강요한다.

이 영향이 그대로 도메인 객체에 들어오면 모델은 도메인의 언어보다 기술의 언어를 말하게 된다.

@Entity
@Table(name = "tb_subs")
public class Subscription {
    @Column(name = "stat_cd")
    private String statCd;

    public void applyPgResponse(PgResponse response) {
        if (response.getResultCode().equals("0000")) {
            this.statCd = "A";
        }
    }
}

이 코드는 영속성 구조와 외부 PG 응답이 도메인 모델 안으로 들어온 예다. 구독 도메인의 언어는 희미하고, 기술 세부사항이 강하게 드러난다.

계층형 아키텍처는 이런 의존성을 밀어내기 위한 구조다.

의존성 방향이 중요하다

계층을 나누는 것보다 더 중요한 것은 의존성 방향이다. 도메인 계층이 프레임워크나 인프라 계층에 직접 의존하면 보호받기 어렵다.

바람직한 방향은 대체로 다음과 같다.

Presentation → Application → Domain
Infrastructure → Domain 또는 Application의 추상화 구현

Application Layer는 Domain Layer를 사용한다. Infrastructure Layer는 Domain Layer가 정의한 Repository 인터페이스를 구현할 수 있다. 하지만 Domain Layer는 Infrastructure의 구체 구현을 몰라야 한다.

// domain
public interface SubscriptionRepository {
    Optional<Subscription> findById(SubscriptionId id);
    void save(Subscription subscription);
}

// infrastructure
public class JpaSubscriptionRepository implements SubscriptionRepository {
    // JPA를 사용한 구현
}

이 구조에서 도메인 모델은 JPA 구현체를 모른다. 저장소가 어떻게 구현되는지는 바깥의 관심사다.

Application Layer는 도메인과 외부 세계를 연결한다

Application Layer는 도메인 계층을 외부 세계와 연결하는 조율자다.

@Transactional
public void cancel(CancelSubscriptionCommand command) {
    Subscription subscription = subscriptionRepository.findById(command.subscriptionId());
    subscription.cancelByMember(command.reason(), clock.now());
    subscriptionRepository.save(subscription);
}

이 계층은 요청을 받아 도메인 객체에게 일을 시킨다. 하지만 도메인 규칙 자체를 직접 처리하지 않는 것이 좋다.

Application Layer가 없다면 Controller가 도메인 객체와 Repository, 외부 API를 직접 조합하게 된다. 반대로 Application Layer가 너무 많은 도메인 판단을 하게 되면 Domain Layer가 빈약해진다.

좋은 Application Layer는 외부 요청과 도메인 모델 사이에서 흐름을 조율하되, 도메인 판단은 도메인 계층에 맡긴다.

Infrastructure는 세부사항이다

데이터베이스, 메시지 브로커, 외부 API, 파일 시스템은 중요하다. 하지만 도메인 관점에서는 세부사항이다.

구독 도메인에서 중요한 것은 “만료된 구독은 갱신할 수 없다”는 규칙이다. 이것이 MySQL에 저장되는지 PostgreSQL에 저장되는지, Kafka로 이벤트를 발행하는지 HTTP로 호출하는지는 도메인 규칙의 본질이 아니다.

물론 기술 선택이 시스템 품질에 큰 영향을 준다. 하지만 도메인 모델이 기술 선택에 종속되어서는 안 된다. Infrastructure Layer는 도메인 모델이 필요로 하는 기능을 구현하되, 도메인 언어를 침식하지 않아야 한다.

public interface PaymentPort {
    PaymentResult pay(PaymentRequest request);
}

도메인이나 애플리케이션은 PaymentResult를 이해하면 된다. 실제 PG사의 응답 코드는 Infrastructure에서 번역한다.

계층을 나누어도 경계가 무너지기 쉽다

패키지를 나누었다고 계층형 아키텍처가 지켜지는 것은 아니다. 다음과 같은 일이 자주 발생한다.

Controller에서 Entity의 setter를 직접 호출한다.
Application Service에 모든 도메인 조건문이 들어간다.
Domain Layer가 외부 API DTO를 import한다.
Infrastructure의 JPA Entity가 도메인 규칙을 모두 결정한다.
공통 Util 클래스가 도메인 정책을 우회한다.

계층은 폴더 구조가 아니라 의존성과 책임의 약속이다. 이 약속이 깨지면 패키지는 남아 있어도 아키텍처는 무너진다.

코드 리뷰에서 다음 질문을 자주 해야 한다.

이 규칙은 어느 계층의 책임인가?
도메인 계층이 기술 세부사항을 알고 있지는 않은가?
Application Service가 도메인 판단을 너무 많이 하고 있지는 않은가?
Infrastructure 모델이 도메인 모델을 대신하고 있지는 않은가?

현실적인 타협은 필요하다

계층형 아키텍처를 이야기하면 모든 기술 의존성을 완벽히 제거해야 하는 것처럼 느껴질 수 있다. 하지만 현실에서는 타협이 필요하다.

작은 서비스에서 도메인 모델과 JPA Entity를 완전히 분리하는 것은 비용이 클 수 있다. 모든 외부 연동에 포트를 만들고 어댑터를 분리하는 것도 과할 수 있다.

중요한 것은 어떤 타협이 Core Domain을 해치는지 판단하는 것이다. 단순 CRUD 영역에서는 기술 친화적인 구조가 충분할 수 있다. 반면 Core Domain에서는 도메인 모델이 기술 구조에 끌려가지 않도록 더 강한 경계가 필요하다.

아키텍처는 종교가 아니다. 목적은 도메인을 보호하는 것이다.

마치며

Layered Architecture는 코드를 네 개의 폴더로 나누는 방법이 아니다. 도메인 모델을 중심에 두고, 외부 요청과 기술 세부사항이 도메인 규칙을 침식하지 않도록 책임을 나누는 구조다.

Presentation Layer는 요청을 받고, Application Layer는 유스케이스를 조율하고, Domain Layer는 비즈니스 언어와 규칙을 담고, Infrastructure Layer는 기술적 세부사항을 구현한다.

이 구분이 중요한 이유는 도메인 모델이 오래 살아남아야 하기 때문이다. 프레임워크는 바뀔 수 있고, 데이터베이스도 바뀔 수 있고, 외부 API도 바뀔 수 있다. 하지만 핵심 도메인의 언어와 규칙은 시스템의 중심에 남아 있어야 한다.

좋은 계층형 아키텍처는 도메인 모델을 고립시키는 것이 아니라, 도메인 모델이 자신의 언어로 일할 수 있는 공간을 만들어준다.


페이지 수정
공유하기:

이전 글
[DDD Intro #20] Hexagonal Architecture: 도메인을 중심에 두는 구조
다음 글
[DDD Intro #18] Specification과 Policy: 조건과 정책을 모델링하기