Menu

C# foreach 반복문: 배열, List, Dictionary, 인덱스와 오류

C#에서 foreach가 동작하는 방식: 배열, 리스트, 문자열, 딕셔너리(KeyValuePair와 분해) 순회, 인덱스 얻기, 루프 변수가 읽기 전용인 이유, "Collection was modified" InvalidOperationException과 해결법, foreach에 쓰려면 타입에 무엇이 필요한지 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

foreach는 관리할 인덱스 없이 컬렉션의 모든 요소에 대해 순서대로 본문을 한 번씩 실행합니다:

출력:

Welcome, Ava
Welcome, Noah
Welcome, Mia
Total: 255
[C][#][7]

루프는 배열, List<T>, 문자열(요소 타입 char), HashSet<T>, LINQ 쿼리 등 어떤 시퀀스에서든 똑같이 동작합니다. 선언하는 타입은 var로 쓸 수 있으며, 요소 타입이 길 때 흔히 씁니다: foreach (var order in pendingOrders).

foreach가 내부에서 하는 일

foreach는 컬렉션에 열거자를 요청하고 이를 따라 걸어갑니다. List<int>의 경우 컴파일러는 루프를 대략 다음과 같이 바꿉니다:

List<int>.Enumerator e = scores.GetEnumerator();
try
{
    while (e.MoveNext())
    {
        int score = e.Current;
        // loop body
    }
}
finally
{
    e.Dispose();
}

여기서 세 가지 결과가 나옵니다. public GetEnumerator() 메서드가 있는 타입이면 무엇이든 동작하며, 이 메서드는 보통 IEnumerable<T>를 구현해서 제공합니다. 본문이 예외를 던지거나 break해도 열거자는 dispose됩니다. 그리고 요소 변수는 매번 e.Current의 새 복사본이므로, 여기에 대입해도 컬렉션이 바뀔 수 없습니다.

GetEnumerator가 없는 타입은 쓸 수 없습니다:

error CS1579: foreach statement cannot operate on variables of type 'Order' because 'Order' does not contain a public instance or extension definition for 'GetEnumerator'

타입에 IEnumerable<T>를 구현하거나, 타입이 담고 있는 컬렉션을 순회하세요(order 대신 order.Lines). yield return으로 직접 시퀀스를 만들 수도 있으며, IEnumerable과 yield 페이지에서 다룹니다.

Dictionary 순회하기

딕셔너리의 요소는 Key와 Value 속성을 가진 KeyValuePair<TKey, TValue> 값입니다:

출력:

apples: 12
pears: 0
kiwis: 30
<apples><pears><kiwis>
Units in stock: 42
 30 kiwis
 12 apples
  0 pears

Dictionary는 어떤 열거 순서도 약속하지 않습니다. 실제로는 항목을 제거하기 전까지 삽입 순서를 따르지만, 순서에 의존하는 코드는 마지막 루프가 OrderByDescending으로 하듯 명시적으로 정렬하거나 SortedDictionary를 쓰세요.

.NET Core 2.0 이상(과 모든 최신 .NET)에서는 KeyValuePair에 Deconstruct 메서드가 있으므로, 루프 헤더에서 바로 쌍을 두 변수로 나눌 수 있습니다:

// modern .NET: KeyValuePair deconstruction
foreach (var (fruit, count) in stock)
{
    Console.WriteLine($"{fruit}: {count}");
}

가장 짧은 형태이며 현재 코드에서 보게 되는 형태입니다. KeyValuePair에 Deconstruct가 없는 .NET Framework에서는 컴파일되지 않으므로, 거기서는 item.Key와 item.Value를 쓰세요.

인덱스 얻기

foreach에는 자체 인덱스가 없습니다. 인덱스를 얻는 보통의 방법은 직접 관리하는 카운터, 또는 각 요소와 함께 인덱스를 넘겨주는 LINQ의 Select 오버로드입니다:

출력:

1. Kenji
2. Sofia
3. Omar
index 0: Kenji
index 1: Sofia
index 2: Omar

카운터가 가장 저렴하고 명확합니다. 인덱스로 podium[i - 1]을 읽거나 podium[i] = ...을 쓰고 있다면, 바로 그 용도로 만들어진 for 루프로 바꾸세요.

루프 변수는 읽기 전용입니다

반복 변수에는 대입할 수 없습니다:

foreach (string name in names)
{
    name = name.Trim();
    // error CS1656: Cannot assign to 'name' because it is a 'foreach iteration variable'
}

허용된다 해도 지역 복사본만 바뀔 뿐입니다. 모든 요소를 변환하려면 새 시퀀스를 만들거나(names.Select(n => n.Trim()).ToArray()), names[i] = names[i].Trim()을 쓰는 for 루프를 쓰세요.

할 수 있는 일은 요소가 클래스인지 구조체인지에 따라 다릅니다. 클래스라면 변수가 참조를 담고 있으므로, 속성을 바꾸면 컬렉션 안의 객체가 바뀝니다:

출력:

Order 1: shipped
Order 2: shipped

Order가 struct였다면 같은 대입이 CS1654(Cannot modify members of 'order' because it is a 'foreach iteration variable')로 실패합니다. 변수가 값에 대한 참조가 아니라 값의 복사본이기 때문입니다.

컬렉션 수정하기: InvalidOperationException

foreach가 List나 Dictionary를 도는 동안 요소를 추가하거나 제거하면 열거자가 깨집니다. 다음 MoveNext가 "Collection was modified; enumeration operation may not execute." 메시지와 함께 InvalidOperationException을 던집니다:

출력:

InvalidOperationException: collection was modified
milk, bread
milk, bread

간단한 것부터 가장 유연한 것까지의 해결법:

  • List<T>의 **RemoveAll(predicate)**는 일치하는 항목을 한 번에 모두 제거합니다.
  • ToList()나 ToArray()로 복사본을 순회한 뒤 원본을 자유롭게 수정합니다.
  • 먼저 모으고 나중에 적용: 루프 동안 삭제할 키를 별도의 리스트에 모았다가 루프 뒤에 제거합니다. Dictionary에서 흔히 쓰는 패턴입니다.
  • RemoveAt(i)를 쓰는 거꾸로 가는 for 루프.

Dictionary에는 알아 둘 예외가 하나 있습니다. .NET Core 3.0부터는 foreach로 도는 동안 기존 키의 값을 덮어쓰거나(stock[key] = 0) 항목을 제거해도 더 이상 예외가 나지 않지만, 새 키를 추가하면 여전히 예외가 납니다. .NET Framework에서는 세 가지 모두 예외를 던지므로, 키를 먼저 모으는 방식이 어디서나 동작하는 버전입니다.

break, continue와 null 컬렉션

break와 continue는 다른 모든 루프에서와 똑같이 동작합니다:

출력:

reading 21
reading 23
no tags, no crash

null 컬렉션을 순회하면 GetEnumerator를 호출할 때 NullReferenceException이 발생합니다. ?? Enumerable.Empty<T>()는 null일 수 있는 시퀀스를 빈 시퀀스로 바꿉니다. 더 좋은 방법은 직접 만든 메서드에서 null 대신 빈 컬렉션을 반환하는 것입니다.

foreach와 List.ForEach

List<T>에는 ForEach(Action<T>) 메서드가 있습니다: names.ForEach(n => Console.WriteLine(n));. 이것은 루프와 같지 않습니다. 본문이 람다이므로 break, continue, yield를 쓸 수 없고, return은 현재 요소에 대한 호출만 끝냅니다. 배열이나 다른 시퀀스가 아니라 List<T>에만 있습니다. foreach 문을 우선 쓰고, ForEach는 이미 메서드로 가지고 있는 한 줄짜리 동작에만 쓰세요.

자주 묻는 질문

C#에서 foreach로 Dictionary를 순회하려면 어떻게 하나요?

각 요소는 KeyValuePair<TKey, TValue>입니다: foreach (var pair in prices) Console.WriteLine($"{pair.Key}: {pair.Value}");. .NET Core 2.0 이상에서는 분해할 수 있습니다: foreach (var (name, price) in prices). 키나 값만 순회하려면 prices.Keys나 prices.Values를 쓰세요.

C# foreach 루프에서 인덱스는 어떻게 얻나요?

foreach에는 기본 제공 인덱스가 없습니다. 카운터를 두거나(루프 전에 int i = 0;, 본문 끝에 i++), LINQ로 각 요소를 인덱스와 함께 투영하세요: foreach (var item in names.Select((name, index) => new { name, index })). 인덱스로 다시 쓰거나 이웃과 비교해야 한다면 for 루프가 더 잘 맞습니다.

"Collection was modified; enumeration operation may not execute"는 무슨 뜻인가요?

foreach가 List, Dictionary 등의 컬렉션을 열거하는 동안 항목을 추가하거나 제거했다는 뜻입니다. 열거자가 변경을 감지하고 다음 단계에서 InvalidOperationException을 던집니다. 복사본을 순회하거나(foreach (var x in list.ToList())), 변경 사항을 모아 두었다가 루프 뒤에 적용하거나, list.RemoveAll(...)을 쓰거나, 거꾸로 가는 for 루프를 쓰세요.

C#에서 foreach 변수에 대입할 수 없는 이유는 무엇인가요?

반복 변수는 읽기 전용입니다. foreach (string name in names) 안의 name = name.Trim();은 CS1656 오류입니다. 대입할 수 있다 해도 컬렉션은 바뀌지 않습니다. 요소를 바꾸려면 인덱스가 있는 for 루프를 쓰거나 Select로 새 컬렉션을 만드세요. 클래스 객체의 속성을 바꾸는 것(order.Status = ...)은 허용됩니다.

C#에서 foreach 루프를 break로 빠져나갈 수 있나요?

네. break는 루프를 즉시 끝내고 continue는 다음 요소로 건너뛰며, for나 while과 똑같습니다. return은 메서드 전체를 빠져나갑니다. List<T>.ForEach 메서드는 다릅니다. 본문이 람다이므로 거기서는 break와 continue를 쓸 수 없습니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기