Log Stash

as an Industrial Personnel

프로그래밍

Using Linq in Unity3d

SavvyTuna 2017. 10. 29. 21:08

애플의 64비트 정책으로 ios에선 il2cpp를 필수로 사용해야 한다는 점 덕분에 유니티 엔진에서 Linq를 사용할 수 있게 되었다. (Thanks apple)

예전에 안드로이드 앱 개발을 할 때 '비동기 로직을 동기처럼 사용할 수 있다'는 말에 홀려 RxJava를 접했던 적이 있었다. 그 당시엔 내가 Promise, Future나 함수형 프로그래밍에 대한 필요성이나 개념이 없어서도 그랬지만, 당시엔 Rx에 대한 튜토리얼이나, Best practice 관련된 문서가 거의 없었기 때문에 (물론 내가 못 찾았을 수도 있지만) 지금 보면 아주 괴상망측한 코드를 만들어 냈었다.

이후에 'Funtional thinking' 책을 읽고, 자바스크립트에서 Promise 패턴을 써보면서 내가 이전에 어떤식으로 코드를 잘못 작성했는지 알아가게 되어가니, (특히 flatmap을 비동기를 동기로 바꿔주는 '마법의 함수'로 남발했었다) 현재 프로젝트에서 사용하고 있는 유니티 엔진 위에서 Linq를 좀 더 적극적으로 사용하고 싶어지더라. 그래서 요즘은 그러고 있다.

물론 성능에 치명적인 인게임 루틴 말고 UI나 데이터 관련된 루틴에서만 사용하고 있다. 게임 루틴 내부에선 GC뿐만이 아니라 동적할당도 최소화 해야 하는 판국이라 잘 안 쓰게 되는듯.

아무튼, Linq를 사용하면서 원래라면 List<>Array타입을 넘겨줬을 만한 곳에 (Lazy evaluated collection인) IEnumerable<>을 사용하고, 초기화 값으로 null 대신에 Enumerable.Empty<>()를 사용하고, 콜렉션 데이터를 거르거나 변경할때 foreach문 대신 SelectWhere를 사용하는 나날을 보내고 있는데, 내가 생각했을때 좋은점이 (지금 당장 생각해보자면) 크게 두 가지가 있다.

  1. Null Check와, Null Pointer Exception을 줄일 수 있다.

    빈 콜렉션은 foreach를 돌릴 수 있지만 null은 아니니까.

    요즘 '힙'한 코틀린이나 스위프트처럼 애초에 null을 넣을 수 없게 하거나, 스칼라처럼 Option[]이 있어서 별도의 Null Check없이 NPE를 피할 수 있는건 아니지만, 그래도 많이 사용하는 유틸 클래스의 메소드 루틴이 null 대신에 empty 콜렉션을 반환하게 함으로써, Null 체크를 하지 않더라도 익셉션이 터져나가지 않게 할 수 있다.

    if (sth != null) return;같은 이런 정말 귀찮은 if문 하나가 줄어든게 얼마나 큰 발전인지, 뭐만 하려고 하면 Null check를 해야하고, 이것 때문에 메인 로직보다 방어 코드가 더 많아지는 상황에 질린 개발자라면 심히 공감이 갈것이라고 생각한다.

  2. 콜렉션 데이터를 조작하는 루틴이 논리별로 나눠져, 깔끔해진다.

    예전에 Linq를 사용하지 못 하던 시절에 짜여진 콜렉션 데이터들을 변환하고, 걸러내고, 조합하는 로직들을 읽다보면, 한번 보고 빠르게 이해하기가 힘들다. 시간을 투자해서 함수의 정의 부분을 이리저리 뛰어 넘나들면서 들여다봐야 한다는 말이다. (내가 작성했던 것들도 마찬가지) 때문에 코드를 읽다가 foreach문만 보면 또 어디까지 여행을 하게 될지 일단 긴장부터 하게 된다.

    물론 이게 개인적인 차이일 수도 있지만, 위의 foreach문과 비교해봤을때, Linq로 콜렉션을 변환하는 루틴들은 나중에도 읽기가 쉬웠다. 각각의 함수 하나 하나가 단 '하나'의 변환 로직만을 담당하고 있는게 제일 크다. (물론 이것도 이상하게 작성 하자면 또 못 할것은 없지만)

    나중에 필터를 추가하거나, 정렬 순서를 바꿀때도 마찬가지. IEnumerable만 이리저리 던져준다면 다른곳에서도 각자의 로직으로 맘대로 콜렉션들을 조작할 수 있다.

(비교적) 최근에 있었던 일을 간단한 예로 하나 들자면, 어떤 게임 오브젝트의 바운더리(Bounds)를 구해야 할 일이 있었다. 단순히 렌더러 컴포넌트를 가져와서 바운더리를 가져올 수 있다면 좋았겠지만, 이 오브젝트의 특성상 수동으로 자식놈들의 바운더리를 다 합쳐서(Encapsulate) 가져와야 했었다. 게다가 몇몇 자식들은 보여지지 않게 하기 위해 카메라 밖으로 밀려져 있을 수 있는 상황이었다. (왜 SetActive()를 안 썼는지 모르겠지만...)

이 때 그래서 이런식으로 작성했다. (물론 앞 뒤로 있는 프로젝트 관련 루틴들은 다 쳐내고 위에서 설명한 상황만 따로 재구성함)

/// <summary>
/// Get an Encapsulated bounds
/// </summary>
public Bounds GetBounds()
{
    Renderer[] renderers = GetComponentsInChildren<Renderer>();

    if (renderers != null)
    {
        // 모든 자식들의 렌더러 컴포넌트에 대해서,
        // 1. x,y 좌표가 5000이상인 놈들은 deactivated 된 놈으로 간주하고 걸러낸다. (filter, Linq에선 Where함수)
        // 2. 렌더러에서 => 렌더러의 Bounds로 변환. (map, Linq에선 Select함수)
        // 3. 변환된 bounds들을 하나로 병합. (reduce, Linq에선 Aggregate함수)

        Bounds encapsulated = renderers
            .Where(renderer => 
            {
                return (-5000f < renderer.bounds.min.x && renderer.bounds.max.x < 5000f 
                        && -5000f < renderer.bounds.min.y && renderer.bounds.max.y < 5000f);
            })
            .Select(renderer => renderer.bounds)
            .Aggregate((b1, b2) => 
            {
                b1.Encapsulate(b2);
                return b1;
            });

        return encapsulated;
    }
    return default(Bounds);
}

딱히 별로 긴 루틴도 아니지만 foreach로 작성한다면 좀 귀찮아진다. 좀 더 말하자면, renderer.bounds.maxx, y가 5000.0f 이상인 Bounds를 걸러내야 한다는점 때문에 루틴이 보기에 안 좋아지거나, 아니면 임시 변수로 메모리를 할당해야 하는 상황이 온다.

foreach로 이 필터링을 구현하려면

  1. if 조건을 만족하는 Bounds만 임시 리스트로 보관해뒀다가 걔네들만 Encapsulate()
  2. Bounds 변수를 하나 만들어서, 맨 첫번째 RendererBounds로 초기화 한 다음, 리스트를 돌면서 조건에 맞는 BoundsEncapsulate()

이런 방식들을 사용할 수 있다.

첫 번째 방법은 코드가 이해하기 어렵진 않겠지만 임시 리스트를 할당해야 하는 측면에서 약간 꺼려졌고, 두 번째 방법은 그럭저럭 괜찮아 보였지만 맨 첫번째 요소가 필터링 되어야할 요소일 경우라면 초기화 할 때 루프를 돌면서 if 조건을 만족하는 첫번째 Bounds를 찾아야 하는데, 그 경우에 똑같은 조건문을 두 번 쓰거나 조건문만 판단하는 람다 함수를 하나 만들거나 하는 방식으로 해결할 수 있을것 같았지만, 둘 다 별로 좋지 못한 방법이라고 생각했다.

그래서 최종적으로 위 처럼 구현하는게 가장 깔끔하다고 생각했다. 요즘은 이런식으로 왠만한 콜렉션에 Linq 익스텐션 함수를 자주 쓰는 편임.

이렇게 쓰다보면 유니티에서 왜 이렇게 Eager evaluated된 List<>나 배열 타입으로 많이 넘겨주는지 아쉬울 따름이다. IEnumerable<>타입으로 넘겨준다면 내가 필터링, 매핑 할거 다 하고 마지막으로 진짜 evaluation이 필요한 시점에 ToList()ToArray()를 해줄텐데.