Day 44 — 메모리 누수 진단과 JVM 튜닝
목차 32
지난 시간엔 JVM 의 메모리 속을 그림으로 그렸어요. 객체는 Heap 에, 메서드의 지역 변수는 Stack 에, 클래스 설명서는 Metaspace 에 산다고 칸을 나눠봤죠. 가비지 컬렉터가 안 쓰는 객체를 자동으로 치워준다는 것, 그런데 다 쓴 객체를 계속 붙들고 있으면 누수가 생긴다는 것까지 따라갔어요. 그러면서 마지막에 약속했었죠. "실제로 메모리가 새고 있는지를 어떻게 눈으로 확인할까?" 하고요.
오늘은 그 그림을 화면 위에서 살아 움직이게 만들어 봅니다. 지난 시간엔 이론을 그림으로 그렸다면, 오늘은 그 그림이 실제로 움직이는 걸 도구로 직접 봐요. 가비지 컬렉터가 정말 도는지 기록을 들여다보고, 누수를 일으키는 객체가 메모리에 쌓이는 걸 그래프로 잡아내고, 끝내 그 범인이 누구인지 이름까지 알아낼 거예요.
흐름은 이래요. 먼저 JVM 에게 "메모리를 이만큼만 써라" 라고 지시하는 옵션부터 익혀요. 그다음 가비지 컬렉터가 돌 때마다 남기는 기록(로그)을 읽는 법을 배우고, VisualVM 이라는 무료 도구로 Heap 을 실시간 그래프로 들여다봅니다. 그러고 나서 지난 시간 만들었던 누수 코드를 실제로 다시 돌려서 메모리가 차오르는 걸 눈으로 확인하고, 그 순간의 Heap 을 통째로 떠내서(덤프) 범인 객체를 찾아낸 다음, 마지막에 누수를 고쳐서 그래프가 평평해지는 것까지 확인하면 끝이에요.
오늘은 새 문법을 배우는 날이 아니라, 지금까지 짠 코드가 메모리에서 어떻게 동작하는지를 도구로 진단하는 실전 정비공의 하루 예요. 자동차 보닛을 열고 계기판을 들여다보는 정비사처럼, JVM 의 보닛을 열어봅시다.
🎯 학습 목표
- JVM 에게 힙 크기를 지시하는
-Xmx·-Xms·-Xmn옵션을 설정할 수 있어요. - 가비지 컬렉터가 남기는 GC 로그(
-Xlog:gc*)를 읽고 무슨 일이 일어났는지 해석할 수 있어요. - VisualVM 으로 실행 중인 프로그램의 Heap 을 실시간 그래프로 모니터링할 수 있어요.
- 메모리 누수를 실제로 재현하고, Heap 덤프를 떠서 어떤 객체가 안 죽고 쌓였는지 진단할 수 있어요.
- 상황(메모리가 빠듯한 서버, 누수 진단 등)에 맞게 어떤 옵션을 줄지 판단할 수 있어요.
Step 1: JVM 메모리 옵션 — 힙 크기를 직접 정하기
지난 시간 우리는 JVM 메모리가 Heap·Stack·Metaspace 세 구역으로 나뉜다고 배웠어요. 그중에서 우리가 new 로 만든 객체들이 사는 곳이 Heap 이었죠. 그런데 이 Heap 이 정확히 얼마나 큰지는 한 번도 정해본 적이 없어요. 그동안은 JVM 이 알아서 적당한 크기로 잡아줬거든요.
오늘 첫 도구는 바로 이거예요. "Heap 을 얼마나 크게 쓸지 우리가 직접 정해주는 것." JVM 에게 메모리 크기를 지시하는 가장 기본적인 손잡이예요.
왜 크기를 정해야 할까
먼저 "왜" 부터 짚어볼게요. Heap 크기를 잘못 잡으면 어떤 일이 벌어질까요? 두 방향 다 문제예요.
Heap 이 너무 작으면, 객체를 만들다가 공간이 가득 차요. 그러면 JVM 은 더 이상 객체를 못 만들고 OutOfMemoryError(메모리 부족 에러)를 던지면서 프로그램이 그대로 죽어버려요. 지난 시간 누수 이야기에서 잠깐 봤던 그 에러예요.
반대로 너무 크면, 안 쓰는 메모리를 컴퓨터에서 잔뜩 빌려놓고 낭비하게 돼요. 게다가 Heap 이 클수록 가비지 컬렉터가 한 번 청소할 때 훑어야 할 양이 많아져서, 청소하는 동안 프로그램이 잠깐 멈추는 시간이 길어질 수도 있어요.
그래서 "너무 작지도 크지도 않게, 딱 적당히" 를 우리가 지정해 주는 거예요.
같은 양의 객체가 들어가도, 힙을 작게 잡으면 금방 꽉 차고 크게 잡으면 헐렁해요.
[██████████] ← 너무 작게 잡음: 꽉 참 → OutOfMemoryError (객체 못 만들고 죽음)
[██████ ] ← 적당히 잡음: 여유 있음 → 가장 좋은 상태
[███ ] ← 너무 크게 잡음: 텅텅 → 메모리 낭비 + 청소 시 멈춤이 길어짐
옵션 세 가지
크기를 정하는 손잡이는 세 개예요. 이름이 좀 암호 같지만, 뜻을 알면 외우기 쉬워요.
-Xmx 는 힙의 최대(max) 크기예요. 끝에 mx 가 붙죠. "여기까지만 커질 수 있어" 라는 천장이에요. 예를 들어 -Xmx512m 이라고 주면 힙이 최대 512메가바이트까지만 자라고, 그걸 넘으면 OutOfMemoryError 가 나요. -Xmx2g 라고 주면 최대 2기가바이트까지 허용한다는 뜻이에요.
-Xms 는 힙의 시작(start) 크기예요. 끝에 ms 가 붙죠. JVM 이 켜질 때 처음부터 확보해두는 크기예요. -Xms256m 이라고 주면 시작하자마자 256메가바이트를 잡아두고 출발해요. 객체가 점점 늘어나서 이 크기를 넘으면 힙이 -Xmx 까지 천천히 자라나요.
한 가지 팁을 드리면, 시작 크기와 최대 크기를 같게 두는 경우가 많아요. -Xms512m -Xmx512m 처럼요. 이러면 힙이 도중에 늘었다 줄었다 하느라 생기는 출렁임이 없어서 더 안정적으로 동작해요.
-Xmn 은 young(젊은) 영역 의 크기예요. 지난 시간 가비지 컬렉션을 배울 때, 갓 만들어진 객체들이 잠깐 머무는 "젊은 구역" 이야기가 나왔던 거 기억나세요? 그 젊은 구역의 크기를 따로 정하는 옵션이에요. 지금은 "이런 것도 있구나" 정도만 알아두면 충분해요. 평소엔 거의 건드릴 일이 없고, 성능을 아주 깊게 튜닝할 때나 꺼내 쓰는 손잡이예요.
크기 뒤에 붙이는 단위는 세 가지예요. k 는 킬로바이트, m 은 메가바이트, g 는 기가바이트. 그래서 -Xmx2g 는 "최대 2기가" 이고 -Xms256m 은 "시작 256메가" 인 거죠.
IntelliJ 에서 설정하는 법
그럼 이 옵션들을 어디에 적을까요? 우리는 평소처럼 IntelliJ 에서 "Run" 버튼으로 프로그램을 실행하니까, IntelliJ 안에서 설정해 줄게요.
상단 메뉴에서 Run → Edit Configurations 로 들어가면 실행 설정 창이 열려요. 거기에 VM options 라는 입력 칸이 있어요. 그 칸에 옵션을 띄어쓰기로 나열해서 적어주면 돼요. 예를 들면 이렇게요.
-Xmx512m -Xms256m
이렇게 적고 실행하면, 우리 프로그램은 256메가로 시작해서 최대 512메가까지 쓸 수 있는 힙을 가지고 돌아가요.
⚠️ 헷갈리기 쉬운 함정 하나만 짚을게요. IntelliJ 메뉴를 뒤지다 보면 Help → Edit Custom VM Options 라는 비슷한 이름의 메뉴가 보일 수 있어요. 그건 IntelliJ 자기 자신 이 쓰는 메모리를 정하는 설정이라, 우리가 실행할 프로그램과는 상관없어요. 우리가 건드릴 건 어디까지나 Run Configuration 의 VM options 칸 이에요. 내가 실행할 프로그램의 힙을 정하는 거니까요.
터미널에서 직접 실행한다면 명령 한 줄로도 줄 수 있어요. java -Xmx512m -Xms256m MainClass 처럼 클래스 이름 앞에 옵션을 붙이면 돼요. 다만 우리는 IntelliJ 로 실습하니, 이 방법은 "이렇게도 된다" 정도로만 기억해두세요.
🙋 학생 질문 — "튜터님, 그냥 기본값 쓰면 안 되나요? 굳이 정해줘야 해요?"
아주 좋은 질문이에요. 결론부터 말하면, 평소 개발할 땐 기본값으로 충분해요. 우리가 지금까지 Day 1 부터 한 번도 이 옵션을 안 줬는데도 프로그램이 잘 돌아갔잖아요? JVM 이 컴퓨터 사정을 보고 알아서 적당한 크기를 잡아주거든요.
그럼 언제 직접 정할까요? 두 가지 경우예요. 하나는 메모리가 빠듯한 서버 에 프로그램을 올릴 때예요. 서버 한 대에 여러 프로그램이 같이 돌아갈 때, "너는 512메가만 써" 라고 선을 그어줘야 서로 메모리를 빼앗지 않아요. 다른 하나는 오늘 우리가 할 누수 진단 이에요. 힙을 일부러 작게 잡아두면 누수가 훨씬 빨리 드러나서 잡아내기 쉬워지거든요.
그러니 "지금 당장 모든 프로그램에 옵션을 줘야 한다" 고 부담 가질 필요는 전혀 없어요. 필요한 순간에 꺼내 쓰는 도구일 뿐이에요.
💡 오늘 기억할 한 줄:
-Xmx는 힙의 최대 크기,-Xms는 힙의 시작 크기. 끝글자mx(max)·ms(start)로 외우면 안 헷갈려요. 평소엔 기본값으로 충분하고, 메모리가 빠듯하거나 누수를 진단할 때 직접 정해주면 돼요.
Step 2: GC 로그 읽기 — 가비지 컬렉터의 작업 일지
지난 시간, 가비지 컬렉터가 안 쓰는 객체를 알아서 치워준다고 배웠죠. 그런데 그 청소가 언제, 얼마나 자주, 한 번에 얼마나 일어나는지는 한 번도 본 적이 없어요. 청소부가 보이지 않는 곳에서 조용히 일하고 있었던 거예요.
JVM 에게 "청소할 때마다 일지를 남겨줘" 라고 부탁하면, 그 작업 기록을 우리가 직접 볼 수 있어요. 이 기록을 GC 로그(log, 기록) 라고 불러요. 로그란 프로그램이 동작하면서 남기는 일지 같은 거예요.
로그 켜기
GC 로그를 켜는 옵션은 -Xlog:gc* 예요. Step 1 에서 옵션을 적었던 그 VM options 칸에 똑같이 적어주면 돼요.
-Xlog:gc— GC 가 일어날 때마다 핵심을 한 줄씩 남겨요.-Xlog:gc*— 끝에 별표(*)를 붙이면 GC 와 관련된 더 자세한 항목까지 남겨요. 별표는 "gc 로 시작하는 모든 기록" 이라는 뜻이에요.
이대로 두면 로그가 화면(콘솔)에 주르륵 찍혀요. 파일로 따로 모으고 싶으면 뒤에 저장할 파일 이름을 붙여요.
-Xlog:gc*:file=gc.log
이러면 gc.log 라는 파일에 기록이 차곡차곡 쌓여서, 나중에 천천히 열어볼 수 있어요.
로그 한 줄 읽는 법
옵션을 켜고 프로그램을 돌리면 이런 줄들이 찍혀요. 실제로 찍힌 한 줄을 가져와 봤어요.
[0.284s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 116M->34M(256M) 2.678ms
암호처럼 보이지만, 우리가 꼭 읽어야 할 핵심은 뒤쪽 네 덩어리뿐이에요.
GC(1) ← 1번째 청소 (전체에서 몇 번째 GC인지)
Pause Young ← 젊은 객체만 훑는 작은 청소
116M->34M ← 청소 전 116메가 → 청소 후 34메가 (82메가어치 회수!)
(256M) ← 전체 힙 크기는 256메가
2.678ms ← 이 청소에 걸린 시간
정리하면 이래요. 1번째 청소가 일어났고, 청소 전엔 힙을 116메가 쓰고 있었는데 청소 후 34메가로 줄었다. 전체 힙은 256메가이고, 청소에 2.678밀리초가 걸렸다. 116메가에서 34메가로 뚝 떨어진 게 보이죠? 가비지 컬렉터가 안 쓰는 객체 82메가어치를 치워준 거예요. 이게 건강하게 돌아가는 GC 의 모습이에요.
Young GC 와 Full GC
로그를 보다 보면 두 종류의 청소가 보여요.
- Young GC — 갓 만들어진 "젊은" 객체들만 빠르게 훑는 작은 청소예요. 자주 일어나고, 위 예시처럼 보통 몇 밀리초 만에 끝나요.
- Full GC — 힙 전체를 훑는 큰 청소예요. 가끔 일어나고, 훑을 양이 많아 더 오래 걸려요.
지난 시간 배운 Mark(표시) → Sweep(쓸기) → Compact(정리) 한 바퀴가, 로그에서는 이렇게 한 줄로 압축돼서 찍히는 거예요. 평소엔 빠른 Young GC 가 대부분이고, Full GC 가 자주 길게 보이기 시작하면 "메모리가 빡빡하구나" 하는 신호로 읽을 수 있어요.
💡 오늘 기억할 한 줄: GC 로그에서
116M->34M(256M)만 읽을 줄 알아도 절반은 한 거예요. 청소 전 → 청소 후 (전체 힙) 순서니까요. 만약 화살표 뒤 숫자가 청소를 해도 계속 안 줄어든다면, 메모리 누수를 의심해 봐야 해요.
Step 3: VisualVM — Heap 을 실시간 그래프로 보기
GC 로그는 훌륭한 일지지만, 텍스트라서 흐름을 한눈에 잡기는 어려워요. 숫자가 줄줄이 찍히면 "지금 메모리가 늘고 있는 건가 줄고 있는 건가?" 가 잘 안 보이죠. 같은 정보를 실시간 그래프 로 보여주는 도구가 있으면 훨씬 직관적이에요. 그게 바로 VisualVM 이에요.
VisualVM 은 실행 중인 자바 프로그램의 속을 들여다보는 무료 도구예요. 지금 메모리를 얼마나 쓰는지, 가비지 컬렉터가 언제 도는지를 그래프로 실시간으로 보여줘요.
받아서 켜기
VisualVM 은 예전엔 JDK 안에 같이 들어 있었는데, 지금은 따로 떨어져 나와서 직접 받아야 해요. visualvm.github.io 에서 내려받을 수 있고, 글을 쓰는 지금 기준 최신은 2.2.1 버전이라 JDK 25 까지 잘 맞아요. 받아서 압축을 풀고 실행하면 돼요.
켜면 왼쪽에 지금 내 컴퓨터에서 돌아가는 자바 프로그램 목록 이 나와요. 우리가 IntelliJ 로 프로그램을 실행해 두면, 그 프로그램이 목록에 뜨죠. 그걸 더블클릭하면 속을 들여다보는 화면이 열려요. 거기서 Monitor 탭을 누르면 Heap 사용량 그래프가 실시간으로 그려져요.
그래프 모양으로 상태 읽기
건강한 프로그램의 Heap 그래프는 톱니 모양 이에요.
힙 사용량
│ /| /| /|
│ / | / | / | ← 객체가 쌓이며 위로 올라가다가
│ / | / | / |
│ / |_/ |_/ |__ ← 가비지 컬렉터가 치우면 뚝 떨어지고, 다시 반복
└───────────────────── 시간
객체가 쌓이며 그래프가 올라가다가, 가비지 컬렉터가 한 번 치우면 뚝 떨어지고, 또 쌓이다가 떨어지고. 이 오르내림이 반복되는 톱니 모양이면 "잘 청소되고 있구나" 하고 안심해도 돼요. 우리가 Step 2 로그에서 본 116M->34M 한 줄이, 그래프에서는 한 번의 "뚝 떨어짐" 으로 보이는 거예요.
문제는 그래프가 톱니가 아니라 한 방향으로 계단처럼 계속 올라가기만 할 때예요. 치워도 안 내려가고 우상향한다면, 누군가 객체를 안 놓고 붙들고 있다는 신호예요. 다음 Step 에서 바로 그 모습을 직접 만들어 볼 거예요.
🙋 학생 질문 — "튜터님, 그럼 GC 로그랑 VisualVM 중 뭘 써야 해요?"
둘 다 같은 가비지 컬렉터를 다르게 보는 도구라, 상황에 맞게 골라 쓰면 돼요.
GC 로그 는 텍스트 일지예요. 프로그램이 끝난 뒤에도 파일로 남아 있어서, "어젯밤에 무슨 일이 있었나" 를 천천히 되짚어 보기 좋아요. 서버처럼 눈으로 계속 지켜볼 수 없는 곳에 잘 어울려요.
VisualVM 은 실시간 그래프예요. 지금 이 순간 메모리가 어떻게 움직이는지 눈으로 바로 보여줘요. 내 PC 에서 직접 돌리면서 "지금 누수가 나나?" 를 확인할 때 편해요.
그래서 보통은 VisualVM 으로 눈으로 먼저 보고, GC 로그로 자세히 확인 하는 식으로 같이 써요. 하나만 골라야 하는 게 아니에요.
Step 4: 메모리 누수를 눈으로 보기 — 누수 재현하기
이제 지난 시간 이론으로만 봤던 메모리 누수를, 실제로 돌려서 눈으로 잡아볼 차례예요. Day 43 에서 만들었던 구독 등록부 SubscriptionRegistry 를 기억하시죠? 구독자를 subscribe() 로 더하기만 하고 unsubscribe() 로 빼지 않으면, 리스트가 구독자를 계속 붙들고 있어서 누수가 난다고 했어요.
그 누수를 일부러 일으키는 작은 프로그램을 하나 준비했어요.
// com/instagram/javabasic/jvm/MemoryLeakDemo.java
public class MemoryLeakDemo {
// 구독만 하고 해지를 한 번도 안 하는 누수 버전이에요.
public static SubscriptionRegistry leakWithoutUnsubscribe(int rounds) {
SubscriptionRegistry registry = new SubscriptionRegistry();
for (int i = 0; i < rounds; i++) {
// 구독만 하고 해지는 하지 않아요. 그래서 구독자가 계속 누적돼요.
registry.subscribe(new NamedSubscriber("subscriber-" + i));
}
return registry;
}
// VisualVM 으로 Heap 이 차오르는 모습을 관찰할 때 실행하는 데모예요.
public static void main(String[] args) {
SubscriptionRegistry registry = new SubscriptionRegistry();
long total = 0;
while (true) {
// 한 라운드에 10만 명씩 등록부에 쌓아요. 해지는 하지 않아요.
for (int i = 0; i < 100_000; i++) {
registry.subscribe(new NamedSubscriber("subscriber-" + i));
}
total += 100_000;
System.out.println("지금까지 쌓인 구독자 수: " + total);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
}
main 이 하는 일은 단순해요. 끝없는 반복 안에서 구독자를 10만 명씩 등록부에 쌓고, 잠깐 쉬고, 또 쌓아요. 해지는 한 번도 안 하니까 구독자가 영원히 늘어나기만 하죠. 중간에 Thread.sleep 으로 잠깐 멈추는 건, VisualVM 이 그래프를 그릴 틈을 주려는 거예요.
돌려보면 무슨 일이 벌어질까
이 main 을 힙을 일부러 작게 잡고(-Xmx64m) GC 로그를 켜서 돌려봤어요. 누수가 빨리 드러나게 하려고 힙을 좁게 준 거예요. 그랬더니 로그에 이런 줄들이 찍혔어요.
[2.121s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) (Evacuation Failure: Allocation) 42M->43M(64M) 56.261ms
[2.641s][info][gc] GC(1) Pause Young (Concurrent Start) (G1 Humongous Allocation) 48M->46M(64M) 17.426ms
Step 2 의 건강한 줄 116M->34M 과 비교해 보세요. 그땐 청소 후 숫자가 뚝 떨어졌는데, 여기선 42M->43M, 48M->46M 처럼 청소를 해도 거의 안 줄거나 오히려 늘어요. 첫 줄 끝에 붙은 Evacuation Failure(대피 실패) 는 "치울 데가 없어 청소에 실패했다" 는 뜻이에요. 가비지 컬렉터가 열심히 돌긴 도는데, 치울 수 있는 게 없는 거죠. 등록부 리스트가 구독자를 전부 강하게 붙들고 있으니까요.
이 순간 VisualVM 그래프를 같이 띄워두면, 톱니가 아니라 계단처럼 끝없이 우상향하는 선 이 그려져요. 한참 지켜보다 GC 버튼을 직접 눌러봐도 그래프는 거의 안 내려가요. 그러다 힙이 꽉 차면 결국 OutOfMemoryError 로 프로그램이 멈춰요. Day 43 에서 "강한 참조가 객체를 붙들면 청소 못 한다" 던 그 경고가, 화면 위에서 실제로 일어나는 모습을 본 거예요.
힙 사용량
│ ___/ ← 치워도 안 내려가고
│ ___/ 계단처럼 계속 올라가다가
│ ___/
│ ___/ ← 끝내 꽉 차서 OutOfMemoryError
└───────────────────────── 시간
💡 오늘 기억할 한 줄: 그래프가 톱니면 건강, 우상향 계단이면 누수 예요. GC 로그에서 화살표 뒤 숫자(
->다음)가 청소를 거듭해도 안 줄어들면, 어딘가에서 객체를 안 놓고 있다는 뜻이에요.
Step 5: Heap 덤프 — 그 순간의 메모리를 통째로 떠내기
그래프가 우상향한다는 것까진 알았어요. 그런데 "메모리가 새고 있다" 까지만으론 부족해요. 정확히 어떤 객체가 안 죽고 쌓이는지를 알아야 고칠 수 있죠. 그러려면 그 순간 힙 안에 무슨 객체가 몇 개나 들어 있는지 들여다봐야 해요.
이때 쓰는 게 Heap 덤프(heap dump) 예요. 덤프란 그 순간 힙에 살아 있는 모든 객체를 통째로 떠낸 메모리 사진 이에요. 사진을 찍어두면 멈춰 세운 화면을 천천히 뜯어볼 수 있잖아요. 그것처럼 메모리를 한 장 찍어서 분석하는 거예요.
덤프 뜨는 법
VisualVM 에서 왼쪽 프로그램 목록의 내 프로그램을 우클릭하면 Heap Dump 메뉴가 있어요. 그걸 누르면 그 순간의 힙을 통째로 떠서 화면에 열어줘요. (떠낸 사진은 .hprof 라는 파일로 저장돼요.)
터미널을 더 좋아한다면 명령으로도 뜰 수 있어요. 프로그램의 번호(PID)를 알아내서 jcmd <번호> GC.heap_dump heap.hprof 라고 치면 heap.hprof 파일이 만들어져요. 다만 우리는 VisualVM 으로 실습하니, 이건 "이렇게도 된다" 정도로 알아두세요.
사진 속에서 범인 찾기
덤프를 열면 어떤 클래스의 객체가 몇 개나 있는지 를 개수 순으로 정렬해서 볼 수 있어요. 누수가 난 우리 프로그램의 덤프를 열어봤더니, 이런 모습이 보였어요.
| 클래스 이름 | 살아 있는 인스턴스 수 |
|---|---|
NamedSubscriber |
1,200,000 |
String |
1,200,041 |
Object[] |
312 |
Integer |
188 |
NamedSubscriber 가 무려 120만 개! 그리고 그 구독자마다 이름(String)을 하나씩 들고 있으니 String 도 그만큼 많죠. 평범한 프로그램이라면 이렇게 한 종류의 객체가 수백만 개씩 쌓일 일이 없어요. 압도적으로 많은 이 객체가 바로 범인 후보 예요. 우리가 Day 43 에서 만든 그 구독자가, 안 죽고 이만큼 쌓여 있던 거예요.
💡 오늘 기억할 한 줄: 누수가 의심되면 Heap 덤프를 떠서 "비정상적으로 많이 쌓인 클래스" 를 찾으세요. 개수가 압도적인 객체가 거의 항상 범인이에요.
Step 6: 참조 그래프 — 누가 이 객체를 안 놓고 있나
범인 후보는 찾았어요. NamedSubscriber 120만 개. 그런데 한 가지가 더 궁금해요. 왜 가비지 컬렉터는 이걸 안 치웠을까? Day 43 에서 배운 규칙을 떠올려보세요. "뿌리(GC Root)에서 화살표로 닿을 수 있으면 살아 있는 객체" 였죠. 그러니 이 120만 개도 어딘가 뿌리에서 화살표가 닿고 있다는 뜻이에요. 그 화살표를 따라가면 진짜 원인이 나와요.
VisualVM 덤프 화면에서 객체 하나를 골라 "이걸 누가 붙들고 있나(가장 가까운 GC Root 까지의 경로)" 를 보여달라고 하면, 이런 사슬이 그려져요.
GC Root (프로그램이 살아 있는 동안 유지되는 뿌리)
│ 붙들고 있음
▼
SubscriptionRegistry 객체
│ subscribers 라는 리스트 필드가
▼
ArrayList ── [구독자][구독자][구독자] … 120만 개를 줄줄이 붙듦
읽어보면 이래요. 뿌리에서 시작한 화살표가 SubscriptionRegistry 로 닿고, 그 안의 subscribers 리스트가 구독자 120만 개를 하나하나 붙들고 있어요. 이 사슬이 끊기지 않고 이어져 있으니, 가비지 컬렉터 입장에선 전부 "아직 쓰는 객체" 로 보여서 손을 못 대는 거예요.
여기서 중요한 깨달음이 하나 있어요. 진짜 범인은 NamedSubscriber 자체가 아니라, 그걸 안 놓는 subscribers 리스트 예요. 객체가 나쁜 게 아니라, 다 쓴 객체를 놓아주지 않는 그 손이 문제인 거죠. 그러니 해결책도 거기서 나와요. 리스트가 손을 놓게 만들면 돼요.
Step 7: 누수 해결하기 — 손을 놓게 만들기
원인을 알았으니 고치는 건 의외로 간단해요. 리스트가 다 쓴 구독자를 놓도록, 등록할 때마다 짝이 되는 해지를 붙여주면 돼요. 같은 데모 안에 누수가 없는 버전도 준비해 뒀어요.
// com/instagram/javabasic/jvm/MemoryLeakDemo.java
// 등록과 해지를 1:1 로 짝지어 누수를 막는 버전이에요.
public static SubscriptionRegistry leakFreeWithUnsubscribe(int rounds) {
SubscriptionRegistry registry = new SubscriptionRegistry();
for (int i = 0; i < rounds; i++) {
NamedSubscriber subscriber = new NamedSubscriber("subscriber-" + i);
// 등록한 그 객체를 그대로 해지해요. 이 짝맞춤이 누수를 막는 안전선이에요.
registry.subscribe(subscriber);
registry.unsubscribe(subscriber);
}
return registry;
}
subscribe() 바로 다음에 unsubscribe() 가 짝으로 붙었죠. 구독자가 등록부에 들어갔다가 곧바로 빠지니, 리스트엔 아무도 안 남아요. 이 메서드는 rounds 가 아무리 커도 끝나고 나면 등록부 크기가 0 이에요. 다 쓴 구독자를 붙드는 손이 없으니, 가비지 컬렉터가 바로바로 치울 수 있어요.
실제 화면 처리에선 구독을 더 늦게 정리하기도 해요. Day 43 과제에서 만든 EventSubscriptionManager 처럼, 화면이 닫히는 순간 한 번에 전체를 비우는 방법이죠.
// com/instagram/javabasic/jvm/solution/day43/EventSubscriptionManager.java
// 화면이 닫힐 때 전체 구독을 한 번에 정리해요. 누수를 막는 안전선이에요.
public void unsubscribeAll() {
subscribers.clear();
}
clear() 로 리스트를 통째로 비우면, 구독자들을 붙들던 마지막 강한 참조가 한 번에 끊겨요. 그 순간 구독자 전부가 가비지 컬렉터의 청소 대상이 되죠.
고치고 나서 다시 보면
이렇게 짝이 되는 해지를 붙인 뒤 같은 진단을 다시 해봤어요. VisualVM 그래프는 계단식 상승이 사라지고 다시 톱니 모양 으로 돌아왔어요. 쌓였다가 GC 때 뚝 떨어지고, 다시 쌓였다 떨어지고. 그리고 Heap 덤프를 다시 떠보니 NamedSubscriber 개수가 0 에 가깝게 떨어져 있었어요.
[Before] 누수 — 치워도 안 내려가고 계속 우상향
힙 │ ___
│ ___
│ ___
└───────────── 시간
[After] 해결 — 톱니 모양으로 복귀
힙 │ /| /| /|
│ / | / | / |
│ / |/ |/ |__
└──────────────── 시간
Day 41 에서 Observer 를 배울 때 "구독했으면 필요 없어질 때 해지하는 습관이 중요하다" 고 했던 그 한마디가, 메모리 누수를 막는 진짜 안전선이었던 거예요. 그리고 지난 시간 잠깐 봤던 WeakReference(약한 참조) 를 떠올려보세요. 깜빡 잊고 해지를 못 할까 봐 걱정된다면, 처음부터 약하게 붙들어서 가비지 컬렉터가 알아서 치우게 두는 선택지도 있어요. 해지를 짝지어 주거나, 약하게 붙들거나 — 둘 다 "다 쓴 객체를 놓아준다" 는 같은 목표를 향해요.
💡 오늘 기억할 한 줄: 누수의 해결은 결국 "붙들던 손을 놓는 것" 이에요. 등록엔 해지를 짝지어 주고, 화면이 닫히면 한 번에 비워주세요.
Step 8: 튜닝 전략 — 언제 무엇을 꺼내 쓸까
오늘 손잡이를 여러 개 만져봤어요. 마지막으로, 이걸 실무에서 언제 어떻게 꺼내 쓰는지 를 정리해 볼게요. 도구는 많이 아는 것보다 "이 상황엔 이걸" 하고 고를 줄 아는 게 중요하거든요.
환경에 따라 다르게
- 개발 환경 (내 PC) — 평소 개발할 땐 기본값으로 충분해요. 옵션을 굳이 안 줘도 JVM 이 알아서 잘 잡아줘요. 누수가 의심될 때만 VisualVM 을 띄워 그래프를 살펴보면 돼요.
- 테스트 / CI 환경 — 메모리가 제한된 곳에서 돌 때가 많아요. GC 로그(
-Xlog:gc*)를 켜두면, 나중에 "여기서 메모리가 빡빡했구나" 를 일지로 되짚어 볼 수 있어요. - 프로덕션 (실제 서버) — 힙 크기를 명확히 정하고(
-Xms와-Xmx를 같게), GC 로그를 파일로 남겨 꾸준히 지켜봐요. 눈으로 계속 못 보는 환경이라, 기록과 경보가 생명이에요.
누수가 의심될 때, 진단 순서
오늘 배운 도구들을 실제로 쓸 땐 이런 순서로 가면 돼요.
1. GC 로그를 켠다 (-Xlog:gc*)
↓ 화살표 뒤 숫자가 안 줄어드나?
2. VisualVM 으로 그래프를 본다
↓ 톱니인가, 우상향 계단인가?
3. 계단이면 Heap 덤프를 뜬다
↓ 비정상적으로 많은 클래스는?
4. 그 객체의 참조 그래프를 본다
↓ 누가 안 놓고 붙들고 있나?
5. 그 손을 놓게 고친다 (해지 / 약한 참조)
이 다섯 단계가 오늘 우리가 Step 2 부터 7 까지 한 바퀴 돌아본 그 흐름이에요. 막막하던 "메모리가 새는 것 같아요" 가, 이제는 차근차근 따라가서 범인을 잡는 절차로 바뀐 거죠.
🌟 추천 첫걸음: 누수가 의심되면 무조건 덤프부터 뜨지 말고, GC 로그 → VisualVM 그래프 로 "정말 새는지" 부터 확인하세요. 멀쩡한데 괜히 덤프 떠서 분석하느라 시간 버리는 일이 흔하거든요.
마무리
오늘은 지난 시간 그림으로만 그렸던 메모리 이야기를, 도구를 들고 직접 진단해 본 실전이었어요. 정비사가 보닛을 열고 계기판을 들여다본 하루였죠.
- JVM 메모리 옵션 —
-Xmx(최대)·-Xms(시작)·-Xmn(young)으로 힙 크기를 직접 정했어요. 너무 작으면 OOM, 너무 크면 낭비예요. - GC 로그 —
-Xlog:gc*로 가비지 컬렉터의 일지를 켜고,116M->34M(256M)한 줄을 "청소 전 → 후 (전체)" 로 읽었어요. - VisualVM — 실행 중인 프로그램의 Heap 을 실시간 그래프로 봤어요. 톱니면 건강, 우상향 계단이면 누수.
- 누수 재현 — Day 43 의
SubscriptionRegistry를 실제로 돌려, 청소해도 안 줄어드는 메모리를 눈으로 잡았어요. - Heap 덤프와 참조 그래프 — 그 순간 메모리를 사진 찍어 범인 클래스를 찾고, 누가 그걸 붙들고 있는지 사슬을 따라갔어요.
- 해결과 전략 — 짝이 되는 해지로 손을 놓게 만들고, 환경별로 어떤 도구를 꺼낼지 정리했어요.
이제 "메모리가 새는 것 같아요" 라는 막연한 불안이, 차근차근 따라가 범인을 잡아내는 다섯 단계 절차로 바뀌었어요. 이게 오늘의 가장 큰 수확이에요.
그런데 한 가지 묘한 게 남아요. VisualVM 은 우리 프로그램 속을 들여다보면서 NamedSubscriber 라는 클래스 이름 을 어떻게 다 알아냈을까요? 그리고 지난 시간 배운 Metaspace 에 담긴 그 클래스 정보는, 대체 누가 읽어서 올려놓는 걸까요? 다음 시간엔 클래스를 메모리에 실어 나르는 클래스 로더, 실행 중에 클래스 속을 들여다보는 리플렉션(reflection), 그리고 @ 로 시작하는 어노테이션 의 정체를 파헤쳐 봅니다. 오늘 덤프에서 본 클래스 이름들이, 그때 또 다른 의미로 살아날 거예요. 수고 많으셨어요!
과제
오늘은 도구를 직접 손에 익히는 게 핵심이었어요. 과제도 눈으로 보고 손으로 만져보는 쪽으로 준비했어요.
과제 1: 힙을 좁혀 OutOfMemoryError 만나보기 [기초]
IntelliJ 의 Run Configuration VM options 칸에 힙을 아주 작게 주고(-Xmx16m 처럼), 동시에 GC 로그(-Xlog:gc)를 켜서 아무 프로그램이나 실행해 보세요. 콘솔에 GC 로그가 어떻게 찍히는지 관찰하고, 한 줄을 골라 "청소 전 → 후 (전체)" 로 해석해 보세요. 힙을 더 좁히면 어떤 에러가 나는지도 확인해 보세요.
과제 2: 누수를 재현하고 덤프로 진단하기 [응용]
MemoryLeakDemo 의 main 을 실행하고 VisualVM 으로 Heap 그래프를 지켜보세요. 그래프가 톱니인지 계단인지 적어보고, Heap 덤프를 떠서 어떤 클래스의 인스턴스가 비정상적으로 많은지 찾아보세요. 그다음 leakWithoutUnsubscribe 와 leakFreeWithUnsubscribe 가 끝난 뒤 등록부 크기가 각각 어떻게 다른지, 그 차이가 왜 생기는지 설명해 보세요.
과제 3: 나만의 누수 시나리오 설계하기 [심화]
구독 등록부 말고, 누수가 날 수 있는 다른 상황을 하나 직접 떠올려 설계해 보세요. (힌트: 캐시처럼 Map 에 데이터를 계속 넣기만 하고 안 빼면 어떻게 될까요?) 어떤 자료구조가 무엇을 강하게 붙들어서 누수가 나는지 글로 분석하고, 이를 막는 두 가지 방법 — 다 쓰면 빼주기 vs 약한 참조로 붙들기 — 를 비교해 보세요. 각 방법이 어떤 상황에 더 어울릴지도 함께요.
생각해볼 주제
1. 가비지 컬렉터가 자동인데, 자바에 왜 메모리 누수가 있을까
자바는 C/C++ 와 달리 메모리를 자동으로 치워주는데도 메모리 누수가 생겨요. "자동" 이라는 말과 "누수" 라는 말이 같이 있는 게 모순처럼 느껴지죠. 가비지 컬렉터가 객체를 치우는 기준이 무엇이었는지 떠올리면서, 자동 청소가 막아주는 것과 막아주지 못하는 것의 경계를 생각해 보세요.
2. 힙은 크게 줄수록 좋은 걸까
OutOfMemoryError 가 무서우니 -Xmx 를 무조건 크게 주면 안전할 것 같아요. 하지만 큰 힙에는 보이지 않는 비용도 따라와요. 가비지 컬렉터가 한 번 청소할 때 훑어야 할 양과, 그동안 프로그램이 멈추는 시간을 떠올리면서, 힙 크기를 키울 때의 득과 실을 저울질해 보세요.
3. 누수가 무서우면 전부 약한 참조로 만들면 되지 않을까
해지를 깜빡해도 약한 참조면 가비지 컬렉터가 알아서 치워주니, 모든 걸 WeakReference 로 들고 있으면 누수 걱정이 없을 것 같아요. 정말 그럴까요? 약한 참조로 들고 있던 객체가 "내가 아직 쓰고 싶은데" 갑자기 사라진다면 어떤 일이 벌어질지, 약한 참조가 잘 어울리는 경우와 그렇지 않은 경우를 나눠 생각해 보세요.
✅ 예시 답안정답 보기
아래는 과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니니, 내가 본 그래프·로그와 비교하며 "왜 이렇게 읽었는지" 를 짚어보세요.
과제 예시답안
과제 1 예시답안 — 힙을 좁혀 OutOfMemoryError 만나보기
핵심 접근
이 과제는 코드를 짜는 게 아니라 도구를 손에 익히는 과제예요. 핵심은 둘이에요. GC 로그 한 줄을 "청소 전 → 후 (전체)" 순서로 끊어 읽을 수 있는가, 그리고 힙을 계속 좁히면 결국 OutOfMemoryError 로 프로그램이 멈춘다는 걸 직접 봤는가. 숫자를 외우는 게 아니라 화살표(->) 앞뒤가 무엇을 뜻하는지 읽는 눈을 만드는 게 목표예요.
미션별 답
- 로그를 켜고 한 줄 해석하기: VM options 칸에
-Xmx16m -Xlog:gc를 적고 아무 프로그램이나 돌리면 콘솔에 GC 줄이 찍혀요. 예를 들어 이런 줄이 나왔다고 해볼게요.
[0.312s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 12M->5M(16M) 1.842ms
이걸 뒤쪽 네 덩어리로 끊어 읽으면 돼요.
GC(2) ← 전체에서 3번째 청소 (0부터 세서 2)
Pause Young ← 젊은 객체만 훑는 작은 청소
12M->5M ← 청소 전 12메가 → 청소 후 5메가 (7메가어치 회수!)
(16M) ← 전체 힙은 16메가 (-Xmx16m 으로 준 그 크기)
1.842ms ← 이 청소에 걸린 시간
한 문장으로 풀면 "3번째 청소가 일어났고, 12메가 쓰던 힙이 청소 후 5메가로 줄었다. 전체 힙은 16메가, 청소엔 1.842밀리초가 걸렸다" 예요. 화살표 뒤 숫자가 앞 숫자보다 작아졌으면 청소가 잘 된 거예요.
- 힙을 더 좁히면?:
-Xmx16m도 부족하다 싶게 더 줄이거나(-Xmx4m처럼), 객체를 많이 만드는 프로그램을 돌리면 힙이 꽉 차요. 그러면 청소를 해도 더 비울 게 없어서, 결국java.lang.OutOfMemoryError: Java heap space가 던져지면서 프로그램이 그대로 멈춰요. 이 에러가 바로 "힙이 가득 차서 더는 객체를 못 만든다" 는 신호예요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 화살표 읽는 순서 | 12M->5M(16M) 를 "청소 전 → 후 (전체)" 순서로 정확히 끊어 읽었는가 |
상 |
| 회수량 해석 | 앞 숫자 - 뒤 숫자만큼 회수됐다고 의미를 짚었는가 | 상 |
| OOM 확인 | 힙을 더 좁혔을 때 OutOfMemoryError 가 난다는 걸 실제로 봤는가 |
중 |
| 옵션 위치 | Run Configuration 의 VM options 칸에 옵션을 넣었는가 (IntelliJ 자체 설정과 혼동 안 했는가) | 중 |
흔한 실수
12M->5M를 "5메가에서 12메가로 늘었다" 고 거꾸로 읽는 경우 → 화살표는 항상 "전 → 후" 방향이에요. 앞이 청소 전, 뒤가 청소 후. 건강한 청소면 뒤가 더 작아요.- 괄호 안
(16M)을 "청소된 양" 으로 오해하는 경우 → 괄호 안은 전체 힙 크기예요. 우리가-Xmx16m으로 준 그 천장이죠. 회수량은 화살표 앞뒤의 차이고요. - VM options 가 아니라 IntelliJ 의
Help → Edit Custom VM Options에 옵션을 적는 경우 → 그건 IntelliJ 자기 자신의 메모리 설정이라, 우리가 실행할 프로그램엔 아무 영향이 없어요. 반드시Run → Edit Configurations안의 VM options 칸이어야 해요.
실무 개선 포인트 (심화)
실무에서는 GC 로그를 콘솔이 아니라 파일로 남겨요(-Xlog:gc*:file=gc.log). 서버는 눈으로 계속 못 지켜보니, 문제가 터진 뒤 그 파일을 열어 "그때 무슨 일이 있었나" 를 되짚는 거죠. 한발 더 나아가면 이 GC 로그를 자동으로 그래프로 그려주는 분석 도구에 올려서, 청소 빈도와 멈춤 시간이 평소와 다르게 튀는 순간을 한눈에 잡아내기도 해요. 지금은 한 줄 읽는 연습으로 충분하지만, 로그가 "남겨두면 나중에 범인 추적의 단서가 된다" 는 감만 챙겨두면 좋아요.
과제 2 예시답안 — 누수를 재현하고 덤프로 진단하기
핵심 접근
이 과제의 마침표는 두 메서드가 끝난 뒤 등록부 크기가 왜 다른지를 "강한 참조" 로 설명하는 거예요. 그래프가 계단이고 덤프에 NamedSubscriber 가 잔뜩 보였다는 관찰까지는 절반이에요. 진짜 점수는 leakWithoutUnsubscribe 는 등록부에 구독자가 그대로 남고(크기 = rounds), leakFreeWithUnsubscribe 는 0 이 되는 이유 — 등록할 때마다 짝이 되는 해지가 리스트의 강한 참조를 곧바로 끊었기 때문 — 를 짚는 데서 나와요.
예시 구현
누수를 막는 쪽 코드부터 다시 볼게요. 이 코드가 왜 등록부를 비운 채로 끝내는지가 이 과제의 핵심이에요.
// com/instagram/javabasic/jvm/MemoryLeakDemo.java
// 등록과 해지를 1:1 로 짝지어 누수를 막는 버전이에요.
public static SubscriptionRegistry leakFreeWithUnsubscribe(int rounds) {
SubscriptionRegistry registry = new SubscriptionRegistry();
for (int i = 0; i < rounds; i++) {
NamedSubscriber subscriber = new NamedSubscriber("subscriber-" + i);
// 등록한 그 객체를 그대로 해지해요. 이 짝맞춤이 누수를 막는 안전선이에요.
registry.subscribe(subscriber);
registry.unsubscribe(subscriber);
}
return registry;
}
subscribe() 바로 다음 줄에 unsubscribe() 가 짝으로 붙어 있죠. 구독자가 리스트에 들어갔다가 같은 반복 안에서 곧바로 빠지니, 한 바퀴가 끝날 때마다 리스트는 다시 비어요. 그래서 rounds 가 아무리 커도 돌려받은 등록부의 크기는 0 이에요.
미션별 답
-
그래프는 톱니인가 계단인가:
main을 돌리면 (특히-Xmx64m처럼 힙을 좁게 주면) VisualVM 그래프가 톱니가 아니라 우상향 계단 으로 그려져요. 치워도 안 내려가고 계속 올라가다가, 결국 힙이 꽉 차서OutOfMemoryError로 멈춰요.main이 해지를 한 번도 안 하고 등록만 쌓기 때문이에요. -
덤프에서 비정상적으로 많은 클래스: Heap 덤프를 떠서 클래스별 인스턴스 수를 보면
NamedSubscriber가 압도적으로 많아요. 그리고 구독자마다 이름(String)을 하나씩 들고 있으니String도 그만큼 따라 많아지죠. 한 종류의 객체가 수백만 개씩 쌓였다면 그게 거의 항상 범인 후보예요. -
두 메서드의 등록부 크기 차이:
leakWithoutUnsubscribe(rounds)→ 등록부 크기 = rounds. 구독만 하고 해지를 안 하니, 만든 구독자가 전부 리스트에 그대로 남아요. 누수예요.leakFreeWithUnsubscribe(rounds)→ 등록부 크기 = 0. 등록 직후 같은 객체를 해지하니, 한 바퀴마다 리스트가 다시 비어요.
차이가 생기는 이유: 등록부의
subscribers리스트는 구독자를 강한 참조 로 붙들어요. 누수 버전은 그 손을 한 번도 놓지 않아서 구독자가 차곡차곡 쌓이고, 무누수 버전은 등록과 해지를 짝지어 그 손을 곧바로 놓으니 아무도 안 남는 거예요. 이 동작은 코드베이스에서 검증돼 있어요 — 누수 버전은 끝난 뒤 크기가rounds, 무누수 버전은 0 이 되는 걸 확인했어요.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 계단 = 누수 판정 | main 그래프가 우상향 계단이고 그게 누수의 신호임을 짚었는가 |
상 |
| 강한 참조로 차이 설명 | 두 메서드의 크기 차이를 "강한 참조를 놓았는가" 로 설명했는가 | 상 |
| 크기 = rounds vs 0 | 누수 = rounds, 무누수 = 0 이라는 결과를 정확히 적었는가 | 상 |
| 덤프에서 범인 후보 | NamedSubscriber 가 비정상적으로 많다는 걸 덤프로 찾았는가 |
중 |
| String 동반 증가 | 구독자마다 이름을 들어 String 도 같이 많아진다는 걸 알아챘는가 |
하 |
흔한 실수
- 등록부 크기가 0 이 됐다고 "구독자 객체가 즉시 메모리에서 지워졌다" 고 설명하는 경우 →
unsubscribe는 리스트의 강한 참조를 끊어 회수 가능한 상태로 만들 뿐이에요. 실제로 언제 치울지는 가비지 컬렉터가 정해요. "참조를 끊어 회수 가능해진다" 가 정확한 표현이에요. - 그래프가 계단인 것만 보고 "덤프 없이 누수라 단정" 하는 경우 → 계단은 강한 신호지만, 어떤 객체가 범인인지는 덤프를 떠야 알아요. 관찰(계단)과 진단(덤프)은 다른 단계예요.
- 범인을
NamedSubscriber자체로만 지목하는 경우 → 틀린 건 아니지만, 진짜 원인은 그걸 안 놓는subscribers리스트예요. 객체가 나쁜 게 아니라 다 쓴 객체를 놓아주지 않는 손이 문제라는 걸 짚으면 더 정확해요(과제 2의 다음 단계가 바로 그 참조 사슬을 따라가는 거였죠).
실무 개선 포인트 (심화)
덤프로 "압도적으로 많은 클래스" 를 찾는 건 시작일 뿐이에요. 실무에서는 한발 더 들어가서 그 객체가 차지한 메모리의 총량(흔히 retained size 라고 불러요 — 이 객체를 치우면 같이 풀려나는 메모리의 합)을 봐요. 개수는 적은데 하나하나가 큰 객체가 진짜 범인인 경우도 있거든요. 또 누수는 한 장의 사진만으로 단정하기 어려울 때가 많아서, 시간 간격을 두고 덤프를 두 번 떠서 "그사이 어떤 클래스가 계속 늘었나" 를 비교하기도 해요. 늘기만 하고 안 줄어드는 클래스가 누수의 가장 확실한 단서예요. 지금은 개수 순으로 범인 후보를 짚는 것만으로 충분하지만, "사진 한 장보다 두 장 비교가 강하다" 는 감은 챙겨두면 좋아요.
과제 3 예시답안 — 나만의 누수 시나리오: Map 캐시는 무엇을 붙드나
핵심 접근
이 과제는 새 코드를 만드는 게 아니라 글로 분석하는 과제예요. 핵심은 셋이에요. 어떤 자료구조가(Map) 무엇을(키와 값) 강하게 붙들어 누수가 나는지 구조로 설명하기, 막는 두 방법(다 쓰면 빼주기 vs 약한 참조)을 비교하기, 그리고 각 방법이 어떤 상황에 어울리는지 판단하기. 구독 등록부가 "리스트가 구독자를 붙든다" 였다면, 캐시는 "맵이 키와 값을 붙든다" 로 같은 원리를 다른 자료구조에 옮겨 보는 거예요.
미션별 답
- 어떤 자료구조가 무엇을 강하게 붙드나: 캐시를 보통
Map으로 만들어요. 한 번 계산하거나 받아온 결과를 키와 함께 넣어두고, 다음에 같은 키가 오면 다시 만들지 않고 꺼내 쓰는 거죠. 코드로는 이런 한 줄이 반복돼요.
cache.put(postId, thumbnail); // 넣기만 하고 빼지 않으면...
문제는 Map 이 자기가 담은 키와 값을 둘 다 강한 참조 로 붙든다는 거예요. 넣기만(put) 하고 빼지(remove) 않으면, 캐시에 들어간 키·값은 바깥에서 아무도 안 써도 맵이 계속 붙들고 있어서 회수되지 않아요. 구독 등록부 리스트가 구독자를 안 놓던 것과 똑같은 구조예요. 자료구조만 리스트에서 맵으로 바뀌었을 뿐, "강하게 붙드는 손이 안 놓는다" 는 누수의 본질은 같아요. 그래서 캐시는 신경 안 쓰면 가장 흔하게 누수가 나는 자리예요. 들어오기만 하고 절대 안 빠지는, 끝없이 부풀어 오르는 맵이 되거든요.
-
막는 두 방법 비교:
방법 어떻게 막나 장점 단점 다 쓰면 빼주기 remove로 직접 빼거나, 크기·시간 한도를 정해 주기적으로 정리무엇을 언제 버릴지 내가 정확히 통제 정리 코드를 직접 짜야 하고, 깜빡하면 다시 누수 약한 참조로 붙들기 키나 값을 약하게 붙들어 가비지 컬렉터가 알아서 치우게 함 깜빡해도 안전, 메모리 급하면 자동 정리 언제 사라질지 통제 못 함 → 캐시 미스가 늘 수 있음 첫째 방법은 "내가 책임지고 비운다" 예요. 통제권은 확실하지만 손이 가고 깜빡할 위험이 남아요. 둘째 방법은 "시스템에 맡긴다" 예요. 안전하지만, 아직 쓰고 싶은데 가비지 컬렉터가 메모리 빠듯할 때 치워버려서 막상 꺼내면 없는(캐시 미스) 일이 생겨요.
-
각 방법이 어울리는 상황:
- 다 쓰면 빼주기 → 캐시에 담는 게 정확히 통제돼야 할 때. 예를 들어 "로그인한 사용자 세션" 처럼 로그아웃하면 반드시 지워야 하고, 멋대로 사라지면 안 되는 데이터요. "최대 1000개까지만, 마지막 사용 후 10분 지나면 버린다" 같은 분명한 정책이 있는 캐시도 이쪽이에요.
- 약한 참조로 붙들기 → 사라져도 다시 만들면 그만인 데이터. 썸네일 이미지 캐시가 대표적이에요. 있으면 빠르고 없어도 다시 받아오면 되니, 메모리가 급할 때 가비지 컬렉터에 양보해도 손해가 없어요.
결국 캐시 적중률(있으면 빠름)과 안전(메모리 안 터짐) 사이의 맞바꿈 이에요. 적중률을 끝까지 챙기려면 직접 관리하고, 안전을 우선하면 약한 참조로 양보하는 거죠.
채점 포인트
| 포인트 | 무엇을 봐야 하는가 | 배점 가중 |
|---|---|---|
| 강한 참조 구조 분석 | Map 이 키·값을 강한 참조로 붙들어 안 빼면 누적된다고 설명했는가 |
상 |
| 두 방법 대비 | "다 쓰면 빼주기" 와 "약한 참조" 의 장단점을 짝지어 비교했는가 | 상 |
| 상황별 판단 | 각 방법이 어떤 데이터·상황에 어울리는지 근거를 댔는가 | 상 |
| 등록부와 연결 | 구독 등록부 누수와 같은 원리(붙드는 손이 안 놓음)임을 짚었는가 | 중 |
| 적중률 vs 안전 | 캐시의 맞바꿈을 "적중률 vs 안전" 으로 정리했는가 | 중 |
흔한 실수
- 코드를 잔뜩 짜오는 경우 → 이 과제는 글로 분석하는 게 핵심이에요.
put한 줄 같은 자명한 예시 외에 새 클래스를 만들 필요는 없어요. 구조와 판단을 글로 풀어내는 데 점수가 있어요. - "약한 참조가 무조건 더 안전하니 항상 그걸 쓰면 된다" 고 결론내는 경우 → 다음 생각해볼 주제 3과 정확히 이어지는 함정이에요. 약한 참조는 "사라져도 되는 데이터" 전용이고, 반드시 살아 있어야 할 캐시(세션 등)에 쓰면 멋대로 사라져 버그가 나요.
Map이 값만 붙들고 키는 안 붙든다고 쓰는 경우 → 일반HashMap은 키와 값을 둘 다 강하게 붙들어요. 그래서 키로 쓴 객체조차 맵이 살아 있는 한 회수되지 않아요(약한 참조로 키를 붙드는 특별한 맵을 따로 쓰지 않는 한요).
실무 개선 포인트 (심화)
실무에서는 캐시를 Map 으로 손수 짜기보다 검증된 캐시 도구를 쓰는 경우가 많아요. "최대 몇 개까지만 담는다", "넣은 지 몇 분 지나면 버린다", "마지막으로 꺼낸 지 오래된 것부터 버린다" 같은 정책을 선언만 하면 알아서 비워주거든요. 이러면 첫째 방법(직접 빼주기)의 통제권과, 누수를 막는 안전함을 둘 다 얻을 수 있어요. 약한 참조를 직접 다루는 건 동작이 미묘해서 디버깅이 까다로운데, 이런 도구는 그 어려움을 안으로 감춰줘요. 그래서 "캐시의 원리는 직접 손으로 이해하되, 실전 캐시는 검증된 도구에 맡긴다" 가 흔한 선택이에요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — 가비지 컬렉터가 자동인데, 자바에 왜 메모리 누수가 있을까
[문제 상황 요약]
자바는 안 쓰는 객체를 가비지 컬렉터가 알아서 치워준다고 했는데, 오늘 우리는 메모리가 새는 걸 그래프로 분명히 봤어요. "자동" 청소가 있는데 왜 "누수" 가 생기는지, 이 모순처럼 보이는 상황의 책임이 누구에게 있는지에 대한 질문이에요.
[튜터의 가이드 및 해설]
핵심은 "가비지 컬렉터가 무엇을 기준으로 치울지 정하는가" 예요. 가비지 컬렉터는 사람 마음을 못 읽어요. 우리가 머릿속으로 "이제 이 구독자 안 써" 라고 생각해도, 그 생각을 코드가 알 방법이 없죠. 가비지 컬렉터가 보는 기준은 오직 하나, 도달 가능성(reachability) 이에요. 뿌리(GC Root)에서 화살표로 닿을 수 있으면 "살아 있는 객체", 못 닿으면 "쓰레기" 로 판단해요.
그래서 우리가 안 쓰지만 코드 어딘가에서 강한 참조가 아직 그 객체를 가리키고 있으면, 가비지 컬렉터는 "어, 닿네? 그럼 쓰는 중이구나" 하고 살려둬요. 오늘 본 구독 등록부가 딱 그거였죠. 화면에서 사라진 구독자라도 subscribers 리스트가 붙들고 있으면, 가비지 컬렉터 눈엔 멀쩡히 살아 있는 객체예요. 덤프에서 참조 사슬을 따라가 보니, 뿌리에서 등록부를 거쳐 120만 개 구독자까지 화살표가 끊김 없이 이어져 있었고요.
그러니 누수는 가비지 컬렉터의 실패가 아니에요. 청소부는 자기 기준(도달 가능성)대로 정확히 일했어요. 진짜 원인은 우리가 안 놓은 참조 예요. "자동으로 치워준다" 가 "아무 신경 안 써도 된다" 는 뜻은 아니라는 거죠. 자동 청소가 있는 언어에서도 개발자가 끝까지 책임질 부분은 "다 쓴 객체의 참조를 제때 놓는 것" 이에요. 구독했으면 해지하고, 맵에 담았으면 다 쓰면 빼고요. 자동 GC 가 막아주는 건 "반납을 깜빡한 진짜 쓰레기" 이고, 막아주지 못하는 건 "우리가 실수로 살려둔, 도달은 되지만 안 쓰는 객체" 인 거예요.
🎯 면접관을 홀리는 핵심 멘트
"가비지 컬렉터는 도달 불가능한 객체만 치웁니다. 누수는 가비지 컬렉터의 실패가 아니라, 개발자가 의도치 않게 살려둔 참조의 문제예요. 청소부는 도달 가능성 기준대로 정확히 일했고, 다 썼는데도 강한 참조를 안 놓은 쪽은 우리죠. 그래서 '자동 GC = 신경 끄기' 가 아니라, 수명이 끝난 객체의 참조를 제때 끊는 건 여전히 개발자 책임입니다. 자동 청소가 막아주는 건 진짜 쓰레기일 뿐, 도달은 되지만 안 쓰는 객체는 사람이 직접 놓아줘야 해요."
생각해볼 주제 2 예시답안 — 힙은 크게 줄수록 좋은 걸까
[문제 상황 요약]
OutOfMemoryError 가 무서우니 -Xmx 를 무조건 크게 주면 안전할 것 같아요. 하지만 큰 힙에는 보이지 않는 비용도 따라와요. 힙을 키우면 무엇을 얻고 무엇을 잃는지, 그 저울질에 대한 질문이에요.
[튜터의 가이드 및 해설]
먼저 큰 힙이 주는 이득은 분명해요. 객체를 담을 공간이 넉넉하니 OOM 이 잘 안 나고, 가비지 컬렉터가 청소하는 횟수도 줄어요. 여유가 많으니 자주 비울 필요가 없는 거죠. 여기까지만 보면 "크게 줄수록 좋다" 가 맞는 것 같아요.
그런데 숨은 비용이 셋 있어요. 첫째, 청소 한 번의 무게예요. 힙이 클수록 가비지 컬렉터가 한 번 청소할 때 훑어야 할 객체가 많아져요. 청소 횟수는 줄지만, 한 번 청소할 때 프로그램이 멈추는 시간(Stop-the-World)이 길어질 수 있어요. 사용자 요청을 처리하는 서버라면 이 "한 번의 긴 멈춤" 이 화면 버벅임으로 느껴지죠. 둘째, 자원 낭비예요. 안 쓰는 메모리를 컴퓨터에서 잔뜩 빌려놓고 깔고 앉는 거라, 같은 서버에 다른 프로그램이 돌 자리를 빼앗아요. 셋째, 어쩌면 가장 무서운 건 문제를 늦게 발견 한다는 거예요. 누수가 있어도 힙이 크면 한참 동안 안 터지니, "괜찮은 줄 알았는데 며칠 뒤 갑자기 OOM" 처럼 문제가 숨어 버려요. 힙을 좁게 잡으면 누수가 빨리 드러나서 잡기 쉽다고 오늘 배웠던 게 바로 이 뒤집힌 면이에요.
그래서 정답은 "크게" 도 "작게" 도 아니라 적정선 이에요. 프로그램이 실제로 쓰는 양을 보고, 거기에 약간의 여유를 더한 크기가 가장 좋아요. 그리고 한 가지 더 — 멈춤 시간 문제는 힙 크기뿐 아니라 어떤 가비지 컬렉터를 쓰느냐로도 다뤄요. 지금(2026년 기준) JDK 25 의 기본은 G1 이라 대부분 그대로 두면 잘 돌아가요. 큰 힙에서도 멈춤을 한계까지 줄여야 하는 특별한 경우엔 ZGC 같은 멈춤에 특화된 선택지를 저울에 올리기도 하는데, 이건 평소에 손댈 일은 거의 없으니 "그런 선택지도 있다" 정도만 알아두면 충분해요.
🎯 면접관을 홀리는 핵심 멘트
"힙은 크게 줄수록 좋은 게 아니라 적정선이 핵심입니다. 크게 잡으면 OOM은 멀어지고 청소 횟수도 줄지만, 청소 한 번에 훑는 양이 늘어 멈춤이 길어지고, 메모리를 낭비하고, 무엇보다 누수가 숨어 발견이 늦어집니다. 그래서 실제 사용량에 여유를 더한 적정선으로 잡고, 멈춤이 치명적이라면 힙 크기 조정과 함께 JDK 25 기본 G1에서 ZGC 같은 저지연 컬렉터를 검토합니다. 큰 힙은 안전이 아니라 문제를 가리는 커튼일 수 있어요."
생각해볼 주제 3 예시답안 — 누수가 무서우면 전부 약한 참조로 만들면 되지 않을까
[문제 상황 요약]
해지를 깜빡해도 약한 참조면 가비지 컬렉터가 알아서 치워주니, 모든 걸 WeakReference 로 들고 있으면 누수 걱정이 없을 것 같아요. 정말 그럴까요? 약하게 붙든 객체가 "아직 쓰고 싶은데" 갑자기 사라지면 어떤 일이 벌어질지에 대한 질문이에요.
[튜터의 가이드 및 해설]
핵심 질문은 하나예요. "이 객체를 시스템이 마음대로 버려도 괜찮은가?" 약한 참조의 본질은 "치우고 싶으면 치워도 돼" 라고 가비지 컬렉터에 허락을 내주는 거예요. 그러니 절대 사라지면 안 되는 객체에 이 허락을 내주면, 멀쩡히 쓰고 있는데 어느 순간 사라지는 사고가 나요.
구체적으로 두 가지가 터져요. 첫째, 캐시 미스 폭증 이에요. 캐시를 전부 약한 참조로 들고 있으면, 메모리가 조금만 빠듯해져도 가비지 컬렉터가 캐시를 통째로 비워버려요. 그러면 캐시가 있으나 마나 한 상태가 돼서, 매번 다시 계산하고 다시 받아오느라 오히려 느려져요. 누수를 피하려다 성능을 잃는 거죠. 둘째, NPE(NullPointerException) 위험 이에요. 약하게 붙든 객체는 꺼낼 때 이미 사라져서 null 일 수 있어요. 오늘 떠올린 지난 시간의 WeakImageCache 가 get() 할 때 null 일 수 있어서 꼭 null 체크를 하고 없으면 다시 받아오게 짠 이유가 바로 이거예요. 이 null 체크를 빼먹으면 사라진 객체를 건드리다 프로그램이 깨져요.
그래서 약한 참조는 만능 안전장치가 아니라, "사라져도 되는 데이터" 전용 도구 예요. 썸네일 캐시처럼 없으면 다시 받아오면 그만인 데이터엔 딱 맞아요. 반대로 켜둔 알림 구독, 로그인 세션처럼 반드시 살아 있어야 할 데이터는 강한 참조로 꽉 붙들고, 다 쓰면 직접 해지하는 게 맞아요. 정리하면 — 꼭 살려야 할 건 강한 참조로 붙들고 책임지고 놓아주기, 사라져도 그만인 건 약한 참조로 가비지 컬렉터에 양보하기. "누수가 무서우니 전부 약하게" 가 아니라, 객체마다 "버려도 되는가" 를 물어 골라 써야 해요.
🎯 면접관을 홀리는 핵심 멘트
"약한 참조는 누수의 만능 해법이 아니라 '사라져도 되는 데이터' 전용 도구입니다. 전부 약한 참조로 들면 메모리가 조금만 빠듯해도 가비지 컬렉터가 캐시를 비워 캐시 미스가 폭증하고, 꺼낼 때 null이라 NPE 위험까지 생깁니다. 그래서 기준은 하나예요 — 시스템이 마음대로 버려도 되는 객체인가. 버려도 되면 약한 참조로 양보하고, 켜둔 알림이나 세션처럼 반드시 살아 있어야 하면 강한 참조로 붙들고 다 쓰면 직접 해지합니다. 안전장치를 객체 성격에 맞게 골라 쓰는 게 핵심입니다."