개발이 취미인 사람

[Java] 참조 타입의 메모리 저장 방식 - Stack과 Heap의 관계 본문

언어(Programming Language)/Java

[Java] 참조 타입의 메모리 저장 방식 - Stack과 Heap의 관계

RyanSin 2026. 1. 30. 09:00
반응형

개요

안녕하세요. 이번 시간에는 Java의 **참조 타입(Reference Type)**이 메모리에 어떻게 저장되는지 알아보겠습니다.

이전 글에서 리터럴에 대해 배웠는데, "기본 타입은 Stack에 값이 저장되고, 문자열은 Heap에 저장된다"는 말을 했었죠? 오늘은 이 개념을 확실하게 이해해볼 거예요!

 

"Stack에 주소를 저장한다"는 말이 정확히 무슨 의미인지, 왜 ==과 equals()가 다른 결과를 내는지, null은 무엇을 의미하는지 등을 메모리 관점에서 깊이 있게 다뤄보겠습니다.

이 개념을 이해하면 Call by Value, String Pool, Garbage Collection 등의 고급 주제들이 훨씬 쉬워집니다!

개념

기본 타입 vs 참조 타입

Java의 데이터 타입은 크게 2가지로 나뉩니다.

기본 타입 (Primitive Type): 8가지

byte, short, int, long      // 정수
float, double                // 실수
char                         // 문자
boolean                      // 불린

참조 타입 (Reference Type): 기본 타입을 제외한 모든 것

String, Integer, Double      // 클래스
int[], String[]              // 배열
List, Map, Set               // 컬렉션
Person, Car, Animal          // 사용자 정의 클래스

핵심 차이

구분 기본 타입 참조 타입

저장 위치 Stack에 값 직접 저장 Stack에 주소, Heap에 객체
기본값 타입별 다름 (0, false 등) null
크기 고정 가변
비교 ==로 값 비교 ==로 주소 비교

1. 기본 타입의 메모리 저장

값이 직접 저장됨

int age = 25;
double height = 175.5;
boolean isStudent = true;

메모리 상태:

Stack (main 스레드)
├─ age = 25         ← 값 25가 직접 저장
├─ height = 175.5   ← 값 175.5가 직접 저장
└─ isStudent = true ← 값 true가 직접 저장

특징:

  • 값 자체가 Stack에 저장됩니다
  • 변수 간 복사 시 값이 복사됩니다

기본 타입 복사 예제

public class PrimitiveExample {
    public static void main(String[] args) {
        int a = 10;
        int b = a;  // 값 10이 복사됨
        
        System.out.println("a = " + a);  // 10
        System.out.println("b = " + b);  // 10
        
        b = 20;  // b만 변경
        
        System.out.println("a = " + a);  // 10 (변경 안됨!)
        System.out.println("b = " + b);  // 20
    }
}

메모리 변화:

[초기 상태]
Stack
├─ a = 10
└─ b = 10  (a의 값이 복사됨)

[b = 20 실행 후]
Stack
├─ a = 10  ← 그대로!
└─ b = 20  ← b만 변경

결론: 기본 타입은 값이 독립적으로 저장되므로 서로 영향을 주지 않습니다.


2. 참조 타입의 메모리 저장

주소를 저장함!

String name = "홍길동";

메모리 상태:

Heap (String Pool)
└─ "홍길동" 객체 (주소: 0x1234)

Stack
└─ name = 0x1234  ← 주소를 저장!

핵심 개념: 참조값(Reference)

참조값: Heap에 있는 객체의 메모리 주소

String str = "Hello";
//     ^^^   ^^^^^^^
//     │     └─ 리터럴 "Hello"는 Heap에 저장
//     └─────── 변수 str은 주소를 저장

비유:

택배 상자(객체) → 창고(Heap)에 보관
송장 번호(주소) → 내 수첩(Stack)에 기록

3. 참조 타입 변수의 동작

예제 1: 객체 생성

public class Person {
    String name;
    int age;
}

public class ReferenceExample {
    public static void main(String[] args) {
        Person p1 = new Person();
        p1.name = "홍길동";
        p1.age = 30;
    }
}

메모리 상태:

Heap
└─ Person 객체 (주소: 0x1234)
   ├─ name → "홍길동" (String 객체도 Heap)
   └─ age = 30

Stack
└─ p1 = 0x1234  ← 주소만 저장!

그림으로 보면:

Stack          Heap
┌─────┐       ┌────────────┐
│ p1  │──────→│Person 객체│
└─────┘       │ name: 홍길동│
              │ age: 30   │
              └────────────┘

예제 2: 참조 복사 (중요!)

public class ReferenceExample {
    public static void main(String[] args) {
        Person p1 = new Person();
        p1.name = "홍길동";
        p1.age = 30;
        
        Person p2 = p1;  // 주소가 복사됨!
        
        System.out.println("p1.name: " + p1.name);  // 홍길동
        System.out.println("p2.name: " + p2.name);  // 홍길동
        
        p2.name = "김철수";  // p2를 통해 변경
        
        System.out.println("p1.name: " + p1.name);  // 김철수 (영향받음!)
        System.out.println("p2.name: " + p2.name);  // 김철수
    }
}

메모리 상태:

Heap
└─ Person 객체 (주소: 0x1234)
   ├─ name → "김철수"
   └─ age = 30

Stack
├─ p1 = 0x1234  ┐
└─ p2 = 0x1234  ┘ 같은 주소!

그림으로 보면:

Stack          Heap
┌─────┐       ┌────────────┐
│ p1  │──┐   │Person 객체│
└─────┘  │   │ name: 김철수│
         └──→│ age: 30   │
┌─────┐  │   └────────────┘
│ p2  │──┘
└─────┘

핵심:

  • p2 = p1은 주소를 복사합니다
  • p1과 p2는 같은 객체를 가리킵니다
  • 둘 중 하나로 수정하면 둘 다 영향받습니다!

예제 3: 새 객체 생성

public class ReferenceExample {
    public static void main(String[] args) {
        Person p1 = new Person();
        p1.name = "홍길동";
        p1.age = 30;
        
        Person p2 = new Person();  // 새 객체!
        p2.name = "김철수";
        p2.age = 25;
        
        System.out.println("p1.name: " + p1.name);  // 홍길동
        System.out.println("p2.name: " + p2.name);  // 김철수
        
        p2.name = "이영희";
        
        System.out.println("p1.name: " + p1.name);  // 홍길동 (영향 없음!)
        System.out.println("p2.name: " + p2.name);  // 이영희
    }
}

메모리 상태:

Heap
├─ Person 객체 (주소: 0x1234)
│  ├─ name → "홍길동"
│  └─ age = 30
│
└─ Person 객체 (주소: 0x5678)
   ├─ name → "이영희"
   └─ age = 25

Stack
├─ p1 = 0x1234  ← 첫 번째 객체
└─ p2 = 0x5678  ← 두 번째 객체 (다른 주소!)

핵심: new를 사용하면 새 객체가 생성되어 독립적입니다!


4. == vs equals() 완벽 이해

== 연산자: 주소 비교

public class ComparisonExample {
    public static void main(String[] args) {
        String s1 = "Hello";      // 리터럴
        String s2 = "Hello";      // 같은 리터럴
        String s3 = new String("Hello");  // new 객체
        
        System.out.println(s1 == s2);  // true
        System.out.println(s1 == s3);  // false
    }
}

메모리 상태:

Heap
├─ String Pool
│  └─ "Hello" (0x1234)
│
└─ new String 객체 (0x5678)

Stack
├─ s1 = 0x1234  ┐
├─ s2 = 0x1234  ┘ 같은 주소!
└─ s3 = 0x5678    다른 주소!

== 비교:

s1 == s2  →  0x1234 == 0x1234  →  true
s1 == s3  →  0x1234 == 0x5678  →  false

equals() 메서드: 내용 비교

System.out.println(s1.equals(s2));  // true
System.out.println(s1.equals(s3));  // true (내용이 같음!)

equals() 비교:

s1.equals(s2)  →  "Hello".equals("Hello")  →  true
s1.equals(s3)  →  "Hello".equals("Hello")  →  true

비교 정리

연산자 비교 대상 결과

== 주소 (참조값) 같은 객체인가?
equals() 내용 (값) 값이 같은가?

실전 예제

public class PersonComparison {
    public static void main(String[] args) {
        Person p1 = new Person("홍길동", 30);
        Person p2 = new Person("홍길동", 30);
        Person p3 = p1;
        
        // == 비교 (주소)
        System.out.println(p1 == p2);  // false (다른 객체)
        System.out.println(p1 == p3);  // true (같은 객체)
        
        // equals 비교 (내용) - equals 오버라이드 필요
        System.out.println(p1.equals(p2));  // false (오버라이드 안 함)
    }
}

메모리:

Heap
├─ Person("홍길동", 30) - 0x1234
└─ Person("홍길동", 30) - 0x5678  ← 다른 객체!

Stack
├─ p1 = 0x1234  ┐
├─ p2 = 0x5678  │ 다른 주소!
└─ p3 = 0x1234  ┘ p1과 같은 주소!

5. null의 의미

null = 참조가 없음

String str = null;

메모리 상태:

Heap
(비어있음)

Stack
└─ str = null  ← 아무것도 가리키지 않음

그림으로:

Stack
┌─────┐
│ str │──X (가리키는 것 없음)
└─────┘

null 예제

public class NullExample {
    public static void main(String[] args) {
        String str = null;
        
        // NullPointerException 발생!
        // System.out.println(str.length());
        
        // null 체크 필수
        if (str != null) {
            System.out.println(str.length());
        } else {
            System.out.println("str은 null입니다");
        }
        
        // null 할당
        Person p = new Person();
        p = null;  // 더 이상 객체를 참조하지 않음 → GC 대상
    }
}

기본 타입은 null 불가!

int x = null;      // 컴파일 에러!
boolean b = null;  // 컴파일 에러!

Integer x = null;  // OK! (참조 타입)
Boolean b = null;  // OK! (참조 타입)

6. 배열의 메모리 저장

배열도 참조 타입!

int[] arr = {10, 20, 30};

메모리 상태:

Heap
└─ int[] 배열 객체 (0x1234)
   ├─ [0] = 10
   ├─ [1] = 20
   └─ [2] = 30

Stack
└─ arr = 0x1234

배열 복사 예제

public class ArrayExample {
    public static void main(String[] args) {
        int[] arr1 = {10, 20, 30};
        int[] arr2 = arr1;  // 주소 복사!
        
        System.out.println("arr1[0]: " + arr1[0]);  // 10
        System.out.println("arr2[0]: " + arr2[0]);  // 10
        
        arr2[0] = 100;  // arr2를 통해 변경
        
        System.out.println("arr1[0]: " + arr1[0]);  // 100 (영향받음!)
        System.out.println("arr2[0]: " + arr2[0]);  // 100
    }
}

메모리:

Heap
└─ int[] 배열 (0x1234)
   ├─ [0] = 100
   ├─ [1] = 20
   └─ [2] = 30

Stack
├─ arr1 = 0x1234  ┐
└─ arr2 = 0x1234  ┘ 같은 배열!

7. 메서드 파라미터 전달

기본 타입 전달

public class ParamExample {
    static void change(int x) {
        x = 100;
        System.out.println("메서드 내부: " + x);  // 100
    }
    
    public static void main(String[] args) {
        int num = 10;
        change(num);
        System.out.println("메서드 외부: " + num);  // 10 (변경 안됨!)
    }
}

메모리:

[change 호출 전]
Stack (main)
└─ num = 10

[change 호출]
Stack (main)
└─ num = 10  ← 그대로!

Stack (change)
└─ x = 10  ← 복사된 값

참조 타입 전달

public class ParamExample {
    static void change(Person p) {
        p.name = "김철수";
        System.out.println("메서드 내부: " + p.name);  // 김철수
    }
    
    public static void main(String[] args) {
        Person person = new Person();
        person.name = "홍길동";
        
        change(person);
        System.out.println("메서드 외부: " + person.name);  // 김철수 (변경됨!)
    }
}

메모리:

Heap
└─ Person 객체 (0x1234)
   └─ name = "김철수"

Stack (main)
└─ person = 0x1234

Stack (change)
└─ p = 0x1234  ← 같은 주소 복사!

핵심: 주소가 복사되므로 같은 객체를 가리킴 → 객체 내부 수정 가능!

자세한 내용은 [Call by Value vs Call by Reference] 글을 참고하세요!


8. 실전 예제

예제 1: 학생 정보 관리

class Student {
    String name;
    int score;
    
    Student(String name, int score) {
        this.name = name;
        this.score = score;
    }
}

public class StudentExample {
    public static void main(String[] args) {
        // 학생 2명 생성
        Student s1 = new Student("홍길동", 90);
        Student s2 = new Student("김철수", 85);
        
        // s3는 s1과 같은 학생
        Student s3 = s1;
        
        // s1의 점수 변경
        s1.score = 95;
        
        System.out.println("s1 점수: " + s1.score);  // 95
        System.out.println("s2 점수: " + s2.score);  // 85
        System.out.println("s3 점수: " + s3.score);  // 95 (s1과 같음!)
    }
}

메모리:

Heap
├─ Student("홍길동", 95) - 0x1234
└─ Student("김철수", 85) - 0x5678

Stack
├─ s1 = 0x1234  ┐
├─ s2 = 0x5678  │
└─ s3 = 0x1234  ┘ s1과 같은 객체!

예제 2: null 체크의 중요성

public class NullCheckExample {
    static void printLength(String str) {
        // 나쁜 예
        // System.out.println(str.length());  // NullPointerException 위험!
        
        // 좋은 예
        if (str != null) {
            System.out.println("길이: " + str.length());
        } else {
            System.out.println("str이 null입니다");
        }
    }
    
    public static void main(String[] args) {
        String s1 = "Hello";
        String s2 = null;
        
        printLength(s1);  // 길이: 5
        printLength(s2);  // str이 null입니다
    }
}

예제 3: 배열과 참조

public class ArrayReferenceExample {
    public static void main(String[] args) {
        int[] original = {1, 2, 3, 4, 5};
        int[] copy = original;  // 주소 복사
        
        // 진짜 복사 (새 배열 생성)
        int[] realCopy = new int[original.length];
        for (int i = 0; i < original.length; i++) {
            realCopy[i] = original[i];
        }
        
        // 또는 Arrays.copyOf() 사용
        // int[] realCopy = Arrays.copyOf(original, original.length);
        
        original[0] = 100;
        
        System.out.println("original[0]: " + original[0]);  // 100
        System.out.println("copy[0]: " + copy[0]);          // 100 (영향받음)
        System.out.println("realCopy[0]: " + realCopy[0]);  // 1 (영향 없음!)
    }
}

핵심 정리

1. 기본 타입 vs 참조 타입

기본 타입: Stack에 값 직접 저장
참조 타입: Stack에 주소, Heap에 객체

2. 참조 복사의 의미

Person p1 = new Person();
Person p2 = p1;  // 주소 복사!
p1과 p2는 같은 객체를 가리킴
→ 하나를 수정하면 둘 다 영향받음

3. == vs equals()

연산자 기본 타입 참조 타입

== 값 비교 주소 비교
equals() - 내용 비교

4. null의 의미

String str = null;  // 아무것도 가리키지 않음
참조가 없다 = null
기본 타입은 null 불가!

5. 메모리 구조

Stack (Thread별 독립)
├─ 지역 변수
│  ├─ 기본 타입: 값 직접
│  └─ 참조 타입: 주소만
└─ 메서드 호출 정보

Heap (모든 Thread 공유)
├─ 객체 (new로 생성)
├─ 배열
└─ String Pool

마무리

이번 시간에는 참조 타입의 메모리 저장 방식에 대해 알아봤습니다.

특히 "Stack에 주소를 저장하고 Heap에 객체를 저장한다"는 개념을 정확히 이해하는 것이 중요합니다. 이 개념을 알면:

  • ==과 equals()의 차이를 이해할 수 있습니다
  • 왜 참조 복사가 위험한지 알 수 있습니다
  • null이 무엇을 의미하는지 알 수 있습니다
  • Call by Value의 원리를 이해할 수 있습니다

다음 시간에는 이 개념을 바탕으로 String & String Pool에 대해 깊이 있게 알아보겠습니다!

꼭 실습을 통해 학습하신 걸 추천드리겠습니다!!

참고 자료