안동민 개발노트

본문 시작

스택·힙·메서드 영역

호출 스택·힙 객체·메서드 영역의 역할을 구분하고 회원 저장소 객체가 생성·전달·회수 가능한 상태가 되는 과정을 추적합니다.

메모리 구조는 특정 주소를 외우는 문제가 아니라 실행 현상을 설명하는 모델입니다.

지역 변수는 왜 호출이 끝나면 사라지는지, 서로 다른 매개변수가 어떻게 같은 객체를 바꾸는지, 객체 참조를 잃으면 왜 더 사용할 수 없는지를 스택·힙·메서드 영역의 역할로 읽습니다.

호출 스택은 현재 실행 중인 메서드의 순서를 쌓아 두는 구조입니다.

정상 호출부터 보면 어렵지 않습니다.

정상 호출 스택의 최소 예제
public static void main(String[] args) {
    first();
}

private static void first() {
    second();
}

private static void second() {
    System.out.println("done");
}

실행 중에는 main → first → second 순서로 스택 프레임이 쌓이고, 출력 뒤에는 second → first → main 순서로 사라집니다.

각 프레임은 그 호출의 매개변수·지역 변수·돌아갈 위치를 보관합니다.

이 정상 흐름을 기준으로 종료되지 않는 재귀가 왜 스택을 소진하는지 살펴봅니다.


재귀 호출과 스택 고갈

lab/RecursiveStackOverflow.java
public final class RecursiveStackOverflow {
    public static void main(String[] args) {
        recurse(1);
    }

    private static void recurse(int depth) {
        if (depth <= 3) {
            System.out.println("depth=" + depth);
        }
        recurse(depth + 1);
    }
}
실패 관찰
depth=1
depth=2
depth=3
Exception in thread "main" java.lang.StackOverflowError

재귀 호출마다 depth 매개변수와 돌아갈 위치를 가진 새 스택 프레임이 쌓입니다.

종료 조건이 없으므로 프레임이 제거되지 않고 스택 영역의 한계에 도달합니다.

StackOverflowError는 힙에 객체가 많다는 뜻이 아니라 호출 깊이가 지나치게 커졌다는 신호입니다.

정상 재귀는 종료 조건을 먼저 둡니다.

private static int sumTo(int value) {
    if (value <= 0) return 0;
    return value + sumTo(value - 1);
}

호출이 가장 깊은 종료점에 도달한 뒤 마지막 프레임부터 반환하며 제거됩니다.

큰 입력에는 반복문이 스택 깊이를 일정하게 유지하는 더 안전한 선택일 수 있습니다.


메모리 영역의 역할

메서드 영역

메서드 영역은 클래스 정보와 메서드 코드를 설명하는 개념적 영역입니다. 이 모델에서는 static도 클래스 소유 상태로 구분하지만, 필드의 물리적 저장 위치를 보장하는 것은 아닙니다.

같은 클래스의 인스턴스를 100개 만들어도 메서드 코드 100벌을 각 객체에 복제하지 않습니다.

스택 영역

메서드 호출마다 스택 프레임을 만들고 매개변수와 지역 변수를 관리합니다.

호출이 끝나면 해당 프레임이 제거됩니다.

지역 참조 변수 자체는 스택 프레임에 있지만 그 변수가 가리키는 객체는 힙에 있습니다.

힙 영역

new로 생성한 객체와 배열이 존재합니다.

여러 참조 변수가 같은 힙 객체를 가리킬 수 있습니다.

더 이상 도달 가능한 참조가 없는 객체는 가비지 컬렉션 대상이 됩니다.

“즉시 삭제되는 정확한 시점”은 개발자가 일반 코드에서 보장하지 않습니다.

JVM 구현 세부는 더 복잡할 수 있지만, 언어 수준의 실행 흐름을 이해할 때 이 구분이 유용합니다.


스택 프레임과 힙 객체

src/StackHeapWalkthrough.java
public final class StackHeapWalkthrough {
    public static void main(String[] args) {
        System.out.println("main:start");
        Member member = new Member("[email protected]", 40);
        celebrateBirthday(member);
        System.out.println("main:" + member.describe());
        System.out.println("main:end");
    }

    private static void celebrateBirthday(Member parameter) {
        System.out.println("birthday:start");
        parameter.celebrateBirthday();
        inspect(parameter);
        System.out.println("birthday:end");
    }

    private static void inspect(Member parameter) {
        System.out.println("inspect:" + parameter.describe());
    }

    private static final class Member {
        private final String email;
        private int age;

        Member(String email, int age) {
            this.email = email;
            this.age = age;
        }

        void celebrateBirthday() {
            if (age >= 120) throw new IllegalStateException("maximum age");
            age++;
        }

        String describe() {
            return email + "=" + age;
        }
    }
}
main:start
birthday:start
inspect:[email protected]=41
birthday:end
main:[email protected]=41
main:end

main 프레임의 member, celebrateBirthday 프레임의 parameter, inspect 프레임의 parameter는 서로 다른 지역 변수입니다.

그러나 세 변수에 복사된 참조값은 같은 Member 힙 객체를 가리킵니다.

inspect 프레임이 사라져도 main의 member가 객체에 도달하므로 객체는 계속 사용됩니다.

메서드 호출 순서는 main → celebrateBirthday → inspect이고 반환은 inspect → celebrateBirthday → main입니다.

후입선출 스택 구조가 호출과 복귀를 자연스럽게 관리합니다.

inspect 진입 시 세 프레임이 같은 Member를 참조한다

StackHeapWalkthrough의 inspect 진입 직후 세 지역 참조가 같은 age 41 Member에 도달하는 호출 모델이다. 뒤이어 describe 호출 프레임이 잠시 추가된다.

StackHeapWalkthrough · inspect 진입 직후

세 호출 프레임의 참조와 하나의 Member 스택 위부터 inspect, 바깥 celebrateBirthday, main이 있고 각 parameter 또는 member가 같은 [email protected]의 age 41 객체를 가리킨다. Member.describe는 아직 호출 전이다. 호출 스택 · 위가 나중에 호출됨 힙 객체 · 개념 모델 inspect 프레임 parameter 다음: Member.describe() 호출 celebrateBirthday 프레임 parameter inspect() 반환을 기다림 main 프레임 member celebrateBirthday() 반환 대기 같은 Member 객체 [email protected] age = 41 객체의 생일 메서드 반환 후
스택 위 · inspect
parameter가 아래의 같은 Member를 가리킵니다. 현재 프레임이며 다음에 Member.describe()를 호출합니다.
그 아래 · celebrateBirthday
parameter가 같은 Member를 가리키며 inspect()의 반환을 기다립니다.
스택 아래 · main
member가 같은 Member를 가리키며 바깥 celebrateBirthday()의 반환을 기다립니다.

공유하는 Member: [email protected], age=41. 객체의 생일 메서드는 이미 반환했습니다.

세 변수는 서로 다르지만 참조 대상은 같습니다. 뒤이은 describe() 호출 때 프레임이 잠시 추가됩니다. 이 세 바깥 호출은 inspect → celebrateBirthday → main 순서로 끝나며, inspect 반환 뒤에도 main.member로 객체를 사용합니다. 물리 주소와 JVM 내부 호출은 표시하지 않습니다.


도달 가능성과 객체 수명

src/ReachabilityTrace.java
public final class ReachabilityTrace {
    public static void main(String[] args) {
        Member first = new Member("[email protected]", 40);
        Member second = first;

        first = null;
        System.out.println(second.describe());

        second = new Member("[email protected]", 50);
        System.out.println(second.describe());
    }

    private record Member(String email, int age) {
        String describe() {
            return email + "=" + age;
        }
    }
}

first를 null로 바꿔도 second가 기존 [email protected] 회원 객체를 가리키므로 도달 가능합니다.

second에 새 [email protected] 회원 객체를 대입하면 기존 [email protected] 회원 객체를 가리키는 지역 참조가 없어집니다.

그 객체는 즉시 사라졌다고 단정하지 않고 가비지 컬렉션 대상이 되었다고 표현합니다.

참조 재대입 뒤 남는 Member 도달 경로

ReachabilityTrace의 별칭 생성 후, first를 null로 바꾼 후, second를 새 Member로 바꾼 후의 지역 참조 경로를 비교한다. GC 실행 시점을 측정한 표가 아니다.

ReachabilityTrace · 각 문장 실행 뒤의 지역 참조 모델
시점firstsecond기존 kim/40으로 가는 경로
second = first 뒤kim/40 객체같은 kim/40 객체두 변수에서 같은 객체로 연결
first = null 뒤null기존 kim/40 객체second 경로가 남음
새 lee/50을 second에 대입한 뒤null새 lee/50 객체그림의 두 지역 변수에서는 경로 없음

ReachabilityTrace · 문장 실행 뒤의 지역 참조 모델

  1. second = first 뒤: first와 second 모두 같은 kim/40 객체를 가리킵니다.
  2. first = null 뒤: first는 null이고 second는 기존 kim/40 객체를 계속 가리킵니다.
  3. 새 lee/50을 second에 대입한 뒤: first는 null, second는 새 객체를 가리킵니다. 두 지역 변수에서 기존 kim/40으로 가는 경로는 없습니다.

kim/40과 lee/50은 각각 [email protected]=40, [email protected]=50인 서로 다른 Member입니다. 출력은 이 순서로 두 줄입니다. 마지막 행은 기존 객체에 대한 참조 경로 제거를 설명하며, 그 순간 실제 메모리가 회수됐다는 뜻은 아닙니다.

System.gc()를 호출해도 특정 객체가 즉시 회수된다는 보장은 없습니다.

자원 파일이나 네트워크 연결의 종료를 GC에 맡기지 않고 try-with-resources 같은 명시적 수명 관리를 사용합니다.


MemberRegistry 실행 메모리

src/MemberRegistryMemoryModel.java
public final class MemberRegistryMemoryModel {
    public static void main(String[] args) {
        MemberRegistry registry = createRegistry();
        registry.add("[email protected]", 40);
        registry.add("[email protected]", 50);
        printTotal(registry);
    }

    private static MemberRegistry createRegistry() {
        return new MemberRegistry(2);
    }

    private static void printTotal(MemberRegistry registry) {
        System.out.println("total=" + registry.totalAge());
    }

    private static final class MemberRegistry {
        private final Member[] members;
        private int size;

        MemberRegistry(int capacity) {
            members = new Member[capacity];
        }

        void add(String email, int age) {
            members[size++] = new Member(email, age);
        }

        int totalAge() {
            int total = 0;
            for (int index = 0; index < size; index++) {
                total += members[index].age();
            }
            return total;
        }
    }

    private record Member(String email, int age) {}
}
total=90

MemberRegistry 객체, Member 배열, 두 Member 객체는 힙에 생성됩니다.

main의 registry와 printTotal의 registry 매개변수는 스택 프레임에 있는 참조 변수입니다.

totalAge의 total과 index도 해당 호출 프레임의 지역 변수입니다.

클래스 정의와 메서드 실행 코드는 메서드 영역의 클래스 정보와 연결됩니다.

MemberRegistry 객체가 배열을 가리키고 배열이 각 Member를 가리키므로 main의 registry 하나에서 전체 객체 그래프에 도달할 수 있습니다.

registry 참조에서 저장소·배열·두 Member에 도달한다

MemberRegistryMemoryModel에서 두 회원 추가 후 printTotal 진입 직후 main과 printTotal의 registry가 같은 저장소에 도달하고 저장소가 길이 2 배열과 두 Member에 이어지는 참조를 보관한다.

MemberRegistryMemoryModel · 두 회원 추가 뒤 printTotal 진입 직후

하나의 저장소로 이어지는 두 지역 참조와 객체 그래프 main과 printTotal의 registry는 같은 size 2 저장소를 참조한다. 저장소의 members는 길이 2 배열을 가리키고 0번은 kim 40, 1번은 lee 50 Member를 가리킨다. 호출 프레임의 지역 참조 힙의 객체 참조 관계 · 개념 모델 main 프레임 registry printTotal 프레임 registry 같은 MemberRegistry size = 2 members Member[] · length = 2 [0] [1] Member [email protected] age = 40 Member [email protected] age = 50
두 지역 참조
main.registry와 printTotal의 registry 매개변수는 같은 MemberRegistry를 가리킵니다.
MemberRegistry 객체
size=2이며 members 필드가 길이 2의 Member 배열을 가리킵니다.
배열의 두 슬롯
[0]은 [email protected]·40세 Member, [1]은 별개의 [email protected]·50세 Member를 가리킵니다.

createRegistry() 호출은 이미 끝났지만 반환한 저장소에 계속 도달합니다. 이어서 totalAge()는 40+50을 합쳐 total=90을 출력하게 합니다. 저장소·배열·Member 두 개만 표시했으며 문자열과 JVM 내부 객체는 생략했습니다.


연습 문제

main이 Counter를 만들고 twice(counter)를 호출하며, twice가 increment(counter)를 두 번 호출하게 하세요.

각 메서드 시작과 종료, 최종 count를 출력해 스택 반환 순서를 확인합니다.

해설 보기
src/CounterStackTrace.java
public final class CounterStackTrace {
    public static void main(String[] args) {
        Counter counter = new Counter();
        System.out.println("main:start");
        twice(counter);
        System.out.println("main:count=" + counter.value);
    }

    private static void twice(Counter counter) {
        System.out.println("twice:start");
        increment(counter);
        increment(counter);
        System.out.println("twice:end");
    }

    private static void increment(Counter counter) {
        System.out.println("increment");
        counter.value++;
    }

    private static final class Counter {
        int value;
    }
}
main:start
twice:start
increment
increment
twice:end
main:count=2

세 메서드의 counter 지역 변수는 서로 다르지만 같은 힙 객체를 가리킵니다.

increment 프레임이 사라져도 객체 필드 변경은 남습니다.