LINQ의 지연 실행과 ToList()는 왜 필요할까?
오늘 이터레이터와 LINQ를 배우고 Select()와 Where()를 이용해 이름을 필터링하는 실습을 진행했다.
실습 중 LINQ로 처리한 결과를 다음과 같이 ToList()로 변환하는 코드가 있었다.
var result = nameList.GetNames(minLength).ToList();
처음에는 GetNames()가 이미 결과를 반환하는데 굳이 ToList()를 호출해야 하는 이유가 궁금했다.
이를 이해하기 위해 오늘 배운 IEnumerable<T>, 이터레이터와 LINQ의 지연 실행 개념을 다시 살펴보았다.
1. IEnumerable과 LINQ
IEnumerable<T>는 데이터를 순차적으로 열거할 수 있도록 하는 인터페이스다.
foreach는 내부적으로 GetEnumerator()를 통해 열거자를 얻고, MoveNext()와 Current를 사용하여 요소를 하나씩 읽는다.
오늘 배운 yield return은 이러한 열거자 동작을 직접 구현하지 않고도 값을 하나씩 반환할 수 있도록 도와주는 문법이다. LINQ의 Where(), Select() 역시 이러한 열거 방식을 활용한다.
실습에서는 다음과 같이 구현했다.
public IEnumerable<string> GetNames(int nameLength)
{
return names
.Take(count)
.Select((name, index) => new { Name = name.Trim(), Index = index + 1 })
.Where(x => x.Name.Length >= nameLength)
.Select((x, index) => $"{index + 1}. 원래 {x.Index}: {x.Name}");
}
Take(): 실제 저장된 요소만 가져온다.Select(): 이름을 가공하고 기존 인덱스를 저장한다.Where(): 최소 이름 길이 조건을 만족하는 요소만 남긴다.- 마지막
Select(): 필터링된 결과에 새로운 순번을 부여한다.
여기서 중요한 것은 이 코드가 실행되는 순간 모든 데이터가 바로 처리되는 것은 아니라는 점이다.
2. LINQ의 지연 실행(Deferred Execution)
Where()와 Select()는 일반적으로 지연 실행 방식으로 동작한다.
int[] numbers = { 1, 2, 3, 4, 5 };
var query = numbers.Where(n => n > 2);
이 시점에는 아직 배열의 각 요소에 대한 조건 검사가 실행되지 않는다.
실제 연산은 foreach처럼 결과를 열거하는 시점에 이루어진다.
foreach(int n in query)
{
Console.WriteLine(n);
}
실행 결과:
3
4
5
즉 query는 필터링된 값을 미리 저장한 리스트라기보다 원본 데이터에 어떤 연산을 수행할지 정의한 시퀀스라고 이해하면 된다.
이러한 방식은 모든 결과를 미리 생성하지 않아도 된다는 장점이 있었고, 원본 데이터가 변경되거나 동일한 쿼리를 여러 번 순회할 때 주의해야 한다.
3. 그렇다면 ToList()는 무슨 역할을 할까?
ToList()는 시퀀스를 순회하여 결과를 새로운 List<T>에 저장하는 메서드다.
var query = numbers.Where(n => n > 2);
var result = query.ToList();
두 변수의 차이는 다음과 같다.
| 구분 | query | result |
|---|---|---|
| 타입 | IEnumerable<int> | List<int> |
| 실행 시점 | 열거할 때 | ToList() 호출 시 |
| 결과 저장 | 별도 리스트로 저장하지 않음 | 새 리스트에 저장 |
| 반복 사용 | 쿼리가 재실행될 수 있음 | 저장된 결과 사용 |
IEnumerable<T>라는 타입 자체가 지연 실행을 의미하는 것은 아니다. 실제 지연 실행 여부는 사용하는 연산에 따라 결정된다.
여기서 가장 중요한 차이는 결과를 만드는 방법을 유지할 것인가? 아니면 현재 결과를 별도로 저장할 것인지에 있다.
원본 데이터가 변경되는 경우
List<int> numbers = new List<int> { 1, 2, 3 };
var query = numbers.Where(n => n > 1);
var result = query.ToList();
numbers.Add(4);
Console.WriteLine(string.Join(", ", query));
Console.WriteLine(string.Join(", ", result));
실행 결과:
2, 3, 4
2, 3
query는 실제로 순회하는 시점에 원본 데이터를 읽으므로 새로 추가된 4까지 포함한다.
반면 result는 ToList()를 호출할 당시의 결과를 저장했기 때문에 이후 원본 리스트에 4를 추가해도 결과가 바뀌지 않는다.
다만 참조 타입 객체를 저장하는 경우 ToList()는 객체 자체를 깊은 복사하는 것이 아니라 참조를 저장한다는 점도 알아두어야 한다.
4. ToList()를 사용하지 않으면 비효율적일까?
찾아보니 반드시 그런 것은 아니였다. 다음 코드를 보면
var query = numbers
.Where(n => n > 2)
.Select(n => n * 10);
foreach(int n in query)
{
Console.WriteLine(n);
}
Console.WriteLine(query.Count());
foreach에서 쿼리를 실행하고, Count()에서 다시 쿼리를 실행할 수 있다.
특히 조건 검사나 데이터 변환에 많은 연산이 필요하다면 이러한 반복 실행은 불필요한 비용이 될 수 있다.
반면 ToList()를 사용하면 다음과 같다.
var result = numbers
.Where(n => n > 2)
.Select(n => n * 10)
.ToList();
foreach(int n in result)
{
Console.WriteLine(n);
}
Console.WriteLine(result.Count);
쿼리를 한 번 실행한 결과를 저장하고, 이후에는 저장된 리스트를 사용한다.
따라서 다음과 같은 차이가 있다.
- 지연 실행 유지: 결과를 한 번만 순회하거나 필요한 요소만 처리할 때 유리하다.
ToList()사용: 결과를 여러 번 사용하거나 특정 시점의 결과를 보관할 때 유리하다.
ToList() 역시 새로운 리스트를 생성하는 만큼 메모리와 연산 비용이 발생하므로 무조건 사용하는 것이 좋은 것은 아니다.
5. 이번 실습에서는 왜 ToList()를 사용했을까?
실제로 작성한 Program에서는 다음과 같이 사용했다.
var result = nameList.GetNames(minLength).ToList();
foreach(string item in result)
{
Console.WriteLine(item);
}
Console.WriteLine($"결과 수: {result.Count}");
이 코드에서는 필터링된 이름 목록을 출력하고, 마지막에 전체 결과 개수도 출력해야 한다.
ToList() 없이도 구현할 수 있지만 그러면 foreach에서 결과를 순회한 후 Count()로 다시 쿼리를 실행할 수 있다.
반면 ToList()를 사용하면 필터링된 결과를 한 번 저장하고, 이후에는 Count 프로퍼티를 통해 개수도 확인할 수 있다.
결국 이번 실습에서 ToList()를 사용한 이유는 단순히 자료형을 바꾸기 위해서가 아니라 필터링 결과를 저장해 재사용하기 위해서라고 이해했다.
6. 정리
처음에는 ToList()가 단순히 IEnumerable<T>를 List<T>로 변환하는 메서드라고 생각했다.
하지만 이터레이터와 LINQ의 지연 실행을 함께 공부하면서, 데이터가 언제 처리되는지를 제어하는 데에도 중요한 역할을 한다는 것을 알게 되었다. 요즘 이런 상황이 많은 것 같은 느낌이 든다.



