LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


서버 사이드 테스트 자동화 여정 – 2. 통합 테스트 수준의 회귀 테스트 환경 구축 및 Docker 활용

1편에서는 테스트 자동화를 시작하게 된 계기와 어떤 테스트 자동화 작업을 진행했는지 알아보았습니다. 이번 글에서는 LINE 미디어 플랫폼 조직에서 안정적인 서비스 개발과 운영을 위한 테스트 자동화 환경에 Docker를 활용한 사례를 소개하겠습니다.

대규모 코드 변경 결과 검증하기

1편에서 소개한 테스트 자동화 작업을 실제 업무 환경에 적용한 후, 저를 포함한 팀 내 개발 방식이 변화했고 팀의 전체적인 생산성이 올라갔지만, 기능 단위 또는 API 단위로 발생하는 문제를 탐지할 수 없다는 문제가 아직 남아 있었습니다. 그러던 중 '동일한 국가에 위치한 IDC 간 DR(Disaster Recovery)을 넘어, 서로 다른 국가에 위치한 IDC 간 DR이 가능한 형태의 시스템을 만들고, 필요한 경우 서로 다른 DFS(Distributed File System)를 사용할 수 있도록 개선한다'라는 과제가 부여되었습니다. 플랫폼에서 제공하는 거의 모든 핵심 기능과 API가 DR 검토 대상에 포함되어 많은 코드를 변경해야 할 것으로 예상되었습니다. 하지만 그로 인해 현재 제공하는 API의 동작과 응답이 변해서는 안된다는, 너무나도 당연하지만 쉽지 않은 문제를 해결해야 했기에 상당히 어려운 과제였습니다. 또한 과제 구현을 완료한다고 해도 어떻게 검증해야 할지 너무 막막하게 느껴졌습니다. 

통합 테스트 개발 및 적용

일일이 사람이 테스트한다면 테스트 비용이 너무 많이 발생하는 것은 물론, 잘못된 방식으로 테스트가 수행되거나 누락되는 테스트가 발생할 것 같았습니다. 아키텍처 수준의 변화가 발생하며 대부분의 코드가 변경될 예정이라 기존 단위 테스트만으로는 변경된 시스템이 이전과 동일한 처리와 응답 코드를 제공한다는 것을 보장하는 것도 어려워 보였습니다. 

사실 이전부터 이런 상황에 대비하면서 한편으론 기능 단위 또는 API 단위로 발생하는 문제를 탐지할 수 없었던 부분까지 해결하기 위해 통합 테스트를 도입하면 어떨까라는 생각을 많이 했었습니다. 하지만 미디어 플랫폼 주요 서버 모듈의 대부분이 Netty 기반으로 개발되었기 때문에 Spring MVC 테스트(Mock MVC)와 같이 잘 만들어진 테스트 프레임워크를 활용할 수 없었습니다. 또한 통합 테스트는 일반적으로 '외부 시스템 모킹(mocking)이 어렵다', '유지 보수에 비용이 많이 든다'라고 알려져 있었기 때문에 더욱 고민이 깊어졌습니다.

하지만 곰곰이 생각해보니 플랫폼 조직이 제공하는 API는 한번 개발되면 명세가 잘 바뀌지 않고, 연동된 서비스 팀과의 협의 없이 API의 동작을 바꿀 수 없으며, 수많은 서비스 팀과 연동되어 있는 API는 사실상 변경이 불가능하다는 특성이 있어서 통합 테스트를 개발해도 유지 보수 비용이 많이 들 것 같지 않았습니다. 외부 시스템 모킹이 어렵다는 부분도, 미디어 플랫폼을 구성하는 모듈이 외부 시스템과 연동하는 부분을 모두 설정으로 제어할 수 있게 설계된 구조 덕분에 쉽게 해결할 수 있을 것 같았습니다. 무엇보다 PR 단위로 통합 테스트를 수행한다면 회귀 테스트를 수행하는 효과를 얻을 수 있으므로 변경 영향의 범위와 발생할 가능성이 있는 사이드 이펙트를 금방 찾을 수 있겠다는 생각이 들었습니다.

이런 아이디어에 기반하여 통합 테스트를 만들어야겠다고 생각은 했지만 '통합 테스트 시나리오를 어떻게 작성하고 관리할 것인가?'에 대한 답이 떠오르지 않았습니다. 그러던 중 우연히 Python 기반의 코드를 잠시 보게 되었는데요. 문득 Python에는 테스트 프레임워크가 없는지 의문이 생겼습니다. 그래서 Python 유닛 테스트 프레임워크를 찾던 중 'pytest'라는 테스트 프레임워크를 알게 되었는데요. 굉장히 간결하고 직관적인 assert 문법과, Python을 따로 공부해 본 적 없이 사고의 흐름대로 코드를 작성해도 테스트 작성에 필요한 기능 정도는 잘 작동하는 것을 보고 '이거다! Python과 pytest를 이용하면 쉽고 빠르게 통합 테스트를 작성할 수 있겠다'라는 생각이 들었습니다. 그래서 미디어 플랫폼의 주요 모듈에 대한 간략한 통합 테스트 프레임워크 구조를 아래 그림과 같이 설계하고 구현했습니다.

이미 앞선 테스트 자동화 과정을 거쳐오며 구성해 둔 Jenkins 서버 환경과 사용하기 쉬운 pytest 덕분인지 별다른 어려움 없이 실제 업무 환경에까지 적용시킬 수 있었습니다. 또한 기존에 존재하지 않았던 통합 테스트를 활용하니 '서로 다른 국가 IDC 간 DR이 가능한 형태의 시스템을 만들고, 필요에 따라 서로 다른 DFS를 사용할 수 있도록 구성하는 대규모 작업'에 따른 코드 변경이 실 서비스에 어떤 영향을 줄 수 있는지 빠르게 확인할 수 있어서 보다 프로그램을 안전하게 개발하는 데 많은 도움을 받고 있습니다.

간단하고 직관적인 pytest 기반 테스트 코드와 실행 결과 보고서 예시

// test-sample.py
 
def test_sample():
    assert True == False
 
---
$ pytest test-sample.py
==================================================== FAILURES ====================================================
__________________________________________________ test_sample ___________________________________________________
 
    def test_sample():
>       assert True == False
E       assert True == False
E         -True
E         +False
 
test-sample.py:2: AssertionError

 HTTP 요청을 생성하고 응답을 검증하는 예시

// test-http-request.py
from my_service_client import *
 
def test_upload(my_service_client, test_resource, random_string):
    object_id = random_string
    result = my_service_client.upload(object_id, test_resource("binary/1k.dat"))
 
    assert result.res.status_code == 201
    assert result.headers.get("x-object-id") == object_id

통합 테스트 보고서를 보기 쉽고 접근하기 편하게 만들기

우연히 접하게 된 Python과 pytest 덕분에 통합 테스트 환경을 아주 적은 공수로 구성할 수 있었습니다. 하지만 기쁨도 잠시, 이 통합 테스트 결과를 어떻게 보관하고 또 보고해야 하는지 고민에 빠졌습니다. 처음에는 Jenkins에 잡(job) 히스토리가 저장되고 있어 별문제가 아니라고 생각했으나, Jenkins 서버 관리의 편의 및 성능상의 이유로 빌드 히스토리는 짧은 기간만 보관하고 있어서 테스트 실행 결과를 오랜 기간 보관하는 것은 Jenkins만으로는 어렵다는 것을 깨닫게 되었습니다. '오랜 기간에 걸쳐 축적된 일관된 기준에 의해 시스템을 평가하고 정량화된 테스트 지표를 수집한다'라는 목표를 달성하기 위해서는 테스트 보고서가 코드의 수명과 동일한 기간 동안 보관되어야 한다고 생각했습니다. 해결 방법을 생각하다보니 결국 코드가 관리되고 있는 GitHub에 댓글로 첨부하면 되겠다는 생각이 들었는데요. 테스트 시나리오가 추가될수록 점점 더 길어지는 pytest 보고서를 GitHub에 댓글로 첨부하는 것은 가독성도 좋지 않고 PR 리뷰 페이지의 로딩 속도를 저하시켜 고민이 되었습니다.

그래서 만약 단일 HTML 파일 형태의 보고서를 생성할 수 있다면 보관하기도 좋고 나중에 커스터마이징할 수 있지 않을까 생각했는데요. 이 아이디어를 그대로 구현해 놓은 'pytest-html'이라는 플러그인을 찾을 수 있었습니다. LINE의 프라이빗 클라우드 플랫폼에는 정적 자원을 저장하기에 적합한 'Amazon S3 compatible storage system'이 있는데요, 이곳에 pytest-html에서 생성된 단일 HTML 보고서 파일을 업로드한 뒤 이곳의 URL을 GitHub 댓글로 달면 좋을 것 같았습니다. 이렇게 하면 아주 오랜 기간 동안 코드 변경에 대한 회귀 테스트 결과를 보관하고 열람할 수 있기 때문입니다. 이 생각을 기반으로 아래 그림과 같은 구조로 pytest를 활용한 통합 테스트 및 보고서 저장 시스템을 구성했습니다.

PR이 생성되면 통합 테스트가 실행되고, 그 결과는 아래 그림과 같이 GitHub 댓글로 달리게 구성하여 통합 테스트 결과를 보기 쉬우면서도 오래 보관할 수 있게 되었습니다.

통합 테스트 보고서 생성 시간 단축하기

불편함을 하나씩 개선하다 보니 드디어 우리 팀 모듈에 적합한 형태의 회귀 테스트가 개발되었습니다. 하지만 기쁨도 잠시, 과거에 바쁘다는 이유로 묵혀두었던 이슈가 문제가 되었습니다. 당시 테스트 대상이 되는 빌드 결과물이 배포되는 테스트 서버를 장비 1대로 운영하고 있었는데요. 여러 개의 PR이 동시에 생성되면 테스트 서버의 특정 포트를 같은 시점에 사용하려고 하기 때문에 포트 바인딩(binding)에 실패하는 문제가 발생했습니다. 테스트 환경 개선과 팀 전체의 생산성 향상을 위한 작업은 분명 중요한 일이지만, 그렇다고 다른 모든 업무보다 우선할 순 없었습니다. 그래서 이 문제를 최대한 적은 비용으로 해결하고자 특정 Jenkins 잡이 동작하고 있으면 잠시 잡 실행을 멈추고 기다리게 만드는 Jenkins의 'Build Blocker' 플러그인을 사용하여 통합 테스트를 트리거하는 잡이 동시에 실행될 수 없도록 조치했는데요. 이 부분이 피드백 전달을 지연시키는 병목 지점이 되어버렸습니다.

코드 리뷰 과정에 통합 테스트가 포함된 초기에는 통합 테스트 환경 자체의 오동작으로 테스트 결과를 신뢰할 수 없었던 기간이 있었습니다. 그래서 해당 기간엔 개발자들이 테스트가 완료되기 전에 개발/테스트 환경에 새로운 코드를 적용하곤 했는데요. 환경 문제가 해결된 뒤에도 관성적으로 그와 같이 코드를 적용하던 개발자들이 문제가 발생하는 상황을 몇 번 경험하게 되었습니다. 그래서 점차 테스트 결과가 나오기 전에 코드를 머지하는 것을 꺼리기 시작했고, 결국 개발/테스트 환경에 새로운 코드를 적용하는 데 걸리는 시간이 이전보다 길어지는 문제가 발생했습니다. 이를 해결하려면 테스트가 PR 단위로 격리된 환경에서 병렬로 실행될 수 있게 구성해야 했습니다. 격리된 환경을 구성하기 위한 시스템을 개발하기 위해선 해결해야 하는 기술적인 문제가 몇 가지 있었습니다. 그중 기억나는 것을 몇 가지 나열해 보면 아래와 같은데요. 제 주 업무를 차질 없이 진행하면서 아래와 같은 사항을 고려하여 시스템을 단기간에 개발하는 건 어렵다는 생각이 들었습니다.

  1. 테스트 대상이 되는 PR의 빌드 결과물은 어떤 테스트용 장비에 배포해야 할까?
  2. 테스트 대상이 되는 PR의 빌드 결과물은 테스트용 장비의 몇 번 포트에 할당해서 테스트해야 할까?
  3. 동적으로 결정되는 PR의 테스트 대상 모듈의 IP 주소와 포트를 테스트 시나리오에서 어떻게 알 수 있을까? 
  4. 테스트 환경 구성은 내 주 담당 업무가 아닌데 직접 개발하면 나중에 유지 보수가 가능할까?
  5. 각 테스트의 실행 환경을 서로 잘 격리시키면서 단기간에 개발하는 것이 가능할까?
  6. 테스트 환경 시스템의 상태에 따라 테스트의 성공과 실패가 결정되는 불확실성이 존재한다면 테스트 결과를 신뢰할 수 있을까?

그런데 이렇게 문제를 정리하고 보니 예전부터 사용해 보고 싶었던 Docker가 생각났습니다. 그래서 관련 문서를 찾아봤고, Docker를 활용하면 이런 문제들을 해결할 수 있을 것 같다는 확신이 들었습니다. 하지만 Docker를 처음 사용해 보기도 하고, 주 업무는 주 업무대로 계속 진행하며 시도해 봐야 했기에 Docker를 통합 테스트 환경에 적용시킬 엄두가 나지 않았습니다. 그렇게 Docker 적용을 망설이던 중 동료 개발자가 한숨을 쉬며 '아.. 요즘은 대학생들도 Docker로 이것저것 해 본다는데... (생략)'라고 말하는 것을 듣게 되었는데요. '그렇다면 혹시 Docker 기반으로 전환하면 통합 테스트 구성에 관심을 갖고 유지 보수와 개선 작업에 동참해주지 않을까?'라는 희망을 갖고 작업을 시작했습니다.

Docker를 이용하여 통합 테스트 환경 개선하기

고민했던 여러 문제에 대한 해답, Docker

격리된 환경을 갖춘 시스템을 개발하기 위해 해결해야 했던 여러 기술적인 문제에 대한 답은 이미 Docker와 Docker 생태계 기술이 제공하고 있었습니다.

위에서 말씀드렸던 문제 중 1, 2, 5번 문제는 Docker가 지원하는 컨테이너 오케스트레이션(Container Orchestration) 도구를 이용하여 해결할 수 있었고, 3번 문제는 Docker 클러스터 내 서비스를 자동으로 발견하고 도메인을 할당해 주는 'traefik'과 같은 프록시 서버를 이용하면 해결할 수 있었습니다. 4번 문제와 관련해선, Docker가 이미 컨테이너 기술의 사실상 표준이기 때문에 기술 자체의 완성도가 높고, 개발자가 아닌 친구도 저에게 'Docker가 뭐야?'라고 물어볼 정도로 인지도가 높으니 관련 지식이 많이 쌓여있어 유지 보수가 용이할 것이라고 판단했습니다. 6번 문제 역시 이미 기술적 완성도가 높은 Docker를 이용하여 항상 동일한 형상으로 서버 구성을 프로비저닝(provisioning)하면 해결할 수 있을 것이라고 판단했습니다.

이런 생각을 바탕으로 통합 테스트 프레임워크를 통합 테스트 환경에 Docker와 Container Orchestration(Docker Swarm)이 적용된 구조로 변경했습니다. 변경한 구조는 아래 그림과 같습니다. 

위와 같이 시스템을 구성하면서 Docker 관련 문서를 읽다 보니 시스템 아키텍처를 매번 가상으로 구성할 수 있는 'Docker Compose'라는 기능을 알게 되었습니다. 이 기능을 이용하여 자주 변경되면서 함께 테스트되어야 하는 컴포넌트들을 모두 PR 단위로 격리시켜 Docker 클러스터 내에 배치할 수 있도록 구성했습니다. 또한 코드 리뷰어가 실제 동작을 확인해 보고 싶을 때 쉽게 각 모듈에 요청을 생성해 보기 쉽도록 도메인을 할당할 수 있다면 편리하겠다는 생각이 들어서 'pr-(PR 번호)-module-(모듈 이름).swarm.example.com'과 같이 이해하기 쉬운 도메인을 할당했습니다. 아래 그림은 Docker Compose를 활용하여 자주 변경되면서 함께 테스트되어야 하는 컴포넌트를 배치한 예시입니다.

Docker 및 Docker Orchestration 도구를 활용한 결과 여러 대의 테스트 서버를 사용하더라도 각 테스트 대상 빌드 결과물에 부여되는 도메인을 이용하여 쉽게 접근할 수 있게 되었습니다. 또한 각 PR 별로 테스트 환경을 격리하여 여러 개의 PR이 동시에 생성되어도 병렬로 테스트를 진행할 수 있어서 통합 테스트 보고서 생성 시간이 단축되었습니다.

외부 시스템 의존 로직도 테스트할 수 있게 만들기

PR 단위로 격리된 환경에서 실행 가능한 통합 테스트 환경이 구성되었지만, 외부 시스템에 의존하는 로직에 대한 테스트가 일관성 있게 동작하게 만드는 것은 쉽지 않았습니다. 외부 시스템 API가 변경되면서 테스트 실패가 발생하기도 하고, 외부 시스템 API의 처리 속도가 테스트 실행 속도에 영향을 주기도 했습니다. 이런 문제를 해결하기 위해 테스트 더블(test double) 역할을 수행해 줄 수 있는 모의(mock) 서버를 개발하고 이 모의 서버도 PR 단위로 격리시켜 생성할 수 있도록 Docker 클러스터에 배치, 구성하였습니다.

이 모의 서버는 외부 시스템의 API를 직접 모킹(mocking)하는 방식으로 개발하지 않고, 모의 서버의 동작을 결정할 수 있는 명세서를 주입할 수 있도록 설계했습니다. 덕분에 외부 시스템이 새로 추가되더라도 모의 서버는 전혀 수정할 필요가 없어 유지 보수가 편리합니다.1 

모의 서버를 활용한 결과 외부 시스템 응답에 대한 모든 경우의 수를 테스트 항목에 포함할 수 있었고, 실제 서비스에선 자주 발생하지 않는 상황에서도 우리의 시스템이 요구 사항에 맞게 잘 동작하는지 검증할 수 있게 되었습니다. 더욱 안정적인 서비스 개발 환경이 구성된 것입니다. 아래 그림은 모의 서버의 시퀀스 다이어그램입니다.

마치며

이번 글에선 pytest를 이용하여 통합 테스트 환경을 개발한 사례와 Docker를 이용하여 그 환경을 개선해 나가는 과정을 살펴볼 수 있었습니다. 이 테스트 환경을 실제 업무 환경에 적용했을 때 어떤 효과가 있었는지, 어려움은 없었는지 궁금하지 않으신가요? 마지막 3편에서는 Docker를 적용하고 난 이후 발생했던 문제점을 어떻게 해결했는지, 그리고 테스트 보고서 커스터마이징을 통해 디버깅에 소요되는 시간을 어떻게 획기적으로 단축시켰는지 소개하겠습니다. 많이 기대해주세요! 


  1. 제 경우엔 특수한 요구 사항 때문에 모의 서버를 직접 구현했는데요. 일반적인 경우에는 오픈 소스로 공개되어 있는 'MockServer'를 활용할 수 있습니다. 모의 서버에 주입 가능한 명세의 예는 다음과 같습니다.
    • '/api/v1/sample-external-api'라는 API에 'x-custom-header'라는 이름의 헤더가 포함되어 있지 않으면 '400'으로 응답한다.
    • '/api/v1/sample-external-api'라는 API에 'x-custom-header'라는 이름의 헤더가 포함되어 있으면 '{ "id" : 1 }'라는 값을 HTTP body에 포함하여 응답한다.
    • 외부 API에 장애가 발생한 상황에서 우리 시스템이 어떻게 동작하는지 확인하기 위해 '/api/v1/sample-external-api'라는 API가 호출되면 API 응답을 5초 지연시킨다.
    • 외부 API에 장애가 발생한 상황에서 우리 시스템이 어떻게 동작하는지 확인하기 위해 '/api/v1/sample-external-api'라는 API가 호출되면 응답하지 않고 TCP 연결을 끊는다.