도메인 클래스에 프레임워크 어노테이션을 어디까지 허용할 것인가. 나에게는 이걸 판단할 기준이 없었다.
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라는 정해진 자리가 먼저 있고, 새 코드를 둘 중 어디에 넣을지부터 생각하게 된다. 나부터 그랬고, 세 번의 시도가 전부 그 안에서 이루어졌다.
지금의 기준
- 엔티티는 엔티티다. 순수하게 만들려는 노력에는 상한이 있다. Kotlin에서는 특히 그렇다
- 순수한 도메인 로직이 필요하면 별도 POJO를 만든다. 실제로 그렇게 하고 있다
- 엔티티와 1:1로 대응하는 도메인 클래스를 두는 것이 DDD인 것은 아니다. 테이블 구조를 한 번 더 베낀 것에 가깝다
orm.xml은 계속 쓴다. 다만 “도메인을 순수하게 만드는 방법”이어서가 아니라, DB 스키마 지식이 클래스에서 빠지는 것 자체가 낫기 때문이다
각론 두 편
3차 시도에서 남은 두 가지 질문은 따로 다룬다.
- Kotlin에서 @Entity는 지울 수 없다 — 어노테이션이 끝까지 남는 이유. 매핑이 아니라 컴파일러 쪽 문제다
- orm.xml로 옮길 수 있는 것과 없는 것 — 무엇이 XML로 나가고 무엇이 안 나가는가