시리즈

- nGrinder 아키텍처와 환경 구성

- 02 Groovy 스크립트와 테스트 설계

- 결과 해석과 병목 진단

첫 번째 테스트를 실행했다면, 이제 실전 스크립트를 작성할 차례다. 대부분의 API는 인증이 필요하고, 요청 파라미터가 다양하며, 동시 접속 상황을 시뮬레이션해야 한다. Groovy 스크립트의 라이프사이클을 이해하면, 비용이 큰 초기화는 한 번만, 측정 대상 로직만 반복하는 구조를 짤 수 있다.

스크립트 라이프사이클

nGrinder의 Groovy 스크립트는 JUnit과 비슷한 라이프사이클을 가진다. 각 어노테이션이 호출되는 시점과 횟수가 다르다.

flowchart TD subgraph Process ["Process (JVM 1개)"] direction TB BP(["@BeforeProcess"]) --> BT subgraph Threads ["Thread 반복"] direction TB BT(["@BeforeThread"]) --> T T(["@Test (반복 실행)"]) --> AT AT(["@AfterThread"]) end Threads --> AP(["@AfterProcess"]) end style BP fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style BT fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px style T fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style AT fill:#E8F5E9,stroke:#4CAF50,stroke-width:2px style AP fill:#E3F2FD,stroke:#2196F3,stroke-width:2px style Process fill:#fafafa,stroke:#999 style Threads fill:#f0f8f0,stroke:#4CAF50
어노테이션실행 시점횟수용도
@BeforeProcessProcess 시작 시Process당 1번static 공유 자원 초기화. GTest 객체, HTTPRequest 생성
@BeforeThreadThread 시작 시Thread당 1번VUser별 초기화. 인증 토큰 획득, 쿠키 설정
@Test매 반복수천~수만 번측정 대상. HTTP 요청 + 응답 검증
@AfterThreadThread 종료 시Thread당 1번VUser별 정리
@AfterProcessProcess 종료 시Process당 1번공유 자원 정리

JMH와 비교하면 @BeforeProcess@Setup(Level.Trial), @BeforeThread@Setup(Level.Iteration)과 역할이 비슷하다. 핵심은 @BeforeProcessstatic이라는 점이다. 여러 스레드가 공유하는 자원은 여기서 초기화한다.

인증 토큰 처리 패턴

대부분의 API는 인증이 필요하다. nGrinder 스크립트에서 토큰을 처리하는 표준 패턴이 있다.

@BeforeThread에서 로그인

각 VUser(Thread)가 시작할 때 로그인해서 토큰을 획득한다. VUser마다 독립된 토큰을 가지므로, 실제 사용자가 각자 로그인하는 상황을 재현한다.

public static HTTPRequest request
private String accessToken

@BeforeProcess
public static void beforeProcess() {
    request = new HTTPRequest()
}

@BeforeThread
public void beforeThread() {
    // 1. 로그인 요청
    HTTPResponse loginResponse = request.POST(
            "http://host.docker.internal:8080/api/auth/sign-in",
            buildLoginForm("testuser@test.com", "password123"))

    // 2. 응답에서 accessToken 추출
    def json = new groovy.json.JsonSlurper()
            .parseText(loginResponse.getText())
    accessToken = json.accessToken

    grinder.logger.info("토큰 획득 완료: ${accessToken?.take(20)}...")
}

accessToken은 인스턴스 변수(non-static)다. 각 Thread가 자신만의 토큰을 가진다. request는 static으로 공유해도 되는데, HTTPRequest 자체는 Thread-safe하기 때문이다.

토큰을 헤더에 설정

@Test에서 매 요청마다 Authorization 헤더를 설정한다.

@Test
public void doTest() {
    def headers = [
        new NVPair("Authorization", "Bearer ${accessToken}")
    ] as NVPair[]

    HTTPResponse response = request.GET(
            "http://host.docker.internal:8080/api/feeds?limit=20" +
            "&sortBy=createdAt&sortDirection=DESCENDING",
            null, headers)

    assert response.statusCode == 200: "응답 코드: ${response.statusCode}"
}

NVPair는 nGrinder가 사용하는 HTTPClient 라이브러리의 키-값 쌍 클래스다. 헤더 배열을 만들어서 GET 메서드의 세 번째 인자로 전달한다.

CSRF 토큰이 필요한 경우

POST, PATCH, DELETE 요청은 CSRF 토큰이 필요한 서버가 있다. Spring Security의 CookieCsrfTokenRepository를 사용하면 쿠키에서 CSRF 토큰을 가져와 헤더로 보내야 한다.

@BeforeThread
public void beforeThread() {
    // 1. CSRF 토큰 획득
    HTTPResponse csrfResponse = request.GET(
            "http://host.docker.internal:8080/api/auth/csrf-token")
    String csrfToken = extractCookie(csrfResponse, "XSRF-TOKEN")

    // 2. CSRF 토큰 + 로그인
    def loginHeaders = [
        new NVPair("X-XSRF-TOKEN", csrfToken)
    ] as NVPair[]

    HTTPResponse loginResponse = request.POST(
            "http://host.docker.internal:8080/api/auth/sign-in",
            buildLoginForm("testuser@test.com", "password123"),
            loginHeaders)

    def json = new groovy.json.JsonSlurper()
            .parseText(loginResponse.getText())
    accessToken = json.accessToken
}

GET 요청에는 CSRF가 필요 없으므로, 피드 조회(GET /api/feeds)만 테스트한다면 이 단계를 건너뛸 수 있다. 좋아요(POST), 댓글 작성(POST) 같은 쓰기 요청을 테스트할 때 필요하다.

파라미터화와 데이터 드리븐 테스트

여러 사용자로 테스트

VUser마다 다른 계정으로 로그인하려면 사용자 풀을 미리 준비한다.

public static List<String[]> userPool

@BeforeProcess
public static void beforeProcess() {
    // CSV 파일에서 사용자 목록 로딩
    userPool = new File("/opt/ngrinder-controller/users.csv")
            .readLines()
            .collect { it.split(",") }
    // 각 줄: email,password
}

@BeforeThread
public void beforeThread() {
    int index = grinder.threadNumber % userPool.size()
    String[] user = userPool[index]
    // user[0] = email, user[1] = password
    loginAndGetToken(user[0], user[1])
}

grinder.threadNumber는 0부터 시작하는 스레드 번호다. 사용자 풀 크기로 나머지 연산을 하면 순환하며 배분된다.

커서 기반 순회

피드 조회의 커서 페이지네이션을 재현하려면, 이전 응답에서 다음 커서 값을 추출해서 다음 요청에 사용한다.

private String nextCursor = null
private String nextIdAfter = null

@Test
public void doTest() {
    String url = "http://host.docker.internal:8080/api/feeds" +
            "?limit=20&sortBy=createdAt&sortDirection=DESCENDING"

    if (nextCursor != null) {
        url += "&cursor=${nextCursor}&idAfter=${nextIdAfter}"
    }

    HTTPResponse response = request.GET(url, null, authHeaders())

    def json = new groovy.json.JsonSlurper()
            .parseText(response.getText())

    if (json.hasNext) {
        nextCursor = json.nextCursor
        nextIdAfter = json.nextIdAfter
    } else {
        // 마지막 페이지 → 처음으로 돌아감
        nextCursor = null
        nextIdAfter = null
    }
}

이 방식은 "사용자가 피드를 스크롤하면서 계속 다음 페이지를 로드하는" 현실적인 시나리오를 재현한다.

GTest로 트랜잭션 분리

하나의 스크립트에서 여러 API를 호출할 때, 각 API의 TPS를 분리해서 측정하려면 GTest 객체를 따로 만든다.

public static GTest feedListTest
public static GTest feedLikeTest

@BeforeProcess
public static void beforeProcess() {
    feedListTest = new GTest(1, "피드 목록 조회")
    feedLikeTest = new GTest(2, "피드 좋아요")
    request = new HTTPRequest()
}

@BeforeThread
public void beforeThread() {
    feedListTest.record(this, "testFeedList")
    feedLikeTest.record(this, "testFeedLike")
}

@Test
public void testFeedList() {
    HTTPResponse response = request.GET(feedListUrl, null, authHeaders())
    assert response.statusCode == 200
}

@Test
public void testFeedLike() {
    HTTPResponse response = request.POST(feedLikeUrl, null, authHeaders())
    assert response.statusCode == 204
}

GTest(1, "피드 목록 조회")의 첫 번째 인자는 트랜잭션 ID다. 결과 리포트에서 이 ID로 각 API의 TPS, MTT, 에러율을 분리해서 확인할 수 있다.

VUser와 Ramp-up 전략

VUser 계산

VUser 수 = Process 수 × Thread 수. nGrinder에서 Process는 별도 JVM이고, Thread는 JVM 안의 스레드다.

설정ProcessThreadVUser용도
기준선111순수 API 응답 시간 확인
점진적11010초기 동시 접속 테스트
중간 부하22550일반적인 동시 접속 시뮬레이션
스트레스425100한계점 탐색

Process를 늘리면 JVM이 추가되므로 Agent의 메모리 사용량이 급증한다. Thread를 먼저 늘리고, Thread만으로 부족할 때 Process를 추가하는 것이 효율적이다.

Ramp-up 설정

Ramp-up 없이 VUser 100을 한꺼번에 투입하면, 서버가 갑작스러운 부하에 한 번에 과부하 상태에 빠진다. 이것은 현실적이지 않고, 서버의 진정한 처리 능력을 왜곡한다.

Ramp-up을 설정하면 VUser가 점진적으로 증가한다. 예를 들어 "30초 동안 0 → 100"이면 초당 약 3.3명씩 추가된다. 서버가 점점 늘어나는 부하에 어떻게 반응하는지, 어느 지점에서 포화되는지를 관찰할 수 있다.

Duration 설정

최소 3분 이상으로 설정한다. 이유는 두 가지다.

  • JVM 워밍업(JIT 컴파일)이 완료되기까지 30초~1분이 걸린다. 워밍업 구간의 데이터가 평균을 왜곡한다.
  • DB 버퍼 캐시, 커넥션 풀이 안정 상태에 도달해야 프로덕션과 비슷한 조건이 된다.

5분이 적당하고, 안정 상태 확인이 목적이면 10분까지 늘려도 좋다.

자주 하는 실수

@BeforeProcess에서 인스턴스 변수 초기화

@BeforeProcessstatic 메서드다. 인스턴스 변수(non-static)에 접근하면 컴파일 에러가 발생한다. 공유 자원은 static으로, VUser별 자원(토큰 등)은 @BeforeThread에서 초기화한다.

[!WARNING] assert 없이 응답 검증 생략

응답 코드를 검증하지 않으면, 서버가 500 에러를 반환해도 "성공"으로 집계된다. TPS가 높게 나왔는데 실제로는 전부 에러 응답인 상황이 발생한다. assert response.statusCode == 200을 반드시 넣는다.

[!WARNING] Duration을 1분 미만으로 설정

JVM 워밍업과 DB 캐시 워밍이 끝나기 전에 테스트가 종료된다. 결과가 콜드 스타트 성능을 반영해서 실제보다 느리게 나온다. 최소 3분, 권장 5분이다.

[!WARNING] 인증 토큰 만료 미고려

테스트 Duration이 토큰 만료 시간보다 길면, 중간부터 401 에러가 발생하면서 에러율이 급등한다. 테스트 시간보다 긴 만료 시간을 설정하거나, 스크립트에서 401을 감지하면 재로그인하는 로직을 넣는다.