본문 바로가기

C#

LINQ(2) - 일반적인 LINQ 코드 작성 원칙 및 유의점

LINQ에 대한 여러가지

LINQ는 워낙 거대한 기능이라 사용방법이 복잡하다. 이 글에서는 LINQ에 관련된 여러가지 잡다한 이야기와 LINQ 코드를 사용할 때 주의할 점들에 대해 서술한다. 또한 이 글은 유니티에서의 사용을 전제로 삼고 있다. LINQ는 SQL로 변환될 수 있는 쿼리 표현식으로 널리 사용되지만, 여기서는 고려하지 않는다.

.


LINQ 코드 작성 원칙

이 글에서 제안하는 LINQ 관련 코드 작성 원칙이다.

 

1. 쿼리 구문보다 메서드 구문을 사용한다.

쿼리 구문은 일반적인 C# 코드와 너무 이질적인 모양을 하고 있다. SQL보다는 C#에 익숙할 유니티 프로그래머들이 굳이 더 생소하게 보일 쿼리 구문을 사용할 필요가 없다. 유니티에서 실제 DB에 쿼리할 일이 없으니 더욱 그렇다.

또한 쿼리 구문에서는 사용할 수 없는 LINQ 메서드가 많다. Average, Sum 등 IEnumerable 전체를 순회해서 값을 내는 메서드들이 대표적인 예이고, 그 외에도 메서드 구문 형식에서만 사용할 수 있는 기능이 여러가지이다. 한 코드 안에 어차피 메서드 구문을 사용하게 될 텐데 쿼리 구문을 굳이 섞으면 읽기가 어려워질 뿐이다.

 

2. 반환값은 var로 받는다.

제네릭을 사용하는 복잡한 반환형을 굳이 명시할 필요는 없다. 특히 유니티에서 사용할 경우 IQueryable 등을 사용할 일이 없기 때문에 더욱 고민할 필요가 없다.

 

3. 델리게이트들은 순수함수를 사용한다.

4. 순회는 foreach 문을 사용한다.

 

LINQ(1) - 함수형 프로그래밍에 대해서

함수형 프로그래밍은 프로그래밍을 하다 보면 누구나 한번쯤은 빠져들게 되는 주제이다. 이 글에서는 C#의 가장 중요한 기능 중 하나인 LINQ에 대해 설명하기 전, 함수형 프로그래밍에 대해 간략

tearsinrain.tistory.com

 

LINQ에서 사용하는 델리게이트들은 가급적 순수함수를 사용하는게 좋다. 순수 함수가 아니라면 선언형 프로그래밍의 장점을 살리기 어렵다. 코드를 읽을 때 변수의 값 변화를 따라가면서 로직을 파악해야 하기 때문이다. 만약 순수 함수를 사용할 수 없을 것 같을 경우, 애초에 해당 로직을 LINQ로 구현하는게 맞을지부터 다시 고려해야 한다. 또한 이 점이 바로 Array나 List 등에는 ForEach 메서드가 있지만, LINQ에는 ForEach에 해당하는 메서드가 없는 이유이다.

 

var arr = new int[10];
Array.ForEach(arr, i => Console.WriteLine(i));	// Array는 ForEach 같은 메서드를 제공한다.

IEnumerable<int> e = arr;
e.ForEach();               // 이런 메서드는 없다. 
                           // foreach에 해당하는 확장 메서드도 만들지 않는다.
foreach (var i in e)       // IEnumerable을 순회해야 할경우 foreach문을 사용한다.
	Console.WriteLine(i);
    
e.Select(static i => i + 1);   // LINQ에 넘기는 델리게이트로 순수 함수는 ok
var count = 0;

e.Select(i =>                  // 순수 함수가 아닌걸 사용하면 흐름을 읽기가 어려워진다.
{                              // 동작은 하지만 나쁜 코드이다.
    count++;                   // 지연 평가 때문에 버그를 발생시킬 확률이 매우 높다.
    return i + 1;					 
});

 


LINQ 코드 작성 시 주의할 점

 

1. 쓸데없는 ToArray, ToList 등을 하지 않는다.

foreach (var v in enumerable)             // IEnumerable을 그대로 사용하면 된다.
{
}

foreach (var v in enumerable.ToList())   // 반드시 필요하지 않다면 하지 않는다.
{
}

 

IEnumerable인 채로 사용하기가 왠지 꺼려지는지 ToList()등을 습관적으로 붙이는 경우가 상당히 많다. 정말 필요한 경우가 아니면 IEnumerable을 굳이 배열이나 리스트 등으로 만들 필요가 없다.

위와 같은 경우에서 ToList()가 필요한 경우를 굳이 예로 들면, enumerable이 List<T>인데 foreach 안에서 enumerable에 변경을 가하는 경우가 있다. 

var list = new List<int>();
foreach (var v in list.ToList())	// 이런 경우엔 list의 복사본이 필요하다.
{
    list.Add(v);
}

 

 

2. IEnumerable이 언제 평가되는지를 반드시 기억한다

IEnumerable은 그 자체로는 열거를 위한 인터페이스일 뿐 값을 평가하지 않는다. foreach 등을 사용하거나 LINQ의 All, Any, Count 등 값을 평가하는 메서드를 사용했을 때 비로소 평가된다.

그래서 복잡한 LINQ문을 두번 이상 평가하게 되면 상상이상으로 느려질 가능성이 있다. 두번 이상 평가하는 경우엔 한번만 평가하도록 로직을 수정하거나 ToArray()등을 사용해서 결과값을 캐싱해놓는 것이 좋다.

var heavyQuery = source     // 복잡한 LINQ문
    .Select( .. )
    .OrderBy( .. )
    .Where( .. )
    ...
    ...
    .Where( .. );
    
var count = heavyQuery.Count( .. );         // 한번 평가한다.
var selected = heavyQuery.First( .. );      // 다시 한번 처음부터 평가한다.
foreach (var v in heavyQuery)               // 또 처음부터...
{
    .....
}

 

LINQ를 사용하면서 주의를 기울이지 않으면 위와 같은 코드를 만들기 쉽다. 언제 평가하는지를 항상 고려하고 있지 않으면 O(N+M) 정도의 시간복잡도면 충분한 로직을 O(N*M)로 만들어버리는 경우도 흔하다.

 

 

3. All(), Any() 를 적극적으로 활용한다.

모든 원소에 대해 만족하는지, 혹은 하나라도 만족하는 원소가 있는지를 판단할때 Where와 Count등을 사용하려는 경우가 많은데, IEnumerable 전체를 검사하기 전에는 끝나지 않는 Count등과 다르게 All이나 Any등은 한개라도 조건을 만족하지 않는 / 만족하는 원소가 발견될 경우 바로 종료한다. 따라서 더 효율적일뿐만 아니라 훨씬 읽기 쉬운 코드를 만들 수 있다.

 

 

4. First()나 Count() 등에 있는 predicate를 인자로 받는 오버로드를 활용한다.

어떤 조건을 만족하는 원소의 수를 세고 싶을 때 Where와 Count의 조합을 먼저 떠올리기 쉽다. 그러나 Count에 특정 조건을 만족하는 원소를 세는 오버로드가 이미 있다. LINQ의 오버로드는 매우 풍부하게 되어 있으므로 익숙해질 필요가 있다.

count = enumerable.Where(i => i > 0).Count();  // X
count = enumerable.Count(i => i > 0);          // O

 

 

5. LINQ를 꼭 써야 한다는 생각 대신 일반적인 루프 활용을 고려한다.

지나치게 복잡한 경우 LINQ를 굳이 무리해서 사용하기보단 루프로 풀어서 작성하는 편이 나을 수 있다. 한 가지 예를 들자면, 여러 캐릭터가 있고 캐릭터에 스킬이 여러개가 있다고 가정하자. 그리고 스킬의 피해는 캐릭터의 공격력과 스킬 계수의 곱이라고 할때, 모든 스킬 피해를 열거하려고 하면 어떻게 해야 할까?

public class Character
{
    public float Attack;
    public List<Skill> Skills;
}

public class Skill
{
    public float Multiplier;
}

public static IEnumerable<float> EnumerateDamageLINQ(List<Character> characters)
{
    return characters
        .SelectMany(character => character.Skills.Select(skill => character.Attack * skill.Multiplier));
}
public static IEnumerable<float> EnumerateDamageForEach(List<Character> characters)
{
    foreach (var character in characters)
    {
        foreach (var skill in character.Skills)
        {
            yield return character.Attack * skill.Multiplier;
        }
    }
}

 

어느 편이 더 보기 편할까? 이 정도의 복잡도로는 LINQ가 더 나아 보일수도 있다. 그러나 기본적으로 LINQ 메서드 안에 들어가는 함수들이 몇줄이상 길어지거나, 위의 예 처럼 그 안에서 또 다른 LINQ문을 사용하는 경우가 된다면 무리하게 LINQ를 적용하기보단 그냥 풀어서 작성하는 편을 고려하는게 낫다.