반응형

[PART6.배열과 문자열 기본(3/14)] 배열 초기화·순회·Length — 가장 많이 쓰는 세 가지 작업

자동 zero-init과 명시적 채우기(Fill·Repeat·Span.Fill)의 차이 / forforeach가 IL에서 어떻게 같아지는가 / Length는 IL 한 명령어로 끝나는 무료 호출 / Array.Sort·IndexOf·Find·Clear의 실전 패턴 / Unity 핫패스에서 매 호출 새 배열 vs Array.Clear로 재사용


1. 왜 이 셋을 한 번에 봐야 하는가

배열은 만들고 → 채우고 → 돌리고 → 길이를 묻는 네 가지 동작이 거의 전부입니다. 그런데 신입이 자주 만드는 비효율 코드 — 60FPS 게임에서 매 프레임 GC 스파이크를 만드는 코드 — 는 이 네 가지 중 어느 한 군데에서 시작되곤 합니다.

C#
// 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이 즉시 박혀 있습니다.

네 가지 채우기 비교

C#
// (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}
IL
// 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. 순회 — forforeach는 같은 IL로 컴파일된다

IEnumerator를 안 만든다는 진실

신입 때 자주 듣는 말 — "foreachIEnumerator를 만들기 때문에 느리다." 일반 컬렉션에서는 사실이지만, 배열에 한해서는 컴파일러가 foreach를 인덱스 기반 for로 다시 써 줍니다. 이건 추측이 아니라 IL에 그대로 박혀 있습니다.

C#
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;
}
IL
// 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(길이 가져오기)·인덱스 증가의 같은 패턴을 돕니다. 배열 한정으로는 forforeach 사이의 성능 차이가 사실상 0이라고 봐도 됩니다 — 가독성이 더 좋은 쪽을 고르면 됩니다.

이 동등성은 컴파일 타임에 타입이 정확히 배열로 확정될 때만 적용됩니다. 같은 데이터를 IEnumerable<int>로 받으면 컴파일러는 IEnumerator<int>를 만들어 돌릴 수밖에 없습니다 — 이 경우 alloc이 발생하니 핫패스에서는 인자 타입을 배열로 명시하는 편이 안전합니다.

인덱스 + 요소가 동시에 필요한 경우

C#
// 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의 특별 대접 중 하나입니다.

arr.Length 호출 비용 비교

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 정적 메서드

C#
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

C#
// ❌ 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;
}
IL
// 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>도 같은 패턴이 적용됩니다.

C#
// ❌ 매 프레임 새 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는 원본을 바꾼다

C#
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 안에서 요소 수정 불가

C#
int[] xs = { 1, 2, 3 };
foreach (int x in xs)
    x *= 2;                    // ❌ 의미 없음 — x는 복사본

foreach의 반복 변수는 요소의 복사본(또는 읽기 전용 참조)입니다. 변수에 새 값을 대입해도 원본이 바뀌지 않습니다. 요소를 바꾸려면 for로 돌면서 xs[i] = ...로 직접 인덱싱해야 합니다.

함정 3 — foreach 도중 길이 변경 금지

C#
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()로 부르려 하기

C#
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 ...).
  • [ ] forforeach는 배열에서 IL이 거의 같다IEnumerator 안 만든다. 가독성으로 고른다.
  • [ ] 인덱스가 필요하면 for. foreach + 카운터 같은 안티패턴은 그냥 for로 통일.
  • [ ] arr.Length는 IL ldlen 단일 명령 — 호출 비용 0. 루프 조건에서 매번 평가해도 OK이며, 오히려 BCE 트리거.
  • [ ] IEnumerable<T>.Count()(LINQ)는 N에 비례한 비용. 큰 배열에는 인자 타입을 int[]로 받아 Length.
  • [ ] 정적 메서드 중 추가 alloc 없는 것: Sort, Reverse, IndexOf, Find, Clear, Copy. 새 배열을 만드는 것: Resize, Clone.
  • [ ] 핫패스: 매 호출 new int[] → 정적 캐시 배열 + Array.Clear로 재사용. 본문에 newarr IL이 있으면 의심.
  • [ ] Array.Sort는 원본을 바꾼다. 보존이 필요하면 Clone 후 정렬.
  • [ ] foreach 반복 변수에 대입해도 원본이 안 바뀐다. 수정은 for + 인덱싱.
  • [ ] Unity 모바일은 IL2CPP + Boehm GC 조합이라 GC 한 번이 길다 — GC.Alloc 컬럼을 0으로 유지하는 게 60FPS의 출발점.
반응형

+ Recent posts