1. 소개
배열, 문자열, 바이트 버퍼를 다룰 때 데이터의 일부를 “봐야” 하는 상황이 자주 생깁니다. 예를 들어, 서브스트링을 떼어내거나 배열의 일부분을 처리하거나 들어오는 스트림의 일부를 다루는 경우죠. 옛 .NET 버전에서는 대개 데이터를 복사(새 배열/서브스트링 생성)하거나 원하는 인덱스부터 끝까지 루프를 도는 방식으로 처리해야 했습니다. 성능이나 가독성 측면에서 그다지 좋지 않습니다.
옛 방식의 예제: 큰 배열의 일부만 메서드에 전달하고 싶을 때:
// 옛 방식 — 배열의 일부를 복사함 (비효율적!)
int[] source = new int[] { 1, 2, 3, 4, 5, 6, 7, 8 };
int[] subArray = source.Skip(2).Take(4).ToArray(); // 새 배열이 생성됨
따라서 배열(또는 문자열의 일부)을 효율적으로 전달하면서 불필요한 객체를 만들지 않으려면, 기존의 C# 수단은 특히 큰 데이터에 대해 분명히 불리합니다.
여기서 등장하는 오늘의 주인공 — Span<T>!
2. Span<T> 란? 핵심 아이디어
Span<T> 는 동일한 타입 T 의 연속된 메모리 영역을 나타내는 타입입니다. 목적은 배열, 문자열 조각, 구조체, 그리고 네이티브(언매니지드) 메모리(예: .NET 관리 영역 밖에서 할당한 메모리)와 같은 데이터 조각을 빠르고 안전하며 효율적으로 다루게 하는 것입니다.
Span<T> 의 핵심 포인트는 “새 배열을 만들지 않는 슬라이스” 입니다. 배열의 어떤 구간을 복사하지 않고 측정 가능한 창(window)을 얻는 것이라고 생각하면 됩니다.
요약:
- Span<T> — 메모리의 일부를 편리하고 안전하게 조작할 수 있는 “윈도우” 또는 “뷰”입니다.
- 새 메모리를 할당하지 않음 — 자원 절약과 GC 부담 감소.
- 배열뿐 아니라 문자열 세그먼트, stackalloc 블록, 심지어 언매니지드 메모리와도 동작함.
- 일반 클래스의 필드에 저장할 수 없음: 스택 전용 타입(stack-only struct)입니다.
왜 중요한가?
고성능 작업(파일 파싱, 큰 버퍼 처리, 암호화, 직렬화)에서는 배열을 몇 번 복사하는 것만으로도 속도 저하와 GC 부담이 커질 수 있습니다. 몇 번의 복사를 줄이는 것만으로도 큰 성능 향상을 얻을 수 있고, 현대적인 C#/.NET 사용법을 알고 있다는 것을 보여줍니다.
3. 기본 사용법: 첫 번째 슬라이스
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// 배열의 일부에 대한 Span 생성 (예: 인덱스 2~5 포함)
Span<int> middle = new Span<int>(numbers, 2, 4); // 인덱스: 2, 3, 4, 5
// 부분 배열을 봅니다: 3, 4, 5, 6
Console.WriteLine(string.Join(", ", middle.ToArray())); // 3, 4, 5, 6
// Span을 수정하면 원본 배열도 변경됩니다!
middle[1] = 999;
Console.WriteLine(numbers[3]); // 999
중요! Span<T> 는 데이터를 복사하지 않고 단지 배열의 “부분”을 가리킵니다. 모든 변경은 원본 배열과 Span에 동시에 반영됩니다.
4. Span<T> 생성 주요 방법
배열 기반:
int[] arr = { 10, 20, 30, 40, 50 };
Span<int> span = arr; // 전체 길이
Span<int> slice = arr.AsSpan(1, 3); // 요소: 20, 30, 40
배열의 일부로부터:
Span<int> part = new Span<int>(arr, 2, 2); // 요소: 30, 40
stackalloc: 스택에 메모리 할당 (아주 빠르고 힙에 올라가지 않음):
Span<byte> buffer = stackalloc byte[128];
buffer[0] = 42;
.Slice() 메서드 사용:
Span<int> subSpan = span.Slice(1, 2); // 요소: 20, 30
슬라이스 시각적 표현
원래 배열: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
<--- Span: 3, 4, 5, 6 --->
5. Span<T> 의 제약과 특징
- Stack-only! 일반 클래스의 필드에 저장하거나 클로저의 일부로 사용하면 안 됩니다 — 스택 전용 타입입니다.
- 클래스 필드로 사용하거나 async 메서드에서 반환하면 안 됩니다(컴파일러 오류 발생).
- 람다/익명 메서드로 캡처할 수 없습니다 — “여기에서 바로 사용” 해야 합니다.
- 직렬화하거나 스레드 간에 직접 전달할 수 없습니다.
이유는 Span이 임의의 메모리 영역을 가리킬 수 있어서, 힙으로 이동하면 안전하지 않은 상태가 될 수 있기 때문입니다.
6. 불변 버전: ReadOnlySpan<T>
때로는 메모리의 일부를 읽기만 하고 수정하고 싶지 않을 때가 있습니다. 그런 경우 불변 버전인 ReadOnlySpan<T> 를 사용합니다.
string text = "Hello, Span!";
ReadOnlySpan<char> letters = text.AsSpan(7, 4); // 'S', 'p', 'a', 'n'
Console.WriteLine(string.Join(", ", letters.ToArray())); // S, p, a, n
// letters[0] = 'Z'; // 오류: 인덱서가 읽기 전용입니다!
일반적인 시나리오: 문자열이나 배열의 일부를 안전하게 전달할 때, 상대가 그 데이터를 변경하지 못하게 하려면 ReadOnlySpan을 씁니다.
7. 실습 예제: 배열과 문자열 “슬라이스” 하기
예를 들어, 긴 문자열에서 서브스트링을 떼어내어 그 안에서 숫자를 찾아 합을 구하는 분석기가 있다고 합시다(초기 슬라이스에서 불필요한 복사를 하지 않음).
using System;
class Program
{
static void Main()
{
// 가정: 사용자가 공백으로 구분된 긴 숫자 문자열을 입력함
string input = "12 34 56 78 90 123 456 789";
// 가운데 부분 예: "56 78 90" 에서만 숫자 합을 계산한다고 하자
// 서브스트링을 할당하지 않고 가져옴
ReadOnlySpan<char> center = input.AsSpan(6, 8); // 인덱스는 동적으로 계산 가능
// Split로 파싱(임시 배열이 생성됨)
string[] numbers = center.ToString().Split(' ');
int sum = 0;
foreach (var str in numbers)
{
if (int.TryParse(str, out int num))
sum += num;
}
Console.WriteLine($"중앙 숫자들의 합: {sum}");
}
}
현대의 CSV/JSON 파서 라이브러리들은 큰 문자열 데이터를 빠르게 처리하기 위해 Span을 사용합니다 — 이제 그 “비결”을 알게 된 셈입니다.
8. 유용한 팁
Span vs 배열 복사
// 옛 방식: 배열의 일부를 복사
int[] arr = Enumerable.Range(0, 1000000).ToArray();
int[] firstThousand = arr.Take(1000).ToArray(); // 1000개 요소의 새 배열 생성
// 새 방식: Span
Span<int> bestThousand = arr.AsSpan(0, 1000); // 전혀 복사하지 않음!
bestThousand[0] = 42; // arr에서도 변경됨
파일 파싱, 네트워크 버퍼 처리, 바이너리 데이터 작업에서 특히 차이가 큽니다.
실무 적용: 왜 Span을 알아야 하는가
- 고속 파싱과 문자열/바이너리 데이터 처리. 현대 직렬화 라이브러리(예: System.Text.Json, Span 문서)가 Span을 사용해 성능을 끌어올립니다.
- 버퍼링 및 파일 읽기(큰 버퍼를 복사 없이 분할).
- 메모리 제약이 있는 환경(embedded, IoT)에서 유용 — Memory/Spans 공식 문서.
- 이미지나 오디오 처리 같은 알고리즘에서 불필요한 할당을 줄이고 속도를 높임.
- CSV/JSON/XML 파싱 가속화 — .NET 8/9 에서 특히 유리합니다.
면접에서도 Span 관련 질문은 .NET Core 2.1+ 이후로 자주 나오기 시작했고, .NET 9 기준으로는 알고 있으면 유리합니다.
시각적 그림: Span 과 배열의 위치
+--------------------+
| int[] 배열 |
| 1 2 3 4 5 6 7 8 |
+--------------------+
^ ^
| |
[ 2, 3, 4, 5 ] <-- Span<int> "메모리 창" (slice)
Span<T> 은 별도의 배열이 아니라 데이터의 “투명한 렌즈”입니다.
다른 컬렉션과의 차이: 비교 표
| 타입 | 데이터를 저장하나요? | 요소를 변경할 수 있나요? | 크기를 변경할 수 있나요? | 슬라이스 시 복사하나요? | 어디에 존재하나요? |
|---|---|---|---|---|---|
|
예 | 예 | 아니요 | 예 (.Take 등으로) | Heap |
|
예 | 예 | 예 | 예 | Heap |
|
아니요 | 예 | 아니요 | 아니요 | Stack |
|
아니요 | 아니요 | 아니요 | 아니요 | Stack |
9. Span/ReadOnlySpan 사용 시 흔한 실수
실수 #1: Span 을 클래스 필드로 저장하려고 함. 컴파일러가 “Span type may not be used in this context” 라는 오류를 냅니다. 의도적으로 안전하지 않은 사용을 막는 것입니다.
실수 #2: Span 을 async 메서드에서 반환하려고 함. 안 됩니다 — async 메서드는 힙으로 이동할 수 있기 때문입니다. 대신 배열이나 다른 안전한 타입을 사용하세요.
실수 #3: Span을 통해 변경하면 원본 배열이 바뀐다는 걸 잊는 것. 외부에서 예기치 않은 데이터 변경이 발생할 수 있습니다.
GO TO FULL VERSION