[Spring Boot] 레이어드 아키텍처 (Layered Architecture)

곽태민·2025년 11월 2일

TIL

목록 보기
69/72

이커머스 프로젝트를 진행하면서 "레이어드 아키텍처로 설계하라" 는 요구사항이 있었다. 처음에는 단순히 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)
    }
}

문제점

  • 비즈니스 규칙이 Service에 흩어져있음.
  • Domain 객체가 단순 데이터 컨테이너로 전락 (Anemic Domain Model)
  • 같은 로직이 여러 Service에 중복될 가능성
  • 테스트하기 어려움

레이어드 아키텍처란?

관심사의 분리(Separation of Concerns) 를 통해 시스템을 계층으로 나누는 아키텍처 패턴이다. 각 계층은 명확한 책임을 가지며, 상위 계층은 하위 계층에만 의존한다.

4계층 구조

┌─────────────────────────────────────┐
│   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  # 구현체

각 계층의 역할

1️⃣ Presentation Layer (표현 계층)

역할: 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에 있는 이유:

  • 외부 통신 전용 객체
  • API 스펙 변경이 Domain에 영향 없음
  • 필요한 정보만 선택적으로 노출

2️⃣ Application Layer (응용 계층)

역할: 비즈니스 흐름 조율, 트랜잭션 관리

// ✅ 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가 하는 일:

  • ✅ 여러 Repository에서 데이터 조회
  • ✅ Domain 객체들 간의 상호작용 조율
  • ✅ 트랜잭션 경계 관리
  • ❌ 비즈니스 규칙 직접 구현 (Domain에 위임!)

3️⃣ Domain 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의 특징:

  • 순수 비즈니스 로직만 포함
  • 어떤 프레임워크에도 의존하지 않음 (Spring, JPA 등)
  • 테스트가 가장 쉬움

4️⃣ Infrastructure 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()
    }
}

🎯 의존성 역전 원칙 (DIP)

핵심: Domain이 Infrastructure를 의존하지 않고, Infrastructure가 Domain을 의존한다.

전통적인 방식 (나쁜 예):
Service → Repository 구현체 (강한 결합)

레이어드 아키텍처 (좋은 예):
Service → Repository 인터페이스 ← Repository 구현체 (약한 결합)

장점:

  • InMemory → JPA → MongoDB로 변경해도 Domain, Application은 수정 불필요
  • Mock 객체로 쉽게 테스트 가능

⚠️ 자주하는 실수

1. CouponStatus를 Infrastructure에 두는 실수

// ❌ 잘못된 위치
infrastructure/coupon/CouponStatus.kt

// ✅ 올바른 위치
domain/coupon/CouponStatus.kt

이유: CouponStatus는 도메인 개념이지 구현 세부사항이 아니다!

2. DTO를 Domain으로 착각하는 실수

// ❌ 잘못된 위치
presentation/order/dto/Order.kt  // 이건 DTO여야 함

// ✅ 올바른 구조
domain/order/Order.kt                    // Domain Model
presentation/order/dto/OrderResponse.kt  // DTO

3. Service에 비즈니스 로직을 넣는 실수

// ❌ 나쁜 예: 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
    }
}

✅ 장점

1. 유지보수성

* 변경의 영향 범위가 명확하게 제한됨
* 각 계층이 독립적으로 진화 가능

2. 테스트 용이성

// Domain 로직 단위 테스트 (프레임워크 없이)
class StockTest {
    @Test
    fun `재고가 부족하면 예외가 발생한다`() {
        val stock = Stock(productId = 1, quantity = 5)
        
        assertThrows<InsufficientStockException> {
            stock.decrease(10)
        }
    }
}

3. 기술 독립성

  • Domain은 순수 비즈니스 로직만 포함
  • 프레임워크 교체 가능 (Spring → Ktor 등)

📊 정리

계층역할변경 이유의존성
PresentationHTTP 처리API 스펙 변경Application
Application흐름 조율비즈니스 프로세스 변경Domain
Domain비즈니스 규칙도메인 규칙 변경없음 (순수)
Infrastructure데이터 저장저장소 기술 변경Domain

핵심 원칙:

  • Domain은 어디에도 의존하지 않음 (순수)
  • 비즈니스 규칙은 Domain Model에
  • Application은 흐름 조율만
  • Infrastructure는 Domain 인터페이스 구현

제대로 된 레이어드 아키텍처를 적용하면, 코드의 책임이 명확해지고 테스트와 유지보수가 훨씬 쉬워진다. Service에 모든 걸 때려박는 습관에서 벗어나, Domain이 중심이 되는 설계를 하자!

profile
Node.js 백엔드 개발자입니다!

0개의 댓글