소프트웨어 개발/웹

Java 메모리 구조와 == equals 차이 | Stack·Heap·Metaspace, Integer 캐시, String 불변, 가비지 컬렉션(Garbage Collection)

boradora 2026. 8. 11. 17:04

작업일2026. 08

Stack·Heap Metaspace 기본형·참조형 == vs equals Integer 캐시 String 불변 StringBuilder Garbage Collection

Java 코드에서 Integer a = 100, b = 100 은 a == b 가 true 인데, 값만 1000으로 바꾸면 false 가 됩니다. 문법을 몰라서 생기는 일이 아니라 값이 어디에 저장되는지를 몰라서 생기는 일이라, 메모리 구조부터 잡고 거기서 갈라져 나오는 것들을 순서대로 정리했습니다.

왜 == 로 문자열을 비교하면 안 될까요?
== 는 변수에 담긴 값을 그대로 비교합니다. 기본형 변수에는 숫자가 들어 있지만 참조형 변수에 들어 있는 것은 객체의 주소라서, 내용이 같아도 객체가 두 개면 주소가 달라 false 가 나옵니다.

JVM 이 메모리를 나누는 방식, 기본형과 참조형의 저장 위치, == 와 equals(), String 이 불변인 이유, 가비지 컬렉션 순서로 정리하겠습니다. 문법적인 것 보다도 "실행 중에 값이 어디에 어떻게 놓이는가"를 따라가도록 구성했습니다.

JVM 이 메모리를 나누는 방식

Java 프로그램은 OS 위에서 바로 도는 것이 아니라 JVM 위에서 도는 것이 특징입니다. .java 를 컴파일하면 .class 바이트코드가 나오고, JVM 이 그 바이트코드를 읽어 실행합니다. 같은 .class 파일이 Windows 에서도 macOS 에서도 도는 이유가 이 한 겹 때문입니다.

궁금!코드를 고치고 컴파일할 때마다 rm -rf bin 을 해야 하나요?

평소에는 필요 없습니다. javac -d bin *.java 만 반복하면 같은 이름의 .class 를 덮어쓰기 때문에 최신 코드가 반영됩니다.

주의할 자리는 하나입니다. javac 는 자기가 만든 파일만 덮어쓰고 남아 있는 .class 는 건드리지 않습니다. 그래서 파일 이름을 바꾸면 옛날 것이 그대로 남습니다.

1. Calculator.java 컴파일   →  bin/Calculator.class
2. 파일 이름을 Calc.java 로 변경
3. javac -d bin *.java      →  bin/Calc.class 추가

   bin/
   ├── Calc.class          ← 새 것
   └── Calculator.class    ← 원본은 없는데 남아 있음

이 상태에서 java -cp bin Calculator 를 치면 지운 줄 알았던 옛날 코드가 실행됩니다. 고친 것이 반영 안 된 것처럼 보여서 한참 헤맬 수 있습니다.

그래서 rm -rf bin 은 두 경우에만 하면 됩니다. 클래스나 파일 이름을 바꿨을 때, 그리고 분명히 고쳤는데 실행 결과가 그대로일 때 입니다.

rm -rf bin && javac -d bin *.java

JVM 은 OS 에게서 받아온 메모리를 다섯 구역으로 나눠 씁니다. 이 구역을 Runtime Data Area 라고 부르고, 어떤 구역은 스레드끼리 공유하고 어떤 구역은 스레드마다 따로 있습니다. 이 구분이 뒤에 나올 동시성 문제의 출발점입니다.

구역 담는 것 공유 여부
Heap new 로 만든 모든 객체와 배열 모든 스레드가 공유
Method Area (Metaspace) 클래스 메타데이터, 메서드 바이트코드, static 변수 모든 스레드가 공유
Java Stack 메서드 호출 프레임, 지역 변수, 기본형 값 스레드마다 하나씩
PC Register 지금 실행 중인 바이트코드의 위치 스레드마다 하나씩
Native Method Stack C·C++ 로 작성된 네이티브 메서드 호출 스레드마다 하나씩

실제로 신경 쓰게 되는 것은 앞의 셋입니다. 메서드를 호출하면 Stack 에 프레임이 하나 쌓이고, 메서드가 끝나면 그 프레임이 통째로 사라집니다. 그래서 지역 변수는 따로 정리할 필요가 없습니다. 반면 new 로 만든 객체는 Heap 에 남아 있고, 아무도 가리키지 않게 됐을 때 가비지 컬렉터가 치웁니다.

Stack : 스레드마다 하나
메서드가 호출될 때 프레임이 쌓이고 끝나면 사라집니다. 크기가 정해진 값(기본형)과 객체의 주소가 여기에 놓입니다. 스레드마다 따로 있어서 지역 변수는 다른 스레드가 건드릴 수 없습니다.
Heap : 모든 스레드가 공유
new 로 만든 객체와 배열이 놓입니다. 크기가 실행 중에 정해지고 수명도 제각각이라 GC 가 관리합니다. 공유 구역이라 여러 스레드가 같은 객체를 동시에 고치면 값이 깨집니다.

Metaspace 는 조금 성격이 다릅니다. 클래스 로더가 .class 파일을 읽어 올려두는 곳으로, 클래스 이름·부모 클래스·필드 목록·메서드 시그니처·어노테이션 정보가 여기에 들어갑니다. Java 8 이전에는 Heap 안의 PermGen 이었는데 지금은 Heap 바깥의 네이티브 메모리를 씁니다. 리플렉션이 읽는 정보가 바로 이 구역에 있는 것이고, 그 이야기는 다음 글에서 다뤄보겠습니다.

기본형과 참조

Java 의 타입은 기본형(primitive) 여덟 개와 그 나머지인 참조형(reference)으로 갈립니다. 기본형은 값을 그대로 담고, 참조형 변수는 객체가 놓인 주소를 담습니다. 크기가 컴파일 시점에 정해진 값만 Stack 에 직접 놓을 수 있어서 이렇게 갈라져 있습니다.

기본형 크기 기본값 범위·비고
byte 1바이트 0 -128 ~ 127
short 2바이트 0 -32,768 ~ 32,767
int 4바이트 0 약 -21억 ~ 21억, 정수의 기본값
long 8바이트 0L 리터럴 끝에 L 을 붙임
float 4바이트 0.0f 리터럴 끝에 f 를 붙임
double 8바이트 0.0 실수의 기본값
char 2바이트 '' 유니코드 문자 하나
boolean 논리적 1비트 false true 또는 false

기본형 여덟 개에는 각각 짝이 되는 래퍼 클래스(Integer, Double, Boolean 등)가 있습니다. 컬렉션은 객체만 담을 수 있어서 List<int> 는 못 쓰고 List<Integer> 를 씁니다. 기본형을 래퍼로 바꾸는 것을 박싱, 되돌리는 것을 언박싱이라고 하고, 컴파일러가 알아서 해 줍니다.

int primitive = 100;
Integer wrapper = primitive;   // 오토박싱   : int  → Integer
int back = wrapper;            // 오토언박싱 : Integer → int

List<Integer> scores = new ArrayList<>();
scores.add(90);                // 90 이 Integer.valueOf(90) 으로 박싱돼서 들어감

저장 위치가 갈리면 값을 넘길 때의 동작도 갈립니다. Java 는 인자를 넘길 때 값을 복사합니다. 기본형이든 참조형이든 같습니다. 다만 참조형 변수에 담긴 값이 객체의 주소라서, 복사되는 것도 주소입니다. 그래서 메서드 안에서 객체의 필드를 고치면 바깥에도 반영되고, 매개변수 자체에 새 객체를 대입하면 바깥에는 아무 영향이 없습니다.

static void rename(Member m) {
    m.setName("바뀐 이름");     // 같은 객체를 가리키므로 바깥에도 반영됨
    m = new Member("새 객체");  // 복사된 주소만 갈아 끼움. 바깥은 그대로
}

Member member = new Member("원래 이름");
rename(member);
System.out.println(member.getName());   // "바뀐 이름"

이 두 줄의 차이를 헷갈리면 "분명히 메서드 안에서 바꿨는데 왜 그대로지" 라는 생각을 하게 됩니다. 필드를 고치는 것은 주소가 가리키는 Heap 의 객체를 고치는 일이고, 매개변수에 대입하는 것은 Stack 에 있는 복사본 주소를 바꾸는 일이라 바깥과 무관합니다.

== 와 equals()

== 는 변수에 담긴 값을 그대로 비교합니다. 기본형끼리면 숫자를 비교하니 우리가 기대하는 대로 동작하고, 참조형끼리면 주소를 비교하니 "같은 객체인가"를 묻는 것이 됩니다. 내용이 같은지 묻고 싶으면 equals() 를 씁니다.

int a = 100, b = 100;
System.out.println(a == b);          // true  : 값 비교

String s1 = new String("SKALA");
String s2 = new String("SKALA");
System.out.println(s1 == s2);        // false : 객체가 둘이라 주소가 다름
System.out.println(s1.equals(s2));   // true  : 내용 비교

여기서 걸리는 것이 equals() 의 기본 구현입니다. 모든 클래스는 Object 를 상속하는데, Object.equals() 는 안에서 == 를 그대로 씁니다. 그러니까 내가 만든 클래스에서 equals() 를 재정의하지 않으면 equals() 를 불러도 주소 비교가 됩니다. String 이 잘 동작하는 것은 String 클래스가 이미 재정의해 뒀기 때문입니다.

public class Stock {
    private final String name;

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

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof Stock other)) return false;
        return this.name.equals(other.name);
    }

    @Override
    public int hashCode() {          // equals 를 고치면 hashCode 도 같이
        return name.hashCode();
    }
}

equals() 를 재정의하면 hashCode() 도 같이 재정의합니다. 둘을 맞춰 두지 않으면 HashMap 이나 HashSet 에 넣었을 때 값이 사라진 것처럼 보입니다. 왜 그런 일이 벌어지는지는 32편에서 해시 버킷 구조와 함께 다루겠습니다.

Integer 캐시가 만드는 함정

맨 앞에서 꺼낸 문제로 돌아오겠습니다. 같은 100인데 Integer 로 담으면 == 가 true 이고, 1000으로 바꾸면 false 가 됩니다.

Integer a = 100, b = 100;
System.out.println(a == b);        // true

Integer c = 1000, d = 1000;
System.out.println(c == d);        // false
System.out.println(c.equals(d));   // true

오토박싱은 안에서 Integer.valueOf() 를 부르는데, 이 메서드가 -128 부터 127 까지의 값에 대해서는 미리 만들어 둔 객체를 재사용합니다. 자주 쓰이는 작은 수마다 객체를 새로 만드는 낭비를 막으려고 넣어 둔 캐시입니다. 100은 캐시 범위 안이라 같은 객체를 가리키게 되고, 1000은 범위 밖이라 객체가 두 개 만들어집니다.

이 함정이 무서운 것은 테스트가 통과한다는 점입니다. 개발할 때 쓰는 ID 가 한 자리 숫자면 == 로 비교해도 멀쩡히 돌다가, 운영에서 ID 가 128을 넘는 순간 조용히 false 가 됩니다. 그래서 규칙은 하나로 정리됩니다. 기본형이 아닌 값을 비교할 때는 equals() 를 씁니다. Long, Character, Boolean 도 같은 캐시 구조를 가지고 있습니다.

String 이 불변인 이유

String 은 한 번 만들어지면 내용이 바뀌지 않습니다. s.toUpperCase() 나 s.replace() 는 원본을 고치는 것이 아니라 새 String 을 만들어 돌려줍니다. 불변으로 설계한 덕에 세 가지가 따라옵니다. 해시값을 한 번 계산해 두고 계속 쓸 수 있고, 여러 스레드가 같은 String 을 봐도 안전하고, DB 접속 정보 같은 값을 넘긴 뒤 남이 바꿔칠 수 없습니다.

대신 문자열을 계속 이어 붙이는 코드에서 비용이 생깁니다. + 로 이어 붙일 때마다 새 String 객체가 만들어지고 이전 것은 버려집니다. 반복문 안에서 이 일이 벌어지면 반복 횟수만큼 쓰레기 객체가 쌓입니다.

// 반복 1만 회 → 중간 String 객체가 1만 개 만들어졌다가 버려짐
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i + ",";
}

// 버퍼 하나를 잡아 두고 이어 붙임
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i).append(",");
}
String result2 = sb.toString();

한 줄짜리 "이름: " + name 은 컴파일러가 알아서 처리하니 그대로 써도 됩니다. 문제가 되는 자리는 반복문 안입니다. 루프 안에서 문자열을 누적한다면 StringBuilder 로 바꿉니다.

append() 가 자기 자신을 돌려주기 때문에 점을 이어서 계속 부를 수 있습니다. 이 방식을 메서드 체이닝이라고 부르고, 뒤에 나올 Stream API 와 Builder 패턴이 같은 형태를 씁니다.

String line = new StringBuilder()
        .append(String.format("%-10s", "스칼라"))   // 왼쪽 정렬 10칸
        .append(String.format("%5d", 25))          // 오른쪽 정렬 5칸
        .toString();

StringBuilder 와 StringBuffer

둘은 사용법이 완전히 같고 한 가지만 다릅니다. StringBuffer 는 모든 메서드에 synchronized 가 붙어 있어서 여러 스레드가 동시에 append() 해도 글자가 섞이지 않고, 그 잠금 처리 때문에 StringBuilder 보다 느립니다.

  StringBuilder StringBuffer
동기화 없음 메서드마다 synchronized
속도 빠름 잠금 비용만큼 느림
도입 Java 5 Java 1.0
쓸 자리 메서드 안의 지역 변수 여러 스레드가 공유하는 필드

실제로는 StringBuilder 를 쓰게 됩니다. 문자열을 조립하는 버퍼는 대부분 메서드 안에서 만들어 쓰고 버리는 지역 변수이고, 지역 변수는 스레드마다 있는 Stack 에 놓이니 애초에 공유되지 않습니다. StringBuffer 가 필요한 상황은 그 버퍼를 여러 스레드가 공유하는 필드로 둔 경우인데, 그런 설계 자체가 드뭅니다.

synchronized 가 하는 일

synchronized 는 공유 자원에 한 번에 한 스레드만 들어가도록 잠그는 키워드입니다. 운영체제 수업에서 배우는 Mutex(상호 배제)를 Java 문법으로 구현한 것입니다. 잠금이 없으면 어떤 일이 벌어지는지는 잔액 확인과 차감 사이에 틈이 있는 코드가 잘 보여 줍니다.

class BankAccount {
    private int balance = 1000;

    public synchronized void withdraw(int amount) {
        if (balance >= amount) {        // ① 잔액 확인
            balance -= amount;          // ② 차감
        }
    }
}

synchronized 가 없으면 스레드 두 개가 ①을 동시에 통과할 수 있습니다. 잔액 1000에서 800을 두 번 빼는 요청이 둘 다 통과해서 잔액이 -600이 됩니다. 키워드를 붙이면 한 스레드가 메서드를 빠져나올 때까지 다른 스레드가 문 앞에서 기다립니다. DB 쪽 Lock 과 목적이 같고, 잠금 범위가 넓을수록 대기가 길어지는 것도 같습니다.

가비지 컬렉션

Heap 에 만든 객체를 개발자가 직접 지우지 않아도 되는 것은 가비지 컬렉터가 치우기 때문입니다. 기준은 "아무도 가리키지 않는가" 하나입니다. 어딘가에서 참조가 살아 있으면 GC 대상이 아닙니다.

GC 는 Heap 을 통째로 훑지 않고 나이대별로 나눠 봅니다. 대부분의 객체는 만들어지고 금방 버려진다는 관찰에서 나온 구조입니다.

영역 담기는 객체 GC 종류
Eden new 로 막 만들어진 객체 Minor GC
Survivor S0 / S1 Eden 에서 살아남아 옮겨진 객체 Minor GC
Old Survivor 를 여러 번 거치고도 살아 있는 객체 Major GC (Full GC)
Metaspace 클래스 메타데이터 (Heap 바깥) 대상 아님

Eden 이 차면 Minor GC 가 돌아 살아 있는 것만 Survivor 로 옮기고 나머지를 버립니다. 훑을 범위가 좁아서 빠릅니다. Survivor 를 여러 번 넘긴 객체는 Old 로 승격되고, Old 가 차면 Major GC 가 돕니다. Old 의 객체는 크고 참조 관계가 얽혀 있어서 훨씬 오래 걸립니다.

여기서 실제 장애로 이어지는 것이 Stop the World 입니다. GC 가 참조 관계를 훑는 동안 애플리케이션 스레드가 전부 멈춥니다. Minor GC 의 멈춤은 짧아서 잘 느껴지지 않지만, Full GC 가 몇 초씩 걸리면 그 시간 동안 모든 요청이 그대로 멈춰 섭니다. API 응답 시간 그래프에 주기적으로 튀는 봉우리가 있다면 GC 로그를 먼저 확인해 볼 만합니다. JDK 15 부터 정식으로 들어온 ZGC 는 이 멈춤을 밀리초 단위로 줄이려고 만들어진 수집기입니다.

System.gc() 는 GC 를 실행하는 명령이 아니라 요청입니다. JVM 이 무시해도 규약 위반이 아니고, 오히려 Full GC 를 유발해 응답을 멈추게 만들 수 있어서 운영 코드에는 넣지 않습니다.

참조가 남아 있으면 GC 는 손대지 못한다

GC 가 있으니 메모리 누수가 없다고 생각하기 쉬운데, Java 의 누수는 형태가 다릅니다. C 에서는 free() 를 빠뜨려서 새고, Java 에서는 쓰지 않는데 참조를 놓지 않아서 샙니다. 대표적인 자리가 static 컬렉션입니다.

public class SessionStore {
    // static 이라 클래스가 살아 있는 동안 계속 남음
    private static final Map<String, Session> SESSIONS = new HashMap<>();

    public static void put(String id, Session s) {
        SESSIONS.put(id, s);      // 넣기만 하고 지우는 코드가 없으면 계속 쌓임
    }
}

Map 이 세션 객체를 계속 가리키고 있으니 GC 는 손을 못 댑니다. 로그아웃이나 만료 시점에 remove() 를 부르지 않으면 Old 영역이 차오르다가 결국 OutOfMemoryError 로 끝납니다. 캐시 용도라면 만료 시간이 있는 구현체를 쓰고, 직접 담아야 한다면 지우는 코드를 같이 넣습니다.

정리

메모리 구조 하나를 잡아 두면 나머지가 따라옵니다. 이 글에서 실제로 코드를 바꾸는 판단은 여섯 개입니다.

  • 기본형은 Stack 에 값이, 참조형은 Stack 에 주소가 놓이고 객체는 Heap 에 있다
  • 인자는 항상 값 복사이고, 참조형에서 복사되는 값은 주소다
  • 값 비교는 equals(). == 는 같은 객체인지 물을 때만
  • Integer 는 -128 ~ 127 만 캐시하므로 == 비교가 128부터 조용히 깨진다
  • 반복문 안에서 문자열을 누적하면 StringBuilder
  • GC 는 참조가 끊긴 객체만 치운다. static 컬렉션에 넣고 안 지우면 샌다

다음 편에서는 예외 처리를 정리하겠습니다. Checked 와 Unchecked 를 나누는 기준, try-with-resources 가 무엇을 닫아 주는지, 그리고 catch 블록을 비워 두면 장애가 어떻게 조용해지는지를 다룹니다.