배경 및 목표
비즈니스 로직이 서비스 클래스에 흩어져 있어 테스트 코드가 전무했습니다. 서비스 하나를 테스트하려면 DB, 외부 의존성을 모두 맞춰야 하여 단위 테스트가 사실상 불가능했고, QA 시 수정 범위가 넓어 대응이 느렸습니다.
graph TB subgraph before["❌ 기존: 로직 분산"] SVC["서비스 클래스"] --> L1["비즈니스 로직 A"] SVC --> L2["비즈니스 로직 B"] SVC --> L3["검증 로직"] SVC --> DB["DB"] SVC --> EXT["외부 의존성"] end subgraph after["✅ 개선: 도메인 중심"] AGG["Aggregate"] --> L4["비즈니스 로직"] AGG --> L5["검증 로직"] SVC2["서비스"] --> AGG SVC2 --> DB2["DB"] end
목표
- 서비스에 흩어진 비즈니스 로직을 도메인으로 모아 단위 테스트가 가능하게 한다.
- QA 시 수정 범위를 최소화하고 부작용을 원천 차단한다.
해결 방법과 해결 후보군
1. Aggregate 패턴으로 경계 설정
관련 엔티티들을 하나의 Aggregate로 묶어 일관된 비즈니스 규칙을 적용했습니다. Aggregate 경계는 “같이 변경되는 단위”를 기준으로 설정했습니다. 예를 들어 Entity A와 하위 항목은 항상 함께 변경되므로 하나의 Aggregate로 묶고, Entity B는 별도 Aggregate로 분리했습니다. Aggregate 간에는 ID 참조만 허용하여 결합도를 낮췄습니다.
graph TB subgraph agg1["Entity A Aggregate"] E["Entity A (Root)"] --> EI["하위 항목"] E --> ES["상태"] end subgraph agg2["Entity B Aggregate"] A["Entity B (Root)"] --> AI["Entity B 정보"] end agg1 -.->|"ID 참조만"| agg2
2. 엔티티 중심 로직 이동
서비스 클래스에 있던 비즈니스 로직을 엔티티 내부 메서드로 이동했습니다. “상태 전환 가능 여부 검증”이 서비스에서 if문으로 처리되던 것을 엔티티의 메서드로 이동하여, DB 없이 순수 객체만으로 테스트가 가능해졌습니다.
// ❌ 기존: 서비스에 로직이 흩어져 있음
class EntityAService {
fun complete(id: Long) {
val entityA = repository.findById(id)
if (entityA.status != Status.ACTIVE) {
throw IllegalStateException("완료할 수 없는 상태")
}
entityA.status = Status.DONE
}
}
// ✅ 개선: 엔티티가 자신의 규칙을 관리
class EntityA {
fun complete() {
require(canComplete()) { "완료할 수 없는 상태: $status" }
this.status = Status.DONE
this.completedAt = LocalDateTime.now()
}
fun canComplete(): Boolean = status == Status.ACTIVE
}이 구조 덕분에 테스트가 다음과 같이 단순해졌습니다:
// DB 없이 순수 객체만으로 테스트 가능
@Test
fun `활성 상태는 완료할 수 있다`() {
val entityA = EntityA(status = Status.ACTIVE)
entityA.complete()
assertEquals(Status.DONE, entityA.status)
}
@Test
fun `대기 상태는 완료할 수 없다`() {
val entityA = EntityA(status = Status.PENDING)
assertThrows<IllegalArgumentException> { entityA.complete() }
}3. 불변 객체 설계
상태 변경을 var 대신 명시적인 메서드(예: complete(), reject())를 통해서만 수행하도록 설계했습니다. 외부에서 entityA.status = Status.DONE처럼 직접 변경하는 것을 막고, 반드시 entityA.complete()를 호출해야 상태가 전환되도록 했습니다. 이를 통해 예상치 못한 상태 변경(부작용)을 원천적으로 방지하고, 각 메서드의 입력/출력이 명확하여 테스트 작성이 용이해졌습니다.
결과
| 지표 | 기존 | 개선 |
|---|---|---|
| 테스트 커버리지 | 0% | 70% |
| QA 대응 | 대규모 수정 | 몇 줄 수정 |
| 코드 구조 | 서비스에 흩어짐 | 도메인 중심 |
| 테스트 속도 | DB 의존 (느림) | 순수 객체 (즉시) |
모니터링
- 테스트 커버리지 추이(0%→70%)를 관측한다.
- 도메인 규칙 위반(예외) 발생 건수를 관측한다.