오픈런 테스트 이렇게 했다 - 성능 테스트

안녕하세요. 트렌비에서 개발팀 Growth Marketing 에서 개발을 하고 있는 모리스 입니다.

저희 팀은 트렌비의 마케팅 관련 페이지 및 이벤트를 개발 하고 있습니다.

자세한 소개는 트렌비 GM 개발팀 에서 확인 하실수 있습니다.

작년 10월에 있었던 선착순 이벤트인 오픈런 이벤트를 준비하면서 동시 접속자가 몰리는경우 문제를 사전에 방지하는데 있어서 Artillery 라는 테스트 툴을 사용 하였는데 툴을 선택한 이유와 툴 사용법에 대해 설명하려고 합니다.

openrun

1. 개요

오픈런 이벤트는 앱에서 선착순 1000명을 대상으로 미리 본인인증, 사진 등록을 마친 다음에 정시에 이벤트가 오픈되면, 선착순으로 입장하여 실제 번호를 부여받고 줄을 서서 대기하는 모습을 생동감 있게 보여주는 것까지의 과정을 개발해야 했습니다.

2. 요구사항

프로젝트에서 위의 단계를 개발하는 데 있어서 중점은 이벤트가 열리는 시간에 수 천명의 사용자가 입장해야 하는 요구사항이 있었고,

오픈런 입장 전 인증단계, 생동감 있게 오픈런 입장을 구현하기 위해 WebSocket을 사용하였기 때문에 시나리오 테스트와 Http WebSocket에 대한 부하 테스트가 필요했습니다.

시나리오와 부하 테스트를 할 수 있는 방법에 대해 고민을 하다가 Artillery를 알게 되었고, 아래와 같은 장점으로 선택하게 되었습니다.

  1. 사용자가 yaml 파일을 사용하여 설정하며, 추가적으로 javascript를 통하여 자유롭게 스크립트를 작성이 가능하다.
  2. 스크립트를 통해 테스트를 하기 때문에 git을 통해 버전 관리 가능하다.
  3. 테스트할 수 있는 Node.js 기반으로 다른 GUI 테스트 툴 보다 가볍다.

아래 내용에서 Artillery의 소개, 사용법에 대해서 간단하게 알아보고, 예제를 통한 테스트를 해보도록 하겠습니다.

3. Artillery 소개

Artillery는 백엔드 개발자, DevOps, SRE를 위한 부하 및 시나리오 테스트 툴입니다.

load test : 부하 테스트  - 대용량의 트래픽 발생 테스트 
smoke test : functional 테스트 - 기능적인 테스트

홈페이지에서는 아래와 같은 시나리오들을 테스트할 수 있다고 설명하고 있습니다.

  • 개발 프로세스의 일부로 개별 API 또는 마이크로 서비스에 대한 임시 로드테스트를 실행하여 성능 특성을 탐색하고 필요한 경우 성능 최적화 (예: 메모리 누수 방지 또는 CPU 사용량이 많은 코드 최적화)
  • 성능 저하를 조기에 포착하고 SLO를 확인하기 위해 CI/CD 파이프라인의 일부로 스테이징/기능 환경에 대한 테스트 실행 새로운 서비스의 프로덕션 릴리스 전 또는 블랙 프라이데이/사이버 먼데이 트래픽과 같이 트래픽이 많은 애플리케이션 및 인프라를 준비하기 위한 대규모 부하 테스트
  • 트래픽 급증에 대비한 안전 여유를 유지하기 위해 프로덕션에 합성 트래픽 추가
  • 여러 지리적 위치의 주요 API에 대한 종합 모니터링을 실행하여 주요 트랜잭션 및 흐름이 예상대로 작동하는지 확인하고 문제가 발생하면 경고 알림

주요 특징

  • 모든 스택 테스트
    HTTP, WebSocket, Socket.io, Kinesis, HLS 등의 로드 테스트
  • 시나리오 : API 또는 웹 앱에서 다단계 상호작용을 테스트하기 위한 복잡한 방법 지원 (전자상거래, 트랜잭션 API, 게임 서버 등에 적합).
  • 부하 테스트 및 연기 테스트
    동일한 시나리오 정의를 재사용하여 API 또는 백엔드에서 성능 테스트 또는 기능 테스트를 실행합니다.
  • 자세한 성능 메트릭 항목
    자세한 성능 메트릭(응답 시간, TTFB, 초당 트랜잭션, 동시성, 처리량)을 가져옵니다.  정밀한 추적 (히스토그램, 카운터 및 비율)
  • 스크립트 가능 npm
    수천 개의 유용한 모듈 중 하나를 사용하여 JS에서 사용자 정의 로직을 작성 합니다.

4. Artillery 사용하여 테스트 시작하기

예제는 설명을 위해 복잡한 실제 로직을 간단하게 정리한 시나리오 아래와 같습니다.

  1. 이벤트 참여
    • 유저 인증 처리
  2. (이벤트 오픈) 입장 가능한지 체크
    • 줄서기가 가능한지 체크
    • 줄서기 입장
      • 유저별 websocket 요청

위의 시나리오로 테스트를 하게 되면 오픈런 입장 후, 입장한 분들을 생동감 있게 표현하기 위해서 websocket을 사용해야 하는데, 이후에도 설명을 하겠지만 설정에 정의한 processor에 정의한 afterResponse에 websocket 연결하는 코드를 넣어주면 되지만, websocket과 restapi 시나리오 테스트를 별도로 설명을 하겠습니다.

설치

node 기반으로 node가 설치가 되어있다면 아래의 명령어로 설치가 가능합니다.

(테스트 당시 최신버전 1.7.6 )

$ npm install -g artillery@latest

command에 artillery dino 입력하면 아래와 같이 나오면 설치 완료

✗ artillery dino
  Telemetry is on. Learn more: https://artillery.io/docs/resources/core/telemetry.html
 ------------
< Artillery! >
 ------------
          \
           \
            ..`
            ;:,'+;
            ,;;,,;,
              #:;':
              @';'+;
            `::;''';
            '; ,:'+;;
  `,,`      .;';'+;'
 ;   `'+;;;::';++':,;
        `+++++##+'';#
           .;+##+''';
            '+##'''#'
           ++# +;'.##
           ##, `: .#,
          :#      '+
          #.      '
          #       +
         :+       #'
         #+`       ';.

REST API 시나리오 부하 테스트

테스트 환경 및 시나리오는 모두 yaml 파일로 작성한다. open_run_test.yml 에 작성

config 설정

환경 설정 config 최상위 속성 값(key)으로 사용

config:
  target: "http://localhost:8080"  
  http:
    timeout: 3
  phases:
    - duration: 2
      arrivalRate: 2500
      name: normal
  processor: "./openrun_processor.js" 
  payload:
  - path: "openrun_sample_5k.csv"
    fields:
      - "email"
      - "password"
  • target : 테스트하는 서버의 endpoint
  • http : HTTP, WebSocket, Socket.io, Kinesis, HLS 등 다양한 설정들이 있지만, REST를 사용하기 때문에 http로 설정
    • timeout : 해당 요청이 10초가 넘어서게 되면 timeout을 출력
  • phases: 부하를 어떻게 줄지에 대한 설정
    • duration : 테스트 진행할 시간(초)
    • arrivalRate : 테스트를 진행하면서 초당 load 유저의 수
    • [option] maxVusers: 동시에 접속하는 유저 수 제한
    • [option] rampTo : arriveRate에서 rampTo value까지 늘림으로써 갑작스런 부하를 줄때 사용
  • processor : 시나리오 실행 중 특정 지점에서 호출될 사용자 정의 Javascript 코드
    • before request( 요청전) , after response(요청 후)
  • payload : 시나리오 테스트에 필요한 데이터(csv)
    • 필드 값 정의 (csv 의 컬럼 순서대로 정의)
      • order : (default: random) random, sequance
      • skipheader : (default: false) false, true
      • delimiter : (default : ,) ,
      • cast : (default: true) true false 문자열을 자동으로 cast 변환 해줌
      • skipEmptyines: (default : true) true false
        예제 csv
          id,email
          goyard@trenbe.com,*******
          chanel@trenbe.com,*******
          LouisVuitton@trenbe.com,*******
          Gucci@trenbe.com,*******
          ...
          ...
        

시나리오

테스트 시나리오 작성

scenarios:
	- name: "openrun 5k test"
	    flow:
            # 로그인 (유저 인증)
              - post:
                url: "/login"
                json:
                  userEmail: ""
                  password: "password"
                capture:
                  json: "$.auth"
                  as: "auth"
              # 줄서기 입장이 가능한지 체크
              - get:
                  url: "/open-run/lineup/ended"
                  headers:
                    authorization: ""
              # 줄서기 입장
              - post: 
                  url: "/open-run/lineup"
                  headers: 
                    authorization: ""
                          beforeRequest: "beforeRequestHandler"
                  afterResponse: "afterResponseHandler"
  • scenarios : 여러개의 시나리오를 개별 flow를 사용하여 정의 할 수 있음
  • flow : 시나리오를 정의 하위에는 실행되는 request method, url 을 정의
    • get, post, put, delete, patch 등의 HTTP 메서드 사용이 가능함
  • Http Method 하위 속성
    • url : 시나리오 url을 입력
    • json : requestbody에 입력되는 json 값
    • capture : response의 값을 변수로 설정하여 이후의 메소드에서 사용이 가능함
    • headers : 헤더에 들어갈 속성값 입력
    • beforeRequest : request를 하기전 실행되는 로직
    • afterRequest : response를 받은후 실행되는 로직

      (위에서 설정한 config > processor 에 정의한 자바스크립트 파일에서 정의된 함수 beforeRequest, afterResponse는 Artillery홈페이지의 문서에 자세히 나와 있습니다.)

실행

$ artillery run openrun_test.yml

$ artillery run openrun_test.yml
Started phase 0 (Warm Up), duration: 2s @ 14:22:55(+0900) 2022-01-16
Report @ 14:23:06(+0900) 2022-01-16
Elapsed time: 10 seconds
  Scenarios launched:  5000
  Scenarios completed: 2022
  Requests completed:  10002
  Mean response/sec: 1292.83
  Response time (msec):
    min: 0
    max: 3557
    median: 1023
    p95: 3028
    p99: 3049
  Codes:
    200: 10002
  Errors:
    ETIMEDOUT: 1232

Report @ 14:23:07(+0900) 2022-01-16
Elapsed time: 11 seconds
  Scenarios launched:  0
  Scenarios completed: 973
  Requests completed:  1262
  Mean response/sec: 190.13
  Response time (msec):
    min: 587
    max: 3000
    median: 2214
    p95: 2942
    p99: 2995
  Codes:
    200: 1262
  Errors:
    ETIMEDOUT: 773

All virtual users finished
Summary report @ 14:23:07(+0900) 2022-01-16
  Scenarios launched:  5000
  Scenarios completed: 2995
  Requests completed:  11264
  Mean response/sec: 1142.89
  Response time (msec):
    min: 0
    max: 3557
    median: 1135
    p95: 3027
    p99: 3048
  Scenario counts:
    openrun 5k test: 5000 (100%)
  Codes:
    200: 11264
  Errors:
    ETIMEDOUT: 2005

Log file: openrun_result2.json

디버깅

디버깅 모드로 시작을 하면 request에 대한 내용을 확인이 가능하다.

$ DEBUG=http artillery run openrun_test.yml
...
http request: {
  "url": "http://localhost:8080/open-run/lineup/end",
  "method": "GET",
  "headers": {
    "user-agent": "Artillery (https://artillery.io)",
    "authorization": "....."
  }
} +1ms
  http request: {
  "url": "http://localhost:8080/open-run/lineup",
  "method": "POST",
  "headers": {
    "user-agent": "Artillery (https://artillery.io)",
    "authorization": "....."
  }
} +1ms
...
Report @ 14:25:27(+0900) 2022-01-16
Elapsed time: 16 seconds
  Scenarios launched:  0
  Scenarios completed: 1533
  Requests completed:  2661
  Mean response/sec: 226.05
  Response time (msec):
    min: 0
    max: 4987
    median: 3382
    p95: 4677.7
    p99: 4945.8
  Codes:
    200: 2661
  Errors:
    ETIMEDOUT: 58

All virtual users finished
Summary report @ 14:25:27(+0900) 2022-01-16
  Scenarios launched:  5000
  Scenarios completed: 4942
  Requests completed:  14884
  Mean response/sec: 940.93
  Response time (msec):
    min: 0
    max: 4987
    median: 879
    p95: 4259
    p99: 4594
  Scenario counts:
    openrun 5k test: 5000 (100%)
  Codes:
    200: 14884
  Errors:
    ETIMEDOUT: 58

테스트를 해보면 초당 2500번의 시나리오를 요청했지만 위의 config에서 http timeout 시간을 3초로 두었기 때문에 Error - ETIMEDOUT 이 발생한 것을 알 수 있다.

리포트 작성하기

위의 결과처럼만 테스트를 한다면 텍스트로 수치를 확인해야 하지만, report라는 옵션을 사용하여 html로 예쁘게 테스트 결과를 확인할 수 있다.

  1. 테스트 결과를 json으로 출력하기

    artillery run --output [결과 데이터 명.json ] openrun_test.yml

     $ artillery run --output openrun_result.json openrun_test.yml
     ...
     Log file: openrun_result.json
    
  2. json을 html로 변환하기

    artillery report --output [리포트 명.html] [run ouput json 파일 이름]

     $ artillery report --output openrun_result.html openrun_result.json
     Report generated: openrun_result.html
    

5. 테스트 리포트

artillery report를 통한 리포트 결과

  • Overall Latency Distribution : 종합적인 응답 지연 시간
  • Latency At Intervals : 응답 지연 시간
  • Concurrent users : 동시 접속자 수
  • Mean RPS : 초당 평균 요청 수
  • RPS Count : 초당 요청 수
  • HTTP codes : 발생한 HTTP status code

artillery report (종합적)

openrun_result01

chart

openrun_result02

openrun_result03

openrun_result04

WebSocket 부하 테스트

websocket

테스트 환경 및 시나리오는 모두 yaml 파일로 작성한다. open_run_webSocket_test.yml 에 작성

config 설정

config:
  target: "ws://localhost:8080/open-run"
  phases:
    - duration: 1
      arrivalRate: 1500
      name: warm up
  ws:
    maxRedirects: 1

websocket을 테스트할 때는 config.ws로 설정을 해주면 websocket에 대한 테스트가 가능하다.

phases는 위의 http에서의 설정과 같다.

시나리오 설정

scenarios:
  - engine: ws
    name: SendOpenRun WebSocket
    flow:
      - send: "1"
      - think: 10

위의 예제 같은 경우에는 데이터만 보내고 websocket 이 바로 close 되어 연결이 끊기게 된다. 이를 해결하기 위해서는 think: 옵션을 사용하여 해결함 (think : 대기 시간 )

flow에서 connetion , send를 하고 난 후 think 를 사용하면 그 시간만큼은 종료되지 않고 open 되어 있는 상태로 계속 유지가 된다. websocket에 대한 응답 값은 아래와 같다.

Started phase 0 (warm up), duration: 3s @ 10:38:49(+0900) 2021-10-18
Report @ 10:38:59(+0900) 2021-10-18
Elapsed time: 10 seconds
  Scenarios launched:  1500
  Scenarios completed: 0
  Requests completed:  650
  Mean response/sec: 65.33
  Response time (msec):
    min: 0.1
    max: 11.9
    median: 0.6
    p95: 2.8
    p99: 5.4
  Codes:
    0: 650
  Errors:
    Unexpected server response: 502: 849

리포트

openrun_result05

websocket 테스트를 한 결과를 보면 651개는 연결을 하였지만 849번의 요청은 502 Error가 발생한 것을 알 수 있다.

6. 결론

실제 운영하기 전 대용량 트래픽이 몰리는 경우에 사전에 부하 테스트를 함으로써 일어날 수 있는 다양한 이슈들을 미리 발생하여, 로직 수정, 서버 확장 등 미리 대처할 수 있었고, 그 결과 안정적으로 이벤트를 진행할 수 있었습니다.

개인적으로 Artillery를 사용하면서 장점을 정리해봤습니다.

  • 일반 GUI 툴에서는 파일로 관리하지만 코드로 관리하여서 버전 관리도 쉽다.
  • 시나리오가 정의가 명확하게 눈에 보이기 때문에 작성하기 쉬웠습니다.
  • Javascript로 정의된 함수 들을 사용해서 요청 전, 응답 후에 대한 처리가 동적으로 가능하다.
    • npm module을 사용하여 다양한 코드 작성이 가능함.

장점뿐만 아니라 단점도 있었습니다.

  • 에러 응답에 대한 자세한 로깅이 되지 않아 사용자가 직접 정의를 해야 한다는 단점이 있습니다.

글은 1.7.6 버전을 중심으로 테스트를 하였는데, 2022.1.17 일 기준 artillery가 벌써 2.0.0-10 버전으로 업데이트되었습니다. 업데이트 된 버전을 살펴보니 테스트 시 편리한 기능들이 많이 추가되었습니다.