문서 읽는 데 45분 · day44

Day 44 — 메모리 누수 진단과 JVM 튜닝

목차 32
전체 48강 중 44강 · 자바 기초
난이도 · 입문

지난 시간엔 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 처럼요. 이러면 힙이 도중에 늘었다 줄었다 하느라 생기는 출렁임이 없어서 더 안정적으로 동작해요.

-Xmnyoung(젊은) 영역 의 크기예요. 지난 시간 가비지 컬렉션을 배울 때, 갓 만들어진 객체들이 잠깐 머무는 "젊은 구역" 이야기가 나왔던 거 기억나세요? 그 젊은 구역의 크기를 따로 정하는 옵션이에요. 지금은 "이런 것도 있구나" 정도만 알아두면 충분해요. 평소엔 거의 건드릴 일이 없고, 성능을 아주 깊게 튜닝할 때나 꺼내 쓰는 손잡이예요.

크기 뒤에 붙이는 단위는 세 가지예요. 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() 로 빼지 않으면, 리스트가 구독자를 계속 붙들고 있어서 누수가 난다고 했어요.

그 누수를 일부러 일으키는 작은 프로그램을 하나 준비했어요.

Java
// 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: 누수 해결하기 — 손을 놓게 만들기

원인을 알았으니 고치는 건 의외로 간단해요. 리스트가 다 쓴 구독자를 놓도록, 등록할 때마다 짝이 되는 해지를 붙여주면 돼요. 같은 데모 안에 누수가 없는 버전도 준비해 뒀어요.

Java
// 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 처럼, 화면이 닫히는 순간 한 번에 전체를 비우는 방법이죠.

Java
// 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: 누수를 재현하고 덤프로 진단하기 [응용]

MemoryLeakDemomain 을 실행하고 VisualVM 으로 Heap 그래프를 지켜보세요. 그래프가 톱니인지 계단인지 적어보고, Heap 덤프를 떠서 어떤 클래스의 인스턴스가 비정상적으로 많은지 찾아보세요. 그다음 leakWithoutUnsubscribeleakFreeWithUnsubscribe 가 끝난 뒤 등록부 크기가 각각 어떻게 다른지, 그 차이가 왜 생기는지 설명해 보세요.

과제 3: 나만의 누수 시나리오 설계하기 [심화]

구독 등록부 말고, 누수가 날 수 있는 다른 상황을 하나 직접 떠올려 설계해 보세요. (힌트: 캐시처럼 Map 에 데이터를 계속 넣기만 하고 안 빼면 어떻게 될까요?) 어떤 자료구조가 무엇을 강하게 붙들어서 누수가 나는지 글로 분석하고, 이를 막는 두 가지 방법 — 다 쓰면 빼주기 vs 약한 참조로 붙들기 — 를 비교해 보세요. 각 방법이 어떤 상황에 더 어울릴지도 함께요.


생각해볼 주제

1. 가비지 컬렉터가 자동인데, 자바에 왜 메모리 누수가 있을까

자바는 C/C++ 와 달리 메모리를 자동으로 치워주는데도 메모리 누수가 생겨요. "자동" 이라는 말과 "누수" 라는 말이 같이 있는 게 모순처럼 느껴지죠. 가비지 컬렉터가 객체를 치우는 기준이 무엇이었는지 떠올리면서, 자동 청소가 막아주는 것과 막아주지 못하는 것의 경계를 생각해 보세요.

2. 힙은 크게 줄수록 좋은 걸까

OutOfMemoryError 가 무서우니 -Xmx 를 무조건 크게 주면 안전할 것 같아요. 하지만 큰 힙에는 보이지 않는 비용도 따라와요. 가비지 컬렉터가 한 번 청소할 때 훑어야 할 양과, 그동안 프로그램이 멈추는 시간을 떠올리면서, 힙 크기를 키울 때의 득과 실을 저울질해 보세요.

3. 누수가 무서우면 전부 약한 참조로 만들면 되지 않을까

해지를 깜빡해도 약한 참조면 가비지 컬렉터가 알아서 치워주니, 모든 걸 WeakReference 로 들고 있으면 누수 걱정이 없을 것 같아요. 정말 그럴까요? 약한 참조로 들고 있던 객체가 "내가 아직 쓰고 싶은데" 갑자기 사라진다면 어떤 일이 벌어질지, 약한 참조가 잘 어울리는 경우와 그렇지 않은 경우를 나눠 생각해 보세요.

✅ 예시 답안정답 보기

아래는 과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니니, 내가 본 그래프·로그와 비교하며 "왜 이렇게 읽었는지" 를 짚어보세요.

과제 예시답안

과제 1 예시답안 — 힙을 좁혀 OutOfMemoryError 만나보기

핵심 접근

이 과제는 코드를 짜는 게 아니라 도구를 손에 익히는 과제예요. 핵심은 둘이에요. GC 로그 한 줄을 "청소 전 → 후 (전체)" 순서로 끊어 읽을 수 있는가, 그리고 힙을 계속 좁히면 결국 OutOfMemoryError 로 프로그램이 멈춘다는 걸 직접 봤는가. 숫자를 외우는 게 아니라 화살표(->) 앞뒤가 무엇을 뜻하는지 읽는 눈을 만드는 게 목표예요.

미션별 답

  1. 로그를 켜고 한 줄 해석하기: 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밀리초가 걸렸다" 예요. 화살표 뒤 숫자가 앞 숫자보다 작아졌으면 청소가 잘 된 거예요.

  1. 힙을 더 좁히면?: -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 이 되는 이유 — 등록할 때마다 짝이 되는 해지가 리스트의 강한 참조를 곧바로 끊었기 때문 — 를 짚는 데서 나와요.

예시 구현

누수를 막는 쪽 코드부터 다시 볼게요. 이 코드가 왜 등록부를 비운 채로 끝내는지가 이 과제의 핵심이에요.

Java
// 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 이에요.

미션별 답

  1. 그래프는 톱니인가 계단인가: main 을 돌리면 (특히 -Xmx64m 처럼 힙을 좁게 주면) VisualVM 그래프가 톱니가 아니라 우상향 계단 으로 그려져요. 치워도 안 내려가고 계속 올라가다가, 결국 힙이 꽉 차서 OutOfMemoryError 로 멈춰요. main 이 해지를 한 번도 안 하고 등록만 쌓기 때문이에요.

  2. 덤프에서 비정상적으로 많은 클래스: Heap 덤프를 떠서 클래스별 인스턴스 수를 보면 NamedSubscriber 가 압도적으로 많아요. 그리고 구독자마다 이름(String)을 하나씩 들고 있으니 String 도 그만큼 따라 많아지죠. 한 종류의 객체가 수백만 개씩 쌓였다면 그게 거의 항상 범인 후보예요.

  3. 두 메서드의 등록부 크기 차이:

    • 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 약한 참조)을 비교하기, 그리고 각 방법이 어떤 상황에 어울리는지 판단하기. 구독 등록부가 "리스트가 구독자를 붙든다" 였다면, 캐시는 "맵이 키와 값을 붙든다" 로 같은 원리를 다른 자료구조에 옮겨 보는 거예요.

미션별 답

  1. 어떤 자료구조가 무엇을 강하게 붙드나: 캐시를 보통 Map 으로 만들어요. 한 번 계산하거나 받아온 결과를 키와 함께 넣어두고, 다음에 같은 키가 오면 다시 만들지 않고 꺼내 쓰는 거죠. 코드로는 이런 한 줄이 반복돼요.
Java
cache.put(postId, thumbnail);   // 넣기만 하고 빼지 않으면...

문제는 Map 이 자기가 담은 키와 값을 둘 다 강한 참조 로 붙든다는 거예요. 넣기만(put) 하고 빼지(remove) 않으면, 캐시에 들어간 키·값은 바깥에서 아무도 안 써도 맵이 계속 붙들고 있어서 회수되지 않아요. 구독 등록부 리스트가 구독자를 안 놓던 것과 똑같은 구조예요. 자료구조만 리스트에서 맵으로 바뀌었을 뿐, "강하게 붙드는 손이 안 놓는다" 는 누수의 본질은 같아요. 그래서 캐시는 신경 안 쓰면 가장 흔하게 누수가 나는 자리예요. 들어오기만 하고 절대 안 빠지는, 끝없이 부풀어 오르는 맵이 되거든요.

  1. 막는 두 방법 비교:

    방법 어떻게 막나 장점 단점
    다 쓰면 빼주기 remove 로 직접 빼거나, 크기·시간 한도를 정해 주기적으로 정리 무엇을 언제 버릴지 내가 정확히 통제 정리 코드를 직접 짜야 하고, 깜빡하면 다시 누수
    약한 참조로 붙들기 키나 값을 약하게 붙들어 가비지 컬렉터가 알아서 치우게 함 깜빡해도 안전, 메모리 급하면 자동 정리 언제 사라질지 통제 못 함 → 캐시 미스가 늘 수 있음

    첫째 방법은 "내가 책임지고 비운다" 예요. 통제권은 확실하지만 손이 가고 깜빡할 위험이 남아요. 둘째 방법은 "시스템에 맡긴다" 예요. 안전하지만, 아직 쓰고 싶은데 가비지 컬렉터가 메모리 빠듯할 때 치워버려서 막상 꺼내면 없는(캐시 미스) 일이 생겨요.

  2. 각 방법이 어울리는 상황:

    • 다 쓰면 빼주기 → 캐시에 담는 게 정확히 통제돼야 할 때. 예를 들어 "로그인한 사용자 세션" 처럼 로그아웃하면 반드시 지워야 하고, 멋대로 사라지면 안 되는 데이터요. "최대 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 일 수 있어요. 오늘 떠올린 지난 시간의 WeakImageCacheget() 할 때 null 일 수 있어서 꼭 null 체크를 하고 없으면 다시 받아오게 짠 이유가 바로 이거예요. 이 null 체크를 빼먹으면 사라진 객체를 건드리다 프로그램이 깨져요.

그래서 약한 참조는 만능 안전장치가 아니라, "사라져도 되는 데이터" 전용 도구 예요. 썸네일 캐시처럼 없으면 다시 받아오면 그만인 데이터엔 딱 맞아요. 반대로 켜둔 알림 구독, 로그인 세션처럼 반드시 살아 있어야 할 데이터는 강한 참조로 꽉 붙들고, 다 쓰면 직접 해지하는 게 맞아요. 정리하면 — 꼭 살려야 할 건 강한 참조로 붙들고 책임지고 놓아주기, 사라져도 그만인 건 약한 참조로 가비지 컬렉터에 양보하기. "누수가 무서우니 전부 약하게" 가 아니라, 객체마다 "버려도 되는가" 를 물어 골라 써야 해요.

🎯 면접관을 홀리는 핵심 멘트

"약한 참조는 누수의 만능 해법이 아니라 '사라져도 되는 데이터' 전용 도구입니다. 전부 약한 참조로 들면 메모리가 조금만 빠듯해도 가비지 컬렉터가 캐시를 비워 캐시 미스가 폭증하고, 꺼낼 때 null이라 NPE 위험까지 생깁니다. 그래서 기준은 하나예요 — 시스템이 마음대로 버려도 되는 객체인가. 버려도 되면 약한 참조로 양보하고, 켜둔 알림이나 세션처럼 반드시 살아 있어야 하면 강한 참조로 붙들고 다 쓰면 직접 해지합니다. 안전장치를 객체 성격에 맞게 골라 쓰는 게 핵심입니다."

전체 목록 자바 기초