본문 바로가기

CS/JVM

[JVM] 내 Java 애플리케이션, 혹시 Serial GC로 동작하고 있진 않을까?

Spring Boot 애플리케이션을 Docker 컨테이너에 올려 운영하고 있다면, 한 가지 확인해볼 것이 있다. 바로 GC(Garbage Collector)가 의도한 대로 선택되어 동작하고 있는지다.

JVM은 실행 환경의 CPU 코어 수와 물리 메모리를 기반으로 GC를 자동 선택하는 Ergonomics라는 메커니즘을 갖고 있다. 문제는 Docker 컨테이너 환경에서 리소스 제한을 어떻게 거느냐에 따라, 개발자가 의도하지 않은 GC가 선택될 수 있다는 점이다.

 


1. 서버 클래스와 GC 자동 선택

JVM은 실행 환경을 서버 클래스(Server-Class)클라이언트 클래스(Client-Class)로 구분하고, 이에 따라 기본 GC를 자동 선택한다.

구분 조건 JDK 9+ 기본 GC
서버 클래스 CPU 2코어 이상 AND 물리 메모리 1,792MB 이상 G1 GC
클라이언트 클래스 위 조건을 충족하지 못하는 환경 Serial GC

요즘 대부분의 서버 환경에서는 별도 설정 없이 G1 GC가 적용된다. 참고로 JDK 8에서는 서버 클래스의 기본 GC가 Parallel GC였고, JDK 9부터 G1 GC로 변경되었다.

 

📌 이 기준은 JDK 5부터 도입되어 현재까지 동일하게 유지되고 있다.

 

JDK 17 공식 문서에는 이 기준이 없다?

한 가지 흥미로운 점이 있다. JDK 17 GC Tuning Guide를 보면 기본 GC가 "Garbage-First (G1) Collector"로만 기재되어 있고, 서버/클라이언트 클래스 구분이나 구체적인 조건에 대한 설명이 빠져있다.

반면 JDK 25 문서에서는 "서버 클래스 머신에서 G1, 그 외에는 Serial Collector"라고 명시하고 있다.

JDK 17에서 서버 클래스 개념이 제거된 것이 아니다. 대부분의 환경이 서버 클래스 조건을 충족하기 때문에 문서에서 생략한 것이고, 실제 JDK 17의 OpenJDK 소스코드(os.cpp)에는 동일한 판별 로직이 그대로 존재한다. 정확한 동작을 확인하려면 JDK 25 문서나 소스코드를 참조하자.

 


2. Docker 환경에서의 함정

핵심 질문은 이것이다: JVM이 보는 "물리 메모리"와 "CPU 코어"는 호스트 머신 기준인가, 컨테이너 기준인가?

컨테이너에 리소스 제한을 걸었으면 컨테이너 기준, 안 걸었으면 호스트 기준이다.

JDK 8u191 이후부터 JVM은 Linux cgroup을 인식하여 컨테이너의 리소스 제한을 읽어온다. 따라서:

     
시나리오 JVM이 인식하는 리소스 GC 선택
리소스 제한 없음 호스트 전체 리소스 서버 클래스 → G1 GC
mem_limit: 2g, cpus: 2 2GB, 2코어 서버 클래스 → G1 GC
mem_limit: 1g, cpus: 1 1GB, 1코어 클라이언트 클래스 → Serial GC

 

세 번째 시나리오가 위험하다. 개발자는 당연히 G1 GC가 동작할 거라 생각하지만, 실제로는 Serial GC가 선택되어 단일 스레드로 GC가 수행되면서 Stop-The-World 시간이 크게 증가한다.

⚠️ JDK 8u131 이전에는 컨테이너의 cgroup을 아예 인식하지 못했다. 이 경우 반대로 위험한데, 컨테이너에 1GB만 할당했어도 호스트의 64GB를 보고 힙을 16GB로 잡아버리는 식이다. JDK 8을 Docker에서 사용한다면 반드시 8u191 이상으로 업데이트해야 한다.

 


3. 실제 사례: TechFork 프로젝트

필자가 개발 중인 TechFork는 한국 기술 블로그를 RSS 기반으로 크롤링하여 통합 검색·추천을 제공하는 서비스다. Spring Boot 3.x + JDK 17 기반이며, Oracle Cloud VM(ARM, 4코어, 24GB) 위에 Docker Compose로 Elasticsearch, MySQL, Redis, Spring Boot를 함께 운영하고 있다.

 

현재 상태: 리소스 제한 없음

현재 docker-compose.yml에 컨테이너별 리소스 제한(mem_limit, cpus)을 명시하지 않은 상태다. JVM은 호스트의 전체 리소스(4코어, 24GB)를 인식하므로 서버 클래스로 판별되어 G1 GC가 정상 선택된다.

단, Spring Boot의 JVM 힙은 -Xms2g -Xmx2g로 명시적으로 설정해두었다. 힙 크기를 지정하지 않으면 JVM이 호스트 메모리의 1/4(약 6GB)을 최대 힙으로 잡아서, 같은 VM의 Elasticsearch나 MySQL과 메모리를 두고 경합하게 된다.

 

리소스 제한을 추가할 때 주의할 점

향후 컨테이너 간 리소스 격리를 위해 제한을 추가할 수 있다. 이때 서버 클래스 기준을 반드시 고려해야 한다.

❌ 위험한 설정

services:
  app-blue:
    mem_limit: 1g   # 1,792MB 미만 → 클라이언트 클래스
    cpus: '1'       # 2코어 미만 → 클라이언트 클래스
    environment:
      - JAVA_OPTS=-Xms512m -Xmx512m

이 설정에서는 JVM이 클라이언트 클래스로 판별하여 Serial GC가 선택된다.

 

✅ 안전한 설정 — 서버 클래스 조건 충족

services:
  app-blue:
    mem_limit: 3g   # 1,792MB 이상
    cpus: '2'       # 2코어 이상
    environment:
      - JAVA_OPTS=-Xms2g -Xmx2g

 

✅ 안전한 설정 — GC 명시적 지정

services:
  app-blue:
    mem_limit: 1g
    cpus: '1'
    environment:
      - JAVA_OPTS=-Xms512m -Xmx512m -XX:+UseG1GC -XX:ParallelGCThreads=1

리소스 제한이 서버 클래스 기준 미만이더라도, -XX:+UseG1GC를 명시하면 Ergonomics 자동 선택을 무시하고 G1 GC를 강제할 수 있다.

 


4. 현재 GC 확인하는 방법

운영 중인 애플리케이션의 GC가 무엇인지 확인하는 가장 간단한 방법은 jcmd를 사용하는 것이다.

# 컨테이너 내부에서 실행 중인 JVM의 GC 플래그 확인
docker exec -it <container> jcmd 1 VM.flags | grep -i "GC"

출력에서 +UseG1GC가 보이면 G1 GC, +UseSerialGC가 보이면 Serial GC가 동작 중이다.

 

JVM 시작 시 어떤 클래스로 판별되었는지 확인하려면 다음 명령을 사용한다:

java -XshowSettings:vm -version

출력에서 Ergonomics Machine Class: server 또는 client로 판별 결과를 확인할 수 있다.

 


마치며

Docker 컨테이너 환경에서 JVM 기반 애플리케이션을 운영할 때, GC 자동 선택에 의존하면 컨테이너 리소스 제한에 따라 의도치 않게 Serial GC가 선택될 수 있다. 운영 환경에서는 -XX:+UseG1GC를 명시적으로 지정하는 것이 가장 안전하다.


참고 자료