글 작성자: HEROHJK

드디어 마지막장입니다.

 

간단한 예시 코드를 테스트가 가능한 구조로 리팩토링하고, 그것을 평가하는 시간을 가져보겠습니다!

 

ERP 시스템 - 이메일 변경 기능

  • 이메일 변경 기능
  • 이메일이 회사 도메인일 경우 직원, 아니면 고객으로 분류.
  • 직원수를 체크
  • 모든 사용자정보는 DB에 저장
  • 변경 후 외부에 알림
class User {
    private(set) var id: Int
    private(set) var email: String
    private(set) var type: UserType

    // 생성자 생략
}


extension User {
    public func changeEmail(newEmail: String, company: Company) {
        if self.email == newEmail { return }
        
        // 이메일 검증 과정, 위에서 구현한 Company에서 처리 (책임 분산)
        let newType = company.isEmailCorporate(newEmail) ? .employee : .customer

        if self.type != newType {
            let delta = newType == .employee ? 1 : -1
            // 직원수 또한 Company에서 처리 (책임 분산)
            company.changeNumberOfEmployees(delta)
        }

        self.email = newEmail
        self.type = newType
    }
}

class Company {
    private(set) var domainName: String
    private(set) var numberOfEmployees: Int
    
    // 생성자 생략
}

extension Company {
    public func changeNumberOfEmployees(delta: Int) {
        Precondition.Requires(self.numberOfEmployees + delta >= 0)

        // 직원 수는 회사에서 관리
        self.numberOfEmployees += delta
    }

    public func isEmailCorporate(email: String) -> Bool {
        let emailDomain = email.split("@")[1]
        
        // 회사 이메일 도메인 검증 또한 회사에서 관리
        return self.domainName == emailDomain
    }
}

public class UserFactory {
    public class func create(data: Data) -> User {
        if !dataCheck(data) { fatalError("Invalid User Data") } // 해당 데이터가 User의 데이터가 맞는지 검사.
        
        let email = String(data[1])
        let type = UserType(data: data[2])
        
        return User(email, type)
    }
    
}

public class CompanyFactory {
    public class func create(data: Data) -> Company {
        if !dataCheck(data) { fatalError("Invalid Company Data") } // 해당 데이터가 Company의 데이터가 맞는지 검사.
        
        let domainName = String(data[0])
        let numberOfEmployees = Int(data[1])
        
        return Company(domainName, numberOfEmployees)
    }
}

class UserController { // 험블 컨트롤러
    private let database: DataBase
    private let messageBus: MessageBus
    
    // 생성자 생략

    public func changeEmail(userID: Int, newEmail: String) {
        // DB를 모델링하는 과정, 리팩토링하며 만든 팩토리 클래스가 수행.
        let user = UserFactory.create(data: self.database.getUserById(userID))
        let company = CompanyFactory.create(data: self.database.getCompany())

        // 이메일을 바꾸는 과정. 아래 재작성한 User가 수행
        user.changeEmail(newEmail: newEmail, company: company)

        // 외부 협력자를 호출
        self.database.saveCompany(company)
        self.database.saveUser(user)
        self.messageBus.sendEmailChangedMessage(userID, newEmail)
    }
}

 

 

이런 코드가 있습니다.

조금 더 구체적으로 이 코드는 리팩토링을 진행한 코드입니다.

 

이 코드만 두고 봤을때는, 당연하다고 생각이 될지도 모르지만, 많은 개발자들은 일정이 그렇게 여유있지 않습니다.

테스트와 프로젝트 관리가 서툴다면 이렇게까지 세밀하게 나누어지는 경우는 드물다고 생각합니다.

(예시코드이긴 하지만, 당장 코드만 보면 그렇게 복잡한 코드도 아니거든요..)

 

보통은 아래의 코드와 같은 상태일것이라고 예상합니다. (혹은 그것보다 더 안좋거나..)

 

class User {
    private(set) var id: String
    private(set) var email: String
    private(set) var type: UserType

    public func changeEmail(userID id: Int, newEmail: String) {
        /// 이 코드처럼 도메인 클래스가 스스로 DB를 검색하고 저장하는 방식을 활성 레코드 패턴이라고 함.
        /// 코드 베이스가 커지면 커질수록, 확장에 애로사항이 생김.
        let data = Database.getUserById(id) // 외부 협력자
        self.id = id
        self.email = String(data[1])
        self.type = UserType(data: data[2])

        if self.email == newEmail { return }

        let companyData = Database.getCompany() // 외부 협력자
        let companyDomainName = String(companyData[0])
        let numberOfEmployees = Int(companyData[1])

        let emailDomain = newEmail.split("@")[1]
        let isEmailCorporate = emailDomain == companyDomainName
        let newType: UserType = isEmailCorporate ? .employee : .customer

        if self.type != newType {
            let delta = newType == .employee ? 1 : -1
            let newNumber = numberOfEmployees + delta
            Database.saveCompany(newNumber) // 외부 협력자
        }

        self.email = newEmail
        self.type = newType

        Database.saveUser(self) // 외부 협력자 
        MessageBus.sendEmailChangedMessage(self.id, newEmail) // 외부 협력자
    }

    public enum UserType {
        case customer
        case employee
    }
}

DB와 통신을 하고, 이메일을 변경하는 비즈니스로직이 합쳐져있습니다.

DB와 메시지버스는 외부 의존성입니다.

협력자수도 많고, 애플리케이션의 핵심 로직이므로 지나치게 복잡한 코드로 분류가 됩니다.

 

1. 암시적 의존성을 명시적으로 만들기

아이디와 이메일은 명시적입니다.

하지만 DB와 메시지버스는 암시적 의존성으로 분류할 수 있습니다.

따라서 클래스 내 DB와 메시지버스의 인터페이스를 추가합니다.

테스트시에는 Mock으로 처리합니다.

(DB는 저장, 메시지버스는 전송 측면이므로 둘 다 명령의 기능을 가지고 있습니다.)

 

2. 애플리케이션 서비스 계층 도입

1번의 연장인데, 외부 서비스와 직접 통신하기 위해 User에서 험블 컨트롤러를 따로 만들어, 책임을 나누어줍니다.

class UserController { // 험블 컨트롤러
    private let database
    private let messageBus
    
    public init(database: DataBase, messageBus: MessageBus) {
    	// 추후 추가적인 요구사항이 생긴다면 이또한 팩토리 클래스로 나눠볼만 하다.
    	self.database = database
        self.messageBus = messageBus
    }

    public func changeEmail(userID: Int, newEmail: String) {
        // DB데이터를 모델링 하는건 서비스에 속하면 안됨.
        let data = self.database.getUserById(userID)
        let email = String(data[1])
        let type = UserType(data: data[2])
        var user = User(userID, email, type) 
        
        // 위와 마찬가지
        let companyData = self.database.getCompany()
        let companyDomainName = String(companyData[0])
        let numberOfEmployees = Int(companyData[1])

        let newNumberOfEmployees = user.changeEmail(newEmail, companyDomainName, numberOfEmployees)

        self.database.saveCompany(newNumberOfEmployees)
        self.database.saveUser(user)
        self.messageBus.sendEmailChangedMessage(userID, newEmail) // 이메일이 이전과 다른지 검사하지 않고, 무조건 메시지를 전송.
    }
}

그리고, 협력자가 제거된 User 클래스를 만들어봅니다.

class User {
    // 위 험블 컨트롤러를 반영한 후 수정된 changeEmail.
    // 더이상 협력자를 필요로 하지 않지만, 여전히 많은 책임을 지고 있다.
    public func changeEmail(newEmail: String, companyDomainName: String, numberOfEmployees: Int) -> Int {
        if self.email == newEmail { return numberOfEmployees }

        var numberOfEmployees = numberOfEmployees

        let emailDomain = newEmail.split("@")[1]
        let isEmailCorporate = emailDomain == companyDomainName
        let newType: UserType = isEmailCorporate ? .employee : .customer

        if self.type != newType {
            let delta = newType == .employee? 1 : -1
            let newNumber = numberOfEmployees + delta
            numberOfEmployees = newNumber
        }

        self.email = newEmail
        self.type = newType

        return numberOfEmployees
    }
}

우선 User Class 자체는 외부 협력자와 격리되었습니다.

 

3. 서비스 복잡도 낮추기

우선 복잡도를 낮추기 위해 Company 클래스를 만들어봅니다.

그리고, User와 Company를 팩토리 패턴을 사용하여, 모델링을 위한 생성자를 만듭니다.

이는 ORM을 용이하게 사용하기 위함입니다.

class Company {
	var domainName: String
    var numberOfEmployees: Int
    
    // 생성자 생략..
}

public class UserFactory {
	public class func create(data: Data) -> User {
    	if !dataCheck(data) { fatalError("Invalid User Data") } // 해당 데이터가 User의 데이터가 맞는지 검사.
        
        let email = String(data[1])
        let type = UserType(data: data[2])
        
        return User(email, type)
    }
    
}

public class CompanyFactory {
	public class func create(data: Data) -> Company {
    	if !dataCheck(data) { fatalError("Invalid Company Data") } // 해당 데이터가 Company의 데이터가 맞는지 검사.
        
    	let domainName = String(data[0])
        let numberOfEmployees = Int(data[1])
        
        return Company(domainName, numberOfEmployees)
    }
}

이후, Company에서 이메일 검사와 직원수 변동에 대한 처리 로직을 작성합니다.

extension Company {
	public func changeNumberOfEmployees(delta: Int) {
        Precondition.Requires(self.numberOfEmployees + delta >= 0)

        // 직원 수는 회사에서 관리
        self.numberOfEmployees += delta
    }

    public func isEmailCorporate(email: String) -> Bool {
        let emailDomain = email.split("@")[1]
        
        // 회사 이메일 도메인 검증 또한 회사에서 관리
        return self.domainName == emailDomain
    }
}

정리된 코드

정리된 코드를 전체적으로 살펴보면 이렇습니다.

class User {
    private(set) var id: Int
    private(set) var email: String
    private(set) var type: UserType

    // 생성자 생략
}


extension User {
    public func changeEmail(newEmail: String, company: Company) {
        if self.email == newEmail { return }
        
        // 이메일 검증 과정, 위에서 구현한 Company에서 처리 (책임 분산)
        let newType = company.isEmailCorporate(newEmail) ? .employee : .customer

        if self.type != newType {
            let delta = newType == .employee ? 1 : -1
            // 직원수 또한 Company에서 처리 (책임 분산)
            company.changeNumberOfEmployees(delta)
        }

        self.email = newEmail
        self.type = newType
    }
}

class Company {
    private(set) var domainName: String
    private(set) var numberOfEmployees: Int
    
    // 생성자 생략
}

extension Company {
    public func changeNumberOfEmployees(delta: Int) {
        Precondition.Requires(self.numberOfEmployees + delta >= 0)

        // 직원 수는 회사에서 관리
        self.numberOfEmployees += delta
    }

    public func isEmailCorporate(email: String) -> Bool {
        let emailDomain = email.split("@")[1]
        
        // 회사 이메일 도메인 검증 또한 회사에서 관리
        return self.domainName == emailDomain
    }
}

public class UserFactory {
    public class func create(data: Data) -> User {
        if !dataCheck(data) { fatalError("Invalid User Data") } // 해당 데이터가 User의 데이터가 맞는지 검사.
        
        let email = String(data[1])
        let type = UserType(data: data[2])
        
        return User(email, type)
    }
    
}

public class CompanyFactory {
    public class func create(data: Data) -> Company {
        if !dataCheck(data) { fatalError("Invalid Company Data") } // 해당 데이터가 Company의 데이터가 맞는지 검사.
        
        let domainName = String(data[0])
        let numberOfEmployees = Int(data[1])
        
        return Company(domainName, numberOfEmployees)
    }
}

class UserController { // 험블 컨트롤러
    private let database: DataBase
    private let messageBus: MessageBus
    
    // 생성자 생략

    public func changeEmail(userID: Int, newEmail: String) {
        // DB를 모델링하는 과정, 리팩토링하며 만든 팩토리 클래스가 수행.
        let user = UserFactory.create(data: self.database.getUserById(userID))
        let company = CompanyFactory.create(data: self.database.getCompany())

        // 이메일을 바꾸는 과정. 아래 재작성한 User가 수행
        user.changeEmail(newEmail: newEmail, company: company)

        // 외부 협력자를 호출
        self.database.saveCompany(company)
        self.database.saveUser(user)
        self.messageBus.sendEmailChangedMessage(userID, newEmail)
    }
}

지금의 예시코드는 간단한 코드이기에, Factory 패턴이 필요가 없긴 하지만, 우선은 넣어보았습니다..

 

이렇게 처음에 보여진 코드의 형태로 완성되었습니다.

 

도메인 모델 및 알고리즘 테스트

void func test_고객에서_직원으로() {    
    let company = Company("mycorp.com", 1)
    var sut = User(1, "user@gmail.com", .customer)

    sut.changeEmail("new@mycorp.com", company)

    XCTAssertEqual(2, company.numberOfEmployees)
    XCTAssertEqual("new@mycorp.com", sut.email)
    XCTAssertEqual(UserType.employee, sut.type)
}

void func test_직원에서_고객으로() {}
void func test_타입은_변하지않을때() {}
void func test_이메일이_바뀌지않을때() {}

이런식으로 도메인 모델, 알고리즘 사분면에 속한 코드들에 대한 테스트를 진행합니다.

 

물론 이후에 컨트롤러도 통합테스트의 영역에서 테스트가 필요합니다만, 현재는 단위테스트의 영역만 다루겠습니다.

 

단위테스트의 4대 요소에 대한 평가.

1. 회귀 방지: 코드의 양이 많아지거나 복잡해지는 경우 발생할 가능성이 높습니다. 복잡한 코드를 전과 비교해서 작은 단위로 나누었기 때문에 회귀방지에 어느정도 도움이 된다고 볼 수있습니다.

2. 리팩터링 내성: 거짓양성이 발생하는 케이스입니다. 이후 UserController를 테스트할때는 추가적인 조치가 필요하겠지만, 현재의 경우 구현 세부사항 자체를 제가 생략(ㅡ,ㅡ)했기 때문에, 예시코드에서는 보기가 어렵지만.. 저기에 들어갈 세부적인 로직을 분리하면 됩니다..

3. 빠른 피드백: 이 부분도 UserController로 의존성을 옮겼기 때문에, 상대적으로 피드백이 빨라졌다고 할수 있습니다.

4. 유지보수성: 계층이 나누어져서 전체적으로는 코드가 많아졌지만. "단위테스트"의 측면에서는 테스트하려는 기능이 나뉘어졌기 때문에 짧아졌고, 이해하기가 쉬워졌습니다.

 

끝으로..

간단하게나마, 정리가 필요해보이는 코드를 나름의 근거를 가지고 어떻게 나누고, 평가해야할지 알아보았습니다.

 

하지만 통합테스트, 컨트롤러의 테스트 및 추가적인 처리사항등 공유할내용이 많습니다.

 

하지만 이는 단위테스트의 영역을 벗어나기 때문에, 추후 기회가 되어 한번 더 다루었으면 좋겠습니다.

반응형