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

[DDD Intro #27] 레거시 시스템에 DDD 적용하기

페이지 수정

DDD를 공부하다 보면 깨끗한 예제를 많이 보게 된다. 도메인 객체는 선명하고, 계층은 분리되어 있으며, Aggregate 경계도 잘 잡혀 있다. 하지만 현실의 시스템은 그렇지 않은 경우가 많다.

레거시 시스템에는 오래된 테이블, 거대한 서비스 클래스, 의미가 불분명한 상태 값, 여러 기능에서 공유하는 공통 모듈, 운영자만 아는 예외 규칙이 섞여 있다. 이런 시스템에 DDD를 적용하려고 하면 막막하다.

가장 위험한 생각은 “전부 새로 만들어야 한다”는 것이다. 레거시에 DDD를 적용한다는 것은 전체를 한 번에 갈아엎는 일이 아니다. 중요한 경계부터 다시 세우고, 핵심 도메인부터 점진적으로 모델을 회복하는 일이다.

Big Ball of Mud를 인정하기

레거시 시스템을 볼 때 가장 먼저 필요한 태도는 인정이다. 현재 시스템이 복잡하고 지저분하다는 사실을 인정해야 한다. 동시에 그 시스템이 지금까지 비즈니스를 지탱해왔다는 사실도 인정해야 한다.

레거시는 아무 이유 없이 생기지 않는다. 빠른 출시, 잦은 정책 변경, 부족한 인력, 모호한 요구사항, 조직 변화, 기술 전환 같은 현실적 이유가 쌓여 만들어진다.

따라서 레거시를 무시하고 새 모델을 이상적으로 설계하면 실패하기 쉽다. 기존 시스템에 숨어 있는 도메인 지식을 찾아야 한다.

왜 이 상태 값이 필요한가?
이 플래그는 누가 언제 바꾸는가?
운영자가 수동으로 처리하는 예외는 무엇인가?
이 배치가 실패하면 어떤 업무가 멈추는가?
고객센터는 이 상태를 어떤 말로 설명하는가?

레거시 코드는 지저분할 수 있지만, 그 안에는 실제 비즈니스 규칙이 남아 있다.

핵심 도메인부터 찾기

레거시 전체를 DDD로 바꾸려 하면 범위가 너무 커진다. 먼저 Core Domain을 찾아야 한다.

구독 서비스라면 다음 중 어디가 가장 중요할까?

구독 갱신과 만료 정책
요금제 변경 정책
환불과 해지 정책
관리자 공지사항
이메일 템플릿 관리
배너 노출 설정

모든 것이 필요하지만, 같은 수준으로 중요하지는 않다. 비즈니스 성과와 고객 경험에 직접 영향을 주고, 변경이 잦고, 규칙이 복잡한 영역부터 다루는 것이 좋다.

예를 들어 “구독 갱신 정책”이 핵심이라면, 전체 시스템을 바꾸기보다 갱신 관련 코드를 먼저 격리하고 모델링한다. 이때 목표는 완벽한 재작성보다 경계 세우기다.

새 모델을 바로 기존 모델에 섞지 않기

레거시 시스템에 새 도메인 모델을 만들 때 흔한 실수는 기존 모델과 새 모델을 바로 섞는 것이다.

예를 들어 레거시 구독 테이블의 상태 값이 다음과 같다고 해보자.

0: 대기
1: 정상
2: 정지
3: 해지
4: 만료
9: 기타

새 모델에서 SubscriptionStatus를 만들었다고 해서 이 값을 그대로 가져오면 새 모델도 레거시의 모호함에 묶인다.

SubscriptionStatus.fromLegacyCode(code);

변환 자체는 필요할 수 있다. 하지만 이 변환이 도메인 모델 곳곳에 퍼지면 안 된다. 레거시 모델과 새 모델 사이에는 번역 계층이 필요하다.

이때 Anti-Corruption Layer가 유용하다.

LegacySubscriptionRecord record = legacyRepository.findById(id);
Subscription subscription = legacySubscriptionTranslator.toDomain(record);

새 도메인 모델은 레거시의 컬럼명과 코드값을 직접 알지 않는다. 번역 계층이 레거시 의미를 해석해 도메인 언어로 바꾼다.

Strangler Fig 방식으로 감싸기

레거시를 한 번에 교체하기 어렵다면, 새로운 모델로 일부 기능을 감싸는 방식이 좋다. 흔히 Strangler Fig 패턴이라고 부르는 접근이다.

기존 시스템을 그대로 두고, 새 기능이나 변경이 많은 핵심 기능부터 새로운 경계 안에서 구현한다. 그리고 점진적으로 레거시의 책임을 줄인다.

예를 들어 구독 해지 정책을 새 모델로 옮긴다고 해보자.

1. 기존 해지 로직을 분석한다.
2. 도메인 전문가와 해지, 환불, 만료, 직권 종료의 언어를 정리한다.
3. 새 Subscription 모델과 CancelPolicy를 만든다.
4. 기존 DB와 새 모델 사이에 Translator를 둔다.
5. 해지 유스케이스만 새 모델을 사용하게 한다.
6. 이후 갱신, 요금제 변경 등으로 범위를 넓힌다.

이 방식은 느려 보일 수 있다. 하지만 전체 재작성보다 훨씬 안전하다. 비즈니스는 계속 돌아가야 하고, 새 모델은 실제 운영 요구 속에서 검증되어야 한다.

트랜잭션 스크립트를 도메인 모델로 옮기기

레거시에는 긴 서비스 메서드가 많다.

public void cancel(Long subscriptionId) {
    // 조회
    // 상태 체크
    // 환불 가능 여부 계산
    // 상태 업데이트
    // 이력 저장
    // 알림 발송
    // 정산 플래그 변경
}

이런 코드를 한 번에 아름답게 바꾸기는 어렵다. 대신 도메인 규칙을 하나씩 찾아 옮길 수 있다.

먼저 상태 체크를 Subscription으로 옮긴다.

subscription.cancelByMember(reason, now);

환불 가능 여부는 RefundPolicy로 분리한다.

RefundDecision decision = refundPolicy.decide(subscription, payment, now);

외부 시스템 호출은 Application Service 또는 Adapter로 분리한다.

이렇게 조금씩 옮기면 서비스 메서드는 점점 유스케이스 조율자에 가까워지고, 도메인 규칙은 모델 안으로 모인다.

기존 DB를 당장 바꾸지 않아도 된다

DDD를 적용한다고 해서 DB 스키마를 즉시 이상적으로 바꿔야 하는 것은 아니다. 레거시 DB는 여러 시스템이 공유하고 있을 수 있고, 변경 비용이 클 수 있다.

처음에는 기존 DB를 유지하면서 도메인 모델과 매핑 계층을 둘 수 있다.

Legacy Table → Mapper/Translator → Domain Model

이 구조는 매핑 비용이 있지만, 새 도메인 모델을 기존 테이블 구조에서 어느 정도 보호한다. 나중에 경계가 안정되고 변경 이유가 충분해지면 스키마 개선을 검토할 수 있다.

중요한 것은 DB 구조를 즉시 바꾸는 것이 아니라, 코드 안에서 도메인 언어를 회복하는 것이다.

작은 성공을 쌓기

레거시 DDD 적용은 긴 작업이다. 큰 선언보다 작은 성공이 중요하다.

상태 코드 하나를 도메인 개념으로 번역한다.
흩어진 조건문 하나를 Policy로 모은다.
setter 기반 상태 변경 하나를 도메인 행위로 바꾼다.
외부 API DTO 하나를 ACL 뒤로 숨긴다.
테스트 하나를 도메인 문장으로 작성한다.

이런 변화는 작아 보이지만, 팀의 언어와 설계 감각을 바꾼다. 레거시 시스템은 한 번에 깨끗해지지 않는다. 하지만 핵심 도메인부터 조금씩 경계와 언어를 회복할 수 있다.

마치며

레거시 시스템에 DDD를 적용한다는 것은 전체를 새로 만드는 일이 아니다. 레거시 안에 숨어 있는 도메인 지식을 찾고, 중요한 경계부터 다시 세우고, 핵심 도메인부터 점진적으로 모델을 회복하는 일이다.

Big Ball of Mud를 인정하되 포기하지 않아야 한다. 기존 모델과 새 모델 사이에는 ACL을 두고, 한 번에 교체하기보다 감싸며 이동해야 한다. 트랜잭션 스크립트에 흩어진 규칙은 조금씩 도메인 객체와 정책 객체로 옮길 수 있다.

DDD는 깨끗한 새 프로젝트에서만 가능한 방법이 아니다. 오히려 복잡한 레거시 속에서 도메인의 언어와 경계를 다시 찾는 데 큰 힘을 발휘할 수 있다.

중요한 것은 완벽한 재작성보다 올바른 방향의 작은 개선이다.


페이지 수정
공유하기:

이전 글
[DDD Intro #28] 모듈러 모놀리스: 마이크로서비스 전에 생각할 것
다음 글
[DDD Intro #26] 테스트로 도메인 모델 설명하기