Software Architecture/DDD & Patterns

DDD Patterns - 09. 리팩터링과 진화

정원은 한 번에 만드는 게 아니다 — 리팩터링과 진화

정원을 가꾼다고 하자. 씨앗을 심고, 싹이 트면 가지를 치고, 자라면 옮겨 심는다. 한 번에 완성된 정원을 만들 수 없다 — 식물이 자라는 속도가 있고, 계절이 바뀌고, 무엇이 잘 자랄지 실제로 길러봐야 안다. 처음부터 완벽한 설계도를 그려놓고 파종하는 정원사는 없다.

소프트웨어도 같다. 처음에 완벽한 도메인 모델을 설계하고 구현하는 건 현실적이지 않다. 도메인에 대한 이해가 코드를 짜면서, 운영하면서, 요구사항이 바뀌면서 깊어진다. 그래서 모델은 진화한다 — 애그리거트 경계가 조정되고, 새 값 객체가 생기고, 서비스가 도메인으로 흡수된다. 이 진화를 안전하게 이끄는 것이 리팩터링이다.

리팩터링 — 행동을 보존하며 구조를 바꾼다

Fowler는 에서 리팩터링을 "외부 행동(behavior)을 바꾸지 않고 내부 구조(structure)를 개선하는 것"이라 정의했다. 정원에서 식물의 위치를 옮기되, 식물이 죽지 않게(행동 보존) 뿌리를 조심히 다루는 것과 같다.

리팩터링의 전제는 테스트다. 행동이 바뀌지 않았음을 보증하는 안전망이 없으면, 구조를 건드릴 수 없다. 식물을 옮기기 전에 물을 주고 흙을 단단히 다지듯, 리팩터링 전에 테스트로 행동을 고정한다. 그 위에서 작은 단계(메서드 추출, 클래스 분리, 이름 변경)를 밟는다. 각 단계 후 테스트가 통과하면 다음 단계로 간다.

flowchart LR
    Red["테스트 실패<br/>(또는 새 기능 필요)"] --> Green["최소 구현<br/>(테스트 통과)"]
    Green --> Refactor["리팩터<br/>(구조 개선, 행동 보존)"]
    Refactor --> Green

이 Red-Green-Refactor 순환(TDD)이 진화의 리듬이다. 기능을 붙이고, 그 직후 구조를 정리한다 — 잡초를 뽑고 가지를 치는 것을 기능 추가와 분리하지 않는다.

코드 냄새 — 잡초의 신호

Fowler가 정리한 코드 냄새(code smell)는 정원의 잡초와 같다 — 당장 고장을 내지는 않지만, 방치하면 자라서 구조를 망친다.

냄새 정원 비유 리팩터링
긴 메서드 가지가 너무 자란 나무 메서드 추출(잔가지치기)
큰 클래스 한 칸에 너무 많은 식물 클래스 분리(분갈이)
중복 코드 같은 식물이 여러 군데 함수 추출로 하나로
기능에 대한 욕심 남의 화단에 손을 뻗는 메서드 메서드를 그 객체로 이동
깊은 중첩 뿌리가 너무 깊은 덤불 조기 반환·가드로 평탄화

냄새를 "고쳐야 할 버그"로 보지 않는다 — "여기 구조가 자연스럽지 않다"는 신호로 본다. 냄새가 나면 그 자리를 조금 정리하고 넘어간다. 보이스카웃 규칙(01-foundations의 기술 부채 글에서 본 것) — 지나가는 자리를 들어올 때보다 조금 더 깨끗하게.

애그리거트 경계의 진화

DDD 모델에서 가장 자주 조정되는 것은 애그리거트 경계다. 처음에 Order 애그리거트가 배송지 정보를 품고 있었는데, 운영하다 보니 배송이 별도의 변경 주기를 갖는다는 게 드러난다. 그러면 배송지를 별도 애그리거트로 분리한다 — 트레이(계란판)를 하나 더 만드는 것이다.

flowchart TB
    subgraph Before["처음 설계 — 큰 애그리거트"]
        O1["Order 애그리거트"]
        O1 --- OL1["OrderLine"]
        O1 --- ADDR1["배송지 (함께 변경)"]
        O1 --- PAY1["결제 정보 (함께 변경)"]
    end
    subgraph After["진화 후 — 작은 애그리거트로 분리"]
        O2["Order 애그리거트"]
        O2 --- OL2["OrderLine"]
        O2 -. "ID 참조" .-> SHIP["배송 애그리거트<br/>(별도 변경)"]
        O2 -. "ID 참조" .-> PAY2["결제 애그리거트<br/>(별도 변경)"]
    end

이 분리는 한 번에 일어나지 않는다 — 운영하면서 "배송지와 결제가 Order와 다른 빈도로 바뀐다"는 것이 관측되고, 그때 분리한다. 처음부터 세 개로 나누는 것도, 끝까지 하나로 두는 것도 아니다. 도메인에 대한 이해가 깊어지면서 경계가 조정된다 — 정원에서 식물이 자라면서 햇빛을 받지 못하는 것을 옮기듯.

재작성의 유혹

정원을 갈아엎고 새로 심고 싶은 순간이 온다 — 잡초가 너무 많고, 배치가 엉망이라고 느낄 때. 소프트웨어도 마찬가지로 "다시 짜는 게 낫겠다"는 유혹이 든다. 하지만 재작성(rewrite)은 거의 항상 과소평가된다(01-foundations의 기술 부채 글에서 본 것). 기존 코드에 박힌 암묵적 규칙(특정 입력에서만 동작하는 분기, 레거시 호환 처리)을 놓친다. 정원을 갈아엎으면 흙 속의 미생물까지 잃는 것과 같다 — 보이지 않던 생태가 사라진다.

점진적 리팩터링은 흙을 보존하면서 식물을 옮기는 것이다. 한 번에 한 식물씩, 테스트라는 물을 주면서. 느려 보이지만, 재작성이 실패할 확률보다 훨씬 낮다.

정원은 끝나지 않는다

정원에 "완성"이 없듯, 소프트웨어 모델에도 "완성"이 없다. 도메인이 변하고, 팀이 배우고, 요구사항이 바뀐다. 그래서 리팩터링은 한 번의 프로젝트가 아니라 일상이다 — 매 변경마다 지나가는 자리를 조금 정리하고, 냄새가 나면 그 자리를 깎고, 식물이 자라면 분갈이한다. 진화적 아키텍처(01-foundations에서 본 것)의 실천이 이 일상적 가꿈에서 나온다 — 큰 한 번의 재설계가 아니라, 매일 조금씩 구조를 개선하는 습관에서.

설계 사례 — 긴 메서드 분해와 god service 쪼개기

Fowler의 리팩터링 카탈로그에서 가장 자주 쓰는 두 기법 — 메서드 추출(Extract Method)과 클래스 분리 — 를 코드로 본다. 각 단계에서 테스트가 행동을 보존한다.

Before — 긴 메서드 + god service (냄새: 긴 메서드 + 큰 클래스):

// 모든 로직이 한 메서드에 — 검증·계산·저장·알림이 뒤섞임
public class OrderService {
    public void placeOrder(Long customerId, List<ItemDto> items) {
        // (1) 검증
        if (customerId == null) throw new IllegalArgumentException();
        if (items.isEmpty()) throw new IllegalArgumentException();
        Customer c = customerRepo.findById(customerId);
        if (c.getStatus() == CustomerStatus.BANNED) throw new IllegalStateException();

        // (2) 계산
        int total = 0;
        for (ItemDto item : items) {
            Product p = productRepo.findById(item.getProductId());
            total += p.getPrice() * item.getQty();
        }
        if (total > 10_000_000) throw new IllegalStateException("한도 초과");

        // (3) 저장 + 알림
        Order order = new Order(customerId, total);
        orderRepo.save(order);
        emailService.send(c.getEmail(), "주문이 접수되었습니다");
    }
}

Step 1 — 메서드 추출: 검증·계산·저장을 각각 분리:

public class OrderService {
    public void placeOrder(Long customerId, List<ItemDto> items) {
        Customer customer = validateCustomer(customerId);
        Money total = calculateTotal(items);
        checkLimit(total);
        Order order = saveOrder(customerId, total);
        notifyCustomer(customer);
    }
    // 각 단계가 이름을 가진 메서드로 — 읽는 사람이 흐름을 한눈에
    private Customer validateCustomer(Long id) { ... }
    private Money calculateTotal(List<ItemDto> items) { ... }
    private void checkLimit(Money total) { ... }
    private Order saveOrder(Long customerId, Money total) { ... }
    private void notifyCustomer(Customer c) { ... }
}

Step 2 — 클래스 분리: 계산을 도메인으로 이동:

// 계산은 OrderService에 있을 이유가 없음 — 도메인 객체로 이동
public class Order {
    public static Money calculateTotal(List<OrderLine> lines, ProductCatalog catalog) {
        return lines.stream()
            .map(line -> catalog.findPrice(line.productId()).multiply(line.qty()))
            .reduce(Money.ZERO, Money::add);
    }
}

// 한도 검증도 도메인 규칙 — Order 또는 별도 정책 객체로
public class OrderLimitPolicy {
    public void check(Money total) {
        if (total.isGreaterThan(MAX_ORDER_AMOUNT))
            throw new IllegalStateException("한도 초과");
    }
}

// OrderService는 오케스트레이션만 — 비즈니스 로직은 도메인으로
public class OrderService {
    public void placeOrder(Long customerId, List<ItemDto> items) {
        Customer customer = customerRepo.findById(customerId);
        customer.checkActive();
        List<OrderLine> lines = items.stream().map(this::toLine).toList();
        Money total = Order.calculateTotal(lines, catalog);
        limitPolicy.check(total);
        Order order = Order.create(customerId, lines, total);
        orderRepo.save(order);
        notifier.notify(customer, order);
    }
}

Step 2에서 비즈니스 규칙(한도 검증, 총액 계산)이 서비스에서 도메인으로 옮겨갔다. OrderService.placeOrder는 이제 "누가 무슨 순서로 무엇을 하는가"(오케스트레이션)만 보여주고, "어떻게"(비즈니스 규칙)는 도메인 객체가 안다. 이것이 05번(빈약한 도메인 모델)에서 본 "풍성한 모델"로의 전환이기도 하다 — 리팩터링과 도메인 모델 개선이 같은 방향으로 움직인다.

각 단계에서 테스트가 행동을 보존한다 — "주문 성공 시 이메일이 발송된다", "한도 초과 시 예외가 발생한다" 같은 테스트가 Step 1 전후, Step 2 전후로 동일하게 통과해야 한다. 행동은 그대로이고 구조만 개선된다 — 정원에서 식물의 위치를 옮기되, 식물이 죽지 않게 뿌리를 조심히 다루는 것과 같다.


참고

  • Fowler, Martin — <Refactoring: Improving the Design of Existing Code> (2nd ed, Addison-Wesley, 2018) (코드 냄새, 리팩터링 카탈로그) — 접근 2026-07-15
  • Evans, Eric — (Addison-Wesley, 2003), Ch.14 (모델의 진화) — 접근 2026-07-15
  • Feathers, Michael — (Prentice Hall, 2004) (레거시에서 테스트 확보) — 접근 2026-07-15