숏컷 코드
// ReadOnlySpan<char> — 문자열 일부를 새 할당 없이 참조
ReadOnlySpan<char> name = "RefDock".AsSpan();
ReadOnlySpan<char> first = name[..3]; // "Ref" — 복사 없음
// 배열의 일부를 span으로
int[] arr = { 1, 2, 3, 4, 5 };
Span<int> slice = arr.AsSpan(1, 3); // { 2, 3, 4 }
slice[0] = 99; // arr[1] == 99 — 원본 수정
// Span 기반 파싱 — Substring() 없이
ReadOnlySpan<char> line = "key=value".AsSpan();
int eq = line.IndexOf('=');
ReadOnlySpan<char> key = line[..eq]; // "key"
ReadOnlySpan<char> value = line[(eq + 1)..]; // "value"문법
어떤 버퍼 타입을 먼저 고르면 되나
| 상황 | 먼저 떠올릴 것 |
|---|---|
| 동기 구간에서 고성능 슬라이스 | Span<T> |
| 읽기 전용 문자열/배열 슬라이스 | ReadOnlySpan<T> |
| await 경계를 넘어야 함 | Memory<T> |
| 큰 임시 배열 재사용 | ArrayPool<T> |
Span<T>는 ref struct — 스택에만 존재
Span<T>는 ref struct 로 선언되어 스택에만 존재할 수 있습니다. 힙에 저장하거나, 필드로 보유하거나, await 경계를 넘을 수 없습니다. 이 제약이 있는 대신 런타임 오버헤드가 극히 작습니다.
// Span<T>의 내부 구조 (개념)
// struct Span<T> { ref T _pointer; int _length; }
// 힙 포인터가 아닌 스택 포인터를 직접 들고 있음
// ❌ span을 클래스 필드로 보유 불가
class Bad { Span<int> _data; } // 컴파일 오류
// ❌ span을 await 경계로 넘길 수 없음
async Task ProcessAsync()
{
Span<int> span = stackalloc int[10];
await Task.Delay(1); // ❌ span은 await를 넘을 수 없음
}
// ✅ 동기 메서드에서만 사용
void Process(Span<int> span) { /* ... */ }// ❌ span을 비동기 경계 밖으로 들고 나가려 함
ReadOnlySpan<char> part = line.AsSpan(0, 3);
// _pending = part; // 필드 저장 불가
// ✅ 비동기/보관이 필요하면 string 또는 Memory<T>로 전환
string part = line.Substring(0, 3);Memory<T> — await 경계를 넘는 뷰
Memory<T> 는 Span<T>와 비슷하지만 힙에 저장 가능합니다. Span<T>의 제약이 필요할 때 Memory<T>로 힙에 보관하고, 동기 코드에서 .Span 프로퍼티로 Span<T>를 얻습니다.
// Memory<T>는 클래스 필드 가능
class Buffer
{
private Memory<byte> _data = new byte[1024];
public async Task ProcessAsync()
{
// Memory<T>는 await 경계를 넘을 수 있음
await File.ReadAllBytesAsync("data.bin");
// 동기 구간에서 Span으로 변환해 효율적으로 처리
Span<byte> span = _data.Span;
ParseBinary(span);
}
}실제 성능 개선 패턴
// ❌ 기존 방식 — 매번 새 string 할당
string[] parts = line.Split(',');
for (int i = 0; i < parts.Length; i++)
Process(parts[i]); // 각 부분이 새 string 객체
// ✅ span 방식 — 할당 없음
ReadOnlySpan<char> remaining = line.AsSpan();
while (!remaining.IsEmpty)
{
int comma = remaining.IndexOf(',');
ReadOnlySpan<char> part = comma >= 0 ? remaining[..comma] : remaining;
ProcessSpan(part);
remaining = comma >= 0 ? remaining[(comma + 1)..] : ReadOnlySpan<char>.Empty;
}ArrayPool<T> — 배열 재사용
짧은 수명의 큰 배열을 자주 만들 때 ArrayPool<T> 로 기존 배열을 재사용해 GC 압력을 줄입니다.
// 임시 버퍼 — 매번 새 배열 할당 → GC 부담
byte[] buffer = new byte[4096];
int read = stream.Read(buffer);
// ArrayPool — 반납 가능한 임시 배열
byte[] rented = ArrayPool<byte>.Shared.Rent(4096);
try
{
int read = stream.Read(rented);
Process(rented.AsSpan(0, read));
}
finally
{
ArrayPool<byte>.Shared.Return(rented); // 풀에 반납
}체크포인트
| 타입 | 저장 위치 | await 가능 | 주요 용도 |
|---|---|---|---|
Span<T> | 스택만 | ❌ | 동기 고성능 처리 |
ReadOnlySpan<T> | 스택만 | ❌ | 읽기 전용 슬라이싱 |
Memory<T> | 힙 가능 | ✅ | 비동기 경계 포함 처리 |
ArrayPool<T> | 풀 관리 | — | 임시 배열 재사용 |
| 핵심 장점 | 복사·할당 감소 | — | 파싱, 직렬화, 프로토콜 |
주의할 점
Span<T>는 강력하지만 수명 제약이 엄격합니다. "이 버퍼가 동기 호출 안에서만 살아도 되는가?"를 먼저 판단하세요. await 경계가 하나라도 있으면 Memory<T>가 필요합니다.
ArrayPool<T>에서 빌린 배열은 반드시 반납해야 합니다. try/finally로 Return()을 보장하거나, .NET 6+의 MemoryPool<T>를 사용하면 IDisposable로 자동 반납할 수 있습니다.
참고 링크
3 sources