이커머스 프로젝트를 진행하면서 "레이어드 아키텍처로 설계하라" 는 요구사항이 있었다. 처음에는 단순히 Controller-Service-Repository 구조만 생각했는데, 제대로 된 레이어드 아키텍처는 훨씬 더 체계적이고 명확한 책임 분리가 필요했다.
특히 "도메인 모델이 비즈니스 규칙을 포함해야 한다"는 요구사항을 보고, Service에 모든 로직을 때려박는 방식이 얼마나 잘못된 것인지 깨달았다. 그래서 레이어드 아키텍처에 대해서 알아보고 싶어서 글에 정리를 해보게 됐다.
// ❌ 흔히 보는 잘못된 코드
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val stockRepository: StockRepository
) {
fun createOrder(productId: Long, quantity: Int): Order {
val stock = stockRepository.findById(productId)
// Service에 비즈니스 규칙이 흩어짐
if (stock.quantity < quantity) {
throw Exception("재고 부족")
}
stock.quantity -= quantity // 직접 수정 💥
val totalAmount = stock.price * quantity // Service에서 계산 💥
val order = Order(
productId = productId,
quantity = quantity,
totalAmount = totalAmount
)
stockRepository.save(stock)
return orderRepository.save(order)
}
}
문제점
관심사의 분리(Separation of Concerns) 를 통해 시스템을 계층으로 나누는 아키텍처 패턴이다. 각 계층은 명확한 책임을 가지며, 상위 계층은 하위 계층에만 의존한다.
┌─────────────────────────────────────┐
│ Presentation Layer (표현 계층) │ ← 외부와의 접점 (Controller, DTO)
├─────────────────────────────────────┤
│ Application Layer (응용 계층) │ ← 비즈니스 흐름 조율 (Service)
├─────────────────────────────────────┤
│ Domain Layer (도메인 계층) │ ← 핵심 비즈니스 규칙 (Domain Model)
├─────────────────────────────────────┤
│ Infrastructure Layer (인프라 계층) │ ← 데이터 저장, 외부 연동
└─────────────────────────────────────┘
src/main/kotlin/com/hhplus/ecommerce/
│
├── presentation/ # 1️⃣ Presentation Layer
│ └── order/
│ ├── OrderController.kt
│ └── dto/
│ ├── CreateOrderRequest.kt
│ └── OrderResponse.kt
│
├── application/ # 2️⃣ Application Layer
│ └── order/
│ └── OrderService.kt
│
├── domain/ # 3️⃣ Domain Layer (핵심!)
│ ├── order/
│ │ ├── Order.kt # 비즈니스 규칙 포함
│ │ └── OrderRepository.kt # 인터페이스만
│ │
│ └── product/
│ ├── Stock.kt # 비즈니스 규칙 포함
│ └── ProductRepository.kt # 인터페이스만
│
└── infrastructure/ # 4️⃣ Infrastructure Layer
└── order/
└── InMemoryOrderRepository.kt # 구현체
역할: HTTP 요청/응답 처리, DTO 변환
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val orderService: OrderService
) {
@PostMapping
fun createOrder(@RequestBody request: CreateOrderRequest): OrderResponse {
// 1. 입력 검증 (DTO)
// 2. Application Layer 호출
val order = orderService.createOrder(
userId = request.userId,
items = request.items
)
// 3. DTO로 변환하여 응답
return OrderResponse.from(order)
}
}
DTO가 Presentation에 있는 이유:
역할: 비즈니스 흐름 조율, 트랜잭션 관리
// ✅ Service는 흐름만 조율
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val stockRepository: StockRepository
) {
@Transactional
fun createOrder(userId: Long, items: List<OrderItem>): Order {
// 1. 재고 조회
val stock = stockRepository.findById(items.first().productId)
?: throw ProductNotFoundException()
// 2. Domain 객체에 비즈니스 규칙 실행 위임
stock.decrease(items.first().quantity)
// 3. Domain 객체가 주문 생성 로직 처리
val order = Order.create(
userId = userId,
items = items
)
// 4. 저장
stockRepository.save(stock)
return orderRepository.save(order)
}
}
Application Layer가 하는 일:
역할: 핵심 비즈니스 규칙 포함
// domain/product/Stock.kt
data class Stock(
val productId: Long,
private var quantity: Int // private으로 캡슐화
) {
// ✅ 비즈니스 규칙을 Domain이 직접 처리
fun decrease(amount: Int) {
require(amount > 0) { "감소 수량은 양수여야 합니다" }
// 비즈니스 규칙: 재고 부족 시 예외
if (quantity < amount) {
throw InsufficientStockException(
"재고 부족: 요청 $amount, 현재 $quantity"
)
}
quantity -= amount
}
fun isAvailable(amount: Int): Boolean = quantity >= amount
}
// domain/order/Order.kt
data class Order(
val id: Long?,
val userId: Long,
val items: List<OrderItem>,
private val _totalAmount: BigDecimal
) {
val totalAmount: BigDecimal get() = _totalAmount
companion object {
// ✅ 생성 로직도 Domain이 관리
fun create(userId: Long, items: List<OrderItem>): Order {
require(items.isNotEmpty()) { "주문 상품이 비어있습니다" }
val totalAmount = items.sumOf { it.calculateAmount() }
return Order(
id = null,
userId = userId,
items = items,
_totalAmount = totalAmount
)
}
}
}
Domain Layer의 특징:
역할: Repository 인터페이스의 실제 구현
// domain/product/ProductRepository.kt (인터페이스만!)
interface ProductRepository {
fun save(product: Product): Product
fun findById(id: Long): Product?
fun findAll(): List<Product>
}
kotlin// infrastructure/product/InMemoryProductRepository.kt (구현체)
@Repository
class InMemoryProductRepository : ProductRepository {
private val storage = ConcurrentHashMap<Long, Product>()
private val idGenerator = AtomicLong(1)
override fun save(product: Product): Product {
val id = product.id ?: idGenerator.getAndIncrement()
val newProduct = product.copy(id = id)
storage[id] = newProduct
return newProduct
}
override fun findById(id: Long): Product? {
return storage[id]
}
override fun findAll(): List<Product> {
return storage.values.toList()
}
}
핵심: Domain이 Infrastructure를 의존하지 않고, Infrastructure가 Domain을 의존한다.
전통적인 방식 (나쁜 예):
Service → Repository 구현체 (강한 결합)
레이어드 아키텍처 (좋은 예):
Service → Repository 인터페이스 ← Repository 구현체 (약한 결합)
장점:
// ❌ 잘못된 위치
infrastructure/coupon/CouponStatus.kt
// ✅ 올바른 위치
domain/coupon/CouponStatus.kt
이유: CouponStatus는 도메인 개념이지 구현 세부사항이 아니다!
// ❌ 잘못된 위치
presentation/order/dto/Order.kt // 이건 DTO여야 함
// ✅ 올바른 구조
domain/order/Order.kt // Domain Model
presentation/order/dto/OrderResponse.kt // DTO
// ❌ 나쁜 예: Service에 비즈니스 규칙
@Service
class StockService {
fun decreaseStock(stock: Stock, amount: Int) {
if (stock.quantity < amount) {
throw Exception("재고 부족")
}
stock.quantity -= amount // 외부에서 직접 수정
}
}
// ✅ 좋은 예: Domain 모델이 비즈니스 규칙 포함
class Stock {
private var quantity: Int
fun decrease(amount: Int) {
if (quantity < amount) {
throw InsufficientStockException()
}
quantity -= amount
}
}
* 변경의 영향 범위가 명확하게 제한됨
* 각 계층이 독립적으로 진화 가능
// Domain 로직 단위 테스트 (프레임워크 없이)
class StockTest {
@Test
fun `재고가 부족하면 예외가 발생한다`() {
val stock = Stock(productId = 1, quantity = 5)
assertThrows<InsufficientStockException> {
stock.decrease(10)
}
}
}
| 계층 | 역할 | 변경 이유 | 의존성 |
|---|---|---|---|
| Presentation | HTTP 처리 | API 스펙 변경 | Application |
| Application | 흐름 조율 | 비즈니스 프로세스 변경 | Domain |
| Domain | 비즈니스 규칙 | 도메인 규칙 변경 | 없음 (순수) |
| Infrastructure | 데이터 저장 | 저장소 기술 변경 | Domain |
핵심 원칙:
제대로 된 레이어드 아키텍처를 적용하면, 코드의 책임이 명확해지고 테스트와 유지보수가 훨씬 쉬워진다. Service에 모든 걸 때려박는 습관에서 벗어나, Domain이 중심이 되는 설계를 하자!