박종훈 기술블로그

로깅도 테스트 해야할까 - 8장 통합 테스트를 하는 이유 (4)

8장 통합 테스트를 하는 이유 (4) 단위테스트 (블라디미르 코리코프)


로그도 테스트 해야할까?

로깅(logging)은 회색 지대로, 테스트에 관해서는 어떻게 해야 할지 분명하지 않다.

로깅과 관련해서는 다음과 같은 질문으로 나눌 수 있다.

8.6.1 로깅을 테스트해야 하는가?

로깅은 횡단 기능(cross-cutting functionality)으로, 코드베이스 어느 부분에서나 필요로 할 수 있다. 다음은 User 클래스의 로깅 예제다.

public class User
{
    public void ChangeEmail(string newEmail, Company company)
    {
        logger.Info($"Changing email for user {UserId} to {newEmail}")

        Precondition.Requires(CanChangeEmail() == null);

        if (Email == newEmail)
            return;

        UserType newType = company.isEmailCorporate(newEmail)
            ? UserType.Employee
            : UserType.Customer;

        if (Type != newType)
        {
            int delta = newType == UserType.Employee ? 1 : -1;
            company.ChangeNumberOfEmployees(delta);
            _logger.Info($"User {UserId} changed type from {Type} to {newType}")
        }

        Email = newEmail;
        Type = newType;
        EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));

        _logger.Info($"Email is changed for user {UserId}")
    }

}

User 클래스는 ChangeEmail 메서드의 시작과 끝에서, 그리고 사용자 유형이 변경될 때마다 로그 파일에 기록한다.

이 기능을 테스트해야 할까? 로깅은 애플리케이션의 동작에 대해 중요한 정보를 생성한다. 그러나 로깅은 너무나 보편적이라, 테스트를 해야할 가치가 있는지 분명하지 않다.

로깅을 테스트 해야하는지를 묻는 질문에 대한 답은 다음과 같다. 로깅이 애플리케이션의 실별할 수 있는 동작인가, 아니면 구현 세부 사항인가?

그런면에서 다른 기능들과 다르지 않다. 결국 로깅은 텍스트 파일이나 데이터베이스와 같은 프로세스 외부 의존성에 사이드 이펙트를 초래한다. 만약 로그의 사이드 이펙트를 고객이나 애플리케이션의 클라이언트 또는 개발자 이외의 다른 사람이 보는 경우라면, 로깅은 식별할 수 있는 동작이므로 반드시 테스트해야 한다. 하지만 보는 이가 개발자뿐이라면, 아무도 모르게 자유로이 수정할 수 있는 구현 세부 사항이므로 테스트해서는 안 된다.

Growing Object-Oriented Software, Guided by Tests 에서는 로깅을 다음과 같이 두 가지 유형으로 나눈다.

8.6.2 로깅을 어떻게 테스트 할 수 있을까?

로깅에는 프로세스 외부 의존성이 있기 때문에 테스트에 관한 한 프로세스 외부 의존성에 영향을 주는 다른 기능과 동일한 규칙이 적용된다. 애플리케이션과 로그 저장소 간의 상호 작용을 검증하려면 목을 써야 한다.

ILogger 위에 래퍼 도입하기

그러나 ILogger 인터페이스를 목으로 처리하지 말라. 지원 로깅은 비즈니스 요구 사항이므로, 해당 요구사항을 코드베이스에 명시적으로 반영하라. 비즈니스에 필요한 모든 지원 로깅을 명시적으로 나열하는 특별한 DomainLogger 클래스를 만들고 ILogger 대신 해당 클래스와의 상호작용을 확인하라.

public class User
{
    public void ChangeEmail(string newEmail, Company company)
    {
        _logger.Info($"Changing email for user {UserId} to {newEmail}") // 진단로그

        Precondition.Requires(CanChangeEmail() == null);

        if (Email == newEmail)
            return;

        UserType newType = company.isEmailCorporate(newEmail)
            ? UserType.Employee
            : UserType.Customer;

        if (Type != newType)
        {
            int delta = newType == UserType.Employee ? 1 : -1;
            company.ChangeNumberOfEmployees(delta);
            _domainLogger.UserTypeHasChanged(UserId, Type, newType); // 지원로그
        }

        Email = newEmail;
        Type = newType;
        EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail));

        _logger.Info($"Email is changed for user {UserId}") // 진단로그
    }
}

진단 로깅은 기존 로거(ILogger 타입)를 사용하지만, 지원 로깅은 이제 IDomainLogger 타입의 새로운 domainLogger 인스턴스를 사용한다.

다음은 IDomainLogger의 구현이다.

public class DomainLogger : IDomainLogger
{
    private readonly ILogger _logger;

    public DomainLogger(ILogger logger)
    {
        _logger = logger;
    }

    public void UserTypeHasChanged(int userId, UserType oldType, UserType newType)
    {
        _logger.Info($"User {userId} changed type from {oldType} to {newType}")
    }
}

DomainLogger 는 ILogger 위에서 작동한다. 도메인 언어를 사용해 비즈니스에 필요한 특정 로그 항목을 선언하므로 지원 로깅을 더 쉽게 이해하고 유지 보수할 수 있다.

이 구현은 아래에서 이야기 할 구조화된 로깅 개념과 매우 유사하다. 이러한 방식은 로그 파일의 후처리(post-processing)와 분석에서 높은 유연성을 가지게 한다.

구조화된 로깅 이해하기

구조화된 로깅(structured logging)은 로그 데이터 캡처와 렌더링을 분리하는 로깅 기술이다.

전통적인 로깅은 다음과 같이 간단한 텍스트로 작동한다.

logger.Info("User Id is " + 12);

이러한 방식의 문제점은 구조상 결과 로그 파일을 분석하기 어렵다는 점이다. 예를 들어 특정 유형의 메시지가 몇 개인지, 특정 사용자 ID와 관련된 메시지가 몇 개인지 알기가 쉽지 않다. 이를 위해 전문 도구(또는 직접 작성한 도구)가 필요하다.

반면 구조화된 로깅은 로그 저장소에 구조가 있다.

logger.Info("User Id is {userId}", 12);

사용은 표면적으로 비슷해 보인다.

그러나 기저 동작은 크게 다르다. 이 메서드는 이면에서 메시지 템플릿의 해시를 계산하고 해당 해시를 입력 매개변수와 결합해 캡처한 데이터 세트를 형성한다.

이후 렌더링 단계를 거친다. 이때 기존 로깅과 마찬가지로 평범한 로그 파일을 사용할 수도 있겠지만, 이는 단지 렌더링 방법 중 하나일 뿐이며, 캡쳐한 데이터를 JSON 또는 CSV 와 같은 형태로 렌더링 하도록 로깅 라이브러리를 설정할 수 있다. 이를 통해 분석이 더 쉬워질 수 있다.

structured-logging

구조화 된 로깅은 로그 데이터와 해당 데이터의 렌더링을 분리(de-coupling)한다.

지원 로깅과 진단 로깅을 위한 테스트 작성

DomainLogger 에는 프로세스 외부 의존성(로그 저장소)이 있다. 여기에 문제가 있다. User가 해당 의존성과 상호 작용하므로, 비즈니스 로직과 프로세스 외부 의존성과의 통신 간에 분리해야 하는 원칙을 위반한다. DomainLogger를 사용하면 User가 지나치게 복잡한 코드 범주로 들어가 테스트와 유지 보수가 어려워진다.

이 문제는 사용자 이메일 변경에 대해 외부 시스템의 알림을 구현한 것과 같은 방식(도메인 이벤트 사용)으로 해결할 수 있다. 사용자 유형의 변경 사항을 추적하고자 별도의 도메인 이벤트를 도임할 수 있다. 그 후 다음 예제와 같이 컨트롤러는 이러한 변경 사항을 DomainLogger 호출로 변환한다.

public class User
{
    public void ChangeEmail(string newEmail, Company company)
    {
        _logger.Info($"Changing email for user {UserId} to {newEmail}") // 진단로그

        Precondition.Requires(CanChangeEmail() == null);

        if (Email == newEmail)
            return;

        UserType newType = company.isEmailCorporate(newEmail)
            ? UserType.Employee
            : UserType.Customer;

        if (Type != newType)
        {
            int delta = newType == UserType.Employee ? 1 : -1;
            company.ChangeNumberOfEmployees(delta);
            AddDomainEvent(new UserTypeChangedEvent(userId, Type, newType)) // DomainLogger 대신 도메인 이벤트 사용
        }

        Email = newEmail;
        Type = newType;
        AddDomainEvent(new EmailChangedEvent(UserId, newEmail))

        _logger.Info($"Email is changed for user {UserId}") // 진단로그
    }
}

UserTypeChangedEvent와 EmailChangedEvent라는 두 가지 도메인 이벤트가 있다. 둘 다 같은 인터페이스(IDomainEvent)를 구현하므로 같은 컬렉션에 저장할 수 있다.

컨트롤러에서는 다음과 같이 처리한다.

public string ChangeEmail(int userId, string newEmail)
{
    object[] userData = _database.GetUserById(userId);
    User user = UserFactory.Create(userData);

    string error = user.CanChangeEmail();
    if (error != null)
        return error;

    object[] companyData = _database.getCompany();
    Company company = CompanyFactory.Create(companyData);

    user.ChangeEmail(newEmail, company);

    _database.SaveCompany(company);
    _database.SaveUser(user);
    _eventDispatcher.Dispatch(user.DomainEvents);

    return "OK";
}

EventDispatcher는 도메인 이벤트를 프로세스 외부 의존성에 대한 호출로 변환하는 새로운 클래스다. (EventDispatcher 의 세부 구현에 대해서는 9장에서 다룬다.)

UserTypeChangedEvenet를 사용하면 두 가지 책임(프로세스 외부 의존성 통신과 도메인 로직)을 분리할 수 있다. 이제 지원 로깅을 테스트하는 것은 다른 비관리 의존성을 테스트하는 것과 다르지 않다.

도메인 클래스가 아니라 컨트롤러에서 지원 로깅이 필요한 경우 도메인 이벤트를 사용할 필요가 없다.

User클래스는 진단 로깅을 하는 방식을 변경하지 않았다. User는 여전히 ChangeEmail 메서드의 시작과 끝을 직접 로거 인스턴스를 사용한다. 이는 의도된 것이다. 진단 로깅을 개발자만을 위한 것이기 때문에 테스트할 필요가 없고 따라서 도메인 모델 테스트에 포함할 필요가 없다.

8.6.3 로깅이 얼마나 많으면 충분한가?

또 다른 중요한 질문은 최적의 로그 분량에 관한 것이다. 지원 로깅은 비즈니스 요구 사항이므로, 여기에는 질문의 여지가 없다. 그러나 진단 로깅은 조절할 수 있다.

다음의 두 가지 이유로 진단 로깅은 과도하게 사용하지 않는 것이 좋다.

도메인 모델에서는 진단 로깅을 절대 사용하지 않도록 하라. 대부분의 경우 이러한 로깅을 도메인 클래스에서 컨트롤러로 안전하게 옮길 수 있다. 무언가를 디버깅해야 할 때만 일시적으로 진단 로깅을 사용하라. 디버깅이 끝나면 제거하라. 이상적으로는 처리되지 않은 예외에 대해서만 진단 로깅을 사용해야 한다.

8.6.4 로거 인스턴스를 어떻게 전달하는가?

코드에서 로거 인스턴스를 어떻게 전달해야할까?

한 가지 방법은 다음 예제와 같이 정적 메서드를 사용하는 것이다.

public class User
{
    private static readonly ILogger _logger = LogManager.GetLogger(typeof(User));

	...
}

Dependency Injection: Priciples, Practives, Patterns 에서는 이러한 유형의 의존성 획득을 앰비언트 컨텍스트(ambient context)라고 부른다. 이는 안티 패턴이며, 다음과 같은 두 가지 단점이 있다.

로거를 명시적으로 주입하는 방법은 다음 예제와 같다.

public void ChangeEmail(string newEmail, Company company, ILogger logger) {
	...
}

또 다른 방법으로는 생성자를 통해 하는 방법도 있다.

8.7 결론

식별할 수 있는 동작인지, 아니면 구현 세부 사항인지 여부에 대한 관점으로 프로세스 외부 의존성과의 통신을 살펴보자.

요약