DDD 설계: Use Case 기반의 Application Layer 구현과 트랜잭션 관리

2025. 6. 12. 23:43Framework/NestJS

이번글은 상반기 동안 진행한 WMS 프로젝트에서, 겪었던 문제상황과 해결과정에 대한 내용을 정리해보려고 합니다. 본 글의 주제는 DDD 설계: Use Case 기반의 Application Layer 구현과 트랜잭션 관리이며 제목처럼 DDD의 철학에서 언급하는 Use Case 기반의 애플리케이션 설계에 대해 DD 예정입니다. 특히 Application Service와 Domain Service의 개념을 활용해서 궁극적으로 유지보수하기 쉬운 코드를 설계하기 위해서 제가 어떤 전략을 사용했는지 정리해 볼 예정입니다. 

 

그전에 잠깐 DDD의 개념에 대해 정리하자면 Domain Driven Design의 약자로 도메인 주도 설계를 의미합니다. 즉, 여기서 말하는 도메인은 업무 영역을 의미하며 복잡한 도메인 로직을 명확하게 모델링하고자 하는 것이 DDD의 궁극적인 철학이라고 할 수 있습니다. 

 

내가 생각하는 전통적인 4-Tier Layered Architecture의 설계의 결함  - 서비스 간 메서드 호출 시 트랜잭션 관리의 어려움

출처: https://ksh-coding.tistory.com/92

일반적인 4-tier Layered Architecture위 이미지처럼 4단계의 레이어를 구성합니다. 일반적인 프레임워크는 이 4-tier Layered Architecture에서 영감을 받아 controller, service, repository, database 계층 구조를 갖도록 설계했습니다. 

 

Layered Architecture는 각 구성 요소들이 '관심사의 분리(Separation of Concerns)'를 달성하기 위해 '책임'을 가진 계층으로 분리한 아키텍처이지만, Business Layer를 구성하는 service 클래스가 방대한 비즈니스 요구사항을 모두 담당하기 시작하면 어느 순간 관심사가 비대해져서 결국 관심사를 '재분리'할 시점이 오는 것을 느꼈습니다. 

 

예를 들어 ProductService는 일반적으로 ProductRepository, ProductItemRepository 두 개의 Entity 생명주기를 관리할 책임만 있었다면 Product와 관련되어 있는 여러 관계 엔티티의 수가 늘어남에 그 서비스의 유지보수성을 전체적으로 낮추는 상황을 직면했습니다.

export class ShippingTransactionService {
  private warehouseRepository: Repository<Warehouse>;
  private transactionGroupRepository: Repository<TransactionGroup>;
  private transactionRepository: Repository<Transaction>;
  private transactionItemRepository: Repository<TransactionItem>;
  private itemRepository: Repository<Item>;
  private shopRepository: Repository<Shop>;
  private inventoryItemRepository: Repository<InventoryItem>;
  private locationRepository: Repository<Location>;
  private externalSystemRepository: Repository<ExternalSystem>;
  private carrierContractRepository: Repository<CarrierContract>;
  private shipperCarrierContractRepository: Repository<ShipperCarrierContract>;
  private logicalLocationsRepository: Repository<LogicalLocation>;

  constructor(
    @Inject(CONNECTION) private readonly dataSource: DataSource,
    private readonly commonUtilsService: CommonUtilsService,
    private readonly localeService: LocaleService,
    private readonly i18n: I18nService,
    private eventEmitter: EventEmitter2,
    private readonly sellmateOmsService: SellmateOmsService,
    @Inject(SHIPPING_TRANSACTIONS_REPOSITORY)
    private readonly shippingTransactionsRepository: ShippingTransactionRepository,
    private readonly StockAllocationRulesService: StockAllocationRulesService,
    private readonly logicalLocationsService: LogicalLocationsService,
    private readonly inventoryItemsService: InventoryItemsService,
    private readonly deliveryPackagesService: DeliveryPackagesService,
    private readonly waybillGroupService: WaybillGroupsService,
    private readonly waybillsService: WaybillsService,
    private readonly wavePickingsService: WavePickingsService,
    private readonly transactionGroupsService: TransactionGroupsService,
    private readonly carrierApiService: CarrierApiService,
    private readonly inventoryItemHistoriesService: InventoryItemHistoriesService,
  ) {
    this.warehouseRepository = this.dataSource.getRepository(Warehouse);
    this.transactionGroupRepository =
      this.dataSource.getRepository(TransactionGroup);
    this.transactionRepository = this.dataSource.getRepository(Transaction);
    this.transactionItemRepository =
      this.dataSource.getRepository(TransactionItem);
    this.itemRepository = this.dataSource.getRepository(Item);
    this.shopRepository = this.dataSource.getRepository(Shop);
    this.inventoryItemRepository = this.dataSource.getRepository(InventoryItem);
    this.locationRepository = this.dataSource.getRepository(Location);
    this.externalSystemRepository =
      this.dataSource.getRepository(ExternalSystem);
    this.carrierContractRepository =
      this.dataSource.getRepository(CarrierContract);
    this.shipperCarrierContractRepository = this.dataSource.getRepository(
      ShipperCarrierContract,
    );
    this.logicalLocationsRepository =
      this.dataSource.getRepository(LogicalLocation);
  }

 

해당 코드를 잠깐 보면, 비즈니스의 요구사항이 늘어나고, 하나의 엔티티와 연관된 관계 엔티티의 수가 늘어나면 해당 클래스에서 관리해야 할 Respository의 수가 점점 많아지고, 다른 Service 클래스의 의존성을 주입받는 개수가 늘어나 전체적으로 애플리케이션의 유지보수성을 낮추는 경험을 했습니다. 

 

가장 큰 문제는 서비스 간 트랜잭션을 관리해야 할 필요가 생기면서부터였습니다. 일반적으로 클래스의 내의 특정 메서드의 스코프 안에서 트랜잭션을 관리하는 하지만, 서비스 클래스 간의 호출이 발생할 때 호출대상이 되는 다른 클래스의 메서드의 행위도 하나의 트랜잭션 안에서 관리되어야 할 필요가 있는 비즈니스 로직이 필요했습니다. 예를 들어 출고지시와 피킹지시라는 두 가지 비즈니스로직이 하나의 트랜잭션으로 관리되어 출고지시나 피킹지시 중 어느 하나라도 오류로 인해 실패했다면 전체 롤백을 해야 하는 상황이었습니다. 그렇기 때문에 서비스 간 호출을 하는 경우 트랜잭션을 관리할 전략이 필요했습니다. 


Service의 분리 - Use Case 기반의 Application Layer 설계(Application Service vs Domain Service)

일반적으로 Service 레이어의 역할을 '비즈니스 로직을 구현한다'로 정의하고 클래스 내의 메서드가 일종의 각각의 비즈니스 요구사항을 충족하는 코드를 작성해 왔을 것입니다. 

class 출고지시도하고피킹지시도하는서비스클래스 {
	function 출고지시 + 피킹지시() {
		출고지시서비스.method();
		피킹지시서비스.method();
	}
}

 

이전의 예시에서 출고지시 + 피킹지시를 하는 서비스 클래스의 경우 특정 메서드가 두 가지 기능의 흐름을 담당하는 코드가 작성되었었습니다. 우리는 개발을 하면서 객체지향 프로그래밍을 위해 '단일책임'을 지키는 코드를 작성해야 한다고 노래를 불렀었는데 정작 저 코든 벌써부터 단일책임원칙에 위배되고 있었습니다. 

 

사실 단일책임원칙을 위배한다고 해서 부모님이 등짝을 때리지는 않지만, 문제는 트랜잭션 관리였습니다. NestJS에서는 트랜잭션 관리를 위해서 QueryRunner의 EntityManager를 사용합니다. @Transactional 같은 데코레이터를 지원하는 외부 라이브러리를 사용하지 않는 상황이라면 하나의 트랜잭션 관리를 위해서 동일한 QueryRunner 인스턴스를 공유해서 사용해야만 하나의 세션에서 작업의 흐름을 관리할 수 있습니다. 

 

문제는 일반적으로 QueryRunner를 서비스 클래스마다 관리하도록 설계되었다는 점입니다. 

constructor(
    @Inject(CONNECTION) private readonly dataSource: DataSource,
    ...
)

 

각각의 서비스 클래스의 생성자에 주입된 DataSource 클래스를 사용해서 메서드마다 각각의 트랜잭션을 관리하고 있었습니다. 

const queryRunner = this.dataSource.createQueryRunner();
    try {
      await queryRunner.connect();
      await queryRunner.startTransaction();

      ...
      
      queryRunner.manager.save(Product, updatedProducts, {
          chunk: batchSize,
        }),
      
      ...

      await queryRunner.commitTransaction();

      return transactionGroup;
    } catch (error) {
		await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }

 

이런 식으로 서비스 클래스자체에서 DataSource 클래스를 주입받고 각각의 메서드마다 독립적으로 트랜잭션을 선언하고 관리하기 때문에 서비스 간 메서드 호출이 발생하는 경우는 동일하지 않은 QueryRunner가 사용되어 하나의 트랜잭션으로 관리될 수 없다는 문제가 발생했습니다. 

 

이 문제를 단순히 'QueryRunner의 공유'라는 것에 초점을 맞혔다면 자체 DataSource를 관리하면서 모든 메서드의 파라미터에 QueryRunner인스턴스를 받도록 하고 전달받은 QueryRunner가 없으면 DataSource에 의해 생성하고... 뭐 이딴 거지 같은 상황이 발생할 수 있는 상황이었습니다. 

 

저는 이 문제를 해결하기 위해서 Service 클래스를 Application ServiceDomain Service로 분리하는 설계를 도입했습니다. 

 

먼저 Application Service는 Use Case를 받아 작업의 흐름을 조율하고 결과를 반환하는 중개자 역할을 담당하는 서비스 클래스입니다. 이 클래스는 말 그대로 Use Case별로 프로세스를 조율하는 역할만을 담당하기 때문에 '비즈니스 규칙 = 도메인'을 직접 구현하지 않는 서비스 클래스입니다. 

 

Domain Service는 특정 도메인(비즈니스 규칙)을 실제로 구현하는 서비스 클래스입니다. 이 클래스의 핵심은 순수한 비즈니스 규칙만을 구현할 책임을 갖는다는 것입니다. 이렇게 되면 해당 클래스의 응집도가 높아지고 Domain Service 간 결합도가 낮아져 전체적인 애플리케이션 유지보수성을 높이는 효과를 볼 수 있습니다. 

class 특정ApplicationService {
	private readonly datasource: DataSource;
	private readonly 출고지시DomainService;
	private readonly 피킹지시DomainService;

	async function 출고지시 + 피킹지시() {
    	const queryRunner = this.dataSource.createQueryRunner();
        try {
          await queryRunner.connect();
          await queryRunner.startTransaction();

          ...

          await 출고지시DomainService.출고지시(queryRunner);
          await 피킹지시DomainService.피킹지시(queryRunner);

          ...

          await queryRunner.commitTransaction();

          return transactionGroup;
        } catch (error) {
            await queryRunner.rollbackTransaction();
        } finally {
          await queryRunner.release();
        }   
    }
}

 

위 코드예시를 보면, Application Service 클래스가 출고지시, 피킹지시라는 각각의 비즈니스 규칙만을 구현한 Domain Service 클래스를 주입받고 프로세스의 흐름을 직접 관리하고 있는 것을 확인할 수 있습니다. 이렇게 설계하면 서비스 간 호출의 흐름을 하나의 트랜잭션으로 관리하는 것이 용이해짐에 따라, Domain Service 클래스에서는 트랜잭션을 직접 관리할 필요가 없으며, datasource 클래스에 대한 의존성도 사라지고, 클래스 내부의 응집도를 높일 수 있습니다. 

 

Domain Service의 또 다른 핵심은 인프라 기술과 무관해야 한다는 것입니다. 여기서 말하는 인프라는 외부라이브러리, RabbitMQ 같은 기술 등의 의존성으로부터 완전히 독립적이어야 한다는 것을 의미합니다.

 

예를 들어 이메일 인증코드를 전송해야 하는 비즈니스 로직이 있다고 가정해 봅시다. 

  • 도메인 규칙: 인증코드를 생성하고 유효성을 검증
  • 인프라: 이메일 전송

Domain Service를 담당하는 AuthCodeService는 인증 코드 생성 및 검증만을 담당하고 해당 코드는 외부 의존성이 존재하지 않는 순수한 비즈니스 규칙만을 구현합니다. 

public class AuthCodeService {

    public AuthCode generateCodeFor(String email) {
        String code = UUID.randomUUID().toString().substring(0, 6);
        return new AuthCode(email, code, LocalDateTime.now().plusMinutes(5));
    }

    public boolean isValid(AuthCode authCode, String inputCode) {
        return authCode.getCode().equals(inputCode)
            && authCode.getExpiresAt().isAfter(LocalDateTime.now());
    }
}

 

Application Service인 AuthApplicationService는 도메인 서비스를 호출하고 이메일 발송을 함으로써 전체적인 프로세스를 관리하고 외부 인프라를 제어하는 역할을 수행합니다. 

public class AuthApplicationService {

    private final AuthCodeService authCodeService;
    private final EmailSender emailSender; // 인프라 인터페이스 (예: JavaMailSender)

    public AuthApplicationService(AuthCodeService authCodeService, EmailSender emailSender) {
        this.authCodeService = authCodeService;
        this.emailSender = emailSender;
    }

    public void sendAuthCode(String email) {
        AuthCode authCode = authCodeService.generateCodeFor(email); // 도메인 로직
        emailSender.send(email, "Your Auth Code", "Code: " + authCode.getCode()); // 인프라 호출
    }
}

 

도메인 서비스가 비즈니스 규칙만을 구현하도록 설계되었다면 테스트 코드를 작성하기 위해서 수행하는 Mocking도 용이합니다. 


결론

이처럼 처음에는 단순히 서비스 간 트랜잭션 관리 문제를 해결하고자 시작한 서비스 분리가, 결과적으로는 애플리케이션의 복잡도를 낮추고 유지보수성과 확장성을 크게 향상하는 성과로 이어졌습니다. 이는 **도메인 주도 설계(DDD)**의 핵심 가치인 관심사의 분리와 명확한 책임 분배를 실천함으로써 얻은 자연스러운 결과였으며, 앞으로도 복잡한 비즈니스 요구사항을 효과적으로 대응하기 위한 강력한 설계 철학임을 다시금 확인할 수 있는 경험이었습니다.