반응형

[PART4.제어문: 분기와 반복(9/13)] for 반복문 — 인덱스를 직접 다루는 반복

요약 for 반복문은 초기식·조건식·증감식 세 부분으로 반복을 정밀하게 제어하는 문법입니다. 핵심은 인덱스를 직접 다룬다는 점입니다. 첫 번째 요소부터 차례대로 읽기만 한다면 다음 글에서 다룰 foreach 가 더 깔끔하지만, 인덱스가 필요하거나 역방향으로 돌거나 두 변수를 동시에 움직이거나 반복 중에 컬렉션을 수정해야 한다면 for 가 정답입니다. Roslyn 컴파일러는 for"본문 먼저, 조건 검사는 끝에서" 패턴으로 IL 변환합니다. 매 반복마다 점프를 한 번만 쓰는 최적화입니다. 또한 배열의 arr.Lengthldlen 한 명령어로 인라인되지만, List<T>.Countcallvirt get_Count() 가상 호출이 매 반복 일어납니다 — 이 작은 차이가 핫패스에서는 누적됩니다. 함정 네 가지: ① 정방향 순회 중 RemoveAt 으로 인덱스 어긋남 (해결: 역방향 순회) ② foreach 중 컬렉션 수정 시 InvalidOperationException (해결: for 사용) ③ float 카운터의 누적 오차 (해결: int 카운터) ④ Unity transform.childCount 를 매 반복 호출 (해결: 로컬 변수 캐싱).


1. 들어가며 — 왜 인덱스가 필요한가

PART 4 의 앞 글들에서 if·switch·패턴 매칭으로 분기를 다뤘습니다. 이번 글부터는 같은 코드를 반복해서 실행하는 문법을 살펴봅니다. 그 시작이 for 입니다.

먼저 흔한 상황 하나입니다. 적의 체력 배열을 화면에 표시하는 코드입니다.

C#
int[] hps = { 100, 80, 60, 40 };
for (int i = 0; i < hps.Length; i++)
{
    Console.WriteLine($"Enemy[{i}].HP = {hps[i]}");
}

여기서 i 는 단순히 "지금 몇 번째인지" 가 아닙니다. 출력 라벨(Enemy[0], Enemy[1]...)에도 쓰이고, 배열 인덱싱(hps[i])에도 쓰입니다. 인덱스가 데이터의 일부입니다.

이 글의 다음 두 글에서 다룰 foreach·while 도 반복을 만드는 문법이지만, 인덱스를 직접 다루지 않습니다. foreach (int hp in hps) 로 쓰면 hp 만 받고 i 는 사라집니다. 그래서 위 같은 라벨이 필요한 코드는 for 로 쓰는 게 자연스럽습니다.

for 가 빛을 발하는 또 다른 상황은 인덱스 자체를 의도적으로 조작해야 할 때입니다.

  • 배열을 거꾸로 돌고 싶을 때 — 끝에서 0 으로
  • 두 인덱스를 동시에 움직이고 싶을 때 — i 는 앞에서, j 는 뒤에서
  • 반복 중에 항목을 삭제해야 할 때 — foreach 는 이걸 막지만 for 는 가능

이 세 가지가 이 글의 핵심입니다.

이 글에서 다룰 것 / 다루지 않을 것
  • 다룰 것: for 의 세 부분, 정방향·역방향 순회, 다중 변수 for, IL 변환, 배열 LengthList.Count 의 JIT 최적화 차이, 컬렉션 수정 패턴, Unity 핫패스 함정.
  • 다루지 않을 것: foreach 의 내부 동작(다음 글), while·do-while (그 다음 글). forforeach 의 선택 기준만 짧게 비교합니다.

2. 핵심 개념 — 세 부분으로 나뉜 반복

for 반복문은 세 부분과 본문(body)으로 이루어집니다.

for 반복문의 실행 흐름 — 세 부분이 언제 평가되는가

평가 순서를 한 줄로 요약하면 다음과 같습니다.

① 초기식 (한 번)  →  ② 조건식  →  ③ 본문  →  ④ 증감식  →  ② 조건식  →  ③ 본문  →  ④ 증감식  →  ...

중요한 함정: ④ 증감식은 본문이 끝난 다음 에 실행됩니다. continue 로 본문을 중간에 건너뛸 때도 증감식은 실행됩니다 — while 과 다른 점 중 하나입니다(다다음 글에서 다룹니다).

가장 단순한 예시 — 0~9 출력

C#
for (int i = 0; i < 10; i++)
{
    Console.WriteLine(i);  // 0, 1, 2, ..., 9
}
  • 초기식 int i = 0 : 루프 변수를 0 으로 시작
  • 조건식 i < 10 : i 가 10 미만일 때만 본문 실행
  • 증감식 i++ : 매 반복 끝에 i 를 1 증가

i < 10 이지 i <= 10 이 아닌 점에 주의하세요. 0 부터 9 까지 총 10 회 반복입니다. 이 한 글자 차이가 실무에서 자주 버그가 됩니다(섹션 5 함정 참조).

for 키워드 — 카운트 기반 반복문 초기식·조건식·증감식 세 부분을 세미콜론으로 구분해 한 줄에 적고, 인덱스를 명시적으로 제어하는 반복문이다. 세 부분 중 어떤 것도 생략 가능하며 for (;;) 는 무한 루프가 된다.
예시: for (int i = 0; i < arr.Length; i++) { ... } i 가 0 부터 arr.Length - 1 까지 1 씩 증가하며 본문을 실행

for vs foreach — 짧게만 비교

다음 글이 foreach 라서 자세히는 그쪽에서 다루지만, 선택 기준 만 정리합니다.

상황 권장
인덱스가 필요하다 (라벨, 배열 인덱싱, 두 인덱스 동시 사용) for
역방향·건너뛰기·반복 중 수정 for
인덱스 없이 모든 요소를 한 번씩 읽기만 한다 foreach
가독성이 우선이고 인덱스 안 쓴다 foreach

성능 차이는 현대 .NET 에서는 거의 없습니다. JIT 가 List<T> ·배열에 대한 foreach 도 충분히 최적화합니다. 그래서 인덱스가 필요한가 라는 의도 기준으로 고르는 게 깔끔합니다.


3. 내부 동작 — IL 로 풀어 본 for 반복문

for 반복문이 컴파일러를 거치면 어떤 IL 이 나오는지 봅시다. 이걸 이해하면 "왜 배열 Length 는 빠르고 List.Count 는 살짝 느린가" 같은 질문에 답할 수 있습니다.

3.1 정방향 for — Roslyn 의 "조건은 끝에서" 패턴

가장 단순한 정방향 합산 코드입니다.

C#
public static int SumArray(int[] arr)
{
    int sum = 0;
    for (int i = 0; i < arr.Length; i++)
    {
        sum += arr[i];
    }
    return sum;
}

이걸 릴리즈 빌드로 컴파일하면 IL 은 이렇게 나옵니다.

IL
.method public static int32 SumArray(int32[] arr) cil managed
{
    .locals init (
        [0] int32,    // sum
        [1] int32     // i
    )

    IL_0000: ldc.i4.0          // sum = 0
    IL_0001: stloc.0
    IL_0002: ldc.i4.0          // i = 0
    IL_0003: stloc.1
    IL_0004: br.s IL_0010      // 무조건 점프 → 루프 끝의 조건 검사로
    // loop start (head: IL_0010)
        IL_0006: ldloc.0       // sum 적재
        IL_0007: ldarg.0       // arr 적재
        IL_0008: ldloc.1       // i 적재
        IL_0009: ldelem.i4     // arr[i] (배열 요소 읽기)
        IL_000a: add           // sum + arr[i]
        IL_000b: stloc.0       // sum 에 저장

        IL_000c: ldloc.1       // i++
        IL_000d: ldc.i4.1
        IL_000e: add
        IL_000f: stloc.1

        IL_0010: ldloc.1       // 조건 검사: i 적재
        IL_0011: ldarg.0       // arr 적재
        IL_0012: ldlen         // arr.Length — 단일 IL 명령어 (인라인)
        IL_0013: conv.i4
        IL_0014: blt.s IL_0006 // i < length 면 본문(IL_0006)으로 점프
    // end loop

    IL_0016: ldloc.0           // return sum
    IL_0017: ret
}

이 IL 을 텍스트 흐름도로 그리면 이렇게 됩니다.

Roslyn 의 for 패턴 —

핵심은 두 가지입니다.

  1. br.s IL_0010 — 루프 진입 직후 무조건 점프해서 루프의 끝에 있는 조건 검사로 먼저 갑니다. 본문은 건너뛰고요.
  2. blt.s IL_0006 — 조건이 참이면 점프해서 본문으로 돌아갑니다. 거짓이면 그대로 떨어져 루프 종료입니다.

왜 이렇게 할까요? 매 반복에서 일어나는 점프 횟수를 줄이기 위해서입니다. "본문 → 조건 검사 → 본문으로 점프" 가 반복되는 구조라 매 반복에 점프가 한 번만 일어납니다. 소박하게 "조건 검사 → 본문 → 조건 검사로 점프" 로 짠다면 점프가 두 번 일어납니다(조건 검사로 돌아가는 점프 + 종료 시 점프). 컴파일된 코드 성능에서 점프 비용은 작지만, 핫패스에서 수억 번 도는 루프라면 누적 효과가 있습니다.

ldlen — 배열 길이 적재 IL 명령어 평가 스택의 맨 위에 있는 배열 참조의 Length 를 읽어 다시 스택에 올린다. CPU 명령 한 개에 가까운 비용이고, JIT 는 종종 루프 외부로 들어내(hoisting) 한 번만 읽도록 최적화한다.
예시: IL_0012: ldlenarr.Length 호출이 메서드 호출이 아닌 단일 IL 로 변환됨

3.2 List 버전 — callvirt get_Count 가 보인다

같은 합산을 List<int> 로 바꾸면 IL 이 미묘하게 달라집니다.

C#
public static int SumList(List<int> list)
{
    int sum = 0;
    for (int i = 0; i < list.Count; i++)
    {
        sum += list[i];
    }
    return sum;
}
IL
.method public static int32 SumList(class List`1<int32> list) cil managed
{
    .locals init (
        [0] int32,    // sum
        [1] int32     // i
    )

    IL_0000: ldc.i4.0
    IL_0001: stloc.0          // sum = 0
    IL_0002: ldc.i4.0
    IL_0003: stloc.1          // i = 0
    IL_0004: br.s IL_0014
    // loop start (head: IL_0014)
        IL_0006: ldloc.0
        IL_0007: ldarg.0
        IL_0008: ldloc.1
        IL_0009: callvirt instance !0 List`1<int32>::get_Item(int32)  // list[i] — 가상 호출
        IL_000e: add
        IL_000f: stloc.0

        IL_0010: ldloc.1
        IL_0011: ldc.i4.1
        IL_0012: add
        IL_0013: stloc.1                                              // i++

        IL_0014: ldloc.1
        IL_0015: ldarg.0
        IL_0016: callvirt instance int32 List`1<int32>::get_Count()   // list.Count — 매 반복 가상 호출
        IL_001b: blt.s IL_0006
    // end loop

    IL_001d: ldloc.0
    IL_001e: ret
}

배열 버전과 비교한 핵심 차이:

  배열 (int[]) 리스트 (List<int>)
길이 적재 ldlen (단일 명령어) callvirt get_Count() (가상 호출)
요소 적재 ldelem.i4 (단일 명령어) callvirt get_Item(int32) (가상 호출)
매 반복 IL 비용 작음 호출 두 번 (단, JIT 가 종종 인라인)
callvirt — 가상 메서드 호출 IL 호출 대상의 실제 타입에 따라 메서드를 디스패치한다. 직접 호출(call)보다 비용이 살짝 더 들지만 JIT 가 inline 가능한 경우(특히 List<T> 같은 봉인되지 않은 제네릭)는 사실상 직접 호출과 같은 속도가 된다.
예시: callvirt instance int32 List\1<int32>::get_Count()list.Count` 프로퍼티 호출

여기서 자주 나오는 질문: "그럼 List.Count 를 로컬 변수에 캐싱하는 게 빠른가?"

답: 현대 .NET 의 JIT 는 List<T>.get_Count 를 보통 인라인합니다. 그래서 차이가 거의 없습니다. 하지만 루프 본문 안에서 list 의 길이가 절대 바뀌지 않는다 는 걸 컴파일러가 증명하기 어려울 때(예: 가상 메서드 호출이 본문에 있을 때) 인라인은 되어도 호이스팅(루프 밖으로 한 번만 옮기기)은 안 됩니다. 핫패스에서는 다음 패턴이 더 안전합니다.

C#
int count = list.Count;  // 캐싱
for (int i = 0; i < count; i++)
{
    sum += list[i];
}

배열에서는 이런 캐싱이 불필요 합니다. JIT 가 ldlen 을 알아서 호이스팅하고 인덱스 범위 검사도 제거합니다. 가독성만 떨어지니 그냥 arr.Length 로 쓰세요.

3.3 역방향 for — 정방향과 거의 동일한 IL

C#
public static int SumArrayReverse(int[] arr)
{
    int sum = 0;
    for (int i = arr.Length - 1; i >= 0; i--)
    {
        sum += arr[i];
    }
    return sum;
}
IL
.method public static int32 SumArrayReverse(int32[] arr) cil managed
{
    IL_0000: ldc.i4.0
    IL_0001: stloc.0          // sum = 0
    IL_0002: ldarg.0
    IL_0003: ldlen
    IL_0004: conv.i4
    IL_0005: ldc.i4.1
    IL_0006: sub              // arr.Length - 1
    IL_0007: stloc.1          // i = arr.Length - 1
    IL_0008: br.s IL_0014     // 끝의 조건 검사로 점프
    // loop start (head: IL_0014)
        IL_000a: ldloc.0
        IL_000b: ldarg.0
        IL_000c: ldloc.1
        IL_000d: ldelem.i4    // arr[i]
        IL_000e: add
        IL_000f: stloc.0      // sum += arr[i]

        IL_0010: ldloc.1
        IL_0011: ldc.i4.1
        IL_0012: sub          // i--
        IL_0013: stloc.1

        IL_0014: ldloc.1
        IL_0015: ldc.i4.0
        IL_0016: bge.s IL_000a // i >= 0 면 본문으로
    // end loop

    IL_0018: ldloc.0
    IL_0019: ret
}

정방향과 비교하면 차이는 두 곳뿐입니다.

  • 증감식: add(정방향, +1) vs sub(역방향, -1)
  • 조건 분기: blt.s(less than) vs bge.s(greater or equal)

구조는 완전히 동일 합니다. 점프 패턴도, 호이스팅도, 인덱스 검사 제거도 똑같이 적용됩니다. 즉 역방향 for 는 정방향 대비 성능 페널티가 없습니다. 정방향만 빠를 거라는 미신은 사실이 아닙니다.

bge.s — branch if greater or equal (short form) 스택 맨 위 두 값을 비교해 첫 번째가 두 번째보다 크거나 같으면 분기한다. s 접미사는 점프 거리가 -128~127 바이트 범위라는 뜻이다. 비슷한 명령어로 blt.s(less than), ble.s(less or equal), bgt.s(greater than) 가 있다.
예시: IL_0016: bge.s IL_000ai >= 0 이 참이면 IL_000a 로 점프

3.4 다중 변수 for — 양방향 포인터의 IL

C#
public static void Reverse(char[] chars)
{
    for (int i = 0, j = chars.Length - 1; i < j; i++, j--)
    {
        char temp = chars[i];
        chars[i] = chars[j];
        chars[j] = temp;
    }
}
IL
.method public static void Reverse(char[] chars) cil managed
{
    .locals init (
        [0] int32,    // i
        [1] int32,    // j
        [2] char      // temp
    )

    IL_0000: ldc.i4.0
    IL_0001: stloc.0          // i = 0
    IL_0002: ldarg.0
    IL_0003: ldlen
    IL_0004: conv.i4
    IL_0005: ldc.i4.1
    IL_0006: sub
    IL_0007: stloc.1          // j = chars.Length - 1
    IL_0008: br.s IL_0020
    // loop start (head: IL_0020)
        IL_000a: ldarg.0
        IL_000b: ldloc.0
        IL_000c: ldelem.u2
        IL_000d: stloc.2      // temp = chars[i]

        IL_000e: ldarg.0
        IL_000f: ldloc.0
        IL_0010: ldarg.0
        IL_0011: ldloc.1
        IL_0012: ldelem.u2
        IL_0013: stelem.i2    // chars[i] = chars[j]

        IL_0014: ldarg.0
        IL_0015: ldloc.1
        IL_0016: ldloc.2
        IL_0017: stelem.i2    // chars[j] = temp

        IL_0018: ldloc.0
        IL_0019: ldc.i4.1
        IL_001a: add
        IL_001b: stloc.0      // i++

        IL_001c: ldloc.1
        IL_001d: ldc.i4.1
        IL_001e: sub
        IL_001f: stloc.1      // j--

        IL_0020: ldloc.0
        IL_0021: ldloc.1
        IL_0022: blt.s IL_000a // i < j 면 본문으로
    // end loop

    IL_0024: ret
}

다중 변수 for 는 IL 레벨에서 특별한 마법이 없습니다.

  • 초기식의 콤마: i = 0 다음에 j = chars.Length - 1 을 그냥 순서대로 실행합니다. 두 로컬이 따로 만들어집니다.
  • 증감식의 콤마: 본문 이후 i++j-- 를 순서대로 실행합니다.
  • 조건식: i < j — 두 로컬을 비교하므로 blt.s 한 번이면 됩니다.

즉 다중 변수 for 는 "두 줄짜리 초기식 + 두 줄짜리 증감식" 을 한 줄로 적는 문법 단축 일 뿐입니다. 컴파일러가 알아서 풀어 씁니다.

이 패턴은 양방향 포인터 알고리즘 에서 강력합니다. 위 Reverse 처럼 배열을 뒤집을 때, i 가 앞에서 자라고 j 가 뒤에서 줄어들면서 가운데서 만나면 끝납니다 — 한 번의 순회로 절반의 위치만 거치며 완료됩니다.


4. 실전 적용 — 컬렉션을 수정하면서 순회하기

forforeach 를 이기는 가장 결정적인 상황입니다. 반복 도중 컬렉션의 항목을 추가·삭제 해야 할 때입니다.

4.1 문제 상황 — 정방향 순회 + RemoveAt 의 인덱스 어긋남

C#
// ❌ 잘못된 패턴: 정방향으로 순회하며 짝수 제거
public static void RemoveEvens_Wrong(List<int> list)
{
    for (int i = 0; i < list.Count; i++)
    {
        if (list[i] % 2 == 0)
        {
            list.RemoveAt(i);
        }
    }
}

// 호출:
var nums = new List<int> { 1, 2, 4, 6, 7 };  // 짝수 3개 (2, 4, 6)
RemoveEvens_Wrong(nums);
// 결과: [1, 4, 7]  ← 4 가 남았다 (짝수인데도)

왜 4 가 남았을까요? RemoveAt(i) 가 호출되면 뒤의 모든 요소가 한 칸씩 앞으로 당겨집니다. 그런데 i++ 는 그대로 일어납니다.

정방향 RemoveAt 의 인덱스 어긋남 — [1, 2, 4, 6, 7]

순서를 따라가면 명확합니다.

반복 i 검사 대상 동작 결과 리스트
1 0 list[0]=1 홀수, 통과 [1, 2, 4, 6, 7]
2 1 list[1]=2 짝수, 제거 → 시프트 [1, 4, 6, 7]
3 2 list[2]=6 짝수, 제거 → 시프트 (4를 건너뜀) [1, 4, 7]
4 3 list[3]=7 이지만 길이가 3 → 종료 [1, 4, 7]

문제는 분명합니다. RemoveAt 으로 시프트가 일어나면 현재 인덱스에 있던 다음 요소 도 같은 인덱스로 이동합니다. 그런데 i++ 가 인덱스를 지나치면서 그 요소를 건너뜁니다.

4.2 해법 — 역방향 for 로 안전하게 제거

C#
// ✅ 올바른 패턴: 역방향으로 순회하며 짝수 제거
public static void RemoveEvens(List<int> list)
{
    for (int i = list.Count - 1; i >= 0; i--)
    {
        if (list[i] % 2 == 0)
        {
            list.RemoveAt(i);
        }
    }
}

var nums = new List<int> { 1, 2, 4, 6, 7 };
RemoveEvens(nums);
// 결과: [1, 7]  ← 정확

뒤에서부터 순회하면 RemoveAt(i) 의 시프트가 이미 검사한 인덱스 (i 보다 큰 위치) 에만 영향을 주고, 앞으로 검사할 인덱스 (i 보다 작은 위치) 는 그대로 유지됩니다. i-- 가 1 씩 줄어들면서 모든 요소를 정확히 한 번씩 검사합니다.

이 코드의 IL 도 살펴봅시다.

IL
.method public static void RemoveEvens(class List`1<int32> list) cil managed
{
    IL_0000: ldarg.0
    IL_0001: callvirt instance int32 List`1<int32>::get_Count()  // 시작 시 1회 호출
    IL_0006: ldc.i4.1
    IL_0007: sub
    IL_0008: stloc.0          // i = list.Count - 1
    IL_0009: br.s IL_0021     // 끝의 조건 검사로
    // loop start (head: IL_0021)
        IL_000b: ldarg.0
        IL_000c: ldloc.0
        IL_000d: callvirt instance !0 List`1<int32>::get_Item(int32)
        IL_0012: ldc.i4.2
        IL_0013: rem          // list[i] % 2
        IL_0014: brtrue.s IL_001d  // 결과가 0 이 아니면 (홀수면) 제거 건너뜀

        IL_0016: ldarg.0
        IL_0017: ldloc.0
        IL_0018: callvirt instance void List`1<int32>::RemoveAt(int32)  // 짝수면 제거

        IL_001d: ldloc.0
        IL_001e: ldc.i4.1
        IL_001f: sub
        IL_0020: stloc.0      // i--

        IL_0021: ldloc.0
        IL_0022: ldc.i4.0
        IL_0023: bge.s IL_000b  // i >= 0 면 본문으로
    // end loop

    IL_0025: ret
}

여기서 한 가지 주목할 점: 시작 시 list.Count 를 한 번만 호출해서 i 의 초기값으로 씁니다(IL_0001~IL_0008). 이후 루프 본문 안에서는 RemoveAt 으로 Count 가 계속 줄어들지만, 우리는 i 만 비교하기 때문에 신경 쓸 필요가 없습니다. 역방향 순회는 코드도, 멘탈 모델도, IL 도 깔끔합니다.

4.3 Unity 실전 — 오브젝트 풀에서 비활성 항목 제거

총알·이펙트 같은 풀링 오브젝트를 관리할 때 자주 나오는 패턴입니다. 활성 리스트에서 비활성화된 오브젝트만 골라 풀로 반환합니다.

C#
// ❌ 정방향 — 일부 오브젝트가 풀로 안 돌아간다
void Update()
{
    for (int i = 0; i < _activeBullets.Count; i++)
    {
        if (!_activeBullets[i].activeInHierarchy)
        {
            _pool.Return(_activeBullets[i]);
            _activeBullets.RemoveAt(i);  // 다음 요소를 건너뛴다
        }
    }
}

// ✅ 역방향 — 모든 비활성 오브젝트가 정확히 한 번씩 처리된다
void Update()
{
    for (int i = _activeBullets.Count - 1; i >= 0; i--)
    {
        if (!_activeBullets[i].activeInHierarchy)
        {
            _pool.Return(_activeBullets[i]);
            _activeBullets.RemoveAt(i);
        }
    }
}

Unity 모바일 게임에서는 비활성화된 풀 오브젝트가 한 프레임에 수십 개씩 발생할 수 있습니다. 정방향 순회로 짜면 매 프레임 절반쯤만 회수되고, 회수되지 못한 오브젝트가 다음 프레임으로 누적되면서 풀이 점점 비대해집니다. 메모리 누수처럼 보이지만 실제로는 알고리즘 버그 입니다.

4.4 Unity 실전 — transform.childCount 캐싱

자식 Transform 을 순회할 때 transform.childCountC++ 엔진 코드를 호출하는 프로퍼티 입니다. JIT 가 매니지드 코드를 인라인하듯 인라인하지 못합니다(엔진 경계 통과). 핫패스 (Update, FixedUpdate) 에서 매 반복 호출하면 누적 비용이 발생합니다.

C#
// ❌ childCount 를 매 반복 호출
void Update()
{
    for (int i = 0; i < transform.childCount; i++)
    {
        transform.GetChild(i).Rotate(0, Time.deltaTime * 30f, 0);
    }
}

// ✅ 한 번 캐싱
void Update()
{
    int count = transform.childCount;
    for (int i = 0; i < count; i++)
    {
        transform.GetChild(i).Rotate(0, Time.deltaTime * 30f, 0);
    }
}

(GetChild(i) 도 마찬가지로 엔진 호출이지만 인덱스로만 접근 가능하기에 캐싱이 어렵습니다. childCount 만이라도 캐싱하는 게 표준 관행입니다.)

추정: transform.childCount 의 정확한 호출 비용은 Unity 버전·플랫폼·IL2CPP 여부에 따라 다릅니다. 위 권고는 Unity Profiler 측정 시 핫패스에서 의미 있는 차이가 보이는 상황에 한해 적용하면 됩니다. 한 프레임에 수만 번 도는 루프가 아니라면 가독성 손실이 더 클 수 있습니다.

5. 함정과 주의사항

5.1 함정 1 — <<= 한 글자 차이 (off-by-one)

C#
int[] arr = new int[10];

// ❌ IndexOutOfRangeException
for (int i = 0; i <= arr.Length; i++)
{
    arr[i] = i;  // i=10 일 때 폭발
}

// ✅ 정확
for (int i = 0; i < arr.Length; i++)
{
    arr[i] = i;
}

배열 인덱스는 0 부터 Length - 1 까지입니다. i <= arr.Length 는 마지막 반복에서 i = arr.Length 가 되어 한 칸 너머를 건드립니다. 반복문에서 발생하는 가장 흔한 런타임 오류 1 위 입니다.

표준 패턴은 외워두세요.

C#
for (int i = 0; i < arr.Length; i++)            // 0 부터 length-1 까지
for (int i = arr.Length - 1; i >= 0; i--)       // length-1 부터 0 까지

5.2 함정 2 — float 카운터의 누적 오차

C#
// ❌ 부동소수점 누적 오차
for (float t = 0.0f; t != 1.0f; t += 0.1f)
{
    Console.WriteLine(t);
}
// 0.1, 0.2, 0.3, ..., 0.9 까지 가다가 t 가 정확히 1.0 이 안 됨
// → 무한 루프

// ✅ 정수 카운터 + 본문에서 실수 변환
for (int i = 0; i <= 10; i++)
{
    float t = i * 0.1f;  // 또는 Mathf.Lerp 등
    Console.WriteLine(t);
}

0.1f 는 이진 부동소수점으로 정확히 표현할 수 없는 수입니다(2 진수로는 0.0001100110011… 의 무한 반복). 매 반복 누적된 오차가 결국 1.0f 와 정확히 같지 않게 만들고, t != 1.0f 가 영원히 참이 됩니다. 무한 루프입니다.

규칙: 반복 카운터는 항상 정수. 실수 값이 필요하면 정수 인덱스를 본문에서 변환하세요.

5.3 함정 3 — foreach 중 컬렉션 수정 시도

for 의 우월성을 보여주는 반례입니다.

C#
var list = new List<int> { 1, 2, 3, 4, 5 };

// ❌ InvalidOperationException
foreach (int x in list)
{
    if (x % 2 == 0)
    {
        list.Remove(x);  // 컬렉션이 변경됨 — 다음 MoveNext() 에서 예외
    }
}

foreach 는 내부적으로 IEnumerator 를 사용하고, 이 enumerator 는 컬렉션의 _version 필드를 모니터링합니다. 컬렉션이 변경되면 _version 이 바뀌고, MoveNext() 가 이를 감지해 InvalidOperationException 을 던집니다. 안전 장치이지 버그가 아닙니다.

C#
// ✅ for 역방향으로 해결 (4.2 와 동일)
for (int i = list.Count - 1; i >= 0; i--)
{
    if (list[i] % 2 == 0)
    {
        list.RemoveAt(i);
    }
}

for 는 enumerator 를 쓰지 않고 인덱스로 직접 접근하므로 _version 검사가 없습니다. 인덱스만 잘 관리하면 자유롭게 수정할 수 있습니다.

5.4 함정 4 — 무한 루프와 i 변경 누락

C#
// ❌ 증감식이 잘못된 무한 루프
for (int i = 0; i < 10; i--)  // i++ 로 써야 하는데 i--
{
    Console.WriteLine(i);  // 0, -1, -2, ... 영원히
}

// ❌ 본문에서 i 를 의도치 않게 다시 0 으로
for (int i = 0; i < arr.Length; i++)
{
    if (arr[i] < 0)
    {
        i = 0;  // ← 음수를 만나면 처음부터 다시? — 무한 루프 가능
        continue;
    }
}

방향이 반대이거나 본문에서 인덱스를 만지면 무한 루프가 만들어집니다. 본문 안에서 i 를 직접 수정하는 건 할 수 있지만 가독성이 매우 나쁩니다. 그렇게 해야 하는 로직이라면 while 로 다시 쓰는 게 보통 더 명확합니다.


6. C# 버전별 변화

for 반복문 자체는 C# 1.0 부터 거의 변하지 않은 기본 문법입니다. 다만 반복 표현의 대안 이 시간이 지나며 늘었습니다.

C# 버전 변화 영향
1.0 (2002) for 도입 (C 에서 그대로) 기본
2.0 (2005) foreach + IEnumerator<T> 인덱스 불필요 시 대안
3.0 (2007) LINQ — Where, Select 함수형 스타일 변환·필터 가능
8.0 (2019) Range/Index (.., ^) 슬라이싱으로 부분 순회 가능
13.0 (2024) params Span<T>/ReadOnlySpan<T> 확장 메모리 효율적 순회

for 의 IL 변환 자체는 컴파일러 (Roslyn) 가 진화하며 미세하게 최적화되어 왔지만, 우리가 보는 패턴 ("br → 본문 → 조건 검사 → blt") 은 거의 동일하게 유지되고 있습니다.

가장 큰 실용적 변화는 C# 8 의 Range/Index 입니다. 부분 순회 시 for 인덱스를 직접 다룰 필요가 줄었습니다.

C#
int[] arr = { 10, 20, 30, 40, 50 };

// 기존: for 로 끝 두 개만 순회
for (int i = arr.Length - 2; i < arr.Length; i++)
{
    Console.WriteLine(arr[i]);  // 40, 50
}

// C# 8 이후: Range 로 슬라이스 후 foreach
foreach (int x in arr[^2..])
{
    Console.WriteLine(x);  // 40, 50
}

단, 슬라이싱은 새 배열을 할당합니다(arr[^2..] 는 길이 2 의 새 배열). Unity 핫패스에서는 for 로 인덱스를 직접 도는 게 GC 측면에서 여전히 유리합니다. 가독성이 중요하면 슬라이싱, 성능이 중요하면 for 인덱스 가 단순한 기준입니다.


7. 정리

이번 글에서 다룬 핵심을 한눈에:

  • for 의 세 부분: 초기식(한 번), 조건식(매 반복 시작), 증감식(매 반복 끝). 본문 → 증감식 → 조건식 → 본문 순서.
  • 선택 기준: 인덱스가 필요하다 / 역방향이다 / 두 변수 동시 조작 / 반복 중 컬렉션 수정 → for. 그 외 단순 순회 → foreach (다음 글).
  • IL 패턴: Roslyn 은 "본문 먼저, 조건 검사는 끝에서" 로 컴파일. 매 반복 점프 1 회. br.s → 본문 → 증감 → blt.s/bge.s 로 본문 재진입.
  • 배열 vs 리스트: 배열 Lengthldlen 단일 IL — 캐싱 불필요. List<T>.Countcallvirt get_Count() — JIT 가 보통 인라인하지만 핫패스라면 로컬 캐싱이 안전.
  • 역방향 for: for (int i = arr.Length - 1; i >= 0; i--). 정방향과 IL 구조 동일, 성능 페널티 없음.
  • 컬렉션 수정 패턴: 정방향 RemoveAt 은 인덱스가 어긋난다. 항상 역방향 으로 돌아라. foreach 중 수정은 InvalidOperationException 이지만 for 는 enumerator 를 안 써서 가능.
  • 다중 변수 for: for (int i = 0, j = arr.Length - 1; i < j; i++, j--) 는 양방향 포인터 알고리즘에 적합. IL 레벨에서는 두 로컬 + 콤마로 묶인 두 줄.
  • 함정 체크리스트: ① < vs <= (off-by-one) ② float 카운터 누적 오차 ③ foreach 중 수정 (for 로 전환) ④ Unity transform.childCount 매 반복 호출 (캐싱).

다음 글에서는 foreach 의 내부 동작 — IEnumerator<T>, MoveNext(), Current, 그리고 foreach 가 만드는 IL 과 boxing 함정 — 을 살펴봅니다.

반응형

+ Recent posts