강의

멘토링

로드맵

인프런 커뮤니티 질문&답변

브로콜리피자님의 프로필 이미지
브로콜리피자

작성한 질문수

면접 전에 알고 가면 좋을 것들 - 신입 Java 백엔드 개발자편

JVM/GC 최적화 관련 질문드립니다!

해결된 질문

작성

·

11

·

수정됨

0

안녕하세요!

 

강의에서 GC와 JVM 메모리 관리에 대해 설명해주신 부분을 듣다가, 제가 겪었던 Thread Starvation 문제가 떠올라 질문을 남기게 되었습니다 🤓

 

제가 겪었던 상황에 대해서 정리해보겠습니다.

 

1차 문제 발생

[프로젝트 환경]

- Spring Boot 3.x, Java 21

- AWS EC2 t3.small (2 vCPU, 2GB RAM)

- Docker 컨테이너 배포

- 개발 서버 (비용 최적화 목적)

 

[문제 발생 과정]

초기에는 Docker 메모리 제한 없이 JVM만 -XX:MaxRAMPercentage=75.0로 설정했습니다. 카카오 소셜 로그인 기능을 추가한 후 배포 시 Thread Starvation 문제가 발생했습니다.

# 초기 설정 (문제 발생)

- Docker: 메모리 제한 없음

- JVM: -XX:MaxRAMPercentage=75.0 (호스트 2GB의 75% = 1.5GB)

 

[결과]

- JVM이 힙 1.5GB + 스택/Metaspace 400MB = 총 1.9GB 사용

- OS/Docker 600MB 사용

- 총 2.5GB 필요 → t3.small 2GB 초과

- Thread Starvation 발생

 

[1차 해결]

Docker 메모리 제한을 추가하고 JVM heap을 축소했습니다.

# Dockerfile

ENTRYPOINT ["java", "-XX:+UseZGC", "-Xms512m", "-Xmx1g", "-jar", "app.jar"]

# 배포 스크립트

docker run -d --memory="1.5g" --memory-swap="1.5g" --cpus="1.8" ...

 

2차 문제 발생

이후 구글/네이버 소셜 로그인, CRUD API들이 추가되면서 다시 Thread Starvation이 발생했습니다.

AWS EC2 서버를 재부팅하고 SSH로 서버에 접속해서 원인을 파악해보았습니다. 아래 내용은 당시 작성했던 깃허브 이슈 내용입니다.

문제 상황 정리

1. 컨테이너 상태 확인

- docker ps -a | grep nugudi-dev (컨테이너는 실행중이지만 unhealthy 상태)

- docker top nugudi-dev (컨테이너 내부 프로세스 확인: 아무런 프로세스도 출력되지 않았습니다 → Java 프로세스가 죽은 상태)

 

2. 헬스체크 실패 원인 확인

image.png

- Connection reset by peer 에러 → Spring Boot 애플리케이션이 정상적으로 응답할 수 없는 상태

 

3. 컨테이너 로그 확인

image.png

- HikariCP에서 반복적으로 Thread Starvation 경고 → GC가 CPU를 독점하여 애플리케이션 스레드들이 실행될 기회를 얻지 못하고 있는 상태

 

4. OOM Killer 확인

image.png

- docker inspect 명령으로 OOMKilled 여부 확인 → false → Linux의 OOM Killer가 프로세스를 강제 종료한 것이 아니라, Java 프로세스가 메모리 부족으로 인한 GC Thrashing 상태에서 스스로 응답 불가 상태

 

5. 메모리 사용량 측정

image.png

- EC2 인스턴스의 실제 메모리 사용 현황을 파악하기 위해 컨테이너를 중지한 상태에서 free -m 명령 실행

- OS + Docker 데몬: 256MB 사용

- t3.small 인스턴스의 총 메모리: 1901MB

- → 컨테이너에 안전하게 할당 가능한 메모리: 1901MB - 256MB = 1645MB (약 1.6GB)

 

6. Docker 메모리 사용량 확인 (docker stats nugudi-dev --no-stream)

image.png

- 재부팅 후 안정화된 상태에서 컨테이너의 메모리 사용량 확인: 866MB / 1.5GB (56%) 사용

- 안정화된 상태의 메모리 사용량. (따라서 애플리케이션 시작 단계에서의 피크 메모리 사용량은 아닙니다.)

 

7. CPU 크레딧 확인

- 78-90 크레딧 유지하고 있음. 따라서 CPU 크레딧 고갈 문제는 아니라고 생각했습니다.

- 문제의 원인은 메모리 부족으로 인한 GC Thrashing으로 예상됩니다.

 

🧐 원인 파악

[메모리 부족 원인]

애플리케이션 시작 단계에서는 모든 클래스를 한 번에 로딩하고, 모든 Spring Bean을 생성하며, DB 커넥션 풀을 초기화하는 등 메모리 사용량이 피크에 달합니다. 이 시점에서 필요한 메모리가 Docker limit인 1.5GB를 초과하면서 메모리 부족 상태가 발생했습니다.

메모리를 확보할 수 없게 된 GC는 계속해서 메모리 확보를 시도하며 CPU의 90% 이상을 소비하게 되고, 이로 인해 애플리케이션 스레드들이 실행될 기회를 얻지 못하는 Thread Starvation 상태에 빠지게 된 것으로 예상됩니다.

[ZGC의 문제점]

현재 사용 중인 ZGC는 초저지연을 목표로 설계된 GC 알고리즘입니다. 이를 위해 복잡한 메모리 구조를 사용하며, 이 과정에서 heap 크기의 10-20%를 Native Memory로 추가 소비합니다. 현재 너구디의 설정인 1GB heap 기준으로 약 200MB의 Native Memory를 사용하는 것입니다.

반면 G1GC는 heap의 약 5%만을 Native Memory로 사용하므로 약 50MB 정도만 필요합니다.

따라서 ZGC를 G1GC로 변경하면 약 150MB의 Native Memory를 절약할 수 있습니다.

ZGC는 수백 GB 이상의 대용량 heap과 초저지연이 중요한 프로덕션 환경에 적합하지만, 1GB 정도의 작은 heap을 사용하는 너구디 개발 서버에서는 오히려 메모리 오버헤드가 부담이 됩니다. G1GC는 10-50ms의 pause time을 가지지만, 개발 서버에서는 이 정도의 지연은 문제가 되지 않으며, 메모리 효율성이 훨씬 중요하다고 판단했습니다.

[애플리케이션 시작 vs 안정화 메모리 차이]

docker stats로 확인한 866MB(56%)는 애플리케이션이 시작을 완료하고 안정화된 상태의 메모리 사용량입니다. 하지만 애플리케이션 시작 단계에서는 훨씬 더 많은 메모리가 필요합니다.

시작 단계(0-2분)에서는 JVM이 모든 클래스를 메모리에 로딩하고(+200MB), Metaspace를 초기화하며(+150MB), Spring Boot가 모든 Bean을 생성하고(+300MB), DB 커넥션 풀을 초기화하며(+100MB), 캐시를 초기화(+100MB)합니다.

이 시점의 피크 메모리 사용량은 1400-1500MB 입니다. 시작이 완료된 후(3분 이후)에는 GC가 불필요한 임시 객체들을 정리하면서 메모리 사용량이 800-900MB로 감소하여 안정화됩니다.

 


🤔 질문

1. GC 알고리즘 선택에 대해

1GB 수준의 heap에서 ZGC보다 G1GC가 적합하다고 판단하여 변경했습니다. 이러한 판단이 적절한지 여쭤보고싶습니다. ZGC는 어느 정도 규모의 heap부터 효과적인지 궁금합니다.

 

2. -Xms = -Xmx 설정에 대해

gemini 코드 리뷰에서 "서버 애플리케이션은 초기 힙과 최대 힙을 동일하게 설정하여 힙 확장 오버헤드를 제거하는 것이 좋다"는 의견을 받았습니다.

메모리가 빡빡한 상황(여유 100MB)에서 처음부터 1.2GB를 할당하는 것이 안전한 선택이 맞을까요..! 오히려 512MB로 시작해서 필요할 때 늘리는 게 더 안전한것일지 여쭤보고 싶습니다.

 

3. Docker 메모리 제한과 JVM heap 비율에 대해

현재 Docker 1.6GB 중 JVM heap 1.2GB (75%)로 설정했는데, 이 비율이 적절한지 여쭤보고 싶습니다. 일반적으로 권장되는 비율이 있는지 궁금합니다!

 

4. Native Memory 측정에 대해

현재는 관련해서 정리해둔 기술 블로그 등을 통해 (G1GC는 heap의 5%, Metaspace 100MB 등)로 추정했는데, 실제로 정확히 측정하려면 어떻게 해야 하는지 궁금합니다! -XX:NativeMemoryTracking=detail 같은 옵션을 운영 환경에서 사용해도 성능에 영향이 없을까요?!

 

5. 현업에서의 리소스 관리

이런 제한된 리소스 환경을 어떻게 관리하시나요?! 메모리 설정의 기준은 어떻게 정하시는지 궁금합니다!

 

6. 모니터링 지표

Thread Starvation을 사전에 감지하려면 어떤 지표를 모니터링해야 할까요??

 

강의 내용과 연결하여 최근에 개발하면서 궁금했던 부분들을 남기게 되었습니다! 초기 스타트업에서 백엔드 개발을 하고 있는데 개발하면서 이슈가 생기면, 강의들을 보기도 하고 구글링을 열심히 하기도 하고 AI와 정말 긴 대화를 나누기도 하면서 원인을 찾고 저희 상황에 제일 적합한 해결책을 찾고있습니다. 하지만 제가 문제 상황을 맞게 파악한건지, 가장 최선의 해결책을 찾은게 맞는지 늘 더 생각하게 되는 것 같습니다. 그리고 스스로의 판단에 확신을 갖기 위해서 더욱 더 많이 공부하고 기반을 탄탄하게 해야함을 느낍니다.

Thread Starvation 문제를 겪고 제가 파악하고 해결한 방법이 적합한지, 리소스 관리와 관련해서 현업에서는 어떻게 관리하고 모니터링하는지 여쭤볼 수 있는 분이 주변에 없어서 긴 글을 남기게 되었습니다 🤓..읽어주셔서 감사합니다 :)

 

답변 2

0

널널한 개발자님의 프로필 이미지
널널한 개발자
지식공유자

우선 전체적인 질문의 내용이 수업범위를 벗어나는 높은 수준임을 말씀드립니다. 😄
즉, 안타깝지만 제가 해드릴 이야기가 별로 없다는 것입니다. 이것이 이론과 실무의 실질적 차이이고 누구도 정확한 답을 해주기 어려운 이유입니다.

  1. 인프런 AI 답변을 보시더라도 알 수 있습니다. 힙 크기가 10GB 미만인 경우는 권장하지 않습니다. 권고되는 수준은 50GB 이상의 초대형 힙환경에서 유리합니다.

  2. 서버는 기본적으로 미리 메모리를 할당해주는 방식을 취합니다. 이유는 간단합니다. 메모리를 동적으로 확보하는 것도 상당히 부하가 걸리는 일이기 때문입니다.

  3. Thread 1개가 소모하는 개별 스택 메모리는 기본적으로 1MB 입니다. 물론 설정에 따라 달라질 수 있지만 수정하지 않았다면 그렇습니다. 스레드가 500개만 되더라도 벌써 500MB가 단순히 스택 메모리 만으로 소진됩니다. 그런데 힙 비중으로 75%나 잡은 것은 비교적 가혹조건이 아닐까 싶습니다. 저라면 65% 수준으로 낮출 것 같습니다. 아무리 높게 잡더라도 70%를 넘기지 않는 것이 좋겠습니다. 개발된 시스템이 생성하는 스레드 개수가 얼마나 될지 잘 확인해보시기 바랍니다. 거기에 도커 컨테이너 오버헤드까지 고려한다면...메모리는 꽤나 부족한 상황이기 때문입니다.

  4. JVM의 NMT기능을 사용하면 정확한 측정이 가능합니다. 그러나 메모리, CPU 모두 5~10%이상 오버헤드가 발생한다고 알려져 있습니다. 디버깅 및 운영 문제 해결을 위한 한시적 사용이면 몰라도 상시 사용은 바람직하지 않습니다.

  5. 이것은 정말 상황에 따라 차이가 큽니다. 서버가 제공하는 기능이 제각각이기 때문입니다. 이것은 정말이지 정답이 없습니다. 직접 부딪혀가며 답을 찾아가야 합니다. 다만 이미 그렇게 하고 있는 것으로 보입니다.

  6. 첫 번재는 어쩌면 너무나 당연하지만 실행 중인 스래드 개수 입니다. 스레드 풀 기반의 작동방식이라면 활성 스레드 개수가 계속 Full 상태인지 확인해봐야 할 것입니다. 그리고 다른 지표로는 HTTP request 응답시간, TPS나 QPS같은 처리량 등을 봐야 합니다. 서버 부하가 증가함에도 TPS가 정체되거나 감소한다면 문제가 발생한 것으로 추정할 수 있습니다. WhaTap 같은 모니터링 도구를 활용해보는 것도 좋은 대응이 될 수 있을 것입니다.

'현업'이라는 말은 생각보다 의미가 없습니다. 상황에 따라 대응 방법이 천차만별이기 때문입니다. 거기에 원인도 정말 온갖 것들이 있을 수 있습니다. 공부를 많이 하는 것 외에 다른 대안도 없고요. 많은 기업들이 CS 전공자를 찾는 이유도 이 때문입니다.

아무튼 지금 좋은 경험을 하고 있는 것으로 보입니다. 의견을 구할 길이 없어 답답한 마음은 아마도 모든 개발자들의 공통된 심정일 것입니다. 대신 잘 이겨내신다면 분명히 좋은 결과가 있으리라 확신합니다. 건투를 빕니다! 😄

0

안녕하세요, 인프런 AI 인턴입니다. 브로콜리피자님.

Thread Starvation 및 ZGC vs G1GC

먼저 G1GC와 ZGC를 비교해보겠습니다.

  • G1GC(Garbage-First Garbage Collector)는 중간 크기의 힙 메모리에 최적화되어 있으며, 애플리케이션의 중단 시간을 최소화하기 위해 설계되었습니다. G1GC는 백그라운드에서 수행되고, 주로 젊은 세대와 노령 세대를 구별하여 효율적인 메모리 관리를 제공합니다.

  • ZGC(Z Garbage Collector)는 대규모 힙 메모리를 대상으로 설계되었고, 특히 큰 호스트 메모리에서 최고의 성능을 발휘합니다. ZGC는 대기 시간이 짧고, 여러 TB의 힙 메모리를 처리할 수 있도록 설계되어 있습니다.

즉, 일반적으로 ZGC는 큰 힙 메모리(수 GB 이상)에서 더 유리하며, 적은 힙 메모리에서는 G1GC가 더 적합할 수 있습니다. 1GB 수준의 힙에서는 G1GC가 더 효과적일 가능성이 있습니다.

ZGC의 효과적인 힙 메모리 규모는 10GB 이상이 추천되며, 이로 인해 대규모 시스템이나 메모리 요구사항이 큰 애플리케이션에 적합합니다. 동일한 이유로, 파인 튜닝이 필요하지 않은 이상 G1GC를 사용하는 것이 작은 메모리 환경에서는 더 바람직할 수 있습니다.

Thread Starvation 관련 조치

Thread Starvation 문제는 특정 스레드가 지속적으로 자원을 기다려야 해서 기아상태에 빠지게 되는 상태를 말합니다. 이와 관련하여 가비지 컬렉터가 모든 자원을 사용하면 다른 스레드의 성능에 영향을 끼칠 수 있습니다. 따라서 스케줄링 정책을 확인하고 적절한 자원 분배가 이루어지도록 환경을 조정하는 것이 필요합니다.

질문해주신 주제는 많은 개발자가 공통으로 겪는 고민입니다. 유사한 주제를 다룬 다른 질문과 답변 링크를 아래에 첨부해 드립니다:
- Java GC 스타일 선택에 대한 질문: G1GC vs ZGC의 차이 - 효율적인 가비지 컬렉션 방식에 대한 기본적 이해를 돕습니다.
- 쓰레드 관리 및 GC 성능 비교 - 스레드와 가비지 컬렉션의 상호작용에 대해 다룹니다.

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

브로콜리피자님의 프로필 이미지
브로콜리피자

작성한 질문수

질문하기