[PART12.메모리 관리와 성능(8/10)] ArrayPool<T> — 배열을 재사용하는 방법
GC를 거치지 않고 배열을 돌려쓰는 방법 / 풀의 내부 구조 / Rent/Return의 함정과 Unity에서의 실전 활용
목차
1. [문제 제기] — 매 프레임 new byte[]가 게임을 멎게 한다
Unity 모바일 게임에서 UDP 패킷을 처리하는 코드를 상상해 봅시다. 네트워크 스레드가 매 틱마다 서버로부터 수백 바이트짜리 패킷을 받아 파싱하는 상황입니다. 가장 단순한 코드는 이렇게 생깁니다.
void OnPacketReceived(Socket socket)
{
byte[] buffer = new byte[4096]; // 매 호출마다 새 배열
int n = socket.Receive(buffer);
Parse(buffer, n);
} // buffer가 참조를 잃고 곧 GC 대상
60Hz로 이 메서드를 호출하면 1초에 60개, 한 시간이면 21만 개의 byte[4096] 배열이 힙에 쌓였다가 사라집니다. 프로파일러를 열어 보면 아래 두 가지 증상이 보입니다.
- GC 스파이크 — Gen 0(Generation 0, GC가 제일 먼저 수거하는 단수명 객체 영역)이 가득 차면서 수십 ms짜리 프리즈가 주기적으로 찍힙니다. 모바일 환경에서 이 멈춤은 프레임 드랍으로 직결됩니다.
- LOH 파편화 — 버퍼 크기를 100 KB로 올리면 상황이 더 악화됩니다. 85,000 바이트(약 83 KB) 이상 배열은 LOH(Large Object Heap, 큰 객체 전용 힙)에 들어가 처음부터 Gen 2로 간주되고, LOH는 기본적으로 압축(Compaction)을 하지 않기 때문에 구멍이 숭숭 뚫린 채 주소 공간이 낭비됩니다. 결국 메모리는 남아 있어도 연속된 빈 공간이 없어
OutOfMemoryException이 터집니다.
GC가 이 배열들을 회수할 때, Unity Mono 백엔드가 사용하는 Boehm GC(Boehm-Demers-Weiser GC, 보수적 비압축 GC)는 "Stop-the-World" 방식으로 모든 관리형 스레드를 정지시키고 힙 전체를 스캔합니다. 메인 스레드가 일시 정지되면 프레임이 통째로 사라집니다.
여기서 떠오르는 의문은 이렇습니다. 배열의 내용물은 매번 덮어쓰이는데, 왜 배열 자체는 한 번 만들어 계속 재사용하지 못할까요? ArrayPool<T>가 바로 이 질문에 대한 .NET의 공식 답입니다.
2. [개념 정의] — 대여소에서 빌리고 돌려주는 배열
비유: 도서관의 도서 대출
ArrayPool<T>를 도서관이라고 생각해 봅시다. 독자(코드)가 책(배열)이 필요할 때마다 서점에서 새로 사지(new byte[]) 않고, 도서관에서 빌려 쓰고(Rent) 반납(Return)합니다. 도서관은 반납된 책을 서가에 꽂아 다음 사용자에게 다시 빌려주므로, 책 자체의 출판(힙 할당) 빈도가 극적으로 줄어듭니다.
new 대신 풀을 쓰면 "GC가 배열을 수거할 일"이 사라집니다. 배열 객체 자체가 살아 있는 상태로 풀 내부에 보관되기 때문입니다.
구조를 눈으로 보기
![new byte[] vs ArrayPool<T>.Rent](https://blog.kakaocdn.net/dna/bXOio4/dJMcahxi47y/AAAAAAAAAAAAAAAAAAAAANI69qmx4V7pTR6I443HUP0mbIUZgT7sCFyO2plbUrcB/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1777561199&allow_ip=&allow_referer=&signature=Y53UJrAUj%2B3NRwKZBPaDy6O5vxk%3D)
기본 사용 코드
Unity에서 가장 자주 쓰이는 패턴은 네트워크 수신 버퍼입니다.
using System.Buffers;—ArrayPool<T>가 포함된 네임스페이스ArrayPool<T>,MemoryPool<T>,IBufferWriter<T>등 버퍼 풀링 API는 모두System.Buffers에 들어 있습니다.
예시:using System.Buffers;선언해야ArrayPool<byte>.Shared를 사용할 수 있습니다.
Before — 매 호출 힙 할당
public void ReceiveBefore()
{
byte[] buffer = new byte[4096]; // 매번 신규 할당
Process(buffer, 4096);
}
After — 풀에서 대여
using System.Buffers;
public void ReceiveAfter()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
Process(buffer, 4096);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}
}
IL로 확인하는 할당 차이
두 메서드를 동일 어셈블리에 두고 ilspycmd로 뽑은 결과입니다.
// ReceiveBefore — 새 배열을 만들어 바로 힙에 올린다
IL_0000: ldc.i4 4096
IL_0005: newarr [System.Runtime]System.Byte // ← 힙 할당
IL_000a: stloc.0
IL_000b: ldarg.0
IL_000c: ldloc.0
IL_000d: ldc.i4 4096
IL_0012: call instance void Sample::Process(uint8[], int32)
IL_0017: ret
// ReceiveAfter — Shared 풀의 Rent 호출 + try/finally로 Return 보장
IL_0000: call class [System.Runtime]System.Buffers.ArrayPool`1<!0>
ArrayPool`1<uint8>::get_Shared()
IL_0005: ldc.i4 4096
IL_000a: callvirt instance !0[] ArrayPool`1<uint8>::Rent(int32) // ← 대여
IL_000f: stloc.0
.try
{
IL_0010: ldarg.0
IL_0011: ldloc.0
IL_0012: ldc.i4 4096
IL_0017: call instance void Sample::Process(uint8[], int32)
IL_001c: leave.s IL_002b
}
finally
{
IL_001e: call ArrayPool`1<uint8>::get_Shared()
IL_0023: ldloc.0
IL_0024: ldc.i4.0 // clearArray: false
IL_0025: callvirt instance void ArrayPool`1<uint8>::Return(!0[], bool) // ← 반납
IL_002a: endfinally
}
IL 해설 — 무엇이 다른가
newarr— Before의 핵심 명령. CLR이 Gen 0(또는 크기에 따라 LOH)에서 메모리를 할당하고 배열 객체를 생성합니다. 이 명령이 호출될 때마다 GC가 추적해야 할 객체가 하나씩 늘어납니다.callvirt ... Rent— After에서는newarr가 사라졌습니다. 풀 내부에서 이미 존재하는 배열 참조를 돌려주는 가상 호출 하나로 끝납니다. 할당 0..try / finally— C# 컴파일러가try-finally를 IL 레벨의 예외 핸들러 블록(.try와finally)으로 그대로 변환합니다.Return이 어떤 경로로도 반드시 호출되게 해 주는 언어 수준 보증입니다.
"첫 호출에서는 풀도 비어 있으니 newarr가 결국 일어납니다. 두 번째 호출부터는 반납된 배열이 재사용되므로 전체 호출 중 할당 횟수가 급격히 줄어듭니다."
3. [내부 동작] — TlsOverPerCoreLockedStacksArrayPool 뜯어보기
ArrayPool<T>.Shared는 단순한 리스트가 아니라, 락 경합을 피하기 위해 3층 구조로 설계된 고급 자료구조입니다. .NET 런타임 소스의 TlsOverPerCoreLockedStacksArrayPool<T> 클래스가 그 구현입니다.
3층 캐시 구조

세 계층의 역할
| 계층 | 저장 구조 | 동기화 | 언제 쓰는가 |
|---|---|---|---|
| ① TLS | 스레드당 1슬롯(버킷별) | 없음 | 첫 시도 — 거의 항상 여기서 해결 |
| ② Per-Core Stack | 코어당 스택(깊이 ~8) | 짧은 SpinLock | TLS가 비었을 때 |
| ③ Steal / Allocate | 다른 코어 스택 / newarr |
- | 전체 풀이 고갈됐을 때 |
Unity 메인 스레드 하나가 같은 크기 버퍼를 반복해서 Rent/Return 하는 전형적인 패턴에서는 거의 모든 호출이 ①에서 끝납니다. 락 없이 포인터 하나만 교환하는 연산이므로 new 대비 수 배 빠릅니다.
버킷: 요청 크기를 올림해서 배열을 맞춘다
풀은 모든 크기를 개별 관리하지 않습니다. 2의 거듭제곱(16, 32, 64, … 1024, 2048, …) 단위로 버킷을 나누고, Rent(n)은 n 이상인 가장 작은 버킷의 배열을 돌려줍니다.

왜 올림만 하고 내림은 없는가 — 풀의 계약은 "요청 크기 이상의 배열을 돌려준다"입니다. 요청보다 작게 주면 데이터가 들어갈 자리가 모자라므로 호출자가 즉시 깨집니다. 반대로 크게 주는 건 안전합니다. 호출자는 자신이 요청한 크기만 쓰면 됩니다.
이 특성이 만드는 대표 버그 — buffer.Length를 그대로 쓰면 안 됩니다. 반드시 자신이 요청한 크기(또는 실제 채운 바이트 수)만 사용해야 합니다. 이건 4절에서 IL과 함께 재확인합니다.
Rent/Return의 진짜 비용
- Rent 평균 — TLS 히트 시 CAS(Compare-And-Swap) 수준, 사실상 포인터 읽기 1회.
- Return 평균 — 마찬가지로 TLS에 포인터 저장 1회.
- 할당 제로 — 한 번 생성된 배열은 풀 안에서 영구 거주. GC가 만질 일이 없습니다.
4. [실전 적용] — Unity 네트워크 핫패스, 스트리밍 I/O, IBufferWriter<T>
4.1 네트워크 수신 루프
Unity 멀티플레이 클라이언트에서 매 틱 TCP/UDP 수신을 돌리는 전형적인 상황입니다.
Before — 프레임마다 GC 스파이크
public void ReceiveLoop(Socket sock)
{
while (sock.Available > 0)
{
byte[] buffer = new byte[8192]; // 초당 수백 개 할당
int n = sock.Receive(buffer);
Dispatcher.Enqueue(buffer, n); // 참조가 큐로 전달됨
}
}
After — 풀 재사용 + 예외 안전
using System.Buffers;
public void ReceiveLoopPooled(Socket sock)
{
while (sock.Available > 0)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
try
{
int n = sock.Receive(buffer, 0, 8192, SocketFlags.None);
// 주의: 8192(요청 크기)로 제한. buffer.Length 아님.
Dispatcher.Enqueue(buffer, n);
}
finally
{
// Dispatcher가 동기 처리라면 여기서 반납. 비동기라면 소비자 측에서 반납.
ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
}
}
}
try-finally— 예외가 나도 반드시 실행되는 블록try안에서 어떤 예외가 발생하든finally블록은 실행됩니다. 풀 반납처럼 "건너뛰면 안 되는 정리 작업"에 필수입니다.
예시:try { 사용; } finally { 반납; }using문도 컴파일러가 내부적으로try-finally로 변환합니다.
소비자가 비동기로 버퍼를 읽는 경우 — 메시지 처리 스레드가 끝난 뒤에 반납해야 합니다. 이때 어느 한쪽에서만 Return을 호출해야 한다는 게 핵심입니다. 이중 반납은 풀을 오염시킵니다.
4.2 버킷 크기 내림 함정 — buffer.Length 금지
요청 크기가 아닌 buffer.Length를 쓰면 할당받은 실제 버킷 크기만큼 읽거나 쓰게 됩니다.
❌ Wrong
public int ReadWrong(Stream stream, int expected)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(expected); // 예: expected=1000
int read = stream.Read(buffer, 0, buffer.Length); // 실제 읽기 길이 1024
ArrayPool<byte>.Shared.Return(buffer);
return read;
}
✅ Right
public int ReadRight(Stream stream, int expected)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(expected);
try
{
int read = stream.Read(buffer, 0, expected); // 요청 크기까지만
return read;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
IL로 보는 차이
// ReadWrong — buffer.Length를 ldlen으로 밀어넣음
IL_000c: ldarg.1 // stream
IL_000d: ldloc.0 // buffer
IL_000e: ldc.i4.0 // offset 0
IL_000f: ldloc.0 // buffer
IL_0010: ldlen // ← 버킷 크기(1024) 획득
IL_0011: conv.i4
IL_0012: callvirt instance int32 System.IO.Stream::Read(uint8[], int32, int32)
// ReadRight — 요청 크기(expected) 그대로 전달
IL_000c: ldarg.1 // stream
IL_000d: ldloc.0 // buffer
IL_000e: ldc.i4.0 // offset 0
IL_000f: ldarg.2 // ← expected(=1000)
IL_0010: callvirt instance int32 System.IO.Stream::Read(uint8[], int32, int32)
IL 해설 — ldlen은 배열 객체의 길이 필드(버킷 크기 1024)를 스택에 올리는 명령입니다. Wrong은 이 값을 Read의 count로 넘겨 스트림에서 1024바이트를 읽으려 하고, 원본 스트림에 실제로 1000바이트만 있다면 끝에 쓰레기 데이터가 남거나 블로킹이 발생합니다. Right는 호출자가 원래 요청한 expected 값을 그대로 넘겨 버킷 크기와 무관하게 일관성을 지킵니다.
ldlen— 배열 길이 로드 IL 명령 배열 참조에서Length프로퍼티 값을 스택에 올립니다.buffer.Length를 C#에서 참조할 때마다 이 한 명령으로 컴파일됩니다.
4.3 JSON 직렬화와 IBufferWriter<T> — 풀 위에 지어진 고수준 API
.NET은 "풀에서 버퍼를 빌려 쓰는" 패턴을 표준 인터페이스로 일반화했습니다.
public interface IBufferWriter<T>
{
void Advance(int count);
Memory<T> GetMemory(int sizeHint = 0);
Span<T> GetSpan(int sizeHint = 0);
}
Utf8JsonWriter, System.IO.Pipelines의 PipeWriter, ArrayBufferWriter<T> 등이 이 인터페이스를 구현하며, 내부적으로 ArrayPool<T>.Shared에서 배열을 Rent·확장·Return합니다.
Unity에서 서버로 JSON 전송 — 할당 최소화
using System.Buffers;
using System.Text.Json;
public ReadOnlyMemory<byte> SerializePlayer(Player p)
{
// ArrayBufferWriter 내부가 ArrayPool로 버퍼를 관리
var writer = new ArrayBufferWriter<byte>(256);
using (var json = new Utf8JsonWriter(writer))
{
json.WriteStartObject();
json.WriteNumber("id", p.Id);
json.WriteString("name", p.Name);
json.WriteEndObject();
json.Flush();
}
return writer.WrittenMemory; // 풀 버퍼 위의 뷰
}
IBufferWriter<T>를 쓰면 사용자는 Rent/Return을 직접 호출하지 않고도 풀의 이점을 그대로 받습니다. 확장·재할당·반납이 모두 API 내부에서 처리됩니다.
4.4 선택 기준 — ArrayPool vs stackalloc vs new
| 방식 | 할당 위치 | GC 영향 | 수명 | 선택 기준 |
|---|---|---|---|---|
stackalloc |
스택 | 없음 | 메서드 종료 시 | 수백 바이트 이하 + 메서드 내부에서만 사용 |
new T[] |
힙 | 있음 | GC 수거까지 | 작고 드물게 할당 + 오래 살아야 함 |
ArrayPool<T>.Rent |
풀(힙 재사용) | 거의 없음 | Return 호출까지 | 수 KB~수 MB, 빈번하게 생성/해제 |
실전 결정 흐름: 크기 > 1KB 이거나 호출 빈도 > 초당 수 회면 → ArrayPool. 크기 < 512B 이고 메서드 내부에서만 쓰면 → stackalloc. 그 외 → new.
5. [함정과 주의사항]
5.1 Return을 잊으면 풀은 서서히 고갈된다
Return 누락은 즉각적인 크래시가 아니라 서서히 풀의 이점을 잃는 은밀한 버그입니다.
❌ Wrong — 예외 경로에서 Return 누락
public void ProcessPacketBad(Socket s)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
int n = s.Receive(buffer); // 예외 발생 시 종료
if (!Validate(buffer, n)) // throw 가능
throw new InvalidDataException();
ArrayPool<byte>.Shared.Return(buffer);
}
✅ Right — try-finally로 보장
public void ProcessPacketGood(Socket s)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
int n = s.Receive(buffer);
if (!Validate(buffer, n))
throw new InvalidDataException();
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
무엇이 문제인가 — Return을 잊으면 그 배열은 풀 입장에서 "영원히 대여 중"인 상태가 됩니다. GC는 결국 그 배열을 수거하지만, 풀은 그 사실을 모릅니다. 다음 Rent 요청이 들어오면 TLS·Per-Core 스택이 비어 있어 결국 newarr로 새 배열을 또 만듭니다. 이 패턴이 반복되면 풀은 사실상 동작하지 않고, 코드는 new byte[]를 쓸 때와 동일한 GC 부담을 받습니다. 겉으로는 풀을 쓰는 것처럼 보이므로 탐지가 어렵습니다.
5.2 이중 반납 — 풀을 오염시키는 최악의 실수
같은 배열을 두 번 Return하면 두 호출자가 같은 배열의 참조를 동시에 쥐게 됩니다. 한쪽이 읽는 동안 다른 쪽이 덮어쓰면 데이터 레이스입니다.
// ❌ 절대 금지
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
ArrayPool<byte>.Shared.Return(buffer);
ArrayPool<byte>.Shared.Return(buffer); // 두 번째 Return — 풀 오염
방지책: 반납 직후 참조 변수에 null을 대입하거나, 소유권을 명확히 하는 래퍼(IMemoryOwner<T>)를 사용합니다.
5.3 민감 데이터는 clearArray: true
풀 배열은 다른 호출자가 썼던 데이터가 그대로 남아 있습니다. 인증 토큰·결제 정보·암호화 키를 담았다면 반드시 0으로 지워 반납해야 합니다.
public void HandleAuth(byte[] token)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(token.Length);
try
{
Buffer.BlockCopy(token, 0, buffer, 0, token.Length);
SendToServer(buffer, token.Length);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true); // ← 필수
}
}
IL로 보는 두 옵션의 차이
// ReturnSensitive — clearArray: true
IL_0024: ldc.i4.1 // ← true
IL_0025: callvirt instance void ArrayPool`1<uint8>::Return(!0[], bool)
// ReturnNormal — clearArray: false (기본값)
IL_0024: ldc.i4.0 // ← false
IL_0025: callvirt instance void ArrayPool`1<uint8>::Return(!0[], bool)
IL 해설 — ldc.i4.1 vs ldc.i4.0은 IL 레벨에서 true/false를 스택에 올리는 차이일 뿐입니다. 진짜 비용 차이는 Return 내부에서 발생합니다. clearArray=true면 풀이 Array.Clear로 배열 전체를 0으로 덮어쓰고(큰 배열일수록 비용 큼), false면 이전 내용을 그대로 보관합니다. 일반 버퍼는 false, 민감 데이터는 true가 기본 원칙입니다.
5.4 참조 타입 배열 + clearArray: false = 메모리 유지
ArrayPool<SomeClass>에서 참조 타입 배열을 빌렸다면 Return(buffer, false) 시 배열 안의 참조가 풀에 살아남습니다. 그 참조가 가리키는 객체도 GC 대상이 되지 않아 메모리 누수처럼 보이는 상황이 만들어집니다.
// ❌ 참조 타입 배열인데 clearArray=false
public void HoldReferences(List<GameObject> items)
{
var arr = ArrayPool<GameObject>.Shared.Rent(items.Count);
try
{
for (int i = 0; i < items.Count; i++) arr[i] = items[i];
// ...
}
finally
{
ArrayPool<GameObject>.Shared.Return(arr, clearArray: false);
// ← GameObject 참조가 풀에 남아 GC 방해
}
}
✅ 참조 타입 배열은 항상 true로 반납
ArrayPool<GameObject>.Shared.Return(arr, clearArray: true);
실제로 .NET 구현은 참조/포인터를 포함한 타입에 대해 clearArray 값과 무관하게 자동 클리어하도록 개선돼 있지만, 코드 가독성과 호환성 측면에서 명시적으로 true를 주는 것이 안전합니다.
5.5 IL2CPP에서의 주의점
Unity IL2CPP는 C# IL을 C++로 변환한 뒤 AOT(Ahead-of-Time) 컴파일합니다. 여기서 주의할 것:
- 제네릭 공유(Generic Sharing) —
ArrayPool<byte>,ArrayPool<int>는 문제없이 동작하지만, 사용자 정의 struct를 타입 인자로 넘기면 AOT 컴파일 단계에서 대응 버전이 생성되어야 합니다.link.xml로 보존을 명시하거나 적어도 한 번은 정적 타입으로 사용해 Ahead-of-Time 컴파일이 포함시키게 해야 합니다. ArrayPool<T>자체는 .NET Standard 2.1 이후 Unity에서 기본 사용 가능 (System.Buffers.dll). Unity 2021 이상이라면 별도 NuGet 패키지가 필요 없습니다.- 스레드 모델 차이 — Unity는 메인 스레드 중심이지만,
Task/ThreadPool을 쓸 경우 TLS 캐시가 스레드마다 생깁니다. 짧은 수명의 스레드를 남발하면 TLS가 채워지기 전에 스레드가 사라져 이점이 줄어듭니다.
6. [C# 버전별 변화]
ArrayPool<T>는 .NET Core 1.0 / .NET Standard 1.1(2016)에서 System.Buffers 패키지로 처음 도입되었고, 이후 주변 API가 점진적으로 확장되는 형태로 발전했습니다. C# 언어 자체의 문법 변화보다는 런타임/BCL 버전에 따라 활용 범위가 넓어졌다고 보는 편이 정확합니다.
6.1 .NET Core 1.0 / C# 7.0 — 최초 도입
// 첫 버전 — 기본 Rent/Return
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
try { /* ... */ }
finally { ArrayPool<byte>.Shared.Return(buffer); }
이 시점에는 Span<T> 없이 배열 자체를 다뤘습니다.
6.2 .NET Core 2.1 / C# 7.2 — Span<T> 결합
using System.Buffers;
byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
try
{
Span<byte> view = buffer.AsSpan(0, 1000); // 요청 크기만큼만
Fill(view);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
Span<T>가 도입되면서 "버킷 오버사이즈로 받은 배열을 안전하게 요청 크기만큼 다루는" 이디엄이 정립됐습니다.
IL 해설 — AsSpan(int, int) 호출은 newobj 없이 스택 위에서 ref 하나와 길이 하나로 구성된 Span<byte> 구조체를 만듭니다. 할당이 추가로 일어나지 않기 때문에 "풀 + Span"이 제로 할당 패턴이 됩니다.
6.3 .NET Core 2.1 — IBufferWriter<T> · System.IO.Pipelines · Utf8JsonWriter
같은 릴리스에서 풀 기반 고수준 API가 함께 도착했습니다.
// ArrayBufferWriter<byte>의 내부가 ArrayPool을 사용
var writer = new ArrayBufferWriter<byte>(1024);
Span<byte> span = writer.GetSpan(512);
span[0] = 42;
writer.Advance(1);
6.4 .NET 6~8 — 성능 미세 조정
- .NET 6 — 대형 배열 임계값 완화, TLS 슬롯 구조 개선으로 히트율 향상.
- .NET 7 —
ArrayPool.Shared와PinnedArrayPool분리. - .NET 8 — 리눅스/ARM64 환경에서 Per-Core 스택 구현 최적화.
사용자 코드는 그대로 두고 런타임 업그레이드만으로 이득을 얻는 구간입니다.
6.5 Unity 대응
- Unity 2020 LTS —
.NET Standard 2.0기본,System.BuffersNuGet 패키지 필요. - Unity 2021 LTS+ —
.NET Standard 2.1이 기본 호환이 되면서ArrayPool<T>·Span<T>·IBufferWriter<T>를 별도 패키지 없이 사용 가능.
7. [정리] — 체크리스트
- 왜 쓰는가 —
new byte[]반복으로 생기는 GC 스파이크와 LOH 파편화를 원천 차단한다. - 내부 구조 —
TlsOverPerCoreLockedStacksArrayPool<T>= TLS 1슬롯 → Per-Core 스택 → Steal →newarr. 메인 스레드 반복 패턴에서는 거의 항상 TLS에서 끝난다. - 버킷 규칙 — 2의 거듭제곱 올림(최소 16, 최대 ~1MB).
buffer.Length가 아니라 자신이 요청한 크기로 순회한다. - Rent/Return 패턴 — 반드시
try-finally로 감싼다. Return 누락은 풀을 서서히 무력화시킨다. clearArray— 민감 데이터·참조 타입 배열이면true. 일반 바이트 버퍼는false(기본값).- 이중 반납 금지 — 같은 배열을 두 번 Return하면 두 호출자가 같은 배열을 동시에 소유하게 된다.
- 고수준 API로 위임 — Utf8JsonWriter·PipeWriter·ArrayBufferWriter 내부가 이미 풀을 쓴다. 가능하면
IBufferWriter<T>를 받는 API를 선택한다. - Unity 실전 — 네트워크 패킷 버퍼, 스트리밍 I/O, 직렬화 중간 버퍼에 우선 적용. Boehm GC 환경에서 효과가 특히 크다.
- 커스텀 풀이 필요할 때 —
ArrayPool<T>.Create(maxArrayLength, maxArraysPerBucket)로 특정 서브시스템 전용 풀을 분리한다. - 대안 선택 — 수백 바이트 이하 + 메서드 내부 →
stackalloc, 수 KB 이상 반복 →ArrayPool, 그 외 작은 일회성 →new.
'C# 심화' 카테고리의 다른 글
| [PART12.메모리 관리와 성능(10/10)] unsafe와 포인터 — 관리형 세계를 벗어나는 방법 (0) | 2026.04.14 |
|---|---|
| [PART12.메모리 관리와 성능(9/10)] stackalloc — 스택에 직접 할당하는 이유 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(7/10)] Span<T> / Memory<T> — 할당 없이 데이터를 다루는 방법 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(6/10)] WeakReference<T> — GC를 방해하지 않는 참조 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(5/10)] 이벤트 핸들러와 메모리 누수 — 가장 흔한 누수 패턴 (0) | 2026.04.14 |