본문 바로가기
Java

JVM Heap과 Stack의 상호작용 및 생명주기 관리

by 정권이 내 2026. 3. 11.

☕ JVM 메모리의 내부를 알아야 할까요?

자바 생태계에서 스프링 부트를 활용해 애플리케이션을 개발하다 보면 마주치는 장벽이 있습니다. 바로 메모리 최적화와 장애 해결입니다.

많은 개발자가 처음 자바를 접할 때 가비지 컬렉터(GC)라는 시스템 덕분에 메모리 관리에 신경 쓰지 않아도 된다고 배우지만 이는 절반만 맞는 이야기입니다.

애플리케이션이 복잡해지고 트래픽이 몰리기 시작하면, 코드 한 줄이 서버의 메모리를 갉아먹거나 순식간에 서버를 다운시키는 원인이 됩니다. OutOfMemoryErrorStackOverflowError 같은 에러 로그를 마주했을 때, JVM의 메모리 구조를 모른다면 문제의 원인을 파악조차 할 수 없습니다.

우리가 작성한 코드가 서버의 메모리 공간 어디에 어떻게 적재되고, 언제 소멸하는지 이해하는 것은 단순히 에러를 잡는 것을 넘어, 더 빠르고 안정적인 고성능 애플리케이션을 설계하기 위한 필수 교양입니다.

오늘은 자바 애플리케이션 메모리 관리의 양대 산맥인 JVM 힙(Heap)과 스택(Stack) 메모리에 대해 깊이 있게 파헤쳐 보겠습니다.

🤦‍♂️ 시행착오 : 메모리 관리 실패

이론을 깊게 파고들기 전에, 개발 과정에서 흔히 겪는 실수들을 먼저 살펴보겠습니다. 개발자들이 JVM 메모리 구조를 간과했을 때 만나는 두 가지 치명적인 에러와 그 원인이 되는 코드입니다.

🚨 무한 루프의 늪, StackOverflowError

스택 메모리는 메서드가 호출될 때마다 그 메서드를 위한 공간인 스택 영역을 순차적으로 쌓아 올립니다. 하지만 이 공간은 무한하지 않습니다.

종료 조건이 명확하지 않은 재귀 함수를 호출하게 되면 지정된 스택 메모리 용량을 초과해버리는 참사가 발생합니다.

public class StackMistake {

    // 종료 조건이 없는 재귀 메서드
    public void recursiveCall() {
        System.out.println("메서드 호출 중...");
        recursiveCall(); // 자기 자신을 계속해서 호출
    }

    public static void main(String[] args) {
        StackMistake mistake = new StackMistake();
        mistake.recursiveCall();
    }
}

위 코드를 실행하면 잠시 후 StackOverflowError가 발생하며 프로그램이 비정상 종료됩니다. 메서드가 호출될 때마다 지역 변수, 매개변수, 리턴 주소 등을 저장하기 위해 스택 메모리를 소비하는데, 반환 없이 호출만 반복되면서 스택 공간이 꽉 차버렸기 때문입니다.

이는 스택 메모리의 한계를 명확히 보여주는 대표적인 실수입니다.

💣 가비지 컬렉터를 속이는 메모리 누수, OutOfMemoryError

힙 메모리는 동적으로 생성된 객체들이 머무는 공간이며, 더 이상 사용되지 않는 객체는 가비지 컬렉터가 알아서 청소해 줍니다.

하지만 우리가 불필요한 객체의 참조를 계속 쥐고 있다면, GC는 이 객체가 아직 사용 중이라고 착각하여 청소하지 못하는데 이것이 메모리 누수(Memory Leak)입니다.

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

public class HeapMistake {

    // static으로 선언된 리스트 (애플리케이션 생명주기와 함께함)
    private static final List<byte[]> leakedMemory = new ArrayList<>();

    public static void main(String[] args) {
        while (true) {
            // 1MB 크기의 배열을 무한히 생성하여 리스트에 추가
            byte[] b = new byte[1048576]; 
            leakedMemory.add(b);
            System.out.println("1MB 객체 추가 완료, 현재 크기: " + leakedMemory.size());

            // 주의: 루프를 빠져나갈 조건이 없으며, 
            // 생성된 객체는 static 리스트에 계속 참조됨
        }
    }
}

이 코드는 java.lang.OutOfMemoryError: Java heap space 에러를 발생시킵니다. 매번 생성되는 1MB 크기의 바이트 배열 객체는 힙 메모리에 올라갑니다. 보통의 경우라면 루프가 끝나거나 변수 범위를 벗어날 때 GC의 대상이 되겠지만, 위 코드에서는 static 키워드로 선언된 전역 리스트가 모든 객체의 참조를 가지고 있습니다.

GC는 참조가 살아있는 객체를 절대 지우지 않기 때문에, 힙 메모리 공간이 가득 찰 때까지 객체가 쌓이다가 결국 서버가 뻗어버리는 것입니다.

🧠 핵심 : JVM 힙과 스택, 완벽하게 파헤치기

이제 본격적으로 자바 애플리케이션이 실행될 때 메모리가 어떻게 동작하는지 뜯어보겠습니다.

JVM은 운영체제로부터 메모리를 할당받아 Runtime Data Area라는 공간을 구성합니다. 이 공간은 여러 영역으로 나뉘지만, 개발자가 가장 민감하게 다루어야 할 두 핵심 영역이 바로 스택(Stack)힙(Heap)입니다.

🏗️ 스택 메모리: 빠르고 엄격한 개인 작업 공간

스택(Stack) 메모리는 스레드(Thread)가 생성될 때마다 하나씩 독립적으로 만들어지는 메모리 영역입니다. 즉, 스프링 부트 웹 서버가 클라이언트의 요청을 받아 새로운 스레드를 생성할 때마다, 해당 스레드 전용의 스택 메모리가 새롭게 할당됩니다.

스택 메모리의 가장 큰 특징은 LIFO(Last In First Out, 후입선출) 구조라는 점입니다. 메서드가 호출될 때마다 해당 메서드만을 위한 블록인 스택 프레임(Stack Frame)이 맨 위에 쌓이고, 메서드의 실행이 끝나면 곧바로 스택에서 제거(Pop)됩니다.

✅ 스택 메모리에 저장되는 것들

  1. 원시 타입 데이터 (Primitive Types): int, double, boolean, byte, char 등 실제 데이터 값이 스택에 직접 저장됩니다.
  2. 참조 변수 (Reference Variables): 객체 자체가 아니라, 힙 영역에 생성된 객체를 가리키는 메모리 주소값이 저장됩니다.
  3. 지역 변수 및 매개변수: 메서드 내부에서 선언된 지역 변수와 메서드 호출 시 전달된 파라미터들이 임시로 저장됩니다.

스택 메모리의 장점은 접근 속도가 매우 빠르다는 것입니다. 메모리의 할당과 해제가 단순히 스택 포인터를 위아래로 움직이는 것만으로 이루어지기 때문에 오버헤드가 거의 없습니다. 또한, 각 스레드가 자신만의 스택을 가지므로 스레드에 안전(Thread-safe)하다는 특징이 있습니다.

동시성 문제를 걱정할 필요 없이 안전하게 데이터를 다룰 수 있는 공간입니다.

하지만 스택 메모리는 크기가 제한적입니다. 앞서 시행착오에서 보았듯 너무 깊은 재귀 호출이나 너무 많은 수의 스택 프레임이 쌓이면 공간이 부족해집니다.

JVM 실행 시 -Xss 옵션을 통해 스택 사이즈를 조절할 수 있지만, 일반적으로는 기본값을 사용하며 코드의 구조적 결함을 고치는 것이 올바른 접근입니다.

🏢 힙 메모리: 객체들의 거대한 공유 광장

힙(Heap) 메모리는 JVM이 관리하는 메모리 중 가장 넓은 영역이며, 애플리케이션의 모든 스레드가 공유하는 거대한 데이터 저장소입니다.

자바 코드에서 new 키워드를 사용하여 생성하는 모든 클래스의 인스턴스(객체)와 배열은 무조건 이 힙 메모리 공간에 할당됩니다.

스택이 잠시 작업하고 빠지는 임시 책상이라면, 힙은 데이터를 오랫동안 보관하는 거대한 창고와 같습니다.

✅ 힙 메모리의 주요 특징

  1. 스레드 간 공유: 스택과 달리 힙은 모든 스레드가 접근할 수 있습니다. 따라서 여러 스레드가 동시에 힙에 있는 같은 객체를 수정하려고 하면 동시성 문제(Race Condition)가 발생할 수 있어, synchronized 블록이나 동시성 유틸리티를 통한 적절한 제어가 필요합니다.
  2. 생명주기가 다름: 메서드 실행이 끝나면 바로 소멸하는 스택의 변수들과 달리, 힙에 생성된 객체는 자신을 가리키는 참조가 모두 사라질 때까지 계속 힙에 남아있습니다.
  3. GC의 관리 대상: 힙 메모리가 무한하지 않기 때문에, 더 이상 사용되지 않는 객체를 찾아내어 메모리에서 해제하는 작업을 GC가 담당합니다.

✅ 힙 메모리의 내부 구조

성능을 극대화하기 위해 힙 영역은 다시 여러 구역으로 나뉩니다. 대부분의 객체는 생성된 직후 금방 쓸모가 없어지고, 오래 살아남는 객체는 극히 드물다는 가설을 기반으로 합니다.

  • Young Generation: 새롭게 생성된 객체들이 위치하는 곳입니다. 이 영역은 다시 Eden 공간과 두 개의 Survivor 공간으로 나뉩니다. 이곳이 가득 차면 Minor GC가 발생하여 살아남은 객체들을 걸러냅니다. 이 과정은 매우 짧고 빠르게 일어납니다.
  • Old Generation: Young Generation에서 발생한 여러 번의 GC 과정에서도 살아남은, 수명이 긴 객체들이 이곳으로 이동(Promotion)합니다. Old 영역이 가득 차면 시스템 전체에 영향을 미치는 Major GC (또는 Full GC)가 발생하며, 이 때는 애플리케이션의 모든 스레드가 일시 정지(Stop-The-World)하는 현상이 일어납니다. 백엔드 성능 튜닝의 핵심은 이 Full GC의 발생 빈도와 시간을 최소화하는 것입니다.

우리가 자주 사용하는 String 역시 객체이므로 힙에 저장됩니다. 특히 자바는 메모리 절약을 위해 힙 내부에 String Constant Pool이라는 특별한 공간을 두어, 리터럴로 생성된 문자열을 캐싱하고 재사용하는 스마트한 메커니즘을 가지고 있습니다.

🤝 스택과 힙의 환상적인 콜라보레이션

개념만으로는 와닿지 않을 수 있습니다. 아주 간단하지만 실무에서 매일 작성하는 코드를 통해 스택과 힙이 어떻게 상호작용하는지 추적해 보겠습니다.

public class MemoryTrace {

    public void processOrder() {
        int orderId = 1001;                     // (1)
        Customer customer = new Customer("Alex"); // (2)
        String status = "PENDING";              // (3)

        customer.updateStatus(status);          // (4)
    }                                           // (5)
}

위 코드가 실행될 때 메모리 내부에서는 다음과 같은 일들이 순차적으로 벌어집니다.

  1. (1) 원시 타입 할당: processOrder 메서드가 호출되면서 현재 스레드의 스택 메모리에 새로운 스택 프레임이 생깁니다. 그리고 그 안에 orderId라는 이름표를 가진 공간이 생기고 1001이라는 실제 정수 값이 저장됩니다. (스택 사용)
  2. (2) 객체 생성 및 참조: new Customer("Alex")가 실행됩니다. 실제 Customer 객체는 힙 메모리에 거대한 덩어리로 생성됩니다. 그리고 생성된 객체의 메모리 주소(예: 0x1A2B)를 리턴합니다. 스택 프레임에는 customer라는 변수가 생기고, 이 변수에는 실제 객체가 아닌 0x1A2B라는 메모리 주소값만 저장됩니다.
  3. (3) 문자열 리터럴: "PENDING"이라는 문자열 리터럴이 생성됩니다. 이는 힙 메모리 내부의 String Constant Pool에 생성되며, 스택에 있는 status 변수는 힙의 풀에 있는 해당 문자열의 주소를 참조하게 됩니다.
  4. (4) 메서드 호출: customer 객체의 메서드를 호출합니다. 스택의 customer 변수가 가지고 있는 주소를 따라 힙 메모리로 이동하여 해당 객체의 데이터를 조작합니다.
  5. (5) 메서드 종료: processOrder 메서드의 실행이 완료되는 순간, 해당 메서드를 위해 만들어졌던 스택 프레임이 스택 메모리에서 통째로 날아갑니다(Pop). 이때 스택에 있던 orderId, customer, status 변수가 모두 사라집니다.

여기서 가장 중요한 점은 스택 프레임이 날아갔기 때문에, 힙 메모리에 남아있는 Customer("Alex") 객체의 참조가 더 이상 존재하지 않게 됩니다. 이 객체는 미아가 되었고, 이를 자바에서는 Unreachable Object라고 부릅니다.

잠시 후 백그라운드에서 GC가 더이상 참조되지 않는 Customer 객체를 발견하고 메모리에서 삭제합니다.

이러한 스택과 힙의 분업 구조 덕분에, 우리는 무거운 객체 데이터를 이리저리 복사할 필요 없이 가벼운 주소값만으로 스택 위에서 빠르게 연산을 처리하고, 거대한 데이터는 힙에 안전하게 보관할 수 있는 것입니다.

💡 백엔드 엔지니어로서의 힙과 스택 튜닝 인사이트

스프링 부트 환경에서 힙과 스택의 원리를 이해했다면, 단순히 코드를 짜는 것을 넘어 아키텍처 관점에서 성능을 고민할 수 있게 됩니다.

  • 동시성 요청과 스택 메모리: 스프링 웹 서버는 기본적으로 사용자의 요청 하나당 스레드 하나를 할당하는 모델을 씁니다. 동시 접속자가 수천 명으로 늘어난다는 것은 스레드가 수천 개 생긴다는 뜻이고, 이는 스택 메모리가 수천 개 할당된다는 의미입니다. 서버의 전체 물리 메모리가 부족하다면 힙뿐만 아니라 스택 메모리의 총량도 한계에 달해 장애를 유발할 수 있습니다.
  • 무거운 로직과 힙 최적화: 한 번의 요청을 처리할 때 내부적으로 수많은 중간 DTO 변환, 대량의 엔티티 조회 등 불필요한 객체 생성을 남발하면 어떻게 될까요? 힙 메모리의 Young Generation이 순식간에 꽉 차게 되고, 끊임없이 Minor GC가 발생하여 CPU 자원을 낭비하게 됩니다. 꼭 필요한 데이터만 DB에서 퍼올리고, 불필요한 객체 생성을 자제하는 것이 백엔드 최적화의 첫걸음입니다.
  • 힙 메모리 설정 최적화: 애플리케이션의 특성에 따라 JVM 옵션을 튜닝해야 합니다. 예를 들어 -Xms(초기 힙 크기)와 -Xmx(최대 힙 크기)를 동일하게 설정하여, 실행 도중 운영체제에 메모리를 추가로 요청하며 발생하는 오버헤드를 막는 것은 아주 대중적인 최적화 기법입니다.

🎯 오늘 내용 3줄 요약

  • 스택 메모리는 각 스레드마다 독립적으로 생성되어 원시 타입 값과 객체의 주소값을 임시로 저장하며, 메서드 종료 시 즉각적으로 비워지는 빠르고 안전한 공간입니다.
  • 힙 메모리는 애플리케이션 전체가 공유하는 거대한 공간으로, new로 생성된 모든 실제 객체 데이터가 저장되며 가비지 컬렉터(GC)에 의해 생명주기가 관리됩니다.
  • 변수는 스택에 생성되어 힙에 있는 객체를 가리키는 참조 구조로 동작하며, 메모리 누수를 막고 성능을 높이려면 불필요한 객체 생성과 무의미한 참조 유지를 피하는 코딩 습관이 필수적입니다.
반응형

댓글