메모리 구조와 Call by Value

허정석·2025년 12월 6일

TIL

목록 보기
15/19
post-thumbnail

메모리 구조와 Call by Value

개요

프로세스와 스레드가 메모리를 어떻게 사용하는지 이해하는 것은 실무에서 발생하는 많은 문제를 해결하는 열쇠입니다. "Heap memory 부족" 에러, 멀티스레드 환경의 동시성 문제, 메서드 파라미터 전달 시 원본 변경 여부 등 모든 질문의 답은 메모리 구조에 있습니다.

이 글에서는 Heap과 Stack의 차이, 참조 변수와 객체의 관계, Java의 Call by Value 메커니즘, 스레드 간 메모리 공유 방식을 다룹니다.


Heap과 Stack: 두 가지 메모리 영역

Stack: 메서드 실행의 작업 공간

Stack은 LIFO(Last In First Out) 구조로 동작하며, 메서드 호출과 생명주기를 함께합니다. 메서드가 호출되면 Stack에 프레임이 생성되고, 메서드가 종료되면 프레임이 pop되어 즉시 소멸합니다.

Stack의 특징

  • 빠른 접근 속도
  • 크기가 작음 (보통 1MB 내외)
  • 각 스레드마다 독립적으로 존재

Stack에 저장되는 데이터

  • Primitive 타입 변수 (int, boolean, char 등)
  • 참조 변수 (객체의 주소값)
  • 메서드 호출 정보 (매개변수, 리턴 주소)

Heap: 객체가 살아가는 공간

Heap은 자유 메모리 영역으로 순차적 구조가 아니며, GC(Garbage Collector)가 관리합니다. Stack과 달리 접근 속도가 상대적으로 느리지만 크기가 크고 설정 가능합니다.

Heap의 특징

  • GC가 메모리 관리
  • 크기가 큼 (설정 가능, 수 GB까지)
  • 모든 스레드가 공유

Heap에 저장되는 데이터

  • new로 생성한 모든 객체
  • 배열
  • String 객체

Stack과 Heap 메모리 구조

┌─────────────────────────────────────────────────────────┐
│                    Process Memory                        │
├─────────────────────────────────────────────────────────┤
│  Stack (Thread 1)     │  Stack (Thread 2)               │
│  ┌──────────────┐     │  ┌──────────────┐              │
│  │ method3()    │     │  │ method2()    │              │
│  ├──────────────┤     │  ├──────────────┤              │
│  │ method2()    │     │  │ method1()    │              │
│  ├──────────────┤     │  └──────────────┘              │
│  │ method1()    │     │                                 │
│  └──────────────┘     │                                 │
│  독립적 Stack          │  독립적 Stack                   │
├─────────────────────────────────────────────────────────┤
│                    Heap (공유 영역)                      │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐             │
│  │ Object 1 │  │ Object 2 │  │ Object 3 │             │
│  └──────────┘  └──────────┘  └──────────┘             │
│  모든 스레드가 접근 가능                                 │
└─────────────────────────────────────────────────────────┘

Stack의 생명주기: 메서드 호출과 함께

Stack은 LIFO 구조로 동작하므로 가장 나중에 호출된 메서드가 가장 먼저 종료됩니다. 프레임이 pop되면 안의 모든 지역 변수가 즉시 사라집니다.

public class StackLifecycle {
    public static void main(String[] args) {
        int a = 10;
        methodA(a);
        System.out.println(a); // 10
    }
  
    static void methodA(int num) {
        int b = 20;
        int result = num + b;
        methodB(result);
    }  // b, result, num 모두 소멸
  
    static void methodB(int value) {
        int c = 30;
        System.out.println(value + c); // 60
    }  // c, value 소멸
}

Stack 프레임 변화 과정

[1] main 시작
Stack: [main: a=10]

[2] methodA 호출
Stack: [main: a=10] [methodA: num=10, b=20, result=30]

[3] methodB 호출
Stack: [main: a=10] [methodA: num=10, b=20, result=30] [methodB: value=30, c=30]

[4] methodB 종료 - POP
Stack: [main: a=10] [methodA: num=10, b=20, result=30]

[5] methodA 종료 - POP
Stack: [main: a=10]

[6] main 종료 - POP
Stack: [ ]

Heap의 생명주기: 참조가 없어지면

Heap 객체는 참조가 없어진 후 GC가 실행될 때 회수됩니다. Stack과 달리 즉시 소멸하지 않습니다.

public class HeapLifecycle {
    public static void main(String[] args) {
        Person p1 = new Person("Alice"); // Heap에 객체 생성
        Person p2 = p1;  // 같은 객체를 가리킴
      
        p1 = null;  // p1 참조 끊음
        // 하지만 p2가 여전히 참조 중 → 객체 살아있음
      
        p2 = null;  // p2도 참조 끊음
        // 이제 아무도 참조 안 함 → GC 대상
    }
}

메모리 변화 과정

[1] Person p1 = new Person("Alice");
Stack: [p1 → 0x1234]
Heap:  [0x1234: Person("Alice")]

[2] Person p2 = p1;
Stack: [p1 → 0x1234, p2 → 0x1234]
Heap:  [0x1234: Person("Alice")] ← 참조 2개

[3] p1 = null;
Stack: [p1 → null, p2 → 0x1234]
Heap:  [0x1234: Person("Alice")] ← 참조 1개 (아직 살아있음)

[4] p2 = null;
Stack: [p1 → null, p2 → null]
Heap:  [0x1234: Person("Alice")] ← 참조 0개 (GC 대상)

[5] GC 실행 후
Heap:  [ ] ← 회수됨

참조가 없어지는 세 가지 방법

public class ReferenceLost {
    public static void main(String[] args) {
        // 방법 1: 스코프를 벗어남
        {
            Person p1 = new Person("A");
        } // p1 사라짐 → 0xAAA 객체는 GC 대상
      
        // 방법 2: null 할당
        Person p2 = new Person("B");
        p2 = null;  // 0xBBB 객체는 GC 대상
      
        // 방법 3: 다른 객체로 재할당
        Person p3 = new Person("C");
        p3 = new Person("D");  // 0xCCC 객체는 GC 대상
    }
}

참조 변수와 객체: 가장 중요한 구분

핵심 원리

Person p = new Person("Alice");

이 한 줄은 두 가지 작업을 수행합니다.

[Stack에서]
변수 p 생성 → 주소값 0x1234 저장

[Heap에서]
0x1234 번지에 Person 객체 생성

메모리 구조 상세

Stack:
┌──────────────────┐
│ p                │
│ [0x1234]         │ ← 참조 변수 (주소만 저장)
└──────────────────┘
         │
         │ 가리킴
         ↓
Heap:
┌────────────────────────┐
│ 0x1234 번지:           │
│ ┌────────────────────┐ │
│ │ Person 객체        │ │
│ │ - name: "Alice"    │ │
│ │ - age: 0           │ │
│ └────────────────────┘ │
└────────────────────────┘

p는 객체가 아니라 주소를 담는 상자입니다.

참조 복사의 동작 원리

public class ReferenceExample {
    public static void main(String[] args) {
        Person p1 = new Person("Alice");
        Person p2 = p1;  // 주소값 복사
      
        p1.name = "Bob";
        System.out.println(p2.name); // "Bob" ← 같은 객체
      
        p1 = new Person("Charlie");
        System.out.println(p1.name); // "Charlie"
        System.out.println(p2.name); // "Bob" ← p2는 원래 객체
    }
}
[초기 상태]
Stack: [p1 → 0x1111]
Heap:  [0x1111: Person("Alice")]

[Person p2 = p1]
Stack: [p1 → 0x1111, p2 → 0x1111]  ← 같은 주소
Heap:  [0x1111: Person("Alice")]

[p1.name = "Bob"]
Stack: [p1 → 0x1111, p2 → 0x1111]
Heap:  [0x1111: Person("Bob")]  ← 둘 다 같은 객체 참조

[p1 = new Person("Charlie")]
Stack: [p1 → 0x2222, p2 → 0x1111]  ← p1만 변경
Heap:  [0x1111: Person("Bob"), 0x2222: Person("Charlie")]

Call by Value: Java의 핵심 메커니즘

정의

Java는 항상 Call by Value로 동작합니다. 메서드에 값을 전달할 때 원본 값을 복사해서 전달합니다. 핵심은 무엇을 복사하느냐입니다.

Call by Value란 변수에 저장된 값(value)을 복사한다는 뜻이며, 그 값이 기본형이면 실제 값, 참조형이면 객체를 가리키는 참조값이다.

Primitive Type의 Call by Value

public class PrimitiveCallByValue {
    public static void main(String[] args) {
        int x = 10;
        System.out.println("Before: " + x);  // 10
      
        changeValue(x);
      
        System.out.println("After: " + x);   // 10 ← 안 바뀜
    }
  
    static void changeValue(int num) {
        num = 100;
        System.out.println("Inside: " + num); // 100
    }
}

메모리 동작 과정

[changeValue(x) 호출]
main Stack:          changeValue Stack:
  - x = 10             - num = 10 (복사본)

[num = 100 실행]
main Stack:          changeValue Stack:
  - x = 10 (안 바뀜)    - num = 100

→ num을 바꿔도 x는 안 바뀜 (복사본이므로)

Reference Type의 Call by Value

public class ReferenceCallByValue {
    public static void main(String[] args) {
        Person p1 = new Person("Alice");
        System.out.println("Before: " + p1.name);  // Alice
      
        changeName(p1);
        System.out.println("After: " + p1.name);   // Bob ← 바뀜
      
        changeReference(p1);
        System.out.println("Final: " + p1.name);   // Bob ← 안 바뀜
    }
  
    static void changeName(Person p) {
        p.name = "Bob";  // 객체 속성 수정
    }
  
    static void changeReference(Person p) {
        p = new Person("Charlie");  // 변수 재할당
    }
}

changeName(p1) 호출 시 메모리 동작

[호출]
main Stack: p1 = 0x1111
changeName Stack: p = 0x1111 (주소값 복사)
Heap: 0x1111: Person("Alice")

→ p1과 p는 같은 객체(0x1111)를 가리킴

[p.name = "Bob" 실행]
Heap: 0x1111: Person("Bob")

→ 같은 객체를 수정했으므로 둘 다 "Bob" 참조

changeReference(p1) 호출 시 메모리 동작

[호출]
main Stack: p1 = 0x1111
changeReference Stack: p = 0x1111 (주소값 복사)

[p = new Person("Charlie") 실행]
main Stack: p1 = 0x1111 (안 바뀜)
changeReference Stack: p = 0x2222 (p만 변경)
Heap: 0x1111: Person("Bob"), 0x2222: Person("Charlie")

→ p와 p1은 별개의 변수이므로 p를 바꿔도 p1은 안 바뀜

Call by Value 증명

public class ProofCallByValue {
    public static void main(String[] args) {
        Person p1 = new Person("Alice");
        tryToChange(p1);
        System.out.println(p1.name);  // Alice ← 안 바뀜
    }
  
    static void tryToChange(Person p) {
        p = new Person("Bob");
        // p만 변경, p1은 안 바뀜
        // 만약 Call by Reference였다면 p1도 변경되어야 함
    }
}

핵심 정리

  • Primitive Type: 값 자체를 복사
  • Reference Type: 주소값을 복사
  • 둘 다 Call by Value (복사하기 때문)
  • 매개변수로 받은 변수 자체는 원본을 바꿀 수 없음
  • 객체 속성 수정은 원본에 영향 (같은 객체를 가리키므로)

스레드와 메모리: 독립 vs 공유

각 스레드는 독립적인 Stack

public class ThreadStackExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            int x = 10;  // t1의 Stack에 저장
            method();
        });
      
        Thread t2 = new Thread(() -> {
            int x = 20;  // t2의 Stack에 저장 (독립적)
            method();
        });
      
        t1.start();
        t2.start();
    }
  
    static void method() {
        int local = 100; // 각 스레드의 Stack에 따로 생성
    }
}

메모리 구조

[Main Thread Stack]
  - t1 → 0x7777
  - t2 → 0x8888

[t1 Thread Stack] - 독립적
  - x = 10
  - local = 100

[t2 Thread Stack] - 독립적
  - x = 20
  - local = 100

→ 각 스레드의 지역 변수는 서로 간섭하지 않음

Heap은 모든 스레드가 공유

class Counter {
    int count = 0;  // Heap에 저장
}

public class HeapSharing {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter(); // Heap에 하나만 생성
      
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.count++;  // 같은 객체 수정
            }
        });
      
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.count++;  // 같은 객체 수정
            }
        });
      
        t1.start();
        t2.start();
        t1.join();
        t2.join();
      
        System.out.println(counter.count); // 2000이 아닐 수 있음
    }
}

2000이 안 나올 수 있는 이유

[Main Thread Stack]
  - counter → 0x5555

[Heap - 공유]
  - 0x5555: Counter { count: 0 }

[t1, t2 동시 실행]
두 스레드가 같은 0x5555 객체를 동시에 수정
→ 동시성 문제 발생 가능

실무 예제: 대용량 처리에서 메모리 관리

로그를 많이 찍으면 Heap 부족

문제 코드

@Service
public class DataProcessor {
    private static final Logger log = LoggerFactory.getLogger(DataProcessor.class);
  
    public void processBigData(List<Data> dataList) {
        for (Data data : dataList) {
            // 나쁜 예: 매번 String 객체 생성
            log.info("Processing: " + data.toString()); // Heap 압박
        }
    }
}

문제점

  • 로그 메시지는 String 객체로 Heap에 저장됩니다
  • 대용량 처리 시 수만~수십만 개의 String 생성
  • GC가 따라가지 못하면 Heap 부족 발생

개선 코드

@Service
public class DataProcessor {
    private static final Logger log = LoggerFactory.getLogger(DataProcessor.class);
  
    public void processBigData(List<Data> dataList) {
        // 좋은 예: 시작/종료만 로깅
        log.info("Starting batch process. Total items: {}", dataList.size());
      
        for (Data data : dataList) {
            // 처리 로직
        }
      
        log.info("Batch process completed");
      
        // 또는 샘플링
        if (processedCount % 1000 == 0) {
            log.info("Processed {} items", processedCount);
        }
    }
}

메서드 파라미터로 객체 전달

@Service
public class UserService {
    public void updateUser(User user) {
        // 가능: 객체 속성 수정
        user.setName("Updated Name");
        user.setEmail("new@email.com");
        // → 원본 객체가 수정됨
    }
  
    public void reassignUser(User user) {
        // 불가능: 변수 재할당
        user = new User("New User");
        // → 원본 변수는 안 바뀜
    }
  
    // 재할당이 필요하면 반환값 사용
    public User createNewUser(User oldUser) {
        return new User("New User");
    }
}

FAQ

Heap은 FIFO 구조인가?

Heap은 Queue 자료구조가 아니라 자유 메모리 영역입니다. FIFO/LIFO 같은 순차적 구조가 없으며, 필요할 때 동적으로 메모리를 할당하고 해제하는 공간입니다.

"참조가 없어진다"는 정확히 무슨 의미인가?

주소값으로 가는 경로(참조 변수)가 모두 사라지는 것을 의미합니다.

Person p = new Person("Alice"); // p → 0x1234
p = null; // 0x1234로 가는 길이 끊김 → 참조 없음

주소값(0x1234) 자체는 Heap에 여전히 존재하지만, 그곳으로 가는 변수가 없으면 접근 불가능하므로 GC 대상이 됩니다.

new로 생성한 것은 Heap인데, 왜 Stack에 저장된다고 하나?

Person p = new Person("Alice");
  • new Person("Alice"): Heap에 객체 생성
  • p 참조 변수: Stack에 주소값만 저장

객체는 Heap, 참조 변수는 Stack입니다.

전역 변수라서 스레드 간 공유되는 건가?

정확하지 않습니다.

public static void main(String[] args) {
    Person obj = new Person(); // 지역 변수
  
    Thread t1 = new Thread(() -> obj.name = "A");
    Thread t2 = new Thread(() -> obj.name = "B");
}
  • obj는 main의 지역 변수입니다
  • 공유되는 이유는 obj가 전역이어서가 아니라, obj가 가리키는 Heap 객체를 여러 스레드가 접근할 수 있기 때문입니다
  • 람다가 obj의 주소값을 캡처해서 같은 Heap 객체에 접근합니다

Call by Value인데 왜 객체가 바뀌나?

Java는 100% Call by Value입니다. Reference Type의 경우 주소값을 복사합니다.

void changeName(Person p) {
    p.name = "Bob"; // p가 가리키는 객체를 수정 → 원본도 "Bob"
}

void changeReference(Person p) {
    p = new Person("Charlie"); // p 변수만 재할당 → 원본은 안 바뀜
}
  • 객체 속성 수정: 같은 주소를 가리키므로 원본도 변경
  • 변수 재할당: p와 원본은 별개 변수라 원본 안 바뀜

Stack 프레임이 pop된다는 게 배열의 마지막 요소 제거와 같나?

Stack은 LIFO 구조이므로:

  • 가장 나중에 push된 프레임(메서드)이 가장 먼저 pop
  • 프레임이 pop되면 그 안의 모든 지역 변수가 한 번에 사라짐
  • 배열에서 마지막 요소를 제거하는 것과 동일한 원리입니다

람다 캡처 메커니즘이란?

람다가 외부 변수를 "기억"하는 방식입니다.

Person person = new Person("Alice");
Runnable r = () -> System.out.println(person.name);

컴파일러가 내부적으로:

class Lambda {
    private final Person capturedPerson;
  
    Lambda(Person person) {
        this.capturedPerson = person; // 주소값 복사
    }
}
  • Primitive: 값 복사
  • Reference: 주소값 복사
  • 여러 람다가 같은 주소를 캡처하면 같은 Heap 객체 공유

핵심 요약

Stack

  • LIFO 구조, 메서드와 생명주기 동일
  • Primitive 값, 참조 변수(주소), 메서드 정보 저장
  • 각 스레드마다 독립적
  • 메서드 종료 시 즉시 소멸

Heap

  • 자유 메모리 영역, GC 관리
  • 객체, 배열, String 저장
  • 모든 스레드가 공유
  • 참조 없어지면 GC가 회수

참조 변수와 객체

  • 참조 변수: 주소를 담는 상자 (Stack)
  • 객체: 실제 데이터 (Heap)
  • 변수는 객체가 아닌 주소만 저장

Call by Value

  • Java는 항상 Call by Value
  • Primitive: 값 복사
  • Reference: 주소값 복사
  • 매개변수 재할당은 원본에 영향 없음
  • 객체 속성 수정은 원본에 영향 (같은 객체를 가리키므로)

1개의 댓글

comment-user-thumbnail
2025년 12월 7일

Call by Value 면접때 처음 듣고 어버버했던 기억이.. 크흡 잘 읽었습니당

답글 달기