[Effective C#] Ch4. LINQ 활용

2022. 4. 14. 15:51카테고리 없음

도입 목적

지연된 쿼리를 지원하고 다양한 데이터 저장소에 대해 쿼리를 수행할 수 있는 통합 구문을 제공하기 위함

 

목표

데이터 소스의 유형과 상관없이 동일한 작업을 수행하는 코드를 손쉽게 작성하는 것

하지만 동일한 구문으로 서로 다른 데이터 소스에 대해서 작업을 수행하는 수준을 넘어서

쿼리와 실제 데이터 소스를 연결해주는 쿼리 제공자를 자유롭게 구현할 수 있는 기능도 함께 제공한다.

⭐컬렉션을 반환하기 보다 이터레이터를 반환하는 것이 낫다.

일련의 시퀀스를 반환하는 메서드를 작성해야 한다면 컬렉션을 반환하기 보다는 이터레이터를 반환하는 것이 좋다.

이터레이터를 반환하면 이를 이용하여 다양한 작업을 좀 더 수월하게 수행할 수 있다.

 

- 이터레이터 메서드

호출자가 요청한 시퀀스를 생성하기 위해서 yield return 문을 사용하는 메서드

 

이터레이터 메서드가 호출되면 앞에서와 같이 컴파일러가 생성한 객체가 인스턴스화된다.

이후 시퀀스 내에 포함된 항목을 요청하면 비로소 시퀀스가 생성된다.

 

필요할 때 생성이라는 전략은 이터레이터 메서드를 작성할 때 가장 중요한 전략 중 하나이다.

이터레이터 메서드는 시퀀스를 생성하는 방법을 알고 있는 객체를 생성한다.

그리고 이 객체는 실제 시퀀스에 대한 접근이 이루어지는 경우에만 사용된다.

 

일려의 시퀀스를 반복적으로 사용하는 경우라면 시퀀스를 캐싱하는 것은 어떤가?

이러한 결정은 사용자가 결정하도록 남겨두는 것이 좋다.

 

메서드는 필요에 따라 다른 반환 타입을 가질 수 있으며 이러한 반환타입의 객체를 생성하는 데 드는 비용은 타입별로 각기 다르다.

계산 시간과 저장소 공간을 모두 고려한다면 간혹은 시퀀스를 반환하는 메서드가 전체 시퀀스를 단번에 생성하여 반환하는 메서드보다 비용이 더 클 수도 있다.

하지만 우리가 작성한 API들을 사용자들이 어떻게 사용할지 예측할 수 없으므로 이에 대해서 과도하게 신경쓰기보다는 API를 좀 더 쉽게 사용할 수 있도록 배려하는 것에 집중하는 편이 좋다.

이런 측면에서 보자면 IEnumerable<T>와 같은 인터페이스를 반환하도록 메서드를 작성하는 편이 좀 더 편리하다.

 

⭐루프보다 쿼리 구문이 낫다

1) 쿼리 구문을 사용하면 프로그램의 논리를 명령형 방식 ➡ 선언적 방식

2) 질의의 내용 구성

3) 개별 항목에 대해 수행하려는 작업의 수행 시기 연기

 

private static IEnumerable<Tuple<int, int>> ProduceIndices3()
{
	var storage = new List<Tuple<int, int>>();
    
    for(var x = 0;x < 100;x++)
    	for(var y = 0;j < 100;y++)
        	if(x + y < 100)
            	storage.Add(Tuple.Create(x, y));
                
    storage.Sort((point1, point2) => 
    	(point2.Item1 * point2.Item1 + point2.Item2 * point2.Item2).CompareTo(
        point2.Item1 * point2.Item1 + point2.Item2 * point2.Item2));
    return storage;
}


private static IEnumerable<Tuple<int, int>> QueryIndices3()
{
	return from x in Enumerable.Range(0, 100)
    		from y in Enumerable.Range(0, 100)
            where x + y < 100
            orderby (x * x + y * y) descending
            select Tuple.Create(x, y);
}

 

1) 명령형으로 구현한 코드는 이전보다 훨씬 이해하기 어려워졌다.

명령형 모델은 동작이 수행되는 절차에 주안을 두기 때문에 동작이 진행되는 과정을 따라가다가 혼돈에 빠지기 쉽고 원래 의도를 잊는 경우도 흔하다.

 

2) 반복 구문에 비해 쿼리 구문이 갖는 또 다른 장점은 더욱 다양하게 조합이 가능하다.

✨ 쿼리구문

개별 항목에 대하여 수행해야하는 작업을 작은 코드 블록으로 생성한다.

쿼리 구문의 지연 수행 모델 덕분에 개별 항목에 대해 수행해야 하는 여러 작업을 하나로 조합할 수 있다.

이러한 기능을 이용하면 한 번의 순회 과정 동안 여러 작업을 결합하여 단번에 수행 가능하다.

 

✨ 반복구문

여러 작업들을 조합하여 수행하는 것이 어렵다.

이러한 문제를 해결하려면 각 작업 단계별로 임시 저장소를 만들거나

초가 메서드를 만들어서 개별 작업들을 조합해야만 할 것이다.

 

주의 사항

- 대신 일부 메서드들의 경우 쿼리구문에서는 사용할 수 없다.

- 혹자는 로프를 이용하여 직접 코딩하면 쿼리보다 성능이 좋은 코드를 작성할 수 있지만 항상 그런 것은 아니다.

쿼리 구문의 성능이 만족스럽지 않다면 우선 수행 성능을 측정해보자.

그리고 성능을 개선하기 위해서 LINQ에 대한 병렬 확장을 사용하는 것도 고려해보자.

 

⭐시퀀스에 사용할 수 있는 조합 가능한 API를 작성하라

주로 반복구문을 사용할 때는 매개변수로 컬렉션을 받아와서 컬렉션에 포함된 요소들을 살펴보거나, 내용을 수정하거나, 혹은 그 중 일부만 필터링해서 또 다른 컬렉션에 그 결과를 저장한 후 반환하는 식으로 코드를 작성하게 된다.

 

이와 같이 작업을 수행하는 것은 효율성에 문제가 있다.

여러 단계를 거치는 동안 중간 결과를 저장하기 위해서 추가적으로 컬렉션(아마도 매우 큰)이 필요할 수도 있다.

각 단계마다 매번 전체 컬렉션을 순회해야 하기 때문에 다단계로 구성된 알고리즘을 수행하는 경우 전체적으로 수행 시간이 길어질 수밖에 없다.

 

대안으로는 개별 요소에 대해 수행해야 하는 모든 작업을 분리된 메서드로 작성한 후 루프 내에서 이 메서드를 호출하는 방법이 있다.

이렇게 하면 컬렉션을 한 번만 순회하면 되므로 성능이 개선되고,

중간 결과물을 저장하기 위한 컬렉션을 사용할 필요가 없기 때문에 메모리도 적게 사용된다.

하지만 이러한 방식으로 코드를 작성하면 메서드의 재사용 가능성이 낮아진다.

 

시퀀스를 다루는 메서드는 C# 이터레이터를 활용하여 작성할 수 있으며 결과를 필요한 시점에 맞춰 메서드가 수행되도록 할 수 있다.

이터레이터 메서드는 단일의 시퀀스를 입력 ➡ 단일의 시퀀스(다른 IEnumerable<T>)를 반환

yield return 문을 사용하면 시퀀스를 반환하는 메서드를 쉽게 만들 수 있기도 하다.

이를 통해 메서드 내에서 시퀀스 내의 개별 요소를 저장히가 위해 별도의 저장소를 마련할 필요가 없다.왜냐하면 정확히 값이 필요한 시점에 입력 시퀀스상에서 다음 요소를 가져오고, 출력결과가 반드시 필요한 시점에 출력 시퀀스로 결과를 내보낸다.

 

public static IEnumerable<int> Unique(IEnumerable<int> nums)
{
	var uniqueVals = new HashSet<int>();
    
    foreach(var in nums)
    {
    	if(!uniqueVals.Contains(num))
        {
        	uniqueVals.Add(num);
            yield return num;
        }
    }
}

yield return 문은 매우 흥미롭게 동작한다.값을 반환한 후 내부적으로 사용하는 이터레이터의 현재위치와 상태 정보를 저장한다.순회 과정은 내부적으로 입력 시퀀스의 현재 위치를 계속 갱신해가면서 출력 시퀀스에 차례차례 그 결과를 반환하는 과정이다.

 

이러한 메서드를 Continuable Method라고도 한다.현재의 수행 상태를 보전하고 있어서 메서드로 재진입 시 이전에 수행한 코드 이후부터 수행을 이어갈 수 있는 메서드를 말한다.

 

이점

1) 시퀀스 내의 개별 요소에 대하여 지연평가/수행이 가능하다.

2) 메서드가 foreach 루프를 포함하고 있는 경우라도 조합 가능한 메서드로 만들 수 있다.

 

Unique() 메서드는 입력 시퀀스의 개별 요소가 정수 타입이라는 것에 한정하여 정수 타입에 대해서만 작업이 가능하도록 작성되지 않았다.따라서 제네릭 메서드로 변경하기가 쉽다.

public static IEnumerable<T> Unique<T>(IEnumerable<T> nums)
{
	var uniqueVals = new HashSet<T>();
    
    foreach(T item in sequence)
    {
    	if(!uniqueVals.Contains(item))
        {
        	uniqueVals.Add(item);
            yield return item;
        }
    }
}

이터레이터 메서드의 진정한 힘은 여러 단계를 거쳐서 처리돼야 하는 작업을 수행하는 경웨 드러난다.

최종적으로 얻고자 하는 값이 고유한 숫자의 제곱값을 가져오는 것이라 가정해보자.

Squre(제곱) 메서드를 이터레이터 메서드로 작성하는 것은 매우 쉽다.

public static IEnumerable<int> Squre(IEnumerable<int> nums)
{
	foreach(var num in nums)
    	yield return num * num;
}

이제 두 메서드를 조합하여 사용할 수 있다.

foreach(var num in Square(Unique(nums)))
	WriteLine(num);

여러 개의 이터레이터 메서드를 조합하더라도 전체 시퀀스에 대한 순회는 단 한 번만 이뤄진다.

 

⭐ Action, Predicate, Function과 순회 방식을 분리하라

시퀀스 내의 개별 항목을 이용하여 작업을 수행하는 유형 외에도, 시퀀스의 순회 방식에 변경을 주는 유형도 있다.ex) 특정 조건에 부합하는 항목만을 가져오거나, 매 N번째 항목만을 건너뛰며 가져오는 메서드의 경우

 

알고리즘의 중간 어디쯤을 커스텀화하는 유일한 방법- 메서드를 호출할 수 있는 무엇인가를 전달- 함수 객체를 전달

 

이를 위해서- 작업을 정의한 델리게이트 사용- 람다 표현식

 

익명의 델리게이트를 사용할 때는 function, action이라는 두 가지 패턴이 있다.function의 특별한 용례인 predicate도 있다.

 

- predicate시퀀스 내의 항목이 조건에 부합하는지를 boolean으로 반환하는 function

 

- action컬렉션 내의 개별 요소에 대하여 실제 수행할 작업을 전달하기 위해 주로 사용

 

namespace System
{
	public delegate bool Predicate<T>(T obj);
    public delegate void Action(T>(T obj);
    public delegate TResult Func(T, TResult)(T arg);
}

⭐ 필요한 시점에 필요한 요소를 생성하라

정숫값의 시퀀스를 생성하는 간단한 예)

static IList<int> CreateSequence(int numberOfElements,
	int startAt, int stepBy)
{
	var collection = new List<int>(numberOfElements);
    for(int i = 0;i < numberOfElements;i++)
    {
    	collection.Add(startAt + i * stepBy);
    }
    return collection;
}

 

이 코드는 잘 동작하지만 yield return을 이용하여 새로운 시퀀스를 생성하는 것에 비해서는 단점이 많다.먼저 이 코드는 결과를 List<int>에 저장한다고 가정하고 있다.클라이언트가 BindingList<int>와 같이 다른 타입을 요구하면 변환 작업을 반드시 수행해야 한다.

 

var data = new BindingList<int>(
	CreateSequence(100, 0, 5).ToList());

이처럼 변환을 수행하면 그 과정에서 미묘한 버그가 발생할 소지가 있다.

BindingList<T>는 생성자의 매개변수로 주어진 리스트를 복사하지 않고 동일한 메모리 공간을 그대로 재사용하는 특징이 있다.

따라서 매개변수로 전달한 객체를 다른 곳에서 이미 사용하고 있다면 일관성의 문제가 발생할 수 있다.

동일한 저장 공간에 대하여 여러 개의 참조가 사용되는 꼴이기 때문이다.

 

또한 클라이언트가 중간에 작업을 중단할 수 없기 때문에 CreateSequence() 메서드는 항상 요청된 개수만큼 요소를 생성한다.

사용자가 페이징이나 혹은 다른 이유로 작업을 중단하고 싶어도 방법이 없다.

시퀀스 내의 요소들을 모두 생성할 때까지 다음 단계를 진행할 수 없기 때문에 전체 파이프라인의 병목 구간이 된다.

시퀀스를 생성하는 기능을 이터레이터 메서드로 만들면 이 같은 문제들을 모두 피할 수 있다.

 

static IEnumerable<int> CreateSequence(int numberOfElements, int startAt, int stepBy)
{
	for(var i = 0;i < numberOfElements; i++)
    	yield return startAt + i * stepBy;
}

시퀀스 내의 개별 요소가 요청 시마다 하나씩 생성된다.

이 코드는 클라이언트가 값을 어떤 컬렉션에 저장하더라도 문제없이 동작한다.

 

그리고 시퀀스에 대한 순회과정을 쉽게 중단할 수 있다.

- 조건에 부합하지 않는 상황

- 사용자로부터 지속 여부를 확인

- 다른 스레드의 상태 폴링

- 수행해야 할 다른 작업이 있는지 확인

 

지연 실행(Deffered Execution)

- 필요한 시점에 필요한 요소를 생성하는 것

- 클라이언트가 값을 요청하면 그 시점에 값을 생성하도록 알고리즘을 작성하는 것이 핵심

 

⭐함수를 매개변수로 사용하여 결합도를 낮추라

함수를 매개변수로 취한다는 것

= 개발자가 더 이상 구상 타입을 작성할 필요가 없다.

= 오히려 추상화된 정의를 통해 종속성을 다루는 것

 

인터페이스와 클래스를 구분하는 것에는 반드시 익숙해져야 한다.

하지만 때로는 인터페이스를 정의하고 구현하느 것조차 성가신 경우가 있으며

전통적인 객체지향 기법과는 다른 기법을 사용하여 API를 좀 더 단순하게 만들 수 있다.

실제로 델리게이트를 사용하여 컴포넌트의 계약을 기술하면 클라이언트 측에서 코드를 사용하기가 쉬워진다.

 

다른 개발자가 사용할 코드를 작성하는 경우 우리는 의도하지 않은 가정을 하게 되고 다양한 의존성 문제를 다룰 수밖에 없는데,

이러한 가정과 의존성 문제를 우리가 작성할 코드에서 분리하는 것은 상당히 까다로운 작업이다.

먼저 작성할 코드가 다른 부분에 의존하면 할수록 단위 테스트를 수행하기 어렵고, 다른 환경에서 코드를 재사용하기가 어려워진다.

 

인터페이스 대신 델리게이트를 사용하는 이유는 델리게이트가 타입을 구성하는 핵심 구성 요소가 아니기 때문이다.

델리게이트는 메서드 중 하나로 간주되지도 않는다.

 

인터페이스를 정의하거나 베이스 클래스를 만들어야 하는 경우라면 함수를 매개변수로 취하는 제네릭 메서드를 구현하는 것이 대안이 될 수 있을지 반드시 검토해봐야 한다.

 

함수를 매개변수로 사용하면 알고리즘 자체와 알고리즘을 적용할 타입을 분리하는 데 도움이 된다.

하지만 이렇게 결합도를 느슨하게 구성하려면 분리된 컴포넌트를 사용할 때 발생할 수 있는 오류를 처리하기 위해서 추가적인 작업을 해야한다.(null 값 체크)

 

마지막으로 사용자에게 요구하는 계약 사항을 상속 기법을 이용하여 엄밀하게 정의하지 않고 델리게이트를 사용한다 하더라도,델리게이트에 대한 참조가 필요하므로 런타임에 결합 관계는 여전히 발생한다는 사실을 알아두어야 한다.만약 어떤 객체가 특정 델리게이트를 나중에 다시 사용하기 위해서 그 복사본을 저장해두면이 객체는 델리게이트의 생명주기에 영향을 미치게 된다.

 

우리가 개발한 컴포넌트와 이를 사용하는 클라이언트 코드 간의 계약 관계를 정의하는 가장 핵심적인 방법은 여전히 인터페이스를 이용하는 것이다.추상 베이스 클래스를 사용하면 클라이언트 측에서 자주 사용할 법한 코드를 미리 구현해서 제공할 수도 있다.하지만 도구의 지원을 많은 부분 포기해야할 수도 있다.더 많은 작업을 해야할 수도 있지만 유연성이라는 선물을 얻게 된다.

 

⭐ 확장 메서드는 절대 오버로드하지 마라

인터페이스와 타입에 대하여 확장 메서드를 작성해야하는 세 가지 이유1) 인터페이스에 기본 구현 추가2) 닫힌 제네릭 타입에 동작 추가3) 조합 가능한 인터페이스 작성

 

하지만 확장 메서드는 설계 의도를 드러내는 방법으로는 썩 훌륭하지 않다.확장 메서드는 컴파일 타임의 객체 타입을 기반으로 강제로 메서드를 호출한다.네임스페이스를 기반으로 어떤 메서드를 호출할지를 결정하는 방식은 그 구조가 너무 허약하다.

 

⭐ 쿼리 표현식과 메서드 호출 구문이 어떻게 대응되는지 이해하라

C# 컴파일러는 쿼리 언어로 작성된 쿼리 표현식을 메서드 호출 구문으로 변환해준다.

 

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

쿼리를 정의한다고 해서 결과 데이터나 시퀀스를 즉각적으로 얻어오는 것은 아니다.

- 지연 평가

쿼리의 결과를 이용하여 순회를 수행해야만 결과가 생성된다.

 

- 즉시 평가

일반 변수를 사용하는 것처럼 즉각적으로 그 값을 얻어온다.

 

쿼리를 작성할 때는 쿼리의 결과를 여러 번 순회하는 경우 어떻게 동작하기를 바라는지를 미리 고려해야 하며,

즉시 데이터의 스냅샷을 얻기 원하는지, 아니면 결과 시퀀스를 생성하는 방법만을 기술할지를 결정해야 한다.

 

시퀀스는 값 그 자체를 갖고 있는 것이 아니라 시퀀스 내의 개별 요소들을 생성하는 방법을 나타내는 코드를 갖고 있다.

 

전체 시퀀스가 필요한 메서드를 사용할 때 반드시 염두에 두어야 할 사항이 몇 가지 있다.

 

1) 시퀀스가 무한정 지속될 가능성이 있다면 이 같은 메서드를 사용할 수 없다.

2) 시퀀스가 무한이 아니더라도 시퀀스를 필터링하는 쿼리 메서드는 다른 쿼리보다 먼저 수행하는 것이 좋다.

선행 단계에서 컬렉션의 요소를 필터링하여 그 개수를 줄일 수 있따면 다음으로 수행할 쿼리의 성능을 개선할 수 있기 때문이다.

 

대부분의 경우 지연 평가가 더 나은 접근 방식임에 분명하다.

하지만 간혹 특정 시점에 값을 반드시 알아야 하는 경우도 있기 마련이다.

시퀀스로부터 값을 즉각적으로 평가하여 그 결과를 얻고 싶다면 ToList()와 ToArray() 2개의 메서드를 사용하면 된다.

 

이 메서드들은 크게 두 가지 경우에 유용하게 활용될 수 있다.

1) 쿼리가 즉각 실행되도록 하여 시퀀스를 실제 순회하기 이전에 지체 없이 데이터의 스냅샷을 얻고자 하는 경우

2) 쿼리의 결과가 매번 변경되지 않고 동일한 결과를 반환하는 경우

이 경우 ToList()나 ToArray()를 사용하여 그 값을 캐싱해두면 이를 반복적으로 사용할 수 있기 때문에 유용하다.

 

거의 대부분의 경우에 지연 평가를 사용하면 즉시 평가에 비해서 작업의 양도 줄고 유연성도 증가한다.

드문 경우이긴 하지만 즉각적으로 쿼리를 수행하고 그 결과를 가져와야 하는 경우라면 ToList()나 ToArray()를 사용하면 된다.

하지만 즉시 평가가 반드시 필요한 경우가 아니라면 대체로 지연 평가를 사용하는 편이 훨씬 낫다.

 

⭐ 메서드보다 람다 표현식이 낫다

모든 조건을 결합하여 하나의 where 절로 변경할 수도 있을 것이다.

동일한 람다 표현식이 반복 사용되는 것을 방지하기 위해서 재사용 가능한 메서드를 이용할 수도 있다.

 

불행히도 메서드로 분리한 부분은 재사용 가능성이 낮아 보인다.

대부분의 개발자가 그러하듯 동일한 코드를 복사하여 사용하는 것은 만악의 근원이요 반드시 제거돼야 하는 부분이라고 생각할 것이다.

메서드로 공통 코드를 분리하면 코드가 더욱 간단해지고 나중에 수정할 부분도 하나이기 때문에 좋다고 생각할 것이다.

이는 좋은 소프트웨어 엔지니어링이란 무엇인가에 대한 매우 규범적인 이야기이기도 하다.

 

그러나 불행히도 이 또한 틀린 이야기다.

 

- 통상 쿼리 표현식 내의 람다 표현식은 델리게이트로 변환되어 수행된다.(LINQ to Objects)

✓ 지역 데이터 저장소에 대하여 쿼리를 수행하는 방법

✓ 일반적으로 컬렉션 내에 저장된 요소를 쿼리할 때 사용

✓ 확장 메서드는 IEnumerable<T>를 입력 시퀀스로 취한다.

 

- 다른 경우에는 람다 표현식을 활용하여 표현식 트리를 만들고, 향후 이를 파싱하여 완전히 다른 구문을 생성한 후, 그 결과를 다른 환경에서 수행하기도 한다.(LINQ to SQL)

✓ 표현식 트리는 쿼리를 나타내기 위한 논리적인 구성을 포함

✓ LINQ to SQL은 이 표현식 트리를 파싱하여, 적절한 T-SQL 쿼리를 생성한 후, 이를 데이터베이스에 전달

전달된 T-SQL 쿼리는 데이터베이스 엔진 내에서 수행

 

이처럼 작업을 수행하려면 LINQ to SQL 엔진이 표현식 트리를 분석하여 모든 연산을 동일한 작업을 수행하는 T-SQL 구문으로 변경해야 한다.

문제는 LINQ to SQL 엔진이 메서드 호출부를 나타내는 이 같은 노드를 T-SQL 표현식으로 변경하지 못한다는 것이다.

이 경우 LINQ to SQL 엔진은 여러 번에 걸쳐 쿼리를 실행한 후 필요한 데이터를 클라이언트 측으로 가져와서 처리하는 대신 예외를 발생시킨다.

 

이터레이터 메서드는 컬렉션 내의 항목들을 실제로 순회하기 전까지는 호출되지 않는다.

즉, 람다 표현식을 표함하는 간단한 메서드들을 작성하여 쿼리의 일부분을 대체할 수 있다.

이러한 메서드들은 반드시 입력 시퀀스를 취하도록 작성되어야 하며, yield return 키워드를 이용하여 시퀀스를 반환해야 한다.

이 같은 패턴을 사용하면 원격지에서 수행할 새로운 표현식 트리를 생성하기 위해서 IQueryable을 사용하는 enumerator를 조합하여 사용할 수 있다.

다음으로 할 일은 앞서 잘게 쪼갠 코드를 재결합하여 응용 프로그램 내에서 재사용할 수 있는 형태의 더 큰 쿼리를 만드는 것이다.

복잡한 쿼리에서 람다 표현식을 재사용하는 가장 효율적인 방법 중 하나는 닫힌 제네릭 타입에 대하여 쿼리를 위한 확장 메서드를 작성하는 것이다.  시퀀스를 매개변수로 받아서 조건에 부합하는 요소만을 반환한다.

실제 운영 환경을 위한 코드를 작성하는 경우라면 IEnumerable<T>을 매개변수로 취하는 오버로드 메서드를 반드시 함께 작성해야할 것이다.

그래야만 LINQ to SQL과 LINQ to Objects 모두를 지원할 수 있기 때문이다.

 

⭐ function과 action 내에서는 예외가 발생하지 않도록 해라

action이나 function이 예외를 일으키는 경우 데이터의 비일관성 문제를 피하기가 어렵다.

작업을 어디까지 수행하다가 예외를 일으켰는지 추적하기도 어렵고, 데이털르 원복하기도 어렵다.

 

일종의 방법)

기존 시퀀스의 개별 요소들을 수정해서 반환하는 것이 아니라 새로운 시퀀스를 생성하여 반환하는 방식

 

이에 대한 단점)

- 더 이상 여러 함수를 조합하여 작업을 수행할 수 없다.

생성하여 대체하는 코드는 캐시를 생성하므로 단일 리스트에 대해 변환 작업을 수행하는 다른 함수들을 조합할 수 없다.

이 경우 어쩔 수 없이 개별 요소에 대한 변환 작업을 명령형 방식으로 수행해야 한다.

실용적인 관점에서 모든 변환 과정을 단번에 수행할 수 있도록 쿼리를 작성하는 것을 고려해볼 수 있다.

이 경우 조합된 작업의 마지막 단계는 캐시된 전체 리스트로 기존 리스트를 대체하는 작업이 될 것이다.

 

지금까지 알아본 내용은 데이터를 변경하는 작업을 수행하는 메서드가 예외를 일으킬 가능성이 있는 경우뿐 아니라 멀티스레드 환경에서도 적용해볼 수 있는 내용이다.

람다 표현식을 사용하는 경우 그 내부에서 예외가 발생하면 문제의 원인을 규명하기가 매우 어려워진다.

마지막 예와 같이 필요한 작업을 복사본에 대해서 수행하고, 예외가 발생하지 않은 경우에 한해서 전체 시퀀스를 변경하는 방식은 상당히 유용한다.

 

⭐ 지연 수행과 즉시 수행을 구분하라

- 선언적 코드

해설적이며 무슨 작업을 해야 하는지를 정의

 

- 명령형 코드

어떻게 작업을 수행해야 하는지를단계별로 세분화해서 기술

 

명령형 모델은 메서드를 호출하고 그 결과를 다른 메서드에 전달하지만

선언적 모델은 메서드를 호출할 수 있는 델리게이트를 매개 변수로 전달한다는 점에서 

큰 차이가 있다.

 

데이터와 메서드가 서로 상호 대체 가능하다면 둘 중 어떤 것을 사용하는 것이 좋을까?

그리고 어떤 때 이 중 하나를 사용해야 하는 것일까?

이 둘 사이에 가장 중요한 차이라면

데이터는 사용하기 전에 반드시 그 값이 확정돼야하는 반면 메서드는 지연 평가가 가능하다는 점이다.

 

만약 매개변수가 필요한 메서드를 지연 평가와 즉시 평가 어떤 방식으로 호출해도 그 결과가 동일하도록 보장하려면 매개변수를 변경 불가 타입으로 제한해야 한다.

 

지연 수행과 즉시 수행 중 어떤 방식을 사용하는 것이 좋을지를 결정하는 핵심 기준은 메서드가 무슨 작업을 하는가에 달려 있다.

 

변경 불가 타입 내의 변경 불가 메서드의 경우 메서드 호출 코드를 이 메서드의 반환값으로 대체해도 아무런 문제가 되지 않으며 프로그램은 올바르게 동작한다.

✨ 변경 불가 메서드

메서드 내에서 I/O 작업을 수행하거나, 전역 변수의 값을 변경하거나, 혹은 다른 프로세스와 통신 하는 등의 작업을 수행하지 않으며 전역적인 상태를 수정하지 않는 메서드

 

LINQ to SQL이 쿼리를 처리하는 방식

- 모든 LINQ to SQL 쿼리는 지연 쿼리로 시작

- 따라서 모든 매개변수는 데이터가 아니라 메서드가 된다.

- 메서드

1) 데이터베이스 엔진 내에서 수행

2) 일부 로컬 메서드는 데이터베이스 엔진에 쿼리를 전달하기 직전에 표현식 트리를 분석

 ➡ LINQ to SQL는 여기 해당

 ➡ 로컬 메서드를 호출해야 하는 부분은 해당 함수의 반환값으로 대체

 ➡ 이 시점에 데이터베이스 엔진에 전달하여 작업을 수행할 수 있도록 표현식 트리를 SQL 구문으로 변환하는 작업

 ➡ 표현식 트리에 포함된 쿼리나 메서드 등은 LINQ to SQL 라이브러리에 의해서 T-SQL 문장으로 대체된다.

 ➡ 이를 통해 성능 개선, 데이터베이스와의 네트워크 대역 절약

 

 

✨ 이러한 대체 작업이 가능하려면 입력 시퀀스의 개별 항목에 대해 메서드가 의존성을 갖지 않아야 한다.

 

⭐값비싼 리소스를 갭처하지 말라

클로저클로저에 바인딩된 변수를 포함하는 객체를 생성한다.

바인딩된 변수의 수명이 길어지면 문제를 발생할 수 있다.

클로저 내에서 변수를 캡처하면 이 변수가 참조하는 객체의 수명이 늘어나게 된다.

캡처된 변수를 사용하는 마지막 델리게이트가 가비지화될 때까지 해당 변수는 가비지로 간주되지 않는다.

간혹은 이보다 수명이 더 연장되는 경우도 있다.

클로저와 캡처된 변수는 이를 정의한 메서드를 벗어나서도 계속 사용될 수 있으며, 이 경우 클로저와 캡처된 변수가 언제 도달 불가능 상태가 될지 알 수 없다.

즉, 캡처된 변수를 참조하는 클로저를 메서드 외부로 반환하면 캡처된 지역 변수는 자신이 선언된 범위를 완전히 벗어나게 된다.

 

다행스러운 것은 일반적인 경우에는 이러한 특성에 대해서 특별히 신경 쓸 필요가 없다.

특별히 무거운 리소스를 참조하는 변수가 아니라면 다른 변수처럼 적절한 시점에 가비지 수집이 될 것이기 때문이다.

하지만 일부 변수들은 매우 무거운 리소스를 참조하고 있을 수도 있다.

통상 이러한 타입들은 리소스를 명시적으로 해제하기 위해서 IDisposable을 구현하고 있다.

이 경우 해당 리소스를 이용하여 결괏값을 획득했다면 그 즉시 리소스를 정리할 수 있다.

 

IEnumerable<IEnumerable<int>> rowOfNumbers;
using(TextReader t = new StreamReader(File.OpenRead("TextFile.txt")))
	// 파싱하는 델리게이트에 TextREader 객체 바인딩
	rowOfNumbers = ReadNumbersFromStream(t);

// 순회하기 전까지는 아무런 작업이 수행되지 않음
foreach(var line in rowOfNumbers)
{
	foreach(int num in line)
    Write(num);
    WriteLine();
}

분명 파일을 명시적으로 닫고 나서 그 내용에 접근하도록 코드를 작성했다.

하지만 실제로는 값을 순회하는 과정에서 ObjectDisposedException이 발생하게 된다.

앞의 코드를 컴파일하게 되면 C# 컴파일러는 파일로부터 값을 읽고 그 값을 파싱하는 델리게이트에 TextReader 객체를 바인딩한다.

rowOfNumbers를 사용한 코드는 여러 곳이지만 실제로 rowOfNumbers를 순회하기 전까지는 아무런 작업도 수행되지 않는다. 스트림으로부터 값을 읽지도 않고, 읽은 값을 파싱하지도 않는다.

리소스 관리에 대한 관리 책임이 호출자에게 전가

 

만약 호출자가 리소스의 생명주기에 대해서 정확히 이해하지 못하고 있다면 리소스 누수가 발생하거나 오류가 발생할 수 있다. 

 

해결 방법)

파일을 닫기 전에 값에 대한 순회를 모두 마치면 된다.

using(TextReader t = new StreamReader(File.OpenRead("TextFile.txt")))
{
	var arrayOfNums - ReadNumbersFromStream(t);
    foreach(var line in arrayOfNums)
    {
    	foreach(var num in line)
        	Write(num);
        WriteLine();
    }
}

하지만 모든 문제가 이렇게 간단하게 해결 가능한 것은 아니다.

이같은 방식으로 문제를 해결하다보면 코드 중복의 문제 발생할 개연성이 높아진다.

 

파일을 닫기 전에 숫자에 대한 순회 과정을 모두 마무리하도록 했기 때문에 문제가 발생하지 않았다.

그런데 위와 같이 코드를 작성하면 실제로 파일을 어느 위치에서 닫아야 할지 알 수가 없다.

만약 값에 대한 순회를 수행하는 마칠 때까지 파일을 닫을 수가 없다.

만약 값에 대한 순회를 수행하는 부분은 API로 구성했다면 이 API를 호출하기 위해서는 파일이 반드시 열려 있어야 하는 상태여야 하고 결과에 대한 순회를 마칠 때까지 파일을 닫을 수가 없다.

 

파일을 여는 루틴은 존재하지만 열려 있는 파일은 어디서 닫히는 걸까?

결국 개발자가 통제할 수 없는 콜스택 어딘가에서 파일을 닫아야 할텐데 이 경우 파일의 이름이 무엇인지도 알 수 없으며, 파일을 닫기 위한 핸들에 접근할 수 없는 지경에 이르게 된다.

 

한 가지 확실한 해결책은 파일을 열고 시퀀스를 모두 읽은 후 그 시퀀스를 반환하도록 메서드를 변경하는 것이다.

여기서 중요한 점은 모든 요소를 읽은 후에 즉각 StreamReader 객체가 제거된다는 것이다.

즉 시퀀스에 대한 순회가 끝나면 파일 객체가 닫히게 된다.

 

따라서 IDisposable 인터페이스를 구현한 리소스에 대해 반복적으로 순회해야 한다면 다른 방법이 필요하다.

파일로부터 값을 읽고 처리하는 루틴에 델리게이트를 이용하여 서로 다른 로직을 전달할 수 있도록 코드를 작성하는것이 현명한 방법이다.

 

값비싼 리소스가 클로저에 의해 캡처될 때 발생할 수 있는 또 다른 문제점으로는

위의 경우처럼 심각하진 않지만 응용프로그램의 성능에 영향을 미칠 수 있다는 점이다.

 

컴파일러는 단일 메서드 내의 모든 클로저를 처리하기 위해서 단 하나의 중첩 클래스만을 생성하며 모든 클로저에서 사용하는 캡처된 변수들을 모두 이 클래스 내에 둔다.

따라서 그 변수가 필요한지를 면밀히 살펴야 하고 클로저가 변수를 제대로 정리하는지를 확인해야 한다.

 

✨ ref 키워드

- 값이 참조로 전달된다.

- 메소드 매개 변수는 값 형식이든 참조 형식이든 관계없이 ref를 통해 수정할 수 있다.

- 참조로 전달되는 경우 값 형식은 boxing 되지 않는다.

- ref 매개 변수를 사용하려면 메서드 정의와 호출 메서드가 모두 ref 키워드를 명시적으로 사용해야 한다.

void Method(ref int refArgument)
{
    refArgument = refArgument + 44;
}

int number = 1;
Method(ref number);
Console.WriteLine(number);
// Output: 45

 

⭐IEnumerable<T> 데이터 소스와 IQueryable<T> 데이터 소스를 구분하라(중요)

var q = from c in dbContext.Customers
	where c.City == "London"
    select c;
        
        
var finalAnswer = from c in q
                order by c.Name
                select c;

var q = (from c in dbContext.Customers
	where c.City == "London"
    select c).AsEnumerable();
        
        
var finalAnswer = from c in q
                order by c.Name
                select c;

 

두 예제의 결괏값은 동일하나 동작방식은 매우 상이하다.

1) IQueryable<T>

- LINQ to SQL 쿼리

LINQ to SQL 라이브러리가 모든 쿼리문을 결합하여 단번에 SQL 결과를 생성한다. 

앞의 예제의 경우 where절, order 절이 모두 결합된 단일의 T-SQL 구문을 만들어서 단 한 차례 데이터베이스 호출한다.

- IQueryable<T>의 기능을 사용

 

 

2) IEnumerable<T>

- 데이터베이스 객체를 IEnumerable<T> 시퀀스로 변경

첫번째 수행된 쿼리문이 IEnumerable<T> 시퀀스를 반환하므로 그 다음 작업은 LINQ to Objects 구현체와 델리게이트를 이용하여 수행한다.

첫 번째 쿼리문이 수행되면 데이터베이스에 쿼리를 전달하여 City 값이 London인 모든 레코드를 가져온다.

이후 가져온 레코드들을 Name 필드에 따라 정렬한다.

정렬 작업은 로컬 머신에서 수행된다.

- 데이터베이스가 아니라 로컬 컴퓨터에서 더 많은 작업을 수행

- 지연평가와 LINQ to SQL 내의 IQueryable<T>를 동시에 사용

 

대부분의 경우 쿼리 작업을 수행할 때 IEnumerable<T>를 사용하는 것보다 IQueryable<T>를 사용하는 편이 훨씬 효율적이다. 하지만 둘의 차이로 인해 일부 쿼리들은 둘 중 어느 한쪽에 대해서만 올바르게 동작하는 경우도 있다.

 

사실 이 둘은 개별 단계만을 비교해도 매우 다르게 동작한다는 것을 알 수 있다.

Enumerable<T>

- 쿼리식 내의 람다 표현식과 함수 매개변수를 나타내기 위해서 델리게이트를 사용

- Enumerable<T>내의 모든 메서드는 로컬 머신에서 수행 ➡ 이로 인해 상대적으로 더 많은 데이터를 응용프로그램의 메모리로 가져와야 함

 

Queryable<T> 

- 동일한 함수라 하더라도 표현식 트리를 이용하여 이를 처리

✨ 표현식 트리

쿼리 내의 동작들을 표현하기 위한 일종의 데이터 구조

- 표현식 트리를 분석한 후 분석된 로직을 제공자에 적합한 형태로 변경한 다음이를 데이터가 실제 위치하고 있는 컴퓨터에서 수행

- 따라서 로컬 컴퓨터로 가져와야할 데이터의 양이 상대적으로 적을 뿐 아니라 전체적으로 시스템의 성능도 개선

- 하지만 표현할 수 있는 쿼리 표현식이 IEnumerable<T>에 비해 상대적으로 제한

그렇다면 언제 Enumerable<T> 구현체를 써야할까?

IQueryable<T>제공자는 각각의 메서드를 분석하지 않는다.

대신 .NET Framework에서 구현하고 있는 일련의 연산자와 메서드 집합을 이해할 따름이다.

만약 쿼리 표현식이 다른 메서드를 호출하는 부분을 포함하고 있다면 Enumerable 구현체를 사용하도록 쿼리를 강제로 변경해야 한다.

IQueryable<T>가 IEnumerable<T>를 상속하고 있기 때문에 이러한 방식이 가능하다.

 

일반적인 경우라면 가장 저수준의 공통 클래스나 인터페이스를 이용하는 메서드를 작성하는 것이 올바른 방법이지만

IEnumerable<T>나 IQueryable<T>의 경우는 예외적이다.

이 둘은 거의 동일한 기능을 가지고 있지만 개별 인터페이스의 구현상의 차이가 명확하기 때문에 어떤 데이터 소스를 사용하느냐에 따라 반드시 그에 부합하는 인터페이스를 사용해야 한다.

특정 데이터 소스가 IQueryable<T>를 지원하는지 아니면 IEnumerable<T>만을 지원하는지를 확인할 수 있으므로 만약 데이터 소스가 IQueryable<T>를 구현한다면 반드시 이를 사용해야 한다.

 

간혹 특정 메서드가 동일한 T 타입에 대해서 IEnumerable<T>와 IQueryable<T>를 이용한 쿼리를 모두 지원해야하는 경우가 있다.

이 경우 코드 중복이 발생할 가능성이 높다.

이럴 때는 AsQueryable()을 사용하여 IEnumerable<T>를 IQueryable<T>로 변경하면 중복을 제거할 수 있다.

AsQueryable()은 시퀀스의 런타임 타입을 확인한다.

만약 시퀀스의 런타임 타입이 IQueryable이라면 IQueryable을 반환할 것이고, 반대로 IEnumerable 타입이라면 LINQ to Objects를 사용하여 IQueryable을 구현한 래퍼를 생성하여 반환한다.

이경우 Enumerable 구현체를 얻기는 하겠지만 IQueryable 타입으로 래핑된 객체를 얻게 된다.

 

⭐ 쿼리 결과의 의미를 명확히 강제하고,Single()과 First()를 사용하라

 

⭐ 바인딩된 변수는 수정하지 말라

C# 컴파일러는 쿼리 표현식과 람다 표현식을 모두 정적 델리게이트나 인스턴스 델리게이트 혹은 클로저로 변환한다. 이 중 어떤 것을 선택하느냐는 람다 본문의 코드가 어떻게 작성됐느냐에 따라 결정된다.

모든 람다 표현식이 항상 동일한 코드를 생성하는 것은 아니므로 컴파일러 입장에서 이에 대응하기 위한 가장 쉬운 방법은 델리게이트를 활용하는 것이다.

컴파일러는 select n * n 람다 표현식을 정적 델리게이트를 정의하는 것으로 변환한다.

 

컴파일러가 람다 표현식에서 사용하는 모든 지역변수를 포함하는 중첩 클래스를 생성한다는 것을 알 수 있다.

실제로 람다 표현식 내에서 사용된 모든 지역변수들은 중첩 클래스 내의 필드로 대체된다.

따라서 람다 내부에서든 혹은 람다 외부에서든 완전히 동일한 필드에 접근하게 된다.

또한 람다 표현식의 본문은 중첩 클래스 내의 메서드 내부에 포함된다.

 

여러 개의 쿼리를 연이어 수행할 때 바인딩된 변수의 값을 수정하게 되면

지연 수행과 컴파일러의 클로저 구현 특성 때문에 예상치 않은 문제가 발생할 수 있다.

따라서 클로저에 의해 캡처되어 바인딩된 변수는 수정하지 않는 것이 좋다.