개요
커머스 프로젝트에서 주문 기능을 만들다가 팀원한테 질문을 받았다.
"OrderService에서 ProductFinder를 직접 쓰고 있는데, 이거 Assembler에서 ProductService로 조회해서 넘겨주는 게 맞지 않아요?"
합리적인 의문이다. 우리 프로젝트 규칙상 Assembler는 Service만 참조할 수 있고, 타 개념의 데이터가 필요하면 Assembler에서 조합하는 게 기본이니까.
그런데 결론부터 말하면, 이 경우는 Assembler로 빼면 오히려 구조가 나빠진다.
왜 그런지 두 가지 사례를 비교해보면 감이 온다.
프로젝트의 계층 구조
Presentation Layer (Controller, Assembler)
↓
Business Layer (*Service)
↓
Logic Layer (*Finder, *Reader, *Manager ...)
↓
Data Access Layer (*Repository, *Client ...)
참조는 위에서 아래로만 가능하고, 같은 레이어끼리는 참조하지 않는다. 다만 Logic Layer만 예외로, Finder끼리 서로 참조할 수 있다.
재사용 가능한 도구 클래스로 쓰라는 취지다.
Assembler는 Presentation Layer에 있고, Service만 참조 가능하다. Finder, Reader,Manager 같은 Logic Layer 클래스는 직접 참조할 수 없다. 역할도 명확하다
— UI 요구사항을 위해 여러 Service 결과를 합치는 것.
사례 1: OrderService에서 ProductFinder를 직접 쓰는 경우
@Service
class OrderService(
private val orderManager: OrderManager,
private val productFinder: ProductFinder,
private val productOptionFinder: ProductOptionFinder,
// ...
) {
fun create(user: User, newOrder: NewOrder): String {
val productOptions = productOptionFinder.find(
newOrder.productOptionIds(), EntityStatus.ACTIVE
)
val products = productFinder.find(
productOptions.map { it.productId }, EntityStatus.ACTIVE
)
return orderManager.create(
userId = user.id,
newOrder = newOrder,
products = products,
productOptions = productOptions,
)
}
}
OrderService가 ProductFinder와 ProductOptionFinder를 직접 들고 있다. Assembler를 안 거친다.
이 코드에서 상품 조회는 "상품 정보를 가져오는 것"이 아니라 "이 상품 옵션이 ACTIVE인지 검증하는 것"이다. ACTIVE가 아니면 주문이 만들어지면 안 된다. 검증 → 조회 → 생성이 한 덩어리의 비즈니스 흐름이고, 이걸 쪼개면 문제가 생긴다.
억지로 Assembler로 빼면 이렇게 된다.
class OrderAssembler(
private val orderService: OrderService,
private val productService: ProductService,
) {
fun create(user: User, newOrder: NewOrder): String {
val products = productService.findActiveProducts(...)
val productOptions = productService.findActiveOptions(...)
return orderService.create(user, newOrder, products, productOptions)
}
}
ACTIVE 상태 검증이라는 비즈니스 판단이 Assembler로 올라간다. Assembler는 UI 조합만 해야 하는데, 비즈니스 로직이 들어가 버린다. 게다가 ProductService에 주문 생성 전용 메서드를 추가해야 하니까, 상품이라는 개념이 주문 요구사항에 끌려다니게 된다.
사례 2: FavoriteAssembler에서 ProductService를 조합하는 경우
@Component
class FavoriteAssembler(
private val favoriteService: FavoriteService,
private val productService: ProductService,
private val brandService: BrandService,
private val merchantService: MerchantService,
) {
private fun getProductFavorites(
user: User, targetType: FavoriteTargetType, offsetLimit: OffsetLimit,
): Page<FavoriteResponse> {
val favorites = favoriteService.findFavorites(
user, targetType,
offsetLimit
)
val productIds = favorites.content
.filter { it.targetType == FavoriteTargetType.PRODUCT }
.map { it.targetId }.distinct()
val productMap = productService.findProducts(productIds)
.associateBy { it.id }
return Page(
FavoriteResponse.ofProduct(favorites.content, productMap),
favorites.hasNext
)
}
}
이건 상황이 다르다. 즐겨찾기 ID 목록을 가져온 다음, 상품명/브랜드명/판매자명을 붙여서 화면에 보여주는 것이다. 검증이 없다. 상태 변경도 없다. 상품 정보가 없으면 주문이 실패하는 게 아니라, 화면에 이름이 안 뜰 뿐이다.
이런 건 Assembler가 해야 할 일이 맞다. FavoriteService 안에서 ProductService를 부르면 같은 Business Layer끼리 참조하게 되어 규칙 위반이고, Assembler에서 여러 Service를 조합하는 게 정확히 Assembler가 존재하는 이유다.
그래서 기준이 뭔데
결국 같은 "타 개념 데이터 조회"인데, 하나는 Service에 두고 하나는 Assembler에 뒀다. 내가 구분하는 기준은 이거다.
그 조회를 빼면 비즈니스가 깨지는가?
주문 생성에서 상품 ACTIVE 검증을 빼면? 비활성 상품으로 주문이 생성된다. 비즈니스가 깨진다. 그러니까 Service 안에 있어야 한다.
즐겨찾기 목록에서 상품명 조회를 빼면? 이름 없이 ID만 보인다. 화면이 불편해질 뿐 비즈니스는 멀쩡하다. 그러니까 Assembler에서 조합하면 된다.
코드 리뷰할 때 타 개념 참조가 보이면, "이거 빼면 뭐가 터지지?"를 먼저 생각해보면 위치가 정해진다.