블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.
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"));
}
- 추상화 할것을 테스트하기 보다 추상화를 테스트하기
- 이벤트는 외부 호출의 추상화
'Code > Test' 카테고리의 다른 글
[단위 테스트] 8-2. 통합 테스트를 하는 이유: 인터페이스 (1) | 2025.03.01 |
---|---|
[단위 테스트] 8-1. 통합 테스트를 하는 이유: 통합 테스트 (0) | 2025.02.28 |
[단위 테스트] 7-1. 가치 있는 단위 테스트를 위한 리팩터링: 코드 유형 (0) | 2025.02.27 |
[단위 테스트] 6-2. 단위 테스트 스타일: 함수형 아키텍처 (0) | 2025.01.29 |
[단위 테스트] 6-1. 단위 테스트 스타일: 단위 테스트 스타일 (0) | 2025.01.25 |