코로나 시대 원격 QA! 오픈소스 디바이스팜 STF 도입기

들어가며

안녕하세요. Software Quality Engineering 팀 임지훈입니다. QA(Quality Assurance) 단계에서 특정 단말기에서만 문제가 발생하는 경우가 종종 있습니다. 하지만 코로나19로 인해 해외 출장은 물론 출근조차 어려워지면서 원격지의 단말기를 입수해 테스트하는 일이 불가능한 경우가 많아졌습니다. 이를 해결하기 위해 디바이스팜을 구축하고 테스트 자동화에 활용한 사례를 여러분께 공유하려고 합니다.

 

디바이스팜이란?

디바이스팜(device farm)이란 Android와 iOS 단말기를 테스트할 수 있도록, 클라우드를 통해 다수의 단말기를 제공하고 각 단말기에 앱을 설치하거나 화면을 제어할 수 있는 도구를 제공하는 서비스를 말합니다. 테스트가 필요한 단말기를 원격지에서 시스템에 연결하고 웹으로 해당 단말기를 제어할 수 있는 도구가 있다면, 개발이나 QA 단계에서 출근하거나 출장을 가지 않아도 문제가 있는 단말기에서 디버깅을 할 수 있습니다. DeviceFarmer/STF는 이런 기능을 제공하는 유일한 오픈소스 툴입니다.

 

DeviceFarmer/STF 소개

DeviceFarmer/STF(Smartphone Test Farm)는 Android 단말기를 원격지의 장비에 연결해 마치 수중에 있는 것처럼 디버깅할 수 있게 해주는 웹 애플리케이션입니다.

 

기능

DeviceFarmer/STF는 Android OS를 지원하며 아래와 같은 기능을 제공합니다.

  • 브라우저에서 단말기 제어 기능
  • 단말기 파일 트리 탐색 기능
  • 단말기 예약 및 파티셔닝(한정된 사용자 지정) 기능

 

STF 아키텍처

STF는 아래 그림과 같이 마이크로 서비스 아키텍처로 구성되어 있습니다. 각 서비스는 ZeroMQ와 Protocol Buffers로 통신하며 총 19개의 마이크로 서비스가 Docker 이미지로 제공됩니다. 구축하려는 목적과 사양에 맞게 적절하게 서비스를 실행해야 합니다.

 

LINE에서 구성한 STF 아키텍처

LINE은 여러 국가에서 서비스하고 있기 때문에 테스트할 때 각 국가에서 독립적인 장비로 단말기를 제공해 주어야 합니다. 이런 상황에서 모든 단말기를 하나의 웹 애플리케이션으로 제어할 수 있도록 구성하고 싶었습니다. 이에 다음 그림과 같이 여러 개의 단말기 서버(그림에서 Provider)와 하나의 STF 웹 서버(그림에서 App Server)로 구성했습니다.

위 구조를 좀 더 자세히 살펴보겠습니다. 아래 그림과 같이 웹 서버 처리에 필요한 마이크로 서비스(이하 서비스)를 묶어 앱 역할(role)로 정의하고 앱 장비(App machine)에 설치했으며, 단말기 처리에 필요한 서비스를 묶어 프로바이더(Provider) 역할로 정의하고 프로바이더 장비(Provider machine)에 설치했습니다. 프로바이더 역할의 경우 여러 개의 장비에 같은 내용으로 설치해야 했는데요. 이런 이유 때문에 쉽게 배포할 수 있도록 앱 역할과 프로바이더 역할 모두 Docker Compose를 이용했습니다.

다음으로 프로바이더 역할과 앱 역할의 Docker Compose 구성 방식과 앱 서버에서 리버스 프락시(reverse proxy)를 구성하는 방식을 살펴보겠습니다.

 

프로바이더 역할의 Docker Compose 구성 방식

프로바이더 역할을 구성하고 있는 서비스를 간략하게 설명하면 다음과 같습니다.

  • adb(Android debug bridge)는 Android 단말기와 통신할 수 있는 커맨드 라인 도구입니다
  • provider는 앱 역할로 들어오고 나가는 커맨드 처리를 담당합니다

다음과 같은 방식으로 Docker Compose를 정의했습니다.

version: '3'
 
services:
  adb:
    image: ${ADB_IMAGE}
    restart: unless-stopped
    privileged: true
    volumes:
      - /dev/bus/usb:/dev/bus/usb
  provider:
    image: ${STF_IMAGE}
    restart: unless-stopped
    command: stf provider --name ${PROVIDER_NAME}
              --connect-sub tcp://${APP_SERVER_IP}:7250
              --connect-push tcp://${APP_SERVER_IP}:7270
              --storage-url http://${APP_SERVER_IP}/
              --public-ip ${PROVIDER_IP}
              --heartbeat-interval 10000
              --screen-ws-url-pattern "ws://${APP_SERVER_IP}/d/${PROVIDER_NAME}/<%= serial %>/<%= publicPort %>/"
              --adb-host adb
              --min-port 7400
              --max-port 7700
              --no-cleanup
    ports:
      - 7400-7700:7400-7700
    depends_on:
      - adb

provider 실행 인자 중 --screen-ws-url-pattern 부분을 살펴보면, 웹소켓 패턴을 다음 코드와 같이 정의했습니다. 특정 프로바이더에 연결된 단말기에 접속하기 원하는 클라이언트가 프로바이더 장비를 찾아갈 수 있도록 독립된 패턴을 제공하는 역할을 합니다.

ws://${APP_SERVER_IP}/d/${PROVIDER_NAME}/<%= serial %>/<%= publicPort %>/

 

앱 역할의 Docker Compose 구성 방식

다음은 앱 역할을 구성하는 서비스에 대한 간략한 설명입니다.

  • app은 STF 웹 앱을 구동하기 위해 필요한 내용이 담겨 있고, 웹소켓과 로그인에 필요한 LDAP 서버를 지정합니다.
  • dev-triproxy는 원격지에 있는 프로바이더 장비로부터 요청을 받아 처리하는 역할을 합니다.
  • websocket은 클라이언트의 자바스크립트와 통신하는 역할을 담당합니다.
  • nginx는 웹 서버를 실행합니다.

이번 글에서는 다루지 않지만 이외에도 rethinkdbprocessor, api 등 13개의 서비스가 더 있습니다. 아래는 앱 역할의 Docker Compose를 구성한 파일의 일부입니다. 

version: '3'

services:
  nginx:
    build: nginx/
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
    restart: unless-stopped
    ports:
      - 80:80
    depends_on:
      - app
      - websocket
  app:
    image: ${STF_IMAGE}
    restart: unless-stopped
    environment:
      - RETHINKDB_PORT_28015_TCP
      - SECRET
      - STF_ADMIN_NAME
      - STF_ADMIN_EMAIL
    command: stf app --auth-url http://${APP_SERVER_IP}/auth/ldap/ --websocket-url ws://${APP_SERVER_IP}/ --port 3000
  dev-triproxy:
    image: ${STF_IMAGE}
    restart: unless-stopped
    command: stf triproxy dev --bind-pub "tcp://*:7250" --bind-dealer "tcp://*:7260" --bind-pull "tcp://*:7270"
    ports:
      - 7250:7250
      - 7270:7270
  websocket:
    image: ${STF_IMAGE}
    restart: unless-stopped
    command: stf websocket --port 3000 --connect-sub tcp://triproxy:7150 --connect-push tcp://triproxy:7170

 

NginX 설정

NginX 설정 파일에서는 단말기 제어 요청이 들어왔을 때 프로바이더 장비를 찾아가기 위해 리버스 프락시를 다음과 같이 구성했습니다.

http {
  server {
    location ~ "^/d/KOREA_BS_1/([^/]+)/(?<port>[0-9]{3,5})/$" {
      proxy_pass http://${KOREA_BS_1_IP}:$port/;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
      proxy_set_header X-Forwarded-For $remote_addr;
      proxy_set_header X-Real-IP $remote_addr;
    }

    location ~ "^/d/KOREA_BS_2/([^/]+)/(?<port>[0-9]{3,5})/$" {
      proxy_pass http://${KOREA_BS_2_IP}:$port/;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
      proxy_set_header X-Forwarded-For $remote_addr;
      proxy_set_header X-Real-IP $remote_addr;
    }

    location ~ "^/d/KOREA_BS_3/([^/]+)/(?<port>[0-9]{3,5})/$" {
      proxy_pass http://${KOREA_BS_3_IP}:$port/;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
      proxy_set_header X-Forwarded-For $remote_addr;
      proxy_set_header X-Real-IP $remote_addr;
    }

    location ~ "^/d/LFK_1/([^/]+)/(?<port>[0-9]{3,5})/$" {
      proxy_pass http://${LFK_1_IP}:$port/;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
      proxy_set_header X-Forwarded-For $remote_addr;
      proxy_set_header X-Real-IP $remote_addr;
    }

    location ~ "^/d/DALIAN_1/([^/]+)/(?<port>[0-9]{3,5})/$" {
      proxy_pass http://${DALIAN_1_IP}:$port/;
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
      proxy_set_header X-Forwarded-For $remote_addr;
      proxy_set_header X-Real-IP $remote_addr;
    }
  }
}

클라이언트에서는 특정 단말기를 사용하고자 할 때 프로바이더 장비의 이름을 포함한 패턴을 URL에 넣어 요청하고, 앱 서버에서는 이 URL을 탐색해서 프로바이더 장비의 IP 주소로 포워딩하는 방식입니다. 

이제 프로바이더 역할과 앱 역할의 Docker Compose를 모두 작성했습니다. 각 머신에서 다음 요구 사항만 만족을 한다면 한 줄의 Docker Compose 명령어로 서버를 실행할 수 있습니다.

 

프로바이더 장비 요구 사항

  • 리눅스 기반의 OS 사용(OSX의 경우에는 VirtualBox와 Docker Machine, 리눅스 설치 후 사용 가능)
  • Docker Compose 설치
  • 고정 IP 주소 필요
  • 앱 장비와의 인바운드 포트로 7400~7700 범위의 포트가 열려 있어야 함
  • 앱 장비와의 아웃바운드 포트로 7250 포트와 7270 포트가 열려 있어야 함
  • (선택 사항)인바운드 포트(7400~7700)가 열려 있으면 클라이언트에서 adb를 이용하여 단말기 디버깅 가능

 

앱 장비 요구 사항

  • 리눅스 기반의 OS 사용(OSX의 경우에는 VirtualBox와 Docker Machine, 리눅스 설치 후 사용 가능)
  • Docker Compose 설치
  • 고정 IP 주소 필요
  • 클라이언트와의 인바운드 포트로 80 포트가 열려 있어야 함

 

자동화 테스트에 디바이스팜 활용하기

자동화 테스트용 CI 장비에서도 단말기를 대여하여 자동화 테스트를 수행할 수 있습니다.

 

자동화 테스트에 디바이스팜을 활용했을 때 얻는 이점

단말기가 클라우드에 존재하므로 CI 장비를 물리 장비가 아닌 가상 장비로 대체할 수 있고, CI 장비에 연결되어 있지 않은 단말기까지 사용할 수 있습니다.

 

디바이스팜 활용 전

아래 그림과 같이 데브옵스(DevOps) 담당자나 개발자가 물리 장비와 단말기를 모두 관리해야 하며, 각 장비에 연결되어 있는 단말기만 사용할 수 있습니다.

디바이스팜 활용 후

아래 그림과 같이 데브옵스 담당자 및 개발자는 가상 장비만 관리하면 되며, 디바이스팜에 연결된 모든 단말기를 사용할 수 있습니다.

 

테스트 자동화에 활용하기 위한 절차

테스트 자동화에 활용하기 위해 STF 디바이스팜을 통해 단말기를 대여하는 방법을 알아보겠습니다.

 

RestAPI 키 발급

STF에 연결된 단말기를 CI 장비에서 대여하기 위해서는 RestAPI를 활용해야 합니다. 이를 위한 키는 STF 웹 앱의 Settings 메뉴에서 발급받을 수 있습니다.

 

단말기 대여

키를 발급받고 나면 아래 코드와 같이 RestAPI를 통해 단말기를 대여할 수 있습니다.

DEVICE_ID=your_device_serial
curl -X POST --header "Content-Type: application/json" --data '{"serial":"'"$DEVICE_ID"'"}' -H "Authorization: Bearer your_access_token" http://stf.linecorp.com/api/v1/user/devices

 

대여한 단말기를 CI 장비 adb에 연결

대여가 완료되면 IP 주소와 포트를 이용해 단말기를 사용할 수 있습니다. 아래 첫 번째 코드로 단말기 IP 주소와 포트를 받아올 수 있습니다. 두 번째 코드는 단말기를 adb에 연결합니다. JSON 파싱에는 JQ 라이브러리를 사용했습니다.

STF_DEVICE_IP=$(curl -X POST -H "Authorization: Bearer your_access_token" http://stf.linecorp.com/api/v1/user/devices/${DEVICE_ID}/remoteConnect | jq -r .'remoteConnectUrl')
adb connect $STF_DEVICE_IP

 

STF 디바이스팜 서버 관리

서버를 관리하는 입장에서 매분 매초 서비스가 이용 가능한지 확인해야 하고 서버가 안정적인 상태인지도 확인해야 합니다. 관리해야 할 물리 서버가 많았기 때문에 중앙화된 관리 방법이 필요했습니다. 

 

모니터링 툴

모니터링 툴로는 ELK 스택을 활용했습니다. 각 물리 서버의 컴퓨팅 자원 상태를 Metricbeat로 수집하며 Filebeat로 로그를 수집합니다. 각 물리 서버에서 수집한 정보는 다음 그림과 같이 Grafana를 활용해 한곳에서 모니터링합니다. 특정 임곗값을 초과하거나 이상 징후가 발견되면 관리자에게 알람이 발송됩니다.

 

서비스 모니터링 테스트

STF 서비스를 지속 가능한 상태로 유지하기 위해 아래 세 가지 기초적인 사용자 행동을 테스트로 작성해서 매분마다 실행하고 있습니다. 테스트에 실패하면 관리자에게 알람이 발송됩니다.

  1. STF 웹으로 요청이 들어왔을 때 정상적으로 응답하는지 확인하고 로그인이 가능한지 확인
  2. RestAPI가 정상적으로 동작하는지 확인
  3. 브라우저에 표시되는 웹 UI가 기대하는 바와 같은지 확인

각 테스트를 좀 더 자세히 살펴보겠습니다.

 

응답 및 로그인 테스트

STF 웹 앱 URL로 요청이 들어왔을 때 정상적으로 응답하는지 확인하고 로그인이 가능한지 확인합니다.

  • 처음 URL로 요청을 보냈을 때 받은 응답이 302 Found이며 로그인 페이지로 리다이렉트되는지 확인
  • 로그인할 때 받은 응답이 200 OK이며 STF 메인 페이지로 리다이렉트되는지 확인
  • STF 메인 페이지로 요청을 보냈을 때 받은 응답이 200 OK인지 확인
class LoginTest(unittest.TestCase):

    def test_main_url_should_be_redirected(self):
        with requests.get(STF_MAIN_URL, allow_redirects=False) as r:
            self.assertEqual(r.status_code, HTTPStatus.FOUND)
            self.assertTrue(r.is_redirect)

    def test_main_url_should_be_redirected_to_login_url(self):
        with requests.get(STF_MAIN_URL, allow_redirects=True) as r:
            self.assertEqual(r.status_code, HTTPStatus.OK)
            self.assertEqual(r.url, STF_LOGIN_URL)

    def test_ldap_login(self):
        try:
            response = stf.login()
        except HTTPError as he:
            raise unittest.SkipTest(f"Skip test due to HTTP error: {repr(he)}")
        r_json = response.json()
        self.assertEqual(response.status_code, HTTPStatus.OK)
        self.assertTrue('success' in r_json)
        self.assertTrue('redirect' in r_json)
        self.assertTrue(STF_MAIN_URL in r_json['redirect'])

    def test_able_to_access_to_main_page_after_login(self):
        with requests.get(self.redirect_url) as r:
            try:
                self.assertEqual(r.status_code, HTTPStatus.OK)
                self.assertEqual(r.url, STF_MAIN_URL)
            except AssertionError as ae:
                send_failure_message()
                raise ae

 

RestAPI 테스트

사용자에게 제공되는 RestAPI가 정상적으로 동작하는지 확인합니다.

  • STF에서 제공하는 RestAPI인 사용자 정보 조회 응답 검증
  • STF에서 제공하는 RestAPI인 단말기 정보 조회 응답 검증
  • 단말기 정보 조회 시 연결된 단말기가 있는지 검증
class RestApiTest(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        try:
            cls.redirect_url = stf.redirect_url_after_login()
        except HTTPError as he:
            send_failure_message()
            raise unittest.SkipTest(f"Skip test due to HTTP error: {repr(he)}")

    @classmethod
    def tearDownClass(cls):
        pass

    def test_rest_api_for_users_should_be_available(self):
        with requests.Session() as sess:
            sess.get(self.redirect_url)  # Acquire authority
            response = sess.get(STF_REST_API_USERS)
            self.assertEqual(response.status_code, HTTPStatus.OK)

    def test_rest_api_for_devices_should_be_available(self):
        with requests.Session() as sess:
            sess.get(self.redirect_url)  # Acquire authority
            response = sess.get(STF_REST_API_DEVICES)
            self.assertEqual(response.status_code, HTTPStatus.OK)

    def test_at_least_one_device_should_be_connected(self):
        with requests.Session() as sess:
            sess.get(self.redirect_url)  # Acquire authority
            response = sess.get(STF_REST_API_DEVICES)
            self.assertEqual(response.status_code, HTTPStatus.OK)
            # ""present": true"
            devices = response.json()['devices']
            count = sum(map(lambda x: x['present'], devices))
            self.assertTrue(count >= 1)

 

UI 테스트

브라우저에 표시되는 웹 UI가 기대한 바와 같은지 확인합니다. 유지 보수에 드는 비용을 최소화하기 위해 기본적인 기능만 빠르게 테스트하는 것으로 테스트 케이스를 줄였기 때문에, 현재 테스트에선 아주 기초적인 UI 엘리먼트들이 화면에 존재하는지만 확인합니다. 

테스트 케이스단말기 목록을 구성하는 UI 엘리먼트가 존재하는지 확인그룹 목록을 구성하는 UI 엘리먼트가 존재하는지 확인
브라우저에 표시된 모습

클라우드 CI(GUI가 아닌 OS)에서 UI 테스트가 가능하도록 UI가 없는 브라우저인 ‘Chrome headless driver with Selenium’을 사용했습니다.

class UITest(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        chrome_options = webdriver.ChromeOptions()
        chrome_options.add_argument('--no-sandbox')
        chrome_options.add_argument('--headless')
        chrome_options.add_argument('--disable-gpu')
        chrome_options.add_argument('--window-size=1920,1080')
        ...

    def test_when_click_device_details_then_find_device_detail_table(self):
        self.helper.click_el('Devices')
        self.helper.click_el('Details')
        self.helper.find_el('Status')
        self.helper.find_el('Product')
        self.helper.find_el('Serial')
        self.helper.find_el('Market name')

    def test_when_click_groups_then_find_group_table(self):
        self.helper.click_el('Groups')
        self.helper.find_el('Status')
        self.helper.find_el('Name')
        self.helper.find_el('Owner')
        self.helper.find_el('Devices')
        self.helper.find_el('Users')

 

디바이스팜의 안정성 확인

아직 사용해 보지 않은 툴이었기 때문에 저희 팀에서도 도입 이전부터 안정성에 대한 의문이 있었는데요. 안정성(stability) 측면과 응답 속도(latency) 측면에 대해 살펴보고 추가로 다양한 adb 연결 방식을 테스트해 본 후기를 공유하겠습니다.

 

안정성 측면 

현재 최대 12대의 단말기를 동시에 연결하고 있습니다. 두 달 이상 운용해 본 결과 STF 때문에 웹 서버가 다운되거나 성능이 저하되는 일은 없었습니다. 조금 더 지켜봐야 알겠지만 현재까지는 안정적인 상태를 계속 유지하고 있습니다.

 

응답 속도 측면

해외 프로바이더 장비는 중국의 다롄과 일본의 후쿠오카, 이 두 곳에 위치하고 있습니다. 물리적으로 가까운 나라여서 그런지 사용하는 데 문제가 없을 정도의 응답 속도를 보여주고 있습니다. 다음 그림은 중국 다롄에 연결된 단말기를 한국에 위치한 앱 서버에서 제어하는 모습입니다.

이보다 더 먼 국가에 프로바이더 장비를 설치해야 할 필요가 생긴다면, 아키텍처를 변경해 앱 역할에 위치한 웹소켓 서비스를 프로바이더 역할로 옮기는 것도 한 가지 방법이 될 것 같습니다.

 

adb 연결 측면

현재 성능과 안정성의 이유로 케이블 연결로만 사용하고 있습니다. 다른 방식으로 연결했을 때 어떤 이슈가 있었는지 공유하겠습니다.

 

와이파이(Wi-Fi) 연결 시 발생하는 문제

STF에서 와이파이로 연결하는 것도 지원하고 있지만 STF 가이드(참고)에서도 이 방법을 추천하지 않습니다. 실제 사용해 본 결과 화면 스트리밍의 FPS(Frames Per Second)가 절반 수준이었습니다.

 

원격 adb 서버 사용 시 발생하는 문제

SSH 터널(tunnel)을 이용해서 원격 장비에 있는 단말기로 연결하는 것도 가능합니다. 하지만 SSH 터널을 오래 열어 놓을 경우 방화벽 정책 등의 영향으로 저절로 끊어지는 현상이 빈번하게 발생했습니다. 이 문제는 autossh나 autoadb로 재연결하는 방식으로 해결할 순 있지만, STF에서 제공하는 프로바이더 서비스가 안정적이므로 원격지 장비에 새 프로바이더를 연결하는 편이 낫다고 판단했습니다. 추후 사내 정책이나 방화벽 등의 문제로 프로바이더 서비스로 해결할 수 없는 경우가 발생하면 SSH 터널을 다시 검토해 볼 여지는 있다고 생각합니다.

 

결론

LINE에서 단말기 테스트를 전담으로 맡고 있는 부서는 현재 후쿠오카에 위치해 있습니다. 간혹 특정 단말기에서 이슈가 발생하는 경우, 코로나19 사태가 발생하기 전에는 개발자가 후쿠오카로 출장 가서 단말기를 입수한 뒤 디버깅을 진행했지만, 현재는 사내 망에 설치된 STF 디바이스팜을 이용해 한국에서 원격으로 단말기를 디버깅하고 있습니다. 이는 AWS Device Farm과 같은 서드 파티 디바이스팜 서비스로는 해결할 수 없는 부분입니다. 현재 한국과 다롄, 후쿠오카 거점에 STF 디바이스팜용 프로바이더 장비가 설치되어 있으며 이를 통해 원격 디버깅의 이점을 누리고 있습니다. 부차적으로 자동화 테스트에도 활용하고 있고, CI 장비의 위치에 구애받지 않아서 유연하게 확장하거나 제거하고 있습니다. 여러분의 부서에서도 디바이스팜을 도입하여 이러한 이점을 함께 누리셨으면 좋겠습니다.