첫 번째 테스트를 실행했다면, 이제 실전 스크립트를 작성할 차례다. 대부분의 API는 인증이 필요하고, 요청 파라미터가 다양하며, 동시 접속 상황을 시뮬레이션해야 한다. Groovy 스크립트의 라이프사이클을 이해하면, 비용이 큰 초기화는 한 번만, 측정 대상 로직만 반복하는 구조를 짤 수 있다.
스크립트 라이프사이클
nGrinder의 Groovy 스크립트는 JUnit과 비슷한 라이프사이클을 가진다. 각 어노테이션이 호출되는 시점과 횟수가 다르다.
| 어노테이션 | 실행 시점 | 횟수 | 용도 |
|---|---|---|---|
@BeforeProcess | Process 시작 시 | Process당 1번 | static 공유 자원 초기화. GTest 객체, HTTPRequest 생성 |
@BeforeThread | Thread 시작 시 | Thread당 1번 | VUser별 초기화. 인증 토큰 획득, 쿠키 설정 |
@Test | 매 반복 | 수천~수만 번 | 측정 대상. HTTP 요청 + 응답 검증 |
@AfterThread | Thread 종료 시 | Thread당 1번 | VUser별 정리 |
@AfterProcess | Process 종료 시 | Process당 1번 | 공유 자원 정리 |
JMH와 비교하면 @BeforeProcess는 @Setup(Level.Trial), @BeforeThread는 @Setup(Level.Iteration)과 역할이 비슷하다. 핵심은 @BeforeProcess가 static이라는 점이다. 여러 스레드가 공유하는 자원은 여기서 초기화한다.
인증 토큰 처리 패턴
대부분의 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 안의 스레드다.
| 설정 | Process | Thread | VUser | 용도 |
|---|---|---|---|---|
| 기준선 | 1 | 1 | 1 | 순수 API 응답 시간 확인 |
| 점진적 | 1 | 10 | 10 | 초기 동시 접속 테스트 |
| 중간 부하 | 2 | 25 | 50 | 일반적인 동시 접속 시뮬레이션 |
| 스트레스 | 4 | 25 | 100 | 한계점 탐색 |
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는 static 메서드다. 인스턴스 변수(non-static)에 접근하면 컴파일 에러가 발생한다. 공유 자원은 static으로, VUser별 자원(토큰 등)은 @BeforeThread에서 초기화한다.
[!WARNING] assert 없이 응답 검증 생략
응답 코드를 검증하지 않으면, 서버가 500 에러를 반환해도 "성공"으로 집계된다. TPS가 높게 나왔는데 실제로는 전부 에러 응답인 상황이 발생한다. assert response.statusCode == 200을 반드시 넣는다.
[!WARNING] Duration을 1분 미만으로 설정
JVM 워밍업과 DB 캐시 워밍이 끝나기 전에 테스트가 종료된다. 결과가 콜드 스타트 성능을 반영해서 실제보다 느리게 나온다. 최소 3분, 권장 5분이다.
[!WARNING] 인증 토큰 만료 미고려
테스트 Duration이 토큰 만료 시간보다 길면, 중간부터 401 에러가 발생하면서 에러율이 급등한다. 테스트 시간보다 긴 만료 시간을 설정하거나, 스크립트에서 401을 감지하면 재로그인하는 로직을 넣는다.