마이크로서비스 통합테스트 자동화 팬시하게 수행하기

웨더컴퍼니는?

웨더컴퍼니는 하루에 전세계 수십만 IoT 소스로 부터 50테라바이트의 데이터를 수집하여 178개의 날씨예측모델을 AI로 블렌딩하여 전세계 22억 지역에 실시간 날씨 정보를 제공합니다. 한국 스크럼팀이 속한 웨더컴퍼니 SUN B2C 개발팀은 하루 500억 리퀘스트를 처리하는 초대형 벡엔드 API서비스를 운영하고 있습니다. 구글과 애플, 삼성전자, 페이스북이 우리의 API를 직접 호출하여 날씨, 생활지수 뿐 아니라 수많은 기후, 재난 관련 콘텐츠들을 고객들에게 서비스하고 있습니다. 폴리글랏 에브리씽 (언어, 스토리지, 퍼시스턴스, 클라우드…)철학으로 클라우드 네이티브 어플이케이션 아키텍처를 가진 서비스들을 애자일과 데브옵스 방법으로 작성하고 개선합니다.

개요

이번 글에서는 웨더컴퍼니에서 사용하고 있는 테스트자동화 사례에 대해 소개합니다.

거대한 트래픽을 처리하기 위해 어플리케이션 아키텍처의 레이어가 상당히 복잡합니다. 테스트 종류별로 커버하고 있는 영역은 다음과 같습니다. 레이어별테스 저희의 단위테스트드 코드나 인수테스트 코드는 상당히 높은 커버리지로 수행되며 CI/CD 파이프라인과 연계되서 진행됩니다.

다만 통합테스트의 경우 자동화 정도가 개별 서비스 마다 차이가 있었는데 서비스가 의존하는 자원들이 많을 수록 자동화하기가 어려웠습니다. 외부 자원들을 Mock하면 그것은 겨우(?) 단위테스트와 별반 다르지 않을 것입니다.

모놀리스 서비스들이 마이크로 서비스 아키텍처로 오면서 얻어지는 장점은 매우 많지만 통합 테스트 측면에서 특히나 체감적인 장점들이 있습니다.

마이크로 서비스들은 일반적으로 퍼시스턴트(RDB, Queue, NoSQL)를 이용하고 아키텍처 디펜던시가 모놀리스보다는 적으므로 테스트 자동화를 구성하는데 좀 더 간단하게 접근할 수 있습니다.

테스트 자동화는 일반적으로 유닛 > 통합 > 시스템 > 인수 Test로 진화시키며 구성을 해나가는데 우리팀의 경우는 Unit과 Integration테스트는 개발자가 작성하고 System과 Acceptance테스트는 테스트엔지니어가 작성합니다. 테스트코드주

Unit테스트는 TDD 방식으로 개발을 하고 CI에서 기본적으로 수행하기 때문에 반드시 작성하며, Mock도구를 이용해 시스템 바운더리 밖에 있는 부분을 쉽게 Mocking할 수 있기 때문에 작성이 상대적으로 용이하다고 할 수 있습니다. (상대적입니다. 절대적 기준으로 보면 유닛테스트를 잘 유지보수 하는게 쉽지 않지만 반드시 필요합니다.)

하지만 통합테스트는 기본적으로 네트워크, 퍼시스턴스 등이 구동되어 있어야 하고 이것들과의 상호작용을 점검하는데 초점을 맞추므로 Mock부분을 최소화하거나 없애야 합니다.

이를 위해 통 테스트 코드에 쉽게 적용할 수 있었던 방법은 임베디드 퍼시스턴스와 프로그래머블 컨테이너의 사용입니다.

1. 임베디드 퍼시스턴스 활용

오픈소스 퍼시스턴스로 흔히 사용하는 레디스, MongoDB, Postgres, Maria등 대부분이 어플리케이션에서 바로 임베디드할 수 있는 라이브러리들을 자체적으로 혹은 또다른 오픈소스 프로젝트로 제공하고 있기 때문입니다. (표 -1 퍼시스턴스 별 임베디드 엔진 라이브러리)

마이크로서비스 A프로젝트는 퍼시스턴스를 레디스를 사용하였는데 쉽게 임베디드 레디스 라이브러리를 적용할 수 있었습니다.

본 예제는 Scala언어와 sbt빌드툴을 사용하고 있으나 Java나 다른 JVM언어에서 동일한 메커니즘으로 사용할 수 있습니다.

build.sbt

  "com.github.kstyrc" % "embedded-redis" % "0.6" % "test"

간단하게 싱글노드로 구동하는 코드는 다음과 같습니다.

  //포트 할당
  import redis.embedded.RedisServer
  (생략)
  val redisServer = new RedisServer(6379);
  redisServer.start();
  // 테스트 수행
  //....
  redisServer.stop();

이 임베디드 레디스는 심지어 센티넬 모드와 센티넬 + 클러스터 모드를 지원하기도 합니다. 아래는 레디스를 클러스터형태로 구동시키고 이를 사용하기 위한 클라이언트를 생성 코드입니다.

  import redis.embedded.RedisCluster
  (생략)
  //레디스 클러스터 3개 노드에 대한 포트 할당
  final val FIRST_PORT = 1001
  final val SECOND_PORT = 1002
  final val THIRD_PORT = 1003
  final val HOST = "localhost"

  val redisClusterMasterHosts = List(HOST, HOST, HOST)
  val redisClusterMasterPorts = List(FIRST_PORT, SECOND_PORT, THIRD_PORT)

  //Builder를 지원하므로 필요한 정보를 할당할 수 있습니다.
  val redisCluster = RedisCluster.builder
  //클러스터와 포트를 매핑합니다. 사용하는 임베디드 라이브러리는 Java용이므로 Java형으로 캐스팅합니다. 자바에서 직접 사용할땐 필요없다.
    .serverPorts(List(FIRST_PORT.asInstanceOf[Integer]).asJavaCollection).replicationGroup("master1", 0)
    .serverPorts(List(SECOND_PORT.asInstanceOf[Integer]).asJavaCollection).replicationGroup("master2", 0)
    .serverPorts(List(THIRD_PORT.asInstanceOf[Integer]).asJavaCollection).replicationGroup("master3", 0)
    .build

  //레디스클러스터를 시작하고 종료시킨다. 일반적으로 테스트 시작전 레디스를 시작시키고 테스트 종료후 멈추므로 테스트프레임워크가 제공하는 BeforeAndAfterAll같은 클래스들을 활용합니다.
  redisCluster.start()
  //테스트 수행
  //....
  redisCluster.stop()

2. 컨테이너 퍼시스턴스 활용

하지만 앞서 설명드린 임베디드 레디스 라이브러리는 아래의 한계점이 있었습니다.

  • 우리가 사용하는 레디스의 프로덕션버전은 5이고 3버전 이상에서 사용하는 특정 명령어를 사용야 했는데 임베디드 레디스의 버전은 2.x에서 프로젝트가 중단되었기 때문입니다.
  • 새로 개발한 마이크로 서비스도 임베디드 레디스를 사용하여 통합테스트 코드를 작성하려 했으나 이 마이크로서비스는 레디스의 순수 클러스터 기능(참고)을 사용하는데 임베디드 레디스는 센티넬이 구성된 레디스 클러스터만 지원이 가능해서 임베디드 레디스 사용이 어렵게 되었습니다.

    따라서 다른 선택지를 찾은게 컨테이너 방식의 퍼시스턴스입니다. 하지만 이 방식을 사용하기 위해서는 Docker Runtime이 무조건 설치되어있어야 합니다. SaaS로 제공하는 대부분의 CI/CD환경은 Docker Runtime을 지원하고 있으며 별로로 On-prem으로 구성한 CI/CD환경에서는 Docker Runtime을 설치해야 합니다.

2.1 Test container

Test Container는 통합테스트 자동화 시 가장 폭넓게 사용되는 OSS이고 레퍼런스가 풍부한 장점이 있습니다.

  • 데이터 액세스 계층 통합 테스트: MySQL, Postgre의 컨테이너형 인스턴스 사용완벽한 호환성을 위해 데이터 액세스 계층 코드를 테스트하는 SQL 또는 Oracle 데이터베이스. 개발자의 컴퓨터에서 복잡한 설정을 요구하지 않고 테스트가 항상 알려진 DB 상태에서 시작된다는 것을 알고 있으므로 안전함. 컨테이너화할 수 있는 다른 데이터베이스 유형도 사용할 수 있습니다.
  • 응용 프로그램 통합 테스트: 데이터베이스, 메시지 큐 또는 웹 서버와 같은 종속성을 가진 어플리케이션을 테스트하기 위한 기능
  • UI/Acceptance 테스트: 셀레늄과 호환되는 컨테이너형 웹 브라우저를 사용하여 자동 UI 테스트 수행가능. 각 테스트는 브라우저 상태, 플러그인 변형 또는 자동화된 브라우저 업그레이드 없이 새로운 브라우저 인스턴스를 얻을 수 있음.

    사용방법은 라이브러리 import후 매우 쉬운 방법으로 사용할 수있는데 아래는 Redis에 대한 예제입니다. build.sbt

    "com.dimafeng" %% "testcontainers-scala-scalatest" % "0.37.0" % Test
    
    import com.dimafeng.testcontainers.GenericContainer
    (생략)
    
    val REDIS_PORT = 6379
    val redisContainer = GenericContainer("redis:5.0.8-alpine", exposedPorts = Seq(REDIS_PORT), waitStrategy = Wait.forListeningPort().withStartupTimeout(java.time.Duration.ofSeconds(5))
    )
    redisContainer.start()
    

    다만, 생성된 레디스에 접속하기 위해서는 할당된 포트 (위에서 6379)를 사용할 수 있는게 아니라 무작위 포트로 접속이 가능합니다. Docker의 포트바인딩이 <랜덤포트>:6379로 되기 때문에 랜덤포트를 가져오는 로직이 필요합니다.. 랜덤포트를 사용하는 이유는 Docker가 Run 될 때 로컬에서 이미 해당 포트가 사용되고 있을수 있으므로 이를 피하기 위해서 입니다.

    val mappedPort = redisContainer.mappedPort(REDIS_PORT)
    

    이 포트를 가져와서 레디스 클라이언트를 생성한 후 테스트를 진행하면 됩니다.

2.2 docker-testkit-scalatest

이 라이브러리 역시 매우 쉽고 강력한 컨테이너 Runtime을 제공하는데, 기본적으로 포트바인딩 시 로컬포트와 컨테이너포트를 동일하게 사용할 수 있습니다.

  (생략)
  val redisContainer = DockerContainer("redis:5.0.4")
      .withPorts(RedisPort -> Some(RedisPort))
      .withReadyChecker(DockerReadyChecker.LogLineContains("Ready to accept connections"))

주의해야 할 점은 위의 DockerReadyChecker를 반드시 설정해야 하는데 비동기 방식으로 서비스를 띄우기 때문에 퍼시스턴스가 제대로 구동되었는지를 로그를 통해 수행할 수 있도록 합니다.

docker구동에 필요한 기본 Docker들이 구동되기 위해 아래와 같이 테스트에 필요한 다커를 추가하는 방식으로 설정합니다. 여러개의 퍼시스턴스가 필요하면 여기서 리스트형태로 접목시킵니다.

   override def dockerContainers: List[DockerContainer] = redisContainer :: super.dockerContainers

.with메소드로 Redis의 프로퍼티들을 설정할수 있습니다.

3. 테스트 수행

Integration 테스트코드는 다음과 같은 절차로 수행 될 것입니다.

테스트절차

레디스 등의 퍼시스턴스가 준비되었으면 필요한 데이터를 레디스 명령어를 통해 입력합니다. 아래는 scala test의 BeforeAndAfterAll 클래스를 사용해 테스트 시작 전에 데이터가 삽입는 될수 있도록 함수를 만들어 사용하습니다.

  override def beforeAll: Unit = {
      super.beforeAll()
      testDate.store(storedData)
    }

로그 메시지를 직접확인하여 서비스가 정상적으로 Run되었을때의 메시지를 넣어야 합니다. 아래 AkkaHttpServer클래스는 akka-http 라이브러리를 저희가 wrapping해놓은 것입니다. 필요에 따라 서버를 구동하는 로직으로 교체하면 됩니다.

  object TestServer {
      def start(s:ActorSystem, m:ActorMaterializer): Unit = {
        val serviceName = "it-test"
        implicit val system: ActorSystem = s
        implicit val materializer: ActorMaterializer = m
        implicit val executor: ExecutionContextExecutor = system.dispatcher
        val server = new AkkaHttpServer(serviceName, TestServerConfig, system, materializer, executor) {
          override def getRoutes: Route = App.mkHandlerRoutes(App.handlers, TestServerConfig)(executor)
          override def awaitHandle(bindingHandler: Future[ServerBinding]): Unit = {}
        }
        server.start()
      }
    }

API를 호출하기 위해 공통적으로 사용되는 리퀘스트 값을 아래와 같이 만들수 있습니다.

     def sendRequest(req: HttpRequest): Future[HttpResponse] = Source.single(req).via(
       Http().outgoingConnection(host = testHost, port = testPort)
    ).runWith(Sink.head)

실제 테스트케이스에서는 아래과 같이 호출하고 사용하는 Matcher로 결과값을 검증하면 됩니다. 구동되는 환경이 저사양의 런타임에서도 돌 수 있게 타임아웃 설정값을 10초로 길게 주었습니다.

  whenReady(sendRequest(HttpRequest(uri = uriStr)), timeout(10 seconds), interval(500 millis)) { response =>
        whenReady(Unmarshal(response.entity).to[String]) { res =>
          response.status shouldBe StatusCodes.OK
          val resJson = parse(res).getOrElse(Json.Null)
          root.test.each.number.getAll(resJson).size should be (3)
          root.test.each.number.getAll(resJson).head.toDouble should be (1)
        }
      }

테스트가 종료되면 마이크소 서비스와 다커 레디스를 종료합니다.

    Await.ready(actorSystem.terminate(), 30 second)
      super.afterAll()
    }

그리고 build.sbt내에서 통합테스트시 코드가 실행될 수 있도록 추가합니다.

  lazy val root = Project("test", file("."))
    .configs(IntegrationTest extend Test)
    .settings(
      commonSettings,
      Defaults.itSettings
    )

코드 구조는 아래와 같이 되어야 합니다.

디렉토리구

통합테스트 자동화를 적용한 A프로젝트는 모든 커밋의 CI에 통합테스트가 수행되는 것이 아니라 통합테스트가 필요한 태그시에만 구동되도록 설정하였습니다. 아래는 RELEASE, RC(Release Candidate), M(Milestone)이 포함된 태깅시 작동되게 해놓은 TravisCI용 yaml입니다.

   jobs:
    include:
    (생략)
    - stage: integration test
      name: Integration test
      script:
      - sbt api/it:test
      if: tag =~ /^(.*RELEASE.*|.*RC.*|.*M.*)$/

결론

  • 레디스 공식 다커 이미지를 사용하므로 프로덕션에서 사용하는 레디스 버전과 테스트 시 사용되는 버전을 일치시킬 수 있습니다. 의외로 퍼시스턴스 버전차이에 의한 오류들이 종종있는데 이것을 방지할 수 있습니다.
  • 테스트 케이스 (혹은 스위트)내에 필요한 임베디드 엔진이나 컨테이너들을 구동시키기 때문에 통합테스트를 위해 CI나 CD파이프라인을 복잡하게 구성할 필요가 없습니다.
  • 추가적인 외부자원 (메시지큐나 심지어는 다른 마이크로서비스)을 손쉽게 테스트케이스에서 추가하여 테스트를 수행할 수 있습니다.

Reference