Code/Test

[단위 테스트] 7-2. 가치 있는 단위 테스트를 위한 리팩터링: 감사 시스템

noahkim_ 2025. 2. 28. 05:28

블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.

 

2. 가치 있는 단위 테스트를 위한 리팩터링하기

고객 관리 시스템

이메일 변경
  • 사용자 이메일이 회사 도메인에 속한 경우, 해당 사용자는 직원으로 표시됨 (그렇지 않으면 고객으로 간주)
  • 시스템은 회사의 직원 수를 추적해야 함
  • 이메일이 변경되면 시스템은 메시지 버스로 메시지를 보내 외부 시스템에 알려야 함

 

초기 구현
항목 설명
도메인 유의성
사용자 식별
- UserType을 통해 사용자가 직원인지 고객인지 구별 (이메일 도메인에 따라 결정)
복잡도
직원 수 업데이트
- 이메일 도메인에 따라 사용자 유형을 변경하고, 직원 수를 업데이트하는 로직 포함
협력자
명시적: userId, newEmail (값 전달 방식으로 협력, 협력자 수에 포함되지 않음)
암시적: Database, MessageBus (외부 시스템과 협력, 직접 제어되지 않음)

 

코드

더보기
더보기
public class User {
    private int userId;
    private String email;
    private UserType type;

    // getter...

    public void changeEmail(int userId, String newEmail) {
        Object[] data = Database.getUserById(userId);
        this.userId = userId;
        this.email = (String) data[1];
        this.type = (UserType) data[2];

        if (email.equals(newEmail)) return;

        Object[] companyData = Database.getCompany();
        String companyDomainName = (String) companyData[0];
        int numberOfEmployees = (int) companyData[1];

        String emailDomain = newEmail.split("@")[1];
        boolean isEmailCorporate = emailDomain.equals(companyDomainName);
        UserType newType = isEmailCorporate ? UserType.EMPLOYEE : UserType.CUSTOMER;

        if (type != newType) {
            int delta = newType == UserType.EMPLOYEE ? 1 : -1;
            int newNumber = numberOfEmployees + delta;
            Database.saveCompany(newNumber);
        }

        this.email = newEmail;
        this.type = newType;

        Database.saveUser(this);
        MessageBus.sendEmailChangedMessage(userId, newEmail);
    }
}
  • 문제점 (활성 레코드 패턴)
    • 도메인 클래스가 스스로 데이터베이스를 검색하고 다시 저장하는 방식
    • 두 가지 책임을 맡으므로 코드베이스가 커지면 확장하기 어려움

 

애플리케이션 서비스 계층 도입
  • 외부 시스템간의 통신에 책임 분리하기 (험블 컨트롤러로 책임 옮기기)
  • 도메인 모델은 프로세스 외부 협력자에게 의존하지 않는 것 (내부 의존성에만 의존해야 함)

 

코드

더보기
더보기
public class UserController {
    private final Database database;
    private final MessageBus messageBus;

    // 생성자를 통해 의존성 주입
    public UserController(Database database, MessageBus messageBus) {
        this.database = database;
        this.messageBus = messageBus;
    }

    public void changeEmail(int userId, String newEmail) {
        // 데이터베이스에서 사용자 정보 가져오기
        Object[] data = database.getUserById(userId);
        String email = (String) data[1];
        UserType type = (UserType) data[2];
        User user = new User(userId, email, type);

        // 회사 정보 가져오기
        Object[] companyData = database.getCompany();
        String companyDomainName = (String) companyData[0];
        int numberOfEmployees = (int) companyData[1];

        // 이메일 변경 처리 및 직원 수 업데이트
        int newNumberOfEmployees = user.changeEmail(newEmail, companyDomainName, numberOfEmployees);

        // 데이터베이스에 변경 사항 저장
        database.saveCompany(newNumberOfEmployees);
        database.saveUser(user);

        // 이메일 변경 알림 메시지 전송
        messageBus.sendEmailChangedMessage(userId, newEmail);
    }
}
  • 문제점 (애플리케이션 서비스 복잡도)
    • 애플리케이션 서비스는 오직 오케스트레이션 코드만 다뤄야 함 (User 인스턴스 생성은 도메인 모델에 속해야 함)
    • 이전 이메일과 수정하려는 이메일이 같더라도 무조건 데이터베이스에 갱신 명령을 내림
    • 복잡한 목 체계
    • 테스트 취약성 (목과 실제 외부 시스템과 일치하지 못할 가능성 존재)

 

인스턴스 재구성 로직 추출하기
  • 책임 소재가 분명해짐
  • 모든 협력자와 격리되어 테스트하기 쉬워짐

 

코드

더보기
더보기
public class UserFactory {
    public static User create(object[] data) {
        Precondition.Requires(data.Length >= 3);
        
        int id = (int)data[0];
        string email = (string)data[1];
        UserType type = (UserType)data[2];
        
        return new User(id, email, type);
    }
}

 

새 Company 클래스
  • 업데이트 된 직원 수를 반환하는 부분을 맡음
  • 육각형 아키텍처의 부작용 처리 방식을 따름
    • 마지막 순간까지 모든 부작용이 메모리에 남아있음
    • 출력 기반 테스트와 상태 기반 테스트로 검증이 가능함

 

코드

더보기
더보기
public class Company {
    private String domainName;
    private int numberOfEmployees;
    
    public Company(String domainName, int numberOfEmployees) {
        this.domainName = domainName;
        this.numberOfEmployees = numberOfEmployees;
    }

    // getter

    public void changeNumberOfEmployees(int delta) {
        if (numberOfEmployees + delta < 0) {
            throw new IllegalArgumentException("Number of employees cannot be negative.");
        }
        numberOfEmployees += delta;
    }

    public boolean isEmailCorporate(String email) {
        String emailDomain = email.split("@")[1];
        return emailDomain.equals(domainName);
    }
}
public class UserController {
    private final Database database;
    private final MessageBus messageBus;

    // 생성자를 통해 의존성 주입
    public UserController(Database database, MessageBus messageBus) {
        this.database = database;
        this.messageBus = messageBus;
    }

    public void changeEmail(int userId, String newEmail) {
        // 사용자 데이터 가져오기
        Object[] userData = database.getUserById(userId);
        User user = UserFactory.create(userData);

        // 회사 데이터 가져오기
        Object[] companyData = database.getCompany();
        Company company = CompanyFactory.create(companyData);

        // 이메일 변경 및 회사 정보 업데이트
        user.changeEmail(newEmail, company);

        // 변경된 데이터 저장
        database.saveCompany(company);
        database.saveUser(user);

        // 이메일 변경 알림 전송
        messageBus.sendEmailChangedMessage(userId, newEmail);
    }
}

 

3. 최적의 단위 테스트 커버리지 분석

도메인 계층과 유틸리티 코드 테스트하기

전제 조건 테스트
  • 회사의 직원 수가 음수가 돼서는 안된다는 전제 조건
  • 예외 상황에서만 활성화되는 보호 장치
  • 소프트웨어가 빠르게 실패하고 오류 확산을 방지하는 메커니즘을 제공함

 

코드

더보기
더보기
@Test
public void changingEmailFromNonCorporateToCorporate() {
    Company company = new Company("mycorp.com", 1);
    User sut = new User(1, "user@gmail.com", UserType.CUSTOMER);

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

    assertEquals(2, company.getNumberOfEmployees());
    assertEquals("new@mycorp.com", sut.getEmail());
    assertEquals(UserType.EMPLOYEE, sut.getType());
}

 

4. 컨트롤러에서 조건부 로직 처리

  • 비즈니스 연산 중에 프로세스 외부 의존성을 참조해야 하는 경우 육각형 아키텍처가 제대로 작동하지 않음
  • 도메인 모델과 외부 의존성이 섞이면 헥사고날 아키텍처의 핵심 원칙인 ‘핵심 로직과 외부 의존성의 분리’가 깨짐
  • 성능 문제(불필요한 외부 호출)와 테스트 어려움도 발생

 

고려사항 

  • 컨트롤러 단순성 (의사 결정 지점 횟수)
  • 외부 의존성과 도메인 모델 분리 (도메인 테스트 유의성)
  • 성능 (외부 의존성에 대한 호출 횟수)

 

해결책

해결책 장점 단점
외부에 모든 읽기/쓰기를 밀어내기 - 컨트롤러 단순화
- 외부 의존성과 도메인 모델 분리
- 성능 저하 (무조건 의존성 호출)
의사 결정 프로세스 단계 세분화 - 컨트롤러 단순화
- 외부 의존성과 도메인 모델 분리
- 성능 향상
- 컨트롤러에서 의사 결정 로직 증가
- 비즈니스 로직 파편화
패턴 사용하기 - 비즈니스 의사 결정이 도메인에 집중 - 구현 복잡성 증가

 

코드) 외부에 모든 읽기/쓰기를 밀어내기

더보기
더보기
public class User {
    public int UserId { get; private set; }
    public string Email { get; private set; }    
    public UserType Type { get; private set; }
    public bool IsEmailConfirmed { get; private set; }
    
    public string ChangeEmail(string newEmail, Company company) {
        if (IsEmailConfirmed) return "Can't change a confirmed email";
        
        // ...
    }
}
public class UserController {    
    public string ChangeEmail(int userId, string newEmail) {
      	object[] userData = Database.GetUserById(userId);
        var user = UserFactory.Create(userData);       
        
        object[] companyData = _database.GetCompany();
        Company company = CompanyFactory.Create(companyData);
        
        string error = user.ChangeEmail(newEmail, company);
        if (error != null) return error;
        
        _database.SaveCompany(company);
        _database.SaveUser(user);
        _messageBus.SendEmailChangedMessage(userId, newEmail);        
    }
}
  • 컨트롤러가 의사결정 하지 않음
  • 필요 없을 수 있는 외부 호출이 무조건 발생함

 

코드) 의사 결정 프로세스 단계 세분화 (컨트롤러가 일부 의사 결정)

더보기
더보기
public class UserController {    
    public string ChangeEmail(int userId, string newEmail) {
      	object[] userData = Database.GetUserById(userId);
        var user = UserFactory.Create(userData);       
        
        if (user.IsEmailConfirmed) return "can't change a confirmed email";
        
        object[] companyData = _database.GetCompany();
        Company company = CompanyFactory.Create(companyData);
        
        user.ChangeEmail(newEmail, company);
        
        _database.SaveCompany(company);
        _database.SaveUser(user);
        _messageBus.SendEmailChangedMessage(userId, newEmail);        
    }
}
  • 성능 개선됨 (이메일 변경이 가능한 후에만 회사 도메인 관련 데이터베이스 조회가 들어감)
  • 비즈니스 로직과 오케스트레이션 코드의 파편화가 일어남

 

코드) 패턴 사용하기 (CanExecute / Execute 패턴 적용하기)

더보기
더보기
public class User {
    public int UserId { get; private set; }
    public string Email { get; private set; }    
    public UserType Type { get; private set; }
    public bool IsEmailConfirmed { get; private set; }
    
    public void CanChangeEmail(string newEmail, Company company) {
        if (IsEmailConfirmed) return "Can't change a confirmed email";
        
        return null;
    }
    
    public void ChangeEmail(string newEmail, Company company) {
        Precondition.Requires(CanChangeEmail() == null);
        
        // ...
    }
}
  • 비즈니스 로직이 도메인 모델에서 컨트롤러로 유출되는 것을 방지 (컨트롤러는 더 이상 이메일 변경 프로세스를 알 필요 ❌)
  • 전제 조건을 통과하기 전에는 이메일 변경이 허용되지 않음을 보장할 수 있음

 

도메인 이벤트

  • 애플리케이션 내에서 도메인에 중요한 이벤트
  • 도메인 모델 변경 사항 추적 가능
주제 기존 시스템 도메인 이벤트
알림
특정 상황을 외부 시스템에 알려야 할 경우 있음
알림을 도메인 이벤트로 캡처 → 명확하게 분리
파편화 방지 도메인 모델 추적의 책임이 컨트롤러에 있을 경우 복잡 중요한 변경 사항 추적 및 외부 의존성 호출로 변환 가능
추적 도움 거쳐온 과정이 너무 깊게 얽혀있을 경우, 추적이 어려움 시스템의 상태 변경을 명시적으로 기록할 수 있음

 

예제) EmailChangedEvent

더보기
더보기
public class EmailChangedEvent {
    public int UserId { get; }
    public string NewEmail { get; }    
}
public class User {
    List<EmailChangedEvent> EmailChangedEvents; 

    public void ChangeEmail(string newEmail, Company company) {
        Precondition.Requires(CanChangeEmail() == null);
        
        // ...
        
        EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));
    }
}
public class UserController {    
    public string ChangeEmail(int userId, string newEmail) {
      	object[] userData = Database.GetUserById(userId);
        var user = UserFactory.Create(userData);       
        
        if (user.IsEmailConfirmed) return "can't change a confirmed email";
        
        object[] companyData = _database.GetCompany();
        Company company = CompanyFactory.Create(companyData);
        
        user.ChangeEmail(newEmail, company);
        
        _database.SaveCompany(company);
        _database.SaveUser(user);
        foreach (var ev in user.EmailChangedEvents)
            _messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail);        
        
        return "OK";
    }
}
  • 데이터베이스는 무조건 호출 될경우 영향이 있으나, 상대적으로 미미함 (ORM으로 상태 변화일 경우에만 호출되도록 하기)
  • 메시지 버스 호출은 식별 가능한 외부 통신 호출이므로 이메일 변경이 있을때만 호출되어야 함

 

예제) 테스트

더보기
더보기
public void Changing_email_from_non_corporate_to_corporate() {
    var company = new Company("mycorp.com", 1);
    var sut = new User(1, "user@gmail.com", UserType.Employee, false);
    
    sut.ChangeEmail("new@mycorp.com", company);
    
    company.NumberOfEmployees.Should().Be(0);
    sut.Email.Should().Be("new@mycorp.com");
    sut.Type.Should().Be(UserType.Customer);
    sut.EmailChangedEvents.Should().Equal(new EmailChangedEvent(1, "new@gmail.com"));    
}
  • 추상화 할것을 테스트하기 보다 추상화를 테스트하기
  • 이벤트는 외부 호출의 추상화