세상에 나쁜 코드는 없다

[Toy Project] 대규모 데이터를 처리하는 게시판(2) - JMeter와 테스트 케이스 생성 본문

웹개발/백엔드

[Toy Project] 대규모 데이터를 처리하는 게시판(2) - JMeter와 테스트 케이스 생성

Beomseok Seo 2023. 11. 1. 14:30

🔗
대규모 데이터를 처리하는 게시판 시리즈 (1) - 웹애플리케이션 개발 및 더미데이터 생성 (2) - JMeter와 테스트플랜 생성, 최초 성능 테스트 (3) - Index(1) (4) - Index(2) (5) - DB Connection (6) - InnoDB Buffer Pool (7) - Replication, Partitioning

JMeter

Apache JMeter - Apache JMeter™
The Apache JMeter™ application is open source software, a 100% pure Java application designed to load test functional behavior and measure performance. It was originally designed for testing Web Applications but has since expanded to other test functions.
https://jmeter.apache.org/

부하테스트를 하기 위한 여러 툴들이 있는데, 그 중에서 JMeter를 선택했습니다. 큰 이유는 없고 설치가 아주 간편해서 선택했습니다.

brew install JMeter -- 설치 완료
cd /opt/homebrew/bin
JMeter -- 실행

구성 요소

  • Test Plan : JMeter에서 여러 테스트를 묶는 가장 큰 단위이며, jmx라는 확장자의 파일로 저장할 수 있습니다.
  • Thread Group : Thread Group은 몇명의 가상 사용자를 만들고, 이들이 몇초동안 나누어 요청을 보내게 할 것인지, 얼마동안 반복하여 요청을 보낼 것인지 등을 결정합니다. Thread Group 내부의 Sampler들은 이 정책들을 따릅니다.
  • Sampler : 실질적인 요청을 구성하는 단위입니다. 저는 HTTP Request만 만들어 봤지만, 다양한 분야의 요청을 처리하는 Sampler들이 있다고 합니다.
  • Controller : Sampler 요청들을 제어하는 논리를 제공해주는 기능입니다. 저는 사용하지 않았습니다.
  • Listener : 테스트에 대한 결과를 제공받을 수 있는 형태들을 선택할 수 있습니다. Test Plan 레벨에 작성하면 모든 Thread Group의 수행 결과를 확인할 수 있으며, 특정 Thread Group 내에 작성한다면 해당 Thread Group만의 수행 결과도 확인할 수 있습니다.

어떻게 테스트해야 하는가?

테스트 케이스를 어떻게 작성하느냐에 따라서 부하테스트의 결과도 달라집니다. 만약 이 서비스가 시중에서 사용되고 있고, 요청의 빈도에 대한 통계 데이터가 있다면 실제 상황과 비슷한 테스트 케이스를 만들어 부하테스트를 수행할 수 있을 것입니다.

하지만 지금은 그런 상황이 아니므로 실제 상황과 비슷한 테스트 셋을 만들어야 했습니다.

트래픽의 비율

일반적으로 게시판과 같은 서비스는 최신 게시물에 대부분의 트래픽이 몰려있습니다. 따라서 전체 트래픽을 8:2 정도로 나누어 최신 게시물과 나머지 게시물에 나눠서 분배하기로 했습니다.

또한 읽기와 쓰기 역시 비율이 다른데, 일반적으로 웹애플리케이션에는 읽기와 쓰기가 7:3에서 8:2 정도의 비율을 갖는다고 합니다. 이 역시 8:2로 맞추어주기로 했습니다.

테스트가 데이터 셋에 주는 영향

읽기와 관련된 트래픽은 데이터 셋을 변경하지 않아 반복적으로 수행될 수 있습니다. 반면 쓰기와 관련된 트래픽은 데이터 셋을 변경하여 반복적으로 수행했을 때 문제를 낳습니다.

테스트 이후 직접 변화된 내용을 쿼리를 작성하여 되돌릴 수 있을 것입니다. 하지만 이 방법은 매번 테스트를 수행할 때마다 손이 간다는 단점이 있습니다.

아니면 기존의 데이터를 dump 해놓고 매 테스트 케이스가 끝날 때마다 데이터를 복구시키는 방법도 있는데, 이는 시간이 매우 오래걸릴 것이 분명합니다.

그렇다고 수정과 관련된 테스트를 아예 빼버리기에는 아쉬웠는데, 이는 lock에 영향을 줘서 연구해볼 부분이 많다고 느꼈기 때문입니다.

결국은 쓰기와 관련된 트래픽은 테스트하지 않기로 했으며 자세한 이유는 아래에서 다시 설명하겠습니다.

DB 캐시

DB에 데이터가 캐싱되어있는지 여부는 부하테스트에 크게 영향을 미칩니다. DB에 요청에서 필요한 데이터가 대부분 캐싱되어있다면 Disk I/O가 발생하지 않으므로 응답 시간은 훨씬 단축될 것이기 때문입니다. 안정적인 테스트 결과를 얻기 위해서 DB의 캐시를 최대한 채워 넣은 이후 모든 테스트를 진행했습니다.

테스트 플랜 작성

HTTP Request 생성

조회 관련 테스트는 다음과 같이 수행하기로 합니다.

  • 최신 페이지 (0~20) 조회 : Article Recent Page ${__Random(…)}을 통해 랜덤한 값의 요청을 보낼 수 있습니다.
  • 최신 페이지 내 개별 Article 조회 : Recent Article 최신글 (ID가 높은) 글 200개를 랜덤으로 조회하기 위해 499800와 500000 사이 수를 랜덤하게 요청하게 했습니다.
  • 전체 페이지 (0~*) 조회 : Article All Page
  • 전체 페이지 내 개별 Article 조회 (0~500,000) : All Article
  • Article 검색 : Article Search 제목을 통한 검색 요청만 진행하기로 합니다.

병렬 테스트

처음에는 Thread Group을 2개로 나누어 최신 게시물 관련 요청과 나머지 게시물 관련 요청을 분리하고 트래픽의 수를 8:2로 나누고자 했습니다.

  • Recent Thread Group (80)
    • Recent Pages
    • Recent Article
  • All Thread Group (20)
    • All Pages
    • All Article

그런데 테스트가 예상과 다르게 동작해서 확인해봤는데, 하나의 Thread Group 내에서 각각의 쓰레드들은 병렬적으로 동작하지만, 하나의 쓰레드에 대해서는 Sampler들을 동기적으로 수행하는 것을 확인했습니다. 제가 원했던 것은 각각의 HTTP Request들이 병렬적으로 수행되는 것이지 Recent Page 요청을 수행한 뒤 Recent Article 요청을 수행하는 것이 아니었습니다.

Thread Group 내의 HTTP Request들을 병렬적으로 요청하기 위해서는 Parallel Controller 플러그인을 설치해야합니다. 설치 이후 진행을 해봤는데 에러 메시지가 발생했고, 구글링을 통해 얻은 결과는 JMeter 5.5 버전 이상에서는 Parallel Controller가 정상적으로 수행되지 않는 다는 것이었습니다.

따라서 다운그레이드를 할 필요가 있었는데, homebrew는 JMeter에 대해서 버전 관리가 안되어있어 조금 귀찮은 상황이 발생했습니다. 따라서 다음과 같이 하나의 샘플러를 처리하는 쓰레드 그룹을 여러개를 만들어 HTTP Request들이 병렬적으로 수행될 수 있게 만들었습니다.

관리해줄 포인트가 늘어났지만, 개별적으로 좀 더 상세히 변경할 수도 있고 다운그레이드하는 것보다 빠르게 다음 태스크로 진행할 수 있다는 생각에 이렇게 진행했습니다.

리스너 생성

리스너는 부하테스트의 결과를 받을 수 있는 여러 형태들인데, 그 중 이렇게 3개를 사용하여 결과를 확인하기로 했습니다.

최초 성능 테스트

최초 성능테스트를 하기 위해 위 요청들을 여러번 반복해서 보낸 뒤 InnoDB Buffer Pool이 80% 채워져 있는 상태에서 결과를 기록했습니다.

요청별로 1개씩

각 요청별로 1개씩 보냈을 때의 상황입니다.

각각 20개의 요청이 있다고 나와있지만, 이것은 단일 요청들의 평균값을 보기 위해서 20번 별개로 수행한 것입니다. 평균적으로 0.3초 정도의 응답시간이 걸렸습니다.

이 중 최신글과 관련된 요청과 나머지와 관련된 요청은 구분되어야합니다. 위에서 이 비율을 8:2로 산정하기로 했으므로, 각각 80개 : 20개 씩 보내도록 Thread Group을 설정해 주었습니다. Article 검색은 100개를 수행하여 총 300개의 요청이 수행되도록 했습니다. 결과는 다음과 같습니다.

300개의 요청

테스트 한 번에 300개의 요청이 발생하므로, 위 요청은 5번 테스트를 수행한 결과입니다. 평균적으로 21초의 응답시간이 걸렸습니다. 아주 끔찍한 상황입니다.

조금 의문인점은 Recent Article과 관련된 요청들은 훨씬 요청 수도 많고 국소한 범위를 처리함으로써 캐싱의 효과를 얻어 더 응답시간이 짧을 줄 알았는데 All Article 요청들과 별반 차이 없거나 더 높은 응답시간을 가졌다는 점입니다.

또한 주의해서 볼 점은 에러 발생율입니다. 스프링부트 로그를 확인한 결과 이 에러들은 전부가 DB Connection을 30초 이상 획득하지 못해서 HikariCP에서 Exception을 발생시킨 경우였습니다. 스프링부트 서버에 늦게 전달된 요청들은 DB 병목 현상에 의해 Connection을 얻지 못하고 기다리다가 에러를 반환하고 있습니다. Max 응답시간이 전부 40초 가량인 이유도 이 이유와 같습니다.

부하테스트에서 적은 수의 요청만으로도 평균 응답시간이 20초를 넘기는 상황에서, 추가적으로 create, update, delete에 대한 테스트 케이스를 넣는 것은 무리라고 판단했습니다. 따라서 이번 프로젝트에서 성능 개선은 오직 조회의 측면에서만 진행하도록 했습니다.

또한 추가로 기능을 만들었던 댓글 게시판의 경우, count 쿼리만으로 3분이 넘게 걸리는 상황이어서 nginx에서 504 Gateway Time-out 에러를 반환했습니다. 따라서 이 경우는 부하테스트에 적합하지 않다고 판단하여 부하테스트의 범위를 Article과 관련된 요청으로 한정했습니다.

⚠️
Nginx 504 Gateway Time-out Nginx가 반환하는 504 에러는 애플리케이션 서버에서 응답이 일정 기간 이상 오지 않았을 때 클라이언트에게 제공되는 에러입니다. 기본 값은 60초로 설정되어있으며 nginx 설정 파일을 수정하여 변경할 수 있습니다.

proxy_send_timeout은 nginx가 애플리케이션 서버에 요청을 보내는데에 허용되는 시간을 결정합니다. Thread Pool이 가득 차 애플리케이션 서버가 더 이상 요청을 받을 수 없는 상태로 60초가 지난다면 nginx는 클라이언트에게 504 에러를 반환합니다.

proxy_read_timeout은 nginx가 애플리케이션 서버의 응답을 기다리는데에 허용되는 시간을 결정합니다. 애플리케이션 서버가 무한 루프 혹은 굉장히 긴 작업으로 인해 60초가 지난 상황이라면 nginx은 클라이언트에게 504 에러를 반환합니다.

location / {
	...
	proxy_send_timeout 60;
	proxy_read_timeout 60;
}

다음 글에서는 여러 성능 개선점을 알아보고 개선된 결과를 확인할 수 있도록 해보겠습니다.


Uploaded by N2T