C#LAB
C# 4.8 · 4부. 고급 문법

메모리와 성능

Span<T> 와 값 형식으로 메모리를 덜 사용하고 빠르게 합니다.

예상 학습 시간 22분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 고급
개념 설명

만들지 않는 것이 가장 빠릅니다

1.3에서 값 형식과 참조 형식이 담기는 방식이 다르다는 것을 보았습니다. 참조 형식을 만들면 힙에 자리를 잡고, 나중에 GC 가 치웁니다.

GC 는 빠릅니다. 문제는 치우는 일 자체가 아니라 자주 치워야 하는 것입니다. 만드는 것이 많으면 그만큼 자주 돌고, 도는 동안 프로그램은 잠깐씩 멈춥니다.

그래서 이 단원의 이야기는 하나로 모입니다. 같은 일을 하면서 만드는 것을 줄이는 법입니다. Span<T> 가 그 한가운데 있습니다.

먼저 재고 나서 고치십시오. 아래 방법들은 뜨거운 자리에서만 값을 합니다. 요청마다 한 번 도는 코드에서는 읽기 쉬운 쪽이 낫습니다.

최소 예제 · 편집 가능

같은 답, 다른 할당량

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
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]);
}
출력
152000096 592

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

둘은 같은 값을 돌려줍니다. 그런데 위쪽은 100만 번 도는 동안 152메가바이트를 만들었고, 아래쪽은 592바이트에 그쳤습니다. 100만 번으로 나누면 한 번에 0.0006바이트입니다. 도는 동안에는 아무것도 만들지 않고 처음 한 번만 잡았다는 뜻입니다.

아래 표는 같은 코드를 net10.0(Release) 콘솔에서 잰 것입니다. 절대값은 실행 환경에 따라 달라지지만 비율은 그대로입니다.

방법시간만든 것GC 0세대
Split + Parse67ms208,000,000바이트25회
Span + Parse14ms0바이트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]33ms0바이트
new byte[64]220ms43,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 · HashtableList<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
무엇이 느린지 모를 때먼저 재십시오. 짐작한 자리는 대개 아닙니다

읽기 어려워지는 것이 대가입니다. 위 최소 예제의 아래쪽은 다섯 배 빠른 대신 무엇을 하는지 한눈에 보이지 않습니다. 그 값을 치를 자리인지는 재 보고 정하는 것이지 미리 정할 것이 아닙니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
6.0
7.2
8.0
10.0
11.0
13.0
  • 7.2Span<T> · ref struct · readonly struct · in 매개 변수
  • 8.0범위 문법 · stackalloc 을 식에서
  • 13.0반복기·비동기 안에 ref struct 지역 변수
C# 7.2+

Span<T>라이브러리 하나가 아니라 언어와 함께 들어왔습니다. 스택에만 살 수 있다는 규칙을 컴파일러가 지켜 주지 않으면 사라진 자리를 가리키게 되므로, ref struct 라는 문법이 먼저 필요했습니다. 그 뒤로는 규칙을 지키면서 쓸 수 있는 자리를 넓혀 온 것이 변화의 전부입니다. C# 13이 반복기와 비동기 안에까지 들여놓았습니다.

실습 문제

직접 해보기

1. 만들지 않고 세기 난이도 하

쉼표로 나뉜 줄에서 항목이 몇 개인지 세는 메서드를 작성해보세요. Split 을 사용하지 말고, 한 바이트도 만들지 않아야 합니다.

항목 수는 쉼표 수보다 하나 많습니다. 문자열을 ReadOnlySpan<char> 로 받으면 부르는 쪽이 그냥 문자열을 넘길 수 있습니다.
static int Count(ReadOnlySpan<char> line) { if (line.IsEmpty) return 0; var n = 1; foreach (var c in line) if (c == ',') n++; return n; } // 부르는 쪽은 그대로 문자열을 넘깁니다 Count("2026-08-29,홍길동,32,서울"); // 4 // 매개 변수를 string 으로 두면 Span 을 넘길 수 없습니다. // 반대 방향은 자동으로 되므로 ReadOnlySpan 으로 받는 편이 넓습니다. // 세기만 한다면 이렇게도 됩니다 static int Count2(ReadOnlySpan<char> line) => line.IsEmpty ? 0 : line.Count(',') + 1;
2. 사라진 자리를 가리키는 코드 난이도 중

아래 코드는 빌드되지 않습니다. 무엇 때문인지 설명하고, 담아 두어야 한다면 어떻게 고칠지 적어보세요.

class Buffer {
    private Span<byte> _data;

    public Buffer(int size) {
        Span<byte> buf = stackalloc byte[size];
        _data = buf;
    }

    public byte First => _data[0];
}
생성자가 끝나면 그 스택은 사라집니다. 필드에 남은 것은 무엇을 가리키고 있겠습니까. 컴파일러가 그것을 미리 막습니다.
// error CS8345: 필드 또는 자동 구현 속성은 ref struct의 인스턴스 멤버인 // 경우 외에는 'Span<byte>' 형식일 수 없습니다. // 둘 다 틀렸습니다. // 1) Span 은 필드가 될 수 없습니다. 객체는 스택보다 오래 삽니다. // 2) stackalloc 크기를 밖에서 받고 있습니다. 큰 값이 오면 무너집니다. // 담아 두어야 하니 Memory 로 바꿉니다. 담을 곳은 힙이어야 합니다. class Buffer { private readonly Memory<byte> _data; public Buffer(int size) => _data = new byte[size]; public byte First => _data.Span[0]; } // 자주 만들고 버릴 것이라면 빌려 씁니다. class Buffer2 : IDisposable { private readonly byte[] _rented; private readonly int _length; public Buffer2(int size) { _rented = ArrayPool<byte>.Shared.Rent(size); _length = size; } public Span<byte> Data => _rented.AsSpan(0, _length); public void Dispose() => ArrayPool<byte>.Shared.Return(_rented); } // Span 은 속성으로 내주는 것까지는 됩니다. 담아 두지만 않으면 됩니다.
요약
  • GC 가 느린 것이 아니라 자주 도는 것이 문제입니다. 만들지 않으면 돌 일이 없습니다.
  • Span<T>있는 자리를 가리킵니다. 잘라도 새로 만들지 않습니다.
  • 매개 변수는 ReadOnlySpan<T> 로 받습니다. 문자열도 그대로 넘어옵니다.
  • stackalloc크기를 상수로 두십시오. 밖에서 받으면 큰 값 하나에 무너집니다.
  • 값 형식을 object 에 담으면 1번에 24바이트가 생깁니다.
  • Span<T> 는 필드가 될 수 없습니다. 담아 두려면 Memory<T> 입니다.
  • 먼저 재고 나서 고치십시오. 읽기 어려워지는 것이 대가입니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.