k6와 Bash로 분산 락 성능 측정 자동화하기
💡복잡한 성능 측정 절차 자동화
동일 상품의 재고 차감 요청이 동시에 들어왔을 때 Redisson 분산 락이 재고 정합성을 지키는지 확인하고, 락 내부 작업을 줄인 전후의 성능을 비교하고자 했다.
k6 스크립트 자체는 한 번의 명령으로 실행할 수 있지만, 개선 전후를 정확히 비교하려면 다음 조건을 매번 동일하게 맞춰야 했다.
- 같은 코드 버전과 락 정책으로 서버를 실행해야 한다.
- 매 실행 전 재고를 동일한 수량으로 초기화해야 한다.
- 웜업을 마친 뒤 본 측정 직전에 Micrometer 스냅샷을 저장해야 한다.
- 동일한 VU와 실행 시간으로 k6 테스트를 실행해야 한다.
- 본 측정이 끝난 뒤 같은 서버 프로세스에서 두 번째 스냅샷을 저장해야 한다.
- k6 결과와 락 지표, 실제 재고 감소량을 서로 대조해야 한다.
이 과정을 명령어로 하나씩 반복하면 시드 초기화나 웜업을 빠뜨리거나, 스냅샷을 잘못된 시점에 저장하거나, 서로 다른 실행 조건의 결과를 비교할 수 있었다. 실행 자체가 성공하더라도 비교 기준이 달라지면 개선 효과를 신뢰하기 어렵다.
따라서 정식 측정을 시작하기 전에 실행 조건 확인, 데이터 초기화, 웜업, 스냅샷 수집, k6 실행, 결과 계산과 유효성 판정까지 정해진 순서로 처리하는 Bash 자동화 스크립트를 만들었다.
최종적으로 다음 두 개의 스크립트로 역할을 나눴다.
scenario1-stock-decrease-lock.js: 동일 상품에 재고 차감 요청을 보내고 성공, 락 타임아웃, 예상하지 못한 실패를 구분한다.run-measurement.sh: 측정 조건 확인부터 데이터 초기화, 웜업, 지표 수집, 부하 테스트와 최종 판정까지 자동으로 수행한다.
전체 측정 흐름
💡정해진 순서와 조건을 지켜야 개선 전후 결과를 비교할 수 있다
1. 실행 조건과 필수 도구 확인
2. 코드 버전과 측정 조건 저장
3. 시드 데이터로 초기 재고 복구
4. VU 10, 30초 웜업
5. Micrometer 스냅샷 A 저장
6. VU 200, 60초 본 측정
7. 서버 처리를 위한 5초 완충 대기
8. Micrometer 스냅샷 B 저장
9. 스냅샷 A와 B의 차이 계산
10. k6 결과, 락 지표, 실제 재고 교차 검증
11. VALID 또는 INVALID 판정
자동화 스크립트는 Product 서비스를 직접 재시작하지 않는다. 대신 측정 시작 시 Micrometer Timer의 누적 건수가 0인지 확인한다. 값이 남아 있다면 이전 실행의 결과가 섞일 수 있으므로 서버를 재시작하라는 메시지와 함께 실행을 중단한다.
PHASE=before RUN=1 ./run-measurement.sh
PHASE=after RUN=1 ./run-measurement.sh
실행자는 개선 전후 구분과 RUN 번호만 지정한다. 이후 시드 초기화부터 최종 판정까지는 자동화 스크립트가 순서대로 처리한다.
k6 실행 모드 분리
💡탐색, 웜업, 전후 비교를 각각 다른 실행 모드로 분리했다
function createScenarios() {
if (TEST_MODE === 'warmup') {
return { warmup: warmupScenario };
}
if (TEST_MODE === 'compare') {
return createCompareScenarios();
}
return createExploreScenarios();
}
explore: VU 20, 50, 100, 200을 순서대로 실행해 부하에 따른 변화와 비교 지점을 탐색한다.warmup: VU 10으로 30초 동안 실행해 JVM과 DB 커넥션 풀, 실행 경로를 예열한다.compare: 지정한 VU와 실행 시간으로 개선 전후의 결과를 측정한다.
처음에는 VU별로 k6 명령을 각각 실행해야 하는지도 검토했다. 하지만 탐색 단계는 여러 constant-vus 시나리오에 서로 다른 시작 시간을 지정해 한 번의 k6 run으로 순서대로 실행되도록 구성했다.
function createExploreScenarios() {
return {
warmup: {
executor: 'constant-vus',
vus: 10,
duration: '30s',
startTime: '0s',
gracefulStop: '20s',
},
vus_20: {
executor: 'constant-vus',
vus: 20,
duration: '1m',
startTime: '1m',
gracefulStop: '20s',
},
vus_50: {
executor: 'constant-vus',
vus: 50,
duration: '1m',
startTime: '2m30s',
gracefulStop: '20s',
},
vus_100: {
executor: 'constant-vus',
vus: 100,
duration: '1m',
startTime: '4m',
gracefulStop: '20s',
},
vus_200: {
executor: 'constant-vus',
vus: 200,
duration: '1m',
startTime: '5m30s',
gracefulStop: '20s',
},
};
}
gracefulStop은 시나리오의 실행 시간이 끝났을 때 이미 진행 중인 요청이 마무리될 시간을 준다. 다음 시나리오의 startTime에는 이 시간을 고려해 간격을 두어 서로 다른 VU 단계가 겹치는 위험을 줄였다.
왜 constant-vus를 선택했는가
💡고정된 사용자가 응답을 받은 뒤 다음 요청을 보내는 상황을 재현했다
function createCompareScenarios() {
return {
compare: {
executor: 'constant-vus',
vus: requiredPositiveInteger('VUS'),
duration: __ENV.DURATION || '1m',
startTime: '0s',
gracefulStop: '20s',
},
};
}
k6의 부하 모델은 크게 Open model과 Closed model로 구분된다.
- Open model: 이전 요청의 완료 여부와 관계없이 정해진 속도로 새로운 요청을 시작한다.
- Closed model: 정해진 수의 가상 사용자가 이전 요청의 응답을 받은 뒤 다음 요청을 보낸다.
이번 측정은 일정한 수의 사용자가 동일 상품의 락을 두고 경쟁할 때 응답시간과 처리량이 어떻게 변하는지 확인하는 것이 목적이었다. 따라서 Closed model의 constant-vus를 선택했다.
Closed model에서는 응답이 느려지면 각 사용자가 다음 요청을 시작하는 시점도 늦어진다. 따라서 VU 수만 확인하지 않고 실제 성공 건수와 성공 TPS, API 응답시간을 함께 확인해야 한다.
성공과 실패를 직접 분류한 이유
💡의도된 락 타임아웃과 예상하지 못한 시스템 오류를 구분했다
const decreaseSuccesses =
new Counter('stock_decrease_successes');
const lockTimeouts =
new Counter('stock_lock_timeouts');
const unexpectedFailures =
new Counter('stock_unexpected_failures');
const lockTimeoutRate =
new Rate('stock_lock_timeout_rate');
const unexpectedFailureRate =
new Rate('stock_unexpected_failure_rate');
http.setResponseCallback(
http.expectedStatuses(200, 409),
);
재고 차감 API의 결과는 세 종류로 나눴다.
- 성공: HTTP 200이며 상품 ID와 수량이 정상적으로 반환된 경우
- 락 타임아웃: 설정한 락 대기와 재시도 정책을 모두 소진해 HTTP 409로 응답한 경우
- 예상하지 못한 실패: 5xx, 네트워크 오류, 잘못된 응답 형식 등 나머지 실패
expectedStatuses(200, 409)는 k6의 기본 지표인 http_req_failed가 HTTP 409를 모두 시스템 장애로 집계하지 않게 한다. 다만 200과 409를 같은 결과로 처리한다는 뜻은 아니다. 실제 의미는 별도의 커스텀 지표로 다시 구분했다.
const success =
res.status === 200 &&
body?.success === true &&
body?.data?.productId === PRODUCT_ID &&
Number.isFinite(Number(body?.data?.quantity));
const lockTimeout = isLockTimeoutResponse(res, body);
const unexpected = !success && !lockTimeout;
if (success) {
decreaseSuccesses.add(1);
}
if (lockTimeout) {
lockTimeouts.add(1);
}
if (unexpected) {
unexpectedFailures.add(1);
}
lockTimeoutRate.add(lockTimeout);
unexpectedFailureRate.add(unexpected);
이렇게 분리하면 락 경합으로 발생한 정책상 실패와 실제 결함을 구분할 수 있다. 테스트 유효성 판단에는 예상하지 못한 실패가 0건인지 사용하고, 락 타임아웃은 경합 정도와 실패 정책을 확인하는 지표로 사용했다.
초기 재고와 최종 재고 확인
💡성공 응답뿐 아니라 실제 재고도 같은 수량만큼 줄었는지 확인했다
export function setup() {
const res = getStock('setup_stock');
const quantity = readStockQuantity(res, '초기');
if (quantity < MIN_REQUIRED_QUANTITY) {
throw new Error(
`초기 재고 부족(${quantity})`
);
}
return { initialQuantity: quantity };
}
export function teardown(data) {
const res = getStock('teardown_stock');
const finalQuantity =
Number(res.json('data.quantity'));
const decreasedQuantity =
data.initialQuantity - finalQuantity;
console.log(`차감된 수량: ${decreasedQuantity}`);
}
응답이 200이었다는 사실만으로 재고 정합성을 증명할 수는 없다. 동시에 실행된 두 요청이 같은 재고를 읽고 모두 성공했다면 성공 응답 수보다 실제 차감량이 적을 수 있기 때문이다.
실제 재고 감소량
= 테스트 전 재고 - 테스트 후 재고
정합성 통과 조건
= 실제 재고 감소량
== k6 재고 차감 성공 건수
또한 재고가 테스트 중간에 소진되면 이후 요청은 정상 차감이 아니라 재고 부족 경로를 실행한다. 측정하려는 코드 경로가 달라지는 것을 막기 위해 충분히 큰 초기 재고를 준비하고, 재고가 부족하면 테스트를 시작하지 않도록 했다.
Bash 자동화 스크립트의 실행 조건
💡측정을 시작하기 전에 잘못된 입력과 실행 환경을 차단한다
#!/usr/bin/env bash
set -euo pipefail
PHASE=${PHASE:-}
RUN=${RUN:-}
VUS=${VUS:-200}
DURATION_SECONDS=${DURATION_SECONDS:-60}
[[ "$PHASE" == "before" ||
"$PHASE" == "after" ]] \
|| abort "PHASE는 before 또는 after여야 합니다."
[[ "$RUN" =~ ^[1-9][0-9]*$ ]] \
|| abort "RUN은 1 이상의 정수여야 합니다."
for command in curl docker git grep jq k6; do
command -v "$command" >/dev/null \
|| abort "$command 명령을 찾을 수 없습니다."
done
-e: 처리되지 않은 명령 실패가 발생하면 스크립트를 중단한다.-u: 선언되지 않은 변수를 사용하면 오류로 처리한다.pipefail: 파이프라인 중간 명령이 실패해도 전체 명령이 성공한 것처럼 넘어가지 않게 한다.
VU와 실행 시간의 형식을 검사하고, curl, docker, git, grep, jq, k6가 설치되어 있는지도 확인한다. 잘못된 입력으로 테스트를 끝까지 실행한 뒤 마지막 계산 단계에서 실패하는 일을 줄이기 위한 처리다.
MySQL과 Redis에 접근할 수 있는지, Product 서비스의 health가 정상인지, 필요한 Actuator 지표가 노출되어 있는지도 확인한다. 하나라도 충족되지 않으면 본 측정을 시작하지 않는다.
기존 결과를 덮어쓰지 않도록 처리
💡실패한 실행도 측정 이력이므로 삭제하지 않고 다음 RUN 번호를 사용한다
for file in \
"$META_FILE" \
"$WARMUP_LOG" \
"$COMPARE_LOG" \
"$K6_RESULT" \
"$SNAPSHOT_A" \
"$SNAPSHOT_B" \
"$VERDICT_FILE"; do
[[ ! -e "$file" ]] \
|| abort "결과 파일이 이미 존재합니다."
done
측정에 실패했다고 기존 RUN 파일을 삭제하고 같은 번호로 다시 실행하면 실패 원인과 당시 실행 조건이 사라진다. 일부 파일만 새 결과로 덮어쓰면 서로 다른 실행의 k6 결과와 Micrometer 스냅샷이 섞일 수도 있다.
따라서 기존 파일이 하나라도 있으면 실행을 중단하고 새로운 RUN 번호를 사용하게 했다. 예상 밖 실패가 발생한 실행도 그대로 보존해 무효로 판정한 근거를 추적할 수 있도록 했다.
코드 버전과 측정 조건 저장
💡측정값만 남기지 않고 어떤 코드와 조건에서 나온 값인지 함께 저장한다
COMMIT_SHA=$(git -C "$REPO_ROOT" rev-parse HEAD)
BRANCH=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)
K6_VERSION=$(k6 version)
WAIT_TIME_SECONDS=$(lock_constant WAIT_TIME_SECONDS)
LEASE_TIME_SECONDS=$(lock_constant LEASE_TIME_SECONDS)
MAX_RETRY_COUNT=$(lock_constant MAX_RETRY_COUNT)
RETRY_BACKOFF_MILLIS=$(
lock_constant RETRY_BACKOFF_MILLIS
)
meta.json에는 다음 정보를 저장했다.
- 개선 전·후 구분과 RUN 번호
- Git 커밋 SHA와 브랜치
- k6 버전
- VU 수와 실행 시간
- 대상 상품 ID
- 락 대기시간, 임대시간, 재시도 횟수와 간격
성능 결과가 달라졌을 때 코드 변경 때문인지, 락 정책이나 실행 조건이 달라졌기 때문인지 구분하려면 이러한 정보가 필요하다. 커밋되지 않은 소스 변경이 있으면 측정을 중단하게 한 것도 같은 이유다.
Micrometer 지표 조회와 파싱
💡락의 대기시간과 점유시간을 결과별로 조회한다
timer_metric() {
local metric=$1
local result=$2
local response
response=$(
curl -fsS \
-G "$BASE_URL/actuator/metrics/$metric" \
--data-urlencode "tag=result:$result"
) || abort "지표 조회 실패"
jq -e '
def measurement($name):
[.measurements[]
| select(.statistic == $name)
| .value][0];
{
count: measurement("COUNT"),
totalTime: measurement("TOTAL_TIME"),
max: measurement("MAX")
}
' <<< "$response"
}
애플리케이션에는 다음 Timer를 구성했다.
stock.lock.wait: 락 획득을 시도하기 직전부터 성공·타임아웃·오류가 결정될 때까지stock.lock.hold: 락 획득 직후부터 락 해제가 완료될 때까지
각 Timer는 acquired, timeout, interrupted, success, error, ownership_lost 등의 결과 태그로 구분했다. 자동화 스크립트는 Actuator API를 호출하고 jq로 COUNT, TOTAL_TIME, MAX를 추출한다.
시간 단위가 초가 아니거나 필수 값이 누락된 경우에는 계산을 진행하지 않는다. 단위를 확인하지 않고 무조건 1,000을 곱하면 측정 결과가 1,000배 틀릴 수 있기 때문이다.
스냅샷 A와 B를 이용한 계산식
💡애플리케이션 기동 이후의 누적값에서 본 측정 구간만 분리했다
Micrometer Timer는 애플리케이션이 실행된 이후의 처리 건수와 시간을 계속 누적한다. 따라서 값을 그대로 읽으면 웜업 요청까지 본 측정에 포함된다.
웜업 실행
→ 스냅샷 A 저장
→ 본 측정 실행
→ 스냅샷 B 저장
→ B와 A의 차이 계산
측정 구간 처리 건수
= B.count - A.count
측정 구간 누적 시간
= B.totalTime - A.totalTime
측정 구간 평균 시간
= 측정 구간 누적 시간 / 측정 구간 처리 건수
Actuator에서 조회한 TOTAL_TIME의 단위는 초이므로 밀리초로 표시하기 위해 마지막에 1,000을 곱했다.
평균 락 대기시간(ms)
= (
B의 acquired TOTAL_TIME
- A의 acquired TOTAL_TIME
)
/ (
B의 acquired COUNT
- A의 acquired COUNT
)
× 1000
평균 락 점유시간(ms)
= (
B의 success TOTAL_TIME
- A의 success TOTAL_TIME
)
/ (
B의 success COUNT
- A의 success COUNT
)
× 1000
| .average = {
lockWaitMs:
(if .delta.waitAcquired > 0 then
(
($B.wait.acquired.totalTime
- $A.wait.acquired.totalTime)
/ .delta.waitAcquired
) * 1000
else
null
end),
lockHoldMs:
(if .delta.holdSuccess > 0 then
(
($B.hold.success.totalTime
- $A.hold.success.totalTime)
/ .delta.holdSuccess
) * 1000
else
null
end)
}
분모가 0이면 평균을 계산할 수 없으므로 null로 기록한다. 이후 유효성 검사에서 락 획득 건수가 한 건 이상인지 별도로 확인한다.
MAX를 차분하지 않은 이유
💡최댓값은 누적 합계가 아니므로 B.MAX에서 A.MAX를 뺄 수 없다
COUNT와 TOTAL_TIME은 계속 더해지는 값이므로 A와 B의 차이로 본 측정 구간을 분리할 수 있다. 반면 MAX는 현재까지 관측한 값 중 가장 큰 값이다.
A 시점 MAX = 10초
본 측정 구간 MAX = 8초
B 시점 MAX = 10초
B.MAX - A.MAX = 0초
실제 본 측정 MAX = 8초
따라서 B.MAX - A.MAX는 본 측정 구간의 최댓값이 아니다. 스냅샷 B의 MAX도 웜업 결과를 포함할 수 있으므로 이상치를 확인하는 참고값으로만 저장하고, 공식 전후 비교에서는 제외했다.
성공 TPS 계산식
💡전체 HTTP 요청이 아니라 실제 재고 차감 성공 건수만 사용했다
성공 TPS
= 재고 차감 성공 건수 / 본 측정 시간(초)
| .k6.successTps = (
.k6.successes / $durationSeconds
)
http_reqs에는 성공 요청뿐 아니라 실패 요청과 setup, teardown의 재고 조회 요청이 포함될 수 있다. 따라서 전체 HTTP 요청률을 성공 처리량으로 사용하지 않고, stock_decrease_successes에 기록된 성공 건수를 측정 시간으로 나눴다.
또한 결과 JSON을 읽을 때는 전역 지표가 아니라 {scenario:compare} 태그가 붙은 지표를 사용했다. 전역 지표에는 본 측정이 아닌 요청이 섞일 수 있기 때문이다.
k6 종료 코드를 보존한 이유
💡k6가 실패해도 원인을 확인할 결과와 스냅샷은 끝까지 수집한다
set +e
k6 run \
--summary-export "$K6_RESULT" \
-e TEST_MODE=compare \
-e VUS="$VUS" \
-e DURATION="${DURATION_SECONDS}s" \
"$K6_SCRIPT" 2>&1 | tee "$COMPARE_LOG"
K6_EXIT=${PIPESTATUS[0]}
set -e
k6가 비정상 종료되거나 threshold를 위반했을 때 자동화 스크립트까지 바로 끝나면 스냅샷 B와 최종 판정 파일을 남길 수 없다. 실패 원인을 확인하려면 테스트 이후의 데이터도 필요하므로 k6 실행 구간에서만 set +e를 적용했다.
출력은 tee를 사용해 로그 파일과 터미널에 동시에 남겼다. 이때 일반적인 $?는 마지막 명령인 tee의 종료 코드를 가리킬 수 있으므로, PIPESTATUS[0]으로 첫 번째 명령인 k6의 종료 코드를 저장했다.
결과 교차 검증
💡한 종류의 지표만 믿지 않고 k6와 애플리케이션, DB 결과를 서로 대조했다
| .crossCheck = {
allRequestsReachedTerminalWait:
(.delta.waitTerminal == .k6.requests),
acquiredEqualsHoldTotal:
(.delta.waitAcquired == .delta.holdTotal),
timeoutMatches:
(.delta.waitTimeout == .k6.timeouts),
stockMatchesSuccess:
(.stock.decreased == .k6.successes)
}
- 전체 요청 수와 락 대기 종료 건수의 합이 같은지 확인한다.
- 락 획득 건수와 락 점유 종료 건수의 합이 같은지 확인한다.
- 애플리케이션의 락 타임아웃 수와 k6가 받은 409 응답 수가 같은지 확인한다.
- 실제 재고 감소량과 k6의 재고 차감 성공 건수가 같은지 확인한다.
스냅샷 A와 B의 process.start.time도 비교했다. 두 값이 다르면 측정 중간에 애플리케이션이 재시작된 것이므로 누적값의 기준이 달라진다. 이 경우 결과를 유효한 비교값으로 사용할 수 없다.
| .validity = {
k6ExitZero: ($k6Exit == 0),
sameProcess:
(.process.startTimeA == .process.startTimeB),
hasMeasurement:
(.delta.waitAcquired > 0),
waitErrorZero:
(.delta.waitError == 0),
waitInterruptedZero:
(.delta.waitInterrupted == 0),
holdErrorZero:
(.delta.holdError == 0),
ownershipLostZero:
(.delta.holdOwnershipLost == 0),
unexpectedZero:
(.k6.unexpected == 0)
}
교차 검증과 유효성 조건이 모두 참일 때만 최종 결과를 VALID로 판정했다.
자동화 결과 파일
💡원본과 계산 결과를 분리해 계산 단계가 실패해도 측정 데이터를 보존했다
results/scenario1-lock/
├── before/
│ ├── run1/
│ │ ├── meta.json
│ │ ├── warmup.log
│ │ ├── compare.log
│ │ ├── k6.json
│ │ ├── metrics-a.json
│ │ ├── metrics-b.json
│ │ └── verdict.json
│ └── ...
└── after/
├── run1/
└── ...
meta.json: 코드 버전과 실행 조건warmup.log: 웜업 실행 로그compare.log: 본 측정 실행 로그k6.json: k6 원본 요약 결과metrics-a.json: 본 측정 전 Micrometer 스냅샷metrics-b.json: 본 측정 후 Micrometer 스냅샷verdict.json: 계산된 평균과 교차 검증, 최종 판정
JSON을 생성할 때는 먼저 .tmp 파일에 쓴 뒤 작업이 성공하면 최종 경로로 이동했다. jq 계산 도중 오류가 발생해도 불완전한 JSON이 정상 결과 파일처럼 남는 것을 막기 위한 처리다.
Codex를 활용해 측정 환경을 구축한 과정
💡측정 목적과 판정 기준은 직접 정하고 코드 구현과 실행은 AI 에이전트에 맡겼다
- 먼저 공식 문서와 기술 블로그를 참고해 k6의 Open model과 Closed model, constant-vus, Micrometer Timer의 기본 구조를 확인했다. 이후 어떤 상황을 재현할 것인지, 성공과 실패를 어떻게 구분할 것인지, 어떤 값이 일치해야 유효한 결과로 볼 것인지를 AI와 대화하며 구체화했다.
- 탐색 단계에서는 VU 20·50·100·200 시나리오가 한 번의 실행으로 이어지도록 구성했다. 정식 비교 단계에서는 여러 명령을 사람이 하나씩 실행하지 않고 시드 초기화부터 웜업, 스냅샷 A 저장, 본 측정, 스냅샷 B 저장과 결과 계산까지 자동으로 수행하도록 요청했다.
- Codex가 k6와 Bash, jq 코드를 구현하고 자동화 스크립트를 실행했다. 나는 생성된 코드의 동작과 계산식을 확인하고, 이해하지 못한 문법은 공식 문서와 실제 결과 파일을 함께 보며 학습했다. 또한 계산 단위와 교차 검증 조건이 측정 목적에 맞는지도 점검했다.
Codex에는 기능 전체를 막연하게 요청하지 않고 다음처럼 구현 조건을 나누어 전달했다.
[k6 스크립트 요청 기준]
- 모든 VU가 같은 상품의 재고를 1개씩 차감할 것
- explore, warmup, compare 모드를 분리할 것
- compare에서는 constant-vus를 사용할 것
- 200 성공, 409 락 타임아웃,
나머지 실패를 각각 집계할 것
- 시작과 종료 시 재고를 조회할 것
- 결과별 커스텀 지표를 JSON에 남길 것
[자동화 스크립트 요청 기준]
- 코드 버전과 실행 조건을 저장할 것
- 시드 초기화 → 웜업 → 스냅샷 A
→ 본 측정 → 스냅샷 B 순서를 지킬 것
- COUNT와 TOTAL_TIME의 차이로 평균을 계산할 것
- k6 결과와 Micrometer, 실제 재고를 교차 검증할 것
- 기존 RUN 결과를 덮어쓰지 않을 것
- 실패한 실행의 원본도 보존할 것
Codex가 만든 jq 코드에서 발견한 오류
💡셸 문법이 정상이어도 문자열 안의 jq 프로그램은 별도로 검증해야 한다
자동화 스크립트로 부하 테스트와 스냅샷 수집은 끝났지만, 최종 결과를 만드는 단계에서 다음 오류가 발생했다.
jq: error: syntax error, unexpected if
jq: error: May need parentheses around object key expression
jq: 3 compile errors
원인은 객체의 값으로 사용한 if-then-else-end 표현식 전체를 괄호로 감싸지 않은 것이었다.
# 오류가 발생한 형태
{
lockWaitMs:
if .delta.waitAcquired > 0 then
1
else
null
end
}
# 수정한 형태
{
lockWaitMs:
(if .delta.waitAcquired > 0 then
1
else
null
end)
}
괄호는 계산 결과를 바꾸지 않는다. if 표현식 전체가 객체 필드의 값이라는 범위를 jq 파서에 알려주는 역할을 한다.
이 오류는 bash -n으로 발견할 수 없었다. Bash 입장에서는 jq 프로그램 전체가 문자열이므로 셸 문법만 검사하고, 문자열 내부의 jq 문법까지 해석하지 않기 때문이다.
다행히 k6 결과와 스냅샷 A·B를 별도 파일로 보존하고 있었다. 부하 테스트를 다시 실행하지 않고 수정한 jq 계산 명령만 기존 원본에 적용해 verdict.json을 복구할 수 있었다.
최종 측정 결과
💡같은 조건으로 개선 전후를 각각 3회 측정하고 지표별 중앙값을 비교했다
| 지표 | 개선 전 | 개선 후 | 변화 |
| 성공 TPS | 151.60 | 166.53 | 9.85% 증가 |
| API 평균 응답시간 | 1,334.35ms | 1,223.27ms | 8.32% 감소 |
| API p99 | 2,762.12ms | 2,511.86ms | 9.06% 감소 |
| 평균 락 점유시간 | 6.10ms | 5.46ms | 10.58% 감소 |
- 측정 결과 재고 정합성과 관계없는 상품 조회와 응답 생성이 락 내부에서 실행되고 있음을 확인했다. 두 작업을 락 밖으로 옮기고, 락 안에는 재고 조회와 차감만 남겼다.
- 리팩토링 전후 6회는 모두 VALID 판정을 받았다. 락 타임아웃, 예상 밖 실패, 인터럽트와 락 소유권 상실은 0건이었으며, 성공 요청 수와 실제 재고 차감량도 일치했다.
- 한 번의 결과만 선택하지 않고 같은 조건에서 3번 측정한 뒤 지표별 중앙값을 사용했다. 다만 로컬 환경에서 수행한 3회 측정만으로 모든 환경에서 같은 개선율을 보장할 수는 없으므로, 이번 결과는 동일한 로컬 환경에서 개선 방향을 확인한 값으로 해석했다.
배운 점
💡자동화의 목적은 명령을 줄이는 데 그치지 않고 같은 조건과 판단 기준을 반복하는 데 있다
- 성능 비교에서는 코드 변경뿐 아니라 초기 데이터와 실행 시간, 웜업, 서버 프로세스 등 측정 조건을 통제해야 한다.
- 반복해야 할 명령과 조건이 많다면 수동 실행을 먼저 반복하기보다 자동화 가능한 절차로 설계하는 편이 실수를 줄일 수 있다.
- HTTP 성공 여부만으로 동시성 정합성을 판단할 수 없으며 성공 건수와 실제 데이터 변경량을 함께 확인해야 한다.
- Micrometer의 누적값은 스냅샷 차분으로 측정 구간을 분리할 수 있지만, MAX처럼 차분할 수 없는 값도 있다.
- 정책상 허용된 실패와 예상하지 못한 실패를 구분해야 테스트 결과를 올바르게 해석할 수 있다.
- 원본 측정 파일과 최종 계산 결과를 분리하면 계산 단계가 실패해도 부하 테스트를 다시 실행하지 않고 복구할 수 있다.
- AI가 코드 작성과 실행을 맡더라도 계산식과 단위, 실패 경로와 결과의 타당성을 판단하는 책임은 개발자에게 있다.
다음 개선 과제로는 저장된 JSON을 입력으로 jq 계산만 검증하는 테스트를 추가하고, 실패한 단계부터 자동으로 재개할 수 있도록 실행 단계를 분리하려 한다. 현재 스크립트는 원본 파일을 이용해 계산 결과를 복구할 수 있지만, 중간 단계부터 자동으로 재개하는 기능은 제공하지 않는다.
참고자료
'Projects > hub-eleven' 카테고리의 다른 글
| [리팩토링] 재고 API - (3) 동시성 처리 최적화 (0) | 2026.09.02 |
|---|---|
| [트러블슈팅] 재고 API - (3) 동시성 처리 최적화 : k6 자동 측정 스크립트의 jq 문법 오류 해결 (1) | 2026.08.04 |
| [리팩토링] 재고 API - (3) 동시성 처리 최적화 : Micrometer 도입 (0) | 2026.08.02 |
| [리팩토링] 재고 API - (3) 동시성 처리 최적화 : Micrometer 란? (0) | 2026.07.30 |
| [부하테스트] 재고 API - (2) k6 부하 테스트 환경과 개발 환경 분리 (0) | 2026.07.29 |