그래서 네 문장을 조금 다듬으면 이렇게 말하면 정확해.
List<T>.Clear()는 Count를 0으로 만들지만 일반적으로 Capacity와 내부 배열은 유지하므로 이후 Add 시 기존 메모리를 재사용할 수 있다. 따라서 Update와 같은 반복 경로에서 List를 계속 새로 생성하는 것보다 기존 List를 Clear하여 재사용하면 불필요한 managed allocation과 GC 부담을 줄일 수 있다. 단, reference를 포함하는 타입의 경우 Clear 과정에서 기존 reference를 제거하기 위한 비용이 발생할 수 있다.
그리고 네 두 번째 질문까지 한 줄로 답하면:
Count가 0이어도 Capacity만큼의 내부 배열은 남아 있으며, pure value type의 경우 이전 값이 물리적으로 남아 있을 수 있지만 더 이상 List의 유효 요소는 아니다. Reference type이나 reference를 포함한 타입은 GC가 객체를 놓아줄 수 있도록 기존 reference를 정리한다.
이건 Unity Profiler에서 GC.Alloc 줄이는 감각이랑 바로 연결되는 개념이야.
어떤 객체가 살아 있어야 하는지는 “Root에서 참조를 따라가서 도달할 수 있는가?”로 판단해.
예를 들어:
Enemy enemy = new Enemy();
개념적으로:
지역 변수 enemy
│
│ reference
▼
[ Enemy 객체 ]
여기서 enemy라는 지역 변수가 아직 유효하다면 GC가 검사했을 때:
GC Root
↓
enemy reference
↓
Enemy 객체
라는 경로를 찾을 수 있어.
그래서 Enemy는 reachable, 즉 살아 있는 객체라고 판단하는 거야.
반대로:
enemy = null;
하고 다른 어디에서도 그 객체를 참조하지 않는다면:
GC Root
X
│
│ 더 이상 경로 없음
▼
[ Enemy 객체 ]
이 객체는 unreachable이 되고, 다음 GC 때 수거 대상이 될 수 있어.
즉:
“GC가 객체를 놓아준다”
라는 말은 편하게 쓰는 표현이고, 더 정확하게는:
그 객체로 가는 managed reference 경로가 사라져서 GC가 더 이상 reachable 객체로 판단하지 않는다
라고 해야 해.
그리고 아까 말했던 **“T가 GC reference를 포함한다”**도 이제 풀어보자.
예를 들어:
class Enemy
{
}
Enemy는 reference type이니까:
Enemy enemy;
라는 변수 안에는 Enemy 객체 자체가 들어가는 게 아니라 Enemy 객체를 가리키는 managed reference가 들어가.
그러니까:
List<Enemy>
의 내부 배열은 개념적으로:
[ ref ][ ref ][ ref ][ ref ]
│ │
▼ ▼
EnemyA EnemyB
이렇게 돼.
이 배열의 각 슬롯에 있는 ref가 바로 GC가 추적하는 reference야.
그런데 struct가 재미있어.
struct Data
{
public int Id;
public string Name;
}
Data 자체는 value type이야.
하지만 내부에:
string Name;
이 있지?
string은 reference type이므로 실제 Data의 내용은 개념적으로:
Data
┌──────────────────┐
│ Id : 123 │ ← 그냥 값
│ Name : reference │───→ String 객체
└──────────────────┘
이렇게 돼.
그래서:
Data는 value type이지만 내부에 GC가 추적해야 하는 managed reference를 포함한다.
라고 표현하는 거야.
즉 **“GC reference를 포함한다”**는 말의 포함은 정말 문자 그대로:
struct 안에
reference 필드가 들어 있다
는 뜻이야.
반대로:
struct Position
{
public float X;
public float Y;
}
는:
Position
┌──────────────┐
│ float X │
│ float Y │
└──────────────┘
뿐이야.
어디에도:
다른 managed 객체를 가리키는 reference
가 없어.
그래서 이런 타입을 보고:
GC references를 포함하지 않는다
라고 하는 거야.
이제 List.Clear()와 다시 연결하면 아주 명확해져.
List<Enemy> enemies;
내부 배열에:
[refA][refB][refC]
│ │ │
▼ ▼ ▼
EnemyA EnemyB EnemyC
가 있다고 해보자.
List 자체가 살아있는 동안 내부 배열도 살아있겠지.
만약 Clear()가 단순히:
Count = 0
만 하고 배열을 이렇게 놔두면:
List
↓
내부 배열
↓
[refA][refB][refC]
│ │ │
▼ ▼ ▼
EnemyA EnemyB EnemyC
GC 입장에서는 여전히:
Root
↓
List
↓
Array
↓
EnemyA
라는 경로가 존재해.
그러니까 EnemyA는 아직 reachable이야.
Count == 0이라는 사실은 GC가 신경 쓰지 않아.
GC가 보는 것은:
“메모리 안에 실제 managed reference가 남아 있나?”
야.
그래서 Clear()가 reference 슬롯을:
[null][null][null]
로 지워주는 거야.
그러면:
Root
↓
List
↓
Array
EnemyA EnemyB EnemyC
↑
더 이상 경로 없음
이 되고, 다른 곳에서도 이 Enemy들을 참조하지 않는다면 GC 수거 대상이 될 수 있어.
여기서 중요한 포인트가 하나 더 있어.
GC는 List.Count 같은 프로그램 논리를 이해하지 않아.
GC가:
"아, Count가 0이네.
그러면 배열의 앞부분은 무시해야겠다."
이렇게 판단하는 게 아니야.
GC는 런타임이 제공하는 타입 정보를 기반으로:
이 객체 안에 reference가 어디 있지?
↓
그 reference가 어떤 객체를 가리키지?
↓
그 객체도 따라가자
를 하는 거야.
그래서 실제 reference를 null로 만들어주는 게 필요한 거지.
전체 그림을 보면:
GC Root
│
├─ static 변수
├─ 살아있는 지역 변수 / 레지스터 등
├─ runtime handle 등
│
▼
Object A
│
│ reference
▼
Object B
│
│ reference
▼
Object C
라면 A, B, C 모두 살아 있다고 판단해.
그런데:
Object B → Object C
reference를 끊으면:
GC Root
↓
Object A
↓
Object B
Object C
↑
경로 없음
Object C는 다른 참조가 없다면 수거 가능해.
그래서 정확한 용어로는:
"GC가 객체를 잡고 있다"
보다는:
"객체가 GC Root로부터 reachable하다"
가 훨씬 좋아.
그리고:
"GC가 객체를 놓아준다"
보다는:
"객체로 향하는 reference가 없어져
GC 수거 대상이 될 수 있다"
가 정확하고.
마지막으로 딱 한 줄로 정리하면:
“GC reference를 포함한다”는 것은 그 값의 메모리 안에 GC가 추적하는 managed object reference가 들어 있다는 뜻이고, GC는 그 reference들을 따라가며 어떤 객체가 Root에서 도달 가능한지 판단한다. GC가 객체를 직접 붙잡고 있는 것은 아니다.
예를 들어 이런 객체 관계가 있다고 해보자.
GC Root
│
▼
Player
│
▼
Inventory
│
▼
Item
그리고 별도로:
Enemy
객체가 하나 있는데 아무도 참조하지 않는다고 하자.
GC는 대략 이런 식으로 생각해.
1. GC Root에서 시작
2. Root가 직접 참조하는 객체를 찾음
3. 그 객체가 참조하는 다른 객체를 따라감
4. 계속 연결된 객체를 방문
5. 방문되지 않은 객체는 unreachable
6. unreachable 객체는 수거 대상
그래서 위 예제에서는:
GC Root
↓
Player ← reachable
↓
Inventory ← reachable
↓
Item ← reachable
이 셋은 살아남아.
반면:
Enemy
는 Root에서 가는 경로가 없으니까:
unreachable
→ 수거 가능
이 되는 거야.
조금 더 그림처럼 보면:
[GC Root]
|
v
[Player] --------> [Weapon]
|
v
[Inventory]
|
v
[Item]
[Enemy]
^
|
아무 연결 없음
GC가 Root에서 출발해서 방문하면:
Player 방문 O
Weapon 방문 O
Inventory 방문 O
Item 방문 O
Enemy 방문 X
이런 식으로 돼.
그래서 Enemy는 수거 후보가 되는 거지.
여기서 중요한 건 reference type 자체가 무조건 GC 대상이라는 뜻이 아니라, 정확히는:
managed heap에 있는 객체들 중에서 Root에서 더 이상 도달할 수 없는 객체가 수거 대상이 된다
는 거야.
예를 들어:
Enemy enemy = new Enemy();
이 상태에서는 지역 변수 enemy가 객체를 참조하니까 살아 있어.
Root
↓
enemy
↓
Enemy 객체
그런데:
enemy = null;
이 되고 다른 참조가 전혀 없다면:
Root
Enemy 객체
↑
경로 없음
이제 다음 GC 때 수거 대상이 될 수 있어.
GC Root는 쉽게 말하면:
GC가 "여기서부터 살아있는 객체를 찾아봐야겠다"라고 시작하는 기준점
이야.
대표적으로는 이런 것들이 있어.
현재 실행 중인 코드의 살아있는 reference 지역 변수
static reference
런타임이 특별히 잡고 있는 handle
정확한 세부는 런타임/JIT에 따라 다르지만 초보자 수준에서는 이 정도면 충분해.
그리고 실제 GC는 보통 “reachable 찾기”만 하고 끝나는 게 아니야.
개념적으로는:
Mark
↓
Sweep / Compact
흐름이 많이 나와.
Mark
Root에서 출발해서 살아있는 객체에 표시해.
Player marked
Inventory marked
Item marked
Enemy not marked
Sweep
표시되지 않은 객체를 죽은 객체로 판단해서 메모리를 다시 쓸 수 있게 해.
Enemy
→ 회수 가능
Compact
일부 GC에서는 살아있는 객체들을 한쪽으로 모아서 메모리 단편화를 줄이기도 해.
예를 들어 GC 전:
[A][빈칸][B][빈칸][C][빈칸]
GC 후:
[A][B][C][빈칸][빈칸][빈칸]
처럼 정리할 수 있어.
.NET GC는 세대별 수집과 compacting 같은 최적화를 같이 사용하는데, 지금은:
Root에서 따라가서 살아있는 객체를 찾고, 못 찾은 객체를 회수한다
이 핵심만 잡으면 돼.
그리고 아까 List.Clear()와 연결하면 더 잘 보여.
Root
↓
List
↓
내부 배열
↓
Enemy A
이렇게 reference가 남아 있으면 Enemy A는 reachable이야.
그래서 Clear()가 내부 reference를 null로 만들면:
Root
↓
List
↓
내부 배열
Enemy A
↑
더 이상 연결 없음
다른 곳에서도 참조가 없다면 다음 GC 때 수거 가능해지는 거지.
한 문장으로 정리하면:
GC는 Root에서 시작해 reference를 따라가며 reachable 객체를 표시하고, 끝까지 도달하지 못한 객체를 garbage로 판단해 메모리를 회수한다.