[PART6.배열과 문자열 기본(4/14)] 범위·인덱스 연산자 — ^1과 1..3 한 글자에 숨은 GC 비용
arr[^1]은 단순 인덱싱으로 컴파일되지만 arr[1..3]은 새 배열을 만든다 / System.Index·System.Range라는 진짜 타입의 정체 / RuntimeHelpers.GetSubArray가 호출되는 순간 GC alloc / Span<T>로 받으면 zero-alloc / C# 13 객체 초기화자 안에서 ^ 신규 허용
목차
1. 한 글자 차이의 진짜 비용
C# 8.0이 도입한 인덱스 연산자(^)와 범위 연산자(..)는 코드를 짧게 만드는 가장 만족스러운 신문법 중 하나입니다.
char last = name[^1]; // 마지막 글자
string head = path[..separator]; // 구분자 앞부분
int[] mid = scores[1..^1]; // 처음·끝 한 개씩 빼기
name.Length - 1을 매번 적던 옛 코드와 비교하면 한눈에 가독성이 좋아집니다. 그런데 신입 개발자가 거의 모르는 사실 — 이 둘은 비용이 다릅니다. ^1 같은 인덱싱은 거의 무료지만, 1..^1 같은 범위 슬라이싱은 새 배열을 만들어 복사합니다. Update 안에 arr[1..^1] 한 줄을 박아 두면 매 프레임 GC alloc이 발생하는 것입니다.
void Update()
{
int[] mid = inputs[1..^1]; // ❌ 매 프레임 new int[] + 복사
ProcessMiddle(mid);
}
이 차이가 어디서 오는지, 어떻게 zero-alloc으로 같은 동작을 얻는지 — 그게 이 글의 주제입니다.
2. Index와 Range라는 진짜 타입
^는 System.Index 생성자 호출이다
^N은 컴파일러가 new Index(N, fromEnd: true) 로 변환합니다. 정수 N을 그냥 인덱스 자리에 쓰면 new Index(N, fromEnd: false)가 됩니다.
Index i1 = ^1; // = new Index(1, fromEnd: true)
Index i2 = 3; // = new Index(3, fromEnd: false) (정수 → Index 암시적 변환)
Index는 (int value, bool fromEnd) 두 필드만 가진 가벼운 값 타입(struct)입니다. 변수에 담아 두면 그 위에 GetOffset(length) 메서드를 호출해서 실제 인덱스를 계산하는 흐름이 됩니다.
..는 System.Range 생성자 호출이다
s..e는 컴파일러가 new Range(start: Index s, end: Index e) 로 변환합니다.
Range r1 = 1..3; // = new Range(1, 3)
Range r2 = ..3; // = Range.EndAt(3) ← 시작이 0이면 정적 팩토리 메서드
Range r3 = 2..; // = Range.StartAt(2) ← 끝이 ^0이면 정적 팩토리 메서드
Range r4 = ..; // = Range.All ← 둘 다 비면 미리 만들어진 정적 인스턴스
Range도 (Index Start, Index End) 두 필드의 값 타입입니다. 한쪽이 비어 있으면 컴파일러가 더 가벼운 정적 팩토리 메서드를 호출해 줍니다 — 의외로 new Range(...)보다 Range.StartAt(...)이 IL이 더 짧습니다.
컴파일러의 직접 인덱싱 최적화
좋은 소식 — arr[^1]처럼 상수 ^N을 배열에 직접 인덱싱하는 패턴은 컴파일러가 Index 객체를 만들지 않고 곧장 arr[arr.Length - N]으로 풀어 줍니다.
public static int Last(int[] xs) => xs[^1];
public static int FromEndN(int[] xs, int n) => xs[^n];
// xs[^1] — Index 객체 생성 없이 단순 인덱싱
IL_0000: ldarg.0 // xs
IL_0001: dup
IL_0002: ldlen // xs.Length
IL_0003: conv.i4
IL_0004: ldc.i4.1
IL_0005: sub // Length - 1
IL_0006: ldelem.i4 // xs[Length-1]
// xs[^n] — 변수도 마찬가지로 단순 빼기
IL_0001: dup
IL_0002: ldlen
IL_0003: conv.i4
IL_0004: ldarg.1 // n
IL_0005: sub // Length - n
IL_0006: ldelem.i4
xs[^1] 한 줄이 IL 4~5개 명령으로 끝나고, Index 객체는 어디에도 없습니다. 컴파일러가 알아서 xs[xs.Length - 1]로 풀어준 결과입니다 — 무료라고 봐도 됩니다.
다만 Index를 변수에 담아 쓰면 이야기가 달라집니다.
public static int IndexedDirect(int[] xs)
{
Index i = ^2;
return xs[i];
}
IL_0001: ldloca.s 0 // Index 변수 주소
IL_0003: ldc.i4.2
IL_0004: ldc.i4.1
IL_0005: call instance void System.Index::.ctor(int32, bool) // ← Index 객체(struct) 초기화
IL_000d: ldloca.s 0
IL_0010: ldlen
IL_0011: conv.i4
IL_0012: call instance int32 System.Index::GetOffset(int32) // ← 실제 인덱스 계산
IL_0017: ldelem.i4
변수에 담은 Index는 GetOffset(length) 메서드 호출이 끼어듭니다. 상수 ^N을 인라인으로 쓰면 무료, 변수로 받으면 메서드 호출. 이 차이를 알면 핫패스에서 어떻게 써야 할지 자연스럽게 결정됩니다.
3. 슬라이싱은 새 배열을 만든다
여기가 이 글의 핵심입니다.
배열의 arr[1..3]은 alloc이다
public static int[] Slice13(int[] xs) => xs[1..3];
IL_0001: ldc.i4.1
IL_0002: call valuetype System.Index System.Index::op_Implicit(int32) // 정수 → Index 변환
IL_0007: ldc.i4.3
IL_0008: call valuetype System.Index System.Index::op_Implicit(int32) // 정수 → Index 변환
IL_000d: newobj instance void System.Range::.ctor(Index, Index) // Range 객체 생성
IL_0012: call !!0[] System.Runtime.CompilerServices.RuntimeHelpers
::GetSubArray<int32>(!!0[], System.Range) // ← 새 배열 + 복사!
GetSubArray<T>가 호출됐다는 사실이 결정적입니다 — 이 메서드는 내부적으로 new T[length]로 새 배열을 잡고 원본 데이터를 복사합니다. 다음 그림이 그 차이를 보여줍니다.
![arr[1..3] 슬라이스 — 두 가지 받는 방식](https://blog.kakaocdn.net/dna/bQTBaK/dJMcadPk88q/AAAAAAAAAAAAAAAAAAAAAMdVaIXjpI1RTD2ooEvA-o93-iBdRfNjTpDOpBbGqaaw/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1780239599&allow_ip=&allow_referer=&signature=13HVlICauVumwa284gFNlSwpchE%3D)
Span<T>로 받으면 alloc 0
public static int SumSliceSpan(int[] xs)
{
ReadOnlySpan<int> mid = xs.AsSpan(1..^1); // ← 원본을 가리키는 뷰만 만든다
int sum = 0;
for (int i = 0; i < mid.Length; i++) sum += mid[i];
return sum;
}
AsSpan(Range)은 새 배열을 만들지 않고 시작 포인터 + 길이만 든 가벼운 struct를 돌려줍니다. 같은 1..^1 슬라이스를 int[]로 받느냐 ReadOnlySpan<int>으로 받느냐에 따라 GC 부담이 천지차이입니다.
| 받는 형태 | IL | 추가 alloc | 핫패스 적합? |
|---|---|---|---|
int[] mid = arr[1..^1]; |
RuntimeHelpers.GetSubArray<int> |
1 (새 배열) | ❌ 매 호출 alloc |
ReadOnlySpan<int> mid = arr.AsSpan(1..^1); |
MemoryExtensions.AsSpan |
0 (struct 슬롯뿐) | ✅ |
Span<int> mid = arr.AsSpan(1..^1); |
위와 동일, 쓰기 가능 | 0 | ✅ |
[..]은 더 단순하지만 여전히 새 배열
public static int[] All(int[] xs) => xs[..];
IL_0001: call valuetype System.Range System.Range::get_All() // 정적 인스턴스 (alloc 없음)
IL_0006: call !!0[] RuntimeHelpers::GetSubArray<int32>(...) // ← 그래도 새 배열!
Range.All은 정적 인스턴스라 객체 생성이 없지만, 결과로 받은 배열은 어차피 새 배열입니다. arr[..]은 사실상 (int[])arr.Clone()과 같은 동작 — 원본 보존이 목적이라면 OK이지만, 단순히 "전체를 읽고 싶다"가 의도라면 그냥 arr을 그대로 쓰면 됩니다.
4. 자주 쓰는 패턴
// 마지막 요소
arr[^1] // 단순 인덱싱 — 무료
// 마지막 N개 (끝부터 N개)
arr[^N..] // 새 배열 → Span으로 받으면 zero-alloc
arr.AsSpan(^N..) // 권장
// 처음 N개 빼고
arr[N..] // 새 배열 → Span 권장
arr.AsSpan(N..)
// 처음·끝 한 개씩 빼고 ('가운데')
arr[1..^1] // 새 배열 → Span 권장
arr.AsSpan(1..^1)
string도 같은 규칙이지만 항상 새 객체
string s = "Hello";
char last = s[^1]; // 'o' — 단순 인덱싱
string head = s[..3]; // "Hel" — 새 string!
string은 불변 타입이라 슬라이싱이 항상 새 string 객체를 만듭니다 — Span으로 우회해도 zero-alloc이 됩니다.
ReadOnlySpan<char> head = s.AsSpan(..3); // alloc 0 — 원본 string 위의 뷰
핫패스에서 부분 문자열만 살피면 되는 경우 ReadOnlySpan<char>으로 받으면 매번 새 string을 만들지 않을 수 있습니다.
List<T>는 인덱서만 지원, 슬라이스 안 됨
List<int> list = new() { 1, 2, 3, 4, 5 };
int last = list[^1]; // ✅ List<T>의 인덱서가 Index를 받음
List<int> mid = list[1..3]; // ❌ 컴파일 오류 — List는 Range 인덱서가 없다
List<T>는 Index를 받는 인덱서만 있고 Range 인덱서는 없습니다. 슬라이스가 필요하면 CollectionsMarshal.AsSpan(list)[1..3] 또는 list.GetRange(1, 2)(새 List 생성).
5. C# 13 신규 — 객체 초기화자 안에서 ^ 사용
C# 12 이전에는 객체 초기화자({ ... = ... }) 안에서 ^를 쓸 수 없었습니다. C# 13부터 가능해졌습니다.
// ❌ C# 12 이전 — 컴파일 오류
public class Buffer8
{
public int[] data = new int[8];
}
var b = new Buffer8
{
data = { [^1] = 99, [^2] = 88, [0] = 1 }
};
// ✅ C# 13부터 위 코드가 정상 컴파일
객체 초기화자 안에서 끝에서부터 차곡차곡 채우는 패턴 — 예를 들어 큐의 마지막 자리에 sentinel을 넣어 두는 패턴 — 이 한 줄로 표현됩니다. 이전엔 객체를 만든 뒤 별도 줄에서 b.data[b.data.Length - 1] = 99;라고 적어야 했습니다.
C# 13 신규는 컴파일러 변환 규칙의 확장이지 런타임 변경은 아니라, .NET 런타임 버전 요구사항은 따로 없습니다.
6. 함정과 주의사항
함정 1 — ^0은 끝 다음, 인덱싱하면 OOR
int[] xs = { 1, 2, 3 };
int? a = xs[^1]; // 3 (마지막 요소)
int? b = xs[^0]; // ❌ IndexOutOfRangeException
int[] all = xs[..^0]; // ✅ 전체 — 범위 끝으로는 OK
^N은 Length - N이므로 ^0 = Length. 인덱싱은 0..Length-1만 유효해서 ^0은 OOR. 범위 끝(a..^0)으로는 의미가 있지만, 인덱스로는 절대 쓰지 말 것.
함정 2 — arr[1..3]은 매 호출 GC alloc
// ❌ Update 안에서 매 프레임 새 배열
void Update()
{
int[] mid = inputs[1..^1]; // GC alloc!
Process(mid);
}
// ✅ Span으로 받기
void Update()
{
ReadOnlySpan<int> mid = inputs.AsSpan(1..^1);
Process(mid); // Process가 ReadOnlySpan을 받도록 시그니처 조정
}
신입이 ^/..의 가독성에 만족해서 무심코 arr[a..b]를 핫패스에 박아 두는 순간, 매 프레임 GC alloc이 시작됩니다. 슬라이스가 필요하면 거의 항상 AsSpan(...)로 받습니다 — 메서드 시그니처도 ReadOnlySpan<T>/Span<T>를 받도록 만드는 편이 안전합니다.
함정 3 — foreach (var x in arr[1..3])도 임시 배열을 만든다
foreach (var x in arr[1..3]) // ❌ 임시 배열 생성 (alloc!)
Console.WriteLine(x);
foreach (var x in arr.AsSpan(1..3)) // ✅ Span 슬라이스 위에서 직접 순회
Console.WriteLine(x);
foreach의 컬렉션 자리에 슬라이스 표현식을 쓰면 그 표현식 결과(=새 배열)가 임시 객체로 살아납니다. 일회용 순회라도 alloc이 발생하니 핫패스에서는 AsSpan(range)을 거쳐 순회해야 합니다.
함정 4 — 변수에 담은 Index는 메서드 호출 비용
// ❌ Index 변수가 GetOffset 호출을 만든다
Index i = ^1;
return xs[i];
// ✅ 인라인 ^1은 단순 인덱싱
return xs[^1];
위 IL 비교에서 봤듯이, 상수 ^N은 컴파일러가 단순 인덱싱으로 풀지만 변수에 담긴 Index는 GetOffset(int) 메서드 호출이 추가됩니다. 핫패스에서는 인라인으로 쓰는 편이 빠릅니다.
7. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 8.0 | ^ (Index)·.. (Range) 도입 / Span<T>도 동일 문법 지원 |
기본 |
| 8.0 | string[Range], Array[Range] 인덱서 추가 |
새 객체 알로케이션 발생 |
| 8.0 | List<T>[Index] 인덱서 추가 (Range는 미지원) |
|
| 13 | 객체 초기화자 안에서 ^ 사용 가능 |
새 패턴 한 줄 |
| 13 | (C# 13 일반) Span<T> 관련 ref struct 제약 일부 완화 |
PART 6-12 참조 |
C# 13의 ^ 객체 초기화자 확장은 새 런타임 기능이 아니라 컴파일러가 받아들이는 위치의 확장이므로, 기존 .NET 런타임에서도 그대로 동작합니다.
8. 정리
- [ ]
arr[^1]은 단순 인덱싱. 컴파일러가arr[arr.Length - 1]로 풀어주므로 무료. - [ ] 변수에 담은
Index는GetOffset(int)메서드 호출이 추가된다. 인라인 사용을 우선. - [ ]
arr[1..3]은 새 배열을 만든다 —RuntimeHelpers.GetSubArray<T>가 호출되어 alloc 발생. - [ ] 슬라이스 받는 형태로
Span<T>/ReadOnlySpan<T>(arr.AsSpan(1..3))을 쓰면 zero-alloc. - [ ]
arr[..]도 새 배열 — 단순히 전체를 읽고 싶으면 그냥arr을 쓰면 된다. - [ ]
^0은 인덱싱 시 OOR(=Length). 범위 끝으로만 사용. - [ ]
string도 슬라이스가 새 string 알로케이션 — 부분 검사만 한다면s.AsSpan(..). - [ ]
List<T>는Index인덱서만 지원.Range는 없으므로CollectionsMarshal.AsSpan(list)[range]우회. - [ ] 핫패스에서
arr[a..b]를 직접 받지 말고arr.AsSpan(a..b)—Update/FixedUpdateGC.Alloc 0의 출발점. - [ ]
foreach (var x in arr[a..b])도 임시 배열을 만든다. 순회도AsSpan으로. - [ ] C# 13부터 객체 초기화자 안에서
^사용 허용 —new T { buffer = { [^1] = 0 } }가능.
'C# 기초' 카테고리의 다른 글
| [PART6.배열과 문자열 기본(6/14)] `string`의 자주 쓰는 메서드 — 모든 호출이 새 객체를 만든다 (0) | 2026.05.01 |
|---|---|
| [PART6.배열과 문자열 기본(5/14)] 컬렉션 식 — 같은 `[1, 2, 3]`이 타입에 따라 다르게 컴파일된다 (0) | 2026.05.01 |
| [PART6.배열과 문자열 기본(3/14)] 배열 초기화·순회·Length — 가장 많이 쓰는 세 가지 작업 (0) | 2026.05.01 |
| [PART6.배열과 문자열 기본(2/14)] 다차원 배열과 들쭉날쭉 배열 — `int[,]`와 `int[][]`는 다른 동물이다 (0) | 2026.05.01 |
| [PART6.배열과 문자열 기본(1/14)] 1차원 배열 — 가장 단순하지만 가장 깊은 데이터 구조 (0) | 2026.05.01 |