k6 스크립트를 좀 더 현실에 가깝게 수정했습니다.
import http from "k6/http";
import { check, sleep } from "k6";
/**
* /api/products 고 RPS 부하 (v3) — 소수 VU, 극소 sleep, 요청 수 극대화
*
* v2 대비: VU↓, think time↓, constant-arrival-rate로 초당 요청 수 직접 제어
*
* 기본: k6 run scripts/k6/search_v3.js
* RPS 조절: k6 run -e TARGET_RPS=900 -e DURATION=5m scripts/k6/search_v3.js
* VU 상한: k6 run -e MAX_VUS=120 -e PREALLOCATED_VUS=24 scripts/k6/search_v3.js
*/
const BASE_URL = __ENV.BASE_URL || "http://localhost:8080";
const SLOW_MS = Number(__ENV.SLOW_MS || 100);
const LOG_SLOW = __ENV.LOG_SLOW !== "0";
const SIZE = Number(__ENV.SIZE || 12);
// 극소 think (0~20ms). 0으로 고정: THINK_MIN_MS=0 THINK_MAX_MS=0
const THINK_MIN_MS = Number(__ENV.THINK_MIN_MS ?? 0);
const THINK_MAX_MS = Number(__ENV.THINK_MAX_MS ?? 20);
// 초당 목표 요청 수 (simple:complex:keyword = 3:1:1). 기본 600/s (이전 200/s의 3배)
const TARGET_RPS = Number(__ENV.TARGET_RPS || 1200);
const DURATION = __ENV.DURATION || __ENV.HOLD || "3m";
const PREALLOCATED_VUS = Number(__ENV.PREALLOCATED_VUS || 12);
const MAX_VUS = Number(__ENV.MAX_VUS || 120);
const SIMPLE_RATE = Math.max(1, Math.floor((TARGET_RPS * 3) / 5));
const COMPLEX_RATE = Math.max(1, Math.floor(TARGET_RPS / 5));
const KEYWORD_RATE = Math.max(1, TARGET_RPS - SIMPLE_RATE - COMPLEX_RATE);
export function setup() {
console.log(
`[k6 v3] TARGET_RPS=${TARGET_RPS} → simple=${SIMPLE_RATE}/s, complex=${COMPLEX_RATE}/s, keyword=${KEYWORD_RATE}/s`
);
console.log(
`[k6 v3] VUs preAllocated=${PREALLOCATED_VUS}, max=${MAX_VUS} | think ${THINK_MIN_MS}~${THINK_MAX_MS}ms | ${DURATION}`
);
}
// ----------------------------------------------------------------------
// 데이터셋 (v2와 동일)
// ----------------------------------------------------------------------
const GENDERS = ["MEN", "WOMEN", "UNISEX"];
const WEIGHTED_CATEGORIES = [
{ name: "TOP", weight: 0.40 },
{ name: "BOTTOM", weight: 0.30 },
{ name: "OUTER", weight: 0.15 },
{ name: "SHOES", weight: 0.10 },
{ name: "HAT", weight: 0.03 },
{ name: "GLASSES", weight: 0.02 },
];
const WEIGHTED_COLORS = [
{ name: "BLACK", weight: 0.40 },
{ name: "WHITE", weight: 0.25 },
{ name: "GRAY", weight: 0.15 },
{ name: "NAVY", weight: 0.10 },
{ name: "BEIGE", weight: 0.05 },
{ name: "BLUE", weight: 0.02 },
{ name: "RED", weight: 0.02 },
{ name: "GREEN", weight: 0.01 },
];
const WEIGHTED_BRANDS = [
{ name: "Nike", weight: 0.35 },
{ name: "Adidas", weight: 0.20 },
{ name: "The North Face", weight: 0.15 },
{ name: "New Balance", weight: 0.10 },
{ name: "Puma", weight: 0.05 },
{ name: "Under Armour", weight: 0.05 },
{ name: "Asics", weight: 0.05 },
{ name: "Patagonia", weight: 0.05 },
];
const WEIGHTED_KEYWORDS = [
{ name: "Nike", weight: 0.25 },
{ name: "Adidas", weight: 0.20 },
{ name: "Hoodie", weight: 0.15 },
{ name: "Sneakers", weight: 0.15 },
{ name: "Jacket", weight: 0.10 },
{ name: "T-Shirt", weight: 0.05 },
{ name: "Running", weight: 0.05 },
{ name: "Carhartt", weight: 0.05 },
];
const KEYWORD_TYPOS = ["Nikke", "Nkie", "Adidass", "Pumma", "Hoodi", "Sneeker", "Jaket"];
const KEYWORD_COMBOS = [
"Nike Hoodie", "Adidas MEN", "Nike Running", "Puma Sneakers", "North Face Jacket",
"Under Armour Shirt", "New Balance Shoes", "Converse WOMEN",
];
const USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36",
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Linux; Android 13; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36",
];
// ----------------------------------------------------------------------
// 유틸
// ----------------------------------------------------------------------
function getHeaders() {
return {
"User-Agent": pick(USER_AGENTS),
Accept: "application/json, text/plain, */*",
"Cache-Control": "no-cache",
};
}
function query(params) {
const parts = [];
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== "") parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
}
return parts.length ? `?${parts.join("&")}` : "";
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function weightedPick(items) {
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
for (const item of items) {
if (random < item.weight) return item.name;
random -= item.weight;
}
return items[items.length - 1].name;
}
function randomPage() {
const r = Math.random();
if (r < 0.70) return 0;
if (r < 0.90) return 1;
// Cap deep pagination: 0~2 only (avoid ES offset paging tail explosion on page>=3)
return 2;
}
function priceBand() {
const r = Math.random();
if (r < 0.55) return { minPrice: 40000, maxPrice: 180000 };
if (r < 0.8) return { minPrice: 10000, maxPrice: 50000 };
if (r < 0.93) return { minPrice: 150000, maxPrice: 350000 };
return { minPrice: 20000, maxPrice: 400000 };
}
function think() {
if (THINK_MAX_MS <= 0) return;
const ms =
THINK_MIN_MS + Math.floor(Math.random() * (THINK_MAX_MS - THINK_MIN_MS + 1));
if (ms > 0) sleep(ms / 1000);
}
function getProducts(params, name) {
const path = `/api/products${query(params)}`;
const res = http.get(`${BASE_URL}${path}`, {
headers: getHeaders(),
tags: { name },
});
if (LOG_SLOW && res.timings.duration >= SLOW_MS) {
const ms = Math.round(res.timings.duration);
console.log(`[SLOW] ${name} | ${ms}ms | ${res.status} | ${path}`);
}
check(res, {
[`${name} status 200`]: (r) => r.status === 200,
[`${name} success`]: (r) => r.json("success") === true,
[`${name} items array`]: (r) => Array.isArray(r.json("data.items")),
});
}
function arrivalScenario(rate, exec, scenario) {
return {
executor: "constant-arrival-rate",
rate,
timeUnit: "1s",
duration: DURATION,
preAllocatedVUs: PREALLOCATED_VUS,
maxVUs: MAX_VUS,
exec,
tags: { scenario },
};
}
export const options = {
scenarios: {
simple: arrivalScenario(SIMPLE_RATE, "simpleBrowse", "simple"),
complex_filters: arrivalScenario(COMPLEX_RATE, "complexFiltersRandom", "complex"),
keyword_search: arrivalScenario(KEYWORD_RATE, "keywordSearchRandom", "keyword"),
},
};
// ----------------------------------------------------------------------
// 실행 함수 (v2 패턴 유지, think만 극소)
// ----------------------------------------------------------------------
export function simpleBrowse() {
const page = randomPage();
const r = Math.random();
if (r < 0.68) getProducts({ page, size: SIZE, sortBy: "LATEST" }, "simple-latest");
else if (r < 0.88)
getProducts(
{ page, size: SIZE, sortBy: "LATEST", category: weightedPick(WEIGHTED_CATEGORIES) },
"simple-category"
);
else if (r < 0.96) getProducts({ page, size: SIZE, sortBy: "POPULARITY" }, "simple-popularity");
else
getProducts(
{
page,
size: SIZE,
sortBy: "POPULARITY",
category: weightedPick(WEIGHTED_CATEGORIES),
},
"simple-cat-pop"
);
think();
}
const COMPLEX_PROFILES = [
{ w: 0.2, tag: "complex-kw-only", extra: () => ({ keyword: weightedPick(WEIGHTED_KEYWORDS) }) },
{ w: 0.14, tag: "complex-cat-only", extra: () => ({ category: weightedPick(WEIGHTED_CATEGORIES) }) },
{
w: 0.12,
tag: "complex-cat-brand",
extra: () => ({
category: weightedPick(WEIGHTED_CATEGORIES),
brand: weightedPick(WEIGHTED_BRANDS),
}),
},
{
w: 0.1,
tag: "complex-cat-gender-color",
extra: () => ({
category: weightedPick(WEIGHTED_CATEGORIES),
gender: pick(GENDERS),
color: weightedPick(WEIGHTED_COLORS),
}),
},
{ w: 0.08, tag: "complex-price", extra: priceBand },
{
w: 0.08,
tag: "complex-kw-cat",
extra: () => ({
keyword: weightedPick(WEIGHTED_KEYWORDS),
category: weightedPick(WEIGHTED_CATEGORIES),
}),
},
{
w: 0.08,
tag: "complex-brand-gender",
extra: () => ({ brand: weightedPick(WEIGHTED_BRANDS), gender: pick(GENDERS) }),
},
{
w: 0.08,
tag: "complex-stack",
extra: () => ({
category: weightedPick(WEIGHTED_CATEGORIES),
brand: weightedPick(WEIGHTED_BRANDS),
gender: pick(GENDERS),
color: weightedPick(WEIGHTED_COLORS),
...priceBand(),
}),
},
{
w: 0.06,
tag: "complex-kw-price",
extra: () => ({ keyword: weightedPick(WEIGHTED_KEYWORDS), ...priceBand() }),
},
{
w: 0.06,
tag: "complex-full",
extra: () => ({
keyword: weightedPick(WEIGHTED_KEYWORDS),
category: weightedPick(WEIGHTED_CATEGORIES),
brand: weightedPick(WEIGHTED_BRANDS),
gender: pick(GENDERS),
color: weightedPick(WEIGHTED_COLORS),
...priceBand(),
}),
},
];
export function complexFiltersRandom() {
const base = { page: randomPage(), size: SIZE, sortBy: Math.random() < 0.88 ? "LATEST" : "POPULARITY" };
let u = Math.random();
for (const p of COMPLEX_PROFILES) {
if ((u -= p.w) < 0) {
getProducts({ ...base, ...p.extra() }, p.tag);
think();
return;
}
}
const last = COMPLEX_PROFILES[COMPLEX_PROFILES.length - 1];
getProducts({ ...base, ...last.extra() }, last.tag);
think();
}
export function keywordSearchRandom() {
const base = { page: randomPage(), size: SIZE, sortBy: Math.random() < 0.85 ? "LATEST" : "POPULARITY" };
const u = Math.random();
if (u < 0.4) getProducts({ ...base, keyword: pick(KEYWORD_TYPOS) }, "kw-typo");
else if (u < 0.8) getProducts({ ...base, keyword: pick(KEYWORD_COMBOS) }, "kw-combo");
else getProducts({ ...base, keyword: weightedPick(WEIGHTED_KEYWORDS) }, "kw-single");
think();
}
- 대기 시간(Think Time)을 봇 수준인 200~900ms에서 실제 사람처럼 2초~8초로 상향 조정했습니다.
- 고정된 0페이지 조회 → randomPage() 2페이지 이상의 조회가 일어날 확률을 약 30% 부여했습니다.
- 필터에 대해서 weightedPick() 함수를 사용했습니다. 나이키나 운동화 같은 인기 필터에 트래픽이 몰립니다.
- 스파이크(Spike) 테스트 스테이지: 웜업 → 평시 트래픽 → spike 트래픽 2배 → 회복
- 헤더(User-Agent) 추가: PC/iOS/Android 브라우저의 User-Agent를 무작위로 주입하는 getHeaders()를 추가했습니다.


VUS 50 - peak 100
total req = 1896, peak RPS = 2.71 req/s
100명 수준까지는 p99가 10ms - 200ms로 훌륭한 수치가 나왔습니다.
VUS를 더욱 올려봤습니다.


VUS를 10배로 늘린 수치입니다.
VUS 500 - peak 1000
total req = 18553, peak RPS = 85.2 req/s
p99가 200ms - 500ms로, 1000명 수준까지는 정말 괜찮은 성능을 보여줍니다.
그런데 그래프를 보면, peak 부분에서 RPS 는 동일한데 약간 대기시간이 튑니다.
1000명부터는 병목이 있다는건데요.

http_req_waiting 수치가 병목의 원인입니다. 즉 서버 외부의 문제는 아닙니다.
grafana - hikariCP 대시보드

첫번째로 의심되는 부분입니다.
peak time에 pending threads, 즉 풀을 얻지 못하고 대기중인 스레드가 85개입니다.
즉 connection pool 정책에서 병목이 생기고 있습니다. pool size를 더 늘려보겠습니다.
커넥션 풀 사이즈는 늘린다고 좋은것은 아닙니다.
보수적으로 조정하며 적절한 수치를 찾는것이 더 좋습니다.


pool size를 15, 20, 30 으로 조정해봤는데요.
오히려 pending thread가 늘어나고 성능이 떨어졌습니다.
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
25a67a7559dc backend-engineering-frontend-1 0.00% 64.42MiB / 15.56GiB 0.40% 480kB / 49.5kB 0B / 4.1kB 34
d5f96e66c3a1 backend-engineering-backend-1 14.54% 638.8MiB / 15.56GiB 4.01% 106MB / 88.6MB 147kB / 688kB 159
d6b7cd55597f backend-engineering-redis-1 0.79% 8.207MiB / 15.56GiB 0.05% 17MB / 217MB 22.3MB / 360kB 6
3b2e8f818ea2 backend-engineering-elasticsearch-1 894.81% 1.077GiB / 15.56GiB 6.92% 37.3MB / 255MB 923MB / 9.88MB 115
f6750fe011a4 backend-engineering-grafana-1 0.85% 108.7MiB / 15.56GiB 0.68% 7.3MB / 24.6MB 283MB / 6.91MB 27
e0809c82c419 backend-engineering-prometheus-1 0.00% 36.94MiB / 15.56GiB 0.23% 31.6MB / 3.82MB 81MB / 7.41MB 18
6f6f1ecea0bc backend-engineering-postgres-1 0.00% 48.29MiB / 15.56GiB 0.30% 726kB / 480kB 51.3MB / 307kB 16
docker의 CPU 사용량을 확인해봤습니다.
병목은 ElasticSearch 였네요. pool size가 문제가 아니었습니다.
{"@timestamp":"2026-05-27T08:12:07.961Z", "log.level": "WARN", "message":"absolute clock went
backwards by [284ms/284ms] while timer thread was sleeping", "ecs.version": "1.2.0","service.
name":"ES_ECS","event.dataset":"elasticsearch.server","process.thread.name":"elasticsearch
[3b2e8f818ea2][[timer]]","log.logger":"org.elasticsearch.threadpool.ThreadPool","elasticsear
ch.cluster.uuid":"f8r6YaweRLma9_TCc8iZgg","elasticsearch.node.id":"d5KydyccSbGWAvjF918V6A","
elasticsearch.node.name":"3b2e8f818ea2","elasticsearch.cluster.name":"docker-cluster"}
ES에 이런 로그가 수십개 찍혀있었습니다.
absolute clock went backwards 로그는 심각한 GC 부하로 인한 스레드 멈춤 현상을 의미합니다.
ES_JAVA_OPTS를 통해 ES의 가용 메모리를 늘려 주겠습니다.



개선된 것 (연쇄 장애 완화)
- DB 커넥션 대기 병목 완화
- RPS가 VU에 정비례:pool/ES blocking 때문에 VU를 올려도 처리량이 잘 안 늘었을 가능성이 큽니다. 지금은 peak 타임에 VU가 늘어난만큼 RPS도 함께 늘어나고 있습니다.
여전한 것 (1차 병목)
- CPU 여전히 높음: ES가 여전히 연산 병목입니다. pending이 줄어도 ES CPU가 포화면 결국 느려집니다.
악화된 것 (꼬리 지연)
- p99 1s → 2s: 처리량(RPS)은 늘었지만, 느린 요청의 꼬리가 더 길어짐.
- 동시 요청이 더 많이 ES까지 도달
- ES CPU 포화 상태에서 대기열이 길어져 p99/p95가 악화
- Redis 캐시 미스 요청(필터 조합, page>0, brand/gender/color 등)은 여전히 ES full cost
시스템 병목은 해결했지만, ES 과부하는 그대로라 tail latency는 나빠진 것으로 보입니다.
그런데 pending이 왜 걸릴까요? pending은 DB transaction에 대한 대기입니다.
ES에서 검색을 도맡아하기때문에, 사실 DB transaction을 걸 이유가 없습니다.
ES를 도입하면서 service 부분의 이 코드를 수정하지 않아서 계속 pending이 걸렸다는것을 지금 알았습니다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public ProductPageResponse list(
...
그럼 이제 결과가 어떨까요?

pending이 전혀 없습니다. ;;
이제 저는 캐싱 대상을 더 확장했습니다.
@Transactional
@CacheEvict(cacheNames = RedisCacheConfig.PRODUCT_LIST_FEED, allEntries = true)
public void delete(Long productId) {
Product product = findProduct(productId);
productRepository.delete(product);
productRepository.flush();
productSearchIndexer.delete(productId);
}
private static final int FEED_PAGE_SIZE = 12;
private static final int MAX_CACHED_PAGE = 2;
private boolean isCacheableFeedRequest(
String keyword,
ProductCategory category,
String brand,
String gender,
String color,
Integer minPrice,
Integer maxPrice,
String sortBy,
int page,
int size
) {
if (size != FEED_PAGE_SIZE || page < 0 || page > MAX_CACHED_PAGE) {
return false;
}
if (!("LATEST".equals(sortBy) || "POPULARITY".equals(sortBy))) {
return false;
}
boolean hasKeyword = hasText(keyword);
boolean hasCategory = category != null;
boolean hasBrand = hasText(brand);
boolean hasGender = hasText(gender);
boolean hasColor = hasText(color);
boolean hasPriceBand = minPrice != null || maxPrice != null;
if (!hasBrand && !hasGender && !hasColor) {
return matchesCacheableFeedWithoutFacetFilters(
hasKeyword, hasCategory, hasPriceBand
);
}
return matchesCacheableFeedWithFacetFilters(
hasKeyword, hasCategory, hasBrand, hasGender, hasColor, hasPriceBand
);
}
private static boolean matchesCacheableFeedWithoutFacetFilters(
boolean hasKeyword,
boolean hasCategory,
boolean hasPriceBand
) {
if (!hasKeyword && !hasCategory && !hasPriceBand) {
return true;
}
if (hasCategory && !hasKeyword && !hasPriceBand) {
return true;
}
if (hasKeyword && !hasCategory && !hasPriceBand) {
return true;
}
if (!hasKeyword && !hasCategory && hasPriceBand) {
return true;
}
if (hasKeyword && !hasCategory && hasPriceBand) {
return true;
}
return hasKeyword && hasCategory && !hasPriceBand;
}
private static boolean matchesCacheableFeedWithFacetFilters(
boolean hasKeyword,
boolean hasCategory,
boolean hasBrand,
boolean hasGender,
boolean hasColor,
boolean hasPriceBand
) {
if (hasCategory && hasBrand && !hasKeyword && !hasPriceBand && !hasGender && !hasColor) {
return true;
}
if (hasCategory && hasGender && hasColor && !hasKeyword && !hasBrand && !hasPriceBand) {
return true;
}
if (hasBrand && hasGender && !hasCategory && !hasKeyword && !hasPriceBand && !hasColor) {
return true;
}
if (hasCategory && hasBrand && hasGender && hasColor && hasPriceBand && !hasKeyword) {
return true;
}
return hasKeyword && hasCategory && hasBrand && hasGender && hasColor && hasPriceBand;
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
package com.marketengine.backend.product.application;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.stereotype.Service;
import com.marketengine.backend.common.config.RedisCacheConfig;
import com.marketengine.backend.product.api.ProductDtos.ProductPageResponse;
import com.marketengine.backend.product.api.ProductDtos.ProductSummaryResponse;
import com.marketengine.backend.product.domain.ProductCategory;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class ProductListFeedCache {
private final ProductListSearcher productListSearcher;
@Cacheable(
cacheNames = RedisCacheConfig.PRODUCT_LIST_FEED,
key = "#sortBy + ':' + #page + ':' + #size"
+ " + ':' + (#category == null ? '-' : #category.name())"
+ " + ':' + (#keyword == null ? '-' : #keyword)"
+ " + ':' + (#brand == null ? '-' : #brand)"
+ " + ':' + (#gender == null ? '-' : #gender)"
+ " + ':' + (#color == null ? '-' : #color)"
+ " + ':' + (#minPrice == null ? '-' : #minPrice)"
+ " + ':' + (#maxPrice == null ? '-' : #maxPrice)"
)
public ProductPageResponse get(
String keyword,
ProductCategory category,
String brand,
String gender,
String color,
Integer minPrice,
Integer maxPrice,
String sortBy,
int page,
int size
) {
Pageable pageable = PageRequest.of(page, size);
Slice<ProductSummaryResponse> pageResult = productListSearcher.search(
keyword,
category,
brand,
gender,
color,
minPrice,
maxPrice,
sortBy,
pageable
);
return ProductPageResponse.from(pageResult);
}
}
추가 대상:
category+brand
category+gender+color
brand+gender
category+brand+gender+color+price
keyword+category+brand+gender+color+price (full)
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
e0809c82c419 backend-engineering-prometheus-1 0.26% 45.98MiB / 15.56GiB 0.29% 129MB / 23.6MB 94.8MB / 33.1MB 18
6f6f1ecea0bc backend-engineering-postgres-1 0.00% 47.19MiB / 15.56GiB 0.30% 1.5MB / 1.07MB 98.8MB / 307kB 16
652902e3b7ca backend-engineering-redis-1 8.04% 26.71MiB / 15.56GiB 0.17% 356MB / 3.92GB 9.09MB / 56.4MB 8
f6750fe011a4 backend-engineering-grafana-1 0.18% 116.9MiB / 15.56GiB 0.73% 10.8MB / 23.6MB 4.37MB / 7.28MB 25
ee55a47b12bb backend-engineering-elasticsearch-1 43.40% 1.088GiB / 15.56GiB 6.99% 31.1MB / 164MB 232MB / 4.92MB 115
9277ec50b4a6 backend-engineering-backend-1 81.57% 860.3MiB / 15.56GiB 5.40% 1.66GB / 1.65GB 93.5MB / 586kB 122
4fccefeb9d9e backend-engineering-frontend-1 0.00% 65.54MiB / 15.56GiB 0.41% 375kB / 42.2kB 15.4MB / 4.1kB 34
peak 시간대 ES CPU 로드율이 크게 떨어졌습니다.
redis memory info:
used_memory_human:3.82M
keyspace_hits:28244
keyspace_misses:1234
캐시 적중률: 약 95%
레디스 메모리 사용량도 3.8M 입니다. 확인해보니 가용량에 비해서 매우 작은 양입니다.
즉, 훨씬 더 강력한 캐싱 전략 운용도 가능하다는 이야기입니다.
ramping_vus 0-15000 운용

가상 유저 수가 15000명을 넘어가면 에러율이 급증합니다.

단일 머신에서는 가상 유저 수 한계에 봉착했군요.
이제 가상 유저 수를 제한하고 Target RPS 테스트를 하겠습니다.


Target_RPS = 5000
p(90)=85ms p(95)=137ms p(99)=약 200ms


Target_RPS = 10000
p(90)=764ms p(95)=822ms p(99)=약 950ms
Peak RPS = 6.35K
1363건의 fail 발생

가동중인 Tomcat thread가 max에 붙었습니다.
server:
tomcat:
threads:
max: ${SERVER_TOMCAT_THREADS_MAX:400}
min-spare: ${SERVER_TOMCAT_THREADS_MIN_SPARE:50}
400까지 올려봅니다.
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
e0809c82c419 backend-engineering-prometheus-1 0.43% 67.37MiB / 15.56GiB 0.42% 148MB / 26.2MB 95.3MB / 42.6MB 18
6f6f1ecea0bc backend-engineering-postgres-1 0.05% 48.46MiB / 15.56GiB 0.30% 1.62MB / 1.16MB 98.8MB / 307kB 16
652902e3b7ca backend-engineering-redis-1 21.20% 37.32MiB / 15.56GiB 0.23% 650MB / 7.78GB 9.09MB / 116MB 8
f6750fe011a4 backend-engineering-grafana-1 0.51% 121.1MiB / 15.56GiB 0.76% 17.1MB / 38MB 4.83MB / 11.1MB 25
ee55a47b12bb backend-engineering-elasticsearch-1 231.95% 1.092GiB / 15.56GiB 7.02% 54.5MB / 248MB 290MB / 8.48MB 115
44227a4b8a1e backend-engineering-backend-1 428.53% 1.012GiB / 15.56GiB 6.50% 721MB / 735MB 0B / 123kB 454
7f4dd4771e7d backend-engineering-frontend-1 0.00% 65.49MiB / 15.56GiB 0.41% 416kB / 26.6kB 0B / 4.1kB 34


성능은 약간 떨어지나, 1300건의 오류가 사라졌습니다.
제 캐싱 전략은 테스트 시나리오에 거의 모든 조합을 커버합니다.
캐싱을 지우고 콜드 캐시 테스트도 진행했습니다.
RPS 5000 콜드 캐시 테스트

대다수 조합의 캐싱이 되기 전 약 1분간은 500ms -> 100ms로 점점 조회속도가 올라가고,
이후 2번째 테스트부터는 약 50ms로 안정적인 조회가 됩니다.
이때 캐시를 hit하지 못하는 조합은 대부분 모든 필터를 적용한 complex 필터로 복잡성이 너무 높은 필터 조합입니다.
웜업 전 : 500ms
이후 : 100ms
TARGET_RPS 최대 콜드 캐시 테스트

웜업 전 : 4s
웜업 후 : 1s
최종 성능 (캐시 웜업 후)
5000 RPS시 성능: p(90)=85ms p(95)=137ms p(99)=약 200ms
최대 RPS: 6.35K req/s
초당 6000건까지는 안정적으로 처리할 수 있으며, 그 이상 부터는 조금씩 느려집니다.
6000RPS는 어느정도일까요?
만약 사용자가 5초에 한번 클릭한다면, 동시접속 3만명 정도를 안정적으로 유지할 수 있는 시스템입니다.
이제 저는 주문 API 에 대한 동시성 테스트를 진행하려고 합니다.
향후 조회성능에서도 튜닝할 수 있는 부분이 더 있는지 계속 알아보겠습니다.
'백엔드 엔지니어링 일지' 카테고리의 다른 글
| 마켓 백엔드 엔진 15 : 주문 시스템 개발 (트랜잭션, JUnit) (0) | 2026.06.02 |
|---|---|
| 마켓 백엔드 엔진 13 : ElasticSearch - 2 / Redis 캐싱 (0) | 2026.05.28 |
| 마켓 백엔드 엔진 12 : ElasticSearch - 1 (0) | 2026.05.26 |
| 마켓 백엔드 엔진 11 : k6 검색 로드 테스트 - 1 (0) | 2026.05.22 |
| 마켓 백엔드 엔진 10 : 키워드 조합/유사도 검색 - pg_trgm (0) | 2026.05.18 |