[testing] Why integration testing?

Donghoon Bae·2025년 9월 15일

testing

목록 보기
4/6

통합 테스트란?

통합 테스트는 테스트 스위트에서 중요한 역할을 한다. 또한 단위 테스트와 통합 테스트의 수를 균형 있게 유지하는 것도 중요하다.

통합 테스트의 역할

  • 작은 코드 단위를 검증
  • 빠르게 수행
  • 다른 테스트와 격리된 방식으로 수행

테스트 피라미드 다시 보기

통합 테스트 특성상 프로세스 외부 의존성에 직접 작동하면 느려질 수 밖에 없고, 이러한 형태의 테스트는 유지비용도 많이 지불해야한다. 유지보수 비용 증가는 다음 두 가지 이유로 인해 발생한다.
1. 프로세스 외부 의존성의 운영이 필요하다.
2. 관련된 협력자가 많아서 테스트가 비대해진다.

단위 테스트와 통합 테스트의 비율에 대해 정답은 없지만 아래와 같은 절차로 진행하면 된다.

  1. 가능한 한 많은 비즈니스 로직을 단위 테스트로 검증한다.
  2. 단위 테스트가 검증하지 못하는 기타 예외 상황(Edge case) 은 통합 테스트로 검증한다.
  3. 그 외에 비즈니스에 대한 주요 흐름(Happy path) 도 통합 테스트로 검증한다.


빠르고 저렴한 단위 테스트는 대부분의 엣지 케이스를 다루는 반면, 적은 수의 느리고 더 비싼 통합 테스트는 시스템 전체의 정확성을 보장한다.

통합 테스트 vs 빠른 실패

통합 테스트를 통해 비즈니스 시나리오당 하나의 해피 케이스와 엣지 케이스를 처리하는 방법에 대해 알아본다.
통합 테스트에서 프로세스 외부 의존성과의 상호 작용을 모두 확인하려면 가장 긴 해피 패스에 대해 테스트를 수행하는 것이 좋다.

func changeEmail(_ newEmail: String, company: Company) {
  precondition(canChangeEmail() == nil)
  /* the rest of the method */
}

컨트롤러는 CanChangeEmail() 메서드를 호출하고 해당 메서드가 오류를 반환하면 연산을 중단한다.

func changeEmail(userId: Int, newEmail: String) -> String? {
  let userData = database.getUserById(userId)
  let user = UserFactory.create(userData)
  
  if let error = user.canChangeEmail() { // HERE
    return error
  }
  /* the rest of the method */
}

위의 코드에서 HERE로 표시된 부분이 엣지 케이스이다.
만약 여기서 컨트롤러가 CanChangeEmail() 메서드를 참조하지 않고, 이메일을 변경하려고 하는 경우 애플리케이션이 실패하고, 실행만 하면 바로 버그를 특정할 수 있으므로 이를 쉽게 찾아내 수정할 수 있다. 즉 테스트를 통해 검증할 필요가 없는 케이스이다.

빠른 실패 원칙(The Fail Fast principle)

빠른 실패 원칙은 예기지 않은 오류가 발생하자마자 현재 동작을 중단하는 것을 의미한다.
이 원칙은 아래와 같은 효과로 애플리케이션의 안정성을 높여준다.

  • 피드백 루프 단축(Shortening the feedback loop)
    버그를 빨리 발견할수록 더 쉽게 해결할 수 있다.
    이미 운영 환경으로 넘어간 뒤 발견된 버그는 수정에 더 큰 비용을 치뤄야 한다.
  • 지속성 상태 보호(Protecting the persistence state)
    버그는 애플리케이션의 상태를 손상 시킨다.
    손상된 상태가 외부 저장소 등으로 침투하면 수정이 훨씬 어려워지므로, 빨리 실패하여 손상의 확산을 막는 것이 좋다.

직접 테스트해야하는 프로세스 의존성은 무엇인가?

통합 테스트는 시스템이 프로세스 외부 의존성과 어떻게 통합하는지를 검증한다.
이를 검증하는 방법은 크게 두 가지로, 하나는 실제 프로세스 외부 의존성을 사용하거나, 해당 의존성을 Mock으로 대치하는 것이다.

프로세스 외부 의존성을 테스트하는 두 가지 유형

관리 의존성(Managed dependencies)

  • 전체를 제어할 수 있는 프로세스 외부 의존성 (e.g., database)
  • 관리 의존성은 애플리케이션을 통해서만 접근할 수 있으며, 해당 의존성과의 상호 작용은 외부 환경에서 볼 수 없다.

비관리 의존성(Unmanaged dependencies)

  • 전체를 제어할 수 없는 프로세스 외부 의존성 (e.g., SMTP server, message bus)
  • 비관리 의존성과의 상호 작용은 외부 환경에서 볼 수 있다.
  • 대표적인 예시 : SMTP 서버, 메시지 버스

관리 의존성이면서 비관리 의존성인 프로세스 외부 의존성

  • 때로는 관리되는 의존성과 관리되지 않는 의존성의 특성을 모두 나타내는 프로세스 외부 의존성을 만나게 된다.
  • 다른 애플리케이션이 접근할 수 있는 데이터베이스가 좋은 예시이다. 시스템이 자체 전용 데이터베이스로 시작했지만, 나중에 다른 시스템이 동일한 데이터베이스의 데이터를 요구하여 제한된 수의 테이블에 대한 접근을 공유하기로 결정하면, 해당 데이터베이스는 관리되는 동시에 관리되지 않는 의존성이 된다.
  • 이러한 경우, 데이터베이스의 외부 애플리케이션에 보이는 부분은 관리되지 않는 의존성으로 취급해야 한다. 이 테이블들은 사실상 메시지 버스 역할을 하며, 행은 메시지 역할을 한다. 통합 테스트에서는 목(mock)을 사용하여 이러한 테이블과의 통신 패턴이 변경되지 않도록 확인해야 한다.
  • 동시에 데이터베이스의 나머지 부분은 관리되는 의존성으로 취급하고, 이들과의 상호 작용이 아니라 최종 상태를 확인해야 한다.
  • 이 두 부분을 구분하는 것이 중요하다. 공유 테이블은 외부에서 관찰 가능하므로, 애플리케이션이 이들과 통신하는 방식에 주의해야 하며, 절대적으로 필요한 경우가 아니라면 상호 작용 방식을 변경해서는 안 된다.

통합 테스트시 실제 데이터베이스를 사용할 수 없는 경우

  • 때로는 통제할 수 없는 이유로 통합 테스트에서 관리되는 의존성(managed dependency)의 실제 버전을 사용할 수 없을 때가 있다. 예를 들어, IT 보안 정책 때문에 테스트 자동화 환경이나 개발자 장비에 레거시 데이터베이스를 배포할 수 없거나, 테스트 데이터베이스 인스턴스를 설정하고 유지보수하는 비용이 너무 비쌀 수 있다.
  • 이런 상황에서 관리되는 의존성임에도 불구하고 데이터베이스를 목(mock)으로 대체 해서는 안 된다. 왜냐하면 관리되는 의존성을 목으로 처리하는 것은 통합 테스트의 리팩토링 저항력(resistance to refactoring)을 훼손하기 때문이다.
  • 만약 데이터베이스를 있는 그대로 테스트할 수 없다면, 아예 통합 테스트를 작성하지 말고 대신 도메인 모델의 단위 테스트에만 집중해야 한다.
  • 모든 테스트는 항상 면밀히 검토해야 하며, 충분한 가치를 제공하지 않는 테스트는 테스트 스위트에 포함될 자격이 없다는 점을 기억해야 한다.

Integration testing: An example

이 시스템은 사용자의 이메일 변경 기능만 구현되어 있으며, 데이터베이스에서 사용자와 회사를 검색하고 의사 결정은 도메인 모델에 위임한 후 그 결과를 데이터베이스에 저장한 뒤, 필요한 경우 메시지 버스에 메시지를 실어 보낸다.

class UserController {
  private let database = Database()
  private let messageBus = MessageBus()

  func changeEmail(userId: Int, newEmail: String) -> String? {
    let userData = database.getUserById(userId)
    let user = UserFactory.create(userData)

    if let error = user.canChangeEmail() {
      return error
    }

    let companyData = database.getCompany()
    let company = CompanyFactory.create(companyData)

    user.changeEmail(newEmail, company)

    database.saveCompany(company)
    database.saveUser(user)
    for ev in user.emailChangedEvents {
      messageBus.sendEmailChangedMessage(ev.userId, ev.newEmail)
    }
    return "OK"
  }
}

어떤 테스트를 위한 시나리오들이 있는가?

가장 긴 해피 패스와 엣지 케이스들을 식별해보자. 먼저 가장 긴 해피 패스는 모든 프로세스 외부 의존성을 거치는 것이 기준이다.
고객 관리 프로젝트에를 기준으로 살펴보면 기업 이메일에서 일반 이메일로 변경하는 것이 가장 긴 해피 패스이다.

  • 데이터베이스에서 사용자와 회사 모두 업데이트 된다.
    • 사용자는 유형을 기업에서 일반으로 변경한 뒤, 이메일도 변경한다.
    • 회사는 직원 수를 변경한다.
  • 메시지 버스로 메시지를 보낸다.

엣지 케이스는 하나로, 이메일을 변경할 수 없는 시나리오이다. 하지만 이 시나리오는 테스트할 가치가 없다. 컨트롤러에 변경 불가능에 대한 확인이 없으면 애플리케이션이 빠른 실패를 보장해주기 때문이다.
따라서 단일 통합 테스트만 남게 된다.

public func changingEmailFromCorporateToNonCorporate() { }

통합 테스트를 위한 database와 message bus 분류

  • 통합 테스트를 작성하기 전에 두 가지 프로세스 외부 의존성을 분류하고, 어떤 것을 직접 테스트하고 어떤 것을 목(mock)으로 대체할지 결정해야 한다.
  • 애플리케이션 데이터베이스는 다른 시스템이 접근할 수 없으므로 관리되는 의존성(managed dependency)에 해당한다. 따라서 통합 테스트에서는 실제 인스턴스를 사용해야 한다.
    • 통합 테스트는 데이터베이스에 사용자와 회사를 삽입하고
    • 이메일 변경 시나리오를 실행한 다음
    • 데이터베이스의 최종 상태를 확인한다.
  • 반면에 메시지 버스는 그 유일한 목적이 다른 시스템과의 통신을 가능하게 하는 것이므로 관리되지 않는 의존성(unmanaged dependency)이다.
  • 통합 테스트는 메시지 버스를 목으로 처리하고 컨트롤러와 목 간의 상호 작용을 확인한다.

E2E란 무엇인가?

  • 종단간 테스트(End-to-end test, E2E 테스트)는 API 시나리오에서 배포되어 완벽하게 작동하는 API 버전에 대해 실행되는 테스트를 의미한다. 이는 프로세스 외부 의존성에 대해 목(mock)을 사용하지 않는다는 뜻이다.

  • E2E 테스트는 외부 클라이언트를 에뮬레이션하므로, 테스트 범위에 포함된 모든 프로세스 외부 의존성을 참조하는 배포된 애플리케이션 버전을 테스트한다. 이 테스트는 관리되는 의존성(예: 데이터베이스)을 직접 확인하지 않고, 애플리케이션을 통해 간접적으로 확인해야 한다.
  • 반면 통합 테스트는 동일한 프로세스 내에서 애플리케이션을 호스팅하며, 관리되지 않는 의존성은 목으로 대체한다. 통합 테스트의 유일한 프로세스 외부 구성 요소는 관리되는 의존성이다.
  • E2E 테스트 사용 여부는 판단의 문제이다. 관리되는 의존성을 통합 테스트 범위에 포함하고 관리되지 않는 의존성만 목으로 처리하면, 통합 테스트만으로 E2E 테스트와 거의 동일한 수준의 보호를 제공할 수 있어 E2E 테스트를 생략할 수 있다.

  • 다만, 배포 후 프로젝트에 대한 건전성 검사(sanity check)를 위해 하나 또는 두 개의 포괄적인 E2E 테스트를 만들 수 있다. 이러한 테스트는 가장 긴 해피 패스를 통과하여 애플리케이션이 모든 프로세스 외부 의존성과 올바르게 통신하는지 확인해야 한다. 외부 클라이언트의 행동을 에뮬레이션하려면 메시지 버스는 직접 확인하고, 데이터베이스의 상태는 애플리케이션 자체를 통해 확인한다.

통합 테스트: 첫 번째 시도

import Testing

@Test 
func changingEmailFromCorporateToNonCorporate() async throws {
  let db = Database(connectionString: connectionString)
  let user = createUser("user@mycorp.com", type: .employee, db: db)
  createCompany("mycorp.com", numberOfEmployees: 1, db: db)

  let messageBusMock = MessageBusMock()
  let sut = UserController(database: db, messageBus: messageBusMock)

  let result = sut.changeEmail(userId: user.userId, newEmail: "new@gmail.com")
  #expect(result == "OK")

  let userData = db.getUserById(user.userId)
  let userFromDb = UserFactory.create(userData)
  #expect(userFromDb.email == "new@gmail.com")
  #expect(userFromDb.type == .customer)

  let companyData = db.getCompany()
  let companyFromDb = CompanyFactory.create(companyData)
  #expect(companyFromDb.numberOfEmployees == 0)

  #expect(messageBusMock.sendEmailChangedMessageCalls.count == 1)
  #expect(messageBusMock.sendEmailChangedMessageCalls.first?.userId == user.userId)
  #expect(messageBusMock.sendEmailChangedMessageCalls.first?.newEmail == "new@gmail.com")
}
  • 샘플 CRM 시스템의 가장 긴 해피 패스인 '기업 이메일에서 비기업 이메일로 변경' 시나리오에 대한 통합 테스트 예시가 제시된다. 이 테스트는 데이터베이스와 메시지 버스를 포함하는 최대 수의 부작용을 검증한다.
  • Arrange(준비) 섹션에서는 createUser 및 createCompany와 같은 헬퍼 메서드를 호출하여 데이터베이스에 사용자와 회사를 삽입한다. 이 헬퍼 메서드는 여러 통합 테스트에서 재사용할 수 있다.
  • Act(실행) 섹션에서는 컨트롤러의 changeEmail 메서드를 호출하여 시나리오를 실행한다.
  • Assert(검증) 섹션에서는 #expect(result == "OK")를 통해 컨트롤러의 반환 값을 확인한다.
  • 데이터베이스 상태 확인: 입력 매개변수로 사용된 데이터와 독립적으로 데이터베이스 상태를 확인하는 것이 중요하다. 이를 위해 Assert 섹션에서 데이터베이스에서 사용자 및 회사 데이터를 별도로 조회하고, 새로운 userFromDb 및 companyFromDb 인스턴스를 생성한 다음, 이들의 상태를 검증한다. 이 방식은 테스트가 데이터베이스에 대한 쓰기와 읽기를 모두 수행하여 회귀 방지를 최대로 보장한다.
  • 목(mock) 상호 작용 확인: 메시지 버스 목(messageBusMock)과 컨트롤러 간의 상호 작용(예: sendEmailChangedMessage가 한 번 호출되었는지)도 확인한다.

의존성 추상화를 위한 인터페이스 사용

단위 테스트에서 가장 많이 오해되는 것 중 하나는 인터페이스의 사용이다. 인터페이스를 선언한 이유를 개발자들이 잘못 설명하거나, 그 결과를 남용하는 경우가 많기 때문이다.

인터페이스와 느슨한 결합

  • 많은 개발자들이 데이터베이스나 메시지 버스와 같은 프로세스 외부 의존성에 대해 인터페이스를 도입하지만, 해당 인터페이스가 단 하나의 구현만 가지고 있는 경우가 많다.
protocol MessageBusProtocol { }

final class MessageBus: MessageBusProtocol { }

protocol UserRepositoryProtocol { }

final class UserRepository: UserRepositoryProtocol { }

이렇게 인터페이스를 남용하는 일반적인 이유는 다음과 같다.

1. 인터페이스가 프로세스 외부 의존성을 추상화해 느슨한 결합을 달성한다.
단일 구현을 위한 인터페이스는 추상화에 해당하지 않으므로 이는 명백한 오해이다. 추상화가 무엇인지 표현할 수 있는 의미가 있긴 하지만, 이는 코드에서 정의하는 것이 아닌 발견되는 것에 가까우며, 진정한 추상화는 구현이 두 가지 이상 있어야한다.

2. 인터페이스는 기존 코드를 변경하지 않고 새로운 기능을 추가해 공개 폐쇄 원칙을 준수한다.
이는 공개 폐쇄 원칙보다 더 기본적인 3대 원칙 중 하나인 YAGNI를 위반한다.
YAGNI 현재 필요하지않은 기능에 시간을 들여서는 안되는 원칙을 말하며, 이는 기회 비용을 낭비하거나 프로젝트 코드의 경량화를 방해한다.

OCP vs YAGNI
https://enterprise-craftsmanship.com/posts/ocp-vs-yagni.

프로세스 외부 의존성에 인터페이스를 사용하는 이유?

  • 단일 구현을 가진 인터페이스를 프로세스 외부 의존성에 사용하는 진정한 이유는 목(mock)을 가능하게 하기 위함이다. 인터페이스가 없으면 테스트 더블(test double)을 만들 수 없어 시스템 언더 테스트(SUT)와 프로세스 외부 의존성 간의 상호 작용을 확인할 수 없기 때문이다.
  • 따라서 프로세스 외부 의존성을 목으로 처리할 필요가 없다면 인터페이스를 도입해서는 안 된다. 관리되지 않는 의존성(unmanaged dependencies)에만 인터페이스를 사용하고, 관리되는 의존성(managed dependencies)은 컨트롤러에 구체적인 클래스로 직접 주입하는 것이 권장된다.
final class UserController {
  private let database: Database
  private let messageBus: MessageBusProtocol

  init(database: Database, messageBus: MessageBusProtocol) {
    self.database = database
    self.messageBus = messageBus
  }

  func changeEmail(userId: Int, newEmail: String) -> String? {
    /* the method uses database and messageBus */
  }
}

프로세스 내부 의존성에 인터페이스 사용

프로세스 외부 의존성뿐만 아니라 프로세스 내부 의존성(도메인 클래스 등)에도 단일 구현을 가진 인터페이스(예: IUser와 User 클래스 쌍)가 사용되는 경우가 있는데, 이는 큰 위험 신호이다.

protocol UserProtocol {
  var userId: Int { get set }
  var email: String { get }
  func canChangeEmail() -> String?
  func changeEmail(_ newEmail: String, company: Company)
}

final class User: UserProtocol {
  /* ... */
}

프로세스 외부 의존성과 마찬가지로, 도메인 클래스에 단일 구현 인터페이스를 도입하는 유일한 이유는 목킹을 가능하게 하기 위함이다. 그러나 도메인 클래스 간의 상호 작용은 목으로 확인해서는 안 된다. 이렇게 하면 테스트가 구현 세부 사항과 결합되어 리팩토링 저항력이 낮은 취약한 테스트가 되기 때문이다.

통합 테스트 모범 사례

통합 테스트를 최대한 활용하고 코드베이스의 건전성을 높이는 데 도움이 되는 몇 가지 일반적인 지침이 있다.
1. 도메인 모델의 경계 명시(Making domain model boundaries explicit)
2. 애플리케이션 내 계층 줄이기(Reducing the number of layers in the application)
3. 순환 의존성 제거(Eliminating circular dependencies)

도메인 모델 경계 명시적으로 만들기

코드베이스에서 도메인 모델의 명시적이고 잘 알려진 위치를 항상 확보해야 한다. 도메인 모델은 프로젝트가 해결하려는 문제에 대한 도메인 지식의 집합이다. 명시적인 경계를 할당하면 코드의 해당 부분을 더 잘 시각화하고 추론하는 데 도움이 되며, 단위 테스트와 통합 테스트를 쉽게 구분할 수 있다.

계층 수 줄이기

  • 대부분의 프로그래머는 코드를 추상화하고 일반화하기 위해 추가적인 간접 계층(layers of indirection)을 도입하는 경향이 있으며, 엔터프라이즈급 애플리케이션에서 이러한 계층을 쉽게 볼 수 있다.
  • 그러나 너무 많은 추상화 계층은 코드베이스를 탐색하고 로직을 이해하기 어렵게 만든다. 이는 코드에 대해 추론하는 능력을 부정적으로 영향을 미치고 개발 프로세스에 추가적인 정신적 부담을 준다.
  • 너무 많은 간접 계층은 테스트에도 도움이 되지 않으며, 컨트롤러와 도메인 모델 사이의 경계를 모호하게 하여 통합 테스트의 가치를 떨어뜨리고 낮은 리팩토링 저항력과 회귀 방지로 이어진다.
  • 가능한 한 적은 수의 간접 계층을 사용해야 한다. 대부분의 백엔드 시스템에서는 도메인 모델, 애플리케이션 서비스 계층(컨트롤러), 인프라 계층의 세 가지 계층으로 충분하다.

순환 의존성 제거

  • 순환 의존성(circular dependencies)
    은 두 개 이상의 클래스가 서로 직간접적으로 의존하여 제대로 작동하는 경우를 말한다. 일반적인 예시는 콜백이다.
final class CheckOutService {
  func checkOut(orderId: Int) {
    let service = ReportGenerationService()
    service.generateReport(orderId: orderId, checkOutService: self)
    /* other code */
  }
}

final class ReportGenerationService {
  func generateReport(orderId: Int, checkOutService: CheckOutService) {
    /* calls checkOutService when generation is completed */
  }
}
  • 순환 의존성은 코드를 읽고 이해할 때 엄청난 인지 부하를 추가하며, 해결책을 탐색할 명확한 시작점을 제공하지 않아 코드를 이해하기 어렵게 만든다.

  • 또한 순환 의존성은 테스트를 방해한다. 클래스 그래프를 분할하고 단일 행동 단위를 격리하기 위해 인터페이스와 목킹에 의존해야 하는 경우가 많지만, 이는 도메인 모델 테스트에서는 허용되지 않는다.
  • 인터페이스를 사용하는 것은 컴파일 시점에 순환 의존성을 제거할 수 있지만, 런타임에는 여전히 순환이 존재하므로 문제를 단순히 가리는 것에 불과하다.
  • 더 나은 접근 방식은 순환을 제거하는 것이다. 예를 들어, ReportGenerationService가 CheckOutService에 의존하지 않고 작업 결과를 일반 값으로 반환하도록 리팩토링해야 한다. 코드베이스의 모든 순환 의존성을 제거하는 것은 거의 불가능하지만, 상호 의존적인 클래스 그래프의 크기를 가능한 한 작게 만들어 피해를 최소화할 수 있다.
final class CheckOutService {
  func checkOut(orderId: Int) {
    let service = ReportGenerationService()
    let report = service.generateReport(orderId: orderId)
    /* other work */
  }
}

final class ReportGenerationService {
  func generateReport(orderId: Int) -> Report {
    /* ... */
  }
}

테스트에서 여러 Act 섹션 사용

  • 테스트에 둘 이상의 Arrange, Act 또는 Assert 섹션이 있는 것은 코드 스멜(code smell) 로, 테스트가 여러 행동 단위를 한 번에 확인하여 유지보수성을 저해한다는 신호이다. 예를 들어 사용자의 등록과 삭제를 하나의 통합 테스트에서 확인하려고 하는 경우 아래와 같은 구조를 가질 수 있다.
    1. 준비 : 사용자 등록에 필요한 데이터 준비
    2. 실행 : UserController.registerUser() 호출
    3. 검증 : 등록 동작의 성공 여부를 확인하기 위해 데이터베이스 조회
    4. 실행 : UserController.deleteUser() 호출
    5. 검증 : 삭제 동작의 성공 여부를 확인하기 위해 데이터 베이스 조회
  • 각 Act를 별도의 테스트로 추출하여 테스트를 분할하는 것이 가장 좋다.
  • 예외적으로, 원하는 상태로 만들기가 어려운 프로세스 외부 의존성(예: 외부 은행 시스템)과 함께 작동하는 테스트의 경우 여러 Act를 단일 테스트로 결합하는 것이 유익할 수 있다. 이는 문제가 있는 프로세스 외부 의존성과의 상호 작용 횟수를 줄이는 효과가 있기 때문이다. 이러한 다단계 테스트는 거의 항상 종단간 테스트 범주에 속한다. 단위 테스트에는 프로세스 외부 의존성과 함께 작동하지 않으므로 여러 Act가 있어서는 안 된다.

로깅 기능 테스트 방법

  • 로깅은 테스트와 관련하여 어떻게 해야 할지 명확하지 않은 복잡한 주제이다.

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

public final class User {
  var userId: Int
  private(set) var email: String
  var type: UserType
  var emailChangedEvents: [EmailChangedEvent] = []
  private let logger: Logger

  public func changeEmail(_ newEmail: String, company: Company) {
    logger.info("Changing email for user \(userId) to \(newEmail)") // Start of the method
    precondition(canChangeEmail() == nil)
    if email == newEmail { return }

    let newType: UserType = company.isEmailCorporate(newEmail) ? .employee : .customer
    if type != newType {
      let delta = newType == .employee ? 1 : -1
      company.changeNumberOfEmployees(delta)
      logger.info("User \(userId) changed type from \(type) to \(newType)") // Changes the user type
    }

    email = newEmail
    type = newType
    emailChangedEvents.append(EmailChangedEvent(userId: userId, newEmail: newEmail))
    logger.info("Email is changed for user \(userId)") // End of the method
  }

  func canChangeEmail() -> String? {
    /* ... */
  }

  init(userId: Int, email: String, type: UserType, logger: Logger) {
    self.userId = userId
    self.email = email
    self.type = type
    self.logger = logger
  }
}
  • 로깅은 애플리케이션의 행동에 대한 중요한 정보를 생성하지만, 너무 보편적이어서 추가적인 테스트 노력을 들일 가치가 있는지 분명하지 않을 수 있다.
  • 로깅을 테스트해야 하는지에 대한 질문의 답은 로깅이 애플리케이션의 관찰 가능한 행동의 일부인지, 아니면 구현 세부 사항인지에 달려 있다.
  • 지원 로깅(Support logging)은 지원 직원이나 시스템 관리자가 추적할 목적으로 메시지를 생성하며, 이는 애플리케이션의 관찰 가능한 행동의 일부이다. 따라서 테스트해야 한다.
  • 진단 로깅(Diagnostic logging)은 개발자가 애플리케이션 내부에서 무슨 일이 일어나고 있는지 이해하는 데 도움을 주며, 이는 구현 세부 사항이다. 따라서 일반적으로 테스트하지 않는다.

로깅을 어떻게 테스트해야 하는가?

  • 로깅은 프로세스 외부 의존성을 포함하므로, 테스트할 때는 다른 프로세스 외부 의존성을 다루는 기능과 동일한 규칙이 적용된다. 애플리케이션과 로그 저장소 간의 상호 작용을 확인하기 위해 목(mock)을 사용해야 한다.
public func changeEmail(_ newEmail: String, company: Company) {
  logger.info("Changing email for user \(userId) to \(newEmail)")
  precondition(canChangeEmail() == nil) // Diagnostic logging
  if email == newEmail { return }

  let newType: UserType = company.isEmailCorporate(newEmail) ? .employee : .customer
  if type != newType {
    let delta = newType == .employee ? 1 : -1
    company.changeNumberOfEmployees(delta)
    domainLogger.userTypeHasChanged(userId: userId, oldType: type, newType: newType) // Support logging
  }

  email = newEmail
  type = newType
  emailChangedEvents.append(EmailChangedEvent(userId: userId, newEmail: newEmail))
  logger.info("Email is changed for user \(userId)") // Diagnostic logging
}
  • 진단 로깅은 여전히 이전 로거(ILogger 타입)를 사용하지만, 지원 로깅은 새로운 domainLogger(IDomainLogger 타입) 인스턴스를 사용한다.
public final class DomainLogger: DomainLoggerProtocol {
  private let logger: Logger

  public init(logger: Logger) {
    self.logger = logger
  }

  public func userTypeHasChanged(userId: Int, oldType: UserType, newType: UserType) {
    logger.info("User \(userId) changed type from \(oldType) to \(newType)")
  }
}
  • DomainLogger는 ILogger 위에서 작동한다. 도메인 언어를 사용하여 비즈니스에서 요구하는 특정 로그 항목을 선언함으로써 지원 로깅을 이해하고 유지보수하기 쉽게 만든다. 사실, 이 구현은 로그 파일 후처리 및 분석에 뛰어난 유연성을 제공하는 구조화된 로깅(structured logging) 개념과 매우 유사하다. 구조화된 로깅 이해하기 구조화된 로깅은 로그 데이터 캡처를 해당 데이터 렌더링과 분리하는 로깅 기법이다. 전통적인 로깅은 단순 텍스트로 작동한다.
logger.info("User Id is " + String(12))
  • 먼저 문자열을 생성한 다음 해당 문자열을 로그 저장소에 기록한다. 이 접근 방식의 문제는 구조가 부족하여 결과 로그 파일을 분석하기 어렵다는 것이다. 예를 들어, 특정 유형의 메시지가 몇 개이고 그중 몇 개가 특정 사용자 ID와 관련되어 있는지 확인하기 어렵다. 이를 위해서는 특별한 도구를 사용해야 한다.
logger.info("User Id is \(12)")
  • 이 메서드는 메시지 템플릿(메시지 자체는 공간 효율성을 위해 조회 저장소에 저장됨)의 해시(hash)를 계산하고 이를 입력 매개변수와 결합하여 캡처된 데이터 세트를 형성한다. 다음 단계는 해당 데이터의 렌더링이다. 전통적인 로깅과 마찬가지로 플랫(flat) 로그 파일을 가질 수 있지만, 이는 가능한 렌더링 중 하나일 뿐이다. 캡처된 데이터를 JSON 또는 CSV 파일로 렌더링하도록 로깅 라이브러리를 구성할 수도 있으며, 이를 통해 분석이 더 쉬워진다.
public func userTypeHasChanged(
  userId: Int, 
  oldType: UserType, 
  newType: UserType
) {
  logger.info("User \(userId) changed type from \(oldType) to \(newType)")
}
  • userTypeHasChanged()를 메시지 템플릿의 해시로 볼 수 있다. userId, oldType, newType 매개변수와 함께 이 해시는 로그 데이터를 형성한다. 이 메서드의 구현은 로그 데이터를 플랫 로그 파일로 렌더링한다.

  • 지원 및 진단 로깅을 위한 테스트 작성 앞서 언급했듯이 DomainLogger는 프로세스 외부 의존성(로그 저장소)을 나타낸다. 이는 User가 해당 의존성과 상호작용하여 비즈니스 로직과 프로세스 외부 의존성과의 통신 간의 분리를 위반한다는 문제를 야기한다. DomainLogger의 사용은 User를 복잡한 코드(overcomplicated code) 범주로 전환하여 테스트하고 유지보수하기 어렵게 만든다.
    이 문제는 외부 시스템에 변경된 사용자 이메일에 대한 알림을 구현했던 것과 동일한 방식으로 해결할 수 있다. 도메인 이벤트의 도움을 받아서이다. 사용자 유형의 변경 사항을 추적하기 위해 별도의 도메인 이벤트를 도입할 수 있다.

public func changeEmail(
  _ newEmail: String, 
  company: Company
) {
  logger.info("Changing email for user \(userId) to \(newEmail)")
  precondition(canChangeEmail() == nil)
  if email == newEmail { return }

  let newType: UserType = company.isEmailCorporate(newEmail) ? .employee : .customer
  if type != newType {
    let delta = newType == .employee ? 1 : -1
    company.changeNumberOfEmployees(delta)
    addDomainEvent(UserTypeChangedEvent(userId: userId, oldType: type, newType: newType)) // Uses a domain event instead of DomainLogger
  }

  email = newEmail
  type = newType
  addDomainEvent(EmailChangedEvent(userId: userId, newEmail: newEmail))
  logger.info("Email is changed for user \(userId)")
}
  • 이제 UserTypeChangedEvent와 EmailChangedEvent라는 두 가지 도메인 이벤트가 있다. 둘 다 동일한 인터페이스(IDomainEvent)를 구현하므로 동일한 컬렉션에 저장될 수 있다.
func changeEmail(userId: Int, newEmail: String) -> String? {
  let userData = database.getUserById(userId)
  let user = UserFactory.create(userData)

  if let error = user.canChangeEmail() {
    return error
  }

  let companyData = database.getCompany()
  let company = CompanyFactory.create(companyData)

  user.changeEmail(newEmail, company: company)
  database.saveCompany(company)
  database.saveUser(user)
  eventDispatcher.dispatch(user.domainEvents) // Dispatches user domain events

  return "OK"
}
  • EventDispatcher는 도메인 이벤트를 프로세스 외부 의존성 호출로 변환하는 새로운 클래스이다.
    • EmailChangedEvent는 messageBus.sendEmailChangedMessage()로 변환된다.
    • UserTypeChangedEvent는 domainLogger.userTypeHasChanged()로 변환된다.
  • UserTypeChangedEvent의 사용은 도메인 로직과 프로세스 외부 의존성과의 통신이라는 두 가지 책임 간의 분리를 복원한다. 이제 지원 로깅을 테스트하는 것은 다른 비관리형 의존성(메시지 버스)을 테스트하는 것과 다르지 않다.
  • User 클래스가 진단 로깅을 하는 방식은 변경하지 않았다는 점에 유의한다. User는 ChangeEmail 메서드의 시작과 끝에서 로거 인스턴스를 직접 사용한다. 이는 의도된 것이다. 진단 로깅은 개발자만을 위한 것이므로 이 기능을 단위 테스트할 필요가 없으며 도메인 모델에서 제외할 필요도 없다.

로깅은 어느 정도가 적당한가?

  • 지원 로깅은 비즈니스 요구사항이므로 논의 대상이 아니지만, 진단 로깅은 통제할 수 있다.
  • 진단 로깅을 과도하게 사용하지 않는 것이 중요하다. 과도한 로깅은 코드를 복잡하게 만들고(특히 도메인 모델에서), 로그의 신호 대 잡음비(signal-to-noise ratio)를 손상시켜 관련 정보를 찾기 어렵게 만든다.
  • 가능하다면 도메인 모델에서 진단 로깅을 사용하지 않는 것이 좋다. 이상적으로는 처리되지 않은 예외에 대해서만 진단 로깅을 사용해야 한다.

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

  • 로거 인스턴스를 코드에 전달하는 방법 중 하나로 정적 메서드를 사용하는 것은 앰비언트 컨텍스트(ambient context) 안티패턴이다. 이는 의존성을 숨기고 변경하기 어렵게 하며, 테스트를 더 어렵게 만든다.
public final class User {
  private static let logger = Logger(for: User.self)

  public func changeEmail(_ newEmail: String, company: Company) {
    Self.logger.info("Changing email for user \(userId) to \(newEmail)")
    /* ... */
    Self.logger.info("Email is changed for user \(userId)")
  }
}
  • 대신, 로거를 포함한 모든 의존성은 항상 명시적으로 주입해야 한다. 이는 클래스 생성자를 통해서나 메서드 인수를 통해 이루어질 수 있다.
public func changeEmail(
  _ newEmail: String, 
  company: Company, 
  logger: Logger
) {
  logger.info("Changing email for user \(userId) to \(newEmail)")
  /* ... */
  logger.info("Email is changed for user \(userId)")
}
profile
iOS & Swift

0개의 댓글