[PART6.배열과 문자열 기본(3/14)] 배열 초기화·순회·Length — 가장 많이 쓰는 세 가지 작업
자동 zero-init과 명시적 채우기(Fill·Repeat·Span.Fill)의 차이 / for와 foreach가 IL에서 어떻게 같아지는가 / Length는 IL 한 명령어로 끝나는 무료 호출 / Array.Sort·IndexOf·Find·Clear의 실전 패턴 / Unity 핫패스에서 매 호출 새 배열 vs Array.Clear로 재사용
목차
1. 왜 이 셋을 한 번에 봐야 하는가
배열은 만들고 → 채우고 → 돌리고 → 길이를 묻는 네 가지 동작이 거의 전부입니다. 그런데 신입이 자주 만드는 비효율 코드 — 60FPS 게임에서 매 프레임 GC 스파이크를 만드는 코드 — 는 이 네 가지 중 어느 한 군데에서 시작되곤 합니다.
// Update() 안에서 매 프레임 실행
int[] active = enemies.Where(e => e.IsActive).Select(e => e.Id).ToArray(); // ❌ 매 프레임 새 배열
foreach (int id in active) DrawHealthBar(id);
Array.Resize(ref active, active.Length + 1); // ❌ 또 새 배열
위 세 줄에는 배열 새로 만들기 두 번, LINQ 임시 객체 여러 개, foreach 한 번이 들어 있습니다. 같은 동작을 GC 부담 없이 하려면 어떻게 해야 하는지 — 그리고 for/foreach/Length 각각이 IL에서 정확히 무슨 비용을 가지는지 — 가 이 글의 주제입니다.
2. 초기화 — 자동 zero-init이 이미 빠르다
비유 — 새 노트의 모든 페이지가 백지인 이유
새 노트를 사면 모든 페이지가 비어 있습니다. 페이지마다 "이 페이지는 비었다"라는 도장을 찍을 필요가 없습니다 — 공장에서 만들 때 한 번에 백지로 만들어진 거니까요. CLR(Common Language Runtime — .NET 코드를 실행하는 가상 머신)이 배열을 만들 때도 같은 일을 합니다. newarr 명령은 메모리 블록을 잡은 뒤 CPU 명령 한 번으로 그 블록 전체를 0으로 청소합니다. 값 타입은 0(또는 false), 참조 타입은 null이 즉시 박혀 있습니다.
네 가지 채우기 비교
// (a) 자동 zero-init — newarr가 알아서 0으로 클리어
int[] a = new int[5]; // {0,0,0,0,0}
// (b) Array.Fill — 0이 아닌 값으로
int[] b = new int[5];
Array.Fill(b, 7); // {7,7,7,7,7}
// (c) Enumerable.Repeat + ToArray — LINQ 경유 (할당이 더 많다)
int[] c = Enumerable.Repeat(7, 5).ToArray(); // {7,7,7,7,7}
// (d) Span<T>.Fill — 부분만 채울 때
int[] d = new int[10];
d.AsSpan(2, 4).Fill(99); // {0,0,99,99,99,99,0,0,0,0}
// AutoZero — newarr 한 줄로 끝
IL_0000: ldc.i4.5
IL_0001: newarr [System.Runtime]System.Int32 // 메모리 클리어 자동 포함
IL_0006: ret
// FilledNew — newarr + Array.Fill 호출
IL_0001: ldc.i4.5
IL_0002: newarr [System.Runtime]System.Int32
IL_0007: stloc.0
IL_0009: ldc.i4.7
IL_000a: call void [System.Runtime]System.Array::Fill<int32>(!!0[], !!0) // 제네릭 정적 메서드 호출
// WithRepeat — IEnumerable + ToArray로 우회
IL_0002: call IEnumerable`1<!!0> System.Linq.Enumerable::Repeat<int32>(...) // 이터레이터 객체 생성
IL_0007: call !!0[] System.Linq.Enumerable::ToArray<int32>(...) // 그 결과를 배열로 다시 모음
// FillSlice — Span 슬라이싱 + Fill (할당 없이 부분만)
IL_000c: call Span`1<!!0> MemoryExtensions::AsSpan<int32>(...) // 원본을 가리키는 Span만 만듦
IL_0016: call instance void Span`1<int32>::Fill(!0)
핵심 비교:
| 패턴 | 추가 객체 | 비고 |
|---|---|---|
new int[N] (zero-init) |
0개 | 가장 빠름. 0이 기본값이면 추가 작업 불필요. |
new int[N] + Array.Fill(arr, v) |
0개 | 0이 아닌 값으로 채울 때. CPU memset 수준 빠름. |
Enumerable.Repeat(v, N).ToArray() |
이터레이터 + 배열 | LINQ 임시 객체가 더 생긴다. 핫패스 금지. |
arr.AsSpan(s, l).Fill(v) |
0개 | 부분 채우기 / 기존 배열 재사용 시. |
값 타입의 기본값(0, false)이 그대로 시작값으로 쓰일 수 있다면 별도 채우기는 필요 없습니다. "초기화하지 않으면 위험하다"는 C 시절의 직감은 C#에서는 맞지 않습니다 — CLR이 이미 청소해 준 상태입니다.
참조 타입 배열의 함정 —Enumerable.Repeat로 같은 인스턴스 N번 ``csharp List<int>[] groups = Enumerable.Repeat(new List<int>(), 5).ToArray(); groups[0].Add(1); Console.WriteLine(groups[1].Count); // 1! 모두 같은 List 객체를 가리킨다`참조 타입을Repeat로 채우면 모든 칸이 **같은 한 객체**를 가리킵니다. 독립된 인스턴스가 필요하면Enumerable.Range(0, 5).Select(_ => new List<int>()).ToArray()`처럼 매번 새로 만들어야 합니다.
3. 순회 — for와 foreach는 같은 IL로 컴파일된다
IEnumerator를 안 만든다는 진실
신입 때 자주 듣는 말 — "foreach는 IEnumerator를 만들기 때문에 느리다." 일반 컬렉션에서는 사실이지만, 배열에 한해서는 컴파일러가 foreach를 인덱스 기반 for로 다시 써 줍니다. 이건 추측이 아니라 IL에 그대로 박혀 있습니다.
public static int SumFor(int[] xs)
{
int sum = 0;
for (int i = 0; i < xs.Length; i++) sum += xs[i];
return sum;
}
public static int SumForeach(int[] xs)
{
int sum = 0;
foreach (int x in xs) sum += x;
return sum;
}
// SumFor 핵심
IL_000a: ldelem.i4 // xs[i] 로드
IL_0013: ldlen // xs.Length 가져오기 (단일 명령)
IL_0014: conv.i4
IL_0015: clt // i < Length
// SumForeach 핵심 — IEnumerator 흔적이 없다
IL_000c: ldelem.i4 // xs[i] 로드 (foreach인데도)
IL_0018: ldlen // xs.Length (foreach인데도)
IL_0019: conv.i4
IL_001a: blt.s IL_000a
두 메서드가 ldelem.i4(int 요소 로드)·ldlen(길이 가져오기)·인덱스 증가의 같은 패턴을 돕니다. 배열 한정으로는 for와 foreach 사이의 성능 차이가 사실상 0이라고 봐도 됩니다 — 가독성이 더 좋은 쪽을 고르면 됩니다.
이 동등성은 컴파일 타임에 타입이 정확히 배열로 확정될 때만 적용됩니다. 같은 데이터를 IEnumerable<int>로 받으면 컴파일러는 IEnumerator<int>를 만들어 돌릴 수밖에 없습니다 — 이 경우 alloc이 발생하니 핫패스에서는 인자 타입을 배열로 명시하는 편이 안전합니다.
인덱스 + 요소가 동시에 필요한 경우
// foreach만 쓰면 인덱스를 못 받음 — 인덱스가 필요하면 for
for (int i = 0; i < xs.Length; i++)
Console.WriteLine($"[{i}] {xs[i]}");
// 또는 LINQ Select((x, i) => ...) — 핫패스에서는 LINQ 알로케이션 주의
foreach (var (x, i) in xs.Select((x, i) => (x, i)))
Console.WriteLine($"[{i}] {x}");
신입이 자주 만드는 안티패턴 — foreach로 돌면서 별도 카운터 변수를 같이 두는 코드 — 는 그냥 for로 쓰면 됩니다. 인덱스가 필요한 순간엔 for가 가장 깔끔합니다.
4. Length — IL 한 명령어로 끝나는 무료 호출
ldlen 단일 명령어
arr.Length는 메서드 호출이 아닙니다. IL에서 ldlen이라는 배열 전용 단일 명령어로 처리되며, 이는 SZ 배열(Single-dimensional Zero-based — 1차원 0 시작 배열)에 대한 CLR의 특별 대접 중 하나입니다.

for (int i = 0; i < arr.Length; i++)처럼 루프 조건에서 매번 Length를 평가해도 호출 비용은 0에 가깝습니다. 오히려 이 정형 패턴이 JIT(Just-In-Time — 실행 직전에 IL을 기계어로 변환하는 컴파일러)의 BCE(Bounds Check Elimination — 범위 검사 제거) 최적화를 발동시키는 트리거이므로, 루프 안에 박아 두는 게 정답입니다.
다차원 배열은 다릅니다 — int[,].GetLength(d)는 진짜 메서드 호출이고 BCE도 잘 듣지 않습니다. (자세한 내용은 PART 6-2 "다차원 배열과 들쭉날쭉 배열"에서 다룹니다.)
Length vs Count vs Count()
| 형태 | 멤버 | IL | 비용 |
|---|---|---|---|
int[] |
arr.Length |
ldlen |
≈ 0 |
string |
s.Length |
callvirt String::get_Length() |
메서드 호출 1회 |
int[,] |
g.GetLength(d) |
callvirt Array::GetLength(int) |
메서드 호출 1회 |
List<T> |
list.Count |
callvirt List::get_Count() |
메서드 호출 1회 |
IEnumerable<int> |
seq.Count() |
call Enumerable::Count<int>(...) |
전수 카운트 — N에 비례! |
IEnumerable<T>.Count()(LINQ)는 시퀀스를 처음부터 끝까지 세는 메서드입니다. 배열·List<T>로 캐스팅 가능하면 길이를 그대로 쓰지만, 일반 시퀀스라면 전수 순회가 일어납니다. 큰 배열을 IEnumerable<int>로 받아서 .Count()를 부르는 건 신입의 흔한 함정 — 그냥 인자 타입을 int[]로 바꿔 Length를 쓰는 편이 N배 빠릅니다.
5. 자주 쓰는 Array 정적 메서드
int[] scores = { 88, 95, 72, 100, 64 };
// 정렬 (in-place — 원본 변경)
Array.Sort(scores); // {64, 72, 88, 95, 100}
// 복사 후 정렬 (원본 보존)
int[] sortedCopy = (int[])scores.Clone();
Array.Sort(sortedCopy);
// 검색
int idx = Array.IndexOf(scores, 88); // 2 (선형 탐색, 못 찾으면 -1)
int high = Array.Find(scores, s => s >= 90); // 95 (조건 만족 첫 요소)
int hidx = Array.FindIndex(scores, s => s >= 90);// 3
// 뒤집기
Array.Reverse(scores); // in-place
// 일정 범위 0/false/null로 초기화
Array.Clear(scores, startIndex: 0, length: 3);
// 다른 배열로 복사
int[] dst = new int[10];
Array.Copy(scores, sourceIndex: 0, dst, destinationIndex: 2, length: scores.Length);
// 길이 변경 — 사실상 새 배열 + 복사
Array.Resize(ref scores, 8); // 늘어난 칸은 자동으로 0
| 메서드 | 동작 | 추가 알로케이션 |
|---|---|---|
Sort |
in-place | 0 (작은 보조 메모리만) |
Reverse |
in-place | 0 |
IndexOf / Find / FindIndex |
검색만 | 0 (Find는 람다 클로저 주의) |
Clear |
범위 zero-init | 0 |
Copy |
복사 | 0 (대상 배열 별도) |
Resize |
새 배열 생성 + 복사 | 1 (원본 + 새 배열) |
Clone |
새 배열 생성 + 복사 | 1 |
Sort·Reverse·Clear·Copy는 모두 추가 힙 할당이 없는 in-place 또는 in-target 작업입니다. 반대로 Resize·Clone은 새 배열을 만들기 때문에 핫패스에서는 피해야 합니다.
Array.Find의 람다는 컴파일러가 클로저로 만들 수 있습니다 — 외부 변수를 캡처하면 클로저 객체가 힙에 할당됩니다. 핫패스에서는 람다 대신 직접 for로 도는 게 안전합니다.
6. 실전 적용 — Unity 핫패스에서 매 프레임 alloc 0 만들기
Before/After: 매 호출 새 배열 vs 캐시 + Array.Clear
// ❌ Before — 매 호출 새 임시 배열
public static int CountVisibleAlloc(int[] flags, int n)
{
int[] visible = new int[n]; // newarr 매 호출
int count = 0;
for (int i = 0; i < n; i++)
if (flags[i] != 0) visible[count++] = i;
return count;
}
// ✅ After — 정적 캐시 배열 + Array.Clear로 재사용
private static readonly int[] _visible = new int[256];
public static int CountVisibleNonAlloc(int[] flags, int n)
{
Array.Clear(_visible, 0, n); // 0으로 재초기화 (CPU memset 수준 빠름)
int count = 0;
for (int i = 0; i < n; i++)
if (flags[i] != 0) _visible[count++] = i;
return count;
}
// CountVisibleAlloc — newarr가 본문에 박힘
IL_0001: ldarg.1
IL_0002: newarr [System.Runtime]System.Int32 // ← 매 호출 GC alloc
// CountVisibleNonAlloc — newarr 없음
IL_0001: ldsfld int32[] ArrayBasicsDemo.HotPath::_visible
IL_0007: ldarg.1
IL_0008: call void [System.Runtime]System.Array::Clear(class System.Array, int32, int32)
// ← 메서드 호출만, 새 객체 생성 X
본문에 newarr가 있느냐 없느냐 — 이게 핫패스 GC 부담의 출발점입니다. Array.Clear는 메서드 호출 한 번이지만 새 객체를 만들지 않고, 내부적으로 CPU의 빠른 메모리 클리어 명령으로 구현됩니다.
Unity 모바일 GC 특수성
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환한 뒤 네이티브 코드로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터, .NET CoreCLR이 쓰는 세대별 GC와 달리 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라, 한 번 GC가 돌 때 화면이 멈추는 시간이 데스크톱·서버 .NET보다 길게 느껴집니다. 매 프레임 호출되는 코드에서 newarr를 한 번만 빼도 GC 주기가 분명히 늘어납니다 — 그게 Unity Profiler의 GC.Alloc 컬럼이 0인지 확인하는 이유입니다.
List<T>.Clear() vs new List<T>()
배열 외에 List<T>도 같은 패턴이 적용됩니다.
// ❌ 매 프레임 새 List
List<int> targets = new List<int>();
foreach (var e in enemies) if (e.IsTarget) targets.Add(e.Id);
// ✅ 캐시 List + Clear()로 재사용
private static readonly List<int> _targets = new List<int>(64);
void Update()
{
_targets.Clear();
foreach (var e in enemies) if (e.IsTarget) _targets.Add(e.Id);
}
List<T>.Clear()는 내부 배열을 그대로 두고 길이만 0으로 만듭니다 — Capacity가 그대로라 다음 Add에서도 새 배열을 만들지 않습니다.
7. 함정과 주의사항
함정 1 — Array.Sort는 원본을 바꾼다
int[] data = { 3, 1, 2 };
SortAndPrint(data);
Console.WriteLine(data[0]); // 1! 원본도 정렬됨
void SortAndPrint(int[] arr) {
Array.Sort(arr); // ← in-place
Console.WriteLine(string.Join(",", arr));
}
배열은 참조 타입이므로 메서드에 넘기면 같은 객체가 넘어갑니다. Array.Sort는 새 배열을 만드는 게 아니라 받은 배열을 그대로 정렬해 버려, 호출자의 배열 상태가 바뀝니다. 원본 보존이 필요하면 (int[])arr.Clone() 후 정렬해야 합니다.
함정 2 — foreach 안에서 요소 수정 불가
int[] xs = { 1, 2, 3 };
foreach (int x in xs)
x *= 2; // ❌ 의미 없음 — x는 복사본
foreach의 반복 변수는 요소의 복사본(또는 읽기 전용 참조)입니다. 변수에 새 값을 대입해도 원본이 바뀌지 않습니다. 요소를 바꾸려면 for로 돌면서 xs[i] = ...로 직접 인덱싱해야 합니다.
함정 3 — foreach 도중 길이 변경 금지
List<int> xs = new() { 1, 2, 3 };
foreach (int x in xs)
if (x == 2) xs.Remove(x); // InvalidOperationException — Collection was modified
List<T>나 다른 컬렉션을 foreach로 돌면서 동시에 수정하면 런타임 예외가 납니다. (배열은 Remove가 없어 다른 형태로 깨지지만, 일반적으로 순회 도중 길이를 바꾸는 건 안전하지 않습니다.) 안전한 패턴은 인덱스 for로 뒤에서부터 돌거나, 별도 결과 배열을 만드는 것입니다.
함정 4 — arr.Length를 ()로 부르려 하기
arr.Length // ✅ 프로퍼티
arr.Length() // ❌ 컴파일 오류
arr.Count() // ⚠️ LINQ Count() — N에 비례하는 비용이 들 수 있음
신입이 string.Length·List.Count·IEnumerable.Count()를 헷갈려 자주 만드는 실수입니다. 배열에는 항상 괄호 없는 Length.
8. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | Length, Array.Sort/Copy/Reverse/Clear 등 기본 정적 메서드 |
기본 |
| 2.0 | 제네릭 — Array.Find/FindIndex<T>(Predicate<T>) 도입 |
람다 친화 |
| 3.0 | LINQ — Where/Select/ToArray/Count/ForEach |
단, 핫패스에선 alloc 주의 |
| 7.2 | Span<T>.Fill / AsSpan — 부분 채우기·복사 zero-alloc |
성능 패턴 정착 |
| 8.0 | ^1·1..3 인덱스/범위 연산자 — arr[^1] (마지막 요소) |
가독성 향상 |
Span<T> 도입(C# 7.2) 이전엔 부분 배열을 다루려면 새 배열을 만들거나 인덱스+길이를 매번 손으로 넘겨야 했습니다. 지금은 arr.AsSpan(start, length).Fill(value) 한 줄로 끝나고 추가 할당이 없어, 임시 버퍼를 재사용하는 핫패스 코드가 훨씬 단순해졌습니다. 인덱스/범위 연산자(^1, 1..3)는 PART 6-4에서 따로 다룹니다.
9. 정리
- [ ]
new int[N]은 자동 zero-init된다 — 값 타입 0, 참조 타입 null. 별도 채우기 불필요한 경우가 많다. - [ ] 0이 아닌 값으로 채울 때는
Array.Fill(전체) 또는arr.AsSpan(s, l).Fill(v)(부분). 둘 다 추가 alloc 0. - [ ]
Enumerable.Repeat(...).ToArray()는 LINQ 임시 객체가 더 생긴다 → 핫패스 금지. - [ ] 참조 타입을
Repeat로 채우면 모든 칸이 같은 인스턴스를 가리킨다. 독립이 필요하면Range(...).Select(_ => new ...). - [ ]
for와foreach는 배열에서 IL이 거의 같다 —IEnumerator안 만든다. 가독성으로 고른다. - [ ] 인덱스가 필요하면
for.foreach + 카운터같은 안티패턴은 그냥for로 통일. - [ ]
arr.Length는 ILldlen단일 명령 — 호출 비용 0. 루프 조건에서 매번 평가해도 OK이며, 오히려 BCE 트리거. - [ ]
IEnumerable<T>.Count()(LINQ)는 N에 비례한 비용. 큰 배열에는 인자 타입을int[]로 받아Length. - [ ] 정적 메서드 중 추가 alloc 없는 것:
Sort,Reverse,IndexOf,Find,Clear,Copy. 새 배열을 만드는 것:Resize,Clone. - [ ] 핫패스: 매 호출
new int[]→ 정적 캐시 배열 +Array.Clear로 재사용. 본문에newarrIL이 있으면 의심. - [ ]
Array.Sort는 원본을 바꾼다. 보존이 필요하면Clone후 정렬. - [ ]
foreach반복 변수에 대입해도 원본이 안 바뀐다. 수정은for + 인덱싱. - [ ] Unity 모바일은 IL2CPP + Boehm GC 조합이라 GC 한 번이 길다 —
GC.Alloc컬럼을 0으로 유지하는 게 60FPS의 출발점.
'C# 기초' 카테고리의 다른 글
| [PART6.배열과 문자열 기본(5/14)] 컬렉션 식 — 같은 `[1, 2, 3]`이 타입에 따라 다르게 컴파일된다 (0) | 2026.05.01 |
|---|---|
| [PART6.배열과 문자열 기본(4/14)] 범위·인덱스 연산자 — `^1`과 `1..3` 한 글자에 숨은 GC 비용 (0) | 2026.05.01 |
| [PART6.배열과 문자열 기본(2/14)] 다차원 배열과 들쭉날쭉 배열 — `int[,]`와 `int[][]`는 다른 동물이다 (0) | 2026.05.01 |
| [PART6.배열과 문자열 기본(1/14)] 1차원 배열 — 가장 단순하지만 가장 깊은 데이터 구조 (0) | 2026.05.01 |
| [PART5.메서드와 매개변수(18/18)] 메서드를 매개변수로 넘기기 — 콜백의 첫 소개 (0) | 2026.04.28 |