본문 바로가기

백엔드 엔지니어링 일지

마켓 백엔드 엔진 14 : k6 검색 로드 테스트 - 2

 

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 에 대한 동시성 테스트를 진행하려고 합니다.

향후 조회성능에서도 튜닝할 수 있는 부분이 더 있는지 계속 알아보겠습니다.