문서 읽는 데 51분 · day43

Day 43 — JVM 메모리 구조와 가비지 컬렉션

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

지난 시간엔 디자인 패턴의 마지막 조각을 맞췄어요. Decorator 로 채널을 겹겹이 감싸고, Iterator 로 Feed 를 순회했죠. 그러면서 마지막에 슬쩍 던졌던 질문이 있었어요. "데코가 데코를 품고, Feed 가 게시물들을 붙들고 있는데 — 이 객체들은 도대체 메모리 어디에 살고 있을까? 더 이상 안 쓰는 객체는 누가 치워줄까?"

오늘은 그 질문에 답하는 시간이에요. 코드에서 한 걸음 물러나서, 우리가 짠 코드가 실제로 돌아가는 무대인 JVM(자바 가상 머신) 속을 들여다봅니다. 객체가 사는 곳, 메서드 호출이 쌓이는 곳, 클래스 정보가 담기는 곳이 어떻게 나뉘는지 그림으로 그려볼 거예요.

그리고 안 쓰는 객체를 자동으로 치워주는 가비지 컬렉터(garbage collector, 쓰레기 수거기) 가 무슨 일을 하는지, 객체를 놓지 못해 생기는 메모리 누수 가 왜 위험한지까지 따라가 봅니다.

오늘은 새 문법을 배우는 날이 아니에요. 지금까지 new 로 수백 번 만든 객체들이 실제로 어디서 태어나고 어떻게 사라지는지, 그 무대 뒤를 구경하는 날이에요. 그동안 막연하게 "메모리에 올라간다" 정도로만 알던 걸 오늘 그림으로 또렷하게 그려봅시다.

🎯 학습 목표

  • JVM 메모리가 Heap·Stack·Metaspace 세 구역으로 나뉜다는 걸 그림으로 이해해요.
  • 메서드 호출이 Stack 에 어떻게 쌓이고, 객체가 Heap 에서 어떻게 살아가는지 설명할 수 있어요.
  • 가비지 컬렉션이 왜 필요하고 어떤 원리로 안 쓰는 객체를 치우는지 알아요.
  • 메모리 누수가 왜 생기는지, 그리고 막는 안전선이 무엇인지 코드로 확인해요.

Step 1: JVM의 세 영역 — Heap·Stack·Metaspace

우리가 자바 프로그램을 "Run" 버튼으로 실행하면, 컴퓨터 안에서는 JVM 이라는 프로그램이 먼저 켜져요. JVM 은 우리가 짠 코드를 한 줄씩 해석해서 실행해 주는 일종의 통역사예요. 그리고 이 통역사는 일을 시작하면서 컴퓨터의 메모리 한 덩어리를 운영체제에서 빌려와요. 그 빌려온 메모리를 용도별로 칸을 나눠서 쓰는데, 오늘 우리가 들여다볼 게 바로 그 칸들이에요.

지난 시간 Day 8 에서 우리는 객체를 처음 만들면서 메모리를 StackHeap 두 구역으로 나눠 그려봤어요. 기억나시죠? 변수는 Stack 에, 진짜 객체는 Heap 에 산다는 그림이요. 오늘은 그 그림을 더 크게 펼쳐서, 여기에 Metaspace 라는 세 번째 구역을 더해 완성할 거예요.

세 구역이 하는 일

먼저 세 구역의 이름과 역할만 가볍게 훑어볼게요. 이름은 영어지만 각각 하는 일은 아주 단순해요.

텍스트
   JVM 이 빌려 쓰는 메모리 한 덩어리

   ├─ Heap       ── 객체들이 사는 큰 창고
   │                ( new 로 만든 모든 것 )
   │
   ├─ Stack      ── 메서드 호출이 쌓이는 곳
   │                ( 지역 변수가 잠깐 머무름 )
   │
   └─ Metaspace  ── 클래스 설명서 보관소
                    ( 클래스 구조 정보 )
  • Heap(힙) 은 객체들이 사는 가장 큰 창고예요. new Member(...), new ArrayList<>() 처럼 new 로 만든 모든 객체가 여기에 자리를 잡아요. 셋 중 가장 넓고, 오늘 가비지 컬렉션 이야기의 주인공이기도 해요.
  • Stack(스택) 은 메서드 호출이 쌓이는 곳이에요. 메서드 안에서 잠깐 쓰는 지역 변수가 여기에 머물러요. 메서드가 끝나면 깔끔하게 비워져요.
  • Metaspace(메타스페이스) 는 클래스의 설명서가 담기는 보관소예요. "Member 클래스에는 어떤 필드가 있고 어떤 메서드가 있다" 같은 클래스 자체의 구조 정보가 여기 들어가요. 객체가 아니라 클래스 정보가 사는 곳이라는 게 핵심이에요.

왜 굳이 나눠서 쓸까?

"그냥 메모리 한 덩어리에 다 넣으면 안 되나?" 싶을 수 있어요. 나누는 이유는 저마다 수명이 다르기 때문이에요.

메서드 안 지역 변수는 메서드가 끝나면 바로 사라져야 해요. 수명이 아주 짧죠. 반면 new 로 만든 객체는 여러 메서드를 오가며 한참 살아남기도 해요. 그리고 클래스 설명서는 프로그램이 켜져 있는 동안 거의 통째로 유지돼요. 수명이 제각각인 것들을 한곳에 섞어두면 치우기가 골치 아파요. 그래서 비슷한 수명끼리 구역을 나눠두면, 짧게 사는 곳은 빠르게 비우고 길게 사는 곳은 천천히 관리할 수 있어요.

💡 오늘 기억할 한 문장은 이거예요. "객체는 Heap 에, 변수는 Stack 에, 클래스 정보는 Metaspace 에 산다." 이 한 줄만 잡고 가면 나머지는 그림으로 따라와요. 다음 Step 들에서 세 구역을 하나씩 자세히 들여다볼 거예요.


Step 2: Stack — 메서드 호출과 지역 변수

먼저 Stack 부터 자세히 봅시다. Stack 은 메서드 호출을 관리하는 구역이에요. 우리가 메서드를 부를 때마다 여기에 차곡차곡 쌓이고, 메서드가 끝나면 위에서부터 하나씩 치워져요.

접시 쌓기 — LIFO

Stack 이라는 이름은 "쌓는다" 는 뜻이에요. 식당에서 깨끗이 씻은 접시를 쌓아두는 모습을 떠올려보세요. 새 접시는 맨 위에 올려놓고, 꺼낼 때도 맨 위 접시부터 가져가요. 맨 아래 접시를 빼려면 위에 쌓인 걸 다 치워야 하죠.

이렇게 나중에 넣은 게 먼저 나오는 방식을 LIFO(Last In, First Out, 후입선출) 라고 불러요. 메서드 호출이 딱 이래요. 가장 최근에 부른 메서드가 가장 먼저 끝나고 치워져요.

메서드를 하나 부를 때마다 Stack 에는 그 메서드를 위한 작은 칸이 하나 생겨요. 이 칸을 스택 프레임(stack frame, 호출 한 칸) 이라고 불러요. 이 칸 안에 그 메서드의 지역 변수들이 들어가요.

텍스트
   main() 이 a() 를 부르고, a() 가 b() 를 부른 순간의 Stack

   ┌──────────────────┐   맨 위 (가장 최근 호출)
   │  b() 의 칸       │     b 의 지역 변수가 여기
   ├──────────────────┤
   │  a() 의 칸       │     a 의 지역 변수가 여기
   ├──────────────────┤
   │  main() 의 칸    │     main 의 지역 변수가 여기
   └──────────────────┘   맨 아래 (가장 먼저 시작)

b() 가 끝나면 맨 위 칸이 사라지고, a() 로 돌아가요. a() 가 끝나면 또 그 칸이 사라지고 main() 으로 돌아가죠. 메서드가 끝날 때마다 칸이 위에서부터 하나씩 비워지는 거예요. 지역 변수가 메서드 끝과 함께 사라지는 이유가 바로 이거예요. 변수가 들어 있던 칸 자체가 통째로 치워지니까요.

콜스택은 이미 만난 적 있어요

이 Stack 그림, 사실 처음 보는 게 아니에요. Day 22 에서 예외가 던져졌을 때 콘솔에 주르륵 찍히던 그 호출 흔적(stack trace) 기억나시나요? 거기 찍힌 줄들이 바로 이 Stack 에 쌓여 있던 메서드 칸들의 목록이었어요. 예외가 터진 순간의 Stack 을 사진 찍어 보여준 거죠. 그래서 어느 메서드가 어느 메서드를 불렀는지 위에서 아래로 읽을 수 있었던 거예요.

무한으로 쌓이면? StackOverflowError

그런데 이 Stack 은 크기가 정해져 있어요. 무한정 쌓을 수 있는 게 아니에요. 만약 메서드가 자기 자신을 끝없이 부르면 어떻게 될까요?

Java
// 자기 자신을 끝없이 부르는 메서드 — 개념 설명용
void dig() {
    dig();   // 자기 자신을 또 부르고… 또 부르고… 끝이 없어요
}

dig() 는 자기를 부르고, 그게 또 자기를 부르고, 끝나는 조건이 없어요. 그러면 Stack 에 dig() 칸이 계속 쌓이기만 하고 절대 비워지지 않아요. 접시를 천장 끝까지 쌓다가 결국 무너지는 거예요. 이때 JVM 이 "더 이상 못 쌓아요!" 하고 던지는 게 StackOverflowError(스택 넘침 오류) 예요.

⚠️ 재귀 메서드(자기 자신을 부르는 메서드)를 짤 때는 반드시 멈추는 조건이 있어야 해요. "여기까지 오면 더 안 부르고 끝낸다" 는 출구가 없으면 Stack 이 넘쳐버려요. 재귀를 쓸 땐 항상 "이게 언제 멈추지?" 를 먼저 확인하는 습관을 들이면 좋아요.


Step 3: Heap — 객체가 살아가는 터전

이제 셋 중 가장 넓은 창고, Heap 으로 가봅시다. new 라는 단어를 쓸 때마다 객체가 태어나는 곳이 바로 여기예요. 지금까지 만든 Member, Post, ArrayList 전부 Heap 에 살고 있었어요.

변수는 주소만, 알맹이는 Heap 에

Day 8 에서 그렸던 그림을 다시 펼쳐볼게요. 회원 객체 하나를 만들면 메모리에서 이런 일이 벌어졌죠.

텍스트
        Stack (지역 변수)                Heap (실제 객체)
   ┌──────────────────────┐        ┌────────────────────────┐
   │  member  ●───────────┼───────│  Member 객체           │
   └──────────────────────┘        │   username   = null    │
                                   │   followers  = 0       │
     member 변수는                 │   posts      = 0       │
     "객체의 주소" 만 담아요       └────────────────────────┘

member 라는 변수는 Stack 에 있고, 진짜 회원 객체는 Heap 에 있어요. 변수 안에는 객체의 주소(화살표) 만 담겨 있죠. 그래서 member 같은 변수를 참조 변수(reference variable) 라고 불러요. 참조는 "가리킨다" 는 뜻이에요. 알맹이를 직접 들고 있는 게 아니라, 알맹이가 있는 곳을 손가락으로 가리키고 있는 거예요.

이 구분이 왜 중요하냐면, 변수와 객체의 수명이 다르기 때문이에요. 변수는 Stack 에 있으니 메서드가 끝나면 사라져요. 하지만 객체는 Heap 에 있어서, 누군가 여전히 그걸 가리키고 있는 한 살아남아요.

참조가 끊기면 객체는 garbage 가 돼요

여기서 오늘 가장 중요한 개념이 나와요. 객체를 가리키는 화살표가 하나도 안 남으면 그 객체는 어떻게 될까요?

텍스트
   [ 처음 ]                        [ member = null 이후 ]

   Stack          Heap            Stack          Heap
  ┌────────┐    ┌──────────┐     ┌────────┐    ┌──────────┐
  │ member │───│ Member   │     │ member │    │ Member   │   아무도
  └────────┘    │  객체    │     │ = null │    │  객체    │     안 가리킴!
                └──────────┘     └────────┘    └──────────┘
                                                  garbage

member = null; 을 하거나 메서드가 끝나서 변수가 사라지면, Heap 의 그 객체를 가리키는 화살표가 끊겨요. 이제 아무도 그 객체에 닿을 수 없어요. 코드 어디에서도 그 객체를 다시 쓸 방법이 없죠. 이렇게 아무도 가리키지 않게 된 객체를 garbage(가비지, 쓰레기) 라고 불러요. 더 이상 쓸모가 없으니 치워야 할 대상인 거예요.

자, 그런데 이 쓰레기를 누가 치울까요? 우리가 직접 "이 객체 지워!" 라고 명령하지 않았는데도, 자바에서는 이런 객체들이 알아서 사라져요. 그 청소를 담당하는 게 바로 가비지 컬렉터예요. Step 5 에서 자세히 만나볼 거예요.

💡 Heap 의 객체는 가리키는 화살표가 하나라도 있으면 살아남고, 다 끊기면 쓰레기가 돼요. 우리가 Day 42 에서 만든 데코레이터 사슬도, Feed 가 붙들고 있던 게시물들도 전부 이 규칙을 따라요. 누군가 붙들고 있는 한 안 죽고, 다 놓으면 치워질 대상이 되는 거죠.


Step 4: Metaspace — 클래스의 설명서

세 번째 구역, Metaspace 차례예요. 여기는 조금 특별해요. 객체가 아니라 클래스 그 자체의 정보가 사는 곳이거든요. 이 구분을 또렷하게 잡는 게 오늘의 작은 고비예요.

붕어빵 틀과 붕어빵

Day 8 에서 클래스를 처음 배울 때 "클래스는 붕어빵 틀, 객체는 붕어빵" 이라고 비유했던 거 기억나시죠? 이 비유를 메모리로 가져와 봅시다.

붕어빵 가게를 떠올려보세요. 붕어빵 은 가게 안에 딱 하나만 있어요. 그 틀로 붕어빵 100개를 찍어내도, 틀은 여전히 하나죠. 찍어낸 붕어빵 100개는 진열대에 쌓이고요.

텍스트
   Metaspace (틀 보관소) — 클래스 설명서 딱 한 부
   ┌───────────────┐
   │  Member.class │   필드 username 등, 메서드 getName 등
   └───────────────┘

   Heap (찍어낸 객체들) — new Member() 할 때마다 하나씩, 값은 제각각
   ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
   │ Member#1 │ │ Member#2 │ │ Member#3 │ │ Member#4 │
   └──────────┘ └──────────┘ └──────────┘ └──────────┘
  • Metaspace 에는 Member 클래스의 설명서가 딱 한 부만 들어가요. "이 클래스에는 username, followers 같은 필드가 있고, getName() 같은 메서드가 있다" 는 구조 정보요. 붕어빵 틀에 해당해요.
  • Heap 에는 그 설명서로 찍어낸 객체들이 잔뜩 살아요. new Member() 를 100번 하면 Heap 에 객체 100개가 생기지만, Metaspace 의 설명서는 여전히 한 부뿐이에요. 진열대의 붕어빵 100개에 해당하죠.

객체마다 필드 은 제각각이에요. 1번 회원은 username 이 "jaehoon", 2번은 "minji" 식으로요. 그 값들은 각 객체 안에, 즉 Heap 에 따로따로 들어 있어요. 하지만 "username 이라는 칸이 있다" 는 구조 자체는 모든 회원이 공유하니까, 그건 Metaspace 의 설명서에 한 번만 적어두면 되는 거예요.

설명서는 언제 Metaspace 에 올라올까?

클래스 설명서가 Metaspace 에 들어가는 순간이 있어요. 바로 그 클래스가 처음으로 필요해지는 시점이에요. 프로그램이 Member 라는 클래스를 처음 마주치면, JVM 이 Member.class 라는 설명서 파일을 찾아서 Metaspace 에 올려둬요. 이걸 클래스 로딩(class loading, 클래스 불러오기) 이라고 해요.

한 번 올려둔 설명서는 프로그램이 도는 내내 거의 그대로 유지돼요. 객체는 만들어졌다 사라졌다 바쁘게 오가지만, 클래스 설명서는 한 번 올라오면 자리를 지키는 편이에요. 수명이 길죠. 그래서 객체가 사는 Heap 과는 별도 구역에 두는 거예요.

💡 한 줄로 정리하면 "클래스 정보는 Metaspace 에 한 부, 그걸로 찍어낸 객체는 Heap 에 여러 개" 예요. 객체(Heap)와 클래스 정보(Metaspace)를 헷갈리지 않는 게 포인트예요.

이 "클래스 로딩" 이라는 단어, 오늘은 가볍게 스쳐 지나가지만 사실 깊은 이야기가 숨어 있어요. JVM 이 클래스 설명서를 어떻게 찾아오고, 심지어 프로그램이 도는 도중에 그 설명서를 읽어 객체를 자유자재로 다루는 기술까지 있거든요.

그건 다음 다음 시간 (Day 45) 에서 클래스 로딩과 리플렉션(reflection) 이라는 이름으로 제대로 파볼 거예요. 오늘은 "클래스 정보가 Metaspace 에 올라온다" 정도만 챙겨두면 충분해요.


Step 5: 가비지 컬렉션의 원리

이제 오늘의 주인공, 가비지 컬렉션을 만나볼 시간이에요. Step 3 에서 "아무도 안 가리키는 객체는 쓰레기가 된다" 까지 봤죠. 그 쓰레기를 자동으로 치워주는 게 바로 가비지 컬렉션이에요.

옛날엔 직접 치웠어요

가비지 컬렉션이 왜 고마운지 알려면, 그게 없던 시절을 봐야 해요. 자바 이전의 C 나 C++ 같은 언어에서는 객체를 다 쓰고 나면 개발자가 직접 "이 객체 이제 치워!" 라고 명령해야 했어요. 메모리를 빌렸으면 직접 반납하는 거죠.

문제는 사람이 이걸 자주 깜빡한다는 거예요. 반납을 깜빡하면 안 쓰는 객체가 메모리에 계속 쌓여서 결국 메모리가 꽉 차버려요. 반대로 아직 쓰고 있는 객체를 실수로 반납해버리면 프로그램이 엉뚱하게 망가지고요. 이 두 가지가 C/C++ 개발자를 가장 괴롭히는 단골 버그였어요.

자바는 이 골치 아픈 청소를 JVM 이 알아서 해주기로 했어요. 개발자는 객체를 만들기만 하면 되고, 안 쓰는 객체를 치우는 건 가비지 컬렉터가 뒤에서 돌아다니며 처리해요. 우리가 Day 8 부터 지금까지 new 만 했지 단 한 번도 "이거 치워!" 라고 명령한 적 없었죠? 그게 다 가비지 컬렉터 덕분이었어요.

어떻게 쓰레기를 골라낼까 — 도달 가능성

가비지 컬렉터가 "이건 쓰레기, 저건 아직 쓸 것" 을 가르는 기준은 딱 하나예요. 거기까지 닿을 수 있는가, 즉 도달 가능성(reachability) 이에요.

프로그램에는 "여기서부터 출발한다" 는 뿌리 같은 시작점들이 있어요. 지금 실행 중인 메서드의 지역 변수 같은 것들이요. 거기서 화살표를 따라가며 닿을 수 있는 객체는 전부 "살아 있는 객체" 예요. 반대로 어떤 화살표로도 닿을 수 없는 객체는 "쓰레기" 고요.

텍스트
   뿌리(실행 중인 변수)
        │
        
   ┌─────────┐      ┌─────────┐
   │ 객체 A  │─────│ 객체 B  │    뿌리에서 닿음 = 살아 있음
   └─────────┘      └─────────┘

   ┌─────────┐      ┌─────────┐
   │ 객체 C  │─────│ 객체 D  │    서로 가리켜도
   └─────────┘      └─────────┘     뿌리에서 못 닿음 = 쓰레기

여기서 재밌는 점이 있어요. 객체 C 와 D 는 서로를 가리키고 있어요. 그런데도 둘 다 쓰레기예요. 왜냐하면 뿌리에서 출발해서 둘 중 어느 쪽에도 닿을 수 없거든요. 자기들끼리 손잡고 있어 봤자, 바깥에서 아무도 그 무리를 가리키지 않으면 통째로 치워질 대상이에요. "서로 참조하니까 살아남겠지" 가 아니라, 뿌리에서 닿느냐가 유일한 기준이라는 게 핵심이에요.

치우는 세 단계 — Mark, Sweep, Compact

쓰레기를 골라냈으면 이제 실제로 치워야겠죠. 가비지 컬렉터는 보통 세 단계로 일해요.

텍스트
   1. Mark (표시)        2. Sweep (수거)        3. Compact (정리)
   살아 있는 객체에       표시 안 된 객체를        살아남은 객체를
   "살아 있음" 도장       메모리에서 비움          한쪽으로 모음

   [A*][ ][B*][ ][C]    [A*][B*][   ][   ]    [A][B][      ]
    ★    ★             빈칸이 군데군데          빈 공간이 한 덩어리로
  • Mark(마크, 표시): 뿌리에서 출발해 닿을 수 있는 객체를 전부 따라가며 "이건 살아 있음" 도장을 찍어요.
  • Sweep(스윕, 수거): 도장이 안 찍힌 객체들을 메모리에서 비워요. 이제 그 빈 공간은 새 객체가 쓸 수 있어요.
  • Compact(컴팩트, 정리): 비우고 나면 메모리가 군데군데 구멍 난 상태가 돼요. 살아남은 객체들을 한쪽으로 쭉 밀어 모으면, 빈 공간이 한 덩어리로 깔끔해져서 다음에 큰 객체도 쉽게 넣을 수 있어요.

청소하는 동안 잠깐 멈춰요 — Stop-the-World

이 청소에는 한 가지 비용이 따라요. 가비지 컬렉터가 메모리를 정리하는 동안, 객체들이 이리저리 옮겨지면 안 되니까 프로그램이 잠깐 멈춰요. 이 멈춤을 Stop-the-World(스톱 더 월드, 세상을 멈춤) 라고 불러요. 청소부가 바닥을 닦는 동안 잠깐 "다들 가만히 계세요" 하는 거랑 비슷해요.

이 멈춤이 너무 길면 사용자가 화면이 버벅이는 걸 느껴요. 그래서 가비지 컬렉터를 만드는 사람들의 가장 큰 숙제가 바로 "이 멈춤을 얼마나 짧게 만드느냐" 예요. 다음 Step 에서 볼 현대적인 가비지 컬렉터들이 다들 이 멈춤 시간을 줄이려고 애쓰는 물건들이에요.


Step 6: G1과 ZGC — 현대적 가비지 컬렉터

가비지 컬렉터는 한 종류만 있는 게 아니에요. 자바가 발전하면서 더 똑똑하고 더 빠른 청소 방식이 여럿 나왔어요. 그중 요즘 가장 많이 쓰는 두 가지, G1 과 ZGC 를 가볍게 만나봅시다. 깊이 외울 건 없어요. "이런 게 있구나" 정도면 충분해요.

한 가지 영리한 관찰 — 세대 가설

현대 가비지 컬렉터들이 공통으로 깔고 가는 영리한 관찰이 하나 있어요. 객체들을 가만히 지켜봤더니, 대부분의 객체는 금방 죽더라는 거예요. 메서드 안에서 잠깐 쓰고 버려지는 객체가 정말 많거든요. 반면 한 번 오래 살아남은 객체는 그 뒤로도 계속 오래 사는 경향이 있고요.

이 관찰을 세대 가설(generational hypothesis) 이라고 불러요. 그래서 Heap 을 나이대로 나눠요. 갓 태어난 객체들이 모인 Young(영, 젊은 세대) 구역과, 오래 살아남은 객체들이 옮겨 가는 Old(올드, 늙은 세대) 구역으로요.

텍스트
   Heap 을 나이로 나눠요

   갓 태어난 객체                오래 산 객체
   ┌─────────┐              ┌─────────┐
   │  Young  │ ──── 승진 ──│   Old   │
   └─────────┘              └─────────┘
   자주, 빠르게 청소            가끔, 크게 청소

Young 구역은 어차피 대부분 금방 죽으니까 자주, 빠르게 청소해요. 청소할 때마다 거의 다 쓰레기라 금방 끝나거든요. 거기서 살아남은 끈질긴 객체만 Old 구역으로 승진시켜요. Old 구역은 자주 안 죽으니까 가끔, 크게 청소하고요. 이렇게 나이대로 나눠 다르게 청소하면 훨씬 효율적이에요.

G1 — 메모리를 작은 구역으로 쪼개요

G1(Garbage First, 가비지 우선) 은 Heap 을 바둑판처럼 작은 구역(region) 여러 개로 잘게 쪼개서 관리해요. 그리고 청소할 때 쓰레기가 가장 많은 구역부터 골라서 치워요. 이름이 "가비지 우선" 인 이유가 이거예요. 쓰레기가 잔뜩 모인 곳부터 치우면, 같은 시간에 가장 많은 공간을 비울 수 있으니 효율이 좋죠.

이 G1 이 지금(2026년 기준) JDK 25 에서 모든 환경의 기본 가비지 컬렉터예요. 우리가 따로 설정을 안 하면 알아서 G1 이 돌아가요. 작은 프로그램이든 큰 프로그램이든 기본값이 G1 으로 통일돼 있어서, 대부분의 경우 우리가 신경 쓸 일이 없어요.

ZGC — 멈춤을 거의 없앤 청소

ZGC(지 가비지 컬렉터) 는 Step 5 에서 말한 그 Stop-the-World 멈춤을 극단적으로 짧게 만드는 데 집중한 물건이에요. 얼마나 짧으냐면, 멈춤 시간이 1밀리초도 안 되는 수준이에요. 1밀리초는 1000분의 1초예요. 사람은 절대 못 느끼죠.

ZGC 는 객체가 아주 많고 멈춤이 조금이라도 길면 안 되는 서비스, 예를 들어 수많은 사용자 요청을 실시간으로 처리하는 큰 서버 같은 곳에서 빛을 발해요. 멈춤이 거의 없으니 화면이 끊기는 느낌 없이 매끄럽게 돌아가죠.

그래서 우리는 뭘 골라야 할까?

여기까지 들으면 "그럼 나는 어떤 걸 써야 하지?" 싶을 텐데요. 결론은 의외로 편안해요.

🌟 보통은 신경 안 써도 돼요. 기본값인 G1 이 알아서 대부분의 경우를 잘 처리해 줘요. 멈춤 시간을 한계까지 줄여야 하는 특별한 서비스를 맡게 됐을 때, 그때 가서 ZGC 같은 선택지를 저울에 올리면 돼요. "어떤 가비지 컬렉터가 있다" 는 걸 알아두는 것만으로 지금은 충분하고, 실제 고르는 건 한참 나중에 필요가 생겼을 때 일이에요.

가비지 컬렉터의 세계는 이렇게 계속 발전하고 있어요. 우리가 new 만 편하게 쓰는 동안, 뒤에서는 이렇게 정교한 청소 기술이 묵묵히 돌아가고 있었던 거예요.


Step 7: 메모리 누수 — 객체가 안 죽을 때

마지막 Step 이에요. 지금까지 "안 쓰는 객체는 가비지 컬렉터가 알아서 치운다" 고 배웠죠. 그런데 청소부가 아무리 부지런해도 치울 수 없는 쓰레기가 있어요. 바로 우리가 실수로 계속 붙들고 놓지 않는 객체예요. 이걸 메모리 누수(memory leak) 라고 불러요.

지난 시간 그 약속, 기억나시죠?

Day 41 에서 Observer 패턴을 배울 때 슬쩍 던졌던 경고가 있었어요. "구독만 해두고 unsubscribe(구독 해지)를 깜빡하면, 주체가 그 관찰자를 계속 목록에 쥐고 있어서 더 이상 필요 없는 객체가 메모리에 남는다" 고요. 그때는 "그럴 수도 있구나" 하고 넘어갔는데, 오늘 그게 왜 위험한지 메모리로 또렷하게 풀어볼 거예요.

구독 목록을 들고 있는 작은 클래스를 하나 봅시다. 누가 사건을 구독하면 목록에 더하고, 구독을 해지하면 목록에서 빼는 단순한 등록부예요.

Java
package com.instagram.javabasic.jvm;

// com/instagram/javabasic/jvm/SubscriptionRegistry.java

import java.util.ArrayList;
import java.util.List;

public class SubscriptionRegistry {

    private final List<Subscriber> subscribers = new ArrayList<>();

    public void subscribe(Subscriber subscriber) {
        subscribers.add(subscriber);
    }

    public void unsubscribe(Subscriber subscriber) {
        subscribers.remove(subscriber);
    }

    public int size() {
        return subscribers.size();
    }
}

구독자는 이런 단순한 약속만 지키면 돼요.

Java
public interface Subscriber {
    String getName();
}

여기서 핵심은 subscribers 라는 리스트예요. subscribe() 로 더한 구독자는 이 리스트 안에 들어가요. 그리고 이 리스트가 그 구독자를 가리키는 화살표를 계속 쥐고 있어요.

강한 참조가 객체를 붙들면 청소 못 해요

자, Step 5 에서 배운 규칙을 떠올려보세요. "뿌리에서 화살표로 닿을 수 있으면 살아 있는 객체" 였죠. 문제는 바로 여기예요.

텍스트
   SubscriptionRegistry (오래 사는 객체)
        │
         subscribers 리스트가 계속 붙들고 있음
   ┌───────────┐ ┌───────────┐ ┌───────────┐
   │ 구독자 A  │ │ 구독자 B  │ │ 구독자 C  │   화면에서 사라졌어도
   └───────────┘ └───────────┘ └───────────┘     리스트가 가리키니 못 죽음!

어떤 구독자가 화면에서 사라지거나 더 이상 필요 없어졌어요. 그런데 unsubscribe() 를 깜빡했다면? 등록부의 리스트가 그 구독자를 여전히 가리키고 있어요. 가비지 컬렉터 입장에서는 "어, 아직 이 객체를 가리키는 화살표가 있네? 그럼 살아 있는 객체구나" 하고 치우지 않아요. 우리는 다 썼는데, 리스트가 안 놓아서 못 죽는 거예요.

이렇게 지금까지 우리가 본 평범한 참조, 그러니까 변수나 리스트가 객체를 가리키는 보통의 화살표를 강한 참조(strong reference) 라고 불러요. 강한 참조가 하나라도 살아 있으면 가비지 컬렉터는 그 객체를 절대 못 건드려요. 강한 참조는 "이거 꼭 살려둬!" 라는 강력한 붙듦이거든요.

이런 객체가 하나둘 쌓이면 어떻게 될까요? 화면에서 사라진 구독자들이 리스트 안에 계속 누적돼요. 쓰지도 않는데 메모리만 야금야금 차오르죠. 오래 켜져 있는 프로그램일수록 이게 쌓이고 쌓여서 결국 메모리가 꽉 차버려요. 이게 바로 메모리 누수예요. 물이 새듯 메모리가 조금씩 새 나가는 거죠.

안전선은 단순해요 — unsubscribe

다행히 막는 방법은 아주 단순해요. 다 썼으면 unsubscribe() 로 빼주면 돼요.

unsubscribe() 가 리스트에서 그 구독자를 제거하는 순간, 마지막 화살표가 끊겨요. 이제 그 구독자를 가리키는 게 아무것도 없으니, 가비지 컬렉터가 비로소 "아, 이건 쓰레기구나" 하고 치울 수 있게 돼요. Day 41 에서 "구독했으면 필요 없어질 때 해지하는 습관이 중요하다" 고 했던 게 바로 이 이야기였어요. 그 습관이 메모리 누수를 막는 안전선이에요.

이 동작은 코드베이스에서 검증돼 있어요. 구독을 더하면 리스트 크기가 늘고, unsubscribe() 로 빼면 다시 줄어드는 걸 확인했어요. 빼고 나면 그 객체를 붙드는 화살표가 사라지니, 청소될 수 있는 상태가 되는 거예요.

일부러 약하게 붙드는 방법 — WeakReference

여기서 한 걸음 더 나가볼게요. "다 쓰면 빼야 한다" 는 걸 알아도 사람은 또 깜빡하잖아요. 그래서 자바에는 일부러 약하게 가리키는 특별한 화살표가 있어요. 바로 WeakReference(약한 참조) 예요.

강한 참조가 "이거 꼭 살려둬!" 라면, 약한 참조는 "가리키고는 있지만, 가비지 컬렉터가 치우고 싶으면 치워도 돼" 라는 느슨한 붙듦이에요. 약한 참조만 남은 객체는, 가비지 컬렉터가 청소할 때 미련 없이 치워져요.

이미지 같은 무거운 데이터를 잠깐 들고 있다가 메모리가 빠듯해지면 양보해도 되는 경우에 잘 어울려요. 이런 캐시 용도의 클래스를 약한 참조로 한번 만들어봤어요.

Java
package com.instagram.javabasic.jvm;

// com/instagram/javabasic/jvm/WeakImageCache.java

import java.lang.ref.WeakReference;

public class WeakImageCache {

    private final WeakReference<byte[]> imageRef;

    public WeakImageCache(byte[] image) {
        this.imageRef = new WeakReference<>(image);
    }

    public byte[] get() {
        return imageRef.get();
    }
}

WeakReference<byte[]> 로 이미지를 감쌌어요. get() 으로 꺼낼 때 이미지가 아직 살아 있으면 그대로 돌려주고, 그사이 가비지 컬렉터가 치워버렸다면 null 이 나와요. 그래서 약한 참조로 무언가를 들고 있을 땐, 꺼낼 때 항상 null 인지 확인해야 해요. "있으면 쓰고, 없으면 다시 만든다" 는 식으로요.

물론 평소 우리가 쓰는 건 거의 다 강한 참조예요. 약한 참조는 "이건 사라져도 괜찮으니 메모리가 급하면 양보할게" 라는 특별한 경우에만 꺼내는 도구예요. 강한 참조와 약한 참조라는 두 가지 붙듦의 세기가 있다는 것, 그리고 그 차이가 가비지 컬렉터의 청소 여부를 가른다는 것만 기억해두면 충분해요.


마무리

오늘은 코드에서 한 걸음 물러나, 그 코드가 돌아가는 무대인 JVM 의 메모리 속을 구경했어요.

  • 세 구역 — 객체는 Heap 에, 메서드의 지역 변수는 Stack 에, 클래스 설명서는 Metaspace 에 살아요. 수명이 다른 것들을 구역으로 나눠 관리하는 거였죠.
  • 가비지 컬렉션 — 아무도 안 가리키는 객체(뿌리에서 못 닿는 객체)를 자동으로 치워주는 청소예요. C/C++ 처럼 직접 반납하는 수고를 JVM 이 대신 해줘요. 요즘은 G1 이 기본으로 알아서 잘 돌아가고, 멈춤을 극단적으로 줄인 ZGC 같은 선택지도 있어요.
  • 메모리 누수 — 다 썼는데도 강한 참조로 객체를 계속 붙들면, 가비지 컬렉터도 못 치워요. Day 41 의 그 경고처럼, 구독했으면 unsubscribe 로 빼주는 습관이 안전선이에요.

오늘 배운 걸로 이제 "객체가 메모리 어디서 태어나고 어떻게 죽는지" 를 그림으로 그릴 수 있게 됐어요. 그런데 지식만으로는 아직 부족해요. 실제로 메모리가 새고 있는지를 어떻게 눈으로 확인할까요? 누수가 의심될 때 어느 객체가 안 죽고 쌓이는지 직접 찾아내고, JVM 에게 "메모리를 이만큼 써라", "이 가비지 컬렉터를 써라" 라고 지시하는 방법은 또 따로 있거든요.

다음 시간엔 오늘 배운 이론을 손에 쥐고, 메모리 누수를 도구로 직접 추적하고 JVM 을 튜닝하는 실전으로 들어가요. GC 가 실제로 어떻게 도는지 기록을 들여다보고, 누수를 일으키는 객체를 화면으로 잡아내 볼 거예요. 오늘 그린 그림이 그때 살아 움직이는 모습을 보게 될 거예요. 수고 많으셨어요!


과제

오늘은 새 문법보다 "객체가 메모리에서 어떻게 태어나고 사라지는지" 를 또렷이 그리는 게 목표였어요. 과제도 코드를 많이 짜기보다, 메모리를 직접 그려보고 누수를 다뤄보는 쪽으로 준비했어요.

과제 1 (기본): 메모리 지도 그리기 — 누가 살아남고 누가 쓰레기가 될까

상황 배경: Step 7 에서 본 SubscriptionRegistryNamedSubscriber 를 가지고, 변수와 객체가 Stack·Heap 어디에 사는지 직접 그려볼 거예요. 코드를 새로 짜진 않아요. 아래 흐름을 따라가며 메모리 상태만 그림으로 표현하면 돼요.

Java
SubscriptionRegistry registry = new SubscriptionRegistry();
NamedSubscriber kim = new NamedSubscriber("kim");
registry.subscribe(kim);
kim = null;            // ← (가) 이 시점

🎯 해결 미션:

  1. registry.subscribe(kim) 까지 실행된 직후의 메모리를 Stack·Heap 으로 나눠 그려보세요. 어떤 변수가 Stack 에 있고 어떤 객체가 Heap 에 있는지, 화살표(참조)가 어떻게 이어지는지 표시하세요.
  2. (가) 지점에서 kim = null; 을 한 뒤, "kim" 이름을 가진 구독자 객체는 가비지 컬렉터가 치울 수 있는 상태가 될까요, 아닐까요? 화살표를 다시 그려 근거를 보이세요.
  3. 만약 이 객체가 안 치워진다면, 어떤 한 줄을 추가해야 비로소 치워질 수 있는지 적어보세요.

과제 2 (응용): 화면이 닫힐 때 구독을 한꺼번에 정리하기

상황 배경: 인스타 알림 화면은 여러 이벤트를 구독해요. 그런데 화면이 닫힐 때 구독을 하나하나 해지하는 걸 깜빡하면, 구독자 객체들이 메모리에 그대로 남아 누수가 돼요. 화면이 닫히는 순간 한 번에 정리하는 안전선을 가진 관리자를 만들어볼 거예요. Step 7 의 Subscriber 인터페이스를 그대로 재사용하세요.

🎯 해결 미션:

  1. Subscriber 를 구독자로 받는 EventSubscriptionManager 를 만드세요. subscribe(구독 추가)·unsubscribe(하나 해지)·activeCount(현재 구독 수) 를 갖춰요.
  2. 화면이 닫힐 때를 위한 unsubscribeAll() 을 추가하세요. 호출하면 모든 구독이 한 번에 비워지고 activeCount 가 0 이 돼야 해요.
  3. unsubscribeAll() 이 왜 메모리 누수를 막는 안전선이 되는지, "강한 참조" 라는 단어를 넣어 한두 줄로 설명해보세요.

과제 3 (심화): 해지로 고칠까, 약한 참조로 고칠까

상황 배경: 메모리 누수를 막는 길은 하나가 아니에요. Step 7 에서 본 두 가지 — 다 쓰면 unsubscribe 로 빼는 방법(강한 참조 + 명시적 해지)과, 처음부터 WeakReference 로 약하게 붙드는 방법 — 은 어울리는 곳이 서로 달라요. 코드를 짜지 않고, 아래 두 상황에 어느 쪽이 맞는지 판단하는 과제예요.

🎯 해결 미션:

  1. 상황 A — 사용자가 "이 게시물 알림 받기" 를 누르면, 직접 끌 때까지 알림이 계속 와야 해요. 시스템이 마음대로 구독을 없애면 안 돼요. 이 구독은 강한 참조 + 명시적 해지가 맞을까요, WeakReference 가 맞을까요? 근거를 적으세요.
  2. 상황 B — 피드를 스크롤할 때 썸네일 이미지를 잠깐 캐시에 담아둬요. 메모리가 빠듯해지면 이 이미지는 버리고 나중에 다시 받아도 괜찮아요. 이 캐시는 어느 쪽이 맞을까요? 근거를 적으세요.
  3. 두 상황을 가른 핵심 질문이 무엇이었는지 한 문장으로 정리해보세요. (힌트: "이 객체를 시스템이 마음대로 버려도 괜찮은가?")

생각해볼 주제

혼자 고민해도 좋고, 동료와 토론해도 좋아요. 정답을 외우기보다 "나라면 어떻게 설명할까" 를 떠올리며 읽어보세요.

1. 가비지 컬렉터가 있는데도 왜 메모리 누수가 생길까?

자바는 안 쓰는 객체를 가비지 컬렉터가 알아서 치워준다고 했어요. 그런데도 오늘 우리는 메모리 누수를 분명히 봤죠. 청소부가 있는데 왜 쓰레기가 쌓이는 걸까요?

"가비지 컬렉터는 무엇을 기준으로 치울지 정하는가" 를 떠올려보세요. 가비지 컬렉터는 사람 마음을 못 읽어요. 오직 "뿌리에서 화살표로 닿느냐(도달 가능성)" 만 봐요. 우리가 머릿속으로는 "이제 이 객체 안 써" 라고 생각해도, 코드 어딘가에서 강한 참조가 그 객체를 아직 가리키고 있으면 가비지 컬렉터는 "아직 쓰는 중이구나" 하고 살려둬요.

그래서 누수는 가비지 컬렉터의 잘못이 아니라, 우리가 안 놓은 참조 때문에 생겨요. "자동으로 치워준다" 는 말이 "아무 신경 안 써도 된다" 는 뜻은 아닌 거죠. 자동 청소가 있는 언어에서도 개발자가 끝까지 책임져야 할 부분이 무엇인지, 오늘 본 구독 목록 예시와 연결해 정리해보세요.

2. C/C++ 의 수동 메모리 관리 vs 자바의 자동 GC — 무엇을 얻고 무엇을 잃었나?

Step 5 에서 잠깐 봤듯, C 나 C++ 은 개발자가 직접 메모리를 반납해요. 반면 자바는 가비지 컬렉터가 알아서 치우죠. 얼핏 보면 자동이 무조건 좋아 보이지만, 모든 선택에는 주고받는 게 있어요.

"무엇을 얻었나" 부터 떠올려보세요. 자동 GC 덕분에 우리는 반납을 깜빡해서 메모리가 새거나, 아직 쓰는 객체를 실수로 지워 프로그램이 깨지는 위험에서 거의 벗어났어요. 그만큼 개발이 빠르고 안전해졌죠.

그럼 "무엇을 잃었나" 도 생각해보세요. 청소를 JVM 에 맡긴 대신, 우리는 "언제 청소가 일어날지" 를 정확히 통제하지는 못해요. 청소하는 동안 잠깐 멈추는 Stop-the-World 같은 비용도 생겼고요. 제어권과 예측 가능성을 내주고 안전성과 생산성을 얻은 맞바꿈인 셈이에요. 이 거래를 어떻게 바라보면 좋을지, 두 방식의 장단점을 나란히 정리해보세요.

3. 기본값 G1 으로 충분한데, 왜 ZGC 같은 가비지 컬렉터가 따로 있을까?

Step 6 에서 "보통은 기본값 G1 이면 충분하다" 고 했어요. 그런데도 ZGC 같은 다른 가비지 컬렉터가 따로 만들어져 있죠. 하나로 다 되면 편할 텐데, 왜 여러 개가 필요할까요?

"프로그램마다 중요하게 여기는 게 다르다" 를 떠올려보세요. 어떤 프로그램은 밤새 대량의 데이터를 처리하는 게 일이라, 중간에 잠깐씩 멈춰도 전체 처리량만 많으면 돼요. 반대로 수많은 사용자 요청에 실시간으로 응답하는 서버는, 단 몇십 밀리초만 멈춰도 사용자가 버벅임을 느끼죠. 앞쪽은 처리량(throughput)이, 뒤쪽은 지연시간(latency)이 더 중요해요.

가비지 컬렉터도 이 둘을 동시에 완벽하게 만족시키긴 어려워요. 멈춤을 극단적으로 줄이려면 그만큼 다른 비용(메모리나 CPU 를 더 씀)을 치러야 하거든요. 그래서 "무엇을 더 중요하게 볼 것이냐" 에 따라 골라 쓸 수 있도록 여러 선택지를 준비해둔 거예요. 정답이 하나가 아니라 상황에 따라 다르다는 것, 이게 왜 합리적인 설계인지 정리해보세요.

✅ 예시 답안정답 보기

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

과제 예시답안

과제 1 예시답안 — 메모리 지도 그리기: 누가 살아남고 누가 쓰레기가 될까

핵심 접근

이 과제의 정답은 "kim 변수를 null 로 만들면 그 구독자 객체도 사라진다" 가 아니에요. 그게 함정이에요. 객체가 살아남느냐는 변수 하나가 아니라, 그 객체를 가리키는 화살표가 하나라도 남았는지 로 갈려요.

registry.subscribe(kim) 을 부른 순간 등록부 안의 subscribers 리스트가 그 구독자를 가리키는 화살표를 하나 더 만들어요. 그래서 kim = null 로 변수 쪽 화살표를 끊어도, 리스트 쪽 화살표가 남아 객체는 회수되지 않아요. 화살표를 세 보는 게 이 과제의 전부예요.

메모리 다이어그램

코드를 새로 짜는 과제가 아니라 메모리를 그리는 과제라, 두 시점을 나눠 그릴게요. 화살표를 따라가며 "지금 이 구독자 객체를 가리키는 게 몇 개인가" 만 세면 돼요.

먼저 registry.subscribe(kim) 까지 실행된 직후예요. 변수 둘이 Stack 에, 진짜 객체 둘이 Heap 에 있어요.

텍스트
 [ subscribe(kim) 직후 ]

   Stack                     Heap
   ┌──────────┐              ┌──────────────────────┐
   │ registry │ ──────────  │ SubscriptionRegistry │
   └──────────┘              └──────────────────────┘
                                  │ subscribers 리스트가
   ┌──────────┐                    가리킴 (화살표 )
   │ kim      │ ───────────  ┌──────────────────┐
   └──────────┘    화살표    │ NamedSubscriber  │
                              │  name = "kim"    │
                              └──────────────────┘
    kim 변수()와 subscribers 리스트(), 둘이 "kim" 객체를 가리킴 = 살아있음

"kim" 구독자 객체를 가리키는 화살표가 두 개예요. 하나는 kim 변수에서, 하나는 등록부의 subscribers 리스트에서요. 같은 객체를 둘이 동시에 가리키는 거예요.

이제 (가) 지점, kim = null 직후예요.

텍스트
 [ kim = null 직후 ]

   Stack                     Heap
   ┌──────────┐              ┌──────────────────────┐
   │ registry │ ──────────  │ SubscriptionRegistry │
   └──────────┘              └──────────────────────┘
                                  │ subscribers 리스트는
   ┌──────────┐                    여전히 가리킴!
   │ kim=null │    ✕ 끊김      ┌──────────────────┐
   └──────────┘                │ NamedSubscriber  │
                               │  name = "kim"    │
                               └──────────────────┘
    변수 화살표는 끊겼지만 리스트가 아직 가리킴(뿌리에서 닿음) = 회수 안 됨

kim 변수 쪽 화살표는 끊겼어요. 하지만 subscribers 리스트 쪽 화살표는 그대로 남아 있어요. 그래서 이 구독자 객체는 회수되지 않아요.

미션별 답

  1. subscribe 직후: registry·kim 두 변수가 Stack 에, SubscriptionRegistry 객체와 NamedSubscriber("kim") 객체가 Heap 에 있어요. "kim" 구독자 객체를 가리키는 화살표는 kim 변수와 subscribers 리스트, 합쳐서 두 개예요.
  2. kim = null 이후, 회수될까?아니요, 회수 안 돼요. kim 변수 화살표는 끊겼지만, 등록부의 registry 가 살아 있고 그 안의 subscribers 리스트가 여전히 그 구독자를 가리켜요. 뿌리(registry)에서 리스트를 거쳐 구독자 객체까지 닿을 수 있으니, 가비지 컬렉터는 "아직 살아 있는 객체" 로 판단해요. 이게 바로 누수의 씨앗이에요.
  3. 치우려면 추가할 한 줄: registry.unsubscribe(kim); 이에요. 단, kim = null 보다 먼저 불러야 해요. 변수가 null 이 된 뒤엔 등록부에서 뺄 대상을 가리키지 못하니까요. 이 한 줄이 리스트 안의 마지막 화살표를 끊어, 비로소 회수될 수 있는 상태로 만들어요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
변수와 객체 분리 변수(registry·kim)는 Stack, 객체는 Heap 으로 나눠 그렸는가
화살표 두 개 인식 subscribe 직후 구독자 객체를 가리키는 화살표가 두 개임을 짚었는가
회수 안 됨 판정 kim = null 후에도 리스트 참조가 남아 회수 안 된다고 결론냈는가
도달 가능성 근거 "뿌리에서 닿을 수 있어 살아있다" 로 근거를 댔는가 (감으로 찍지 않았는가)
해결 한 줄 registry.unsubscribe(kim) 을 제시했는가

흔한 실수

  • "kim = null 했으니 그 객체도 사라진다" 고 답하는 경우 → 가장 흔한 오답이에요. 변수를 null 로 만든 건 그 변수의 화살표 하나만 끊은 거지, 객체를 지운 게 아니에요. 리스트가 붙들고 있으면 객체는 멀쩡히 살아 있어요.
  • 리스트의 존재를 그림에서 빠뜨리는 경우 → subscribers 리스트를 안 그리면 화살표가 하나처럼 보여 잘못된 결론으로 가요. subscribe 가 리스트에 더한다는 걸 그림에 꼭 넣어야 해요.
  • 해결책으로 registry = null 을 적는 경우 → 등록부 전체를 버리면 물론 회수돼요. 하지만 등록부는 다른 구독자도 관리하는 오래 사는 객체라, 보통 통째로 버리지 않아요. "한 구독자만 빼는" unsubscribe(kim) 이 의도에 맞아요.

실무 개선 포인트 (심화)

실무에서는 이 누수를 사람의 기억에 맡기지 않으려고 화면(컴포넌트)의 "닫힘" 같은 수명 끝나는 시점에 등록한 구독을 자동으로 거두는 구조를 둬요. 구독할 때 받은 "해지 핸들" 을 모아뒀다가 화면이 닫힐 때 한꺼번에 풀어주는 식이죠.

과제 2 의 unsubscribeAll() 이 그 작은 출발점이에요. 더 나아가면 약한 참조를 쓰는 자료구조(예: 키를 약하게 붙드는 맵) 로 "쓰는 쪽이 놓으면 자동으로 빠지게" 만들기도 하는데, 이건 동작이 미묘해서 누수가 분명히 잡힐 때만 신중히 꺼내는 편이에요.


과제 2 예시답안 — 화면이 닫힐 때 구독을 한꺼번에 정리하기

핵심 접근

화면이 닫히는 순간 누수가 생기는 이유는 등록부가 구독자들을 강한 참조 로 붙들고 있어서예요. 그러니 막는 방법도 단순해요. 화면이 닫힐 때 그 강한 참조를 한 번에 끊어주면 돼요. 리스트의 clear() 한 줄이 모든 구독자를 붙들던 화살표를 동시에 끊어, 바깥에서 안 쓰는 구독자는 회수될 수 있는 상태가 돼요.

activeCount() 로 그 결과를 숫자(0)로 확인하는 게 이 과제의 마침표예요. Step 7 의 Subscriber 인터페이스를 그대로 재사용하니 새로 만들 건 매니저 하나뿐이에요.

예시 구현

Java
package com.instagram.javabasic.jvm.solution.day43;

import com.instagram.javabasic.jvm.Subscriber;
import java.util.ArrayList;
import java.util.List;

// com/instagram/javabasic/jvm/solution/day43/EventSubscriptionManager.java

public class EventSubscriptionManager {

    private final List<Subscriber> subscribers = new ArrayList<>();

    public void subscribe(Subscriber subscriber) {
        subscribers.add(subscriber);
    }

    public void unsubscribe(Subscriber subscriber) {
        subscribers.remove(subscriber);
    }

    public void unsubscribeAll() {
        subscribers.clear();
    }

    public int activeCount() {
        return subscribers.size();
    }
}

핵심은 subscribers 리스트예요. 구독자를 더하면 리스트가 그 객체를 강하게 붙들고, 그래서 등록된 동안엔 회수되지 않아요. unsubscribeAll()clear() 가 리스트를 통째로 비우면, 구독자들을 붙들던 마지막 강한 참조가 한 번에 끊겨요.

이 동작은 코드베이스에서 검증돼 있어요. 구독을 더하면 activeCount() 가 늘고, unsubscribeAll() 을 부르면 다시 0 으로 돌아가는 걸 확인했어요.

쓰는 쪽은 이렇게 화면이 닫히는 시점에 한 줄만 부르면 돼요.

Java
EventSubscriptionManager manager = new EventSubscriptionManager();
manager.subscribe(likeSubscriber);
manager.subscribe(commentSubscriber);
// manager.activeCount() == 2

// 화면이 닫힐 때
manager.unsubscribeAll();
// manager.activeCount() == 0  → 구독자들을 붙들던 화살표가 모두 끊김

미션별 답

  1. 매니저 만들기: subscribe·unsubscribe·activeCount 세 메서드를 갖춘 EventSubscriptionManager 를 위처럼 작성해요. Subscriber 인터페이스는 Step 7 의 것을 그대로 import 해서 재사용해요.
  2. unsubscribeAll 추가: subscribers.clear() 한 줄로 전체를 비워요. 호출 후 activeCount() 가 0 이 되는 걸로 정리가 끝났음을 확인할 수 있어요.
  3. 왜 안전선인가: 등록부의 리스트는 구독자들을 강한 참조 로 붙들고 있어서, 화면이 닫혀 더는 안 쓰는 구독자라도 리스트에 남아 있으면 회수되지 않아요. unsubscribeAll() 이 그 강한 참조를 한 번에 끊어주니, 바깥에서 놓은 구독자는 비로소 회수될 수 있게 돼요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
unsubscribeAll 동작 clear() 로 전체를 비워 호출 후 activeCount() 가 0 이 되는가
Subscriber 재사용 새 인터페이스를 만들지 않고 Step 7 의 Subscriber 를 그대로 썼는가
강한 참조 설명 3번 설명에 "강한 참조" 가 들어가고, 그게 회수를 막는 원인임을 짚었는가
네 메서드 완비 subscribe·unsubscribe·activeCount·unsubscribeAll 이 모두 있고 역할이 맞는가

흔한 실수

  • unsubscribeAll() 에서 리스트를 새 리스트로 갈아끼우는(subscribers = new ArrayList<>()) 경우 → subscribersfinal 이라 컴파일이 안 돼요. clear() 가 정석이에요. 같은 리스트 객체를 그대로 두고 안만 비우는 거죠.
  • activeCount() 가 0 이 됐다고 "구독자 객체가 즉시 지워졌다" 고 설명하는 경우 → clear() 는 회수될 수 있는 상태로 만들 뿐이에요. 실제로 언제 치울지는 가비지 컬렉터가 정해요. "참조를 끊어 회수 가능해진다" 가 정확한 표현이에요.
  • 3번 설명에서 "강한 참조" 를 빼고 "그냥 리스트를 비우니까 정리된다" 로만 쓰는 경우 → 틀린 건 아니지만, 왜 비우기 전엔 회수가 안 됐는지(강한 참조가 붙들어서) 를 짚어야 핵심을 보여준 거예요.

실무 개선 포인트 (심화)

지금 매니저는 unsubscribeAll() 을 사람이 잊지 않고 불러야 동작해요. 실무에서는 이 "정리" 를 화면의 수명에 묶어 자동으로 부르게 만드는 경우가 많아요. 화면을 여는 코드와 닫는 코드가 짝을 이루게 해서, 닫을 때 등록한 구독이 자동으로 비워지게요.

Day 23 에서 본 try-with-resources 가 같은 정신이에요. 자원을 열면 닫는 걸 언어가 보장해주듯, 구독도 "열면 자동으로 닫힌다" 는 구조로 감싸면 깜빡할 여지를 없앨 수 있어요. 지금 단계에선 unsubscribeAll() 한 메서드로 정리 지점을 한곳에 모은 것만으로도 충분히 좋은 출발이에요.


과제 3 예시답안 — 해지로 고칠까, 약한 참조로 고칠까

핵심 접근

두 상황을 가르는 질문은 딱 하나예요. "이 객체를 시스템이 마음대로 버려도 괜찮은가?" 버리면 안 되는, 반드시 살아 있어야 하는 객체라면 강한 참조로 꽉 붙들고 다 쓰면 직접 해지해요. 사라져도 다시 만들면 그만인 객체라면 약한 참조로 느슨하게 붙들어 가비지 컬렉터에 양보해요. 코드를 짜는 과제가 아니라, 이 한 질문을 두 상황에 대입하는 판단 과제예요.

미션별 답

  1. 상황 A (알림 받기, 직접 끌 때까지 유지)강한 참조 + 명시적 해지 가 맞아요. 사용자가 "이 게시물 알림 받기" 를 켰으면, 직접 끄기 전까지는 무슨 일이 있어도 알림이 와야 해요. 그런데 약한 참조로 붙들면 가비지 컬렉터가 메모리가 빠듯할 때 그 구독을 마음대로 치워버릴 수 있어요.

그러면 사용자는 끈 적도 없는데 알림이 슬그머니 멈추죠. 명백한 버그예요. 그래서 이 구독은 강한 참조로 꽉 붙들어 절대 임의로 사라지지 않게 하고, 사용자가 "알림 끄기" 를 누를 때 unsubscribe 로 직접 빼주는 게 맞아요.

  1. 상황 B (썸네일 캐시, 빠듯하면 버리고 재생성 OK)WeakReference(약한 참조) 가 맞아요. 썸네일은 사라져도 다시 받아오면 그만인, "있으면 빠르고 좋고 없어도 그만" 인 데이터예요. 그런데 강한 참조로 캐시에 쌓아두면, 스크롤하며 본 썸네일이 메모리에 계속 누적돼 결국 메모리가 꽉 차요. 약한 참조로 담아두면 가비지 컬렉터가 메모리가 급할 때 미련 없이 치워줘요. 꺼낼 때 null 이면 다시 받아오면 되고요. 메모리를 양보해도 되는 데이터라, 약한 참조의 "치우고 싶으면 치워도 돼" 가 딱 맞아요.

  2. 두 상황을 가른 핵심 질문"이 객체를 시스템이 마음대로 버려도 괜찮은가?" 버리면 안 되면(상황 A) 강한 참조 + 명시적 해지, 버려도 되면(상황 B) WeakReference 예요. 한 질문의 답이 두 갈래를 깔끔하게 나눠줘요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
상황 A 판단 강한 참조 + 명시적 해지를 골랐는가
상황 A 근거 "약한 참조면 GC 가 임의로 치워 알림이 멈춘다" 는 위험을 짚었는가
상황 B 판단 WeakReference 를 골랐는가
상황 B 근거 "사라져도 재생성 가능 + 메모리 양보 대상" 으로 근거를 댔는가
핵심 질문 정리 "마음대로 버려도 괜찮은가" 한 문장으로 추출했는가

흔한 실수

  • 두 상황 모두 WeakReference 로 답하는 경우 → 상황 A 에 약한 참조를 쓰면, 사용자가 켜둔 알림이 GC 의 변덕으로 꺼져버려요. "사라지면 안 되는 것" 에 약한 참조는 독이에요. 약한 참조는 "사라져도 되는 것" 전용이에요.
  • 상황 B 에 "unsubscribe 로 빼면 된다" 고 답하는 경우 → 틀린 건 아니지만, 캐시는 "언제 버려야 할지" 를 사람이 정하기 어려워요. 메모리 상황에 따라 GC 가 알아서 양보하게 두는 약한 참조가 더 어울려요.
  • 근거 없이 결론만 적는 경우 → 이 과제는 "왜 그쪽인가" 가 핵심이에요. "버려도 되는가" 라는 기준으로 근거를 대야 점수를 받아요.

실무 개선 포인트 (심화)

실무에서는 상황 B 같은 캐시를 직접 WeakReference 로 짜기보다, 검증된 캐시 도구를 쓰는 경우가 많아요. "최대 몇 개까지만, 마지막 사용 후 몇 분 지나면 버린다" 같은 정책을 선언만 하면 알아서 관리해주는 라이브러리들이 있거든요.

약한 참조는 동작이 미묘해서(언제 치워질지 정확히 통제 못 함) 직접 다루면 디버깅이 까다로워요. 그래서 "약한 참조의 원리는 이해하되, 실전 캐시는 검증된 도구에 맡긴다" 가 흔한 선택이에요. 반대로 상황 A 의 명시적 해지는 라이브러리에 맡기기보다 화면 수명에 묶어 직접 관리하는 게 보통이고요.


생각해볼 주제 예시답안

생각해볼 주제 1 예시답안 — 가비지 컬렉터가 있는데도 왜 메모리 누수가 생길까?

[문제 상황 요약]

자바는 안 쓰는 객체를 가비지 컬렉터가 알아서 치워준다고 했는데, 오늘 우리는 메모리 누수를 분명히 봤어요. 청소부가 있는데 왜 쓰레기가 쌓이는지, 그 책임이 누구에게 있는지에 대한 질문이에요.

[튜터의 가이드 및 해설]

핵심은 "가비지 컬렉터가 무엇을 기준으로 치울지 정하는가" 예요. 가비지 컬렉터는 사람 마음을 못 읽어요. 우리가 머릿속으로 "이제 이 객체 안 써" 라고 생각해도, 그 생각을 코드가 알 방법이 없죠. 가비지 컬렉터가 보는 기준은 오직 하나, 도달 가능성(reachability) 이에요. 뿌리에서 화살표로 닿을 수 있으면 "살아 있는 객체", 못 닿으면 "쓰레기" 로 판단해요.

그래서 우리가 안 쓰지만 코드 어딘가에서 강한 참조가 아직 그 객체를 가리키고 있으면, 가비지 컬렉터는 "어, 닿네? 그럼 쓰는 중이구나" 하고 살려둬요. 오늘 본 구독 목록이 딱 그거였죠. 화면에서 사라진 구독자라도 리스트가 붙들고 있으면, 가비지 컬렉터 눈엔 멀쩡히 살아 있는 객체예요.

그러니 누수는 가비지 컬렉터의 실패가 아니에요. 청소부는 자기 기준(도달 가능성)대로 정확히 일했어요. 진짜 원인은 우리가 안 놓은 참조 예요. "자동으로 치워준다" 가 "아무 신경 안 써도 된다" 는 뜻은 아니라는 거죠. 자동 청소가 있는 언어에서도 개발자가 끝까지 책임질 부분은 "다 쓴 객체의 참조를 제때 놓는 것" 이에요. 구독했으면 해지하고, 리스트에 담았으면 다 쓰면 빼고요.

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

"가비지 컬렉터는 도달 불가능한 객체만 치웁니다. 누수는 가비지 컬렉터의 실패가 아니라, 개발자가 의도치 않게 살려둔 참조의 문제예요. 청소부는 도달 가능성 기준대로 정확히 일했고, 다 썼는데도 강한 참조를 안 놓은 쪽은 우리죠. 그래서 '자동 GC = 신경 끄기' 가 아니라, 수명이 끝난 객체의 참조를 제때 끊는 건 여전히 개발자 책임입니다."


생각해볼 주제 2 예시답안 — C/C++ 수동 관리 vs 자바 자동 GC: 무엇을 얻고 무엇을 잃었나?

[문제 상황 요약]

C 나 C++ 은 개발자가 직접 메모리를 반납하고, 자바는 가비지 컬렉터가 알아서 치워요. 얼핏 자동이 무조건 좋아 보이지만 모든 선택엔 주고받는 게 있죠. 두 방식이 각각 무엇을 얻고 무엇을 잃었는지에 대한 질문이에요.

[튜터의 가이드 및 해설]

두 축으로 갈라볼게요. 얻은 것부터요.

자동 GC 가 준 가장 큰 선물은 안전성과 생산성 이에요. 수동 관리 시절 C/C++ 개발자를 가장 괴롭힌 두 가지 버그가 있었어요. 반납을 깜빡해 메모리가 새는 것, 그리고 아직 쓰는 객체를 실수로 반납해 프로그램이 엉뚱하게 깨지는 것(이걸 흔히 dangling reference 라고 불러요). 자바는 청소를 JVM 에 맡기면서 이 두 부류 버그에서 거의 벗어났어요. 개발자는 new 로 만들기만 하면 되니 개발 속도도 빨라졌고요.

그럼 잃은 것도 봐야죠. 청소를 JVM 에 맡긴 대신 우리는 제어권과 예측 가능성 을 내줬어요. 언제 청소가 일어날지 우리가 정확히 통제하지 못해요. 그리고 청소하는 동안 잠깐 멈추는 Stop-the-World 비용도 생겼어요. C/C++ 은 내가 원하는 그 순간에 정확히 반납하니 이런 멈춤이 없죠. 메모리를 한 톨까지 직접 다루니 게임 엔진이나 운영체제처럼 극한의 성능·예측 가능성이 필요한 곳에선 여전히 수동 관리가 힘을 발휘해요.

그래서 이건 우열이 아니라 맞바꿈 이에요. 자바는 제어권을 내주고 안전성·생산성을 얻었고, C/C++ 은 손이 더 가는 대신 제어권과 예측 가능성을 쥐었어요. 만드는 게 일반적인 서비스라면 자바의 거래가 거의 항상 이득이고, 1밀리초의 멈춤도 용납 안 되는 특수한 영역이라면 수동 관리의 거래가 맞는 거죠. "어느 쪽이 옳다" 가 아니라 "무엇을 더 중요하게 보느냐" 의 문제예요.

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

"자바의 자동 GC 는 안전성과 생산성을 얻은 대신 제어권과 예측 가능성을 내준 맞바꿈입니다. 반납 깜빡과 이중 해제 같은 단골 버그는 사라졌지만, 언제 청소가 일어날지 통제하지 못하고 Stop-the-World 멈춤이라는 비용을 떠안았죠. 그래서 일반 서비스라면 자바의 거래가 거의 항상 이득이지만, 멈춤이 치명적인 영역에선 여전히 수동 관리에 손을 들어줍니다. 우열이 아니라 무엇을 더 중요하게 보느냐의 선택이에요."


생각해볼 주제 3 예시답안 — G1 으로 충분한데 왜 ZGC 같은 게 따로 있을까?

[문제 상황 요약]

"보통은 기본값 G1 이면 충분하다" 고 했는데, 그런데도 ZGC 같은 가비지 컬렉터가 따로 만들어져 있어요. 하나로 다 되면 편할 텐데 왜 여러 개가 필요한지에 대한 질문이에요.

[튜터의 가이드 및 해설]

출발점은 "프로그램마다 중요하게 여기는 게 다르다" 예요. 두 부류를 떠올려볼게요.

어떤 프로그램은 밤새 대량의 데이터를 묵묵히 처리하는 게 일이에요. 중간에 잠깐씩 멈춰도, 하룻밤에 처리한 전체 양만 많으면 돼요. 이런 쪽은 처리량(throughput) 이 중요해요. 반대로 수많은 사용자 요청에 실시간으로 응답하는 서버는, 단 수십 밀리초만 멈춰도 사용자가 화면 버벅임을 느껴요. 이런 쪽은 지연시간(latency), 그러니까 멈춤이 짧은 게 더 중요하죠.

문제는 가비지 컬렉터가 이 둘을 동시에 완벽히 만족시키긴 어렵다는 거예요. 멈춤을 극단적으로 줄이려면 그만큼 다른 비용을 치러야 하거든요. 멈춤 없이 청소하려고 메모리를 더 쓰거나 CPU 를 더 돌리는 식으로요. 공짜 점심은 없어요. 그래서 "무엇을 더 중요하게 볼 것이냐" 에 따라 골라 쓰도록 여러 선택지를 준비해둔 거예요.

지금(2026년 기준) JDK 25 에서는 G1 이 모든 환경의 기본이라 대부분은 그대로 두면 잘 돌아가요. 멈춤을 한계까지 줄여야 하는 특별한 서비스를 맡았을 때, 그때 ZGC 같은 선택지를 저울에 올리면 돼요.

ZGC 는 멈춤을 1밀리초 아래로 누르는 데 집중한 물건이라, 멈춤이 조금이라도 길면 안 되는 큰 서버에서 빛을 발하죠. 결국 "정답이 하나가 아니라 상황에 따라 다르다" 는 게 핵심이에요. 여러 도구를 준비하고 워크로드에 맞게 고르는 게, 하나로 모두를 억지로 맞추려는 것보다 합리적인 설계예요.

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

"가비지 컬렉터가 여러 개인 건 워크로드마다 중요한 게 다르기 때문입니다. 대량 배치는 처리량이, 실시간 서버는 지연시간이 중요한데, 멈춤을 극단적으로 줄이려면 메모리·CPU 라는 추가 비용을 치러야 해요. 공짜 점심은 없죠. 그래서 JDK 25 의 기본 G1 으로 대부분을 처리하되, 멈춤이 치명적인 서비스엔 ZGC 처럼 서브밀리초 멈춤에 특화된 선택지를 올립니다. 정답이 상황에 따라 다르니 여러 도구를 준비하는 게 합리적인 설계예요."

전체 목록 자바 기초