동료가 무엇인가를 붙잡고 있길래 보았더니 발상이 기발한 흥미로운 코드가 있었다. 정확히 기억이 나진 않지만 다음과 같았다.

   1: List<object> listA = new List<object>();
   2: listContainer.Where(l=>
   3:      {
   4:           listA.AddRange(l);
   5:           return true;
   6:      });
   7: //listContainer의 type은 List<<List<object>>이다.

그렇다. 그는 List<> 객체가 ForEach라는 메소드를 가지고 있는 것을 몰랐던 것이다. 그가 겪었던 문제는 Where를 ForEach로 바꾸는 것으로 간단히 해결되었다. 하지만 질문은 계속 되어야한다. 왜 동료가 작성했던 저 코드로는 원하는 결과를 얻을 수 없었던 것일까? 이 글을 읽고 있는 당신, 더 이상 읽지 말고 여기서 잠깐 생각해보시라!

대부분 정답을 바로 머리에 떠올렸을것이다. 부끄럽지만 난 2번 디버깅을 한 후에야 원인을 알았다.(어처구니 없게도 내 첫번째 추측은 C#컴파일러가 똑똑해 저 코드를 의미없다고 판단해 삭제시켜 컴파일 했으리라는 생각이었다.) 정답을 보자. Where<>는 IEnumerable<>을 반환하고 IEnumerable<>은 IEnumerator<>를 반환하는데 이 IEnumerator<>의 MoveNext()와 Current{}가 불린 적이 없기 때문에 Where절이 실행되지 않았던 것이다. 에잇! 뭐라는 거야! 열거자를 반환했는데 열거자가 열거되지 않아 Where()내의 lambda문이 실행되지 않았다는 것이다.(What Does the Yield Keyword Really Generate?글을 보면 깊은 곳까지 자세히 알 수 있다.) 즉 위 코드는 다음과 같이 쓰면 실행된다.

   1: List<object> listA = new List<object>(); 
   2: var results = listContainer.Where(l=> 
   3:      { 
   4:           listA.AddRange(l); 
   5:           return true; 
   6:      }); 
   7: foreach(var result in results); 

물론 다음과 같이 쓸 수도 있다.

   1: List<object> listA = new List<object>(); 
   2: listContainer.Where(l=> 
   3:      { 
   4:           listA.AddRange(l); 
   5:           return true; 
   6:      }).ToList();
   7:  

그런데 동료가 굳이 foreach를 사용하지 않고 Where메소드를 사용하려했던 이유는 IEnumerable<> 조합 + 확장 메소드 + lambda 조합으로 코드를 작성할때 손이 훨씬 덜가기 때문이리라.(추측임) 변수이름 치고 . 찍고 Where절 누르고 이러면 인텔리센스로 스피디 하게 훅훅 지나간다. 그리고 코드에 ()=> 이런거 보이면 나 왠지 코딩 좀 하는 것 같아 보인다. 여기에 맛들이면 언젠가 부터 코드에 foreach, for가 사라지기 시작한다. IEnumerable<>에 없는 ForEach, For는 자주 사용하는 Library dll의 필수 아이템이다.

   1: public static void ForEach<T>(this IEnumerable<T> list, Action<T> action)
   2: {
   3:     foreach (var item in list)
   4:     {
   5:         action(item);
   6:     }
   7: }

그런데 이게 좋은 습관은 아닌 것 같다. 일단 ForEach 확장 메소드를 쓰면 그 자리에 없었던 함수 호출의 수가 집합의 크기만큼 생겨버린다. 게다가 Action<T> delegate를 통한 호출이라 Inlining을 통한 최적화도 쉬울 것 같지 않다. 뭐... 사실 여기서 생긴 성능 감소는 알고도 억지로 눈감는 수많은 문제들에 비하면 너무 사소해 그냥 지나가도 양심에 부담감이 없지만 break 와 return 키워드들을 쓸 수 없다는 것이 큰 아픔이다.(ForEach내의 return문의 의미는 return이 아니라 continue이기에) 아니나 다를까 최근에 ForEach와 lambda문으로 조합된 코드를 foreach로 변경해야 할 일이 있었다. 코드를 처음 만들 때는 필요 없었는데 나중에 break가 필요했기 때문이다. 이 후에는 ForEach보다는 foreach를 사용하는데 사실 여전히 ForEach 타이핑의 편리함이 못내 그립다.

결론 : foreach, for를 사랑해주세요.

잡담 : 그런데 빌드시 C# 컴파일러는 코드 삭제를 통한 최적화는 하지 않는건가?

   1: List<object> listA = new List<object>(); 
   2: var results = listContainer.Where(l=> 
   3:      { 
   4:           listA.AddRange(l); 
   5:           return true; 
   6:      }); 
   7: foreach(var result in results); 
   1: List<object> listA = new List<object>(); 
   2: listContainer.Where(l=> 
   3:      { 
   4:           listA.AddRange(l); 
   5:           return true; 
   6:      }).ToList();
   7:  

위 코드들 전부 최적화 옵션주고 컴파일 했을 때 허공으로 사라질 것이라 생각했는데 IL코드도 정상적으로 나오고 실행결과도 이전과 동일했다. 뭐, 열거자는 "열거"라는 의미가 있기에 저 코드를 의미없는 것이라 생각하지 않는구나! 라고 결론을 내리고 다음 코드로도 실험해보았는데

   1: for(int i=0;i<10;i++){}

이것도 정상적으로 IL 코드에 추가되더라. 그래서 C# 컴파일러는 코드 제거를 통한 최적화는 하지 않는다는 결론을 내렸다.(최적화 옵션을 주고 빌드했을 때 바뀐점은 첫번째 예의 코드내의 foreach 키워드가 while로 변한것이었다. 여기서도 foreach는 사랑받지 못한다. ㅜ.ㅜ). 아 그리고 .NET 프로그램은 2번 최적화 기회를 가지는데 IL 코드로 변환될 때가 첫번째요, IL코드가 Native코드로 변환되는 JIT 컴파일 타임이 두번째이다. 그런데 Language Compile Time보다 JIT Compile Time에 더 많은 최적화가 이뤄진다고 한다.(좀 의아하다.) 마지막 'for'문의 예는 IL 코드에 추가되긴 했지만 JIT Compile Time에 IL 코드 삭제를 통한 최적화가 이루어졌을 수도 있기 때문에 실제로 실행되었는지 여부는 알 수 없다.(이걸 알 수 있는 방법은 없나?)

--- 있었다. JIT 컴파일러가 생성한 기계어 확인하는 법을 보면 확인 할 수 있다. 정책임님 감사해요~^^

references : Release IS NOT Debug: 64bit Optimizations and C# Method Inlining in Release Build Call Stacks, What Does the Yield Keyword Really Generate?

신고
TAG