본질적 복잡성과 우연적 복잡성 — 스프링에서 FP는 어디까지 밀어넣을 수 있는가

0. 익숙한 코드에서 시작한다

코틀린과 스프링으로 주문 서비스를 만들면 대체로 이런 모양이 나온다.

@Transactional
fun create(request: OrderRequest): OrderResponse {
    val order = GoodsOrder()
    for (itemRequest in request.items) {
        val goods = goodsService.getGoods(itemRequest.goodsId)
        goods.decreaseStock(itemRequest.quantity)
        val subtotal = goods.calculateTotalPrice(itemRequest.quantity)
        val discount = discountService.calculateDiscount(goods, quantity, subtotal)
        val orderItem = OrderItem(
            goods = goods,
            quantity = itemRequest.quantity,
            unitPrice = goods.price,
            subtotal = subtotal,
            discountAmount = discount
        )
        order.addItem(orderItem)
    }
    order.calculatePrices()
    return OrderResponse.from(orderRepository.save(order))
}

동작한다. 잘 동작한다. 그런데 뜯어보면 side effect가 여기저기 숨어있다.

decreaseStock()부터 보자. 상태를 변경하는 side effect가 있는데, 시그니처에는 드러나지 않는다.

fun decreaseStock(quantity: Int) {
    if (stock < quantity) throw OutOfStockException(...)
    stock -= quantity
}

반환 타입이 Unit이다. 이 메서드가 Goodsstock 필드를 바꾼다는 건 구현을 열어봐야 안다. cancel()은 더 심하다. GoodsOrder의 메서드인데 Goods의 재고까지 바꾼다.

fun cancel() {
    changeStatus(OrderStatus.CANCELLED)
    items.forEach { it.goods.increaseStock(it.quantity) }
}

다음으로, 한 for문 안에 DB 조회, 재고 검증, 가격 계산, 할인 계산, 상태 변경이 전부 섞여있다. 비즈니스 로직만 따로 테스트하려면 이렇게 된다.

val goodsService = mock<GoodsService>()
val discountService = mock<DiscountService>()
val orderRepository = mock<OrderRepository>()
whenever(goodsService.getGoods(1L)).thenReturn(goods)
whenever(discountService.calculateDiscount(any(), any(), any())).thenReturn(1000)
whenever(orderRepository.save(any())).thenAnswer { it.arguments[0] }

mock 3개를 세팅하고 나서야 테스트를 시작할 수 있다. 검증하고 싶었던 건 "10,000원짜리 상품 2개 주문하면 20,000원인가?" 이 한 가지뿐인데 말이다.

마지막으로, decreaseStock()이 재고 부족 시 예외를 던진다. 그런데 재고 부족이 정말 "예외적 상황"인가? 주문 서비스에서 재고 부족은 충분히 예상 가능한 비즈니스 결과다.

이 문제들이 실무에서 왜 중요한가. 숨겨진 side effect는 코드 리뷰에서 잡기 어렵고, 새 팀원이 cancel()을 호출하면서 재고가 바뀌는 걸 모르면 버그로 이어진다. IO와 계산의 혼합은 테스트 작성 비용을 높이고, mock 세팅에 쏟는 시간이 쌓이면 "테스트를 안 쓰는 게 빠르다"는 유혹으로 연결된다. 예외 기반 에러 처리는 시그니처에 실패 가능성이 드러나지 않아서, 호출자가 예외를 핸들링하지 않은 채 프로덕션에 나가고 나서야 장애로 발견되는 경우가 생긴다. 개별로 보면 사소하지만, 서비스가 커지면서 이런 것들이 누적되면 개발 속도와 안정성 모두에 영향을 준다.


이런 불편함이 쌓이면 결국 FP를 검토하게 된다. immutable data, pure function, typed error handling. 위의 문제들을 정확히 겨냥하는 도구처럼 보인다.

직접 해봤다. 코틀린/스프링 주문 서비스에 FP를 극한까지 밀어넣었다. JPA 엔티티를 data class로 바꿨고, 예외를 sealed class로 대체했고, 할인 정책을 고차 함수로 전환했고, Arrow의 Either까지 도입했다.

결론부터 말하면 — 되는 것과 안 되는 것이 있었다. 흥미로운 건, "안 되는 것"의 이유가 두 종류였다는 점이다.

하나는 도구의 한계다. JPA가 val을 거부하는 것, @TransactionalEither를 모르는 것. 이건 다른 도구를 쓰면 사라진다.

다른 하나는 문제 자체의 본성이다. 변경을 추적하려면 상태가 필요하다는 것, encapsulation을 풀면 안전망이 약해진다는 것. 이건 어떤 도구를 써도 남는다.

Fred Brooks가 1986년에 쓴 "No Silver Bullet"에서는 전자를 우연적 복잡성(accidental complexity), 후자를 본질적 복잡성(essential complexity)이라고 부른다. 이 구분이 실무에서 중요한 이유는 단순하다. 대응이 완전히 다르기 때문이다.

우연적 벽은 선택의 문제다. 도구를 바꾸든, 관례를 존중하고 돌아가든. 본질적 벽은 타협의 문제다. 완벽한 해결이 없으므로, 트레이드오프를 의식적으로 설계해야 한다.

이 글은 코틀린/스프링 주문 서비스에 FP를 극한까지 밀어넣으면서 만난 벽들을, 하나하나 "이건 본질인가 우연인가?"로 판별한 기록이다.

1. JPA 엔티티를 immutable로 만들 수 있을까? — 우연적 벽

mutable state가 side effect의 근원이라면, 가장 직관적인 해결책은 immutable로 만드는 것이다. 코틀린에는 data classval이라는 훌륭한 도구가 있으니, JPA 엔티티에 그대로 적용해보기로 했다.

// Before: 가변 엔티티
@Entity
class GoodsOrder(
    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
    var items: MutableList<OrderItem> = mutableListOf(),

    @Enumerated(EnumType.STRING)
    var status: OrderStatus = OrderStatus.PENDING,

    var totalPrice: Int = 0,
    var totalDiscount: Int = 0,
    var finalPrice: Int = 0,

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0
) {
    fun addItem(item: OrderItem) {
        items.add(item)
        item.order = this
    }

    fun changeStatus(newStatus: OrderStatus) {
        if (!status.canTransitionTo(newStatus)) throw InvalidStatusException(...)
        this.status = newStatus
    }
}
// After: 불변 엔티티 시도
@Entity
data class GoodsOrder(
    @OneToMany(mappedBy = "order", cascade = [CascadeType.ALL])
    val items: List<OrderItem> = emptyList(),

    @Enumerated(EnumType.STRING)
    val status: OrderStatus = OrderStatus.PENDING,

    val totalPrice: Int = 0,
    val totalDiscount: Int = 0,
    val finalPrice: Int = 0,

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0
)

// 상태 변경은 copy()로 새 객체 반환
fun GoodsOrder.withItem(item: OrderItem): GoodsOrder =
    copy(items = items + item)

fun GoodsOrder.withStatus(newStatus: OrderStatus): GoodsOrder =
    copy(status = newStatus)

varval로 바뀌고, 상태 변경 메서드가 사라졌다. order.withItem(item).withCalculatedPrices()처럼 체이닝으로 의도를 표현할 수 있게 됐다. 상태 변경 순서에 대한 고민도 없다. 컴파일도 통과한다. 앱도 뜬다. INSERT도 된다.

그런데 UPDATE가 안 된다.

val updated = order.withStatus(OrderStatus.CONFIRMED)
orderRepository.save(updated)
// DB를 확인하면: status = PENDING. 안 바뀌었다.
// 예외? 없다. 에러 로그? 없다. 그냥 조용히 무시된다.

컬렉션은 더 심하다.

val order = orderRepository.findById(1L).get()
println(order.items.size)
// 출력: 0
// 실제로는 아이템 3개를 넣었다. 500 에러도 안 난다. 정상 응답에 빈 배열이 온다.

컴파일 에러가 아니라 runtime silent failure다. 이게 단순한 "안 됨"보다 훨씬 위험한 이유는, 테스트 없이는 발견할 수 없기 때문이다. 프로덕션에서 "주문했는데 상태가 안 바뀌어요" 이슈로 처음 발견될 수 있다.

왜 이런 일이 벌어지는가

Hibernate의 작동 원리 네 가지가 동시에 깨진다.

dirty checking: Hibernate는 트랜잭션이 끝날 때 엔티티 필드의 변경을 감지해서 UPDATE를 만든다. val이면 필드가 바뀔 수 없으니 Hibernate가 "변경 없음"으로 판단한다. UPDATE 쿼리 자체가 나가지 않는다.

copy()와 영속성 컨텍스트: copy()는 새 JVM 인스턴스를 만든다. Hibernate가 관리하고 있던 원래 인스턴스와는 완전히 별개의 객체다. save()를 호출해도 Hibernate는 이걸 "기존 엔티티의 갱신"이 아니라 "새 엔티티의 삽입"으로 처리하려 한다.

PersistentBag: @OneToMany로 선언된 컬렉션은 Hibernate가 자체 구현체인 PersistentBag으로 교체한다. 이 교체가 setter를 통해 일어나는데, val이면 setter가 없다. 결과적으로 Hibernate의 컬렉션이 주입되지 못하고, 생성자에서 설정한 emptyList()가 그대로 남는다.

data class의 equals/hashCode: data class는 모든 프로퍼티를 기반으로 equals()hashCode()를 생성한다. JPA는 엔티티의 동일성을 id 기반으로 관리하는데, status 하나만 바뀌어도 equals()가 false를 반환하면서 Hibernate의 1차 캐시 관리가 무너진다.

그런데 왜 컴파일은 되는가?

코틀린의 kotlin("plugin.jpa")가 no-arg constructor를 자동 생성하고, kotlin("plugin.spring")open 키워드를 자동으로 붙여준다. JPA가 요구하는 두 가지 조건이 컴파일 시점에 조용히 충족되기 때문에, data class + val 엔티티가 아무 경고 없이 빌드된다.

플러그인이 "이건 JPA 엔티티인데 data class로 선언하면 위험합니다" 같은 경고를 줄 수 있었을 텐데, 그런 경고는 없다. 컴파일이 되니까 동작할 거라고 생각하는 건 자연스러운 일이다.

판정: 우연적 복잡성

이 벽은 JPA/Hibernate의 설계 선택에서 비롯된다.

immutable collection을 지원하는 ORM은 존재한다. Exposed나 jOOQ에서는 valdata class를 자연스럽게 쓸 수 있다. Hibernate가 setter 기반으로 상태를 관리하는 건 Java 초기부터 이어져 온 설계 방식이지, 영속성이 반드시 이렇게 동작해야 하는 건 아니다.

다만, 여기에는 본질적 성분이 하나 섞여있다. "변경을 추적하려면 상태가 있어야 한다"는 원리 자체는 도구에 독립적이다. dirty checking이라는 구체적 방식은 우연적이지만, 변경 추적이 가변성을 전제한다는 점은 본질적이다. 이 구분은 섹션 4에서 다시 다룬다.

2. 벽을 돌아가다 — 도메인 모델 분리

JPA 엔티티를 immutable로 만들 수 없다면, 발상을 바꿔야 한다. 엔티티 자체에 FP를 적용하는 대신, FP를 위한 별도의 모델을 만들고 비즈니스 로직을 그쪽으로 옮기는 것이다.

JPA 엔티티는 그대로 둔다. var, class, MutableList — Hibernate가 원하는 대로. 대신 비즈니스 계산에 필요한 데이터만 뽑아서 불변 모델을 만든다.

/** 불변 도메인 모델 — JPA를 모른다 */
data class GoodsSnapshot(
    val id: Long,
    val name: String,
    val price: Int,
    val stock: Int,
    val categoryDiscountRate: Int
)

이 모델로 계산하는 pure function을 만든다. DB를 모르고, Spring을 모르고, 예외를 던지지 않는다.

/** Functional Core — pure function. 입력 → 출력만. */
object OrderCalculator {
    fun calculate(
        inputs: List<OrderItemInput>,
        goodsMap: Map<Long, GoodsSnapshot>,
        discountPolicy: (GoodsSnapshot, Int, Int) -> Int
    ): OrderResult {
        val aggregated = inputs.groupBy { it.goodsId }
            .map { (goodsId, group) -> OrderItemInput(goodsId, group.sumOf { it.quantity }) }

        for (input in aggregated) {
            val goods = goodsMap[input.goodsId]
                ?: return OrderResult.Failed(listOf(OrderError.GoodsNotFound(input.goodsId)))

            if (goods.stock < input.quantity)
                return OrderResult.Failed(listOf(OrderError.OutOfStock(goods.id, goods.stock, input.quantity)))

            val subtotal = goods.price * input.quantity
            val discount = discountPolicy(goods, input.quantity, subtotal)
            // ... 결과 조립
        }
        // ... 결과 조립 생략
        return OrderResult.Success(OrderCalculation(items, totalPrice, totalDiscount, finalPrice, stockDeductions))
    }
}

에러는 예외가 아니라 sealed class로 반환한다.

sealed class OrderError {
    data class OutOfStock(val goodsId: Long, val available: Int, val requested: Int) : OrderError()
    data class GoodsNotFound(val goodsId: Long) : OrderError()
}

sealed class OrderResult {
    data class Success(val calculation: OrderCalculation) : OrderResult()
    data class Failed(val errors: List<OrderError>) : OrderResult()
}

서비스는 IO만 담당한다. DB에서 엔티티를 꺼내고, 스냅샷으로 변환하고, Core를 호출하고, 결과에 따라 엔티티를 조립해서 저장한다.

/** Imperative Shell — IO 조율만 */
@Transactional
fun create(request: OrderRequest): OrderResponse {
    // 1. IO: DB에서 상품 조회
    val goodsEntities = request.items.map { it.goodsId }.distinct()
        .associateWith { goodsService.getGoods(it) }

    // 2. 변환: JPA 엔티티 → 불변 스냅샷
    val snapshots = goodsEntities.mapValues { (_, g) -> g.toSnapshot() }

    // 3. Core 호출: pure computation (DB 모름, 예외 안 던짐)
    val inputs = request.items.map { OrderItemInput(it.goodsId, it.quantity) }
    val result = OrderCalculator.calculate(inputs, snapshots, discountPolicy)

    // 4. 결과 처리: 실패면 예외, 성공이면 엔티티 조립 + 저장
    return when (result) {
        is OrderResult.Failed -> throw result.errors.first().toException()
        is OrderResult.Success -> {
            val calc = result.calculation
            for (deduction in calc.stockDeductions) {
                goodsEntities[deduction.goodsId]!!.applyStockDeduction(deduction.quantity)
            }
            val order = GoodsOrder()
            for (itemCalc in calc.items) {
                order.addItem(OrderItem(
                    goods = goodsEntities[itemCalc.goodsId]!!,
                    quantity = itemCalc.quantity,
                    unitPrice = itemCalc.unitPrice,
                    subtotal = itemCalc.subtotal,
                    discountAmount = itemCalc.discountAmount
                ))
            }
            order.totalPrice = calc.totalPrice
            order.totalDiscount = calc.totalDiscount
            order.finalPrice = calc.finalPrice
            OrderResponse.from(orderRepository.save(order))
        }
    }
}

이 구조에는 이름이 있다. Gary Bernhardt가 2012년 "Boundaries" 강연에서 발표한 Functional Core / Imperative Shell 패턴이다. pure function으로 계산하는 Core와, IO를 orchestrate하는 Shell을 분리한다.

이 구조에서 테스트는 어떻게 달라지는가

이 전환에서 가장 체감이 큰 부분이다.

// Before: mock 3개
val goodsService = mock<GoodsService>()
val discountService = mock<DiscountService>()
val orderRepository = mock<OrderRepository>()
whenever(goodsService.getGoods(1L)).thenReturn(goods)
whenever(discountService.calculateDiscount(any(), any(), any())).thenReturn(1000)
whenever(orderRepository.save(any())).thenAnswer { it.arguments[0] }
val service = OrderService(orderRepository, goodsService, discountService)
// 이제야 테스트를 시작할 수 있다


// After: mock 0개
val result = OrderCalculator.calculate(
    inputs = listOf(OrderItemInput(1L, 2)),
    goodsMap = mapOf(1L to GoodsSnapshot(1L, "티셔츠", 10000, 5, 10)),
    discountPolicy = { _, _, subtotal -> (subtotal * 0.10).toInt() }
)
assert(result is OrderResult.Success)
assertEquals(18000, (result as OrderResult.Success).calculation.finalPrice)
// 끝. DB 없다. 스프링 컨텍스트 없다. mock 없다.

DB가 필요 없으니 테스트가 밀리초 단위로 돌아간다. 같은 입력이면 항상 같은 출력이 나오니 결과가 결정적이다. "이 테스트가 왜 실패했지?"라는 고민이 사라진다 — 입력만 보면 된다.

대가: Shell이 뚱뚱해졌다

1회차 create()는 15줄이었다. FC/IS를 적용한 후 35줄이 됐다. 매핑 코드(엔티티 → 스냅샷, Core 결과 → 엔티티)가 늘어난 것이다.

그런데 이 "늘어난 코드"를 자세히 보면, 상당 부분이 1회차에서 암묵적이었던 것의 명시화다.

예를 들어, 같은 상품이 여러 번 들어올 때의 수량 합산. 1회차에서는 JPA 1차 캐시의 인스턴스 동일성에 의존해서 "우연히" 동작하고 있었다. 2회차에서는 Core가 groupBy로 명시적으로 합산한다. 코드 한 줄이 늘었지만, JPA 구현의 내부 동작에 의존하지 않게 됐다.

순수 매핑 코드(toSnapshot, 엔티티 조립)는 진짜 새로운 overhead다. 모델을 분리하면 반드시 따라오는 비용이다. 이 비용이 Core의 테스트 용이성을 상쇄하는가는 프로젝트 규모에 따라 다르다. 비즈니스 로직이 복잡할수록 Core의 이득이 커지고, 매핑의 비용은 일정하니, 규모가 커질수록 이득이 압도한다.

FC/IS 구조가 잡혔으니, 이제 Core에 넣을 수 있는 비즈니스 로직을 더 찾아볼 차례다. 재고 검증과 할인 정책 — 이 두 곳에서 FP 전환이 어떤 마찰을 일으키는지, 그리고 그 벽이 본질인지 우연인지를 확인한다.

3. Pure function의 영역을 넓히다

3-1. 재고 검증 — 본질적 긴장: encapsulation vs composition

1회차 코드에서 재고 차감은 Goods 엔티티의 메서드였다.

class Goods {
    fun decreaseStock(quantity: Int) {
        if (stock < quantity) throw OutOfStockException(
            "재고가 부족합니다. 현재 재고: $stock, 요청 수량: $quantity"
        )
        stock -= quantity
    }
}

이 메서드는 두 가지 일을 한다. validation(재고가 충분한가?)과 execution(재고를 줄여라). OOP 관점에서는 자연스럽다. 객체가 자기 invariant(stock >= 0)를 스스로 지키는 것이니까.

그런데 2회차에서는 이미 Core(OrderCalculator)에서 재고 검증을 하고 있다. Shell에서 decreaseStock()을 호출하면 같은 검증이 두 번 실행된다. 게다가 Core는 sealed class로 에러를 반환하는데, decreaseStock()은 예외를 던진다. 패턴이 충돌한다.

검증과 실행을 분리했다.

// Core: pure function. Int 두 개 넣으면 결과 나온다.
fun validateStock(stock: Int, quantity: Int): StockValidation =
    if (stock >= quantity) StockValidation.Sufficient
    else StockValidation.Insufficient(available = stock, requested = quantity)

// 엔티티: 검증 없이 차감만. Core가 검증을 보장한다.
class Goods {
    fun applyStockDeduction(quantity: Int) {
        stock -= quantity
    }
}

validateStock(10, 3)StockValidation.Sufficient를 반환한다. mock이 필요 없다. DB도 필요 없다. 함수 하나를 호출하면 끝이다.

side effect도 해소됐다. Goods 엔티티에서 OutOfStockException import가 사라졌다. 도메인 엔티티가 Spring의 HTTP 예외(HttpStatus를 참조하는 BusinessException)에 의존하던 문제가 끊어진 것이다.

그러나 여기에는 트레이드오프가 있다. applyStockDeduction()은 검증 없이 재고를 차감한다. 누군가 Core를 거치지 않고 이 메서드를 직접 호출하면 음수 재고가 될 수 있다. 1회차의 decreaseStock()은 그 자체로 안전했다. 호출자가 누구든 재고 부족이면 예외가 터졌다.

판정: 본질적 복잡성.

도구의 문제가 아니다. encapsulation(객체가 자기를 방어)과 composition(검증과 실행을 분리해서 조합)은 어떤 언어, 어떤 프레임워크에서든 존재하는 설계 trade-off다. encapsulation을 풀면 유연해지지만 방어막이 약해진다. composition을 하면 테스트가 쉬워지지만 convention에 의존하게 된다. FC/IS에서는 "Shell이 항상 Core를 먼저 호출하고, Core의 결과에 따라서만 실행한다"는 플로우 자체를 방어막으로 삼는다. 컴파일러가 강제하지는 않지만, 코드 리뷰에서 잡을 수 있는 수준이다.

3-2. 할인 정책 — 우연적 벽만 있는 곳

재고 검증에서 본질적 긴장을 경험한 직후, 할인 정책을 전환했다. 대비가 극명했다.

1회차의 할인 정책은 전략 패턴(Strategy Pattern)으로 구현되어 있었다.

interface DiscountPolicy {
    fun calculate(goods: Goods, quantity: Int, subtotal: Int): Int
}

@Component
class QuantityDiscountPolicy : DiscountPolicy {
    override fun calculate(goods: Goods, quantity: Int, subtotal: Int): Int =
        if (quantity >= 3) (subtotal * 0.10).toInt() else 0
}

@Component
class CategoryDiscountPolicy : DiscountPolicy {
    override fun calculate(goods: Goods, quantity: Int, subtotal: Int): Int {
        if (goods.category.discountRate <= 0) return 0
        return (subtotal * goods.category.discountRate / 100.0).toInt()
    }
}

@Service
class DiscountService(private val policies: List<DiscountPolicy>) {
    fun calculateDiscount(goods: Goods, quantity: Int, subtotal: Int): Int =
        policies.maxOfOrNull { it.calculate(goods, quantity, subtotal) } ?: 0
}

인터페이스 1개, 구현체 2개, 서비스 1개. 파일 2개. 클래스 4개.

그런데 DiscountPolicy 인터페이스를 보면, 메서드가 하나다. calculate() 하나. 이건 본질적으로 함수 타입 (Goods, Int, Int) -> Int과 같다.

typealias DiscountPolicy = (GoodsSnapshot, Int, Int) -> Int

val quantityDiscount: DiscountPolicy = { _, quantity, subtotal ->
    if (quantity >= 3) (subtotal * 0.10).toInt() else 0
}

val categoryDiscount: DiscountPolicy = { goods, _, subtotal ->
    if (goods.categoryDiscountRate <= 0) 0
    else (subtotal * goods.categoryDiscountRate / 100.0).toInt()
}

fun bestDiscount(policies: List<DiscountPolicy>): DiscountPolicy =
    { goods, quantity, subtotal ->
        policies.maxOfOrNull { it(goods, quantity, subtotal) } ?: 0
    }

val defaultDiscountPolicy = bestDiscount(listOf(quantityDiscount, categoryDiscount))

클래스 4개가 함수 3개 + 고차 함수 1개가 됐다. 파일 2개가 1개가 됐다. @Component@Service가 사라졌다. Goods 엔티티에 대한 의존도 사라졌다 — GoodsSnapshot을 받으니 Core에서 직접 쓸 수 있다.

Shell에 있던 어색한 브릿지 코드도 사라졌다. 1회차에서는 DiscountServiceGoods 엔티티를 받는데 Core는 GoodsSnapshot을 쓰기 때문에, Shell에서 함수 타입으로 래핑하는 변환 코드가 필요했다. 할인 정책 자체가 GoodsSnapshot 기반이 되면서 브릿지가 불필요해졌다.

이 전환에서 마찰이 거의 없었던 이유는 단순하다. 할인 정책에는 IO가 없다. DB를 읽지 않는다. state를 변경하지 않는다. 입력(상품 정보, 수량, 소계)을 받아서 출력(할인 금액)을 반환할 뿐이다. 본질적으로 pure computation이니, pure function으로 표현하는 데 아무 저항이 없다.

판정: 우연적 복잡성.

SAM(Single Abstract Method) 인터페이스를 interface + class + DI로 구현한 건 Spring의 convention에서 비롯된 것이다. Strategy Pattern의 본질은 "behavior를 교체 가능하게 만든다"이고, 이건 function type으로도 동일하게 달성할 수 있다. OOP에서는 interface로, FP에서는 function type으로 — 둘 다 "behavior를 값으로 다루기"라는 같은 본질에 도달하는 서로 다른 경로다.

단, 정책이 DB에서 할인율을 읽어야 한다면 이야기가 달라진다. IO가 필요해지면 pure function이 깨지고, Shell에서 값을 미리 조회해서 Core에 주입하는 구조가 필요해진다. 현재 구현에서는 할인율이 하드코딩이라 문제가 없지만, 이건 "운이 좋은" 케이스다.

두 전환의 대비에서 보이는 패턴

재고 검증은 본질적 긴장이 있었다. 할인 정책은 벽이 없었다. 차이가 뭔가?

"이 로직에 IO가 있는가?"

할인 정책: IO가 없다. pure computation이다. → FP 전환 마찰 제로. 재고 검증: 검증은 pure하지만, 차감은 IO(상태 변경)다. → 분리는 가능하지만 encapsulation과의 긴장. 재고 차감: IO다. 엔티티의 상태를 바꾼다. → FP로 만들 수 없다. Shell의 영역.

IO의 유무가 FP 전환의 난이도를 결정한다. IO가 없으면 벽이 없다. IO가 있으면 분리가 필요하고, 분리하면 본질적 트레이드오프를 마주하게 된다. 이것이 섹션 3의 핵심 발견이다.

4. 벽에 부딪히다 — 본질과 우연이 동시에 오는 구간

섹션 3까지는 "자연스러운 개선"의 영역이었다. 코틀린 기본 기능만으로, 스프링과 싸우지 않으면서 FP를 적용할 수 있었다. 여기서부터 달라진다.

4-1. cancel()은 왜 Core로 빠지지 않는가 — 본질적 벽

create()는 FC/IS로 깔끔하게 분리됐다. 그러면 cancel()도 같은 방식으로 할 수 있을까?

1회차의 cancel()은 이렇게 생겼다.

fun cancel() {
    changeStatus(OrderStatus.CANCELLED)
    items.forEach { it.goods.increaseStock(it.quantity) }
}

상태 전이 검증은 pure function으로 뺄 수 있었다.

fun validateStatusTransition(from: OrderStatus, to: OrderStatus): StatusTransitionResult =
    if (from.canTransitionTo(to)) StatusTransitionResult.Allowed(from, to)
    else StatusTransitionResult.Denied(from, to)

validateStatusTransition(PENDING, CANCELLED)은 pure function이다. mock도 DB도 필요 없다. 여기까지는 좋다.

문제는 재고 복원(items.forEach { it.goods.increaseStock(it.quantity) })이다. 이걸 Core에서 "명령서"로 만들어보려 했다. CancelCommand, StockRestoration, OrderItemSnapshot — 3개의 타입과 buildStockRestorations 함수를 만들었다.

그리고 전부 지웠다.

만들어도 Shell에서 결국 JPA 엔티티(items.forEach { it.goods })로 실행해야 하니, 명령서가 중간에 낀 번역 비용만 추가하는 셈이었다. Core가 "goodsId 1번의 재고를 2개 복원하라"는 명령서를 만들어도, Shell은 그 명령서를 해체해서 다시 JPA 관계 그래프를 탐색해야 한다. YAGNI 위반이다.

create()cancel()의 결정적 차이:

판정: 본질적 복잡성.

이건 JPA 때문이 아니다. Exposed를 쓰든, jOOQ를 쓰든, "이미 영속화된 데이터를 조작하는 유스케이스"에서는 pure function이 할 수 있는 게 검증뿐이고, 실행은 필연적으로 상태 변경이다. 섹션 1에서 예고했던 본질적 성분 — "변경을 추적하려면 상태가 있어야 한다"는 원리가 여기서 구체적으로 드러난다. dirty checking이라는 구체적 방식은 우연적이지만, "이미 있는 것을 바꾸는 행위 자체가 상태 변경을 전제한다"는 것은 본질적이다.

"새로 만드는 것"은 FP에 유리하고, "이미 있는 것을 바꾸는 것"은 OOP에 유리하다. 이건 도구의 문제가 아니라 도메인의 본성이다.

cancel()에서 본질적 벽을 확인한 뒤, 다른 방향으로 FP를 밀어봤다. 이번에는 에러 처리 — 예외를 완전히 제거하면 어떻게 되는가?

4-2. 예외를 제거하면 무엇을 잃는가 — 우연적 벽의 집중포화

Core는 sealed class로 에러를 반환하지만, Shell(서비스)에서 다시 예외로 변환해서 @ControllerAdvice에 맡기고 있었다. 이 변환을 제거해보기로 했다. 서비스가 Either<OrderError, OrderResponse>를 반환하고, 컨트롤러에서 직접 HTTP 매핑을 하는 구조다.

// Before: 서비스가 예외를 던지고, @ControllerAdvice가 글로벌 핸들링
@RestController
class OrderController {
    @PostMapping @ResponseStatus(HttpStatus.CREATED)
    fun create(@RequestBody request: OrderRequest): OrderResponse {
        return orderService.create(request)  // 한 줄
    }
}

// After: 서비스가 Either 반환, 컨트롤러가 fold
@RestController
class OrderController {
    @PostMapping
    fun create(@RequestBody request: OrderRequest): ResponseEntity<Any> {
        return orderService.create(request).fold(
            ifLeft = { it.toResponseEntity() },
            ifRight = { ResponseEntity.status(HttpStatus.CREATED).body(it) }
        )
    }
}

변경 후 잃은 것들이 쏟아졌다.

@ControllerAdvice의 글로벌 핸들링이 깨졌다. 1회차에서는 서비스가 예외를 던지면 @ControllerAdvice가 한 곳에서 HTTP 매핑을 처리했다. 컨트롤러는 비즈니스 로직을 호출하기만 하면 됐다. sealed class로 바꾸자 모든 컨트롤러 메서드에 fold 분기가 반복됐다.

ResponseEntity<Any>. 성공이면 OrderResponse, 실패면 ErrorResponse. 컴파일러는 Any만 보니 타입 안전성이 사라졌다. Swagger 문서 자동 생성도 깨진다.

프로젝트 내 일관성 파괴. 주문 도메인만 sealed class 기반이고, 상품/카테고리는 여전히 예외 기반이다. 한 프로젝트에 에러 처리 방식이 두 개 공존하는 셈이다.

여기서 아이러니한 발견이 있었다.

@ControllerAdvice는 "예외의 글로벌 핸들러"이자 "스프링이 이미 제공하고 있는 FP적 관심사 분리"였다.

1회차의 @ControllerAdvice가 하던 일을 다시 보면 — 에러 처리를 비즈니스 로직에서 분리해서 한 곳에 모아 처리한다. 이건 FP에서 말하는 "관심사 분리"를 AOP로 달성하고 있는 것이다. sealed class로 바꾸면 이 분리가 깨지고 에러 처리가 각 컨트롤러로 흩어진다.

FP가 항상 관심사 분리에 유리한 건 아니다. 프레임워크가 이미 제공하는 분리 메커니즘을 FP로 대체하면 오히려 관심사가 흩어질 수 있다.

판정: 우연적 복잡성. @ControllerAdvice가 예외 전용인 건 스프링의 설계 선택이다. Ktor의 StatusPages는 값 기반 에러도 처리한다. 그러나 스프링 안에서 이 벽을 넘으려면, 비용이 원래 문제의 비용보다 크다. 이게 우연적 복잡성의 핵심 특성이다 — "불가능"이 아니라 "비용 과다".

5. @Transactional의 함정 — 바이트코드를 까보다

섹션 4-2에서 예외를 제거하고 Either를 도입했다. 수제 sealed class(OrderResult)로 시작했지만, 서비스와 컨트롤러까지 값 기반 에러를 관통시키려면 결국 Arrow의 Either<OrderError, T>Raise DSL이 필요했다. 에러 전파를 raise()/bind()로 자동화하기 위해서다.

그런데 Either를 도입한 뒤, 한 가지 의문이 남았다. @Transactional 메서드가 Either.Left를 반환하면, 트랜잭션은 롤백되는가 커밋되는가?

결론부터 말하면 — 커밋된다. 이유를 알려면 Arrow의 내부를 열어봐야 한다.

Arrow Raise의 정체

Arrow의 either { } 블록 안에서 raise(error)를 호출하면, 내부적으로 예외가 던져진다.

raise(error)
  → throw NoTrace(error, raise)
      extends RaiseCancellationException
          extends CancellationException
              extends IllegalStateException
                  extends RuntimeException

RuntimeException의 자식이다. 스프링의 @TransactionalRuntimeException이면 롤백한다. 그러면 롤백되어야 하지 않은가?

실행 순서가 답이다

either { }는 코틀린의 inline fun이다. 메서드의 바이트코드 안에 인라인된다. 내부적으로는 평범한 try-catch다.

@Transactional AOP 프록시 (바깥)
  └─ 실제 메서드 호출
      └─ either { } 의 try-catch (안쪽)
          └─ raise(error)
          └─ → throw NoTrace
          └─ catch (e: RaiseCancellationException) → Either.Left(error) 반환
      └─ 메서드가 Either.Left를 정상 반환
  └─ 프록시가 보는 것: 예외 없이 정상 반환됨 → commit

either { }try-catch@Transactional 프록시보다 안쪽에 있다. raise()가 던진 예외는 either가 먼저 잡아서 Either.Left로 변환한다. 프록시는 예외를 볼 기회가 없다. 정상 반환으로 인식하고 커밋한다.

왜 이게 위험한가

현재 코드는 안전하다. FC/IS 원칙에 따라 Core 호출(pure computation)이 IO(재고 차감) 이전에 실행되기 때문이다. 계산이 실패하면 IO에 도달하지 않고 Either.Left로 빠져나간다.

하지만 순서가 바뀌면?

@Transactional
fun dangerousCreate(request: OrderRequest): Either<OrderError, OrderResponse> = either {
    // IO: 재고를 먼저 차감했다
    goodsEntities[goodsId]!!.applyStockDeduction(quantity)

    // Core: 그 다음에 계산을 한다
    val calc = OrderCalculator.calculateEither(inputs, snapshots, policy).bind()
    //                                                                   ^^^^
    // bind()가 Left면 raise() → 예외 → either가 catch → Either.Left 반환
    // @Transactional은 커밋
    // → 재고는 줄었는데 주문은 생성되지 않음. 데이터 정합성 파괴.
}

예외 기반이었다면 이런 문제가 없다. throw하면 @Transactional이 잡아서 롤백한다. IO 순서와 무관하게 안전하다.

Either를 쓰면 예외가 제공하던 안전망(자동 롤백)이 사라진다. 그리고 이건 Arrow의 문제가 아니라, 값 기반 에러 처리와 예외 기반 트랜잭션 관리가 만나는 지점에서 발생하는 구조적 간극이다.

Spring 팀의 공식 입장

Spring Framework #27323 — "Result return types for @Transactional Kotlin functions" → declined.

Spring 팀은 ResultEither 같은 값 기반 에러 패턴을 @Transactional에서 인식하도록 지원할 계획이 없다고 밝혔다. 트랜잭션 롤백은 예외 기반으로만 동작한다.

판정: 우연적 복잡성이다. 값 기반 롤백은 기술적으로 구현 가능하다. Spring 팀이 하지 않기로 결정한 것이다.

그러나 이 우연적 복잡성은 단순하지 않다. @Transactional의 예외 기반 롤백, @ControllerAdvice의 예외 기반 핸들링, ResponseEntity의 타입 시스템 — 이것들이 전부 "예외로 에러를 표현한다"는 하나의 세계관 위에 서있다. 개별 컴포넌트의 설계 선택이 아니라 프레임워크의 철학이다.

우연적 복잡성도 충분히 쌓이면 본질적처럼 느껴진다. 하나의 벽은 넘을 수 있지만, 벽이 프레임워크의 모든 레이어에 걸쳐 있으면 "프레임워크를 바꾸는 것"에 가까운 비용이 든다. 스프링 안에서 FP의 에러 처리를 밀어넣는 건, 결국 스프링의 세계관과 싸우는 일이다.

6. 벽의 지도 — 어디서 싸우고 어디서 돌아가는가

여정을 정리한다.

판별 결과 종합

전환 벽의 종류 대응
JPA 엔티티 불변화 (섹션 1) 우연적 돌아감 — 도메인 모델 분리
FC/IS 도입 (섹션 2) 우연적 벽 우회를 위한 구조 설계
재고 검증 분리 (섹션 3-1) 본질적 타협 — 플로우로 안전 보장
할인 정책 전환 (섹션 3-2) 우연적 해소 — pure computation이라 벽 자체가 없음
cancel() Core 분리 (섹션 4-1) 본질적 타협 — 검증만 Core, 실행은 Shell
예외 제거 (섹션 4-2) 우연적 돌아감 — 비용이 이득을 초과
@Transactional + Either (섹션 5) 우연적 돌아감 — 안전망 상실 위험

여기서 패턴이 보인다.

pure computation 영역(할인 정책, 재고 검증, 주문 가격 계산)에서는 FP 전환이 자연스럽고 이득이 컸다. accidental wall은 쉽게 넘거나 돌아갔고, essential wall은 타협 가능한 수준이었다.

프레임워크 infrastructure 영역(@Transactional, @ControllerAdvice, ResponseEntity)에서는 accidental wall이 한꺼번에 터졌다. 개별로는 넘을 수 있었지만, 전부 "예외 기반"이라는 하나의 세계관에 묶여있어서, 하나를 바꾸면 전체가 흔들렸다.

최적 멈춤 지점

손익분기선은 "pure computation"과 "framework infrastructure"의 경계에 있다. pure computation 영역까지는 밀어도 괜찮다. 그 너머는 프레임워크의 세계관과 싸우게 된다.

실무 구조: "Core에서 FP, Shell에서 OOP, 경계에서 변환"

// ✅ Core: pure function. Either로 에러 표현. mock 0개 테스트 가능.
object OrderCalculator {
    fun calculate(
        inputs: List<OrderItemInput>,
        goodsMap: Map<Long, GoodsSnapshot>,
        discountPolicy: DiscountPolicy
    ): Either<OrderError, OrderCalculation> = either {
        // pure computation only. DB 모름. 예외 안 던짐.
    }
}

// ✅ 도메인 모델: 불변. JPA 무관.
data class GoodsSnapshot(val id: Long, val price: Int, val stock: Int, ...)

// ✅ 할인 정책: 함수 타입 + 고차 함수.
typealias DiscountPolicy = (GoodsSnapshot, Int, Int) -> Int
val defaultPolicy = bestDiscount(listOf(quantityDiscount, categoryDiscount))

// ⚠️ Shell: 스프링 관례 유지. Core의 Either → 예외로 변환.
@Service
class OrderService(...) {
    @Transactional
    fun create(request: OrderRequest): OrderResponse {
        val calc = OrderCalculator.calculate(inputs, snapshots, defaultPolicy)
            .getOrElse { error -> throw error.toException() }
        // ↑ 경계에서 변환: Either → 예외. @Transactional 롤백 안전.
        // IO: 재고 차감, 엔티티 조립, 저장
        ...
    }
}

// ✅ 컨트롤러: 단순 위임. @ControllerAdvice가 에러 처리.
@RestController
class OrderController(private val orderService: OrderService) {
    @PostMapping @ResponseStatus(HttpStatus.CREATED)
    fun create(@RequestBody request: OrderRequest): OrderResponse =
        orderService.create(request)
}

이 구조가 하는 것을 정리하면 이렇다.

  • Core: FP를 자유롭게 쓴다. Either, sealed class, pure function — 벽이 없으니 마음껏.
  • Shell: 스프링 관례를 존중한다. @Service, @Transactional, 예외 기반 에러 — 프레임워크와 싸우지 않는다.
  • 경계: Either를 예외로 변환한다. getOrElse { throw it.toException() } — 이 한 줄이 두 세계를 연결한다.

@Transactional이 롤백을 보장하고, @ControllerAdvice가 에러를 글로벌하게 처리하고, 컨트롤러는 한 줄로 남는다. 프레임워크가 제공하는 안전망을 그대로 활용하면서, Core의 테스트 용이성은 확보된다.

7. 결론

이 글을 관통하는 질문은 하나였다. "이 벽은 본질적인가, 우연적인가?"

essential wall은 타협의 대상이다. encapsulation을 풀면 유연해지지만 방어막이 약해진다. 생성은 FP에 유리하지만 변경은 OOP에 유리하다. 이건 어떤 도구를 써도 남는 trade-off이고, 완벽한 해결이 아니라 의식적 설계가 필요하다.

우연적 벽은 선택의 대상이다. JPA의 PersistentBagval을 거부하는 건 넘을 수 있다 — 도메인 모델을 분리하면 된다. @ControllerAdvice가 예외 전용인 건 넘을 수 있다 — 그러나 비용이 이득을 초과하면 돌아가는 게 낫다.

극한까지 밀어봐야 이 판별이 가능해진다. 밀어보기 전에는 "이론적으로 FC/IS가 좋다"에서 멈추게 된다. 밀어봤기 때문에 @Transactionalraise()를 어떻게 처리하는지, @ControllerAdvice가 이미 관심사 분리를 제공하고 있었다는 것을 발견할 수 있었다.

마지막으로 하나. FP vs OOP라는 프레이밍 자체가 정확하지 않다는 생각이 들었다. 더 정확한 변수는 pure vs impure다.

  • IO가 없는 pure computation → paradigm에 관계없이 테스트가 쉽고 추론이 쉽다.
  • IO가 있는 impure code → paradigm에 관계없이 mock이 필요하고 복잡도가 올라간다.

FC/IS가 하는 일의 본질은 "FP를 도입하는 것"이 아니라 "pure한 것과 impure한 것을 구조적으로 분리하는 것"이다. 그리고 그 분리의 경계가, 스프링에서 FP가 살 수 있는 자리다.

이 글이 다룬 범위는 단일 서비스 내부의 계층 설계다. 서비스 간 통신에서의 에러 전파(gRPC의 Status, 이벤트 기반 아키텍처에서의 실패 처리 등)나, 분산 환경에서의 트랜잭션 관리는 또 다른 차원의 문제이고, 본질/우연의 구분도 달라질 수 있다. 이 부분은 별도의 주제로 다뤄져야 한다.

"There is no single development, in either technology or management technique, which by itself promises even one order of magnitude improvement within a decade in productivity, in reliability, in simplicity." — Fred Brooks, "No Silver Bullet" (1986)

40년이 지났다. FP도 은탄환이 아니다. 그러나 벽의 종류를 알면, 어디서 싸우고 어디서 돌아갈지는 판단할 수 있다.