본문으로 건너뛰기
bluesocket.dev
뒤로 가기

도메인 클래스에 무엇을 허용할 것인가

도메인 클래스에 프레임워크 어노테이션을 어디까지 허용할 것인가. 나에게는 이걸 판단할 기준이 없었다.

DDD나 헥사고날 아키텍처에서 도메인은 프레임워크를 몰라야 한다고 한다. 그런데 JPA를 쓰는 순간 @Entity, @Column, @Table이 클래스에 붙는다. 둘 다 지킬 수는 없으니 어딘가에서 선을 그어야 한다.

@Column(name = "order_no")는 DB 컬럼명이다. 비즈니스 규칙을 담는 클래스에 스키마 정보가 섞여 있다. 나는 이게 어색했다. 이 어색함이 출발점이었고, 세 번의 시도로 이어졌다.

목차

목차 열기

세 번의 시도

세 번 시도했고, 매번 형태는 바뀌었지만 판단 기준은 생기지 않았다.

형태얻은 것남은 문제
1차엔티티에 비즈니스 로직 추가 (단일 클래스)로직이 @Service 밖으로 나옴POJO 도메인 클래스라는 감각이 안 생김. “엔티티에 함수를 더 추가한 느낌”
2차도메인 클래스와 엔티티를 별개로 분리클래스가 확실히 순수해짐경계마다 변환 비용. 1:1 매핑이라 결국 테이블 구조를 한 번 더 베낀 형태
3차단일 클래스 + orm.xml로 매핑 분리어노테이션이 걷혀 도메인 클래스로 읽힘어노테이션이 전부 사라지지는 않음. 무엇이 XML로 나가는지도 불균일

1차. 이전 회사에서 클린 아키텍처를 적용할 때 택한 형태다. 당시엔 비즈니스 로직을 전부 @Service 클래스에 쓰던 시기라 이것으로도 충분했다. 문제는 시간이 지나면서 드러났다. 도메인 클래스의 용도가 애매해졌고, 결국 getter/setter에 뭔가 조금 더 붙은 정도로 남았다.

2차. 참고 예제대로 도메인과 엔티티를 나눴다. 팀 내 이견은 없었다. 클래스는 확실히 순수해졌다. 대신 두 가지가 남았다. 경계마다 변환 코드가 붙었고, 더 큰 문제는 그 도메인 클래스가 엔티티와 1:1로 대응했다는 것이다. 필드 구성이 테이블과 같으니, 순수한 클래스를 얻은 게 아니라 테이블 구조를 한 벌 더 만든 셈이었다.

3차. 단일 클래스로 돌아가되 매핑을 orm.xml로 뺐다. 어노테이션과 테이블 설명이 주렁주렁 달려 있지 않으니 그제야 도메인 클래스로 읽히기 시작했다. 다만 어노테이션이 전부 사라지지는 않았고, 무엇을 XML로 옮길 수 있는지도 균일하지 않았다. 이 두 가지는 각론으로 따로 다룬다.

기준은 여전히 없었다

3차까지 와서도 판단 기준은 1차 때와 같았다. 없었다.

클래스에 남긴 어노테이션을 허용한 근거는 IDE가 오류나 경고를 내서였다. 그게 전부다. 지울 수 있는 건 지웠고, 지우면 IDE가 빨간 줄을 그으니 남겼다. 어떤 어노테이션이 도메인에 있어도 되는 것이고 어떤 것이 아닌지에 대한 논리는 없었다.

기술적으로는 세 번에 걸쳐 정교해졌다. 판단은 그렇지 않았다.

질문이 틀렸다

도메인 로직이 반드시 엔티티에 있어야 할 이유가 없다.

스프링 의존 없이 테스트를 작성하려다 알았다.

사용자가 서비스를 이용하는 흐름을 제어하는 규칙이 있었다. 지금 단계에서 다음 단계로 넘어갈 수 있는가를 판단하는, 전형적인 상태 전이 규칙이다. 이걸 엔티티에 넣자니 자리가 애매했다. 판단에 필요한 데이터가 그 엔티티 밖에도 흩어져 있었기 때문이다. 서비스에 두자니 @Service 안에서 조건문이 계속 불어났다.

결국 엔티티도 서비스도 아닌 클래스를 하나 만들었다. 아래 코드는 실제 코드가 아니다. 도메인도 규칙 수도 다르고, 구조만 남겨 주문 흐름으로 옮겼다.

// 스프링도 JPA도 모른다. import 문에 프레임워크가 없다.
class OrderFlowPolicy(
    private val current: Step,
    // 판단에 필요한 값을 "값"이 아니라 "부를 수 있는 함수"로 받는다
    private val verifiedAt: () -> Instant?,
    private val pendingPaymentCount: () -> Int,
) {
    fun canMoveTo(next: Step): Boolean = when (next) {
        Step.PAYMENT -> current == Step.CART && verifiedAt() != null
        Step.DONE    -> current == Step.PAYMENT && pendingPaymentCount() == 0
        else         -> false
    }
}

호출하는 쪽은 서비스다. 조회 방법을 아는 것은 서비스뿐이고, 규칙은 그걸 몰라도 된다.

@Service
class OrderFlowService(
    private val verificationRepository: VerificationRepository,
    private val paymentRepository: PaymentRepository,
) {
    fun moveTo(order: Order, next: Step) {
        val policy = OrderFlowPolicy(
            current = order.step,
            verifiedAt = { verificationRepository.findVerifiedAt(order.userId) },
            pendingPaymentCount = { paymentRepository.countPending(order.id) },
        )
        require(policy.canMoveTo(next)) { "$next 단계로 넘어갈 수 없다" }
        order.step = next
    }
}

데이터를 함수로 받는 것은 성능 때문에 한 선택이다. 규칙마다 필요한 데이터가 다른데 전부 미리 조회하면, 자주 불리는 경로에서 쓰지도 않을 쿼리가 나간다. 무거운 조회만 필요한 시점으로 미뤘다. 가벼우면 그냥 값으로 받는다. 복잡도에 따라 다르게 했다.

그런데 성능 때문에 한 선택에 부수효과가 있었다. 테스트에서 저 자리에 람다만 넣으면 된다.

@Test
fun `본인인증 전에는 결제 단계로 갈 수 없다`() {
    val policy = OrderFlowPolicy(
        current = Step.CART,
        verifiedAt = { null },          // 인증 안 됨
        pendingPaymentCount = { 0 },
    )

    assertFalse(policy.canMoveTo(Step.PAYMENT))
}

스프링 컨텍스트도, DB도, 엔티티 인스턴스도 없다. @SpringBootTest도 목 프레임워크도 필요 없다. 그리고 여기서 알아챘다. 이 클래스에는 애초에 지울 어노테이션이 없었다.

엔티티를 순수하게 만드는 문제가 아니었다. 순수한 클래스가 필요하면 하나 더 만들면 되는 문제였다. 세 번의 시도는 전부 “엔티티를 어떻게 도메인 클래스로 만들 것인가”를 물었고, 그 질문이 틀렸다.

한 가지 더 눈에 띈다. OrderFlowPolicy어떤 테이블과도 1:1로 대응하지 않는다. 2차 시도에서 만든 도메인 클래스가 엔티티를 그대로 베낀 형태였던 것과 대조적이다. 필요해서 만든 클래스는 규칙을 단위로 삼지, 테이블을 단위로 삼지 않는다.

적어도 내가 일해 온 Spring 프로젝트에서는 “필요하면 그냥 클래스를 하나 만든다”는 선택지가 잘 나오지 않았다. @Service@Entity라는 정해진 자리가 먼저 있고, 새 코드를 둘 중 어디에 넣을지부터 생각하게 된다. 나부터 그랬고, 세 번의 시도가 전부 그 안에서 이루어졌다.

지금의 기준

각론 두 편

3차 시도에서 남은 두 가지 질문은 따로 다룬다.


이 글 공유하기:

다음 글
Kotlin에서 @Entity는 지울 수 없다