Software Architecture/DDD & Patterns
DDD Patterns - 09. 리팩터링과 진화
정원은 한 번에 만드는 게 아니다 — 리팩터링과 진화
정원을 가꾼다고 하자. 씨앗을 심고, 싹이 트면 가지를 치고, 자라면 옮겨 심는다. 한 번에 완성된 정원을 만들 수 없다 — 식물이 자라는 속도가 있고, 계절이 바뀌고, 무엇이 잘 자랄지 실제로 길러봐야 안다. 처음부터 완벽한 설계도를 그려놓고 파종하는 정원사는 없다.
소프트웨어도 같다. 처음에 완벽한 도메인 모델을 설계하고 구현하는 건 현실적이지 않다. 도메인에 대한 이해가 코드를 짜면서, 운영하면서, 요구사항이 바뀌면서 깊어진다. 그래서 모델은 진화한다 — 애그리거트 경계가 조정되고, 새 값 객체가 생기고, 서비스가 도메인으로 흡수된다. 이 진화를 안전하게 이끄는 것이 리팩터링이다.
리팩터링 — 행동을 보존하며 구조를 바꾼다
Fowler는
리팩터링의 전제는 테스트다. 행동이 바뀌지 않았음을 보증하는 안전망이 없으면, 구조를 건드릴 수 없다. 식물을 옮기기 전에 물을 주고 흙을 단단히 다지듯, 리팩터링 전에 테스트로 행동을 고정한다. 그 위에서 작은 단계(메서드 추출, 클래스 분리, 이름 변경)를 밟는다. 각 단계 후 테스트가 통과하면 다음 단계로 간다.
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
'Software Architecture > DDD & Patterns' 카테고리의 다른 글
| DDD Patterns - 10. GoF 디자인 패턴 (0) | 2026.07.21 |
|---|---|
| DDD Patterns - 08. 이벤트 스토밍 (0) | 2026.07.21 |
| DDD Patterns - 07. 컨텍스트 통합 (0) | 2026.07.21 |
| DDD Patterns - 06. 헥사고날 실천 (0) | 2026.07.21 |
| DDD Patterns - 05. 빈약한 도메인 모델 (0) | 2026.07.21 |