Software Architecture/DDD & Patterns
DDD Patterns - 04. 앤터프라이즈 패턴
도서관 사서에게 책을 빌리는 법 — Repository, Unit of Work, Specification
도서관에서 책을 빌린다고 하자. 서가에 직접 들어가 책을 찾지 않는다. 사서에게 "이 책 찾아주세요" 하면 사서가 가져다 준다 — 책이 어디 칸에 있는지, 분류 체계가 어떻게 되어 있는지 빌리는 사람은 모른다. 여러 권을 한 번에 빌리면 사서가 대출 목록에 한꺼번에 기록한다. 찾을 때는 "파란 표지에 2020년 이후 출간된 소설"이라고 조건을 말한다.
엔터프라이즈 패턴은 도메인 객체의 영속(persistence)을 이렇게 사서처럼 다룬다. Fowler의
Repository — 데이터 접근의 사서
Repository는 도메인 객체의 저장·조회를 추상화한 인터페이스다. 도메인은 "주문을 저장해"라고 Repository에 말하고, Repository가 SQL을 날리든 캐시를 뒤지든 ORM을 부르든 알아서 한다. 도메인은 데이터 접근 기술(JPA, MyBatis, MongoDB)을 모른다.
flowchart LR
Domain["도메인 계층<br/>Order 도메인 로직"] -->|"save(order)<br/>findById(id)"| Repo["OrderRepository<br/>(인터페이스)"]
Repo --> Impl["JpaOrderRepository<br/>(구현체)"]
Impl --> DB[("데이터베이스")]
// 도메인 인터페이스 — 기술에 무관
public interface OrderRepository {
OrderId save(Order order);
Optional<Order> findById(OrderId id);
List<Order> findByCustomer(CustomerId customerId);
}
// 도메인은 구현체를 모른다 — JPA든 뭐든 인터페이스 뒤에 숨음
class OrderService {
void placeOrder(Order order) {
order.calculateTotal();
repo.save(order); // "저장해" — 기술은 모름
}
}
Repository가 없으면 도메인 코드에 SQL이나 ORM 호출이 직접 박힌다. 테스트하기 어렵고, 데이터 기술을 바꾸면 도메인까지 흔들린다. Repository는 도메인과 데이터 기술 사이의 사서다.
Unit of Work — 한 번에 대출 처리
Unit of Work(UoW)는 여러 변경을 모아서 한 번에 커밋하는 패턴이다. 도서관에서 여러 권을 빌릴 때 한 권씩 대출 처리하지 않고 목록에 모았다가 한 번에 기록하듯, 주문 추가·재고 차감·결제 기록을 한 트랜잭션에 모아 커밋한다.
// Unit of Work 없이 — 각 save마다 별도 트랜잭션 (중간 실패 시 불일치)
repo1.save(order); // 트랜잭션 1
repo2.decreaseStock(); // 트랜잭션 2 — 여기서 실패하면 주문은 있는데 재고는 안 깎임
repo3.recordPayment(); // 트랜잭션 3
// Unit of Work — 변경을 모아서 한 번에 커밋
uow.registerNew(order);
uow.registerUpdated(stock);
uow.registerNew(payment);
uow.commit(); // 한 트랜잭션 — 전부 성공하거나 전부 실패
Spring의 @Transactional이 UoW의 실천적 형태다 — 메서드 안의 변경들이 한 트랜잭션으로 묶인다. Fowler는 UoW가 "트랜잭션 스크립트(절차적 코드)에 빠지지 않고 객체 변경을 트랜잭션으로 관리하는 수단"이라고 본다.
Specification — 조건으로 찾기
Specification은 "어떤 객체가 조건을 만족하는가"를 객체로 표현하는 패턴이다. "파란 표지, 2020년 이후 출간"이라는 검색 조건을 객체로 만들어 재사용하고 조합할 수 있다.
// 조건을 객체로 — 조합·재사용 가능
Specification<Order> overdue = new OverdueSpec();
Specification<Order> highValue = new TotalOverSpec(Money.won(100000));
Specification<Order> query = overdue.and(highValue);
List<Order> results = repo.findBySpecification(query);
// "연체이면서 10만 원 이상인 주문" — 조건이 객체로 전달됨
Specification이 없으면 조건이 쿼리 문자열이나 if문에 흩어진다. 조건을 객체로 빼면 비즈니스 규칙이 한 곳에 모이고, 테스트하기 쉽고, 조합할 수 있다.
패턴은 수단이다
이 세 패턴은 도메인을 데이터 기술로부터 해방하는 수단이다. 하지만 수단이 목적이 되면 안 된다 — 모든 객체에 Repository를 만들고, 모든 변경에 UoW를 붙이고, 모든 조회에 Specification을 쓰면 과잉 설계가 된다. 간단한 CRUD에는 Repository만으로 충분할 때가 많다. 패턴이 가져다주는 이점(테스트 용이성, 기술 독립성, 규칙 응집)이 그 복잡성보다 클 때 쓴다 — 사서가 필요 없는 작은 서가에 사서를 두지 않듯.
설계 사례 — Specification 조합과 메모리 Repository 테스트
Specification의 진가는 조합에서 드러난다. "연체 주문" 규칙과 "고액 주문" 규칙을 각각 객체로 만들면, 두 조건을 AND/OR로 엮어 새 쿼리를 만들 수 있다 — 조건이 SQL 문자열이나 if문에 흩어지지 않고 객체로 재사용된다.
// 개별 규칙을 Specification 객체로
public class OverdueSpec implements Specification<Order> {
private final Duration threshold;
public OverdueSpec(Duration threshold) { this.threshold = threshold; }
public boolean isSatisfiedBy(Order order) {
return order.getStatus() == OrderStatus.PLACED
&& order.getPlacedAt().isBefore(Instant.now().minus(threshold));
}
}
public class HighValueSpec implements Specification<Order> {
private final Money min;
public HighValueSpec(Money min) { this.min = min; }
public boolean isSatisfiedBy(Order order) {
return order.getTotal().isGreaterThanOrEqual(min);
}
}
// 조합 — "연체이면서 고액인 주문"
Specification<Order> escalate = new OverdueSpec(Duration.ofDays(3))
.and(new HighValueSpec(Money.won(500000)));
List<Order> targets = orderRepo.findBySpecification(escalate);
이 구조에서 "연체 기준을 3일에서 5일로 바꿔"라는 요구가 들어오면, OverdueSpec 생성자 인자만 바꾸면 된다 — 규칙이 한 곳에 있다. SQL 여기저기에 WHERE placed_at < NOW() - INTERVAL '3' DAY가 흩어져 있었다면, 모두 찾아 고쳐야 했을 것이다.
Repository의 테스트 이점도 구체적으로 본다. 도메인 인터페이스(OrderRepository)를 두면, 테스트에서 메모리 구현체를 주입할 수 있다 — DB 없이, Spring 없이, 순수 자바만으로 도메인 로직을 검증한다.
// 메모리 Repository — DB 없이 도메인 테스트
public class InMemoryOrderRepository implements OrderRepository {
private final Map<OrderId, Order> store = new HashMap<>();
public OrderId save(Order order) { store.put(order.getId(), order); return order.getId(); }
public Optional<Order> findById(OrderId id) { return Optional.ofNullable(store.get(id)); }
}
// 도메인 단위 테스트 — 프레임워크 없이, 밀리초 단위
@Test
void 주문_취소_시_이미_배송중이면_실패() {
OrderRepository repo = new InMemoryOrderRepository();
Order order = Order.draft(CustomerId.of("c1"));
order.addLine(ProductId.of("p1"), 2, Money.won(10000));
order.confirm();
order.startShipping(); // 배송 중으로 전환
repo.save(order);
Order loaded = repo.findById(order.getId()).orElseThrow();
assertThatThrownBy(() -> loaded.cancel())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("배송 중인 주문은 취소할 수 없습니다");
}
이 테스트는 Spring 컨테이너를 띄우지 않고 밀리초 단위로 실행된다. 도메인 규칙(배송 중 취소 불가)이 Order 객체 안에 있기 때문에, DB나 웹 레이어 없이 검증할 수 있다 — 이것이 Repository와 풍성한 도메인 모델이 함께 가져다주는 테스트 용이성이다.
참고
- Fowler, Martin —
(Addison-Wesley, 2002) (Repository, Unit of Work, Specification) — 접근 2026-07-15 - Evans, Eric —
(Addison-Wesley, 2003), Ch.6 (Repository, Specification) — 접근 2026-07-15
'Software Architecture > DDD & Patterns' 카테고리의 다른 글
| DDD Patterns - 06. 헥사고날 실천 (0) | 2026.07.21 |
|---|---|
| DDD Patterns - 05. 빈약한 도메인 모델 (0) | 2026.07.21 |
| DDD Patterns - 03. 애그리거트 설계 (0) | 2026.07.20 |
| DDD Patterns - 02. DDD 전술적 설계 (0) | 2026.07.20 |
| DDD Patterns - 01. DDD 전략적 설계 (0) | 2026.07.20 |