배경 및 목표

비즈니스 로직이 서비스 클래스에 흩어져 있어 테스트 코드가 전무했습니다. 서비스 하나를 테스트하려면 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%)를 관측한다.
  • 도메인 규칙 위반(예외) 발생 건수를 관측한다.