[Java] JVM 메모리 구조는 실제 서버 성능과 어떤 관계가 있을까

2026. 5. 8. 09:10·Java & Spring
반응형

JVM 메모리 구조는 실제 서버 성능과 어떤 관계가 있을까

자바 서버를 운영하다 보면 어느 순간 이런 질문을 하게 된다.

 

응답 시간이 평소엔 50ms인데 왜 가끔 2초씩 튈까. 메모리 사용량은 한참 여유 있는데 왜 OOM이 날까. Heap 크기를 두 배로 늘렸더니 왜 더 느려졌을까.

 

이런 현상의 원인을 찾으려면 JVM이 메모리를 어떻게 나눠 쓰는지부터 봐야 한다. JVM 메모리 구조는 단순히 시험에 나오는 그림이 아니라, 운영 중인 서버의 응답 속도·처리량·재시작 빈도와 직접 연결되어 있는 실제 자원 모델이다.

 

이 글에서는 JVM의 런타임 데이터 영역을 한 번 정리하고, 그 구조가 실제 서버 성능에 어떤 식으로 보이는지 단계별로 살펴본다.

이 개념이 필요한 이유

자바 개발자가 처음 메모리 구조를 배울 때는 보통 "Stack에는 지역 변수가 들어가고 Heap에는 객체가 들어간다" 정도로 끝난다.

 

이 수준의 이해는 단일 프로세스에서 작은 코드를 돌릴 때는 충분하다. 하지만 서버 환경으로 넘어오면 이야기가 달라진다. 서버는 다음과 같은 조건에서 동작한다.

 

  • 수십에서 수천 개의 동시 요청을 처리한다.
  • 24시간 이상 프로세스가 살아 있다.
  • 응답 시간 SLA가 정해져 있다.
  • 컨테이너로 묶여 있고, 정해진 메모리 한도를 가진다.

 

이 조건에서 메모리 구조에 대한 무지는 곧바로 두 가지 형태로 드러난다. 하나는 응답 시간이 갑자기 튀는 GC pause, 다른 하나는 갑자기 컨테이너가 죽는 OOM-Kill이다.

두 현상 모두 "어느 영역이 어떻게 채워지고, 어떻게 비워지는가"를 알아야 원인을 추적할 수 있다.

 

먼저 알아야 할 배경지식

 

JVM은 운영체제에 직접 의존하지 않고 자체적인 메모리 모델을 가진다. 그래서 자바 코드에서 우리가 다루는 "객체", "스레드", "클래스 정보"는 모두 OS의 가상 메모리 위에 올라간 JVM의 자체 영역에 분산되어 저장된다. JVM 명세는 이 영역들을 런타임 데이터 영역(Run-Time Data Areas) 이라고 부른다. 이름에서 보이듯, 컴파일 시점이 아니라 실행 시점에 만들어졌다 사라지는 메모리 구획이다. 이 영역들은 두 부류로 나뉜다.

 

  • 모든 스레드가 공유하는 영역
  • 각 스레드마다 별도로 가지는 영역

이 구분은 단순히 형식적인 분류가 아니다. 동시성 문제, 락, GC의 동작 방식 모두 이 구분에서 출발한다. "공유되는가, 아닌가"는 그 영역에서 무슨 일이 일어날 수 있는가를 결정한다.

 

다섯 개의 런타임 데이터 영역

JVM은 실행 시점에 다음 다섯 영역을 사용한다.

  • Heap
  • Method Area (HotSpot에서는 Metaspace로 구현)
  • JVM Stack
  • PC Register
  • Native Method Stack

이 다섯 영역의 차이를 한 번에 비교해 본다.

 

영역 공유 여부 저장 대상 부족 시 발생하는 예외
Heap 공유 객체 인스턴스, 배열 OutOfMemoryError: Java heap space
Metaspace 공유 클래스 메타데이터, static 필드 OutOfMemoryError: Metaspace
JVM Stack 스레드별 메서드 호출 프레임 StackOverflowError
PC Register 스레드별 실행 중인 명령어 주소 (별도 OOM 없음)
Native Method Stack 스레드별 JNI 호출 시 네이티브 스택 StackOverflowError

 

여기서 중요한 점은 각 영역은 다른 이유로 부족해진다는 것이다. Heap이 부족한 것과 Metaspace가 부족한 것은 원인도, 해결 방법도 완전히 다르다. 서버에서 OOM이 났을 때 "메모리가 부족하다"고 뭉뚱그리지 말고 어느 영역에서 났는지부터 봐야 하는 이유다.

 

 

 

Heap은 한 덩어리가 아니다

서버 성능 이야기의 대부분은 Heap에서 일어난다. Heap은 모든 스레드가 공유하고, 객체 인스턴스가 모두 여기에 살기 때문이다.

JVM 명세 자체는 Heap의 내부 분할을 강제하지 않는다. 하지만 OpenJDK HotSpot 같은 주류 구현체는 세대별 가설 (generational hypothesis) 을 따른다. 이 가설은 "대부분의 객체는 짧게 살고, 오래 살아남는 객체는 정말 오래 산다"는 경험적 관찰이다. 이 가설을 받아들이면, 짧게 살 객체와 오래 살 객체를 같은 공간에서 다루는 것은 비효율적이다. 그래서 HotSpot은 Heap을 다음과 같이 나눈다.

 

  • Young Generation
    • Eden
    • Survivor 0 (S0)
    • Survivor 1 (S1)
  • Old Generation (Tenured)

 

새 객체는 거의 대부분 Eden에 할당된다. Eden이 가득 차면 Minor GC가 일어나고, 살아남은 객체는 Survivor 영역으로 이동한다. 일정 횟수 이상 살아남으면 Old로 promote 된다.

 

여기서 한 가지 짚고 넘어가야 한다. 이 세대별 모델은 Serial GC, Parallel GC, G1 GC 같은 전통적 알고리즘에 강하게 결합되어 있다. JDK 15에서 정식 지원된 ZGC, Shenandoah는 STW pause를 줄이기 위해 세대 분리를 약화한 형태로 출발했고, JDK 21에서는 Generational ZGC가 들어와 다시 세대 분리를 도입한다. 즉 "Young/Old"는 자바 명세가 아니라 GC 구현 전략의 결과라는 점을 기억해 두면 좋다.

 

Metaspace는 Heap 바깥에 있다

JDK 7까지는 클래스 메타데이터가 PermGen이라는 Heap 내부 영역에 있었다. JDK 8부터는 PermGen이 사라지고, 대신 Heap 바깥의 네이티브 메모리에 Metaspace 가 생겼다 (JEP 122). Metaspace의 특이점은 다음과 같다.

 

  • 위치는 Heap이 아니라 OS의 네이티브 메모리다.
  • -Xmx로 잡은 Heap 크기와 별도로 늘어난다.
  • 기본값은 MaxMetaspaceSize가 사실상 무제한이다.

 

이 마지막 점이 운영 환경에서 자주 사고를 부른다. 프록시·바이트코드 생성·핫 리로드를 사용하는 애플리케이션은 클래스 정보가 천천히 쌓이고, 클래스 로더가 회수되지 않으면 Metaspace가 멈추지 않고 늘어난다. 컨테이너 메모리 한도에 부딪히는 순간 OOM-Kill이 일어나는데, 정작 자바 Heap은 절반밖에 차지 않은 상태일 수 있다.

 

객체는 어떻게 만들어지고 사라지는가

서버 한 대에서 일어나는 일을 단순화해 본다.

  1. 요청이 들어와 컨트롤러 메서드가 호출된다.
  2. 메서드 안에서 객체 여러 개가 생성된다. 이들은 Eden에 할당된다.
  3. 메서드가 끝나면 지역 변수 참조는 Stack 프레임과 함께 사라진다.
  4. 다른 곳에서 참조하지 않는 객체는 다음 Minor GC 때 회수된다.
  5. 캐시처럼 오래 살아남는 객체는 Survivor를 거쳐 Old로 promote 된다.
  6. Old가 가득 차면 Major GC 또는 Full GC가 일어난다.

여기서 서버 성능과 직접 닿는 지점은 4번과 6번이다. Minor GC는 보통 짧다. 살아 있는 객체만 Survivor로 옮기는 copying collector 방식이라 Eden이 완전히 비어 있는 순간이 짧기 때문이다. 하지만 Eden이 너무 작으면 Minor GC가 자주 발생한다. 한 번이 짧아도 누적 비용은 크다. Old의 GC는 다르다. 살아 있는 객체가 많고, 압축이나 이주 비용이 더 든다. GC 알고리즘에 따라 다르지만, Stop-the-World pause가 수백 ms 이상으로 길어질 수 있다.

 

이 pause 동안 모든 애플리케이션 스레드는 멈춘다. 즉 그 시간만큼 응답이 지연된다.

 

메모리 구조가 서버 성능으로 보이는 순간

JVM 메모리 구조가 단순히 그림이 아닌 이유는 운영 중인 서버의 지표에 직접 보이기 때문이다.

GC pause가 응답 시간으로 나타난다

GC가 동작하는 동안 애플리케이션 스레드는 멈춘다. 이 멈춤은 평균 응답 시간에는 잘 안 보이고, p99·p99.9 응답 시간에 튀는 형태로 드러난다. "평균은 50ms인데 p99가 1초가 넘는다"는 패턴은 GC pause가 가장 흔한 원인이다.

Heap 크기는 빈도와 길이의 트레이드오프다

Heap을 키우면 GC 빈도는 줄어든다. 대신 한 번의 GC가 더 길어질 수 있다. Heap을 줄이면 빈도가 늘어난다. 한 번은 짧지만 누적 비용은 클 수 있다. 이 둘 사이에서 정답은 없고, 워크로드와 GC 알고리즘에 따라 다르다. 중요한 점은 "Heap을 무조건 크게 잡는 것이 답이 아니다"는 사실이다.

살아남는 객체의 비율이 GC 패턴을 결정한다

캐시, 연결 풀, 세션처럼 오래 살아남는 객체가 많으면 Old가 빠르게 차고 Major GC가 잦아진다. 이런 워크로드에서는 Old를 충분히 잡거나, Old GC가 효율적인 알고리즘(G1, ZGC)을 고려해야 한다. 반대로 요청마다 객체를 잠깐 만들었다 버리는 워크로드는 Young 영역을 충분히 두는 편이 유리하다.

Metaspace는 천천히 새는 메모리다

Metaspace는 Heap에 비해 변동 폭이 작아 보이지만, 동적 클래스 로딩이 누적되는 환경에서는 천천히 늘어난다. 컨테이너 한도에 닿기 전에 모니터링으로 잡지 못하면 어느 날 갑자기 OOM-Kill이 난다.

스레드 수가 많으면 Stack도 무시할 수 없다

기본 스택 크기는 보통 1MB 정도다. 스레드 풀이 1000개라면 그것만으로도 1GB를 차지한다. WAS의 max thread 설정과 컨테이너 메모리 한도는 함께 봐야 한다.

 

 

코드 예제: 메모리 구조와 직결되는 두 가지 패턴

잘못된 코드: 캐시를 무한히 키우는 경우

public class UserCache {
    // static field이므로 클래스 로더가 살아 있는 한 회수되지 않는다.
    private static final Map<Long, User> CACHE = new HashMap<>();

    public User get(long id) {
        return CACHE.computeIfAbsent(id, this::load);
    }

    private User load(long id) {
        return userRepository.findById(id);
    }
}

이 캐시는 만료 정책이 없다. 시간이 지날수록 Old 영역에 살아남은 객체가 누적된다. 처음에는 Minor GC만 짧게 일어나다가, 어느 순간부터 Major GC가 잦아지고 응답 시간이 흔들린다. 힙 덤프를 떠 보면 Old의 대부분을 User 인스턴스가 차지하고 있다.

 

개선한 코드: 만료와 상한을 갖는 캐시로 교체

public class UserCache {
    private static final Cache<Long, User> CACHE = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(10))
            .build();

    public User get(long id) {
        return CACHE.get(id, this::load);
    }

    private User load(long id) {
        return userRepository.findById(id);
    }
}

상한과 만료가 있으면 객체가 결국 회수된다. 이 차이만으로 Old 압박이 줄어들고, Major GC 빈도와 pause 시간이 안정된다.

 

GC 알고리즘과 메모리 영역의 관계

같은 Heap 구조 위에서도 GC 알고리즘은 매우 다르게 동작한다. 세 가지를 비교해 본다.

 

GC 우선순위 Heap 크기 적합 범위 특징
Parallel GC Throughput 비교적 작은~중간 처리량 최대, pause는 상대적으로 김
G1 GC Latency / Balanced 중간~큰 Heap JDK 9+ 기본, region 단위 회수
ZGC Low pause 큰 Heap (수십 GB+) pause < 10ms 목표, JDK 15 production

 

여기서 중요한 점은 GC 알고리즘 선택이 "Heap 구조"를 다시 정의한다는 것이다. G1은 Young/Old를 region 단위로 잘게 쪼개고, ZGC는 colored pointer 기반으로 동시 압축을 한다.

 

따라서  "Heap 메모리 구조"를 이야기할 때는 GC 알고리즘과 떼어놓고 보기 어렵다. 공부할 때는 Eden/Survivor/Old 모델을 기준으로 잡되, 실제 운영 GC가 어떻게 그 영역을 다시 해석하는지를 같이 보는 편이 좋다.

 

컨테이너 환경에서 메모리를 보는 법

요즘 자바 서버는 거의 다 컨테이너 위에서 돈다. 컨테이너 환경에서 자주 빠지는 함정은 "Heap만 보고 메모리를 잡는다"는 점이다.

자바 프로세스의 RSS는 다음을 모두 포함한다.

  • Heap (-Xmx)
  • Metaspace
  • 스레드 스택의 합 (스택 크기 × 스레드 수)
  • Direct memory (NIO, Netty 등에서 사용)
  • JIT 코드 캐시
  • 기타 네이티브 라이브러리 메모리

따라서 컨테이너 메모리 한도가 4GB라면 -Xmx4G로 잡으면 안 된다. 보통은 -Xmx를 한도의 70~80% 수준으로 잡고, 나머지를 Heap 외 메모리로 둔다. JDK 10+ 부터는 -XX:MaxRAMPercentage를 사용해 컨테이너 한도의 비율로 Heap을 자동 조정할 수 있다. 다만 이 옵션의 기본값은 JDK 버전에 따라 달라질 수 있으므로, 운영에 적용하기 전에 사용 중인 JDK 문서를 한 번 확인하는 편이 안전하다.

자주 헷갈리는 부분

  • Stack과 Heap은 같은 메모리가 아니다. Stack은 스레드별로 따로 잡히고, 메서드 호출이 끝나면 자동으로 정리된다. GC 대상이 아니다.
  • PermGen은 사라졌다. JDK 8 이후로는 Metaspace다. 옛날 글에서 "PermSize" 옵션을 보았다면 더 이상 쓰지 않는다.
  • DirectByteBuffer는 GC가 안 한다는 말은 정확하지 않다. 자바 객체 자체는 Heap에 있고, 그 객체가 GC될 때 네이티브 메모리도 함께 해제된다. 다만 Heap 압박이 약하면 GC가 늦어져 네이티브 메모리가 오래 점유될 수 있다.
  • -Xmx는 시작값이 아니라 최대값이다. 운영에서는 보통 -Xms와 -Xmx를 같게 잡아 시작 시점에 Heap을 미리 확보한다. 동적 확장이 GC 패턴을 흔들기 때문이다.

마무리

JVM 메모리 구조는 책에 그려진 박스 그림이 아니다. 운영 중인 서버의 응답 시간 그래프, 컨테이너 메모리 사용량 곡선, OOM-Kill 알림이 모두 이 구조 위에서 일어난다.

이 구조를 이해하고 나면 다음과 같은 질문에 답할 수 있다.

  • 어디가 부족해서 OOM이 났는가
  • 어떤 객체가 어디에 쌓이고 있는가
  • GC pause는 어느 영역의 GC에서 나오는가
  • Heap 외에 무엇이 컨테이너 메모리를 먹고 있는가

여기서부터가 실제 튜닝의 출발점이다.

 

참고자료

  • The Java Virtual Machine Specification, Chapter 2 — https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html
  • HotSpot Virtual Machine Garbage Collection Tuning Guide — https://docs.oracle.com/en/java/javase/21/gctuning/
  • JEP 122: Remove the Permanent Generation — https://openjdk.org/jeps/122
  • JEP 248: Make G1 the Default Garbage Collector — https://openjdk.org/jeps/248
  • JEP 377: ZGC — A Scalable Low-Latency Garbage Collector (Production) — https://openjdk.org/jeps/377
  • JEP 379: Shenandoah — A Low-Pause-Time Garbage Collector (Production) — https://openjdk.org/jeps/379
  • JEP 439: Generational ZGC — https://openjdk.org/jeps/439
반응형

'Java & Spring' 카테고리의 다른 글

[Java & Spring] Pub/Sub은 어떻게 컴포넌트를 떼어놓을까  (0) 2026.05.25
[Java & Spring] 실시간 주식 차트는 어떻게 만들까 / feat.시세 fan-out 아키텍처  (1) 2026.05.22
[Java & Spring] 낙관적 락, 비관적 락 (with. JPA와 AOP)  (6) 2025.08.02
[Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기  (0) 2025.07.02
[Java] ReentrantLock 정리 (with. synchronized)  (0) 2025.06.27
'Java & Spring' 카테고리의 다른 글
  • [Java & Spring] Pub/Sub은 어떻게 컴포넌트를 떼어놓을까
  • [Java & Spring] 실시간 주식 차트는 어떻게 만들까 / feat.시세 fan-out 아키텍처
  • [Java & Spring] 낙관적 락, 비관적 락 (with. JPA와 AOP)
  • [Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기
Penguin Dev
Penguin Dev
What does the Penguin say?
    글쓰기 관리
  • Penguin Dev
    Pengha!
    Penguin Dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (157) N
      • Java & Spring (9)
      • Redis (1) N
      • System Hacking (4)
      • Algorithm (8)
        • Sorting algorithm (3)
      • Python (6)
      • Web (2)
        • Web Hacking & Security (2)
      • Write-Up (108)
        • pwnable.kr (17)
        • HackCTF (16)
        • 해커스쿨 FTZ (21)
        • LOB(lord of bufferoverflow) (19)
        • LOS (lord of sql injection) (28)
        • XSS-game (6)
        • Webhacking.kr (1)
      • SUA (19)
        • 오픈소스 보안 (19)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    DB정리
    putval()
    computeifpresent
    동시성처리
    enumerate #list comprehension
    ConcurrentHashMap
    concurrenthashmap vs hashmap
    computeifabsent()
    ReentrantLock
    reentrantlock실습
    Lock
    lord of bufferoverflow
    쿠폰발급
    spring boot
    낙관적 락
    비관적 락
    tabat
    SpringBoot
    Java
    spring
    nop sled
    computeifabsent
    코드트리
    동시성
    CacheHit
    CountDownLatch
    thread-safe
    코드트리조별과제
    hashmap vs concurrenthashmap
    computeifpresent()
  • 최근 댓글

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
Penguin Dev
[Java] JVM 메모리 구조는 실제 서버 성능과 어떤 관계가 있을까
상단으로

티스토리툴바