Kotlin & Java

JVM Garbage Collection Tuning

상쾌한기분 2025. 4. 10. 13:59
반응형

JVM Garbage Collection Tuning

Spring boot + Kotlin GC 튜닝 

 Spring Boot + Kotlin을 기반으로 GC 튜닝을 실습하고 모니터링을 통해서 아웃풋이 어떻게 변화되는지 모니터링 해보자.
우선 스프링부트 사용시 가장 일반적으로 많이 사용될 스택과 코드 구성을 구성했다.

  • Backend: Spring Boot + Kotlin
  • Database: MySQL, Redis
  • Monitoring: Micrometer + Prometheus + Grafana
  • Containerization: Docker

Service

일반적으로 많이 사용되고 간단하게 구성을 했다.

@Service
class OrderService(
    private val productRepository: ProductRepository,
    private val orderRepository: OrderRepository,
) {

    @CacheEvict(value = ["orders"])
    @Transactional
    fun createOrder(): Order {
        val product = productRepository.findAll().random()
        val quantity = Random.nextInt(1, 10)
        val order = OrderEntity(
            orderNumber = Instant.now().toEpochMilli().toString(),
            amount = product.price * quantity.toBigDecimal(),
            quantity = quantity,
            createdAt = Instant.now(),
            product = product
        )
        return orderRepository.save(order).let {
            Order.valueOf(it)
        }
    }

    @Cacheable(value = ["orders"])
    @Transactional(readOnly = true)
    fun getOrders(pageRequest: PageRequest): List<Order> {
        return orderRepository.findAll(pageRequest).let { paged ->
            paged.content.map { Order.valueOf(it) }
        }
    }

    @Transactional(readOnly = true)
    fun getOrdersRealTime(pageRequest: PageRequest): List<Order> {
        return orderRepository.findAll(pageRequest).let { paged ->
            paged.content.map { Order.valueOf(it) }
        }
    }

    @Cacheable(value = ["orders"], key = "#orderId")
    @Transactional(readOnly = true)
    fun getOrder(orderId: Int): Order {
        val order = orderRepository.findByIdOrNull(orderId)
            ?: throw EntityNotFoundException("Order not found with id:$orderId")
        return Order.valueOf(order)
    }

    @CachePut(value = ["orders"], key = "#orderId")
    @Transactional
    fun completeOrder(orderId: Int): Order {
        val order = orderRepository.findByIdWithLock(orderId)
            ?: throw EntityNotFoundException("Order not found with id:$orderId")
        order.complete()
        return Order.valueOf(order)
    }
}

Dockerfile, Docker compose

Dockerfile.serial, Dockerfile.g1gc, ... 으로 실행 환경 옵션별로 Dockerfile을 생성 하였고 이미지 빌드 및 푸시를 한 후 환경별 이미지를 docker-compose.yaml 파일에 선언을 하였다.

FROM amazoncorretto:21.0.7 AS builder

WORKDIR /app
COPY . .
RUN ./gradlew clean build -x test

###################################

FROM amazoncorretto:21.0.7

WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar

ENV JAVA_TOOL_OPTIONS="-XX:+UseG1GC" # 환경별 옵션
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
version: "3.8"
name: about-gc-tuning

services:
  agt-mysql8:
    image: mysql:8.4
    container_name: agt-mysql8
    ports:
      - "10010:3306"
    environment:
      MYSQL_ROOT_PASSWORD: rootroot
      MYSQL_DATABASE: tuning
      MYSQL_USER: user
      MYSQL_PASSWORD: passw0rd
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 1G

  agt-redis:
    image: redis:latest
    container_name: agt-redis
    ports:
      - "10011:6379"
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 1G

  agt-app-serial:
    image: girr311/agt-app:serial
    container_name: agt-app-serial
    ports:
      - "10012:8080"
    depends_on:
      - agt-mysql8
      - agt-redis
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 2G

  agt-app-g1gc:
    image: girr311/agt-app:g1gc
    container_name: agt-app-g1gc
    ports:
      - "10013:8080"
    depends_on:
      - agt-mysql8
      - agt-redis
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 2G

  agt-app-zgc:
    image: girr311/agt-app:zgc
    container_name: agt-app-zgc
    ports:
      - "10014:8080"
    depends_on:
      - agt-mysql8
      - agt-redis
    deploy:
      resources:
        limits:
          cpus: '1'
          memory: 2G
  
  agt-prometheus:
    image: prom/prometheus:latest
    container_name: agt-prometheus
    ports:
      - "10020:9090"
    volumes:
      - .docker/prometheus:/etc/prometheus/
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 500M

  agt-grafana:
    image: grafana/grafana:latest
    container_name: agt-grafana
    ports:
      - "10030:3000"
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin
      - GF_USERS_ALLOW_SIGN_UP=false
    depends_on:
      - agt-prometheus
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 500M

Locust

부하 테스트 설정은 다음처럼 설정 하였다.

import collections
import random
from dataclasses import dataclass, field

from locust import FastHttpUser, between, task


@dataclass(frozen=True)
class Config:
    @dataclass(frozen=True)
    class TaskConfig:
        endpoint: str
        weight: int

    create_order: TaskConfig = field(
        default=TaskConfig(endpoint="/orders", weight=5),
    )
    get_orders: TaskConfig = field(
        default=TaskConfig(endpoint="/orders", weight=80),
    )
    get_orders_realtime: TaskConfig = field(
        default=TaskConfig(endpoint="/orders/realtime", weight=10),
    )
    get_order: TaskConfig = field(
        default=TaskConfig(endpoint="/orders/{order_id}", weight=10),
    )
    complete_order: TaskConfig = field(
        default=TaskConfig(endpoint="/orders/{order_id}/complete", weight=5),
    )


config = Config()


class MyUser(FastHttpUser):
    order_ids: collections.deque = None
    wait_time = between(1, 3)

    def on_start(self) -> None:
        self.order_ids = collections.deque(maxlen=100)

    @task(config.create_order.weight)
    def create_order(self):
        response = self.client.post(config.create_order.endpoint)
        if response.ok:
            self.order_ids.append(response.json()["id"])

    @task(config.get_orders.weight)
    def get_orders(self):
        self.client.get(config.get_orders.endpoint)

    @task(config.get_orders_realtime.weight)
    def get_orders_realtime(self):
        self.client.get(config.get_orders_realtime.endpoint)

    @task(config.get_order.weight)
    def get_order(self):
        if self.order_ids:
            order_id = random.choice(self.order_ids)
            self.client.get(
                config.get_order.endpoint.format(order_id=order_id),
                name=config.get_order.endpoint,
            )

    @task(config.complete_order.weight)
    def complete_order(self):
        if self.order_ids:
            self.order_ids.remove(order_id := random.choice(self.order_ids))
            self.client.post(
                config.complete_order.endpoint.format(order_id=order_id),
                name=config.complete_order.endpoint,
            )

Locust 실행 CLI

locust -f main.py \
    --master \
    --host http://127.0.0.1:10014 \
    --users 5000 \
    --spawn-rate 100 \
    --run-time 10m \
    --stop-timeout 2s

# in another terminal
locust -f main.py --worker

코드 구현 저장소

https://github.com/sanggi-wjg/kotlin-spring-boot-demos/tree/main/about-gc-tuning

 

kotlin-spring-boot-demos/about-gc-tuning at main · sanggi-wjg/kotlin-spring-boot-demos

Contribute to sanggi-wjg/kotlin-spring-boot-demos development by creating an account on GitHub.

github.com


1 Core + 2GB Memory 환경에서 GC 부하 테스트 결과 

GC 옵션별 하나하나 하기 보다는 전체적인 GC의 맥락을 이해하기 위해서 JVM 옵션으로 GC만 설정하여 테스트 하였고 10분간 5000명 유저까지 요청 하도록 부하 테스트 설정을 하고 진행 하였다.

GC RPS
Request per Second
ART
Average Response TIme
50th Percentile 기준
Serial GC 2000 peek 후 계속 우하향하여
900 까지 하락함
시간이 지남에 따라 계속해서 증가하여 
4000ms 까지 도달
G1 GC 600 ~ 700 유지 6000ms 유지
Z GC 500 ~ 600 유지 6000~8000ms 유지

초기 부하 테스트 결과, Seiral GC가 가장 높은 응답률과 낮은 지연 시간을 기록, 반면 최신 GC 알고리즘들은 예상보다 낮은 성능을 보였습니다. 

GC GC 수행 STW (pause time)
Serial GC Minor GC 약 1~1.5 ops/s  Major GC 약 500ms 지연 발생
Minor GC 약 50ms 지연 발생
G1 GC Minor GC 0.5 ops/s  Major GC는 발생하지 않았고 minor GC만 발생
20~100ms 지연 발생 
Z GC GC 0.2~ 0.8 ops/s  GC 수행시 1ms 지연 발생

JVM 모니터링 결과에서는 특이점 없이 예측 했던 결과를 보여주었다.


이러한 결과를 바탕으로 몇 가지 가정을 설정하고 분석을 진행했습니다

  1. 단일 코어 환경에서는 멀티-코어 멀티-쓰레드 GC의 장점이 나타나지 않았으며 오히려 컨텍스트 스위칭 및 오버헤드만 증가하여 성능 저하를 유발했다. 그렇다면 멀티 코어 환경에서 기본 설정값으로도 충분한 성능을 보일 것인가?
  2. Serial GC 경우 수치들이 계속해서 우하향 했는데 10분이 아니라 1시간 동안 부하 테스트 했다면 어떻게 될까?

Serial GC

G1 GC

Z GC

Serial GC (부하 테스트 40분)

Serial GC는 부하테스트 시간이 점점 길어짐에 따라서 RPS, ART 지표가 계속해서 안좋은 결과를 보여주었다.


3 Core + 6GB Memory 환경에서 GC 부하 테스트 결과 

JVM Application을 3 코어로 실행했지만 RPS가 크게 변화가 없었다. 원인은 주요 테스트 로직들이 MySQL, Redis를 사용하는 로직이고 아마 Redis가 싱글 스레드로 동작하여 눈에 띄는 변화가 없지 않을까 싶다.

Serial GC

Z GC

3 Core + 6GB Memory 환경에서 컴퓨팅 로직을 추가한 GC 부하 테스트 결과 

Redis 병목으로 유의미한 테스트가 안되는 것 같아서 아래 임의 로직을 추가 후 테스트 해보았다.

@Service
class LoadService {

    companion object {
        const val EMAIL_REGEX = "^([^@]+)@"
    }

    fun compute(): ComputeResult {
        val results = mutableListOf<Double>()
        for (i in 0 until 10_000) {
            val term = 4.0 * (1 - (i % 2) * 2) / (2 * i + 1)
            results.add(term)
        }
        val pi = results.sum()
        return ComputeResult(pi, results)
    }

    fun convert(people: People): PeopleAdapter {
        return PeopleAdapter(
            people.persons.map {
                PersonAdapter(
                    it.id,
                    it.name,
                    it.email,
                    extractEmailId(it.email)
                )
            }
        )
    }

    private fun extractEmailId(email: String): String {
        val regex = Regex(EMAIL_REGEX)
        val match = regex.find(email)
        return match?.groups?.get(1)?.value ?: email
    }
}
@dataclass(frozen=True)
class Config:
    @dataclass(frozen=True)
    class TaskConfig:
        endpoint: str
        weight: int

    create_order: TaskConfig = field(
        default=TaskConfig(endpoint="/orders", weight=5),
    )
    get_orders: TaskConfig = field(
        default=TaskConfig(endpoint="/orders", weight=20),
    )
    get_orders_realtime: TaskConfig = field(
        default=TaskConfig(endpoint="/orders/realtime", weight=10),
    )
    get_order: TaskConfig = field(
        default=TaskConfig(endpoint="/orders/{order_id}", weight=10),
    )
    complete_order: TaskConfig = field(
        default=TaskConfig(endpoint="/orders/{order_id}/complete", weight=5),
    )

    computing: TaskConfig = field(
        default=TaskConfig(endpoint="/load/computing", weight=30),
    )
    json_computing: TaskConfig = field(
        default=TaskConfig(endpoint="/load/json", weight=30),
    )

Serial GC를 도입하기 전, 기존 1 코어 환경과의 성능 차이에 대한 우려가 있었습니다. 이번 3코어 환경에서 부하 테스트 결과, Serial GC의 멀티코어 환경의 한계로 보이는 처리량과 응답 시간에서 차이를 확인했습니다.

특히, Serial GC의 RPS(초당 처리 요청 수)는 738로, Z GC의 986에 비해 현저히 낮았습니다. 이는 Serial GC가 병렬 처리 능력이 부족하여 전체적인 처리 속도에 영향을 미치고 있음을 시사합니다.

주목할 점은 Serial GC의 평균 응답시간이 G1 GC나 Z GC에 비해 훨씬 더 길었다는 것입니다. 이는 사용자 경험에 직접적인 영향을 미칠 수 있는 요소이므로, Serial GC 도입 후 추가적인 성능 개선이 필요함을 알 수 있었습니다.

 

Serial GC

G1 GC

ZGC

728x90
반응형