데이터 엔지니어에서 백엔드 엔지니어로 전향하면서, 코드로 해결해야 하는 문제가 훨씬 더 많아졌다. 지금은 MAU가 꽤 나오는 글로벌 이커머스 백엔드를 만들고 있다. 트래픽도, 요구사항도, 의존하는 외부 시스템도 많다 보니 코드베이스는 빠르게 커지고 복잡도도 함께 증가한다. 그래서 단순히 “기능을 구현하는 방법”이 아니라, 유지보수와 확장에 강한 구조를 더 진지하게 공부해야겠다고 느꼈다. 이 글에서는 현재 실무에서 백엔드 개발에서 자주 사용중인 Usecase 패턴을 중심으로, 실제 이커머스 도메인에서 어떻게 적용할 수 있는지 정리해보려 한다.
전통적인 Controller–Service–Repository 구조의 한계
전통적인 Backend pattern 은 다음과 같다. Controller, Service, Repository, Entity 로 이루어진다. 이해하기 쉽고 직관적이다.
Controller : Handles HTTP requests and responses (Presentation layer)
└─ Service : Contains the business logic and orchestrates data flow (Business layer/Service Layer)
└─ Repository : Manages database interactions (Persistence/Data Access layer)
└─ Entity : Represents the data model in the database
그렇지만 이 구조의 문제는 점점 서비스가 발전함에 따라서 Service Layer 가 비대해질 수 있다는 것이다. Service는 이런 역할들을 모두 떠 안게 된다.
비즈니스 규칙 : 도메인 규칙, 정책 판단, 상태 변경 가능 여부
유즈케이스 흐름 제어 : A->B->C 의 흐름, 실패시 분기
트랜잭션 : 어디서부터 트랜잭션을 시작하고 끝내는지의 여부
외부 시스템 호출 : 결제 시스템 호출, 재고 시스템 호출, 배송 시스템 호출 등
검증 : 요청 값 검증, 상태 검증, 중복 요청 방지
로깅 / 메트릭 : 이벤트 발행, 모니터링 로그 수집
즉, Service Layer는 시간이 지날수록비즈니스 규칙과 기술 관심사가 뒤섞인 거대한 실행 클래스가 된다.Service Layer가 커지면 이게 어떤 비즈니스 로직인지 바로 이해하기가 어려워진다. 또한 새로운 요구 사항이 들어왔을 때 사이드 이펙트 파악하기도 쉽지 않아서 유지보수가 쉽지 않다. 주문 기능이 있다고 할 때 OrderService 라는 이름만 봐서는 어떤 행동을 하고, 시나리오를 가지고 있고, 변경 범위가 어디인지 파악하기 어렵다. 반면, Usecase 구조는 이러한 책임을 “비즈니스 행동 단위”로 분리하여 코드의 의도를 다시 드러낼 수 있다.
Usecase
Usecase란 행위자(Actor)에게 시스템이 제공하는 하나의 행동(Action)을 정의한 것이다.기존 Service Layer에 뒤섞여 있던 책임을 비즈니스 행동 단위로 재배치하는 역할을 한다. Usecase는 상태가 아니라 행동을 표현한다. 그래서 클래스 이름은 항상 동사 형태를 사용한다. 클래스 이름만 봐도 이 usecase가 어떤 행동을 제공하는지 직관적으로 알 수 있다.
하나의 usecase 는 하나의 시나리오이다. 시나리오 내부에서 여러 도메인 객체들을 조합하고, 실행 순서를 정의내리고 실행한다.
@Service
class PlaceOrder(
private val loadProductsFromCart: LoadProductsFromCart, // Port Layer
private val saveOrderResult: SaveOrderResult // Port Layer
) : PlaceOrderUseCase {
@Transactional
override fun execute(command: PlaceOrderCommand): PlaceOrderResult {
val cart = loadProductsFromCart.load(command.cartId)
val order = Order.place(
customerId = command.customerId,
cart = cart,
shippingAddressId = command.shippingAddressId,
paymentMethod = command.paymentMethod,
couponId = command.couponId
)
val orderId = saveOrderResult.save(order, command.idempotencyKey)
return PlaceOrderResult(orderId.value)
}
}
Ports (input / output ports)
Usecase는 Port(인터페이스)를 통해서만 바깥과 소통한다. 또한 Usecase 는 행위를 요청만 하고, 어떤식으로 만들어지는 알지 못한다.
Input Port: Usecase가 외부에서 행위자에 의해 어떻게 호출되는지 정의한다. Controller, Scheduler, Consumer 등 모든 호출자는 이 인터페이스만 의존한다. 아래의 인터페이스는 PlaceOrder 라는 Usecase를 외부에 노출하는 Input Port 이다 .
interface PlaceOrder {
fun execute(command: PlaceOrderCommand): PlaceOrderResult
}
Output Port:Usecase가 필요로 하는 기능을 정의한다. 주문 시스템에서, 주문이 이루어지면 주문 결과를 db 에 저장해야 한다고 하자. 그러기 위해서는 Order Id 와 같은 새로운 주문에 대한 식별자 값이 필요할 것이다.
interface SaveOrderResult {
fun save(order: Order): OrderId
}
Usecase 입력 모델: Command
Usecase는 보통 execute() 하나로 호출 규칙을 통일하고 있다. 이때 Usecase 실행에 필요한 입력값은 Command 객체로 캡슐화한다.Command 는 Usecase 를 실행하기 위한 입력값을 하나의 요청 객체로 묶어놓은 것이다. 아래와 같은 형태로Usecase 실행에 필요한 데이터만 담는 DTO이다. 파라미터가 늘어나는 것을 막고, usecase의 입력을 안정적으로 만든다. 또한 “이 행동을 실행한다”는 의도를 코드에 명확히 드러내는 효과가 있다.
PlaceOrderCommand
CancelOrderCommand
ApplyPromotionCommand
data class PlaceOrderCommand(
val customerId: String,
val cartId: String,
val shippingAddressId: String,
val paymentMethod: PaymentMethod,
val couponId: String? = null,
val idempotencyKey: String
)
enum class PaymentMethod {
CARD,
PAYPAL,
APPLE_PAY
}
Adapter
Port 는 내부에서 (Usecase) 바깥에 요구하는 “계약(Interface)” 에 대해서 정의한다. 그리고 Adapter 는 계약에 대해서 실제로 구현하는 클래스이다.
Port 는 아래와 같다.
package com.company.ecommerce.application.order.port
interface LoadProductsFromCart {
fun load(cartId: String): List<CartProduct>
}
data class CartProduct(
val productId: String,
val name: String,
val unitPrice: Long,
val quantity: Int
)
Adapter는 Usecase가 정의한 요구 조건(Port)을 실제 기술로 구현한 클래스다. 예를 들어서, "장바구니에서 물품 목록을 조회해야 한다"는 요구를 JPA, SQL, HTTP와 같은 외부 인프라를 이용해 충족시킨다. 따라서 Adapter에는 데이터베이스 접근, 외부 API 호출 등 기술 의존적인 코드가 포함된다.
@Component
class CartProductQueryAdapter(
private val cartItemRepository: CartItemJpaRepository,
private val productRepository: ProductJpaRepository
) : LoadProductsFromCart {
@Transactional(readOnly = true)
override fun load(cartId: String): List<CartProduct> {
val cartItems = cartItemRepository.findAllByCartId(cartId)
if (cartItems.isEmpty()) return emptyList()
val productIds = cartItems.map { it.productId }.distinct()
val products = productRepository.findAllById(productIds)
.associateBy { it.id }
return cartItems.map { item ->
val p = products[item.productId]
?: throw IllegalStateException("Product not found. productId=${item.productId}")
CartProduct(
productId = p.id,
name = p.name,
unitPrice = p.unitPrice,
quantity = item.quantity
)
}
}
}
Domain Service
순수 비즈니스 규칙에 해당하는 로직이다. 여러 엔티티에 걸친 정책이다. 정책 이기 때문에 상태가 없다. (Stateless)
Adapter와 다르게 특정 외부 기술 (SaaS, Database 등)에 의존적이지 않다. 언뜻보기에 비슷해보여서 헷갈리지만, 둘은 완전히 다른 목적의 layer이다. Adapter는 Usecase가 정의한 요구 조건을 기술(JPA, HTTP 등)을 통해 구현하는 계층이며, Domain Service는 특정 엔티티에 속하지 않는 순수 비즈니스 규칙을 표현한다. Domain Service는 기술을 모르고, Adapter는 비즈니스를 판단하지 않는다.
멤버 등급에 따라서 할인을 해주는 비즈니스 규칙이 있다고하자. 아래와 같이 구현할 수 있을 것이다.
class DiscountPolicy {
fun applyMembershipDiscount(order: Order, memberGrade: MemberGrade): Money {
return when (memberGrade) {
MemberGrade.VIP -> order.totalAmount().multiply(0.9)
MemberGrade.NORMAL -> order.totalAmount()
}
}
}
Domain Service는 도메인 내부의 비즈니스 규칙을 표현하는 객체이기 때문에, Port처럼 구현 교체를 전제로 한 인터페이스를 반드시 둘 필요는 없다. 정책이 여러 개로 분기되거나 전략 교체가 필요한 경우에만 인터페이스 도입을 고려하는 것이 적절하다. 만약 할인 정책이 여러개이고, 런타임에 바뀌는 경우에는 interface를 둘 수도 있을 것이다.
interface DiscountPolicy {
fun apply(order: Order, memberGrade: MemberGrade): Money
}
class VipDiscountPolicy : DiscountPolicy
class NormalDiscountPolicy : DiscountPolicy
Conclusion
Usecase 패턴은 코드를 “기능”이 아니라 “행동”으로 바라보는 하나의 관점이다. Usecase 구조는 Service가 무거워지고 복잡해지는 문제를 해결하기 위해 “주문한다”, “취소한다”, “할인을 적용한다”와 같은 비즈니스 행동 단위로 책임을 분리한다. Usecase는 시나리오의 흐름을 담당하고, Domain은 규칙을 표현하며, Adapter는 외부 기술과의 연결만을 책임진다. Usecase 패턴은 복잡한 서비스를 직관적으로 풀어내기에 현실적인 선택지인 것 같다.