LINQ 메모리 사용

NightMiya827·2023년 12월 17일
0

(블로그 이사하면서 옮겨진 글입니다)

이런 저런 방식으로 LINQ를 쓰다보니 '메모리를 여기서 과하게 사용하고, 결과적으로 쓸데없이 잦은 GC 호출을 만들게 되는 건 아닐까?'라는 생각이 들었다. LINQ동작에 대해서 거의 모르는 상태로 쓰고있었기 때문에, 간단하게 몇가지만 테스트를 해봤다.

결론은 어떻게 써야하는건지 저도 잘 모르겠습니다. LINQ구문으로 부르는 구문들을 포함해서 IEnumerable과 연관된 메소드는 너무나 많고, 우리가 그 메소드의 구현체를 모두 기억하기는 어려운 일입니다. 더군다나 그 구현체의 코드가 버전업마다 바뀌지 않는다는 보장도 없기때문에, 매번 업데이트마다 체크해야한다고 생각하면 더더욱 어려운 일이 됩니다. 이걸 잘 통제하면서 능숙하게 쓰는 방법은 도저히 답이 안보이지만, 그래도 일단 알아낸 것들은 기록해두고자 생각했습니다.

테스트코드

printMemoryUsage는 사용한 메모리를 출력하는 함수고, 우리가 주목해야할 정보는 difference입니다.(코드는 github에서 확인할 수 있습니다.)

        printMemoryUsage("");
        var enumerator = Enumerable.Range(0, 20 * 1024);
        printMemoryUsage(nameof(enumerator));
        var list = enumerator.ToList();
        printMemoryUsage(nameof(list));
        var orderedEnumerator = enumerator.OrderBy(x => x);
        printMemoryUsage(nameof(orderedEnumerator));
        var select = enumerator.Select(x => x);
        printMemoryUsage(nameof(select));
        var whereAndSelect = enumerator.Where(x => true).Select(x => x);
        printMemoryUsage(nameof(whereAndSelect));
        var orderedList = enumerator.OrderBy(x => x).ToList();
        printMemoryUsage(nameof(orderedList));
                       :: difference: 21KB, memory: 21KB
             enumerator:: difference: 10KB, memory: 31KB
                   list:: difference: 128KB, memory: 159KB
      orderedEnumerator:: difference: 0KB, memory: 159KB
                 select:: difference: 0KB, memory: 159KB
         whereAndSelect:: difference: 0KB, memory: 159KB
            orderedList:: difference: 128KB, memory: 287KB

아마 예상한대로, ToList를 호출하는 경우들에 한해서 실제로 데이터들이 담긴 List 콜렉션을 만들기 때문에 실제로 그만큼의 메모리를 할당하고, 그 외의 경우에는 메모리 할당을 하지 않습니다.(IEnumerable에 대한 메모리가 할당될 수는 있겠으나, 이 경우에선 매우 작은 크기이기 때문에 무시했습니다.)

여기까지는 예상대로였지만, 아래 예시는 저한테는 조금 의외의 결과를 보여주었습니다.

        foreach (var iter_enumerator in enumerator) {
            printMemoryUsage(nameof(iter_enumerator));
            break;
        }
        foreach (var iter_list in list) {
            printMemoryUsage(nameof(iter_list));
            break;
        }
        foreach (var iter_orderedEnumerator in orderedEnumerator) {
            printMemoryUsage(nameof(iter_orderedEnumerator));
            break;
        }
        foreach (var iter_select in select) {
            printMemoryUsage(nameof(iter_select));
            break;
        }
        foreach (var iter_whereAndSelect in whereAndSelect) {
            printMemoryUsage(nameof(iter_whereAndSelect));
            break;
        }
        foreach (var iter_orderedList in orderedList) {
            printMemoryUsage(nameof(iter_orderedList));
            break;
        }
        iter_enumerator:: difference: 0KB, memory: 287KB
              iter_list:: difference: 0KB, memory: 287KB
 iter_orderedEnumerator:: difference: 208KB, memory: 495KB
            iter_select:: difference: 0KB, memory: 495KB
    iter_whereAndSelect:: difference: 0KB, memory: 495KB
       iter_orderedList:: difference: 0KB, memory: 495KB

List객체를 기준으로 보자면 Iteration은 단순히 콜렉션에서 순서대로 읽어오는 것이고, IEnumerable을 기준으로 보아도 순서대로 다음 객체를 읽어오는 것이니 당연히 메모리 할당이 크게 될 일이 없습니다. 실제로 한가지 케이스를 제외하고는 메모리 할당이 전혀 되지 않고 있습니다. 하지만 iter_orderedEnumerator에서만 큰 메모리 할당이 이루어지고 있습니다. 왜 그런지 확인해보니, OrderBy의 리턴값은 IEnumerable이 아니라 IOrderedEnumerable입니다. 생각해보면 정렬된 값을 미리 들고있을리는 없으니, 값을 읽으려는 순간에 메모리를 사용하여 정렬하고 그 값을 사용하는 게 당연하긴합니다. 하지만 다른 LINQ 오퍼레이션은 아무 생각없이 썼는데 여기서만 갑자기 다른 동작을 보여서 역시 당황스럽긴합니다. 정말로 중요한 부분이라면 C#쪽 소스코드가 어떻게 되어있는지를 한번 확인하고 넘어가는게 좋을 것 같기도 합니다.

  • 번외
    List의 생성자 코드를 한번 확인해봅시다.
        [__DynamicallyInvokable]
        public List(IEnumerable<T> collection)
        {
            if (collection == null)
            {
                ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);
            }

            ICollection<T> collection2 = collection as ICollection<T>;
            if (collection2 != null)
            {
                int count = collection2.Count;
                if (count == 0)
                {
                    _items = _emptyArray;
                    return;
                }

                _items = new T[count];
                collection2.CopyTo(_items, 0);
                _size = count;
                return;
            }

            _size = 0;
            _items = _emptyArray;
            foreach (T item in collection)
            {
                Add(item);
            }
        }

ICollection이 아니면 foreach루프를 돌면서 아이템을 하나씩 추가해주는데, 시간 효율적인 방법은 아닐 것 같습니다. 그런데 IEnumerable은 ICollection이 아닙니다. 특별한 해결책을 제시하려는 건 아니지만, 메모리 효율 뿐만 아니라 이런 곳에서 시간효율도 조금씩 손해를 보고있을 수 있으니 성능이 아주 중요한 프로그램이라면 생각보다 많은 곳에서 디테일을 신경쓸 필요가 있어보입니다.

0개의 댓글

관련 채용 정보