본문 바로가기
프로그래밍/Effective C#

Effective C# Item 37 : 쿼리를 사용할 때는 즉시 평가보다 지연 평가가 낫다

by bantomak 2023. 10. 11.

쿼리를 사용할 때는 즉시 평가보다 지연 평가가 낫다

쿼리를 정의한다고 해서 결과 데이터나 시퀀스를 즉각적으로 얻어오는 것은 아니다. 실제로는 쿼리를 정의하는 작업은 수행 시에 어떤 과정으로 작업을 수행할지에 대한 절차만을 정의하는 것에 지나지 않는다. 일반적으로 이러한 방식이 더 효과적이며 실제로 쿼리의 결과를 이용하여 순회를 수행해야만 결과가 생성된다. 이를 지연 평가(lazy evaluation)라고 한다. 하지만 가끔은 이러한 동작 방식이 적절하지 않은 경우도 있다. 마치 일반 변수를 사용하는 것처럼 즉각적으로 그 값을 얻어와야 할 때도 있다. 이를 즉시 평가(eager evaluation)라고 한다.

 

  • 지연 평가 (lazy evaluation)
  • 즉시 평가 (eager evaluation)

 

쿼리를 작성할 때는 쿼리의 결과를 여러 번 순회하는 경우 어떻게 동작하기를 바라는지를 미리 고려해야 하며, 즉시 데이터의 스냅샷을 얻기 원하는지, 아니면 결과 시퀀스를 생성하는 방법만을 기술할지를 결정해야 한다.

 

사실 이러한 개념은 기존에 개발자에게 익숙한 방식과는 사뭇 달라 보인다. 코드를 살펴봐도 즉각적으로 코드가 수행될 것처럼 보인다. 하지만 LINQ 쿼리를 사용한다면 코드조차도 데이터처럼 생각해야 한다. 쿼리에서 사용하는 람다 표현식 인자는 즉각 수행되지 않으며 향후 필요한 시점에 수행될 코드 조각을 나타낸다. 게다가 쿼리 제공자가 델리게이트 대신 표현식 트리(expression tree)를 사용하는 경우 새로운 쿼리를 기존 표현식 트리에 반영할 뿐이다.

 

지연 평가와 즉시 평가의 차이를 예제를 통해서 살펴보기로 하자. 다음의 코드는 시퀀스를 생성한 다음 생성된 시퀀스를 두 차례에 걸쳐 순회하되 매 단계를 수행할 때마다 잠깐씩 중지하도록 작성했다.

 

private static IEnumerable<TResult> Generate<TResult>(int number, Func<TResult> generator)
{
    for (var i = 0; i < number; i++)
    {
        yield return generator();
    }
}

private static void LazyEvaluation()
{
    Console.WriteLine($"Start time for Test One:{DateTime.Now:T}");
    var sequence = Generate(10, () => DateTime.Now);

    Console.WriteLine("Waiting.... \tPress Return");
    Console.ReadLine();

    Console.WriteLine("Iterating...");
    foreach (var value in sequence)
    {
        Console.WriteLine($"{value:T}");
    }

    Console.WriteLine("Waiting.... /tPress Return");
    Console.ReadLine();

    Console.WriteLine("Iterating...");
    foreach (var value in sequence)
    {
        Console.WriteLine($"{value:T}");
    }
}

 

LazyEvaluation() 메서드를 호출하면 그 결과는 다음과 같다.

 

Start time for Test One:오후 3:11:56
Waiting....     Press Return

Iterating...
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
Waiting.... /tPress Return

Iterating...
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57
오후 3:11:57

 

앞의 코드는 지연 평가의 특징을 잘 보여주는데 시퀀스를 매번 순회할 때마다 서로 다른 시간정보가 출력된다는 사실을 주의 깊게 살펴보기 바란다. 출력 결과로부터 미루어 짐작할 수 있겠지만, 시퀀스는 값 그 자체를 갖고 있는 것이 아니라 시퀀스 내의 개별 요소들을 생성하는 방법을 나타내는 코드를 갖고 있다. 앞의 예제를 직접 수행해 보면서 각각의 단계를 차근히 쫓아가보면 어느 시점에 평가가 이뤄지는지 쉽게 이해할 수 있을 것이다. 이를 통해 LINQ 쿼리의 지연 평가 방식을 정확히 이해할 수 있길 바란다.

 

이번에는 쿼리의 결과를 대상으로 추가적인 쿼리를 수행하는 코드를 작성해 보자. 첫 번째 쿼리 결과를 획득한 후 여러 번에 걸쳐 순회를 재수행하는 대신 추가 쿼리를 수행하여 두 개의 쿼리가 결합될 수 있도록 코드를 작성했다. 다음 코드는 이전 쿼리의 시간 결과를 세계 공용 포맷으로 변경하는 추가 쿼리를 작성한 예이다.

 

var sequence1 = Generator(10, () => Datetime.Now);
var sequence2 = from value in sequence1 
select value.ToUniversalTime();

 

sequence1과 sequence2는 데이터를 공유하는 것이 아니라 내부적으로 함수를 합성하여 수행된다. 다시 말하면 sequence2를 순회할 때 sequence1이 이미 생성해 둔 값을 순회하면서 개별 요소를 세계 공용 포맷을 수정하는 것이 아니라, 순회 시점에 맞춰 sequence1에서 지정한 코드를 호출하여 그 결과를 얻은 후 연이어 세계 공용 포맷으로 그 내용을 변경한다. 2개의 시퀀스를 각기 다른 시간에 순회해 보면 개별 시퀀스가 전혀 관련 없이 동작함을 알 수 있다. sequence2는 sequence1이 생성한 값을 변경하는 것이 아니라 완전히 새로운 값을 만들어낸다. 다시 말하지만 sequence1이 생성한 일련의 데이터 세트를 가져와서 세계 공용 포맷으로 변경된 시퀀스를 생성하는 것이 아니라, 세계 공용 포맷의 시간 정보가 필요한 시점에 완전히 새로운 값을 생성한다.

 

쿼리 표현식은 이처럼 지연 평가를 수행하기 때문에 이론적으로 무한 시퀀스를 표현하는 것이 가능하다. 시퀀스의 첫 번째 값부터 시작하여 적절한 값을 만난 경우 작업을 중단하도록 코드를 작성하기만 하면 된다. 하지만 일부 쿼리 표현식의 경우 결과를 얻기 위해서 반드시 전체 시퀀스가 준비되어야 하는 경우도 있다. 어떤 경우가 그러한지를 알고 있다면 성능이 저하되지 않도록 쿼리를 작성하는 데도 도움 되고, 기존에 작성한 쿼리로부터 병목 현상의 원인을 찾을 때도 도움 될 것이다.

 

다음의 코드를 살펴보자.

 

static void Main(string[] args)
{
    var answers = from number in AllNumbers()
                  select number;

    var smallNumbers = answers.Take(10);
    foreach(var num in smallNumbers)
    {
        Console.WriteLine(num);
    }
}

static IEnumerable<int> AllNumbers()
{
    var number = 0;
    while(number < int.MaxValue)
    {
        yield return number++;
    }
}

 

이 예제는 쿼리를 수행할 때 전체 시퀀스가 필요하지 않은 경우를 보여주기 위한 코드다. 앞의 코드를 수행하면 순차적으로 0, 1, 2, 3, 4, 5, 6, 7, 8, 9를 출력한다. 하지만 AllNumber() 메서드는 무한 시퀀스를 생성한다. (중단하지 않고 계속해서 순회를 이어간다면 오버플로가 발생할 것이다.)

 

이 코드가 충분히 빠른 속도로 동작할 수 있는 이유는 사전에 전체 시퀀스를 생성하는 것이 아니기 때문이다. Take() 메서드는 시퀀스로부터 처음부터 N개의 객체를 반환하는 작업을 수행한다. 하지만 다음과 같이 쿼리를 변경하면 프로그램은 영원히 종료되지 않을 것이다.

 

static void Main(string[] args)
{
    var answers = from number in AllNumbers()
                  where number < 10
                  select number;

    foreach(var num in answers)
    {
        Console.WriteLine(num);
    }
}

 

이 코드가 영원히 수행(혹은 int.MaxValue까지)되는 이유는 이 코드의 쿼리 구문을 수행하기 위해서 시퀀스 내의 모든 값을 대상으로 비교 연산을 수행하기 때문이다. 즉, 이 코드는 이전 코드와 동일한 적을 수행하지만 전체 시퀀스가 필요한 예다.

 

정상적으로 쿼리를 수행하기 위해서 전체 시퀀스가 반드시 필요한 연산자가 여럿 있다. where는 전체 시퀀스를 요구한다. 따라서 무한 시퀀스에 대해서 where를 수행하면 시퀀스 내의 개별 요소가 주어진 조건에 부합하는지를 검사하여 또 다른 무한 시퀀스를 생성하게 된다. orderby는 정렬을 위해서 전체 시퀀스가 필요하다. Max나 Min 또한 where와 유사한 이유로 전체 시퀀스가 필요하다. 사실 이러한 작업들은 시퀀스 내의 모든 요소들을 검사하지 않고는 올바른 결과를 얻어낼 수 없는 연산들이다. 최댓값/최솟값, 혹은 정렬 등의 작업이 필요하다면 이러한 메서드를 쓰지 않을 도리가 없다.

 

다만, 전체 시퀀스가 필요한 메서드를 사용할 때는 반드시 염두에 두어야 할 사항이 몇 가지 있다. 첫째로 앞서 살펴본 바와 같이 시퀀스가 무한정 지속될 가능성이 있다면 이 같은 메서드를 사용할 수 없다. 둘째로 시퀀스가 무한이 아니더라도 시퀀스를 필터링하는 쿼리 메서드는 다른 쿼리보다 먼저 수행하는 것이 좋다. 선행 단계에서 컬렉션의 요소를 필터링하여 그 개수를 줄일 수 있다면 다음으로 수행할 쿼리의 성능을 개선할 수 있기 때문이다.

 

대부분의 경우 지연 평가가 더 나은 접근 방식임에 분명하다. 하지만 간혹 특정 시점에 값을 반드시 알아야 하는 경우도 있기 마련이다. 시퀀스로부터 값을 즉각적으로 평가하여 그 결과를 얻고 싶다면 ToList()와 ToArray() 2개의 메서드를 사용하면 된다. 이 2개의 메서드는 각각 List <T>와 Array 컨테이너에 결과를 저장해 준다.

 

이 메서드들은 크게 두 가지 경우에 유용하게 활용될 수 있다. 먼저 쿼리가 즉각 실행되도록 하여 시퀀스를 실제 순회하기 이전에 지체 없이 데이터를 스냅샷을 얻고자 하는 경우다.

둘째로 쿼리의 결과가 매번 변경되지 않고 동일한 결과를 반환하는 경우다. 이 경우 ToList()나 ToArray()를 사용하여 그 값을 캐싱해 두면 이를 반복적으로 사용할 수 있기 때문에 유용하다.

 

거의 대부분의 경우에 지연 평가를 사용하면 즉시 평가에 비해서 작업의 양도 줄고 유연성도 증가한다. 드문 경우이긴 하지만 즉각적으로 쿼리를 수행하고 그 결과를 가져와야 하는 경우라면 ToList()나 ToArray()를 사용하면 된다. 하지만 즉시 평가가 반드시 필요한 경우가 아니라면 대체로 지연 평가를 사용하는 편이 훨씬 낫다.

 

출처

 

이펙티브 C# - 예스24

더 나은 C# 코드를 작성하는 새로운 방법 50가지 C#은 전통적인 .NET 기반 개발에서 유니티 게임 엔진으로 개발 영역을 확대하면서 더욱 주목받고 있다. 또한 자마린으로 다양한 모바일 플랫폼에

댓글