첫 도메인주도 개발

새 프로젝트의 백엔드 설계/개발을 혼자 맡게 됐다. 설레지만, 여전히 긴장된다. 요구사항을 모델로 녹여내는 일은 즐겁지만, 상위 의사결정자에게 컨펌을 받는 순간은 매번 손에 땀이 난다. 이번엔 기본 요구사항은 전달받았지만, 스펙과 방향이 수시로 바뀔 수 있음이 전제다.

DB만 봐도 그렇다. 현재는 MySQL을 쓰지만 PostgreSQL로 바뀔 가능성이 있고, 어떤 기능은 RDBMS가 맞을지, NoSQL이나 벡터 DB가 맞을지 아직 결론이 없다. 이전 프로젝트에선 이런 유동성에 대비하지 못했다. “요구사항대로만 하면 된다”는 생각으로 시작했다가, POC와 검증 과정에서 방향이 바뀔 때마다 구조가 발목을 잡았다.

이번엔 다르게 가려 한다. 레이어드 아키텍처만으로는 가장 하위 계층(엔티티/영속성) 변화를 흡수하기 어려웠다. 그래서 DDD(도메인 주도 설계)헥사고날 아키텍처(포트-어댑터) 를 선택했다. 바뀌는 축과 바뀌지 않는 핵심을 분리하고, 도메인 모델을 인프라로부터 격리해 DB/메시징/검색엔진 교체를 구조적으로 감당하려는 선택이다. 이 글은 그 첫 시도, “첫 도메인 주도 개발” 의 기록이다.

1. 내가 느낀 DDD

글을 더 작성하기 전에 먼저 내가 생각하고 느낀 DDD에 대해서 간단하게 정리한다.

  • 변수, 메서드 이름 잘짓기(유비쿼터스 언어)와 도메인 간 경계 나누기 + 규칙을 객체와 애그리거트에 가두는 것.
  • presentation, application, domain, infrastructure간 dto, port, adapt, mapper를 사용해 관계를 느슨하게유지한다.
  • 각 영역이 바뀌더라도 해당 영역만 수정하면되기때문에 다른 모듈, 영역에 영향도가 적고, 모킹하기 편해서 테스트 및 범인 발견이 쉽다.
  • 영역 간 관계를 느슨하게하기위해 추가되는 코드가 많다.

아직 명확하게 DDD를 이해한 게아니라 다른 사람은 다르게 느낄 수도 있다. 부족한 지식은 다른 포스팅에서 추가적으로 기록하겠다.

2. 도메인

이 글의 범위는 "정책이라는 도메인을 어떻게 ddd했나" 정도로 좁히고 거창하게말고 정책 하나만 잡고 테스팅까지 끝내보는 작은 ddd를 기록한다.

요구사항

  • 정책은 활성화/비활성화 할 수 있다.
  • 정책은 rule이 존재하며 정규표현식이다.
  • 정책은 이름이 존재한다.
  • 정책은 적용 대상이 존재한다.
  • 정책은 이름, rule, 적용 대상, 상태를 변경할 수 있다.

위 요구사항을 기반으로 도메인을 작성한다

// domain/policy.domain.ts
export enum RuleTarget = {
  TEST = 1,
  DEV = 2
}

export class PolicyInfo {
  private constructor(
    private name: string,
    private rule: string,
    private ruleTarget: RuleTarget,
  ) {}
}

export class Policy {
  private constructor(
    readonly id: string,
    private policyInfo: PolicyInfo,
    private enabled: boolean,
  ) {}

  enable() {
    this.enabled = true
  }
  disable() {
    this.enabled = false
  }
  
  changePolicyInfo(pi: PolicyInfo) {
	this.policyInfo = pi
  }
  
}

3. 포트와 유스케이스

db를 조작하는 코드를 포트로 추상화한다.


// application/ports/policy-rule.repository.ts
import { Policy } from '../../domain/policy.domain';
export interface PolicyRepositoryPort {
  findOneById(id: string): Promise<Policy | null>;
  save(rule: Policy): Promise<void>;
}

// infrastructure/policy-rule.repository.memory.ts
import { PolicyRuleRepositoryPort } from '../../application/ports/policy-rule.repository';
import { Policy } from '../../domain/policy.domain';

export class InMemoryPolicyRuleRepository implements PolicyRepositoryPort {
  private store = new Map<string, Policy>();
  async findOneById(id: string) { return this.store.get(id) ?? null; }
  async save(rule: Policy) { this.store.set(rule.id, rule); }
}

// application/usecases/create-policy.usecase.ts
import { Policy, PolicyInfo } from '../../domain/policy.domain';
import { PolicyRepositoryPort } from '../ports/policy-rule.repository';

export class CreatePolicyUseCase {
  constructor(private readonly repo: PolicyRepositoryPort) {}
  
  async execute(dto: PolicyInfo) {
	const id = ... // uuid 생성
    const rule = PolicyRule.create({
		id,
		enabled: false,  // 비활성화를 기본값으로 지정
		...dto
    });
    
    await this.repo.save(rule);
    return rule;
  }
}

4. 테스트


// tests/policy.domain.spec.ts
import { describe, it, expect } from '@jest/globals';

// 도메인 코드
import { Policy, PolicyInfo,  RuleTarget } from '../domain/policy.domain';

// 간단 헬퍼: 정규식 컴파일 가능 여부
const isValidRegex = (pattern: string) => {
  try {
    // 슬래시로 감싸지지 않은 순수 패턴을 가정
    new RegExp(pattern);
    return true;
  } catch {
    return false;
  }
};

describe('Policy (domain)', () => {
  it('정상 생성: 이름/정규식/타깃을 가진 Policy를 만든다', () => {
    const newInfo = PolicyInfo('Mask Password', '(password\\s*=\\s*).+', RuleTarget.TEST);
    const newPolicy = Policy('policy-1', info, true);

    expect(policy).toBeDefined();
    // enabled 토글 동작 확인
    policy.disable();
    expect((policy as any).enabled).toBe(false);

    policy.enable();
    expect((policy as any).enabled).toBe(true);
  });

  it('정책 정보 변경: changePolicyInfo 로 교체된다', () => {
    const newInfo1 = PolicyInfo('Mask Password', '(password\\s*=\\s*).+', RuleTarget.TEST);
    const newPolicy = Policy('policy-1', newInfo1, true);

    const newInfo2 = PolicyInfo('Mask API Key', '(api[_-]?key\\s*=\\s*).+', RuleTarget.DEV);
    policy.changePolicyInfo(newInfo2);

    expect((policy as any).policyInfo).toBe(newInfo2);
  });
});


5. 마무리

바뀔 수 있는 것(인프라)은 바깥으로 뺐고 바뀌지 말아야할 것은 (규칙) 안으로 가뒀다.
요구사항을 기반으로 도메인의 규칙을 작성했고 도메인의 성격이 같은 공통된 필드는 VO로 묶어서 간편하게 수정할 수 있도록 처리했다.
dbms가 바뀌더라도 infrastructor의 repo를 추상화했기때문에 갈아끼우기만하면되니 수정범위가 줄어들어 유지보수성이 늘었다.
코드가 많고 복잡할 수도 있지만 "과도한 디자인을 리팩토링하는 것은 디자인이 없는 것을 리팩토링하는 것보다 쉽습니다." 를 기억하며 나중에 리팩토링하는 것도 괜찮을 것 같다