[DDD] 도메인 주도 설계


도메인 주도 설계에 대해 알아본다.


iOS 앱을 개발하는데 많이 쓰이는 MVVM 패턴과 클린 아키텍처를 공부하면서 DDD라는 말도 같이 나오기 시작했다. 이 DDD는 무엇인지, 어떤 방법으로 개발하는 것인지 정리하려고 한다. DDD는 도메인 주도 설계를 의미한다.

도메인 주도 설계란?

도메인 주도 설계란 무엇인가?

개발자의 세계와 이용자의 세계는 같다고 볼 수 없다. 개발자는 소프트웨어를 사용하는 사람들의 세계에 기본적으로 무지하다. 따라서 가치 있는 소프트웨어를 개발하기 위해서는 이용자의 세계를 이해해야 한다.

  1. 이용자의 세계가 무엇인지 파악
  2. 해결할 수 있는 최선의 수단 생각

의 과정을 반복해 이용자의 세계와 소프트웨어 구현을 연결짓는 것이 도메인 주도 설계의 목적이다.

이용자의 세계를 이해함으로써 지식을 코드에 녹여 넣을 수 있다.

image

도메인 지식에 초점을 맞춘 설계 기법

도메인은 영역이다. 소프트웨어 개발에서 말하는 도메인은 프로그램이 쓰이는 영역이 된다. 도메인은 영역이기 떄문에 무엇이 도메인인가보다는 어떤 것들이 도메인에 속하는 지를 알아내는 것이 중요하다.

예를 들어 회계 시스템에서는 ‘금전’, ‘장부’ 라는 용어가 등장한다. 물류 시스템에서는 ‘화물’, ‘창고’, ‘운송수단’과 같은 용어가 등장한다. 이 ‘금전’, ‘장부’, ‘화물’, ‘창고’와 같은 것들이 도메인에 속하는 개념이 된다. 이렇듯 도메인에 포함되는 개념은 시스템의 대상 분야에 따라 천차만별이 된다.

가치 있는 소프트웨어를 만들기 위해서는 이용자의 문제를 정확히 이해하는 것이 중요하다. 이를 위해 가장 좋은 방법은 이용자의 도메인을 직접 접하는 것이다. 도메인에 속하는 개념을 이해하고 유용한 지식을 추출해 소프트웨어 코드로 녹이는 것은 필수적인 과정이다.

나를 포함한 여러 개발자들은 신기술에 매혹되어 문제를 파악하는 것보다 기술을 중시하기 쉽다고 생각한다. 이를 피하기 위해 도메인의 지식에 초점을 맞춰야 한다. 관찰을 통해 알게 된 지식을 코드로 제대로 표현하는 것은 소프트웨어 개발 과정의 일부다.

도메인 모델링이란?

모델은 현실에 일어나는 사건/개념을 추상화한 개념이다. 추상화한다는 것은 여러 사물/개념에서 공통적인 특징을 뽑아 파악하는 것으로, 현실의 모든 것을 반영하는 것이 아니다.

사건/개념을 추상화하는 작업을 모델링이라고 한다. 이 모델링의 결과를 모델이라고 한다. 도메인 주도 설계에서는 도메인 개념을 모델링한 모델을 도메인 모델이라고 한다.

지식을 코드로 나타내는 도메인 객체

도메인 모델을 소프트웨어 형태의 동작하는 모듈로 나타낸 것이 도메인 객체다. 즉 도메인 모델을 코드로 작성한 것이 도메인 객체다.

소프트웨어 이용자의 세계는 항상 같은 상태로 존재하지 않는다. 이때 도메인 객체가 도메인 모델을 충실히 반영하고 있다면 도메인의 변화를 코드로 쉽게 옮길 수 있다.

도메인에 발생한 변화는 우선 도메인 모델로 전달되어야 한다. 연쇄적으로 도메인 모델로 전달된 변화는 도메인 객체에까지 전해진다. 반대로, 도메인 객체가 도메인에 대한 태도를 변화시킬 수도 있다. 프로그램 상에서는 도메인에 대한 명확하지 않은 이해가 구현을 방해하기 때문에, 도메인 모델을 직시하고, 도메인의 개념을 추상화하는 방법을 바꾸는 것으로 이어질 수 있다.

이렇게 도메인 개념과 도메인 객체는 도메인 모델을 통해 연결되며, 서로 영향을 주고 받는 반복적 개발로 실현된다.

image

도메인 주도 설계를 실천하기 어려운 이유?

위에서 도메인 개념을 이해하는 것이 중요하다고 했는데, 이를 위해 가장 좋은 방법은 이용자의 도메인을 접하는 것이라고 했다. 하지만 이것이 불가능한 현장도 많다. 하지만 개발자 개인이 재량으로 실천할 수 있는 프랙티스도 있다. 설계라는 것은 현실과는 약간 거리가 있는 행위라고 볼 수 있다. 따라서 이상을 현실에 끼워맞추기 보다는 현실적으로 사용가능한 수단을 골라 선택하는 것이 중요하다.

더 자세한 내용을 보기 앞서서 큰 그림을 먼저 보도록 하겠다.

  • 지식 표현을 위한 패턴
    • 값 객체
    • 엔티티
    • 도메인 서비스
  • 애플리케이션을 구성하는 패턴
    • 리포지토리
    • 애플리케이션 서비스
    • 팩토리
  • 지식 표현을 위한 고급 패턴
    • 애그리게이트
    • 명세

관계는 도메인 지식을 표현하기 위한 패턴(값 객체, 엔티티, 도메인 서비스, 애그리게이트, 명세)를 애플리케이션을 구성하는 패턴이 사용하는 구조이다.

뒤에서 더 자세히 보겠지만, 먼저 각 개념들을 간단히 보겠다.

지식 표현을 위한 패턴

  • 값 객체 : 도메인만의 고유의 개념을 값으로 나타내는 패턴
  • 엔티티 : 도메인 개념을 나타내는 것에서 값 객체와 비슷하지만, 값 객체와 차이가 있다.
  • 도메인 서비스 : 값 객체/엔티티만으로 잘 표현할 수 없는 지식을 다루기 위한 패턴이다.

애플리케이션을 구성하기 위한 패턴

도메인 지식을 표현한 것만으로는 유용하지 않다. 이를 이용자의 필요를 만족시키기 위해 애플리케이션을 구성하는 과정이 필요하다. 즉 도메인 지식을 코드로 나타낸 것 외에도 이용자의 요구사항을 만족시키기 위해 추가적인 작업들을 구현하는 것이 필요하다는 것이다.

  • 리포지토리: Data persistency(데이터 저장, 복원)를 담당하는 객체다. 데이터베이스와 같은 구체적인 데이터스토어보다는, 이들을 추상화한 개념이다.

값 객체, 엔티티, 도메인 서비스, 리포지토리 이렇게까지만 이해해도 애플리케이션을 구성하는 최소한의 개념을 이해한 것이다.

  • 애플리케이션 서비스 : 앞서 언급한 4가지 요소가 서로 협력하며 애플리케이션으로서 기능하게 하는 서비스이다.
  • 팩토리 : 객체를 만드는 데 필요한 지식에 특화된 객체이다. 복잡한 구조의 객체는 만드는 방법도 복잡하기 때문에 생성하는 작업도 일종의 지식으로 취급한다. 객체 생성은 여기저기서 일어나는 일이기 때문에, 팩토리 패턴을 통해 객체 생성에 대한 지식을 한 곳에 모아 놓으면 코드의 의도를 파악하기 쉽다.

지식 표현을 위한 고급 패턴

  • 애그리게이트 : 무결성을 유지하는 객체다.
  • 명세 : 객체를 평가하기 위한 지식이다.

도메인 주도 설계가 필요한 이유?

소프트웨어를 개발하면 수정은 불가피하다. 도메인 주도 설계는 변화에 대응해 소프트웨어를 수정할 때 진정한 가치가 드러난다.

시스템 특유의 값을 나타내기 위한 값 객체

값 객체란?

프로그래밍 언어에는 원시 데이터 타입이 있다. 원시 데이터 타입만 이용해서 개발할 수 있지만, 시스템 특유의 값을 정의해야 할 떄도 있다.

여기 두 사람이 있다고 해보자. 그리고 두 사람의 성을 출력해보기 위한 코드를 작성한다고 해보자.

let personName1 = "김 철수"
let personName2 = "Tim Mark"

let person1 = personName1.split(separator: " ")
let person2 = personName2.split(separator: " ")

print(person1[0]) // 한국인은 첫 글자가 성
print(person2[1]) // 미국인은 뒤의 단어가 성

이렇게 이름마다 코드가 달라지는 것을 막기 위해 클래스를 사용한다.

class FullName {
    private(set) var firstName: String
    private(set) var lastName: String
    
    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}

let 철수 = FullName(firstName: "철수", lastName: "김")
        let Tim = FullName(firstName: "Tim", lastName: "Mark")

FullName 클래스는 이름을 나타내는 객체로, 값을 표현한다. 이는 객체이기도 하면서 값이기도 하기 때문에 값 객체라고 한다. 도메인 주도 설계에서 말하는 객체는 이렇게 시스템 특유의 값을 나타내는 객체다. 즉 값을 그냥 객체로 표현했다! 라고 이해하면 될 것 같다.

image

위 사진에서 primitive 이 primary로 잘못 써져 있는데 무시해서 보면 되겠다.

값의 성질과 값 객체 구현

값의 성질을 이해하는 것은 값 객체를 이해하는 데 중요하다. 값의 성질은 아래와 같다..

  1. 변하지 않는다. (불변)
  2. 주고받을 수 있다.
  3. 등가성을 비교할 수 있다.

1. 값의 불변성

프로그래밍에서는 값을 수정하는 것은 흔한 일인데 불변성이 무슨 말일까?

var personName = "김 철수"
personName = "john"

위와 같이 값을 수정하는 과정은 사실 새로운 값을 대입한 것이다. 즉 대입을 통해 수정되는 것은 변수의 내용이지, 값 자체가 수정되는 것은 아니다.

만약에 값을 수정하는 코드가 있다면 매우 혼란스러워 질 것이다. 코드에서 1이라는 값을 0으로 수정하면, 코드 상의 모든 1들이 0으로 바뀌게 되어 예상하지 못한 결과를 초래할 수도 있다.

따라서 값을 바꾸는 것은 권장되지 않는다. 이를 염두해서 위에서 언급했던 FullName 클래스를 다시 보자. 그리고 만약에 이 클래스에 이름을 수정하는 동작이 있다고 해보자.

var person = FullName(firstName: "철수", lastName: "김")
person.changeLastName(lastName: "이")
dump(person)

위와 같이 코드를 작성하면 부자연스러운 부분이 생긴다. 위의 코드는 값이 수정되기 때문에 좋은 코드가 아니다. FullName은 값 객체이고, 값이기도 하다. 따라서 1이 0으로 변하면 안되는 것처럼 변해선 안된다. 따라서 changeLastName처럼 값을 수정하는 기능이 클래스에 정의되면 안된다.

불변하는 값의 장점

상태가 변화하지 않게 하면 프로그램을 단순하게 만들 수 있다. 병렬 처리가 일어나는 프로그램에서는 상태가 변화할 수 있는 객체를 다루는 방법이 관건이기 때문에, 객체의 상태가 변하지 않는다면 병렬/병행 처리를 쉽게 구현할 수 있다. 물론 객체의 일부만 바꾸고 싶다고 해도 객체를 아예 새로 생성해야 한다는 단점이 있다. 하지만 가변 객체 -> 불변 객체로 바꾸는 작업보다 불변 객체 -> 가변 객체 로 바꾸는 작업이 노력이 적게 들기 때문에 일단 불변 객체를 적용하는 것이 낫다.

2. 교환 가능하다.

값 객체를 수정하는 방법은 새로운 객체를 만들어 대입을 통해 교환하는 방식으로 구현한다.

var person = FullName(firstName: "철수", lastName: "김")
person = FullName(firstName: "철수", lastName: "이")

image

3. 등가성 비교 가능

프로그래밍의 원시타입끼리는 같은 값끼리 비교할 수 있다. 이처럼 값 객체도 속성을 통해 비교할 수 있다. 하지만 값 객체에서 값을 꺼내서 비교하는 것은 자연스럽지 못하다.

// 원시 타입
print(0 == 0)
print("hi" == "hi")

let person1 = FullName(firstName: "철수", lastName: "김")
let person2 = FullName(firstName: "철수", lastName: "김")

// 값 객체의 값을 또 꺼내서 비교한다. 값에서 값을 꺼낸다? 부자연스럽다.
let result = person1.firstName == person2.firstName && person1.lastName == person2.lastName
print(result)

값과 마찬가지로 값 객체도 값끼리 비교하는 것이 자연스럽다. 이를 위해서 값 객체를 비교하는 메서드를 정의한다.

class FullName: Equatable {
    private(set) var firstName: String
    private(set) var lastName: String
    
    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
    
    func equals(fullName: FullName) -> Bool {
        guard self === fullName
                || self.firstName == fullName.firstName
                && self.lastName == fullName.lastName else {
            return false
        }
        return true
    }
    // Equatable protocol
    static func == (lhs: FullName, rhs: FullName) -> Bool {
        guard lhs === rhs
                || lhs.firstName == rhs.firstName
                && lhs.lastName == rhs.lastName else {
            return false
        }
        return true
    }
}



// 비교는 이렇게 할 것이다.
let person1 = FullName(firstName: "철수", lastName: "김")
let person2 = FullName(firstName: "민수", lastName: "김")
let person3 = person1

print(person1.equals(fullName: person2))
print(person1.equals(fullName: person3))
print(person2.equals(fullName: person3))
print(person1 == person3)

위처럼 equals를 구현하면 코드가 자연스러워 질 뿐만 아니라, 값 객체에 속성을 추가해도 수정이 필요 없다. 만약 equals를 구현하지 않고 모든 곳에서 일일이 값 객체의 값을 꺼내서 비교했는데, 새로운 속성이 추가되면 이 코드들을 모두 찾아 수정해야 한다. 하지만 값 객체에서 직접 비교 수단을 제공하면 이런 작업을 피할 수 있다.

값 객체가 되기 위한 기준

위에서 언급한 예시에서 FullName안의 속성 또한 값 객체로 만들 수 있다. 이에 대한 기준은 사람마다 다른데, 기준을 아래와 같이 잡을 수 있다.

  1. 규칙이 존재하는가
  2. 낱개로 다루어야 하는가

예를 들어 FullName에는 ‘성과 이름으로 구성된다’라는 명백한 규칙이 있다. 하지만 lastNamefirstName에는 아직 규칙이 없다. 이 경우에는 두 속성을 값 객체로 굳이 만들 필요가 없다. 하지만 만약 성과 이름에 제약이 있다면 얘기는 달라진다. 물론 값 객체로 만들지 않고도 FullNameinitlastNamefirstName의 규칙을 언급해서 제약을 걸 수도 있다. 하지만 값 객체로 정의해도 상관 없다. 값 객체로 만들기로 했다면 성과 이름을 각각 다른 타입의 값 객체로 만들지를 고민해 보면 된다.

또한 값 객체로 정의할 만한 가치가 있는 개념을 구현 중에 발견했다면 이 개념은 도메인 모델로 피드백해야 한다. 앞서서 언급했듯이, 도메인 객체에 대한 피드백이 도메인 모델을 보는 관점을 변화시킬 수 있다.

행동이 정의된 값 객체

값 객체에서 중요한 점은 독자적인 행위를 정의할 수 있다. 즉 데이터만을 저장하는 컨테이너가 아니라 행동을 가질 수도 있다.

enum ArgumentExceptionError: Error {
    case ArgumentExceptionError(String)
}

class Money {
    private(set) var amount: Decimal
    private(set) var currency: String
    
    init(amount: Decimal, currency: String) {
        self.amount = amount
        self.currency = currency
    }
    
    func addMoney(money: Money) throws -> Money {
        guard self.currency == money.currency else {
            throw ArgumentExceptionError.ArgumentExceptionError("화폐 단위가 다르다")
        }
        return Money(amount: amount + money.amount, currency: currency)
    }
}

var money: Money = Money(amount: 1000, currency: "원")
        
do {
    money = try money.addMoney(money: Money(amount: 2000, currency: "₩"))
} catch {
    print(error)
}

이처럼 값 객체는 데이터만을 담기 위한 것이 아니라, 데이터와 더불어 데이터에 대한 행동을 모아놔서 자신만의 규칙을 갖는 도메인 객체가 된다. 추가로, 위에서 Money에서는 더하는 메서드가 있고, 곱하거나 빼는 메서드는 없기 때문에 Money는 오로지 더하는 행위만 가능한 것을 알 수 있다. 이렇게 메서드 정의를 통해 암묵적으로 가능한 행위와 그렇지 않은 행위를 표현할 수도 있다.

값 객체를 도입했을 때의 장점

값 객체를 많이 생성할 수록 클래스가 늘어난다. 하지만 클래스가 많아짐에 따라 늘어나는 부담감에 비해 얻을 수 있는 장점들이 굉장히 많다.

  1. 표현력이 증가한다.
  2. 무결성이 유지된다.
  3. 잘못된 대입을 방지한다.
  4. 로직이 여기저기 흩어지는 것을 방지한다.

표현력의 증가

// 원시 타입으로 정의
var productCode = "a2000-D-2009"
        
// 구조체로 값 객체 생성
struct ProductCode {
    let productCode: String
    let area: String
    let productNum: String

    func toString() -> String {
        productCode + "-" + area + "-" + productNum
    }
}

// 더 많은 정보를 알 수 있다. 문서화 효과도 가지고 있다.
var productCode2 = ProductCode(productCode: "a2000", area: "D", productNum: "2009")

무결성의 유지

유효한 값과 그렇지 않은 값을 확인하는 것은 중요하다. 만약 값에 유효하지 않은 값을 허용하면 값을 사용하는 모든 곳마다 유효성을 검사해야 한다. 하지만 값 객체를 이용하면 유효하지 않은 값을 처음부터 방지할 수 있다.


class FullName: Equatable {
    private(set) var firstName: String
    private(set) var lastName: String
    
    init(firstName: String, lastName: String) throws {
        if lastName.count < 3 {
            throw ArgumentExceptionError.ArgumentExceptionError("성이 3글자 미만")
        }
        
        self.firstName = firstName
        self.lastName = lastName
    }
}

do {
    var fullName = try FullName(firstName: "Hi", lastName: "i") // exception 발생
} catch {
    print(error)
}

위처럼 애초에 생성할 때 값이 유효한지 아닌지를 검사하면, 값 객체를 사용하는 모든 부분에서 값이 유효한지를 검사할 필요가 없다.

잘못된 대입 방지하기

굉장히 어이 없는 실수이지만 아래와 같이 init할 때 값을 바꿔서 넣어버리는 실수를 했다고 해보자.

struct User {
    var id: String
    var name: String
    
    init(id: String, name: String) {
        self.id = name
        self.name = id
    }
}

위 코드는 에러는 나지 않지만 동작하는데는 문제가 없을 것이다. 이처럼 잘못된 대입을 방지하기 위해 값 객체를 사용할 수 있다.

image

위와 같이 값 객체들을 더 정의해주면 코드 상의 잘못된 대입에서 컴파일러 에러를 확인할 수 있고, init 외에도 다른 많은 부분에서 잘못된 대입을 방지 할 수 있다. 특히 Swift는 정적 타입 언어이기 때문에 이 특성을 이용하면 에러를 방지하기 쉽다.

로직을 한 곳에 모아두기

DRY 원칙(Do not Repeat Yourself)에 따르면 코드 중복을 방지하는 일은 굉장히 중요하다. 중복된 코드가 많아지면 코드를 수정하는데 노력이 배로 든다.

위에서도 언급했지만, 값 객체를 이용하면 객체 생성시에 객체의 유효성을 검사할 수 있다. 규칙을 값 객체에 기술하면 수정해야 하는 곳도 값 객체에 국한된다.

정리

값 객체의 개념은 시스템 고유의 값을 만드는 것이다. 원시 타입만으로도 할 수 있지만, 원시 타입은 범용적이기 때문에 표현력이 빈약하다.

도메인에는 다양한 규칙이 포함된다. 값 객체를 정의해서 이런 규칙을 값 객체 안에 기술해 코드 자체가 문서의 역할을 하게 할 수 있다.

생애주기를 갖는 객체 - 엔티티

엔티티는 도메인 모델을 구현한 도메인 객체를 의미한다. 값 객체 또한 도메인 객체다. 엔티티와 값 개개체의 차이는 동일성을 통해 식별이 가능한지에 달려 있다.

예를 들어 앞에서 값 객체를 예로 들었을 때는 성, 이름이 합쳐진 값을 나타내는 FullName이라는 값 객체가 있었다. 이름이 “김철수”인데 이 이름을 “이철수”라고 바꾸면 둘은 완전히 다른 값이 된 것이다. “김철수”라는 이름 자체가 “이철수”와 같을 수는 없다. 반면에 사람은 어떤가? 사람의 특성에는 나이, 이름이 있을 수 있을 것이다. “김철수”라는 이름을 가진 사람이 “이철수”로 개명을 했다고 해도 개명 후에 다른 사람이 된 것은 아니다. 이것은 속성과는 무관한 “동일성”을 지켜주는 무엇이 있다는 소리가 된다.

소프트웨어 시스템에서도 속성으로 구별되지 않는 객체가 있다. 사용자가 대표적인 예다. 사용자가 프로그램을 사용하다가 닉네임을 바꿀 수 있고, 프로필 상태를 바꿀 수도 있다. 하지만 그 사용자가 자신의 정보를 수정하기 전과 다른 사용자가 된 것은 아니다. 이렇듯 사람, 사용자는 동일성(identity)로 식별된다.

엔티티의 성질

엔티티는 속성이 아닌 동일성으로 식별되는 객체라고 했다. 하지만 앞서서 값 객체는 속성으로 동일성이 식별되었다.

엔티티와 값 객체는 모두 도메인 모델을 구현한 도메인 객체라는 점에서 비슷하지만, 성질에 차이가 있다.

  1. 가변이다.
  2. 속성이 같아도 구분할 수 있다.(위에서 언급한 동일성)
  3. 동일성을 통해 구별된다.

가변이다

값 객체는 불변성을 갖는 객체에 비해 엔티티는 가변성을 갖는 객체다. 시간에 따라 속성이 변할 수 있다.

위에서 언급했던 사용자를 예로 들어보자. 사용자의 이름을 바꿀 수 있는 메서드를 아래와 같이 추가할 수 있다.

class User {
    private(set) var name: String?
    
    init(name: String) throws {
        try changeName(name: name)
    }
    
    func changeName(name: String) throws {
        if name.count < 3 {
            throw ArgumentExceptionError.ArgumentExceptionError("이름이 3자 미만")
        }
        
        self.name = name
    }
}

값 객체는 불변성을 갖기 때문에 객체를 교환(대입)해 수정했지만, 엔티티는 수정을 위해 객체를 교환하지 않고 객체의 행동을 통해 수정하면 된다.

속성이 같아도 구분할 수 있다.

서로 다른 엔티티를 구별하기 위해 식별자, identifier가 사용된다. 따라서 위에서 언급했던 사용자에 UserId를 추가한다.

struct UserId {
    let value: String
}

class User {
    private let userId: UserId
    private(set) var name: String
    private let checkNameIsValid: (String) throws -> Void = { name in
        if name.count < 3 {
            throw ArgumentExceptionError.ArgumentExceptionError("이름이 3자 미만")
        }
    }
    
    init(id:String, name: String) throws {
        self.userId = UserId(value: id)
        try checkNameIsValid(name)
        self.name = name
    }
    
    func changeName(name: String) throws {
        try checkNameIsValid(name)
        self.name = name
    }
}

이 UserId를 통해서 두 사용자가 같은 사용자인지 아닌지 확인할 수 있다.

동일성

위에 정의한 User의 이름을 바꿔도 User는 다른 User가 되지 않는다. 이 동일성을 비교하기 위해 식별자가 사용된다. 식별자는 동일성의 실체이기 때문에 가변으로 설정할 필요가 없다. 식별자는 단순히 저장하는 것이 아니라 동일성을 비교하기 위한 행위가 따로 정의되어야 한다. 이를 위해 값 객체 UserId에 eqauls를 정의하고 동일성 비교를 위해 User에도 equals를 정의했다.

import Foundation
    
struct UserId : Equatable {
    let value: String
    
    func equals(userId: UserId) -> Bool {
        if value == userId.value {
            return true
        }
        
        return false
    }
    
    static func == (lhs: UserId, rhs: UserId) -> Bool {
        lhs.equals(userId: rhs)
    }
}

class User: Equatable {
    private let userId: UserId
    private(set) var name: String
    private let checkNameIsValid: (String) throws -> Void = { name in
        if name.count < 3 {
            throw ArgumentExceptionError.ArgumentExceptionError("이름이 3자 미만")
        }
    }
    
    init(id:String, name: String) throws {
        self.userId = UserId(value: id)
        // 중복! 그렇다고 !나 ?로 선언하고 싶지는 않음...
        try checkNameIsValid(name)
        self.name = name
    }
    
    func changeName(name: String) throws {
        try checkNameIsValid(name)
        self.name = name
    }
    
    func equals(user: User) -> Bool {
        if self === user || self.userId == user.userId {
            return true
        }
        return false
    }
    
    static func == (lhs: User, rhs: User) -> Bool {
        lhs.equals(user: rhs)
    }
}

do {
    user1 = try User(id: "id1", name: "hii")
    user2 = try User(id: "id1", name: "Hii")

    print(user1.equals(user: user2))
    print(user1 == user2)

} catch {
    print(error)
}

엔티티의 판단 기준 - 생애주기와 연속성

값 객체가 되기 위한 기준으로, ‘규칙이 존재하는가’와 ‘낱개로 다뤄야 하는가’ 가 있었다. 엔티티는 생애주기의 존재여부와 생애주기의 연속성 여부가 중요하다.

생애주기를 갖지 않거나 생애주기를 나타내는 것으 무의미한 개념이라면 우선 값 객첼 다루는 것이 좋다. 생애주기를 갖는 객체는 태어나서 죽을 때까지 변화를 겪을 수 있다.

값 객체도 되고 엔티티도 될 수 있는 모델

같은 대상이라도 환경에 따라 모델링 방법이 달라진다. 이는 앞서 말한 생애주기와 연관지어 고려해보면 된다.

도메인 객체를 정의할 때 장점

엔티티와 값 객체 모두 도메인 모델을 나타내는 도메인 객체다. 도메인 객체를 정의할 때 장점은 아래와 같다.

  1. 자기 서술적인 코드가 된다.
  2. 도메인에 변경사항이 있을 때 코드에 반영하기 쉽다.

자기 서술적인 코드가 된다.

User 클래스를 정의할 때, name 프로퍼티를 단순히 string으로 해도 되지만, 이를 값 객체로 정의해서 UserName이라는 값 객체를 만들고, 이 값 객체에 유효성을 검사하는 로직을 추가했다고 해보자. 이때 사용자는 값 객체의 코드만 보고 규칙을 확인할 수 있다. 도메인 모델과 관련된 규칙은 모두 도메인 객체로 옮겨지고 이 규칙으로 도메인 객체의 유효성을 보장한다.

도메인에 일어난 변경을 코드에 반영하기 쉽다.

만약 사용자의 이름이 3글자 이상이어야 했던 규칙을 6글자 이상으로 수정하고 싶다고 해보자. 이름에 대한 값 객체를 정의한 경우 해당 값 객체에서 유효성 검사를 하는 부분만 수정하면 되지만, 값 객체를 정의하지 않았을 때는 수정이 필요한 부분이 여기 저기 흩어져 있을 수 있다.

즉 도메인 객체에 행동이나 규칙을 코드로 작성하면 도메인 모델로 전달된 도메인의 변화를 객체까지 전달해서 반영하기 쉽다.

부자연스러움을 해결하는 도메인 서비스

서비스란?

소프트웨어 개발에서 말하는 서비스는 클라이언트를 위해 무언가를 해주는 객체를 의미한다.

DDD에서 서비스는 두 가지로 나뉜다.

  1. 도메인을 위한 서비스 (도메인 서비스)
  2. 애플리케이션을 위한 서비스 (애플리케이션 서비스)

도메인 서비스란?

값 객체, 엔티티 같은 도메인 객체에는 객체의 행동을 정의할 수 있었다. 하지만 값 객체나 엔티티에 구현하기 어색한 행동도 있는데, 이를 도메인 서비스로 해결한다.

값 객체나 엔티티에 정의하기 어색한 행동

사용자의 닉네임이 중복 가능하지 않다고 해보자. 그러면 이를 체크하기 위해 모든 사용자의 사용자명과 일치하는 것이 있는지를 확인해야 할 것이다. 이는 도메인 규칙이기 때문에 도메인 객체에 행동으로 정의되어야 한다.

만약에 User 클래스에 중복 확인을 하는 메서드가 있다고 생각해보자. 그러면 사용자명의 중복을 확인하는 메서드는 아래와 같이 호출 될 것이다.

let user: User = User()
user.isNameDuplicate(user: user) // 새로 만든 객체, 즉 자기 자신에게 중복 여부를 묻는다

위 코드는 자기 자신에게 중복 여부를 묻는 상황이 되어 부자연스러운 상황이 된다. 그렇다면 자기 자신에게 중복 여부를 확인하는 일을 해당 객체에 맡긴다면 결과로 참을 반환해야 할지 아니면 거짓을 반환해야 할 지 의문이다. 이런 상황은 개발자가 혼란을 일으키기 쉽다.

하지만 다른 접근법을 사용해서, 중복을 확인하는 목적으로만 사용되는 전용 인스턴스를 만들어보는 것이다.

class UserService {
    func isNameDuplicate() -> Bool {
        // check whether name is duplicate
        return false
    }
}

부자연스러움을 해결해주는 객체

도메인 서비스는 자신의 행동을 바꿀 수 있는 인스턴스만의 값을 갖지 않는다는 점에서 객체,, 엔티티와 다르다. UserService 인스턴스를 만들어서 사용자명 중복을 처리하면 앞선 상황처럼 자기 자신에게 중복 여부를 확인하거나 중복 확인을 위해 쓸데없는 인스턴스를 만들 필요가 없어졌다.

이렇게 값 객체나 엔티티에 정의하기 부자연스러운 처리를 도메인 서비스에 정의하면 자연스러운 코드를 만들 수 있다.

도메인 서비스를 남용한 결과

위와 같이 부자연스러운 처리만을 도메인 서비스에 정의하면 된다. 그렇지 않으면 모든 처리가 도메인 서비스에 정의될 수도 있다. 만약 사용자의 이름을 바꾸는 코드를 UserService로 옮겼다고 해보자. 그러면 User 클래스의 코드에서는 아래처럼 될 것이다.

class User {
    private let userId: UserId
    private(set) var name: String
    
    init(id:String, name: String) throws {
        self.userId = UserId(value: id)
        try checkNameIsValid(name)
        self.name = name
    }
}

모든 처리를 도메인 서비스에 구현하면 엔티티에는 게터와 세터만 남게 된다. 이런 경우 코드만으로는 도메인 규칙을 발견하기 어렵다. 즉 이 상태로는 도메인 객체는 데이터를 저장하는 용도만 있고, 다른 정보를 제공할 수 없게 된다.

위처럼 원래 객체가 포함했어야 할 지식이나 처리 내용을 모두 도메인 서비스나 애플리케이션서비스에 빼앗겨 자신이 제공할 수 있는 정보가 없는 도메인 객체를 빈혈 도메인 모델이라고 한다. 빈혈 도메인 모델에서는 데이터와 행위가 같이 모여져있지 않으므로 객체 지향 설계 원칙을 무시하는 것이 된다.

도메인 서비스는 가능한 한 피할 것

모든 행위를 도메인 서비스에 구현해서 도메인 객체를 모두 빈혈 도메인 객체로 만들 수 있다. 하지만 이는 가능한 한 피해야 한다.

앞서도 언급했지만, ** 도메인 서비스를 남용하면 데이터와 행위가 단절돼 로직이 흩어지기 쉽다. 이는 소프트웨어가 변화에 대응하는 유연성을 저하시킨다.**

엔티티/값 객체와 함께 유스케이스 수립하기

사용자를 다루는 유스케이스 중 ‘사용자를 생성하는 유스케이스’를 다뤄보도록 하자.

명세는 1. 클라이언트가 id, 생성자명을 지정해 사용자 생성 처리를 호출한다. 2. 중복이 없는 사용자명이라면 사용자를 생성해 저장한다.

사용자 엔티티 확인

앞서 User 엔티티, UserId 값 객체를 정의했다.

import Foundation
    
struct UserId : Equatable {
    let value: String
    
    func equals(userId: UserId) -> Bool {
        if value == userId.value {
            return true
        }
        
        return false
    }
    
    static func == (lhs: UserId, rhs: UserId) -> Bool {
        lhs.equals(userId: rhs)
    }
}

class User: Equatable {
    private let userId: UserId
    private(set) var name: String
    private let checkNameIsValid: (String) throws -> Void = { name in
        if name.count < 3 {
            throw ArgumentExceptionError.ArgumentExceptionError("이름이 3자 미만")
        }
    }
    
    init(id:String, name: String) throws {
        self.userId = UserId(value: id)
        try checkNameIsValid(name)
        self.name = name
    }
    
    func changeName(name: String) throws {
        try checkNameIsValid(name)
        self.name = name
    }
    
    func equals(user: User) -> Bool {
        if self === user || self.userId == user.userId {
            return true
        }
        return false
    }
    
    static func == (lhs: User, rhs: User) -> Bool {
        lhs.equals(user: rhs)
    }
}

사용자 생성 처리 구현

클라이언트 쪽에서 id와 name을 입력하면 name이 중복되는지 먼저 확인하고 중복이 안되면 local 에 저장하는 것을 해보겠다.

실제 코드는 아래와 같지 않지만, 간략하게 표현하기 위해 아래와 같이 썼다. 실제 프로젝트와 코드를 보려면 여기를 참고하면 될 것 같다. p/r, branch마다 각 step을 분리했다.

class ViewController {
   private func createUser(id: String, name: String) throws {
        if UserService.isNameDuplicate(userName: name) {
            return
        }
        
        let userModel: UserModel = try UserModel(id: id, name: name)
        
        // 이 밑이 모두 Data를 local에 저장하기 위한 로직임.
        let context = persistentContainer.viewContext
        let entity = NSEntityDescription.entity(forEntityName: "User", in: context)
        
        if let entity = entity {
            let user = NSManagedObject(entity: entity, insertInto: context)
            user.setValue(userModel.userId.value, forKey: "id")
            user.setValue(userModel.name, forKey: "name")
        }
        
        try context.save()
    }
}

위 코드를 보면 윗 부분은 user의 이름 중복 체크를 하고 userModel을 생성하는 것을 알 수 있다. 그 밑은 모두 local에 저장하기 위해 coreData 코드를 사용했다. CoreData를 이용하는 코드는 쉽게 이해하기가 어렵다. 물론 윗 부분의 코드에서 나오지는 않았지만 UserService에서도 이름 중복 여부를 체크하기 위해 local 데이터베이스를 조회하는 작업이 필요하다. 위의 코드는 제대로 동작하긴 하지만 유연성이 떨어진다. 만약 CoreData 대신에 Realm을 사용한다면 어떻게 될까? ViewControllerUserService에 모두 있는 CoreData를 사용하는 코드를 수정하지 않으면 안된다.

여기서 중요한 것은 유스케이스의 본질이 무엇인지 확인하는 것이다. 사용자 생성 유스케이스의 본질은 데이터를 조회해서 1. 사용자 이름의 중복 여부를 확인하는 것, 그리고 2. 사용자 생성 데이터를 저장하는 것이다. 코드에서는 이런 본질적인 내용을 다뤄야하지 특정 데이터스토어를 직접 다루면 안된다. 소프트웨어에서 데이터를 저장하는 것은 꼭 필요한데, 이는 리포지토리 패턴으로 작성한다.

image

도메인 서비스의 기준

도메인 서비스는 도메인 모델을 코드 상에 나타냈다는 점에서는 값 객체, 엔티티와 같다. 위에서 봤던 데이터 스토어는 도메인에는 원래 없는 존재로, 애플리이션 구현을 위해 추가된 애플리케이션만의 관심사다. 또한 유스케이스의 본질을 파악해서 유스케이스의 본질만을 코드로 나타내야 한다고 했는데, 어떤 처리를 도메인 서비스로 만들어야 할 지는 그 처리가 도메인에 기초한 것인지를 생각해봐야 한다. 애플리케이션을 만들며 필요하게 된 것은 도메인 서비스가 아니고, 이는 애플리케이션 서비스로 정의해야 한다.

정리

도메인에는 도메인 객체에 구현하기에 자연스럽지 못한 행위가 있다. 이런 행위는 여러 개의 도메인 객체를 거쳐 이뤄지는 처리인 경우가 많다. 도메인 서비스는 이런 경우에 유용하다. 빈혈 도메인 모델이 생기지 않으려면 어떤 행위를 어디에 구현해야 할 지 생각해봐야 한다. 행위가 빈약한 객체는 절차적 프로그래밍으로 빠지기 쉽기 때문에 도메인 지식을 객체의 해위로 나타낼 기회를 잃게 된다.

추가로 봤던 문제점은 위에서 설계한 유스케이스에서 처음부터 끝까지 데이터스토어를 다뤄야 한다는 것이다. 이를 리포지토리로 해결할 수 있다.

데이터와 관계된 처리를 분리하자 - 리포지토리

리포지토리란 무엇인가

소프트웨어 개발에서 말하는 리포지토리는 데이터 보관창고를 말한다. 소프트웨어로 도메인 개념을 표현하기만 하면 안된다. 메모리에 로드된 데이터는 프로그램을 종료하면 그대로 사라지는데, 엔티티는 생명 주기를 가진 객체이므로 프로그램 종료와 함께 객체가 없어지면 안된다. 즉 다시 이용하기 위해 데이터스토어에 객체 데이터를 저장/복원 할 수 있어야 한다. Repository는 데이터를 저장하고 복원하는 처리를 추상화하는 객체다.

리포지토리와 데이터스토어를 헷갈리면 안된다. 데이터스토어가 객체를 실제로 저장하고 복원하는 처리를 담당한다면, 리포지토리는 이를 추상화하는 애들이다. 즉 아래와 같은 구조를 가지게 된다.

image

실제 객체 인스턴스를 저장할 때는 앞서서 봤던 코드처럼 데이터스토어에 저장하는 코드를 그대로 호출하지 않고 리포지토리에 객체의 저장을 맡긴다. 이런 방법을 통해 소프트웨어의 유연성을 확보할 수 있다.

도메인 객체에 스포트라이트를 비추는 리포지토리

리포지토리는 도메인 객체와 달리, 도메인 개념에서 유래한 객체가 아니라는 점에서 도메인 객체와 차이가 있다. 하지만 그렇다고 도메인 객체와 무관한 것도 아니다. 앞서 봤던 예제들에서 실제 데이터스토어에 저장/복원 하는 코드들은 기술적인 요소와 관련이 있는 코드들이다. Core Data를 이용할지, Realm 을 이용할지에 따라 바뀔 수도 있다. 이런 코드때문에 원래 코드의 의도를 알기 어려워지는 문제가 생기기도 한다. 리포지토리는 이런 기술적 요소와 관련된 코드를 모아 문제 해결을 위한 코드가 침식되는 것을 막는다.

리포지토리의 책임

리포지토리의 책임은 도메인 객체를 저장, 복원하는 persistency다.

Persistency를 구현하는 코드는 기술에 많이 의존적이기 때문에 까다로운 경우가 많다. 이를 도메인 코드에 바로 노출시키면 앞서 봤던 코드와 같은 상황이 생기는 것이다.

// MARK: data handling
private func createUser(id: String, name: String) throws {
    let userModel: UserModel = try UserModel(id: id, name: name)

    let context = persistentContainer.viewContext
    let entity = NSEntityDescription.entity(forEntityName: "User", in: context)

    if let entity = entity {
        let user = NSManagedObject(entity: entity, insertInto: context)
        user.setValue(userModel.userId.value, forKey: "id")
        user.setValue(userModel.name, forKey: "name")
    }

    try context.save()
}

위 코드는 비교적 간단하지만, 그래도 let context = ~ 뒤에 오는 것들이 1. 기술이 변경됨에 따라 수정되어야 하고, 2. 원래 코드의 의미를 명확히 하는 것을 해치므로 이를 UserRepository로 따로 분리할 것이다. 이 코드를 아래와 같이 만들 것이다.

// MARK: data handling
private func createUser(id: String, name: String) throws {
    let userModel: UserModel = try UserModel(id: id, name: name)

    userRepository.save(userModel)
}

훨씬 간단해졌을 뿐만 아니라, 의도가 무엇인지 파악하기도 쉽다. 그리고 사용자 이름의 중복을 체크하는 UserService의 코드는 어떻게 바뀔 지 살펴보자.

static func isNameDuplicate(userName: String) -> Bool {
    // check whether name is duplicate

    let viewController = UIApplication.shared.windows.first!.rootViewController as! ViewController
    let context = viewController.persistentContainer.viewContext
    let query: NSFetchRequest<User> = User.fetchRequest()

    let predicate = NSPredicate(format: "name contains[c] %@", userName)
    query.predicate = predicate

    do{
        let users = try context.fetch(query)

        if users.count > 0 {
            return true
        }
    }catch{
        print("error")
    }

    return false
}

도메인 서비스의 메서드의 코드 전체가 데이터 스토어를 사용하고 있다고 해도 과언이 아니다. 이는 아래와 같이 바꿀 것이다.

static func isNameDuplicate(userName: String) -> Bool {
    // check whether name is duplicate
    guard let result = userRepository.Fetch(userName) else {
      return false
    }
    return true
}

위와 같이 바꿨더니 데이터베이스를 다루는 코드가 없어지고 의도를 명확히 파악할 수 있게 되었다.

리포지토리의 인터페이스

이제 각 메서드들이 UserReposiotry를 통해서 어떻게 바뀌었을 지 알아봤으니 UserRepository 를 작성해보겠다. 앞서 말했듯이, repository는 persistency를 추상화하기 때문에 interface, 지금은 swift 로 작성하고 있으니 protocol로 작성해줄 것이다.

UserRepository

protocol UserRepository {
    @discardableResult
    func findUserByName(name: String) -> [UserModel]?
    func save(user: UserModel)
}

DefaultUserRepository(UserRepository의 구현체)

final class DefaultUserRepository {
    private let viewContext: NSManagedObjectContext
    
    init(viewContext: NSManagedObjectContext) {
        self.viewContext = viewContext
    }
}

extension DefaultUserRepository: UserRepository {
    func save(user: UserModel) {
        let entity = NSEntityDescription.entity(forEntityName: "User", in: viewContext)
        
        if let entity = entity {
            let userEntity = NSManagedObject(entity: entity, insertInto: viewContext)
            userEntity.setValue(user.userId.value, forKey: "id")
            userEntity.setValue(user.name, forKey: "name")
        }
        do {
            try viewContext.save()
        } catch {
            print(error)
        }
    }
    
    func findUserByName(name: String) -> [UserModel]? {
        let query: NSFetchRequest<User> = User.fetchRequest()
        
        let predicate = NSPredicate(format: "name contains[c] %@", name)
        query.predicate = predicate

        do{
            let users = try viewContext.fetch(query)
            
            if users.count == 0 {
                return nil
            }
            
            return users.map { user -> UserModel in
                var userModel: UserModel!
                do {
                    userModel =  try UserModel(id: user.id!, name: user.name!)
                } catch {
                    print(error)
                }
                return userModel
            }
            
        } catch{
            print("error")
        }
        
        return nil
    }
}

이제 UserService의 코드는 아래와 같이 바뀐다.

UserService

class UserService {
    
    private let userRepository: UserRepository
    
    init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }
    
    func isNameDuplicate(userName: String) -> Bool {
        // check whether name is duplicate
        if userRepository.findUserByName(name: userName) != nil {
            return true
        }
        return false
    }
    
    func saveUser(user: UserModel) {
        userRepository.save(user: user)
    }
}

훨씬 간단해지지 았았나? 여기서 주의할 점은 리포지토리의 책임은 객체의 퍼시스턴시까지라는 것이다. 위의 isNameDuplicate와 같이 사용자명 중복 확인과 같은 목적이 있다면 이 메서드 자체를 리포지토리에 구현해서 이 메서드를 아래와 같이 만들어버리겠다는 생각을 할 수 있다.

func isNameDuplicate(user: userModel) -> Bool {
    // check whether name is duplicate
    return userRepository.Exists(user)
}

위와 같이 작성하면, 사용자의 중복 확인이 사용자명 기준이라는 지식이 도메인 객체에서 누락된다. 위에서 보여줬던 코드들에서 메서드 이름과 인자를 명시적으로 name으로 받아줬기 때문에 이런 여지가 없었던 것이지, 충분히 위와 같은 상황이 생길 수도 있다.

리포지토리의 책임은 객체의 퍼시스턴시까지이기때문에, 사용자명의 중복 확인은 도메인 규칙에 가까워서 이를 리포지토리에 구현하는 것은 리포지토리의 책임을 벗어나는 일이다. 따라서 사용자명 중복 확인은 도메인 서비스가 주체가 되는 것이 맞다.

null과 optional 타입

null로 인한 버그를 방지하기 위한 가장 좋은 방법은 null을 사용하지 않는 것이다.

CoreData를 사용하는 리포지토리 구현하기

final class DefaultUserRepository {
    private let viewContext: NSManagedObjectContext
    
    init(viewContext: NSManagedObjectContext) {
        self.viewContext = viewContext
    }
}

extension DefaultUserRepository: UserRepository {
    func save(user: UserModel) {
        let entity = NSEntityDescription.entity(forEntityName: "User", in: viewContext)
        
        if let entity = entity {
            let userEntity = NSManagedObject(entity: entity, insertInto: viewContext)
            userEntity.setValue(user.userId.value, forKey: "id")
            userEntity.setValue(user.name, forKey: "name")
        }
        do {
            try viewContext.save()
        } catch {
            print(error)
        }
    }
    
    func findUserByName(name: String) -> [UserModel]? {
        let query: NSFetchRequest<User> = User.fetchRequest()
        
        let predicate = NSPredicate(format: "name contains[c] %@", name)
        query.predicate = predicate

        do{
            let users = try viewContext.fetch(query)
            
            if users.count == 0 {
                return nil
            }
            
            return users.map { user -> UserModel in
                var userModel: UserModel!
                do {
                    userModel =  try UserModel(id: user.id!, name: user.name!)
                } catch {
                    print(error)
                }
                return userModel
            }
            
        } catch{
            print("error")
        }
        
        return nil
    }
}

내가 지금 사용하는 언어가 swift이고, CoreData를 사용하기 좋은 환경이어서 이를 사용했는데, 구현할 때는 자신이 사용하고자 하는 것(SQL 등등)에 맞춰서 구현하면 될 것같다.

여기서 등장하는 비즈니스 로직에서 특정한 기술에 의존하는 구현은 바람직하지 않지만, 리포지토리의 구현 클래스라면 특정 기술에 의존하는 구현은 문제가 없다. 그리고 ViewController에서는 아래와 같이 repository를 가질 수 있다.

private let userService: UserService = UserService(userRepository: DefaultUserRepository(viewContext: PersistencyManager.shared().persistentContainer.viewContext))

코드가 좀 지저분 한데, 이는 나중에 Container를 이용해서 차차 정리할 것이다.

즉 아래와 같은 구조를 통해 repository의 메서드가 view단에서 호출되고 있다.

image

위처럼 인터페이스를 잘 활용하면 ViewController 단에서 퍼시스턴시와 관련된 구체적인 처리를 구현하지 않아도 객체 인스턴스를 데이터스토ㅓ에 저장할 수 있다.

테스트로 구현 검증하기

TDD라는 말이 있을 정도로 테스트는 소프트웨어 개발에 있어서 필수적이다. 코드를 다 짜놓고 에러가 터지기 전까지 불안해하는 것보다 테스트를 통해 확실히 검증하는 것이 훨씬 좋을 것이다. 테스트는 소프트웨어가 변경될 때에도 효과가 있다. 테스트를 통해 코드를 변경한 후에도 원래 있던 기능이 제대로 동작하는 지 확인하면 소프트웨어 변경에 따른 검증 비용을 줄일 수 있다.

테스트용 리포지토리 만들기

테스트만을 위한 인프라를 갖추는 것은 번거롭다. 이 문제를 해결하기 위해 테스트용 리포지토리를 만들거나 테스트용 환경을 구축하는 것이다. Core Data에서는 아래와 같이 설정해서 테스트용 repository를 구축한다.

class TestCoreDataStack: NSObject {
    lazy var persistentContainer: NSPersistentContainer = {
        let description = NSPersistentStoreDescription()
        description.url = URL(fileURLWithPath: "/dev/null") // /dev/null로 설정하는 것이 중요하다.
        
        let container = NSPersistentContainer(name: "Model")
        container.persistentStoreDescriptions = [description]
        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
        return container
    }()
}

다른 언어 (C# 등등)과 같은 경우였다면 repository에서 찾은 객체를 그대로 반환하는 것이 아니라 깊은 복사를 통해 만든 새로운 객체를 반환해야 한다. 이렇게 하는 이유는 복원된 인스턴스를 조작했을 때 리포지토리에 저장된 객체에 그 영향이 미치지 않게 하기 위해서이다. 만약 깊은 복사를 하지 않으면 가져온 데이터를 조작했을 때 리포지토리에 저장된 인스턴스가 영향을 받을 수 있다.

repository를 사용해서 객체를 저장하고 이름을 통해 사용자를 불러오는 것을 테스트하는 것을 아래와 같이 구현했다. Core Data를 unit testing하는 방법은 이후 다른 글에서 더 자세하게 다룰 것이다.

class UserRepositoryTests: XCTestCase {
    var repository: UserRepository!
    var context: NSManagedObjectContext!

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        context = TestCoreDataStack().persistentContainer.newBackgroundContext()
        repository = DefaultUserRepository(viewContext: context)
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        repository = nil
        context = nil
    }

    func testSave() throws {
        let userModel1 = try DDD.UserModel(id: "id", name: "name100")
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context) { _ in
            return true
        }
        
        context.perform { [weak self] in
            self?.repository.save(user: userModel1)
        }
    
        waitForExpectations(timeout: 2.0) { error in
            XCTAssertNil(error, "save not occured")
        }
        
    }
    
    func testFindByName() throws {
        let userModel1 = try DDD.UserModel(id: "id", name: "name1")
        let userModel2 = try DDD.UserModel(id: "id", name: "name2")
        let userModel3 = try DDD.UserModel(id: "id", name: "name3")

        repository.save(user: userModel1)
        repository.save(user: userModel2)
        repository.save(user: userModel3)
        
        let result1 = repository.findUserByName(name: "name1")
        let result2 = repository.findUserByName(name: "name2")
        let result3 = repository.findUserByName(name: "name3")
        
        XCTAssertNotNil(result1, "result1 nil")
        XCTAssertNotNil(result2, "result1 nil")
        XCTAssertNotNil(result3, "result1 nil")

        XCTAssertEqual(result1!.first?.name, "name1")
        XCTAssertEqual(result2!.first?.name, "name2")
        XCTAssertEqual(result3!.first?.name, "name3")
    }

    func testPerformanceExample() throws {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }
}

위 두 테스트를 실행해서 실제로 기능이 잘 동작하는지를 확인한다.

image

리포지토리에 정의되는 행동

리포지토리에 정의되는 행위는 퍼시스턴시, 즉 객체의 저장/복원에 대한 것이라고 했다.

객체의 저장과 관련된 행위

protocol UserRepository {
    @discardableResult
    func save(user: UserModel)
}

객체를 저장할 떄는 저장 대상 객체를 인자로 전달받아야 하지, 아래처럼 객체의 식별자/수정 항목을 인자로 받게 메서드를 정의하면 안된다.

protocol UserRepository {
    @discardableResult
    func updateName(id: String, name: String)
}

위와 같이 정의했을 때 문제가 될 수 있는 것은 리포지토리에 수많은 수정 메서드가 생길 수 있기 때문이다.

protocol UserRepository {
    @discardableResult
    func updateName(id: String, name: String)
    func updateAddress(id: String, address: String)
    func updateAge(id: String, age: Int)
}

객체가 저장하고 있는 데이터를 수정하려면 애초에 리포지토리가 아닌 객체 자체에 맡기는 것이 좋다. 마찬가지로 객체를 생성하는 처리도 리포지토리에 정의해서는 안된다.

생성과 마찬가지로, persistnecy에는 객체를 삭제하는 것도 있을 수 있다. 생애주기를 다한 객체는 삭제해야 하고, 이는 리포지토리에서 지원해야 한다.

protocol UserRepository {
    func deleteUser(user: UserModel)
}

저장된 객체의 복원과 관계된 행위

저장된 객체를 복원하는 것중에 많이 쓰이는 방법은 식별자로 검색을 수행하는 것이다.

protocol UserRepository {
    @discardableResult
    func findUser(id: String)
}

원래는 식별자를 키로 하는 검색 메서드를 사용하지만, 사용자명 중복 여부를 확인하는 등의 이유로 모든 객체를 받아와야 할 수 있다. 이를 위해 모든 객체를 받아오는 메서드를 정의한다.

protocol UserRepository {
    @discardableResult
    func fetchAllUser() -> [UserModel]
}

다만 가져와지는 객체에 따라 컴퓨터의 자원이 바닥이 날 수 있으니 성능 문제를 회피하려면 검색에 적합한 메서드를 제공한다.

protocol UserRepository {
    @discardableResult
    func findUser(id: String)
    func findUserByName(name: String)
}

위 메서드는 검색의 키가 될 데이터를 인자로 받기 때문에 리포지토리의 구현체도 최적화된 검색을 수행할 수 있다.

정리

로직이 특정 infrastructure의 기술에 의존하면 소프트웨어의 유연성이 떨어진다. 이는 위에서도 확인했듯이 코드의 대부분이 리포지토리에 의존적이게 되며 코드의 의도가 잘 드러나지 않는다.

리포지토리를 이용하면 데이터 퍼시스턴시와 관련된 처리를 추상화해서 소ㅡ트웨어의 유연성을 향상시킬 수 있다.

유스케이스를 구현하기 위한 ‘애플리케이션 서비스’

앞서서 서비스에는 크게 도메인 서비스, 애플리케이션 서비스 이렇게 두 가지가 있다고 했다. 애플리케이션 서비스는 유스케이스를 구현하는 객체라고 할 수 있다.

지금까지 사용자를 다뤄으므로 사용자와 관련된 기능을 예로 들면 ‘사용자 등록하기 유스케이스’, ‘사용자 정보 수정하기 유스케이스’ 등이 필요하다. 이런 행위는 도메인 객체를 실제로 조합해 실행되는 스크립트같은 것이다.

애플리케이션 서비스의 의미

애플리케이션은 일반적으로 이용자의 목적에 부응하는 프로그램을 말한다. 따라서 목표는 사용자의 목적을 충족시키는 것이다. 도메인 객체는 도메인을 코드로 옮긴 것이다. 하지만 도메인을 코드로 나타내는 것만으로는 사용자의 문제가 해결되지는 않는다. ㄷ문제를 해결하려면 도메인 객체를 엮어 올바른 행위를 해야 한다. 애플리케이션 서비스는 도메인 객체를 조작해서 이용자의 목적을 달성하게 이끄는 객체가 된다.

유스케이스 수립하기

아까 예시를 들었던 사용자 관련 유스케이스를 구현해보도록 하자. 사용자와 관련된 유스케이스는 아래와 같이 정리할 수 있을 것이다.

  1. 사용자 등록(Create)
  2. 사용자 정보 확인(Read)
  3. 사용자 정보 수정(Update)
  4. 탈퇴하기(Delete)

도메인 객체 준비하기

우선 사용자는 생애주기를 갖는 모델이므로 엔티티로, 앞서 사용했던 User 클래스를 이용할 것이다.

ddddsss