REST API 백엔드 시스템에서 Stress Testing을 위해 Artillery를 사용해보도록 하겠습니다.
https://nodejs.org/en/download/
먼저 위 링크를 통해 node.js, NPM 설치를 진행합니다.
설치가 완료되면 아래와 같이 버전을 확인할 수 있습니다.
npm을 통해 아래 명령어로 Artillery를 설치합니다.
공식 문서처럼 전역으로 설치하였습니다.
npm install -g artillery@latest
아래 명령어로 설치를 확인할 수 있습니다.🦖
artillery dino
Arillery 테스트 스크립트는 JSON, YAML 파일로 작성할 수 있습니다.
해당 파일은 크게 configuration 부분과 scenarios 부분으로 나눌 수 있습니다.
🔧 configuration
: 성능 테스트를 위한 구성을 설정합니다.
config:
target: "https://example.com/api"
phases:
- duration: 60
arrivalRate: 5
name: Warm up
payload:
path: "keywords.csv"
fields:
- "keyword"
config.target : 테스트하려는 애플리케이션의 URL을 설정합니다.
config.phases : 얼마나 많은 가상의 사용자가 얼마나 자주 서버에 트래픽 요청을 보낼것인지 설정할 수 있습니다.
위 예시에서는 60초간 매초 5명의 가상의 유저가 생성됩니다.
config.payload : Artillery에서 외부 CSV 파일을 로드할 수 있습니다. 로드한 데이터를 변수에 담아 테스트 시나리오에서 사용할 수 있습니다.
🎆 scenarios
: 여러 시나리오를 정의할 수 있으며 각 시나리오에는 하나 이상의 작업이 포함됩니다.
scenarios:
- name: "Search and buy"
flow:
- post:
url: "/search"
json:
kw: "{{ keyword }}"
위 예시 시나리오에서는 각 가상 유저가 HTTP POST 요청을 생성합니다.
해당 요청의 body는 json 데이터를 갖는데 데이터 값은 외부에서 로드한 CSV 파일의 데이터를 셋팅합니다.
keyword.csv
computer
video game
vacuum cleaner
예를 들어 위 keyword.csv 파일을 로드했을 때, 각 가상 사용자는 아래 데이터를 요청 데이터로 셋팅합니다.
{"kw": "computer"}, {"kw": "video game"}, {"kw": "vacuum cleaner"}
{
"config": {
"target": "http://localhost:8080",
"phases": [
{"duration": 20, "arrivalRate": 10}
],
"defaults": {
"headers": {
"User-Agent": "Artillery"
}
},
"payload": {
"path": "./data.csv",
"order": "sequence",
"fields": ["email", "password", "name", "phone"]
}
},
"scenarios": [
{
"name": "유저생성테스트",
"flow": [
{"post":
{
"url": "/api/users",
"json": {"email": "{{email}}", "password": "{{password}}", "name" : "{{name}}", "phone" : "{{phone}}" }
}
}
]
}
]
}
회원가입 테스트를 위한 시나리오를 Json 파일로 작성하였습니다.
참고로 외부 파일을 load하는 경우 데이터를 랜덤하게 선택해서 사용하기 때문에 중복되는 요청을 할 수 있습니다.
해당 서비스에서는 이메일을 아이디처럼 사용하여 이메일이 중복되면 회원가입을 할 수 없기 때문에 "order": "sequence" 옵션을 추가하여 sequencial하게 데이터를 선택하도록 하였습니다.
artillery run test.json
위 명령어를 통해 작성한 스크립트를 실행할 수 있습니다.
20초간 매초 10명의 가상의 유저가 '유저 생성 테스트'를 진행했을 때 문제가 발생하였습니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User extends BaseTimeEntity {
@Id
@GeneratedValue
@Column(name = "user_id")
private Long id;
...
}
현재 User 엔티티의 id 필드에 @GeneratedValue 어노테이션을 설정하였는데 해당 어노테이션의 기본값은 GenerationType.AUTO입니다.
Hibernate 5.0부터 MySQL의 AUTO는 IDENTITY가 아닌 TABLE을 기본 시퀀스 전략을 사용합니다.
이는 공용 시퀀스 테이블(hibernate_sequence)을 두고 모든 테이블의 id 시퀀스를 한 테이블에서 관리하는 방식입니다.
select
next_val as id_val
from
hibernate_sequence for update
update
hibernate_sequence
set
next_val= ?
where
next_val=?
insert into users (created_at, modified_at, email, name, password, phone, role, user_id) values (?, ?, ?, ?, ?, ?, ?, ?)
hibernate_sequence 공용 시퀀스 테이블은 최신 시퀀스 값을 유지하며 조회한 row에 lock을 걸어 두 개의 트랜잭션이 동일한 시퀀스 값을 획득하는 것을 방지합니다.
이때 장기 실행 트랜잭션이라면 다른 트랜잭션이 새 시퀀스 값을 획득함에 오랜 시간이 소요되기 때문에 ID를 select하고 update하는 별도의 트랜잭션을 사용합니다.
즉 하나의 스레드가 Connection 2개를 획득해야하는 TABLE 전략은 상당한 성능 오버헤드를 수반하고 특히 부하가 심할 때 커넥션을 획득하는데 문제가 생기는 것을 확인할 수 있었습니다.
부하가 심한 경우 각 스레드가 Connection을 획득하여 남아있는 Idle Connection이 없는 상황에서 추가적인 2차 Connection을 얻으려고 하기 때문에 모든 스레드가 Connection을 얻을 수 없는 데드락 문제가 발생할 것입니다.
@GeneratedValue(strategy = GenerationType.IDENTITY)
@GeneratedValue 어노테이션의 전략을 GenerationType.IDENTITY로 변경해준 결과 timeout 문제를 해결할 수 있었습니다.
https://artillery.io/docs/guides/getting-started/installing-artillery.html