nGrinder를 통한 성능 테스트

이전 포스트에서 티어리스트 목록 조회 API의 응답시간이 4000ms를 초과하는 것을 확인했다.

nGrinder를 통해 4000ms의 응답속도면 어느정도의 TPS가 나오는지 확인해보자.

테스트 준비

nGrinder 스크립트 작성

티어리스트 목록 조회 API는 인증을 필요로 하지 않으므로 디폴트 스크립트에서 주소만 수정해주면 된다. 스크립트는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

    public static GTest test
    public static HTTPRequest request
    public static Map<String, String> headers = [:]
    public static Map<String, Object> params = [:]
    public static List<Cookie> cookies = []

    @BeforeProcess
    public static void beforeProcess() {
        HTTPRequestControl.setConnectionTimeout(300000)
        test = new GTest(1, "Test1")
        request = new HTTPRequest()
        grinder.logger.info("before process.")
    }

    @BeforeThread
    public void beforeThread() {
        test.record(this, "test")
        grinder.statistics.delayReports = true
        grinder.logger.info("before thread.")
    }

    @Before
    public void before() {
        request.setHeaders(headers)
        CookieManager.addCookies(cookies)
        grinder.logger.info("before. init headers and cookies")
    }

    @Test
    public void test() {
        HTTPResponse response = request.GET("https://api.tierlist.site/tierlist?page=0&size=6&filter=HOT", params)

        if (response.statusCode == 301 || response.statusCode == 302) {
            grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
        } else {
            assertThat(response.statusCode, is(200))
        }
    }
}

테스트 설정

위의 스크립트를 기반으로 에이전트 별 가상 사용자를 설정해준다. 처음엔 호기롭게 1000명의 VUSER를 설정했다.

당연하게도 4000ms의 응답을 주는 API에 성능 측정이 될 리가 없었다.

스프링 부트의 스레드풀의 max 갯수가 default 200인데 단순 계산만으로도 한 트랜잭션 당 4초를 잡고 있으니 에러가 날 수 밖에 없다.

심지어 VUSER를 10명으로 잡아도 많은 ERROR응답으로 오류가 발생 했다.

10명이었는데도 불구하고 오류가 발생한 이유를 다음과 같이 추측할 수 있다.

HikariPool의 default pool size는 10이기 때문에 한 트랜잭션에 1초 이상이 걸린다면 그 이상의 트랜잭션을 받을 수 없기 때문이다.

스프링부트 서버의 로그를 확인해보니 다음과 같았다.

풀을 이미 다 사용해서 Connection is not available 상태가 일어났다.

VUSER를 4로 더 낮춰 겨우 테스트를 진행할 수 있었다.

성능 측정 결과

처참한 결과이다. 평균 TPS가 0.7이다. 즉, 조금만 사용자가 몰리면 서버가 뻗어버리는 것이다. 프로젝트 코드를 먼저 살펴보고 이를 개선해보자.

문제 코드 살펴보기

해당 API의 코드를 레이어 별로 살펴보자.

Controller

UseCase를 호출하는 것 이외에 특별한 로직이 존재하지 않는다.

1
2
3
4
5
@GetMapping({"/tierlist"})
public ResponseEntity<List<TierlistResponse>> getTierlists(@AuthenticationPrincipal String email, @RequestParam int pageCount, @RequestParam int pageSize, @RequestParam String query, @RequestParam TierlistFilter filter) {
  List<TierlistResponse> response = this.tierlistReadUseCase.getTierlists(email, pageCount, pageSize, query, filter);
  return ResponseEntity.ok(response);

Service

Repository를 호출하는 것 이외에 별다른 로직이 존재하지 않는다.

1
2
3
4
5
6
7
8
9
@Transactional(readOnly = true)
@Override
public PageResponse<TierlistResponse> getTierlists(String email, Pageable pageable, String query,
    TierlistFilter filter) {
  Page<TierlistResponse> tierlistResponses =
      tierlistLoadRepository.loadTierlists(email, pageable, query, filter);
  return PageResponse.fromPage(tierlistResponses);
}

Repository

그렇다면 Repository, 즉 쿼리의 문제일 가능성이 크다. 여러 테이블을 조인하며 성능이 저하되었을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
  @Override
  public Page<TierlistResponse> loadTierlists(String viewerEmail, Pageable pageable,
      String query,
      TierlistFilter filter) {
    QMemberJpaEntity viewer = new QMemberJpaEntity("viewer");
    QMemberJpaEntity writer = new QMemberJpaEntity("writer");

    List<TierlistResponse> tierlistResponses = jpaQueryFactory.select(
            Projections.constructor(TierlistResponse.class,
                tierlistJpaEntity.id,
                tierlistJpaEntity.title,
                tierlistJpaEntity.thumbnailImage,
                tierlistJpaEntity.createdAt,
                tierlistJpaEntity.likeCount,
                tierlistJpaEntity.commentCount,
                new CaseBuilder()
                    .when(viewer.email.isNotNull()).then(1).otherwise(0)
                    .max().gt(0).as("liked"),
                tierlistJpaEntity.isPublished,
                writer.id,
                writer.nickname,
                writer.profileImage,
                topicJpaEntity.id,
                topicJpaEntity.name,
                categoryJpaEntity.id,
                categoryJpaEntity.name
            ))
        .from(tierlistJpaEntity)
        .join(topicJpaEntity)
        .on(tierlistJpaEntity.topicId.eq(topicJpaEntity.id))
        .join(categoryJpaEntity)
        .on(topicJpaEntity.categoryId.eq(categoryJpaEntity.id))
        .join(writer)
        .on(tierlistJpaEntity.memberId.eq(writer.id))
        .leftJoin(tierlistLikeJpaEntity)
        .on(tierlistLikeJpaEntity.tierlistId.eq(tierlistJpaEntity.id))
        .leftJoin(viewer)
        .on(viewer.email.eq(viewerEmail), viewer.id.eq(tierlistLikeJpaEntity.tierlistId))
        .where(hasQuery(query), tierlistJpaEntity.isPublished)
        .orderBy(orderByFilter(filter))
        .groupBy(tierlistJpaEntity.id)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    Long count = jpaQueryFactory.select(tierlistJpaEntity.count())
        .from(tierlistJpaEntity)
        .where(hasQuery(query), tierlistJpaEntity.isPublished)
        .fetchOne();

    return new PageImpl<>(tierlistResponses, pageable, Objects.isNull(count) ? 0 : count);
  }

댓글남기기