순수 함수로 서비스 레이어 작성

서비스 레이어에서 비즈니스 로직과 부수 효과(DB 저장, 이벤트 발행, 외부 API 호출)가 뒤섞이면 테스트하기 어렵다. FP의 핵심 원칙 중 하나는 순수 함수(비즈니스 로직)와 부수 효과를 분리하는 것이다.


전형적인 서비스 — 뒤섞인 코드

@Service
class DiscountService(
    private val userRepository: UserRepository,
    private val orderRepository: OrderRepository,
    private val couponRepository: CouponRepository,
) {
    fun calculateDiscount(userId: Long, couponCode: String, orderAmount: Money): Money {
        val user = userRepository.findById(userId)    // 부수 효과 (DB)
            ?: throw UserNotFoundException()
 
        val coupon = couponRepository.findByCode(couponCode)  // 부수 효과 (DB)
            ?: throw CouponNotFoundException()
 
        if (!coupon.isValid()) throw InvalidCouponException()  // 비즈니스 로직
 
        val orderCount = orderRepository.countByUserId(userId)  // 부수 효과 (DB)
 
        // 비즈니스 로직이 부수 효과 사이에 흩어져 있음
        return when {
            user.isVip && orderCount >= 10 -> orderAmount * 0.2.toBigDecimal()
            coupon.discountRate != null    -> orderAmount * coupon.discountRate
            coupon.discountAmount != null  -> coupon.discountAmount
            else                           -> Money.ZERO
        }
    }
}

이 함수를 테스트하려면 DB Mock이 3개 필요하다.


부수 효과를 레이어 경계로 밀어내기

비즈니스 로직을 순수 함수로 분리한다. 순수 함수는 외부 상태에 접근하지 않고, 입력만으로 출력을 결정한다.

// ✅ 순수 함수 — 비즈니스 로직만
object DiscountCalculator {
 
    fun calculate(
        user: User,
        coupon: Coupon,
        orderCount: Int,
        orderAmount: Money,
    ): Either<DiscountError, Money> {
        if (!coupon.isValid()) return DiscountError.InvalidCoupon(coupon.code).left()
 
        val discount = when {
            user.isVip && orderCount >= 10 -> orderAmount * 0.2.toBigDecimal()
            coupon.discountRate != null    -> orderAmount * coupon.discountRate
            coupon.discountAmount != null  -> coupon.discountAmount
            else                           -> Money.ZERO
        }
 
        return discount.right()
    }
}
// 서비스 — 부수 효과(데이터 수집) + 순수 함수 호출
@Service
class DiscountService(
    private val userRepository: UserRepository,
    private val orderRepository: OrderRepository,
    private val couponRepository: CouponRepository,
) {
    fun calculateDiscount(
        userId: Long,
        couponCode: String,
        orderAmount: Money,
    ): Either<DiscountError, Money> = either {
        // 부수 효과: 데이터 수집
        val user       = userRepository.findById(userId)
            .toEither { DiscountError.UserNotFound(userId) }.bind()
        val coupon     = couponRepository.findByCode(couponCode)
            .toEither { DiscountError.CouponNotFound(couponCode) }.bind()
        val orderCount = orderRepository.countByUserId(userId)
 
        // 순수 함수: 비즈니스 로직
        DiscountCalculator.calculate(user, coupon, orderCount, orderAmount).bind()
    }
}

순수 함수 테스트 — Mock 없이

DiscountCalculator는 순수 함수이므로 Mock이 전혀 필요 없다.

class DiscountCalculatorTest {
 
    @Test
    fun `VIP 사용자가 10회 이상 주문 시 20% 할인`() {
        val user   = User(isVip = true)
        val coupon = Coupon(code = "VIP2024", isValid = true, discountRate = null, discountAmount = null)
        val amount = Money.of(100_000)
 
        val result = DiscountCalculator.calculate(user, coupon, orderCount = 10, orderAmount = amount)
 
        result shouldBe Money.of(20_000).right()
    }
 
    @Test
    fun `만료된 쿠폰 사용 시 InvalidCoupon 에러`() {
        val user   = User(isVip = false)
        val coupon = Coupon(code = "EXPIRED", isValid = false)
        val amount = Money.of(50_000)
 
        val result = DiscountCalculator.calculate(user, coupon, orderCount = 0, orderAmount = amount)
 
        result shouldBe DiscountError.InvalidCoupon("EXPIRED").left()
    }
 
    @Test
    fun `정액 쿠폰 — 5000원 할인`() {
        val user   = User(isVip = false)
        val coupon = Coupon(code = "SAVE5000", isValid = true, discountAmount = Money.of(5_000))
        val amount = Money.of(30_000)
 
        val result = DiscountCalculator.calculate(user, coupon, orderCount = 2, orderAmount = amount)
 
        result shouldBe Money.of(5_000).right()
    }
}

DB가 없어도 테스트가 통과한다. 실행 속도도 빠르다.


도메인 서비스 — 순수 비즈니스 로직 집합

순수 함수를 object로 묶으면 도메인 서비스가 된다.

object OrderPricingService {
 
    fun calculateTotal(items: List<OrderItem>): Money =
        items.fold(Money.ZERO) { acc, item -> acc + item.subtotal }
 
    fun applyDiscount(total: Money, discount: Money): Either<PricingError, Money> {
        if (discount > total) return PricingError.DiscountExceedsTotal.left()
        return (total - discount).right()
    }
 
    fun calculateShipping(total: Money, address: Address): Money = when {
        total >= Money.of(50_000) -> Money.ZERO  // 5만원 이상 무료
        address.isRemote          -> Money.of(5_000)
        else                      -> Money.of(3_000)
    }
 
    fun finalPrice(
        items: List<OrderItem>,
        discount: Money,
        address: Address,
    ): Either<PricingError, FinalPrice> = either {
        val itemTotal   = calculateTotal(items)
        val discounted  = applyDiscount(itemTotal, discount).bind()
        val shipping    = calculateShipping(discounted, address)
        FinalPrice(itemTotal, discount, shipping, discounted + shipping)
    }
}
data class FinalPrice(
    val itemTotal: Money,
    val discount: Money,
    val shipping: Money,
    val total: Money,
)

레이어별 역할 분리

Controller
  │  HTTP 요청/응답 변환, 인증 확인
  │
  ▼
Application Service (@Service)
  │  부수 효과 조율 (Repository, EventPublisher, 외부 API)
  │  순수 함수 호출
  │
  ▼
Domain Service (object)
  │  순수 비즈니스 로직
  │  의존성 없음, 테스트 쉬움
  │
  ▼
Domain Model (data class)
     상태 표현, copy()로 변경
// Application Service — 조율만 담당
@Service
class OrderApplicationService(
    private val userRepository: UserRepository,
    private val orderRepository: OrderRepository,
    private val couponRepository: CouponRepository,
    private val eventPublisher: ApplicationEventPublisher,
) {
    @Transactional
    fun placeOrder(command: PlaceOrderCommand): Either<OrderError, Order> = either {
        // 1. 데이터 수집 (부수 효과)
        val user   = userRepository.findById(command.userId).bind()
        val coupon = command.couponCode?.let {
            couponRepository.findByCode(it).bind()
        }
 
        // 2. 순수 함수로 가격 계산
        val pricing = OrderPricingService.finalPrice(
            items    = command.items,
            discount = coupon?.let { DiscountCalculator.calculate(user, it, ...).bind() } ?: Money.ZERO,
            address  = command.shippingAddress,
        ).bind()
 
        // 3. 도메인 모델 생성 (순수)
        val order = Order.create(command, pricing)
 
        // 4. 저장 (부수 효과)
        val saved = orderRepository.save(order)
 
        // 5. 이벤트 발행 (부수 효과)
        eventPublisher.publishEvent(OrderPlacedEvent(saved.id, saved.totalAmount))
 
        saved
    }
}

정리

  • 순수 함수: 외부 상태 없이 입력 → 출력만. Mock 없이 테스트 가능.
  • Application Service: 부수 효과(DB, 이벤트, 외부 API)를 조율. 순수 함수를 호출.
  • Domain Service (object): 비즈니스 로직만 담당. Spring 빈이 아니어도 됨.
  • 분리하면 비즈니스 로직 테스트가 @SpringBootTest 없이 밀리초 단위로 실행된다.