작업일2026. 08

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 에 남아 있고, 아무도 가리키지 않게 됐을 때 가비지 컬렉터가 치웁니다.
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 블록을 비워 두면 장애가 어떻게 조용해지는지를 다룹니다.
'소프트웨어 개발 > 웹' 카테고리의 다른 글
| 묵시적 커밋과 AUTO COMMIT | TRUNCATE와 DELETE 차이, 롤백 안 되는 자리 (0) | 2026.08.07 |
|---|---|
| JavaScript 비동기 처리| 이벤트 루프, Promise, async/await, Fetch (0) | 2026.07.26 |
| 웹 개발을 위한 네트워크 기초 (0) | 2026.07.26 |
| [개발 기초 정리] HTML·CSS·JavaScript (0) | 2026.07.23 |