Develog
로그인

[Unity] 프로세스와 메모리

KYO
KYO
∙2026년 6월 24일∙조회 5
UnityMemoryProcess
Unity Native 이해를 위한 학습 로드맵
본문 로딩 중...
이전 글[Unity] 내 게임은 어떻게 실행되는가
다음 글[Unity] Managed, Unmanaged
0
KYO

댓글 0

주요 개념

  • 프로세스
  • 가상 메모리
  • 스택(Stack)과 힙(Heap)
  • 객체와 참조

학습 목표

  • 객체가 메모리에 존재한다는 것은 무슨 의미인가?
  • 참조는 무엇을 나타내는가?
  • 스택과 힙을 구분해서 사용하는 이유는?

1. 프로세스

프로세스란 프로그램의 실행 단위이다. OS에서 여러개의 프로세스가 동시에 실행될 수 있으므로 우리는 Unity와 브라우저를 동시에 실행할 수 있다.

운영체제는 각 프로세스에 대해 다음을 할당한다.

  • 독립된 메모리 공간
  • CPU 스레드
  • 시스템 자원(커널 자원들의 핸들)

이들 프로세스는 서로의 메모리를 직접 참조할 수 없다.

2. 가상 메모리

C#에서 변수의 주소를 출력하면 나오는 주소 - 예시로 0x0230 으로 나타난 주소가 실제 물리 메모리의 그곳을 의미하지는 않는다는 것에 주의하자. 운영체제는 프로세스에 가상의 메모리를 할당한다. 그리고 그 가상 메모리를 운영체제가 실제 메모리로 매핑한다.

가상 메모리가 가져오는 이점은 아래와 같다.

  • 서로 다른 프로세스가 서로의 메모리를 참조할 수 없도록 분리한다.
  • 프로세스가 커널에 직접 접근하지 못한다.
  • 물리 메모리보다 큰 공간을 사용하는 척이 가능하다.

3. 가상 주소 공간 구조

프로세스의 가상 주소 공간은 이렇게 나뉜다.

Mermaid
렌더링 중...
  • 커널: 운체가 관리하므로 접근 불가
  • 빈 공간: 스택, 힙이 양쪽에서 늘어나 차지하는 공간
  • 정적 데이터: static, 전역변수
  • 코드: 실행할 기계어

스택과 힙, 값타입과 참조타입에 대해서는 이미 다뤘으므로 그 개념을 자세히 풀지는 않겠으나 몇 가지 부연할 것이 있다.

Stack frame, SP, BP

함수가 실행되면 스택에 스택 프레임으로써 그 함수가 들어간다. 이 안에는 헤더와 지역변수들이 들어 있으며, 헤더에는 반환할 주소도 기록된다(함수 실행 결과 다시 호출부로 점프해야하므로). 함수가 끝나 스코프가 사라지면 해당 스택 프레임과 함께 그 안에 지역변수도 통째로 사라진다. 이는 스택 포인터(SP)의 증감이라는 저비용의 할당/해제 과정이다. 또한 프레임 안의 지역 변수에 대한 참조도 베이스 포인터(BP. 현재 프레임의 기준)에 오프셋을 뺀 값*으로 이루어지므로, 스택이 힙보다 빠르다는 말이 바로 이러한 맥락에서 비롯된다.

  • 컴파일 시점에, 지역 변수는 BP로부터 몇 바이트 떨어져있는지에 대한 정보를 담아 BP - 4나 BP - 12와 같이 변환되어 기계어가 된다. CPU는 BP에 그 오프셋을 빼는 것으로 빠르게 접근한다(비용 0)
  • 더하는게 아니라 빼는것은, 스택이 아래로 자라기 때문이다.

C#에선 참조 ≠ 주소값

C#코드의 변수 메모리 주소를 IDE에서 조회한 값은 가상 메모리 주소값으로, 이는 C++도 동일하다. 그러나 C#의 Managed 힙에서는 GC가 자기 판단에 따라 객체의 주소를 옮기고 참조 변수를 갱신할 수 있다. 이는 메모리 파편화와 같은 문제에 대응하는, 최적화를 위해 필요한 과정이다. 따라서 우리는 직접적인 포인터 연산이나 주솟값 참조를 해선 안 되고, 할 수도 없다. 다만 편의를 위해 변수의 주소라는 표현을 쓰고, 참조라는 표현이 권장되기도 한다.

명확한 이해를 위한 한 줄 예시

csharp
Enemy e = new Enemy();
//  ↑참조(스택)    ↑객체(힙)

4. 커널로의 요청 처리

프로세스는 때때로 커널에 자원을 요청한다. 힙 할당, 시간 조회, 스레드 요청, 네트워크 소켓 생성/연결/송수신 등이 그것이다. 이들은 비용이 다양한데, 여기서는 힙 할당에 대해서만 다룬다.

힙할당, 시스템콜, 세그먼트, 파편화

힙에 객체를 생성할 때 메모리가 부족하다면 CLR은 운영체제(커널)에 메모리를 요청한다(시스템콜). 매번 시스템콜을 하는 것은 비효율적이므로 CLR은 일정 단위(세그먼트, 수MB)의 요청을 하며 여기에 객체를 이어서 추가한다. 이어서 추가한다는 것은, 현재 메모리의 끝 뒤에 새 세그먼트를 이어붙이고 그곳의 시작점에 추가한다는 뜻이다.

여기서 Managed 힙의 메모리 파편화의 원인을 짐작할 수 있다. GC가 연속된 객체 중 참조가 없는 객체를 수거할 것이고 그 객체가 있던 공간 자체는 그대로 힙에 남을 것이다. CLR이 빈 공간에 객체를 넣지 않고 메모리 끝에 이어붙이므로 이 빈공간은 일단 메워지지 않는데, 이렇게 빈 공간이 흩어지는 것을 메모리 파편화라고 부른다. 대신 GC가 메모리를 정리하며 빈 공간을 채우기 위해 객체의 주소를 이동하고 참조를 갱신하며(위에서 언급한, 주소값 연산 등이 안 되는 이유), 이로써 메모리가 압축된다.

다만 LOH(Large Object Heap)에 저장되는 비교적 큰 객체(약 85Kb 이상)는 기본적으로 압축을 하지 않는데, 너무 커서 이동 비용도 크기 때문이다. 따라서 너무 큰 객체나 긴 배열을 생성/파괴 반복하는 것이 파편화에 취약하다.

한편 GC압축 이후, 앞서 요청했던 많은 세그먼트들 중 나중에 추가된 세그먼트에는 필연적으로 많은 빈 공간이 존재할 것이다. 이는 os에게 반환될 수도 있고 아닐 수도 있는데, 이것을 결정하는 것도 런타임 환경이다. 닷넷은 비교적 적극적으로 반환한다고 하며, 그에 비해 Mono와 IL2CPP는 거의 반환하지 않는다. 이는 반환 자체도 시스템콜로써 부하를 일으키기 때문인데, 게임 프로세스의 경우 수많은 객체가 생성 및 파괴를 반복하는 특성상 반환이 불리하다. 어차피 나중에 또 요청할 가능성이 크기 때문이다. Unity가 한번 받은 메모리를 반환하지 않는다는 말은 이것을 의미하며, 따라서 게임의 메모리 사양을 체크할 때 Avg가 아닌 Max를 측정하는 것이다. 우리는 Unity 프로파일러에서 Managed 메모리의 Used와 Reserved를 통해 그 수치를 파악할 수 있다.

5. 마무리

  • 객체가 메모리에 존재한다는 것은 무슨 의미인가?

객체를 new 키워드로 생성하여 Managed힙에 넣는다. 이 객체는 GC가 관리하는 대상이 되고 우리는 참조 변수로 이 객체에 접근한다. (다소 모호. 이 시리즈 다음 장에서 해소됨)

  • 참조는 무엇을 나타내는가?

참조는 객체를 가리키는 핸들(handle)이며, 이 핸들은 GC가 관리한다.

  • 스택과 힙을 구분해서 사용하는 이유는?

스택은 매우 빠르지만 스코프가 제한적이고, 동적인 생성/파괴가 어렵다. 힙은 그 반대의 특징을 보이므로 구분 사용이 필요하다.

새 글이 올라오면 Develog 알림으로 알려드려요

댓글 0

댓글을 작성하려면 로그인이 필요합니다.

아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요.

관련 글

[Unity] 제네릭의 컴파일

[Unity] 제네릭의 컴파일

ㅇㅇ

10월 7일42
[Unity] 제네릭과 가변성(variance)

[Unity] 제네릭과 가변성(variance)

제네릭과 가변성

10월 6일821
[Unity] IEnumerator 개요

[Unity] IEnumerator 개요

코루틴에이저포에버

10월 6일4
[Unity] 제네릭의 컴파일
Unity

NEW[Unity] 제네릭의 컴파일

ㅇㅇ

KYO
KYO
420
KYO
KYO
∙10월 7일
420
[Unity] 제네릭과 가변성(variance)
Unity

[Unity] 제네릭과 가변성(variance)

제네릭과 가변성

KYO
KYO
821
KYO
KYO
∙10월 6일
821
[Unity] IEnumerator 개요
Unity

[Unity] IEnumerator 개요

코루틴에이저포에버

KYO
KYO
400
KYO
KYO
∙10월 6일
400