박종훈 기술블로그

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

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


로그도 테스트 해야할까?

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

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

  • 로깅을 조금이라도 테스트해야 하는가?
  • 만약 그렇다면 어떻게 테스트해야 하는가?
  • 로깅이 얼마나 많으면 충분한가?
  • 로거(logger) 인스턴스를 어떻게 전달할까?

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장에서 다룬다.)

  • EmailChangedEvent 는 _messageBus.SendEmailChangedMessage()로 변환
  • UserTypeChangedEvent 는 _domainLogger.UserTypeHasChanged()로 변환

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

  • 단위 테스트는 테스트 대상 User에서 UserTypeCHangedEvent 인스턴스를 확인해야 한다.
  • 단일 통합 테스트는 목을 써서 DomainLogger와의 상호 작용이 올바른지 확인해야 한다.

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

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

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

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

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

  • 과도한 로깅은 코드를 혼란스럽게 한다. 이는 특히 도메인 모델에 해당한다. 그렇기 때문에 단위 테스트 관점에서 사용하는 것이 좋을지라도 User에서는 진단 로깅을 사용하지 않는 것이 좋다. 코드가 모호해진다.
  • 핵심은 로그의 신호 대비 잡음 비율이다. 로그가 많을수록 관련 정보를 찾기가 어려워진다. 신호를 최대한으로 늘리고 잡음을 최소한으로 줄여라.

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

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 결론

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

요약

  • 통합 테스트는 단위 테스트가 아닌 테스트에 해당한다. 통합 테스트는 시스템이 프로세스 외부 의존성과 통합해 작동하는 방식을 검증한다.
    • 통합 테스트는 컨트롤러를 다루고, 단위 테스트는 알고리즘과 도메인 모델을 다룬다.
    • 통합 테스트는 회귀 방지와 리팩터링 내성이 우수하고, 단위 테스트는 유지 보수성과 피드백 속도가 우수하다.
  • 통합 테스트의 기준은 단위 테스트보다 높다. 통합 테스트에서 회귀 방지와 리팩터링 내성에 대한 점수는 단위 테스트보다 유지 보수성과 피드백 속도가 떨어진 만큼은 높아야 한다. 테스트 피라미드가 이러한 절충을 나타낸다. 대부분의 테스트는 빠르면서 비용이 낮아야 하고, 시스템이 전체적으로 올바른지 확인하는 통합 테스트는 속도가 느리고 비용이 많이 발생하므로 그 수가 적어야 한다.
    • 단위 테스트를 통해 가능한 한 많은 비즈니스 시나리오의 예외 상황을 확인하라. 통합 테스트를 사용해서 하나의 주요 흐름과 단위 테스트로 확인할 수 없는 예외 상황을 다루도록 하라.
    • 테스트 피라미드의 모양은 프로젝트 복잡도에 따라 달라진다. 간단한 프로젝트는 도메인 모델에 코드가 거의 없으므로 단위 테스트와 통합 테스트의 개수가 동일하다. 아주 단순한 경우 단위 테스트가 없을 수도 있다.
  • 빠른 실패 원칙은 버그가 빠르게 나타날 수 있도록 하며 통합 테스트에서 할 수 있는 대안이다.
  • 관리 의존성은 애플리케이션을 통해서만 접근할 수 있는 프로세스 의부 의존성이다. 관리 의존성과의 상호 작용은 외부에서 관찰할 수 없다. 대표적인 예는 애플리케이션 데이터베이스다.
  • 비관리 의존성은 다른 애플리케이션이 접근할 수 있는 프로세스 외부 의존성이다. 비관리 의존성과의 상호 작용은 외부에서 관찰할 수 있다. 대표적인 예로 SMTP 서버나 메시지 버스 등이 있다.
  • 관리 의존성과의 통신은 구현 세부 사항이고, 비관리 의존성과의 통신은 식별할 수 있는 동작이다.
  • 통합 테스트에서 관리 의존성은 실제 인스턴스를 사용하라. 비고나리 의존성은 목으로 대체하라.
  • 때로는 관리 의존서오가 비관리 의존성 모두의 특성을 나타내는 프로세스 외부 의존성이 있다. 전형적인 예로는 다른 애플리케이션이 접근할 수 있는 데이터베이스가 있다. 비관리 의존성의 식별 가능한 부분을 비관리 의존성으로 간주하고, 테스트에서 해당 부분을 목으로 대체하라. 나머지 부분을 관리 의존성으로 간주하고, 해당 부분과의 상호 작용 대신 최종 상태를 검증하라.
  • 통합 테스트는 관리 의존성과 작동하는 모든 계층을 거쳐야 한다. 데이터베이스를 예로 들면, 입력 매개변수로 사용한 데이터와 별개로 해당 데이터베이스의 상태를 확인하는 것을 의미한다. (ex. 데이터 입력에 따라 정상적으로 데이터베이스 상태가 업데이트가 되었는지)
  • 구현이 하나분인 인터페이스는 추상화가 아니며 해당 인터페이스를 구현하는 구체클래스보다 결합도가 낮지 않다. 이러한 인터페이스에 대한 향후 구현을 예상하면 YAGNI 원칙을 위배한다.
  • 구현이 하나뿐인 인터페이스를 사용하기에 타당한 이유는 목을 사용하기 위한 것뿐이다. 비관리 의존성에만 사용하고, 관리 의존성은 구체 클래스(concrete classes, 추상 클래스의 반대)를 사용하라.
  • 프로세스 내부 의존성에 대해 구현이 하나뿐인 인터페이스는 좋지 않다. 이러한 인터페이스는 목을 사용해 도메인 클래스 간의 상호 작용을 확인하게 되고, 테스트가 코드의 구현 세부 사항에 결합된다.
  • 도메인 모델을 코드베이스에 명시적이고 잘 알려진 위치에 둬라. 도메인 클래스와 컨트롤러 사이의 경계가 명확하면 단위 테스트와 통합 테스트를 좀 더 쉽게 구분할 수 있다.
  • 간접 계층이 너무 많으면 코드를 추론하기가 어려워진다. 간접 계층을 가능한 한 적게 하라. 대부분의 백엔드 시스템은 도메인 모델, 애플리케이션 서비스 계층(컨트롤러), 인프라 계층, 이 세 가지 계층만 있다.
  • 순환 의존성이 있으면 코드를 이해하려고 할 때 알아야 하는 부담이 커진다. 대표적인 예는 콜백(수신자가 발신자에게 작업 결과를 알리는 경우)이다. 값 객체를 도입해 순환을 업애고, 호출부에 주는 결과를 값 객체로 반환하라.
  • 테스트에 여러 실행 구절이 있는 것은 올바른 상태가 되기 어려운 프로세스 외부 의존성으로 작동하는 경우에만 타당하다. 단위 테스트는 프로세스 외부 의존성으로 수행되지 않기 때문에 여러 가지 실행을 해서는 안 된다. 다단계 테스트는 대부분 엔드 투 엔드 테스트 범주에 속한다.
  • 지원 로깅은 지원 부서나 시스템 관리자를 위한 것이며, 애플리케이션의 식별할 수 있는 동작이다. 진단 로깅은 개발자가 애플리케이션 내부에서 진행되는 작업을 이해하는데 도움을 주며, 구현 세부 사항이다.
  • 지원 로깅은 비즈니스 요구 사항이므로 해당 요구 사항을 코드베이스에 명시적으로 반영하라. 비즈니스에 필요한 모든 지원 로깅이 나열돼 있는 특별한 DomainLogger 클래스를 도입하라.
  • 지원 로깅을 프로세스 외부 의존성으로 작동하는 다른 기능처럼 취급하라. 도메인 이벤트를 사용해 도메인 모델의 변경 사항을 추적하라. 컨트롤러에서 도메인 이벤트를 DomainLogger 호출로 변환하라.
  • 진단 로깅을 테스트하지 말라. 지원 로깅과 달리 도메인 모델에서 직접 진단 로그를 남길 수도 있다.
  • 진단 로깅은 가끔 사용하라. 진단 로깅을 너무 많이 쓰면 코드를 복잡하게 하고 로그의 신호 대비 잡음 비율이 나빠진다. 이상적으로는 진단 로깅을 처리되지 않은 예외에 대해서만 사용해야 한다.
  • 항상 모든 의존성(로거 포함)을 생성자 또는 메서드 인수를 통해 명시적으로 주입하라.