포스트

멀티 모듈 및 DDD에 대한 질문과 답변

멀티모듈 및 DDD로 구성한 사이드 프로젝트 진행 중 BE 팀원에게 이런 질문을 받았다.

어플리케이션 계층에 Repository의 Interface가 존재하는 이유가 뭔가요? 인프라 계층에서 Interface를 정의하고 구현하면 되지 않을까요?

어플리케이션 계층과 도메인 계층에서 공통적으로 서비스 패키지가 존재하는 이유가 뭔가요? 역할의 차이를 모르겠어요.

질문을 받았던 당시 팀원분께 참고했던 책과 유튜브 링크를 보내드리면서, “의존성 역전 원칙을 적용하기 위함입니다.”, “서로 다른 계층에서 사용되는 서비스이기 때문입니다.” 라며 대충 답변드리고 넘어갔었다. 회의가 길어져 피곤했던 것도 있었지만, 질문에 명쾌하게 답변 드릴 수 있을만큼 내용 정리가 되지 않았던 것 같다. 이번 글을 통해 DDD에 대한 정리와 함께, 위 질문들에 대한 답변을 정리해보려한다.


I. Domain Driven Design

도메인이란 단어는 정말 자주 사용되지만 정확하게 정의하기 쉽지 않다. 폭 넓게 사용되는 만큼 추상적이기 때문인 것 같은데, Eric Evans는 그의 저서 Domain-Driven Design에서 도메인에 대해 아래와 같이 정의한다.

A sphere of knowledge, influence, or activity. The subject area to which the user applies a program.

“지식, 영향력, 활동의 영역으로써, 사용자가 프로그램을 사용하는 대상이 되는 범위” 정도로 번역을 해볼 수 있을 것 같다. DDD 관련해 가장 유명하고 교과서 격인 책이지만 도메인에 대한 설명이 역시 잘 와닿지 않는다. 지금까지 도메인과 관련해 읽었던 여러 아티클과 책들 중에서, 가장 이해가 잘 되었던 건 김우근씨의 “자바/스프링 개발자를 위한 실용주의 프로그래밍”의 한 대목이었다. 그 내용을 간략하게 요약 및 각색하면 다음과 같다.


도메인에 대한 정의 이전에, 소프트웨어 개발이 어떻게 시작되는 지에 대해 이야기해야 한다. 이는 ‘자바를 쓰고, gradle을 사용해 관리하고 어쩌고’하는 개발자적인 시선이 아니라, 비즈니스 관점에서 소프트웨어 개발을 시작하는 것, 다시 말해 지극히 사업가 시선에서 소프트웨어를 만들게 되는 과정을 살펴보아야 한다. 우리는 살아가면서 다양한 불편을 겪는다. 은행 창구 상담은 시간을 너무 잡아먹고, 출근길 버스를 하염없이 기다려야 한다. 이런 불편이 있을 때 사업가가 등장한다. 개발자의 시선에서 사업가는 대다수 사람들이 겪는 불편을 분석하고 해결하는 사람이다. 물론 해결과 함께 돈을 받긴 하지만..

사업가는 사람들의 불편을 분석한다. 다시 말해 사업가는 문제를 정의한다. 정의된 문제는 특성에 따라 한 문제 영역으로 묶을 수 있다. 은행 창구 상담의 불편은 ‘대출 과정이 복잡하다.’, ‘대출 심사가 오래 걸린다.’, ‘예적금 가입 시 신분증이 꼭 필요하다.’ 와 같이 문제가 정의될 수 있고, ‘대출 과정이 복잡하다.’, ‘대출 심사가 오래 걸린다.’와 같은 문제는 “대출 문제 영역”으로, ‘예적금 가입 시 신분증이 꼭 필요하다.’와 같은 문제는 “개인 인증 문제 영역”으로 묶을 수 있다. 이 때 문제 영역이 바로 도메인이다.


도메인 주도 설계에서 개발자는 소프트웨어를 통해 도메인을 해결한다. 개발자는 단순히 요구사항에 맞는 소프트웨어를 개발하는 것이 아니라, 도메인을 이해하고 소통하며, 적절한 솔루션을 개발할 수 있어야 한다. 도메인의 이해가 전제되기에 소프트웨어 디자인 역시 도메인을 중심으로 진행된다. DDD의 계층 구조를 살펴보기에 앞서, 일반적인 3-Tier 계층 구조를 먼저 알아보자.

II. 3-Tier Layered Architecture

3-tier-arch (그림 II-1) 3 Tier Layered Architecture

대부분의 Spring 개발자에게 Layered Architecture를 물어보면, 보통 위와 같은 구조로 설명한다. Spring Component(@Controller, @Service, @Repository)와 3-Tier 구조를 1대 1로 대응시키곤 한다. 협업 상황에서 위와 같은 구조는 예상치 못한 문제를 야기하곤 한다.

의존 방향이 Controller -> Service -> Repository 순서이다 보니, 개발자는 자연스럽게 인프라 계층부터 개발하려고 한다. Jpa를 사용한다면 JpaRepository를 만들어야 할거고, 그 이전에는 JpaEntity를 정의해야할 것이다. JpaEntity를 정의하기 이전에는 당연히 테이블부터 정의를 해야하고, 결국 핵심적인 비즈니스 로직을 다루는 개발은 CREATE TABLE 어쩌고하는 DDL 없이는 먼저 진행될 수 없다.

이는 OOP의 SOLID 원칙이 목표로 하는 유연한 설계와 확장성을 달성할 수 없는 구조가 된다. 인프라 모듈 담당자의 작업 전에는, 어떤 코드도 작성될 수 없다.

III. 3-Tier to DDD

1. 도메인 계층 추가하기

위에서 이야기한 3-Tier 구조의 문제점을 인식한 상태에서 도메인을 추가해보자. 앞서 이야기했듯 도메인은 해결해야 할 문제 영역이다. 문제 영역의 해결은 규칙과 정책을 따라 진행되며, 보통 이를 비즈니스 로직이라고 부른다. 결국 도메인이 가야할 곳은 비즈니스 계층일 수 밖에 없다. 또한 DDD에서 개발은 도메인을 중심으로 진행된다. 다시 말해 도메인은 가장 먼저 개발되어야 하고, 다른 계층에서 참고할 수 있어야 한다. 때문에 도메인은 다른 계층에 의존해서는 안되며, 순수한 도메인 관련 로직만을 포함해야 한다. 마지막으로 도메인을 비즈니스 계층에서 도메인 계층으로 분리하면 아래와 같은 구조가 된다.

3-tier-arch-with-domain (그림 III-1) 3 Tier Layered Architecture with domain

도메인을 추가함으로써, 인프라 계층 작업이 끝나길 기다리지 않고도 코드를 작성할 수 있게 되었다. 하지만 비즈니스 계층은 여전히 인프라 계층에 의존하고 있다. 다음 과정을 통해 이 문제도 해결해보자.

2. 인프라 계층에 대한 의존 끊기

어플리케이션 계층에 Repository의 Interface가 존재하는 이유가 뭔가요? 인프라 계층에서 Interface를 정의하고 구현하면 되지 않을까요?

이번엔 팀원분이 위 질문에서 말씀하신 구조를 도식화해보자.

3-tier-arch-with-repository-interface (그림 III-2) 3 Tier Layered Architecture with repository interface

아쉽지만 위 구조에서 비즈니스 계층은 여전히 인프라 계층과 강하게 결합되어 있다. JpaRepository가 아닌 순수한 Repository interface를 사용하기 때문에, 구현 기술(Jpa, MyBatis 등)나 DB(MySQL, MongoDB 등)에 변경이 있을 경우 비교적 간단하게 대처할 수 있다는 장점은 분명 존재한다.

이러한 결합을 끊을 수 있는 방법은 Spring DI를 활용하는 것이다. Spring 프레임워크는 컴포넌트의 멤버 변수가 interface로 정의되어 있을 때, 그 구현체로써 생성된 객체를 자동으로 주입한다. 다시 말해 비즈니스 계층에 interface로 기능을 정의하고, 인프라 계층에서 해당 interface의 구현체를 만들면 비즈니스 계층과 인프라 계층의 의존성을 역전시킬 수 있다.

3-tier-arch-with-dip (그림 III-3) 3 Tier Layered Architecture with DIP for repository

위 그림을 보면 비즈니스 계층은 필요한 요소들에 대해 interface로 정의해두고, interface를 사용해 Service 컴포넌트를 개발한다. 비즈니스 계층은 더이상 인프라 계층에서 어떤 기술과 어떤 스택을 사용해 기능을 제공하는지 알 필요가 없다. 또한 인프라 계층은 Jpa, MyBatis, Mongo, MySQL 등 사용할 기술과 스택에 따라 자유롭게 취사 선택해 기능을 구현할 수 있다.

3. 비즈니스 계층 이름 바꾸기

개념적인 측면에서 비즈니스 계층은 도메인 계층을 포함하는 계층이다. 비즈니스 로직을 구현하는 계층에서, 도메인(문제 영역)이 분리되었다면 더 이상 비즈니스 계층으로 부를 수 없다. 때문에 DDD에선 어플리케이션을 구성하는 데 필요한 컴포넌트를 위한 집합이라는 의미에서, 응용(Application) 계층이라고 부른다. 이름까지 변경하고 나면, 최종적으로 아래와 같은 구성이 된다.

3-tier-arch-with-app (그림 III-4) 3 Tier Layered Architecture with application layer

IV. DDD 계층 구조와 멀티모듈

지금까지 DDD 계층 구조에 대해 설명한 것을 토대로, 실제 이번 사이드 프로젝트에서 멀티모듈과 DDD를 어떻게 연관지어 설계했는지 확인해보자. 먼저 프로젝트 패키지 구조는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
connectors-backend/
├── core/                       # 공통 유틸리티, 예외 처리
├── setting/                    # 인프라 설정 모듈
│   ├── cache/                  # Redis 설정
│   ├── database/               # JPA/MySQL 설정
│   ├── storage/                # S3 등 스토리지 설정
│   └── external/               # 외부 API 설정 (Feign)
├── presentation/               # 진입점 (API, Batch)
│   ├── api/                    # REST API 엔드포인트
│   └── batch/                  # 배치 작업
└── {context}/                  # Context (DDD)
    ├── {context}-domain/       # 도메인 모델 & 비즈니스 로직
    ├── {context}-app/          # 유스케이스 & infra 계층 interface 정의
    └── {context}-infra/        # 인프라 구현 (JPA, Redis, Feign, S3)

1. 도메인 계층({context}-domain)

DDD-domain-layer (그림 IV-1) Domain Layer

위 사진에서 확인할 수 있듯, 도메인 계층은 어떤 계층에도 의존성을 지니지 않는다. 때문에 core로 정의된 전 모듈 공통 사항을 제외하면, 어떤 모듈도 참조하지 않는다.

1
2
3
4
5
dependencies {
    implementation(project(":core"))

    implementation 'org.springframework:spring-context'
}

다만 spring-context 관련 의존은 도메인 서비스 관리 측면에서 편의성을 고려해 추가했다.

2. 인프라 계층({context}-infra)

DDD-infra-layer (그림 IV-2) Infrastructure Layer

인프라 계층은 Repository 구현을 위해 응용 계층의 Repository를 알아야 한다. 응용 계층에서 Repository를 정의할 때, 도메인 모델을 기준으로 조회/생성/수정/삭제하게 되는데, 이 때 아래와 같이 도메인 계층의 모델이 함께 사용된다.

1
2
3
4
5
6
public interface MemberRepository {
    Optional<Member> findByEmail(String email);
    Member save(Member member);
    Optional<Member> findById(MemberId memberId);
    Optional<Member> findByNickname(String nickname);
}

때문에 인프라 모듈은 도메인 모듈과 어플리케이션 모듈을 컴파일 시점에는 알고 있어야 하지만, 실질적으로 인프라 모듈 실행 시점에 도메인 모듈 혹은 어플리케이션 모듈에 정의된 기능을 사용하는 일은 없다. 때문에 CompileOnly로 도메인 모듈과 어플리케이션 모듈을 참조한다.

1
2
3
4
5
6
7
8
9
10
11
dependencies {
    api(project(":setting:cache"))
    api(project(":setting:database"))
    api(project(":setting:external"))
    api(project(":setting:storage"))

    implementation(project(":core"))

    compileOnly(project(":{context}:{context}-app"))
    compileOnly(project(":{context}:{context}-domain"))
}

:setting:{stack}으로 정의된 모듈들은 각 항목에 맞는 환경변수(.yml)과 org.springframework.boot:spring-boot-starter-data-jpa 같은 스택 사용에 공통적으로 필요한 모듈을 참조하고 있다. 이 내용들은 상위 계층까지 전파될 필요가 있기 때문에, api를 적용했다.

3. 응용 계층({context}-app)

DDD-app-layer (그림 IV-3) Application Layer

응용 계층은 도메인 계층에 대한 의존만을 필요로 한다. 인프라 계층의 구현체는 Spring DI를 통해 주입될 것이기 때문에, 인프라 계층에 대한 의존도 필요 없다. 다만 아래와 같이 도메인 계층에서 사용되는 Enum 등이 표현 계층에서 사용되는 경우가 있기 때문에, api를 사용해 표현 계층에 도메인 관련 의존성을 전파한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package xxx.xxx.presentation.api.xxx.request;     // 표현 계층에 정의된 객체

import xxx.xxx.xxx.domain.enums.OAuth2Provider;   // Domain 계층의 Enum 참조
...(생략)...

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class OAuth2UrlRequest {
    @NotNull(message = "Provider required")
    @Schema(description = "OAuth2 Provider", example = "GOOGLE")
    private OAuth2Provider provider;
    
    ...(생략)...
}

또한 표현 계층이 갖는 트랜잭션 관리의 책임과, Spring 환경에서의 편의성을 고려해 org.springframework:spring-txorg.springframework:spring-context를 참조한다.

1
2
3
4
5
6
7
dependencies {
    implementation(project(":core"))
    api(project(":{context}:{context}-domain"))

    implementation 'org.springframework:spring-tx'
    implementation 'org.springframework:spring-context'
}

표현 계층에서 api를 통해 domain 모듈에 대한 의존을 전파하는 것이 맞는지는 아직 고민 중이다. 전파하지 않고 처리할 수 있게 구현하는 게 맞는 것 같은데, 도메인 모델에서 사용하는 Enum으로 변환하는 과정에서 너무 많은 보일러플레이트가 발생한다…

4. 표현 계층(:presentation:{api/batch/etc..})

DDD-presentation-layer (그림 IV-4) Presentation Layer

표현 계층은 기본적으로 Application 모듈을 의존하여, Application 계층에 구현된 Application Service를 호출해 요청을 처리한다. API 서버, Batch 서버 등 실제로 Runnable한 모듈이 위치하기 때문에 spring-boot-starter-web와 같은 모듈을 의존한다. 또한 인프라 모듈을 RuntimeOnly로 의존함으로써 모듈 구현 시에는 전혀 인프라 모듈의 내용을 알 수 없지만, 런타임에 인프라 모듈을 활용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
plugins {
    id 'org.springframework.boot'
}

dependencies {
    implementation(project(":core"))

    implementation(project(":{context}:{context}-app"))

    runtimeOnly(project(":{context}:{context}-infra"))

    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // Swagger
    implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.8.13'
    implementation 'org.apache.commons:commons-lang3:3.18.0'    // Apache Commons Lang3 취약점 대응 (GHSA-j288-q9x7-2f5v)

    // Json web token
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
    compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
}

bootJar { enabled = true }
jar { enabled = false }

V. 도메인 서비스 vs 어플리케이션 서비스

어플리케이션 계층과 도메인 계층에서 공통적으로 서비스 패키지가 존재하는 이유가 뭔가요? 역할의 차이를 모르겠어요.

두 서비스의 차이를 설명하기 전에, 먼저 우리에게 가장 익숙한 Spring Annotation 중 하나인 @Service에 대해 알아보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * Indicates that an annotated class is a "Service", originally defined by Domain-Driven
 * Design (Evans, 2003) as "an operation offered as an interface that stands alone in the
 * model, with no encapsulated state."
 *
 * <p>May also indicate that a class is a "Business Service Facade" (in the Core J2EE
 * patterns sense), or something similar. This annotation is a general-purpose stereotype
 * and individual teams may narrow their semantics and use as appropriate.
 *
 * <p>This annotation serves as a specialization of {@link Component @Component},
 * allowing for implementation classes to be autodetected through classpath scanning.
 *
 * @author Juergen Hoeller
 * @since 2.5
 * @see Component
 * @see Repository
 */

@Service의 주석이다. 중요해 보이는 내용을 번역하면 아래와 같다.

  1. @Service는 DDD에 영감을 받아 작성되었으며, 캡슐화된 상태 없이 모델과 독립된 인터페이스를 제공한다.
  2. 비즈니스 서비스 파사드처럼 사용될 수 있으며, 일반적인 용어로 기획되었으나 필요에 따라 개념을 좁혀 사용할 수 있다.

서비스는 도메인 모델과 별개로 독립된 연산을 수행하며, 일종의 파사드로서 기능할 수 있는 장치로 볼 수 있다. 도메인 서비스든 어플리케이션 서비스든 결국 서비스이기 때문에, 우리는 각 계층에서 어떻게 서비스가 활용되는지를 기준으로 구분해야 한다.

1. 도메인 서비스

도메인 서비스는 도메인 계층에서, 도메인 모델 단독으로 처리할 수 없거나 기술될 수 없는 연산을 처리한다. 예를 들어 최종 주문 금액을 계산해야 한다고 가정해보자. 이 때 최종 주문 금액은 회원의 멤버십 등급에 따라 다르게 할인율을 적용하고, 쿠폰 할인율도 함께 계산해야한다. 주문 도메인 모델, 회원 도메인 모델, 쿠폰 도메인 모델 그 어떤 모델도 단독으로 해당 요구사항을 구현할 수 없다. 이런 경우 아래와 같이 도메인 서비스가 활용된다.

1
2
3
4
5
6
7
8
9
// Domain Service
public class OrderPriceCalculator {
    public Money calculate(Order order, Customer customer, Coupon coupon) {
        Money basePrice = order.getTotalPrice();
        Money discount = coupon.calculateDiscount(basePrice);
        Money memberDiscount = customer.getMembershipLevel().getDiscount(basePrice);
        return basePrice.minus(discount).minus(memberDiscount);
    }
}

2. 어플리케이션 서비스

어플리케이션 서비스는 표현 계층에서 사용할 유스케이스를 구현한다. 어플리케이션 서비스는 도메인 서비스, 도메인 모델, 각종 외부 Port(Repository, ExternalAPI 등)와 통신을 중재해 비즈니스 목표를 달성한다. 위 예시와 같은 상황에서 어플리케이션 서비스는 다음과 같이 동작한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Application Service
@Transactional
public class OrderApplicationService {
    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;
    private final OrderPriceCalculator priceCalculator;
    
    public OrderResult placeOrder(PlaceOrderCommand command) {
        Customer customer = customerRepository.findById(command.getCustomerId());
        Order order = Order.create(command.getItems(), customer);
        
        Money finalPrice = priceCalculator.calculate(order, customer, command.getCoupon());
        order.setFinalPrice(finalPrice);
        
        orderRepository.save(order);
        return OrderResult.from(order);
    }
}


VI. 그래서 답변은?

지금까지 정리한 내용을 토대로 두 가지 질문에 대한 답변을 하자면 다음과 같다.

어플리케이션 계층에 Repository의 Interface가 존재하는 이유가 뭔가요? 인프라 계층에서 Interface를 정의하고 구현하면 되지 않을까요?

의존성 역전 원칙(Dependency Inversion Principle)을 적용하기 위함입니다.

만약 인프라 계층에서 Repository Interface를 정의한다면, 어플리케이션 계층은 인프라 계층에 직접 의존하게 됩니다. 이는 다음과 같은 문제를 야기합니다:

  1. 기술 종속성: MySQL에서 MongoDB로, JPA에서 MyBatis로 변경할 때 어플리케이션 계층의 코드도 영향을 받습니다.
  2. 테스트 어려움: 어플리케이션 서비스를 테스트하려면 실제 데이터베이스나 인프라 계층의 구현체가 필요합니다.
  3. 도메인 중심 설계 위배: 인프라가 중심이 되어, 비즈니스 로직이 기술에 종속됩니다.

반면 어플리케이션 계층에 Repository Interface를 정의하면:

  1. 도메인 언어로 인터페이스 작성: findByIdAndJoinFetch() 같은 JPA 기술적 메서드가 아닌, findActiveOrders() 같은 비즈니스 중심 메서드를 정의할 수 있습니다.
  2. 독립적인 개발 가능: 인프라 구현 없이도 어플리케이션 로직을 먼저 개발할 수 있습니다.
  3. 유연한 구현 교체: JPA에서 MyBatis로, MySQL에서 PostgreSQL로 변경해도 어플리케이션 계층은 영향받지 않습니다.

그림 III-3에서 확인할 수 있듯, 어플리케이션 계층이 필요한 기능을 Interface로 정의하고, 인프라 계층이 이를 구현하는 구조로 의존성의 방향이 역전됩니다. 이는 DDD가 추구하는 도메인 중심 설계의 핵심이며, 인프라는 단지 도메인을 지원하는 수단일 뿐이라는 철학을 구현하는 방법입니다.

어플리케이션 계층과 도메인 계층에서 공통적으로 서비스 패키지가 존재하는 이유가 뭔가요? 역할의 차이를 모르겠어요.

두 서비스는 책임과 역할이 명확히 다릅니다.

도메인 서비스는 순수한 비즈니스 로직을 담당합니다. 단일 도메인 모델로는 표현할 수 없는 도메인 규칙을 구현하며, 인프라에 대한 의존성이 전혀 없습니다. 예를 들어 OrderPriceCalculator는 주문, 회원, 쿠폰이라는 여러 도메인 모델을 조합하여 최종 금액을 계산하는 순수한 비즈니스 로직을 담고 있습니다.

어플리케이션 서비스는 유스케이스를 구현합니다. 도메인 모델과 도메인 서비스를 조율하고, Repository를 통해 데이터를 조회/저장하며, 트랜잭션을 관리합니다. OrderApplicationService는 고객 조회, 주문 생성, 가격 계산(도메인 서비스 위임), 주문 저장이라는 일련의 흐름을 조율합니다.

Spring의 @Service는 DDD의 서비스 개념에서 영감을 받았지만, 실제로는 두 가지 서비스가 각각의 계층에서 다른 역할을 수행합니다. 도메인 서비스는 “무엇을 계산할 것인가”에 집중하고, 어플리케이션 서비스는 “어떻게 흐름을 조율할 것인가”에 집중합니다.

VII. 참고

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.