Skip to main content

13. JVM, 메모리 관리, 성능 이해하기

JVM, 메모리 관리, 성능 이해하기

왜 이 장이 중요한가

자바를 처음 배울 때는 문법이 보입니다. 조금 익숙해지면 클래스와 객체가 보입니다. 그런데 한 단계 더 올라가면, 이제는 코드가 JVM 안에서 어떻게 실행되는지가 보이기 시작합니다.

바로 이 지점이 중요합니다. 같은 기능을 하는 코드라도 어떤 코드는 유난히 빠르고, 어떤 코드는 메모리를 많이 먹고, 어떤 코드는 갑자기 멈춘 것처럼 보입니다. 그 이유는 대부분 "자바 문법"보다 아래층에 있는 JVM의 실행 방식, 메모리 구조, 가비지 컬렉션, JIT 최적화에 숨어 있습니다.

이 장에서는 자바 프로그램이 실행되는 내부 원리를 처음부터 차근차근 살펴봅니다. 어렵게 느껴질 수 있지만, 한 번 흐름이 잡히면 성능 문제를 보는 눈이 확 달라집니다.

자바 코드는 어떻게 실행될까

우리가 작성한 .java 파일은 컴파일을 거쳐 .class 파일, 즉 바이트코드가 됩니다. 이 바이트코드는 특정 운영체제의 기계어가 아니라, JVM이 이해할 수 있는 중간 형태입니다. JVM은 이 바이트코드를 읽어 실제 컴퓨터가 실행할 수 있는 방식으로 처리합니다.1

큰 흐름은 다음과 같습니다.

  • 클래스 로더(Class Loader)가 필요한 클래스 파일을 메모리로 읽어들입니다.

  • 런타임 데이터 영역(Runtime Data Areas)에 클래스 정보와 객체, 메서드 실행 정보를 배치합니다.

  • 실행 엔진(Execution Engine)이 바이트코드를 해석하거나, 자주 실행되는 코드는 JIT로 기계어에 가깝게 바꿔 더 빠르게 실행합니다.1

즉, 자바 프로그램은 운영체제 위에서 곧바로 뛰는 것이 아니라, JVM이라는 무대 감독의 지휘를 받으며 움직입니다.

JVM 메모리 구조 한눈에 보기

JVM 메모리는 하나의 통짜 창고가 아닙니다. 역할에 따라 구역이 나뉘어 있습니다. 이 구역을 이해하면 "이 변수는 어디에 있지?", "왜 이 객체는 안 사라지지?", "왜 StackOverflowError가 나지?" 같은 질문에 답할 수 있습니다.1

메서드 영역과 메타스페이스

클래스의 구조 정보, 메서드 정보, 런타임 상수 풀, static 필드 같은 클래스 수준 정보가 저장되는 영역입니다. 자바 8 이후 HotSpot JVM에서는 전통적인 PermGen 대신 Metaspace를 사용하며, 이는 힙 바깥 네이티브 메모리를 활용합니다.1

쉽게 말해, 객체 그 자체보다 "이 객체가 어떤 설계도로 만들어졌는가"에 가까운 정보가 여기에 놓입니다.

힙(Heap)

new로 만든 객체와 배열이 저장되는 공간입니다. 모든 스레드가 함께 사용하는 공유 공간이며, 가비지 컬렉터가 가장 열심히 들여다보는 곳이기도 합니다.1

실무에서 "메모리 많이 먹는다"는 말은 대체로 힙 사용량이 커졌다는 뜻인 경우가 많습니다.

스택(Stack)

각 스레드마다 따로 생기는 메모리 영역입니다. 메서드가 호출될 때마다 스택 프레임이 쌓이고, 메서드가 끝나면 자동으로 빠집니다. 지역 변수, 매개변수, 중간 계산값, 반환 위치 같은 정보가 들어갑니다.1

스택은 계산대처럼 빠르게 쓰고 치우는 공간입니다. 그래서 속도는 빠르지만, 너무 깊게 쌓이면 StackOverflowError가 날 수 있습니다.

PC 레지스터

각 스레드가 지금 어느 명령을 실행 중인지 가리키는 작은 정보 공간입니다. 아주 눈에 띄진 않지만, 스레드가 각자 어디까지 실행했는지 기억하는 데 필요합니다.1

네이티브 메서드 스택

자바가 아닌 C, C++ 같은 네이티브 코드를 JNI로 호출할 때 사용하는 영역입니다. 일반적인 애플리케이션 개발에서는 직접 만질 일이 적지만, 라이브러리나 시스템 연동이 많으면 무시할 수 없는 영역입니다.1

스택과 힙, 헷갈리지 않게 이해하기

많은 초보자가 가장 헷갈려하는 부분이 바로 스택과 힙입니다. 핵심은 아주 단순합니다.

참조 변수는 주로 스택에 있고, 실제 객체는 힙에 있다는 점입니다.

다음 코드를 봅시다.

public class MemoryDemo {
    public static void main(String[] args) {
        int count = 3;
        Person person = new Person("Java");
        person.sayHello();
    }
}

class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public void sayHello() {
        System.out.println("Hello, " + name);
    }
}

이 코드가 실행되면 main()의 지역 변수 count와 참조 변수 person은 스택 프레임에 놓입니다. 하지만 new Person("Java")로 생성된 실제 Person 객체는 힙에 만들어집니다. person은 그 객체를 가리키는 손가락 같은 역할을 합니다.1

이제 감이 옵니다. 메서드가 끝나면 스택 프레임은 자동으로 사라집니다. 하지만 힙의 객체는 참조가 끊어졌는지를 봐야 정리됩니다. 이 차이가 바로 GC를 이해하는 출발점입니다.

객체는 왜 사라지고, 왜 안 사라질까

자바에서는 free()나 delete를 직접 호출하지 않습니다. 대신 JVM이 더 이상 도달할 수 없는 객체를 찾아 메모리를 회수합니다. 이것이 가비지 컬렉션(GC)입니다.

중요한 기준은 "내가 지금 안 쓴다"가 아니라, GC 루트에서 참조 경로가 남아 있는가입니다. 지역 변수, 활성 스레드, static 필드 같은 곳에서 계속 참조하고 있으면, 그 객체는 여전히 살아 있는 것으로 간주됩니다.1

그래서 자바에서도 메모리 누수는 생길 수 있습니다. 객체를 잊고 있어도, 어디선가 참조를 붙들고 있으면 GC는 그 객체를 치우지 못합니다.

예를 들어 이런 코드는 위험합니다.

import java.util.ArrayList;
import java.util.List;

public class BadCache {
    private static final List<Object> CACHE = new ArrayList<>();

    public static void add(Object obj) {
        CACHE.add(obj);
    }
}

CACHE는 static이므로 프로그램이 살아 있는 동안 오래 유지됩니다. 여기에 객체를 계속 쌓기만 하면, 사실상 "버릴 수 없는 창고"가 되어 힙을 점점 잠식합니다.

힙은 왜 세대별로 나뉠까

JVM은 대부분의 객체가 금방 죽는다는 경험칙을 이용합니다. 그래서 힙을 한 덩어리로만 다루지 않고 세대(Generation) 개념으로 나눠 관리합니다.1

Young Generation

새로 생성된 객체가 먼저 들어가는 구역입니다. 일반적으로 Eden과 두 개의 Survivor 영역으로 구성됩니다. Eden이 차면 Minor GC가 일어나고, 살아남은 객체는 Survivor로 이동합니다. 몇 번이고 살아남으면 Old Generation으로 승격됩니다.1

이 구간의 GC는 비교적 빠른 편입니다. 대부분의 객체가 짧게 살고 끝나기 때문입니다. 웹 애플리케이션에서 요청 하나 처리하며 잠깐 만들었다 사라지는 객체들이 여기에 많이 해당합니다.

Old Generation

여러 차례 GC를 견디고 오래 살아남은 객체들이 머무는 구역입니다. 캐시, 오래 유지되는 세션 데이터, 커다란 객체 그래프 같은 것들이 이쪽으로 이동할 수 있습니다.1

이 영역을 수집하는 작업은 상대적으로 무겁습니다. 살아 있는 객체도 많고, 검사 범위도 넓기 때문입니다.

Minor GC, Major GC, Full GC

용어는 JVM과 도구에 따라 조금씩 다르게 쓰일 수 있지만, 학습 단계에서는 이렇게 이해하면 충분합니다.

  • Minor GC: Young 영역 중심 정리

  • Major GC: Old 영역 중심 정리

  • Full GC: 힙 전체를 폭넓게 정리하는 무거운 작업

대체로 우리가 겁내는 것은 Full GC가 자주 길게 발생하는 상황입니다. 이때 애플리케이션 응답이 눈에 띄게 느려질 수 있습니다.2

Stop-the-World는 왜 무서울까

GC를 이야기할 때 자주 나오는 말이 Stop-the-World입니다. 이름부터 무섭습니다. 말 그대로 GC의 특정 단계에서 애플리케이션 스레드가 잠시 멈추는 현상입니다.2

잠깐 멈추는 건 괜찮습니다. 문제는 그 "잠깐"이 길어질 때입니다. 사용자는 버튼을 눌렀는데 반응이 없다고 느끼고, 서버는 응답 시간이 튀고, 모니터링 시스템은 지연 경보를 울립니다.

GC 튜닝의 핵심은 "GC를 아예 없애는 것"이 아니라, 필요한 만큼 잘 일하게 하되 정지 시간을 너무 길게 만들지 않는 것입니다.

GC 알고리즘은 왜 여러 종류가 있을까

모든 프로그램이 같은 성격은 아닙니다. 배치 프로그램은 처리량이 중요할 수 있고, 게임 서버나 금융 시스템은 짧은 지연 시간이 더 중요할 수 있습니다. 그래서 JVM은 여러 GC를 제공합니다.2

대표적으로 알아둘 만한 것들은 다음과 같습니다.

  • Serial GC: 단순하고 가벼우나 단일 스레드 기반이라 큰 시스템에는 불리합니다.

  • Parallel GC: 처리량 중심입니다. CPU 자원을 적극 활용합니다.

  • G1 GC: 힙을 여러 Region으로 나눠 관리하며, 균형 잡힌 성능을 제공합니다. Java 9 이상에서 기본 GC로 널리 쓰입니다.2

  • ZGC: 매우 낮은 지연 시간을 목표로 설계된 최신 계열 GC입니다. 큰 힙에서도 짧은 일시 정지를 지향합니다.2

입문 단계에서는 "어떤 GC가 무조건 최고인가?"보다 우리 프로그램이 처리량 중심인지, 지연 시간 중심인지를 먼저 생각하는 습관이 중요합니다.

클래스 로딩은 프로그램 시작 전에 끝나는 게 아니다

많은 사람이 클래스는 프로그램 시작할 때 한 번에 다 올라간다고 생각합니다. 실제로는 필요한 시점에 로딩되는 경우가 많습니다.

클래스 로더는 클래스를 읽어 들이고, 검증하고, 링크하고, 초기화합니다. 이 과정에서 클래스 메타데이터가 메타스페이스에 올라가고, static 필드가 준비됩니다.1

예를 들어 어떤 라이브러리 클래스가 실제로 사용되는 순간 처음 로딩될 수도 있습니다. 그래서 애플리케이션 시작 속도, 첫 요청 지연, 동적 플러그인 구조, 리플렉션 사용량 같은 요소가 클래스 로딩과 연결되기도 합니다.

또 하나 기억할 점은, 클래스 로더가 살아 있으면 그 로더가 올린 클래스 메타데이터도 쉽게 사라지지 않는다는 것입니다. 서버 환경에서 잘못된 클래스 로더 사용은 메타스페이스 누수로 이어질 수 있습니다. "힙은 괜찮은데 메모리가 이상하게 찬다"는 상황이면 이쪽도 의심해야 합니다.1

JIT 컴파일러가 자바를 빠르게 만든다

자바는 바이트코드를 JVM이 실행합니다. 그렇다면 늘 느릴까요? 그렇지 않습니다. 여기서 등장하는 주인공이 JIT(Just-In-Time) 컴파일러입니다.

처음에는 인터프리터가 바이트코드를 읽어 실행할 수 있습니다. 하지만 JVM은 실행 중에 자주 호출되는 코드, 즉 "뜨거운 코드(hot code)"를 발견하면 그 부분을 더 빠른 형태로 컴파일해 둡니다. 그러면 다음부터는 훨씬 효율적으로 실행할 수 있습니다.3

이 말은 아주 중요한 사실로 이어집니다. 자바 프로그램은 실행 초반과 충분히 달아오른 뒤의 성능이 다를 수 있다는 뜻입니다.

워밍업이 필요한 이유

메서드가 몇 번 안 불렸을 때는 아직 JIT 최적화가 충분히 일어나지 않았을 수 있습니다. 그래서 간단한 테스트를 한두 번 돌려 보고 "이 코드가 느리네"라고 결론 내리면 틀릴 수 있습니다.

성능 측정에서는 워밍업(warm-up)이 중요합니다. JVM이 최적화할 시간을 준 뒤 측정해야 비교적 믿을 만한 결과가 나옵니다.

왜 작은 메서드가 빠를 때가 있을까

JIT는 때때로 메서드를 인라이닝(inlining)합니다. 즉, 호출 비용을 줄이기 위해 메서드 본문을 호출 지점에 녹여 넣는 식으로 최적화할 수 있습니다. 이런 덕분에 자바는 "가상 머신 위에서 도는 언어"라는 인상보다 훨씬 빠르게 동작할 수 있습니다.3

왜 어떤 코드는 빠르고 어떤 코드는 느릴까

이제 감각을 조금 현실로 가져와 봅시다. 성능 차이는 거창한 알고리즘만이 아니라, 평범한 코드 습관에서도 나옵니다.

1. 객체를 너무 자주 만들면 GC가 바빠진다

짧은 시간에 엄청나게 많은 임시 객체를 만들면 Young 영역이 빨리 차고, Minor GC가 자주 일어납니다. Minor GC 자체는 빠를 수 있지만, 너무 잦으면 전체 성능에 부담이 됩니다.

예를 들어 문자열을 반복해서 +로 이어 붙이거나, 루프 안에서 불필요한 박싱 객체를 계속 만드는 습관은 메모리 압박을 키울 수 있습니다.

2. 오래 살아남는 객체가 많아지면 Old 영역 부담이 커진다

캐시, 전역 컬렉션, 장수 객체 그래프가 많아지면 Old 영역 GC 비용이 커질 수 있습니다. 특히 "편해서 일단 static으로 두자"는 습관은 나중에 성능 문제로 돌아오기도 합니다.

3. 재귀가 깊어지면 스택이 위험하다

재귀는 코드가 우아해 보일 수 있지만, 호출이 깊어질수록 스택 프레임이 계속 쌓입니다. 자바는 꼬리 재귀 최적화를 보장하지 않으므로, 큰 입력에서는 반복문이 더 안전한 경우가 많습니다.

4. 첫 실행이 느릴 수 있다

JIT 최적화 전이라면 초반 실행 속도는 기대보다 낮을 수 있습니다. 이것은 "자바가 원래 느리다"가 아니라, 아직 엔진이 몸을 덜 푼 상태라고 보는 편이 정확합니다.3

메모리 오류를 보고 원인을 짐작해 보자

에러 메시지는 단순한 경고가 아니라 힌트입니다.

OutOfMemoryError: Java heap space

힙이 부족하다는 뜻입니다. 객체가 너무 많거나, 참조가 끊기지 않아 GC가 회수하지 못하거나, 힙 설정이 너무 작을 수 있습니다.

StackOverflowError

대개 재귀가 너무 깊거나, 메서드 호출이 비정상적으로 반복되는 경우입니다. 힙 문제가 아니라 스택 문제입니다.

OutOfMemoryError: Metaspace

클래스 메타데이터 영역이 부족하다는 뜻입니다. 동적 클래스 생성, 과도한 프록시, 클래스 로더 누수 등을 의심해야 합니다.1

이처럼 에러 이름만 봐도 "힙 문제인지, 스택 문제인지, 메타데이터 문제인지" 방향을 잡을 수 있습니다.

모니터링 도구로 JVM을 관찰하는 법

성능 문제를 감으로만 해결하려 하면 대개 삽질이 됩니다. JVM은 다행히 꽤 많은 관찰 도구를 제공합니다.

GC 로그

가장 먼저 익숙해질 만한 도구는 GC 로그입니다. HotSpot JVM에서는 -Xlog:gc 또는 더 자세히는 -Xlog:gc* 옵션으로 GC 동작을 기록할 수 있습니다. 로그에는 어떤 GC가 발생했는지, 얼마나 메모리를 회수했는지, 정지 시간이 얼마였는지 같은 정보가 담깁니다.4

예를 들면 다음처럼 실행할 수 있습니다.

java -Xlog:gc* -jar app.jar

GC 로그를 읽을 때는 특히 다음을 봅니다.

  • Young GC가 너무 자주 발생하는가

  • Full GC가 반복되는가

  • 한 번의 정지 시간이 유난히 긴가

  • GC 뒤에도 사용량이 계속 우상향하는가

마지막 항목은 메모리 누수의 냄새일 수 있습니다.

jstat

jstat은 JVM의 GC와 클래스 통계를 빠르게 확인하는 명령줄 도구입니다. 실행 중인 프로세스의 Young/Old 사용량, GC 횟수, 누적 시간을 볼 수 있습니다.5

jstat -gc <pid> 1000

이 명령은 1초마다 GC 관련 통계를 보여줍니다. 실습 환경에서 "객체를 많이 만들면 수치가 어떻게 바뀌는지" 관찰해 보면 아주 공부가 잘 됩니다.

jcmd

jcmd는 진단 명령을 JVM에 보내는 강력한 도구입니다. 힙 덤프를 뜨거나, Flight Recorder를 제어하거나, 여러 런타임 정보를 볼 수 있습니다.5

jcmd <pid> GC.heap_dump heap.hprof

힙 덤프는 메모리 스냅샷입니다. "누가 메모리를 많이 잡아먹는지"를 나중에 분석할 수 있게 해 줍니다.

VisualVM과 JConsole

그래프가 보이면 이해가 빨라집니다. VisualVM과 JConsole은 메모리, 스레드, 클래스 로딩, GC 활동을 눈으로 볼 수 있게 해 주는 도구입니다. 특히 학습 단계에서는 수치보다 그래프가 훨씬 직관적입니다.6

메모리 사용량이 톱니 모양으로 오르내리는지, 스레드 수가 계속 늘어나는지, GC 후에도 기준선이 점점 올라가는지 관찰해 보세요. 책에서 읽은 개념이 실제 움직임으로 연결됩니다.

JFR과 JMC

좀 더 깊이 들어가고 싶다면 JFR(Java Flight Recorder)와 JMC(Java Mission Control)를 익혀 보세요. JFR은 JVM 내부 이벤트를 비교적 낮은 오버헤드로 기록하고, JMC는 그 기록을 분석하는 도구입니다.6

메모리, GC, 스레드, 메서드 실행, 할당 패턴까지 폭넓게 볼 수 있어서 "왜 느린지"를 파고들 때 강력합니다.

실전에서 바로 쓰는 성능 해석 감각

이제 몇 가지 전형적인 패턴을 실제 감각으로 정리해 봅시다.

Young GC가 지나치게 자주 일어난다

대개 임시 객체 생성량이 많습니다. 문자열 결합, 불필요한 래퍼 객체, 지나친 스트림 남용, 짧은 생명 주기의 객체 남발을 의심해 볼 수 있습니다.

Full GC가 자주 일어난다

장수 객체가 많거나, 힙 압박이 심하거나, 메모리 누수가 있을 수 있습니다. 캐시 정책, static 컬렉션, 세션 적재 방식, 대형 객체 보관 로직을 확인해야 합니다.

애플리케이션 시작은 빠른데 첫 요청이 느리다

지연 로딩, 클래스 초기화, JIT 워밍업 가능성이 있습니다. 첫 호출만 유독 느리다면 이쪽을 떠올려 보세요.

CPU는 높은데 처리량은 안 나온다

GC가 너무 자주 돌고 있거나, 락 경합, 바쁜 루프, 예외 남발 같은 문제가 있을 수 있습니다. 이 장에서는 메모리 중심으로 보는 것이므로 우선 GC 로그와 할당 패턴부터 확인하면 좋습니다.

성능을 망치지 않는 코딩 습관

성능 튜닝은 마지막에만 하는 마법이 아닙니다. 평소 습관이 큰 차이를 만듭니다.

  • 객체를 필요 이상으로 만들지 않습니다.

  • 오래 유지할 데이터와 잠깐 쓸 데이터를 구분합니다.

  • static 컬렉션은 특히 신중하게 사용합니다.

  • 큰 캐시는 만료 정책과 크기 제한을 둡니다.

  • 반복적인 문자열 결합에는 StringBuilder를 고려합니다.

  • 재귀는 입력 크기와 스택 한계를 생각합니다.

  • 성능 평가는 반드시 관찰 도구와 함께 합니다.

핵심은 "최적화를 위한 최적화"가 아닙니다. JVM이 일하기 쉬운 코드를 쓰는 것입니다.

초급 개발자에서 한 단계 올라서는 순간

문법을 안다고 해서 성능을 이해하는 것은 아닙니다. 하지만 JVM의 구조를 이해하면 코드가 다르게 보입니다.

이제 여러분은 이런 질문을 할 수 있게 되었습니다.

  • 이 객체는 어디에 놓일까?

  • 이 메서드가 끝나면 무엇이 바로 사라지고, 무엇은 남을까?

  • 이 컬렉션은 왜 메모리를 계속 붙잡고 있을까?

  • 이 느려짐은 GC 때문일까, 클래스 로딩 때문일까, JIT 워밍업 때문일까?

바로 이 질문을 던질 수 있다는 것 자체가 성장입니다. 자바 개발 실력은 "코드가 돌아간다"에서 끝나지 않습니다. 코드가 왜 그렇게 돌아가는지 이해하는 순간, 한 단계 위의 개발자가 됩니다.

정리

이 장에서 배운 핵심을 다시 묶어 봅시다.

JVM은 클래스 로더, 메모리 영역, 실행 엔진으로 구성되며, 자바 코드는 그 위에서 실행됩니다. 스택은 메서드 실행과 지역 변수의 공간이고, 힙은 객체의 공간입니다. 힙은 Young과 Old 세대로 나뉘어 GC 효율을 높입니다. GC는 편리하지만 공짜가 아니며, Stop-the-World와 같은 비용을 동반합니다. 클래스 로딩과 메타스페이스도 메모리 문제의 원인이 될 수 있습니다. JIT는 자바를 빠르게 만드는 중요한 엔진이며, 그래서 성능 측정에는 워밍업 개념이 필요합니다. 마지막으로, 성능은 추측이 아니라 관찰로 풀어야 하며, GC 로그, jstat, jcmd, VisualVM, JFR 같은 도구가 그 출발점입니다.

다음 장에서는 이제 JVM 내부 원리를 바탕으로, 실무에서 자주 마주치는 테스트, 빌드, 모듈, JDBC, 그리고 Spring 입문으로 시야를 넓혀 보겠습니다.

참고

1DigitalOcean Java memory management explained

2JAVA Garbage Collector 완벽 가이드

3자바의 JIT 컴파일러와 GC: 성능 최적화의 핵심 이해

4Oracle Garbage Collector Implementation

5자바가상머신에서 자바 메모리 관리

6Java App Monitoring - Java Learning Platform

책 목록

처음부터 실무까지 단계별로 배우는 JAVA 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기