본문 바로가기
Study for./Engineering Culture

SQL에 월세 내던 비즈니스 규칙, 도메인으로 이사했습니다

by harrykim 2026. 2. 27.

 

부제: 오늘, 비즈니스 규칙이 SQL에서 탈출한 비율이 0.03퍼센트 늘었습니다


"오늘 전 세계 극심한 빈곤 상태에 있는 사람의 수가 0.01퍼센트 줄었습니다!"

이 문장을 뉴스 속보로 내보내는 방송국은 없을 것이다.
하지만 이 0.01퍼센트가 매일 쌓이면, 10년 뒤에는 수억 명의 삶이 달라진다.

최근에 코드를 다루면서 비슷한 감각을 느끼고 있다.
하루하루의 변화는 눈에 보이지 않지만,
그 변화들이 모두 하나의 질문을 향해 쌓이고 있다는 감각이다.

비즈니스 규칙은 어디에 살아야 하는가?

서울 전셋값만큼 어려운 질문은 아니지만,
잘못된 곳에 살게 두면 매달 월세를 내야 한다는 점에서는 비슷하다.
쉬는 날 오후의 장애 대응, SQL과 Java를 오가며 원인을 추적하는 디버깅,
"이걸 수정하면 다른 데 영향 없나요?"라는 질문에 확신 없이 "아마요"라고 대답하는 순간들.
비즈니스 규칙이 SQL에 살고 있는 한, 이 월세는 계속된다.


 

개쩐다.. 난 언제쯤..

 


오후 6시. 소파에 누워 멍하니 천장을 보고 있었다.
유튜브 대신 천장을 보고 있었던 건, 딱히 볼 게 없어서였다.
손목 위의 워치가 짧게 진동했다. 슬랙 알림이었다.

 "연휴에 죄송합니다"

괜찮겠지, 싶어 눈을 감았다.
5초 뒤, 워치가 다시 진동했다.

"... 충전중인데 크루요금제 적용이 안되는듯하네요 일반요금으로 결제가 됩니다. 월요일이 되면 확인한번 부탁드립니다."

소파에서 일어나 노트북을 열었다.
자율 출근도, 사내 카페도, 여유로운 저녁도 없었다. 쉬는 날 오후 6시의 슬랙 채널이 있을 뿐이었다.
채널에는 이미 스크린샷이 올라와 있었다.
결제 완료 화면. 할인 금액: 0원.

원인을 추적하기 시작했다.

결제 시스템에서 멤버십 할인을 적용하려면,
먼저 해당 사용자의 멤버십 유형을 확인해야 한다.
그런데 이 유형을 확인하는 방식이 문제였다.

데이터베이스에는 멤버십 유형이 짧은 코드로 저장되어 있었다.
"M01", "M02", "M03" 같은 코드들이 있었고,
SQL 쿼리에서는 이 코드들을 IN 절로 조회하고 있었다.

WHERE type_code IN ('M01', 'M02', 'M03', ...)



여기까지는 문제가 없어 보인다.
그런데 이 쿼리의 결과를 받아서 처리하는 Java 코드에서, 조건을 한 번 더 뒤집고 있었다.
NOT 조건으로 결과를 반전시켜서, 마치 NOT IN으로 조회한 것과 같은 효과를 만들어놓은 것이다.

즉, SQL에서는 "이 코드들에 해당하는 데이터를 가져온다"고 해놓고,
Java에서는 "가져온 데이터 중에서 이 조건이 아닌 것만 사용한다"고 한 번 더 걸러내고 있었다.

이 구조에서 새로운 멤버십 유형에 해당하는 코드 "M10"이 데이터베이스에 추가되었다.
"M10"은 SQL의 IN 절 목록에 포함되어 있지 않았기 때문에 쿼리 결과에서 빠졌다.
그리고 Java의 NOT 조건까지 거치면서, 이 멤버십 사용자는 할인 대상에서 완전히 제외되었다.

문제의 핵심은 이것이었다.
M10"이라는 코드가 무엇을 의미하는지, 이 코드가 할인 대상에 포함되어야 하는지 아닌지를,
코드를 읽는 것만으로는 알 수 없었다.

SQL의 IN 절에 나열된 "M01", "M02", "M03"이 각각 어떤 멤버십 유형인지, 왜 이 목록에 있는 것들만 조회하는지, 그리고 Java에서 왜 그 결과를 다시 뒤집는지, 이 세 가지를 동시에 이해해야만 전체 흐름을 파악할 수 있었다.

누군가가 "M10"이라는 새 코드를 데이터베이스에 추가할 때, SQL의 IN 절과 Java의 NOT 조건까지 함께 수정해야 한다는 사실을어디에도 기록되어 있지 않았고, 코드만 봐서는 추론하기도 어려웠다.

SQL을 열었다. IN 절을 찾았다. "M10"이 없었다. Java 코드를 열었다. NOT 조건이 걸려 있었다. 그래서 빠진 거였다.
IN 절에 "M10"을 추가하고, Java의 조건문을 확인하고, 슬랙에 응대 완료를 남겼다.

노트북을 덮었다. 시계를 봤다. 7시 반. 기술적으로는 코드 한 줄을 추가한 것에 불과했다. 하지만 그 한 줄을 찾기까지 SQL 쿼리와 Java 로직을 오가며 추적해야 했고, "이 코드가 왜 IN 절에 없는지"를 확인하려면 데이터베이스의 코드 테이블까지 들여다봐야 했다.

이 경험 이후, 한 가지 확신이 생겼다." 비즈니스 규칙이 SQL과 Java 로직 사이에 흩어져 있으면,  새로운 변경이 기존 흐름에 어떤 영향을 주는지 아무도 확신할 수 없다. "

쉬는 날 오후 한 시간 반.
기술적으로는 IN 절에 코드 하나를 추가한 것에 불과했지만,
그 한 줄을 찾기 위해 치른 비용 SQL 추적, Java 로직 역추적, 데이터베이스 코드 테이블 확인은 코드 한 줄의 무게가 아니었다.
이것이 비즈니스 규칙을 SQL에 두고 있는 대가로 매달 내는 월세였다.

"오늘 이벤트 하나를 추가했는데, 기존 결제 흐름에 영향을 주지 않는다고 확신할 수 있는 비율이 0퍼센트입니다."

이것이 내가 마주한 현실이었다. 이 월세를 끊으려면, 비즈니스 규칙이 사는 곳을 바꿔야 했다.




JOIN을 분리한다는 것의 의미

이사를 결심했다. 내가 아닌 비즈니스 로직을

결제 프로그램의 JOIN 분리 작업을 시작했다. Java와 MyBatis로 작성된 기존 구조를 Kotlin과 JPA, QueryDSL 구조로 전환하면서, SQL에 월세를 내고 있던 비즈니스 규칙을 서비스 계층의 코드로 끌어올리는 작업이다.

이 작업의 핵심은 단순히 기술 스택을 바꾸는 것이 아니었다.

멤버십 할인 사례로 돌아가보자.
만약 멤버십 할인 적용 조건이 SQL의 IN 절과 Java의 NOT 조건에 나뉘어 있지 않고,
하나의 서비스 메서드 안에 명시적으로 작성되어 있었다면 어떻게 달라졌을까?

예를 들어, 이런 코드가 있었다면

fun isDiscountEligible(membershipCode: String): Boolean {
    return membershipCode in ELIGIBLE_MEMBERSHIP_CODES
}



새로운 멤버십 코드 "M10"이 추가될 때,
이 메서드의 `ELIGIBLE_MEMBERSHIP_CODES` 목록에 "M10"을 추가하기만 하면 된다.
SQL의 IN 절을 확인하고, Java의 NOT 조건을 확인하고, 두 개의 흐름이 합쳐졌을 때
최종 결과가 어떻게 되는지 머릿속으로 조합할 필요가 없다.

그리고 이 메서드에 대해 이런 테스트를 작성할 수 있다

@Test
fun newMembership_M10_isEligibleForDiscount() {
    assertThat(isDiscountEligible("M10")).isTrue()
}



이 테스트가 존재했다면, "M10"을 추가하지 않은 상태에서 테스트가 실패했을 것이고,
쉬는 날 오후 6시까지 기다릴 필요 없이 배포 전에 문제를 발견했을 것이다.

JOIN 분리 작업은 이런 변화를 만들어내는 것이다.
SQL에서 "이 이벤트가 적용되는 조건"을 표현하던 CASE WHEN 구문을,
Kotlin 코드의 명시적인 메서드로 옮기는 것이다.

이렇게 하면 무엇이 달라지는가?

첫째, 이벤트의 적용 조건이 코드로 존재하므로, IDE에서 이 메서드를 호출하는 곳을 검색할 수 있다.
"M10" 코드를 추가했을 때 영향을 받는 곳이 어디인지, SQL을 하나하나 읽지 않고도 파악할 수 있게 된다.

둘째, 코드로 존재하므로 테스트를 작성할 수 있다. 멤버십 코드 M10이 할인 대상에 포함되는가?를
자동화된 테스트로 검증할 수 있게 된다. 더 나아가 "이벤트 A와 이벤트 B가 동시에 적용될 때 할인 금액이 올바른가?"처럼,
여러 조건이 조합되는 복잡한 시나리오도 테스트로 확인할 수 있다.

셋째, 더 이상 사용되지 않는 이벤트 코드나 메서드를 발견할 수 있다. SQL 안에 있을 때는 "이 IN 절의 코드 목록이 아직 유효한 건지" 알기 어려웠지만, 코드로 옮기고 나면 IDE가 "이 메서드는 아무 데서도 호출되지 않습니다"라고 알려준다.

"오늘, 비즈니스 규칙의 영향 범위를 IDE로 추적할 수 있게 된 비율이 0.5퍼센트 늘었습니다."

속보로 나올 만한 소식은 아니다. 하지만 이 0.5퍼센트가 쌓이면, 어느 날 갑자기 "이 이벤트를 수정해도 안전한가?"라는 질문에 자신 있게 "네"라고 대답할 수 있게 된다.
월세가 한 달치 줄어드는 셈이다.




규칙이 한 곳에만 살면, 한 곳만 고치면 된다

이 JOIN 분리 작업을 하면서, 이커머스 도메인 모델링루퍼스을 공부할 기회가 있었다. 상품, 브랜드, 좋아요, 주문으로 구성된 이커머스 플랫폼의 도메인을 설계하고 구현하는 작업이었다.

그 과정에서 상품의 재고를 차감하는 로직을 어디에 둘 것인지 결정해야 했다.

선택지는 두 가지였다.

선택지 A. 주문을 처리하는 서비스에서 직접 재고를 차감한다.

이 방식의 문제는, 재고를 차감하는 곳이 여러 군데가 될 수 있다는 것이다. 주문 서비스에서도 차감하고, 관리자 페이지에서도 차감하고, 반품 처리에서도 차감한다면, "재고가 0 미만이 되면 안 된다"는 규칙을 세 곳에서 각각 검증해야 한다. 멤버십 할인 사례에서 "이 코드가 할인 대상인가?"라는 판단이 SQL의 IN 절과 Java의 NOT 조건에 나뉘어 있었던 것과 같은 구조다.

선택지 B. 상품 객체 자체가 재고 차감 메서드를 가지고, 그 안에서 검증한다.

fun decreaseStock(quantity: Int) {
    require(quantity > 0) { "차감 수량은 1 이상이어야 합니다." }
    if (this.stockQuantity < quantity) {
        throw CoreException(
            ErrorType.BAD_REQUEST,
            "상품의 재고가 부족합니다. " +
                "(상품명: $name, 요청 수량: ${quantity}개, 현재 재고: ${stockQuantity}개)",
        )
    }
    this.stockQuantity -= quantity
}



이 방식에서는 재고 차감을 누가 호출하든, 검증 로직은 상품 객체 안에 한 번만 존재한다. 주문 서비스든, 관리자 페이지든, 반품 처리든, 모두 이 메서드를 호출하기만 하면 된다. "재고가 부족하면 주문을 거부한다"는 규칙이 한 곳에만 살고 있으므로, 이 규칙을 수정해야 할 때 한 곳만 고치면 된다.

멤버십의 할인 적용 조건이 SQL과 Java 사이에 흩어져 있던 것을
하나의 메서드로 모으는 작업과 본질적으로 같은 일이었다.




하나의 쿼리가 너무 많은 관심사를 담고 있을 때

이커머스 도메인을 설계하면서 또 하나의 결정이 필요했다.

사용자가 상품에 "좋아요"를 누르는 기능이 있는데, 이 좋아요를 상품의 한 속성(예를 들어, 좋아요를 누른 사용자 ID 목록)으로 관리할 것인지, 아니면 좋아요 자체를 독립된 개념으로 분리할 것인지 결정해야 했다.

만약 좋아요를 상품의 속성으로 관리하면, 상품을 조회할 때마다 좋아요를 누른 모든 사용자의 ID가 함께 조회된다. 상품 정보만 필요한 상황에서도 불필요한 데이터가 따라온다. 그리고 "이 사용자가 좋아요를 누른 상품 목록"을 조회하려면, 모든 상품의 좋아요 목록을 뒤져야 한다.

결제 프로그램에서 하나의 SQL에 너무 많은 테이블을 JOIN하면서 겪었던 문제와 비슷했다. 하나의 쿼리가 너무 많은 관심사를 담고 있으면, 그중 하나를 수정할 때 나머지에 영향을 줄 수 있다.

그래서 좋아요를 독립된 도메인으로 분리했다. 좋아요는 자신만의 생명주기(등록, 취소, 복원)를 가지고, 좋아요 수는 상품 객체가 별도로 관리한다.

// 좋아요 등록 시: LikeService가 멱등성을 보장하고,
// 실제로 새 좋아요가 등록된 경우에만 상품의 좋아요 수를 증가시킨다.
fun likeProduct(userId: Long, productId: Long) {
    productService.findById(productId)  // 상품이 존재하는지 확인
    val isNewLike = likeService.like(userId, productId)
    if (isNewLike) {
        productService.incrementLikesCount(productId)
    }
}



여기서 "멱등성을 보장한다"는 것은, 같은 사용자가 같은 상품에 좋아요를 여러 번 눌러도 좋아요 수가 한 번만 증가한다는 뜻이다.
이미 좋아요를 누른 상태에서 다시 누르면, 아무 일도 일어나지 않는다. 이전에 좋아요를 취소한 적이 있다면, 새로 만드는 대신 취소된 기록을 복원한다.

이렇게 분리하면, 좋아요와 관련된 규칙을 수정할 때 상품 코드를 건드릴 필요가 없다.




시간이 지나도 변하지 않아야 하는 데이터

주문 기능을 구현하면서 한 가지 더 고민이 있었다.

사용자가 "감성 티셔츠"라는 상품을 25,000원에 주문했다고 하자. 일주일 뒤에 이 상품의 이름이 "감성 오버핏 티셔츠"로 바뀌고, 가격이 29,000원으로 올랐다면, 이 사용자의 주문 내역에는 어떤 정보가 보여야 할까?

당연히 주문 당시의 정보인 "감성 티셔츠, 25,000원"이 보여야 한다. 하지만 주문 데이터에 상품 ID만 저장하고, 조회할 때마다 상품 테이블에서 가져온다면, 주문 내역에는 "감성 오버핏 티셔츠, 29,000원"이 표시될 것이다.

이것은 실제 이커머스 서비스에서 빈번하게 발생하는 문제다.
해결 방법은 주문을 생성하는 시점에 상품의 이름, 가격, 브랜드명을 주문 항목에 복사해두는 것이다.

// 주문을 생성할 때, 상품의 현재 정보를 주문 항목에 스냅샷으로 저장한다.
val item = OrderItemModel(
    order = order,
    productId = product.id,
    productName = product.name,                    // 지금 이 순간의 상품 이름
    brandName = brandNameResolver(product.brandId), // 지금 이 순간의 브랜드 이름
    price = product.price,                          // 지금 이 순간의 가격
    quantity = quantity,
)



이렇게 하면 나중에 상품 정보가 아무리 바뀌어도, 주문 내역은 주문 당시 그대로 보존된다.
상품이 삭제되더라도 주문 이력은 사라지지 않는다.

이 설계를 고민할 때 결제 프로그램에서의 경험이 떠올랐다. 이벤트 설정값이 데이터베이스에만 존재할 때, 과거에 적용된 이벤트의 조건을 나중에 확인하려면 그 시점의 데이터가 남아 있어야 했다. 하지만 이벤트 설정이 변경되면 과거 기록을 추적하기 어려웠다.주문 스냅샷은 이 문제를 주문 도메인에서 미리 해결하는 방법이다.




규칙이 코드로 올라오면 테스트할 수 있다

비즈니스 규칙이 SQL 안에 있을 때는 테스트하기가 매우 어려웠다. "이 SQL이 올바른 결과를 반환하는가?"를 확인하려면, 실제 데이터베이스에 테스트 데이터를 넣고 쿼리를 실행해봐야 했다.

하지만 규칙이 코드로 옮겨지면, 데이터베이스 없이도 테스트할 수 있다.

@DisplayName("재고가 부족하면 예외가 발생한다")
@Test
fun throwsException_whenInsufficientStock() {
    // 준비: 재고가 3개인 상품을 만든다
    val product = ProductModel(
        name = "감성 티셔츠",
        price = 25000L,
        brandId = 1L,
        stockQuantity = 3,
    )

    // 실행 및 검증: 5개를 차감하려고 하면 예외가 발생해야 한다
    assertThatThrownBy {
        product.decreaseStock(5)
    }
        .isInstanceOf(CoreException::class.java)
        .hasMessageContaining("재고가 부족합니다")
}



이 테스트는 데이터베이스가 없어도 실행된다.
상품 객체를 만들고, 재고를 차감하려고 시도하고, 예외가 발생하는지 확인하는 것이 전부다.

결제 프로그램에서 SQL에 묻혀 있던 이벤트 조건을 코드로 옮기면서 가장 크게 체감한 변화가 바로 이것이다. "이 규칙이 올바르게 동작하는가?"를 매번 데이터베이스에 데이터를 넣어보지 않고도, 코드 한 줄로 확인할 수 있게 된 것이다.

멤버십 할인 사례에서 `isDiscountEligible("M10")` 테스트가 있었다면 배포 전에 문제를 잡았을 것이라고 이야기했는데,
이커머스 도메인에서도 같은 원리가 적용된다. 'product.decreaseStock(5)' 테스트가 있으니, 재고 차감 규칙을 수정하더라도 기존 동작이 깨지는지 바로 확인할 수 있다.




조합하는 역할과 판단하는 역할을 분리한다

상품, 좋아요, 주문이 각각 독립된 도메인으로 분리되면,
이들을 연결해서 하나의 기능을 완성하는 역할이 필요하다.

예를 들어, 주문을 생성하려면 다음과 같은 단계를 거쳐야 한다.

1. 주문에 포함된 상품 ID들에 중복이 없는지 확인한다.
2. 상품 ID들로 상품 정보를 일괄 조회한다.
3. 상품들의 브랜드 ID를 모아서 브랜드 정보를 일괄 조회한다.
4. 주문 서비스에 주문 생성을 요청한다. (이 안에서 재고 차감과 스냅샷 저장이 이루어진다.)

이 흐름을 조율하는 것이 응용 계층(Application Layer)의 역할이다. 응용 계층은 비즈니스 규칙을 직접 실행하지 않는다. "재고가 부족한가?"를 판단하지 않고, 상품 객체에게 재고 차감을 요청할 뿐이다. "좋아요 수를 올려야 하는가?"를 판단하지 않고, 좋아요 서비스의 반환값을 확인할 뿐이다.

fun createOrder(userId: Long, items: List): OrderInfo {
    // 1. 중복 상품 검증
    validateNoDuplicateProducts(items)

    // 2. 상품 일괄 조회
    val productIds = items.map { it.productId }
    val productMap = productService.findAllByIds(productIds).associateBy { it.id }

    // 3. 브랜드 일괄 조회 (상품마다 개별 조회하지 않고, 한 번에 모아서 조회한다)
    val brandIds = productMap.values.map { it.brandId }.distinct()
    val brandMap = brandService.findAllByIds(brandIds).associateBy { it.id }

    // 4. 주문 생성 (재고 차감과 스냅샷은 OrderService 내부에서 처리된다)
    val orderItems = items.map { item ->
        val product = productMap[item.productId]
            ?: throw CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")
        product to item.quantity
    }

    val order = orderService.createOrder(
        userId = userId,
        orderItems = orderItems,
        brandNameResolver = { brandId ->
            brandMap[brandId]?.name
                ?: throw CoreException(ErrorType.NOT_FOUND, "존재하지 않는 브랜드입니다.")
        },
    )

    return OrderInfo.from(order)
}



이 코드에서 응용 계층은 "무엇을 어떤 순서로 조합하는가"만 결정한다.
"재고가 부족하면 어떻게 할 것인가"는 상품 객체가 결정한다.
"좋아요가 신규인지 중복인지"는 좋아요 서비스가 결정한다.

결제 프로그램에서 SQL JOIN이 "무엇을 조합하는가"와 "어떤 규칙을 적용하는가"를 동시에 처리하고 있었다.
멤버십 할인 사례에서도 SQL의 IN 절이 "데이터를 조회하는 역할"과 "할인 대상을 판단하는 역할"을 동시에 맡고 있었고, Java의 NOT 조건이 그 판단을 다시 뒤집고 있었다. 코드로 옮긴 뒤에는 조합하는 역할과 규칙을 적용하는 역할이 분리된다.

 




경계를 다시 그리면 보이지 않던 것이 보인다

JOIN 분리 작업을 하면서 예상하지 못한 수확이 있었다.

SQL 안에 있을 때는 보이지 않던 것들이, 코드로 옮기면 드러났다.

앞서 이야기한 "M10" 코드처럼, 데이터베이스에는 존재하지만 어디서 어떻게 사용되는지 명확하지 않은 코드 값들이 있었다.
JOIN을 분리하면서 각 코드 값의 의미를 추적하다 보니, 더 이상 사용되지 않는 이벤트 코드가 발견되었다. 어떤 코드는 과거에 한시적으로 운영된 프로모션을 위해 만들어졌다가, 프로모션이 끝난 뒤에도 SQL의 IN 절 안에 그대로 남아 있었다.

임시로 작성되었다가 그대로 남아 있는 로직도 있었다. "나중에 정리하겠다"고 메모만 남긴 채 몇 년이 지난 코드였다. SQL 안에 묻혀 있을 때는 이런 코드의 존재 자체를 알기 어려웠지만, 서비스 계층의 메서드로 옮기고 나니 "이 메서드는 어디서도 호출되지 않는다"는 것이 바로 보였다.

가장 당황스러웠던 발견은, 두 곳에서 같은 규칙을 다른 방식으로 구현하고 있는 경우였다. 멤버십 할인 장애의 근본 원인도 이것과 같은 맥락이었다. SQL에서 한 번 걸러내고, Java에서 다시 한번 뒤집어서 걸러내는 구조. 같은 판단을 두 곳에서 서로 다른 방식으로 하고 있었기 때문에, 한쪽만 수정하면 다른 쪽과 어긋나는 것이 당연한 구조였다.

이커머스 도메인 모델링에서 좋아요를 독립 도메인으로 분리할 때와 같은 경험이었다. "이 기능의 경계는 어디까지인가?"를 명확히 하면, 경계 밖에 있었지만 경계 안인 척 하고 있던 불필요한 코드가 자연스럽게 드러난다.

도메인의 경계를 다시 그리는 작업은, 코드를 새로 작성하는 것이 아니라 기존 코드의 지도를 다시 그리는 것이다.

"오늘, 사용되지 않는 것으로 확인된 이벤트 코드가 3개 있습니다."

이 역시 속보로 나올 만한 소식은 아니다.
하지만 이렇게 하나씩 정리되는 코드가 쌓이면,
어느 날 새로운 멤버십 코드를 추가할 때 "기존 코드에 영향이 없다"고 확신할 수 있는 날이 온다.




"약속"과 "구현"을 분리하면 기술을 교체할 수 있다

이커머스 도메인 모델링에서 하나 더 배운 것은 데이터 접근 방식을 분리하는 방법이다.

상품 정보를 데이터베이스에서 조회하는 방법은 여러 가지가 있다. 지금은 JPA를 사용하고 있지만, 나중에 QueryDSL로 바꿀 수도 있고, 특정 조회는 직접 SQL을 작성하는 것이 더 효율적일 수도 있다.

이때 비즈니스 로직이 특정 데이터 접근 기술에 직접 의존하고 있으면, 기술을 바꿀 때 비즈니스 로직까지 수정해야 한다.

이 문제를 해결하기 위해, 데이터 접근의 "약속"과 "구현"을 분리했다.

도메인 계층에는 "상품을 ID로 조회할 수 있어야 한다"는 약속(인터페이스)만 존재한다. 이 약속을 실제로 지키는 구현체는 별도의 계층(인프라스트럭처)에 존재한다.

// 도메인 계층: "무엇이 가능해야 하는가"만 정의한다
interface ProductRepository {
    fun save(product: ProductModel): ProductModel
    fun findByIdAndDeletedAtIsNull(id: Long): ProductModel?
    fun findAllByDeletedAtIsNull(brandId: Long?, pageable: Pageable): Page
}

// 인프라스트럭처 계층: "어떻게 구현하는가"를 담당한다
@Component
class ProductRepositoryImpl(
    private val productJpaRepository: ProductJpaRepository,
) : ProductRepository {

    override fun findAllByDeletedAtIsNull(brandId: Long?, pageable: Pageable): Page {
        return if (brandId != null) {
            productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable)
        } else {
            productJpaRepository.findAllByDeletedAtIsNull(pageable)
        }
    }
}



이 구조에서는 데이터 접근 방식을 바꾸더라도, 도메인 계층의 코드는 전혀 수정할 필요가 없다.
인프라스트럭처 계층의 구현체만 교체하면 된다.

결제 프로그램에서 MyBatis를 JPA로 전환하는 과정이 어려운 이유 중 하나도 이 분리가 되어 있지 않기 때문이다. 데이터 접근 코드와 비즈니스 로직이 하나의 SQL 안에 섞여 있으면, SQL을 바꿀 때 비즈니스 로직이 깨지지 않는지 하나하나 확인해야 한다.
이사하고 싶어도, 짐이 벽에 박혀 있으면 옮길 수가 없다.




같은 고민을 하고 있는 곳들

이 작업들을 하면서 여러 기업의 기술 블로그를 찾아봤다.

캐나다의 럭셔리 이커머스 플랫폼인 SSENSE는, 고객 주문부터 재고 변경까지 도메인 주도 설계를 적용한 경험을 공유하고 있었다. 특히 "주문 상태 전이"와 "재고 변동"을 상태 머신(State Machine)으로 관리하면서, 비즈니스 규칙을 코드에 명시적으로 표현하는 방법을 다루고 있었다.

Walmart의 기술 블로그에서는, 주문 서비스가 "주문이 취소되었다"는 이벤트를 발행하면 결제 서비스와 재고 서비스가 이를 각각 구독하여 처리하는 구조를 소개하고 있었다. 앞서 이야기한 "응용 계층에서 도메인을 조합하는 방식"의 확장 버전이라고 볼 수 있다.규모가 커지면 직접 호출 대신 이벤트 기반으로 전환하는 것이 자연스러운 진화 방향이다.

카카오의 추천팀은 도메인 주도 설계를 도입하면서 "이벤트 스토밍"이라는 워크숍을 진행했다. 여러 색의 메모지에 중요한 이벤트와 비즈니스 규칙을 적어서 벽에 붙이면서 도메인의 경계를 식별하는 과정이었다. "좋아요를 상품에 둘 것인가, 독립시킬 것인가"를 결정할 때 이런 식으로 시각적으로 정리했다면 더 빠르게 결정할 수 있었을 것이다.

카카오페이에서는 대출(여신) 도메인에 도메인 주도 설계를 적용했는데, 금융 도메인 특유의 엄격한 비즈니스 규칙을 엔티티에 캡슐화하는 방식이 "재고 차감 규칙을 상품 객체에 캡슐화하는 것"과 같은 접근이었다.

 



매일 0.01퍼센트씩

"어제에 비해 글을 읽고 쓸 수 있는 사람의 비율이 0.0008퍼센트 높아졌습니다!"

누구도 이 소식에 놀라지 않을 것이다.
하지만 이 변화가 수십 년간 쌓이면, 전 세계의 문해율은 극적으로 달라진다.

이사도 비슷하다고 느꼈다.

JOIN 하나를 분리했다고 시스템이 갑자기 깨끗해지지 않는다. 비즈니스 규칙 하나를 코드로 옮겼다고 모든 문제가 해결되지 않는다. 테스트 하나를 추가했다고 모든 버그가 사라지지 않는다.

하지만 짐을 하나씩 옮기다 보면, 어느 날 새로운 멤버십 코드를 추가할 때 SQL의 IN 절을 확인하고, Java의 NOT 조건을 확인하고, 머릿속으로 두 결과를 조합하는 대신, 하나의 메서드에 코드 한 줄을 추가하고 테스트를 돌리는 것만으로 끝나는 날이 온다. 쉬는 날 오후에 워치가 진동하는 대신, 배포 전에 테스트가 실패해서 문제를 미리 잡아내는 날이 온다.

오늘 나는 SQL에 월세를 내고 있던 비즈니스 규칙을 하나 더 도메인으로 옮겼다.
내일은 테스트를 하나 더 추가할 것이다.
모레는 사용되지 않는 코드를 하나 더 정리할 것이다.

당장은 아무것도 달라지지 않은 것처럼 보일 것이다.
하지만 이 0.01퍼센트가 매일 쌓이면, 결국 월세는 끝난다.
그리고 쉬는 날 오후, 소파 위에서의 시간은 다시 온전히 나의 것으로 돌아올 것이라 생각한다.


출처.

이 글에서 소개한 이커머스 도메인 모델링은 학습 프로젝트에서 구현한 것입니다.
실무 환경에서는 동시성 제어, 분산 트랜잭션, 성능 최적화 등 추가적인 고려사항이 필요합니다.