카테고리 없음

C# List/GC

LetsCoding 2026. 9. 27. 16:11

그래서 네 문장을 조금 다듬으면 이렇게 말하면 정확해.

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로 판단해 메모리를 회수한다.