넥슨이 개발하고 서비스하는 액스(AxE)는 본래 싱글 플레이 게임으로 개발이 시작됐다. 이후 MO 게임으로 노선을 변경했고, 최종적으로는 MMORPG 게임으로 유저 앞에 선보여졌다. 이 개발 과정에서 유니티 엔진의 모바일 자유 시점 MMORPG AXE를 개발하면서 발생한 문제사례를 NDC 2018에서 들을 수 있었다.

세션은 세 부분으로 나뉘어 진행됐다. 먼저 조우진 개발자가 액스 개발에 사용된 디버그, 프로파일러, 작은 사례들을 소개했다. 뒤이어 이용서 개발자가 액스 서비스 도중 일어난 메모리 문제와 그 해결 과정을 발표했다. 마지막으로 안종우 개발자가 싱글 플레이로 시작한 액스를 MMORPG로 만드는 과정에서 진행된 렌더링 과정을 소개했다.



▲ 넥슨레드 조우진 개발자

조우진 개발자가 액스 개발에 유니티 엔진을 쓴 이유로 밝힌 이유는 네 가지다. 먼저 유니티는 다양한 게임 개발에 이미 쓰인 검증된 모바일 엔진이라는 점, 프로그래머가 아니더라도 에디터로 쉽게 게임을 편집할 것을 기대할 수 있다는 점, 액스 프로토타입을 개발하면서 조직이 유니티에 익숙해졌고 또 일부 콘텐츠가 구현됐다는 점이다. 마지막으로 꼽은 이유는 이제 유니티로도 높은 수준의 그래픽 표현을 기대할 수 있다는 점이다. 넥슨레드가 액스 개발에 쓴 유니티는 5.6버전이다.

유니티를 쓴 이유를 전한 조우진 개발자는 다음으로 액스 개발 당시 사용한 프로파일러 프로그램을 소개했다. 먼저 소개된 프로파일러 프로그램은 유니티에 내장된 '유니티 프로파일러'이다. '유니티 프로파일러'는 쉽게 사용할 수 있다는 장점이 있다. 그는 이 프로그램으로 게임의 CPU, 렌더링, 메모리, 오디오, 비디오, Physics 등의 정보를 확인할 수 있었다고 전했다.

함께 사용한 Frame Debug는 '유니티 프로파일러'와 같이 쓰기 좋다는 장점이 있다. 조우진 개발자는 Frame Debug로 Draw 순서나 횟수를 파악하기 쉬워 렌더링 최적화에 용이하다고 소개했다.

iOS 최적화를 위해 이용한 프로그램은 Xcode의 Instruments이다. Instruments는 시간 단위로 프로파일링이 가능하다.

그러나 위의 프로파일러 프로그램은 문제 추적이 힘들다는 단점이 있다. 이를 극복하기 위해 액스 개발팀에서 사용한 것은 Apteligent의 Crittercism이다. 이 프로그램 역시 유니티에 적용이 쉽다는 장점이 있다. 또한, 문제 추적을 위한 여러 방법을 제공한다. 거기다 자동화를 위한 도구까지 제공해 프로파일링 작업을 손쉽게 해준다. 그러나 Crittercism는 Android Native Crash Report를 제공하지 않는다는 단점이 있었다. 단순하게 안드로이드 상에서 발생한 유니티 스크립트의 리셉션 정보는 수집되지만, 튕길 때의 정보는 못 받았다.

그는 Android Native Crash Report 수집을 위해 Fabric의 Crashlytics를 사용했다고 전했다. Crashlytics는 Crittercism의 단점을 해결해주었고, 필요한 정보를 그래프 등의 도식화로 편하게 보여준다.


액스를 개발하며 부딪친 가벼운 문제들

액스는 원래 싱글 플레이 게임으로 개발이 시작됐다. 이후 MMORPG로 노선을 바꿨는데, 맵이 넓어지고 다양한 기능이 추가되면서 Scene 전환 시 메모리 문제가 부각됐다.

Scene 문제 해결을 위해 프로파일링을 하니 Scene A에서 B를 로딩하면, B 로딩이 끝나야 A에서 사용한 메모리를 정리한다는 것을 발견했다. 이 때문에 순간적으로 메모리 사용량이 많아졌고, Scene 전환 시 버벅대는 문제가 발생했다.

▲ Scene 전환 시 급격하게 변하는 그래프

조우진 개발자는 이 문제를 두 Scene 사이에 Empty Scene을 만드는 트릭으로 해결했다고 전했다. A에서 B로 넘어가기 전 Empty Scene을 로딩하게 했고, 이 로딩이 끝나면 Empty Scene에서 B를 로딩하도록 하는 방법이다.

다음으로 액스 개발팀이 발견한 문제는 Distance 검사로 발생한 이슈다. 액스에 필요한 필드에서 이벤트 존, 특정 캐릭터, NPC, 채집물 등 거리에 따라 동작하는 기능들의 Distance 검사를 스크립트로 구현했었다. 그런데 다양한 기능 추가와 오브젝트 수 증가로 별문제가 되지 않을 거로 생각했던 Distance 검사도 부담이 되기 시작했다고 그는 밝혔다.

해결하기 위해 Distance 검사를 위한 프로그램을 진행했다. 이때의 목적은 정확한 Distance 계산이 아니라, 해당 객체가 캐릭터 범위 안에 들어오는가에 대한 검사이다. 범위 안에 들어오는지만 확인하더라도 게임 플레이에 지장을 주지 않고, 부담도 적기 때문에 이 방법을 택했다고 조우진 개발자는 전했다.

테스트 방법은 10,000개의 오브젝트를 랜덤하게 흩뿌리고, 일정 속도로 움직이는 캐릭터를 생성해, 캐릭터를 기준으로 10,000개의 오브젝트와의 Distance를 비교하는 방식이다. 첫 검사는 코드로만 계산하는 것으로 시작했다. transform position에 접근하는 코드를 짜 Distance를 측정했다. 그러나 이 방식은 transform position 값에 접근하는 것만으로도 꽤 많은 부담이 갔고, 계산 역시 쉽지 않았다.

그래서 액스 개발팀은 유니티 내 Physics.OverlapSphereNonAlloc를 사용하는 방법을 택했다. 이 방법은 코드로 직접 검사하는 것과 비교하면 부담이 거의 가지 않았다. 그러나 검사한 오브젝트가 영역 안으로 들어온 것인지, 나간 것인지 따로 확인이 필요했다. 그리고 매번 업데이트에서 검사를 필요로 했다.

마지막으로 택한 방법은 Distance 검사를 Physics Collider Trigger로 구현하는 방식이다. 검사 기준이 되는 오브젝트에 Distance 크기의 SphereCollider를 추가했다. 이후 검사 대상을 레이어로 묶어, OnTriggerEnter, OnTriggerExit에서 필요한 기능을 처리했다. 덕분에 앞의 방법의 단점이었던 오브젝트가 들어온 것인지 나간 것인지를 확인할 수 있었다.

조우진 개발자가 전한 Physics Collider Trigger의 단점은 Collider 추가를 위해 오브젝트 수가 늘어날 때 부담이 될 수 있다는 점이다. 또한, 한정된 레이어를 계속 사용할 수도 없다.

▲ 필요한 때에 따라 세 방법을 번갈아 사용했다

액스 개발에서도 enum 키워드를 사용한 열거형은 약 500개이다. enum은 유니티 개발 시 자주 사용하는 기능인데, enum 키를 사용하는 dictionary가 문제 될 수 있다고 조우진 개발자는 전했다. dictionary는 키로 사용되는 모든 method에서 comparer의 equals, GetHashCode method로 키를 비교하게 된다.

dictionary의 기본 comparer는 Object.GetHashCode(object)이다. Object.Equals(object)를 사용하는 참조 형식이다. enum은 Value type이므로 키로 사용될 때 박싱이 발생한다. 이는 struct도 value type으로 마찬가지이다.

▲ enum, dictionary 문제를 확인하기 위한 테스트 코드

▲ 어디에서 garbage가 발생했는지 확인할 수 있었다

테스트를 거치니 Add에선 GetHashCode가 ContainsKey와 TryGetValue, get에선 GetHashCode와 Equals 둘 다 사용되며 garbage가 발생함을 확인할 수 있었다.

▲ 위 문제를 comparer을 만들어 적용하니

▲ 문제가 해결됐다

조우진 개발자는 문제 해결을 위해 역배열을 하는 dictionary를 만들고, comparer을 만들면 된다고 전했다. 결론은 comparer을 만들면 된다는 것이다.


원인을 알 수 없는 메모리 문제 해결하기

다음으로 넥슨레드 이용서 개발자가 강연을 이어갔다. 이용서 개발자는 액스 클라이언트 프로그래머로, 다양한 메모리 관련 이슈를 해결해왔다. 그는 "라이브 서비스 이후 액스 플레이 도중 화면이 꺼진다는 유저들의 제보가 빗발친 적이 있었다"라고 말하며 강연을 시작했다.

▲ 넥슨레드 이용서 개발자

액스 개발팀은 유저 동향을 통해 주로 안드로이드 기기에서 클라이언트가 강제 종료되는 문제임을 확인했다. 그래서 정확한 문제 파악을 위해 수십 종의 테스트 기기에서 Crashlytics에 수집되는 crash와 같은 문제를 일부러 발생시켜 tombstone을 확인하는 작업을 진행했다.

이용서 개발자는 정말 메모리가 부족한지 확인하기 위해 "수집된 crash 주소 값을 함수 signature 함수를 확인했다"고 전했다. 이어 addr2line.exe로 symbol 파일에서 주소 값으로 함수를 확인할 수 있고, symbol 파일은 유니티 설치 폴더에서 확인 가능하다고 덧붙였다.

▲ symbol 파일 주소 예시

이용서 개발자는 "symbol 파일을 프로파일링해보니 OutOfMemory가 발생함을 확인할 수 있었다" 전했다. 그러나 결과에 의구심이 들었다고 덧붙였다. 많은 최적화 과정을 거친 액스였기에 메모리가 부족한 현상은 이해가 가지 않았기 때문이다. 유니티 프로파일러로 확인해도 특이사항은 발견되지 않았다. 어셋 데이터도 마찬가지였다.

액스 개발팀은 다양한 검수 도중에 OutOfMemory의 원인이 메모리 단편화가 아닐까라고 추측했다. 확인을 위해 GC에 log를 심기로 했다. 무엇이 단편화를 발생시키는지 찾기 위해 실제로 파괴되는 C# 객체를 추적했다. GC가 실제로 파괴되는 시점과 파괴되는 객체를 type 별로 통계를 냈다.

▲ 메모리 단편화를 추적하는 과정

▲ log를 심어 원인을 찾아갔다

▲ 해당 코드에 5초에 한 번씩 누적된 통계를 log로 찍어주기로 했다

▲ 결과 범인을 찾을 수 있었다

액스 개발팀이 찾아낸 문제의 원인은 NavMeshAgent.path였다. NavMeshAgent를 읽을 때마다 새로운 NavMeshPath를 생성하고 내부 데이터를 복사해 새로운 객체를 return 했기 때문에 문제가 발생했다.

▲ 이 문제로 메모리가 단편화되어, 에셋이 들어설 공간이 없었다

이용서 개발자는 "NaveMeshPath 생성이 반복되어 생긴 문제이니 coroutine를 사용하지 않으면 해결될 거로 추측했다"고 밝혔다. 액스 개발팀은 coroutine 동작에서 업데이트마다 current를 확인해 적절한 처리를 하고, WaitForSeconds 생성지점 1초 후에 MoveNest()를 한 뒤, MoveNext()가 false가 나올 때까지 반복하도록 만들었다.

이 방법으로 액스 개발팀은 문제 해결과 함께 coroutine을 사용하지 않으니 메모리 할당이 없다는 장점을 갖게 되었고, 메모리 HeapFree가 증가하는 이슈를 수정할 수 있었다.


싱글 플레이같은 MMORPG 그래픽 최적화

▲ 넥슨레드 안종우 개발자

액스가 싱글 플레이에서 MMORPG로 바뀌자 당면한 문제는 프레임 드랍 현상이다. 애초에 화려한 액션을 내세운 싱글 플레이가 MMO로 넘어가니 많은 캐릭터와 배경 오프젝트, 이펙트가 충돌하는 일은 당연했다. 안종우 개발자는 "그럼에도, 액스 개발팀은 화려한 액션을 포기하기 싫었다"고 전했다. 두 마리 토끼를 다 잡기 위해 액스 개발팀은 프로파일러로 문제를 찾아갔다.

유니티 프로파일러로 찾은 문제점은 배경과 캐릭터, 스킬 이펙트에서 프레임 드랍 현상이다. 배경과 캐릭터 폴리곤 수가 문제 됐고, 스킬 이펙트에서 많은 draw call이 감지됐다. 그러나 GPU 관련 문제는 찾지 못해 Xcode GPU 프로파일러를 사용했다. Xcode GPU 프로파일러로 카메라 포스트 이펙트가 문제의 50% 이상임을 확인했다.

액스 개발팀이 배경과 캐릭터 최적화를 위해 사용한 방법은 Level Of Detail(LOD)이다. LOD는 거리에 따라 적합한 퀄리티의 이미지를 보여주는 기술이다. 안종우 개발자는 "객체가 가까이 있을 경우 많은 폴리곤이 사용된 이미지를, 멀리 있으면 적은 폴리곤의 이미지를 보여주는 것으로 최적화를 진행"했다고 전했다.

그는 "캐릭터 Shader 최적화를 위해 가까울 경우 Normal Map을 사용하고 Pixel Lighting 연산을 사용해 고품질 이미지 노출시켰다"고 설명했다. 반대로 캐릭터가 멀리 있으면 Normal Map을 사용하지 않고 Vertex Lighting 연산을 사용해 부담을 줄였다.

▲ "어차피 멀리 있는 건 고품질로 해도 티가 안 난다"

그러나 LOD를 사용하면 거리에 따라 이미지 변환시 이질감이 드는 현상(poping)이 발생했다. 고품질에서 저품질로 이미지가 바뀌는 순간, 많은 유저가 이상하다고 여기는 것이다. 액스 개발팀이 poping을 해결하기 위한 방안으로 사용한 것은 Cross Fade Shader 기법이다. 그는 "변하는 중간값의 이미지를 자연스레 보여줘 이질감을 최소화할 수 있었다"고 전했다.

다음으로 안종우 개발자는 카메라 포스트 이펙트 최적화 과정을 소개했다. 액스에 사용된 카메라 포스트 이펙트는 세 가지로, Bloom, Tone Mapping, LUT(color grading look up table)이다.

Bloom은 캐릭터가 칼을 휘두를 시, 칼 주변으로 광원 효과가 은은하게 빛나는 효과를 준다. 또는 로비에서 판금 갑옷 캐릭터의 찬란한 모습을 보여줄 때도 사용된다. 안종우 개발자는 AD에게 "Bloom 꼭 써야 하나?" 라고 물으니 "예쁘게 보이기 위해서는 필요하다"라는 답변을 받았다고 한다.

▲ Bloom은 '있어' 보이게 해준다

둘 중 액스 개발팀은 필드의 이펙트와 배경 최적화에 집중했다. 먼저 기존에 1/4 크기로 쓰였던 shader 이미지들을 1/24로 크게 줄였다. 그리고 7번에 걸쳐 진행되던 shader 과정을 3번으로 줄였다. 그 결과 아이폰6+ 기준 bloom이 8ms 걸리던 것을 5ms로 줄일 수 있었다.

다음으로 안종우 개발자는 Tone Mapping과 LUT를 통합해 가장 큰 해상도 render texture의 계산을 하나의 shader에서 관리하도록 만들었다고 전했다. 그 결과 기존 10.2ms 걸리던 작업을 7ms까지 줄일 수 있었다.

그는 이어 캐릭터 스킬 이펙트 최적화 노하우를 공유했다. 스킬 이펙트 최적화 과정은 draw call batching 최적화이기도 하다.

▲ 싱글 게임 퀄리티를 MMORPG에서 살리는 게 관건이었다

Draw call Batching은 Draw call이 많으면 CPU에 부담이 가기 때문에, 묶어서 GPU에 전달하는 기술이다. 이를 위해서는 같은 material이어야 한다는 조건이 있다. 이펙트 종류를 묶어 아틀라스 작업을 거쳤다. 그러나 안종우 개발자는 "아틀라스 후에도 여전히 많은 draw call batching이 있었다"고 전했다. 이유를 살펴보니 스킬, 먼지, 폭발, 바닥 이펙트의 depth 값이 교차한다는 것을 확인했다.

액스 개발팀은 해결을 위해 rendering 순서 옵션을 추가했다. material 별로 rendering 순서를 강제했고, 이펙트를 바닥에서 올라가는 순서대로 값을 설정했다. 그 결과 draw call batching이 기존 20에서 80까지 나가던 것을 5에서 10까지로 줄일 수 있었다.