TL;DR
다들 레이어드를 Controller-Service-Repository라는 세 칸짜리 형식으로만 쓴다.
칸만 채우면 지킨 것 같지만, 형식은 "어디 두냐"만 줄 뿐 "왜 거기 둬야 하냐"는 안 알려준다.
그래서 애매한 건 전부 Service로 간다.
이번 구현을 한참 헤매고나니 계층의 책임에 대해 조금은 안 것 같았다.
계층을 나눈다는 건 코드 한 줄마다 "이건 누구 책임이냐"를 묻게 강제하는 일이다.
레이어드를 그냥 규칙으로 알았다
레이어드라고 하면 대부분 같은 계층을 떠올린다. Controller가 요청을 받고, Service가 비즈니스 로직을 돌리고, Repository가 DB를 처리한다.
Controller → Service → Repository
이게 워낙 굳어 있어서, 레이어드를 쓴다는 게 거의 이 세 칸을 채우는 일처럼 됐다.
새 기능이 들어오면 '이게 무슨 계층이지?'를 고민하기보다 일단 Service에 메서드 하나부터 추가하게 된다.
근데 이 틀은 "어디에 두냐"는 알려줘도 "왜 거기 둬야 하냐"는 안 알려준다. 그래서 애매한 건 죄다 Service로 간다. 재고 검사도, 주문 조립도, 좋아요 카운트도, 갈 데가 마땅찮으면 전부 거기. 칸은 다 채웠으니 "잘 지켰네" 싶지만, 정작 누가 무엇을 책임지는지는 아무도 안 정한 상태다.
이커머스(상품·브랜드·좋아요·주문)를 짜면서도 처음엔 딱 이렇게 시작했다. 근데 막힌 건 기능이 아니라 위치였다. 같은 한 줄을 Service에 두느냐 도메인에 두느냐로 결과물이 완전히 달라졌다.
처음 짠 걸 다시 보니 답이 거의 다 Service에 쏠려 있었다. 그땐 잘 정리한 줄 알았는데, 사실은 결정해야 할 책임들을 미뤄 둔 것에 가까웠다. 그래서 이번에는 빈칸을 채우는 대신, 책임이 자연스럽게 모이는 구조를 다시 만들어 보기로 했다.
도메인 주도란, 한 줄마다 "이건 도메인 일이냐"를 묻는 것
도메인 주도로 잡는다는 게 거창한 게 아니었다. 비즈니스 규칙(도메인)을 중심에 두고, 코드 한 줄마다 "이건 도메인이 책임질 일인가, 아닌가"를 먼저 묻는 것. 그게 전부였다. 앞에서 틀이 안 물어주던 그 질문을, 이번엔 매 줄 직접 던지기로 한 거다.
이렇게 도메인을 중심에 두면 구조에도 그대로 드러난다. 도메인이 중심이려면 기술에서 독립해야 한다. 도메인이 JPA나 HTTP를 알면, 기술이 바뀔 때마다 규칙이 흔들리니까. 그래서 의존 방향을 도메인 쪽으로 모았다.
Application → Domain ← Infrastructure
Application도 Infrastructure도 둘 다 가운데 Domain을 바라본다. Domain은 위도 아래도 모른다.
화살표가 일자가 아니라 가운데로 모이니 잠깐 헷갈렸다. 이건 레이어드가 아닌가? 근데 아니라고 할 순 없다. 계층은 여전히 셋이고, 의존 방향만 뒤집혔을 뿐이다. 이게 DIP(의존성 역전 원칙)다.
"고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다." — Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (2002)
덕분에 도메인은 JPA를 모른다. 운영은 JPA 구현체로, 테스트는 메모리 Fake로 갈아 끼워도 도메인 코드는 한 줄도 바뀌지 않는다.
근데 구조는 그릇일 뿐이었다. 의존 방향을 아무리 잘 잡아도, "이 한 줄이 도메인 일이냐"는 매번 따로 판단해야 했다. 결국 도메인 주도는 그 질문을 코드마다 던지는 일이었고, 아래는 그 질문에 하나씩 답한 기록이다.
재고는 Product가 깎는다
요구사항은 간단하다. Product는 재고를 들고 있고, 주문이 들어오면 그만큼 차감한다. 재고는 0 밑으로 못 내려간다.
질문은 이거다. 재고를 검사하고 차감하는 코드를 누가 가질까. Product인가, 서비스인가.
// 후보 A — 서비스가 직접 검사하고 깎는다
if (product.getStock() < qty) throw ...;
product.setStock(product.getStock() - qty);
// 후보 B — Product가 스스로 깎는다
product.decreaseStock(qty);
직감은 B였다. 근데 왜 B인지는 설명하라고 하면 명확히 설명하기 어려웠다.
처음 떠올린 이유는 중복이었다. 재고를 건드리는 곳이 주문, 관리자 수동 조정, 장바구니 예약까지 세 군데로 늘면, 후보 A는 if (stock < qty) throw를 세 군데에 복붙한다. 정책이 바뀌면 다 찾아 고쳐야 하고, 하나라도 빠뜨리면 거기서 음수 재고가 샌다.
근데 파다 보니 중복은 진짜 문제가 아니었다. 진짜는 setStock(int)이 public이라는 거다. 그러면 코드 어디서든 product.setStock(-999)가 그냥 된다. "재고는 음수 불가"라는 규칙이 Product 바깥에 떠 있으니, 그 규칙을 모르는 사람이 setter를 부르면 막을 길이 없다. 중복은 불편이지만, 이건 규칙을 우회하는 길이 뚫려 있는 거다. 무게가 다르다.
데이터(getter/setter)만 있고 규칙은 전부 서비스에 나가 있는 이런 모델을 anemic 도메인 모델이라 부른다.
"행위가 거의 없어서, getter와 setter 자루에 지나지 않는다는 걸 깨닫게 된다. 도메인 모델의 비용은 다 치르면서 그 이득은 하나도 얻지 못한다." — Martin Fowler, "AnemicDomainModel" (2003)
후보 B는 재고 필드를 private로 숨기고 decreaseStock 하나만 통로로 연다. 그 통로 안에 검사가 박혀 있으니 우회할 수 없다.
public void decreaseStock(Quantity qty) {
if (this.stock < qty.value()) {
throw new CoreException(ErrorType.CONFLICT, "재고가 부족합니다.");
}
this.stock -= qty.value();
}
코드는 별것 없다. 핵심은 이 메서드가 생긴 순간 "재고는 음수가 될 수 없다"는 규칙의 주인이 Product 하나로 확정됐다는 거다. 책임의 주인을 묻는 첫 답이었다.
주문 조립은 Facade 일이 아니었다
규칙은 Product에 넣었는데, 정작 decreaseStock을 호출하는 코드가 엉뚱한 데 있었다. 주문 생성 첫 구현의 OrderFacade다.
// before — OrderFacade
@Transactional
public OrderInfo place(Long userId, List<OrderLineCommand> lines) {
List<OrderItem> items = lines.stream()
.map(line -> {
Product product = productService.getProduct(line.productId());
Quantity quantity = new Quantity(line.quantity());
product.decreaseStock(quantity); // 재고 차감을 Facade가 부른다
return new OrderItem(product.getId(), product.getName(),
product.getPrice(), quantity); // 항목 조립도 Facade가
})
.toList();
Order order = orderService.place(userId, items);
return OrderInfo.from(order, items);
}
흐름은 한눈에 보인다. 그래서 처음엔 괜찮아 보였다. 문제는 Facade가 하는 일이었다.
Facade의 일은 조율이다. 트랜잭션을 열고, 객체를 꺼내고, 도메인에 일을 시키고, 결과를 DTO로 만든다. 비즈니스 규칙은 직접 갖지 않는다. 그 기준으로 보면, "여러 상품의 재고를 차감하고 주문 항목을 만든다"는 가운데 토막은 조율이 아니라 주문 도메인의 규칙이다. 그게 Facade에 나와 있었다.
그래서 차감과 항목 조립을 OrderService로 옮겼다. Facade는 요청을 도메인 입력(OrderLine)으로 바꿔 넘기는 일만 한다.
// after — OrderFacade
@Transactional
public OrderInfo place(Long userId, List<OrderLineCommand> commands) {
List<OrderLine> lines = commands.stream()
.map(c -> new OrderLine(c.productId(), new Quantity(c.quantity())))
.toList();
return OrderInfo.from(orderService.place(userId, lines));
}
재고 차감 하나에도 책임이 셋으로 갈렸다. 음수를 막는 규칙은 Product, 여러 상품을 도는 조율은 OrderService, 요청을 도메인 언어로 번역하는 일은 Facade. 세 칸짜리 틀이라면 셋 다 Service 한 칸에 뭉쳐 있었을 자리다.
상품 상세는 도메인 서비스를 써야할까?
여기선 처음 손이 가던 방향을 한 번 거슬렀다. 상품 상세의 Product + Brand 조합은 여러 객체를 엮는 일이니, 반사적으로 도메인 서비스 자리 같았다. 흔히들 그렇게 묶기도 하고.
도메인 서비스는 한 엔티티에 자연스럽게 안 붙는 도메인 규칙을 담는 자리다.
"도메인의 중요한 프로세스나 변환이 Entity나 Value Object의 자연스러운 책임이 아닐 때, 그 연산을 독립된 Service로 모델에 추가하라." — Eric Evans, Domain-Driven Design (2003)
핵심은 "프로세스나 변환", 즉 규칙이 있을 때다. 그런데 상품 상세는 Product와 Brand를 읽어다 화면 데이터로 붙이는 게 전부다. 계산도, 판단도, 불변식도 없다. 규칙이 없는데 도메인 서비스를 만들면, 빈 껍데기에 이름만 거창하게 붙는다. 이것도 "칸이 있으니 채운다"는 틀 의존의 다른 얼굴이다.
그래서 도메인 서비스 대신 Facade에서 바로 합쳤다. "규칙 있으면 도메인 서비스, 없으면 Facade"로 정리한 거다.
public ProductInfo getProduct(Long id) {
Product product = productService.getProduct(id);
Brand brand = brandService.getBrand(product.getBrandId());
return ProductInfo.from(product, brand);
}
나중에 "비활성 브랜드 상품은 안 보인다" 같은 규칙이 붙으면 그때 올리면 된다. 규칙이 생기기 전에 미리 만드는 건 과한 설계다.
대신 대가가 하나 있다. 목록 조회에서 상품 N개를 가져온 뒤 브랜드를 건건이 조회하는 N+1이 생긴다. 왜 그런지는 다음 절로 이어진다.
브랜드는 안고 있지 말고 ID로 가리킨다
지금까지가 "이 행위를 누가 하냐"였다면, 여기선 질문이 한 칸 더 들어간다. "이 데이터가 애초에 이 객체 소속이 맞냐." 상품 상세에서 미뤄둔 N+1이 바로 이 질문에서 나온다.
Product는 브랜드를 가리켜야 한다. 자연스럽게 떠오르는 건 @ManyToOne으로 Brand 객체를 직접 무는 거다. 그러면 product.getBrand().getName() 한 줄로 끝난다. 근데 그렇게 안 했다. Product는 Brand를 객체가 아니라 Long brandId로만 든다.
// @ManyToOne private Brand brand; // 직접 안는 대신
private Long brandId; // ID로만 가리킨다
Brand가 Product와 다른 애그리거트여서다. 애그리거트는 함께 변하고 함께 일관성을 지키는 묶음인데, 브랜드가 바뀐다고 상품이 같은 트랜잭션에서 같이 바뀌어야 하는 건 아니다. 경계가 다르다. 다른 애그리거트는 객체로 끌어안지 말고 ID로 가리키라는 게 정설이다.
"외부 Aggregate는 직접 객체 참조로 들지 말고, 전역적으로 고유한 식별자(ID)로 참조하라." — Vaughn Vernon, Implementing Domain-Driven Design (2013)
객체로 물면 getBrand() 한 방에 끝나 편하지만, 두 애그리거트가 한 덩어리로 엉켜 경계가 흐려진다. ID로 끊으면 경계는 깨끗한데, 화면에서 브랜드 이름이 필요할 때 따로 조회해야 한다. 상품 상세에서 미뤄둔 그 N+1이 여기서 나온다. 목록에서 상품 N개를 가져온 뒤 brandId로 브랜드를 건건이 또 조회하니까.
편의와 경계의 청결함을 맞바꾼 셈이다. 이번엔 경계를 택했고, N+1은 brandId를 모아 한 번에 가져오는(IN 조회) 식으로 갚을 자리로 남겨뒀다. 알고 미룬 거다.
정리
이번에 던진 질문은 결국 하나로 모인다. 이 한 줄, 이 데이터의 주인이 누구냐. 행위로 보면 재고 규칙은 Product로 들어갔고 조율은 Facade에서 OrderService로 내려갔다. 데이터로 보면 브랜드는 Product가 객체로 안고 있을 게 아니라 ID로만 가리킬 남의 애그리거트였다. 행위든 데이터든, 묻는 건 같았다.
세 칸짜리 틀은 이걸 안 물어본다. 그냥 빈칸을 준다. 그래서 애매한 건 다 Service로 갔고, 처음 짠 내 코드가 딱 그 꼴이었다. 의존 방향을 일자로 그리든 가운데로 모으든, 틀의 모양은 이 질문을 대신 답해주지 않았다.
레이어드 아키텍처를 처음엔 "계층 나누는 규칙"으로만 알았다. 다 짜고 나니, 계층을 나눈다는 건 코드 한 줄마다 "이 책임의 주인이 누구냐"를 묻게 강제하는 일이었다.
참고 자료
- Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (2002) — DIP
- Martin Fowler, "AnemicDomainModel" (2003) — https://martinfowler.com/bliki/AnemicDomainModel.html
- Eric Evans, Domain-Driven Design (2003) — Service·Aggregate
- Vaughn Vernon, Implementing Domain-Driven Design (2013) — Reference Aggregates by Identity