메모리와 성능
Span<T> 와 값 형식으로 메모리를 덜 사용하고 빠르게 합니다.
만들지 않는 것이 가장 빠릅니다
1.3에서 값 형식과 참조 형식이 담기는 방식이 다르다는 것을 보았습니다. 참조 형식을 만들면 힙에 자리를 잡고, 나중에 GC 가 치웁니다.
GC 는 빠릅니다. 문제는 치우는 일 자체가 아니라 자주 치워야 하는 것입니다. 만드는 것이 많으면 그만큼 자주 돌고, 도는 동안 프로그램은 잠깐씩 멈춥니다.
그래서 이 단원의 이야기는 하나로 모입니다. 같은 일을 하면서 만드는 것을
줄이는 법입니다. Span<T> 가 그
한가운데 있습니다.
먼저 재고 나서 고치십시오. 아래 방법들은 뜨거운 자리에서만 값을 합니다. 요청마다 한 번 도는 코드에서는 읽기 쉬운 쪽이 낫습니다.
같은 답, 다른 할당량
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
var line = "2026-08-29,홍길동,32,서울특별시 강남구"; const int N = 1_000_000; // 1) 잘라서 새 문자열을 만듭니다 var a = GC.GetAllocatedBytesForCurrentThread(); for (var i = 0; i < N; i++) _ = int.Parse(line.Split(',')[2]); Console.WriteLine(GC.GetAllocatedBytesForCurrentThread() - a); // 2) 잘라 보기만 합니다. 새로 만들지 않습니다. a = GC.GetAllocatedBytesForCurrentThread(); for (var i = 0; i < N; i++) _ = Age(line); Console.WriteLine(GC.GetAllocatedBytesForCurrentThread() - a); static int Age(string line) { var s = line.AsSpan(); var i1 = s.IndexOf(','); var i2 = s[(i1 + 1)..].IndexOf(',') + i1 + 1; var i3 = s[(i2 + 1)..].IndexOf(',') + i2 + 1; return int.Parse(s[(i2 + 1)..i3]); }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
둘은 같은 값을 돌려줍니다. 그런데 위쪽은 100만 번 도는 동안 152메가바이트를 만들었고, 아래쪽은 592바이트에 그쳤습니다. 100만 번으로 나누면 한 번에 0.0006바이트입니다. 도는 동안에는 아무것도 만들지 않고 처음 한 번만 잡았다는 뜻입니다.
아래 표는 같은 코드를 net10.0(Release) 콘솔에서 잰 것입니다. 절대값은 실행 환경에 따라 달라지지만 비율은 그대로입니다.
| 방법 | 시간 | 만든 것 | GC 0세대 |
|---|---|---|---|
| Split + Parse | 67ms | 208,000,000바이트 | 25회 |
| Span + Parse | 14ms | 0바이트 | 0회 |
시간 차이보다 GC 가 스물다섯 번 돌았다는 것이 중요합니다. 그동안 다른 일이 멈췄고, 그 멈춤은 요청이 몰릴수록 눈에 띕니다.
Span<T> 는 자리를 가리킵니다
Span<T> 는 이미 있는 자리의 어디부터
어디까지를 가리킵니다. 담긴 것을 옮기지 않으므로 만들 것이 없습니다.
string s = "2026-08-29"; var year1 = s.Substring(0, 4); // 새 문자열 "2026" 이 생깁니다 var year2 = s.AsSpan(0, 4); // 같은 글자를 가리키기만 합니다
가리키는 곳은 무엇이든 됩니다. 문자열·배열·스택·네이티브 메모리가 같은 형식으로 다뤄집니다.
Span<byte> a = new byte[64]; // 힙의 배열 Span<byte> b = stackalloc byte[64]; // 스택 ReadOnlySpan<char> c = "글자"; // 문자열 // 받는 쪽은 어디서 왔는지 몰라도 됩니다 static int Sum(ReadOnlySpan<byte> data) { … }
매개 변수는 ReadOnlySpan<T> 로 받는 것이
기본입니다. 고칠 것이 아니면 읽기 전용으로 받아야 부르는 쪽이
string 도 넘길 수 있습니다.
범위로 자르기
1.7의 범위 문법이 그대로
동작합니다. Slice 를 부르는 것과 같습니다.
var s = "2026-08-29".AsSpan(); s[0..4] // 2026 s[5..7] // 08 s[^2..] // 29 // 자르는 것마다 새로 만들지 않습니다. 위 셋을 다 해도 0바이트입니다.
int.Parse·DateTime.Parse 를
비롯한 대부분의 파싱이 ReadOnlySpan<char> 를
받습니다. 자른 것을 문자열로 되돌릴 필요가 없습니다.
스택에 잡기
stackalloc 은 힙이 아니라 스택에
자리를 잡습니다. 메서드가 끝나면 함께 사라지므로 GC 가 볼 것이 없습니다.
Span<byte> buf = stackalloc byte[64];
| 방법 | 100만 번 | 만든 것 |
|---|---|---|
| stackalloc byte[64] | 33ms | 0바이트 |
| new byte[64] | 220ms | 43,519,784바이트 |
크기를 상수로 두고, 작게 잡으십시오. 스택은 좁습니다(보통 1MB). 받은 값으로 크기를 정하면 큰 값 하나에 프로그램이 죽습니다. 잡을 수 없을 때 예외가 아니라 그대로 무너지는 자리라 더 위험합니다.
// 이렇게 적지 마십시오 Span<byte> buf = stackalloc byte[size]; // size 가 밖에서 옵니다 // 한도를 두고 넘으면 힙으로 넘깁니다 Span<byte> buf = size <= 256 ? stackalloc byte[256] : new byte[size];
담기만 해도 만들어집니다
값 형식을 object 나 인터페이스에 담으면 힙에 자리를
잡습니다. 이것을 박싱이라고 합니다.
for (var i = 0; i < 1_000_000; i++) { object o = i; // 1번에 24바이트, 모두 24,000,000바이트 }
눈에 잘 띄지 않는 자리가 몇 군데 있습니다.
| 이럴 때 | 대신 |
|---|---|
| string.Format · 옛 로깅 | 보간 문자열. 컴파일러가 박싱을 피하도록 만들어 줍니다 |
| ArrayList · Hashtable | List<T>·Dictionary<K,V>(3.2) |
| 구조체를 인터페이스로 받기 | 제네릭 제약(where T : IShape)으로 받습니다 |
| Equals(object) 재정의만 | IEquatable<T> 도 함께 구현합니다 |
빌려 사용하고 돌려주기
큰 배열이 자주 필요하면 만들지 말고 빌립니다.
ArrayPool<T> 가 담아 두었다가 내줍니다.
var pool = ArrayPool<byte>.Shared; var buf = pool.Rent(4096); // 4096 "이상" 입니다. 더 클 수 있습니다. try { var span = buf.AsSpan(0, 4096); // 그래서 길이를 직접 자릅니다 … } finally { pool.Return(buf); // 돌려주지 않으면 빌린 뜻이 없습니다 }
돌려준 배열은 더 사용하지 마십시오. 다른 곳이 이미 빌려 갔을 수 있어, 남의 자료를 읽거나 덮어쓰게 됩니다. 이런 자리는 재현되지 않아 찾기 어렵습니다.
Span 을 둘 수 없는 자리
Span<T> 는 스택에만 살 수 있는
형식(ref struct)입니다. 스택이 사라지면
가리키던 자리도 사라지므로, 더 오래 살아남을 수 있는 곳에는 둘 수 없습니다.
// 필드로 둘 수 없습니다 class Holder { private Span<int> _span; } // error CS8345: 필드 또는 자동 구현 속성은 ref struct의 인스턴스 멤버인 // 경우 외에는 'Span<int>' 형식일 수 없습니다. // 비동기 메서드의 매개 변수가 될 수 없습니다 async Task Work(Span<int> s) => await Task.Delay(1); // error CS4012: 'Span<int>' 형식의 매개 변수는 비동기 메서드나 // 비동기 람다 식에서 선언할 수 없습니다. // object 에 담을 수 없습니다 object o = s; // error CS0029: 암시적으로 'System.Span<int>' 형식을 'object' 형식으로 // 변환할 수 없습니다.
반복기와 비동기 안에서는 경계를 넘을 때만 막힙니다. 넘기지 않고 그 자리에서 끝내면 됩니다.
Span<int> s = stackalloc int[4]; yield return s[0]; s[1] = 1; // 넘어서도 사용합니다 // error CS4007: 'await' 또는 'yield' // 경계를 넘어 … 보존할 수 없습니다.
Span<int> s = stackalloc int[4]; var v = s[0]; yield return v; // 됩니다
오래 담아 두어야 한다면 Memory<T> 입니다. 필드로
둘 수 있고 비동기도 넘어가며, 실제로 다룰 때
.Span 으로 꺼냅니다.
어디서 값을 하는가
| 이럴 때 | 어떻게 |
|---|---|
| 요청마다 한 번 | 하지 마십시오. 읽기 쉬운 쪽이 낫습니다 |
| 줄마다·항목마다 도는 파싱 | 여기가 Span 자리입니다 |
| 바이트를 다루는 자리 | Span·ArrayPool |
| 무엇이 느린지 모를 때 | 먼저 재십시오. 짐작한 자리는 대개 아닙니다 |
읽기 어려워지는 것이 대가입니다. 위 최소 예제의 아래쪽은 다섯 배 빠른 대신 무엇을 하는지 한눈에 보이지 않습니다. 그 값을 치를 자리인지는 재 보고 정하는 것이지 미리 정할 것이 아닙니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 7.2Span<T> · ref struct · readonly struct · in 매개 변수
- 8.0범위 문법 · stackalloc 을 식에서
- 13.0반복기·비동기 안에 ref struct 지역 변수
Span<T> 는 라이브러리 하나가 아니라
언어와 함께 들어왔습니다. 스택에만 살 수 있다는 규칙을 컴파일러가
지켜 주지 않으면 사라진 자리를 가리키게 되므로,
ref struct 라는 문법이 먼저 필요했습니다.
그 뒤로는 규칙을 지키면서 쓸 수 있는 자리를 넓혀 온 것이
변화의 전부입니다. C# 13이 반복기와 비동기 안에까지 들여놓았습니다.
직접 해보기
쉼표로 나뉜 줄에서 항목이 몇 개인지 세는 메서드를 작성해보세요.
Split 을 사용하지 말고, 한 바이트도 만들지
않아야 합니다.
아래 코드는 빌드되지 않습니다. 무엇 때문인지 설명하고, 담아 두어야 한다면 어떻게 고칠지 적어보세요.
class Buffer { private Span<byte> _data; public Buffer(int size) { Span<byte> buf = stackalloc byte[size]; _data = buf; } public byte First => _data[0]; }
- GC 가 느린 것이 아니라 자주 도는 것이 문제입니다. 만들지 않으면 돌 일이 없습니다.
Span<T>는 있는 자리를 가리킵니다. 잘라도 새로 만들지 않습니다.- 매개 변수는
ReadOnlySpan<T>로 받습니다. 문자열도 그대로 넘어옵니다. stackalloc은 크기를 상수로 두십시오. 밖에서 받으면 큰 값 하나에 무너집니다.- 값 형식을
object에 담으면 1번에 24바이트가 생깁니다. Span<T>는 필드가 될 수 없습니다. 담아 두려면Memory<T>입니다.- 먼저 재고 나서 고치십시오. 읽기 어려워지는 것이 대가입니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.