in 포스트

UniRx, UniTask, 제네릭 메타 데이터와 관련된 최적화 경험

유니티 IL2CPP 제네릭 메타 데이터가 너무 거대해서 최적화를 시도한 경험.

프로젝트 내에 어셈블리가 엄청난 양의 일회성 스크립트, 중복되거나 잘못된 구현으로 불필요하게 엄청 거대했다.

여기에 다수의 일회성 스크립트들도 UniRx를 사용하면서 가능한 제네릭 조합 수가 엄청나게 불어났다. 그리고 UniTask를 통해 메서드를 비동기로 선언하면서 같은 함수에 대한 시그니쳐가 더 늘어났다.

결국 어셈블리가 더욱 비대해졌으며, 그 이상으로 메모리에 로드되는 제네릭 타입에 대한 메타 데이터가 너무 거대해졌다.

런타임 코드와 메타 데이터가 거대해지면서 메모리는 항상 파편화된 상태로 존재하게 되었고, 타입 정보를 조회하는데 걸리는 시간도 길어지게 되었다.

이 문제는 다음 과정을 통해 해결할 수 있다.

- 적극적인 코드 스트립핑

- 빠른 빌드 사용

- 명확한 목적으로만 리플렉션 사용

- 이외에 당연한 작업들 (불필요하게 복잡한 코드 제거 등)

우선 적극적인 코드 스트립핑을 통해 불필요한 코드들, 불필요한 제네릭 타입을 처음부터 배제해야 한다.

또한 적극적으로 코드 스트립핑을 도입하면 명확한 규율없이 사용되던 리플렉션들에서 공격적으로 예외를 발생시켜 이들을 찾아 고칠 수 있다.

느린 빌드는 가능한 제네릭 조합들에 대해 글로벌 메타 데이터&머신코드를 미리 빌드하게 된다. 따라서 메타 데이터와 빌드 크기가 처음부터 크지만, 필요시 지연생성하는 방법에 비해 게임 시작 속도, 런타임 오버헤드가 작아진다.

반대로 빠른 빌드는 빌드 사이즈를 작게 하기 위해, 모든 제네릭 조합에 대해 미리 메타 데이터&머신코드를 생성하지는 않는다. 따라서 첫 로드시 적은 크기의 메타 데이터가 로드되게 할 수있다. 우리는 메타 데이터가 워낙 커서 빠른 빌드 설정이 필요했다.

UniTask의 경우 유니티에서 비동기 메서드를 적극적으로 사용할 수 있다는 장점이 함수 시그니쳐가 늘어난다는 단점에 비해 압도적으로 컸다. 따라서 그대로 사용하는 것이 나았다.

또한 UniRx가 생성하는 제네릭과 메타 데이터가 압도적으로 컸으므로 UniTask를 정리한다고 애쓰는 것보다 UniRx 하나만 정리하는데 집중하는게 이득이 컸다.

그런데 결론적으로 프로젝트에서는 위 해결방안들을 제대로 시행하지는 못했다.

기능 구현을 담당하는 곳과 최적화 또는 안정화를 담당하는 곳의 책임이 나누어져있다보니, 기능 구현에 있어서 최적화나 클린 코드의 필요성에 공감대가 없던 것이다.

특히 빠른 기능 구현을 위해 리플렉션이 남용되고 있었는데 이를 고치는 것에는 저항이 상당했다.

또한 리플렉션 등을 통해 어셈블리와 전체 타입을 통채로 로드하는 코드 등이 존재하였기에, 빠른 빌드를 사용한 상태에서 이들 코드가 동작하면 순간적으로 매우 큰 메타 데이터를 생성 시도하면서 심각한 프리즈 또는 앱 크래시를 유도하였다.

이는 빠른 빌드를 유지하며 잘못된 곳에서 예외를 적극적으로 발생시켜, 이들을 수정해야 했으나, 결론적으로는 반발에 이를 시도하지 못하고 전체 메타 데이터를 모두 빌드하는 설정을 유지하게 되었다.

여기서 깨닫은 점은, 프로젝트를 설계할때, 제약을 가장 최초에 어떠한 Loose ends 없이 최고 수준으로 구성해야 한다는 것이다.

프로젝트 설계자는 구현자들이 가능한 모든 exploit을 써서 설계의 울타리를 탈출하여 코드를 복잡하게 만들거라고 가정해야 한다.

위 사례에서는 프로젝트 처음부터 어느정도 저항을 감안해서라도 높은 수준의 코드 스트립핑과 빠른 빌드 설정을 활성화시켰어야 했던 것이다.

또한 구현자에게 최적화와 안정화 의무를 적극적으로 부여해야 한다.

그렇지 않고 최적화를 특정 팀을 나누어 그들의 의무로만 할당하면, 해당 최적화 팀이 매우 적극적인 지원을 받지 않는 이상, 적대적인 분위기 속에서 최적화 작업을 제대로 수행하지 못할 확률이 높다.