Unity가 직면한 기술적 문제들

UPDATE: 이 글에 대한 피드백을 정리한 Unity가 직면한 기술적 문제들 2를 작성했습니다.

자체 게임 엔진을 개발할 여력이 안 되는 중소 모바일 게임 개발사가 선택할 수 있는 게임 엔진은 크게 Unity3DUnreal Engine으로 양분되는 것 같습니다. 제가 맡았던 두 팀은 이미 Unity로 게임을 제작하고 있었고, 팀원들도 모두 Unity 에디터가 손에 익었기 때문에 별다른 고민 없이 Unity를 사용해 프로젝트를 계속 진행하게 되었습니다.

Unity가 모바일 게임 개발에 기여한 바는 이론의 여지가 없을 정도로 큽니다. .NET을 이용한 크로스 플랫폼 개발 환경 제공, C#과 같은 고수준 프로그래밍 언어의 사용, 편리한 에디터 등 Unity의 장점은 매력적이었습니다. C, C++, J2ME로 피처폰 게임을 개발하던 방식과 비교하면 격세지감을 느낄 수 있는 수준입니다.

하지만 시간이 지나면 혁신의 속도도 늦어지는 법인지, 제가 지난 1년 2개월간 경험한 Unity는 다음과 같이 몇 가지 기술적인 문제점을 노출하고 있었습니다.

  • 정체된 .NET 버전

Unity는 .NET을 오픈소스로 구현한 Mono 엔진을 사용하고 있습니다. 문제는 굉장히 오래된 Mono 엔진을 사용하고 있다는 점입니다. Unity 4.5.1f3의 Mono 버전은 2.0.50727.1433인데, Mono 2.0이 2008년에 릴리즈되었다는 점을 생각하면 골동품 수준의 엔진을 사용하고 있습니다. 최근에 릴리즈된 Unity 5에서도 기대와는 달리 Mono 버전의 업그레이드는 없었습니다.

Mono 버전이 오래되었다는 건 최신의 .NET 기술을 전혀 사용할 수 없다는 뜻입니다. 일례로, Unity는 비동기 프로그래밍에 상당한 발전을 가져온 .NET의 Task Parallel Library나 C#의 async, await 키워드 등을 사용할 수 없습니다.

Unity가 사용하는 Mono는 .NET 버전 기준으로 3.5 + 일부 4.0 API가 포함되어 있는 수준입니다. 요즘 대부분의 .NET 개발자들은 .NET 최소 버전을 4.0으로 잡고 있기 때문에 최신의 .NET 라이브러리는 수정 없이 Unity에서 사용하기가 어려워지고 있습니다. 어떤 라이브러리를 Unity에 사용하려면 별도의 수정 작업이 필요하다는 건, 더 이상 Unity를 .NET 개발 환경이라고 부르기 힘들어졌다는 뜻입니다. 일례로, Reactive Programming의 유행을 가져온 Rx는 Unity에서 지원되지 않습니다. 대신 비슷한 개념을 Unity에서 사용할 수 있게 새로 만든 UniRx라는 별도의 라이브러리가 나와 있습니다.

  • 안정성과 성능

Unity가 직면한 또 다른 문제점은 안정성과 성능입니다. 특히, iOS 빌드에 사용하는 AOT 컴파일러는 심각한 수준의 문제가 있습니다. 일례로, LINQ를 사용한 코드는 iOS 빌드 시에 크래시를 일으킵니다. LINQ가 아에 안 되는 것도 아니고 LINQ의 특정한 조합에서만 발생하는 문제이기 때문에 디버깅도 쉽지 않습니다. 이 문제를 피하기 위해 아에 LINQ 사용을 금지하는 것이 가장 현실적인 조언이 될 정도입니다.

이 문제도 노후한 Mono 엔진과 연결이 되어 있습니다. iOS 빌드 시 발생하는 크래시 문제도 Mono 2.0에 포함된 AOT 컴파일러의 안정성에 문제가 있기 때문에 발생합니다.

Mono 엔진 자체는 2.0 버전 이후 이후 안정성과 성능에 상당한 개선이 있었습니다. 또한, 최근 마이크로소프트가 coreclrcorefx라는 이름으로 .NET 코드를 오픈소스로 공개하였는데, Mono 4.0부터는 필요한 부분에 대해서는 마이크로소프트 코드를 일부 차용하기 시작하여 안정성이나 호환성이 크게 개선되기 시작하였습니다.

문제는 Unity는 이런 Mono 엔진의 성능 및 안정성 개선 혜택을 전혀 누리지 못하고 있다는 점입니다. Unity 엔진은 계속해서 Mono 2.0에 머무르고 있고, 엔진 업그레이드를 못 하고 있는 이유가 단순히 인력 부족은 아닌 것으로 보입니다. 들리는 루머에 따르면 Mono 버전 업그레이드와 관련하여 Mono의 개발사인 Xamarin과 라이선스 협상에 문제가 있었고, 그 결과 Unity는 단순히 Mono 버전을 업그레이드하는 대신 이 문제를 전혀 다른 방식으로 문제를 풀기로 결정합니다.

  • IL2CPP 개발

Unity가 노후화된 Mono 엔진의 성능 및 안정성 문제를 한 번에 해결하기 위해 들고 나온 해결책은 IL2CPP라는 기술입니다. 이 기술을 간단히 말해 C#을 컴파일해서 나온 중간 언어인 IL을 컴파일해 C++를 출력한 다음 다시 각 플랫폼이 제공하는 C++ 컴파일러를 이용해 네이티브 코드를 만드는 기술입니다.

Unity가 IL2CPP를 통해 목표하는 바는 제가 앞서 제기한 Unity의 문제점과 정확히 일치합니다. 노후화된 Mono 런타임의 성능 및 안정성 문제를 해결할 뿐만 아니라, .NET 버전도 업그레이드하겠다는 것입니다. 하지만 IL2CPP 자체는 특별한 게 없습니다. Mono의 AOT 컴파일러를 대체하는 또 다른 AOT 컴파일러이기 때문입니다. 다만, 그동안은 Mono를 주력으로 사용하고, iOS 등 라이선스 정책 때문에 어쩔 수 없이 AOT 컴파일을 해야하는 상황에서만 AOT 컴파일러를 사용한 것과 달리 될 수 있으면 모든 플랫폼에 AOT를 적용하겠다는 게 달라진 부분입니다. 물론, 이는 성능이나 안정성 측면에서 IL2CPP가 제공하는 AOT 컴파일러 및 런타임이 Mono 런타임을 뛰어넘었을 경우에만 가능한 옵션이기도 합니다.

  • 노출된 문제점

IL2CPP은 크게 AOT 컴파일러와 VM으로 구성되어 있습니다. 바꿔 말해 Mono를 완전히 대체하는 대신 .NET API 구현 등 기존 Mono 인프라는 최대한 살려서 이용하고 성능 및 안정성, 이식성 등의 핵심이 되는 엔진만 교체하겠다는 구상입니다. 이 계획 자체는 상당히 합리적이라고 생각하지만, 문제는 시간입니다. .NET이라는 게 작은 플랫폼이 아니고, 다양한 플랫폼에서 새로운 엔진의 성능을 개선하고 안정성을 확보하는 것도 상당한 노력이 들어가는 작업입니다.

이 과정에서 가장 큰 변수는 애플의 64비트 지원 강제 정책입니다. iOS에 신규로 등록되는 모든 앱에 대해서 64비트 지원을 강제하고, 약간의 유예 기간이 있지만 기존 앱에 대해서도 64비트 지원을 요구하면서 Unity는 발등에 불이 떨어지게 됩니다. 왜냐하면, iOS 빌드에 사용해온 기존 Mono AOT 컴파일러가 64비트 빌드를 지원하지 않고 있기 때문에, 64비트 빌드를 지원하려면 반드시 IL2CPP를 사용해야 하는 상황이 되었기 때문입니다.

결국 Unity는 울며겨자 먹기 식으로 아직 안정성이 확보되지 않은 IL2CPP를 Unity 4와 5에 탑재하게 됩니다. UNITY 4.6.2 IOS 64-BIT SUPPORT 릴리즈 노트를 보면 BeginInvoke/EndInvoke등 .NET의 기본적인 API도 동작 안 하는 상태에서 iOS 64 비트 지원을 발표합니다. 이후 Unity는 거의 매주 업데이트 및 패치를 진행하면서 IL2CPP 버그에 대응하고 있는 상황입니다.

정리하면, 지금 Unity가 직면한 기술적인 문제들은 Unity의 미래를 흔들 수도 있을 만큼 심각합니다. IL2CPP가 이 문제에 대한 궁극적인 해결책이 되기도 어렵습니다. 단순한 안정성이나 성능 확보에도 어려움을 겪고 있는 상황에서 빠른 속도로 발전하는 .NET 기술을 따라가는 것이 게임 엔진 개발사의 Unity 입장에서 쉬운 일이 아니기 때문입니다.

결국 .NET 기술을 주도하고 있는 마이크로소프트와 어떤 식으로는 협력을 모델을 끝어내지 않으면 이런 한계를 넘어서기는 어려워 보입니다. 더군다나 마이크로소프트가 .NET 기술을 오픈소소화하면서 여러 가지 변화를 꾀하고 있는 상황이라 Unity가 직면한 불확싱성이 어느 때보다 커보입니다. 게임 개발사 입장에서 Unity는 여전히 좋은 도구이지만, 앞으로 계속 투자할 수 있는 기술인지에 대해서는 의문이 남는 대목입니다.

코루틴(Coroutine) 이해하기

Unity 프로그래밍에서 중요한 개념 중 하나는 코루틴(Coroutine)입니다. 게임 프로그래밍은 수많은 게임 오브젝트들의 상호 작용으로 표현될수 밖에 없기 때문에 어떤 식으로는 동시성(concurrency)을 표현하는 것이 중요합니다. Unity는 .NET을 사용함에도 불구하고 멀티쓰레드가 아닌 코루틴으로 동시성을 표현하는데, 이는 멀티쓰레드 프로그래밍이 버그 없는 코드를 작성하기 어렵기로 악명 높기 때문입니다.

일단 코루틴이 무엇인지 용어 정의부터 시작하겠습니다. 코루틴은 서브루틴(C#의 메소드에 해당)을 다음 두 축으로 확장한 강력한 서브루틴입니다.

  • 여러 개의 입구(entry point)를 허용하여 실행 재개가 가능
  • 멈춤 시 돌아갈 위치 지정

일반적인 서브루틴은 호출될 때마다 메소드의 처음부터 실행을 시작하고 리턴될 때 항상 호출자(caller)로 돌아가는 반면, 코루틴은 이전 상태를 기억하고 있어서 호출 시 이전 호출에서 멈춘 위치에서부터 다시 실행을 재개하게 되고, 멈출 때는 호출자로 돌아가는 대신 돌아갈 위치를 지정할 수 있습니다.

Unity 프로그래밍에서 코루틴이 중요한 이유는 Unity는 코루틴을 협력형 멀티태스킹(cooperative multitasking)을 구현하는 용도로 사용하기 때문입니다. 협력형 멀티태스킹은 일종의 시분할(time-sharing) 방식으로 여러 task가 하나의 CPU를 나눠쓰는 방식인데, 선점형 멀티태스킹(preemptive multitasking)과 달리 운영체제의 개입 없이 각 task가 독점적으로 CPU를 사용하고 사용이 끝나면 자발적으로 양보하는 방식입니다. 이 방식의 장점은 리소스를 사용하는 도중에 강제로 CPU를 뺏기는 일이 없으므로, 크리티컬 섹션(critical section)을 보호하기 위한 락(lock)이나 세마포어(semaphore) 등 동기화 수단이 필요 없다는 점입니다.

그럼 코루틴으로 협력형 멀티태스킹을 어떻게 구현하는 걸까요? 이해를 돕기 위해 큐에 아이템을 생성하여 집어넣는 produce task와 큐에서 아이템을 꺼내와서 사용하는 consume task를 의사코드(pseudo code)로 구현해 보겠습니다. 이 예제는 위키피디아의 Coroutine 페이지를 참고하였습니다.

var q := new queue
coroutine produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield to consume

produce task는 q가 가득 차지 않았으면 새로운 아이템을 생성하여 q에 집어 넣습니다. q가 가득 찼으면 더 이상 할 수 있는 일이 없으므로 consume task에 양보합니다.

coroutine consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield to produce

consume task는 q가 가득 차 있으면 q에서 아이템을 꺼내서 사용합니다. 꺼낼 아이템이 없으면 더 이상 할 수 있는 일이 없으므로 produce task에 양보합니다.

위 코드에서 주목할 부분은 별도의 스케쥴러가 없다는 점입니다. produce task와 consume task 모두 다음 task를 명시적으로 지정하고 있기 때문에 제3자가 개입하여 다음 task를 지정할 필요가 없습니다. “협력형 멀티태스킹”이란 말도 이런 특징에서 기인하였습니다.

여기까지 이해했으면 의문점이 생기게 됩니다. 코루틴으로 협력형 멀티태스킹을 구현할 수 있다는 건 알겠는데, 정작 Unity게임 프로그래밍에 사용하는 C#이란 언어는 코루틴을 제공하지 않기 때문입니다. C#이 코루틴이 없는데, 어떻게 Unity는 코루틴으로 협력형 멀티태스팅을 구현하는 걸까요? 정답은 C#의 이터레이터(iterator)를 사용하는 것입니다.

C# 개발자라면 yield 키워드로 대표되는 C#의 이터레이터를 사용해 본 경험이 있을 겁니다. C# 이터레이터는 프로그래머가 IEnumerableIEnumerator를 쉽게 구현할 수 있게 프로그래밍 언어가 제공하는 기능입니다. 파이썬, EcmaScript 6 등 다른 프로그래밍 언어에서는 제너레이터(generator)로 알려져 있는 기능이기도 합니다. 이해를 돕기 위해 다시 위키피디아 Generator 페이지에 나와있는 C# 이터레이터 예를 하나 살펴보겠습니다.

public class CityCollection : IEnumerable {
    public IEnumerator GetEnumerator() {
        yield return "New York";
        yield return "Paris";
        yield return "London";
    }
}

위 예제의 GetEnumerator 함수는 IEnumerator 타입을 리턴하는데, 이 IEnumeratorMoveNext() 메소드는 호출될 때마다 “New York”, “Paris”, “London”을 차례대로 리턴하게 됩니다. 다시 말해, 일반적인 서브루틴과 달리 위 메소드는 yield return이 값을 리턴하고 이 위치를 기억해 두었다가 다음 호출 시에는 그 다음 위치부터 실행을 재게합니다.

입구가 여러 개이고 실행이 멈춘 지점을 기억했다가 다시 재개한다는 점은 앞서 설명한 코루틴에 대한 설명과 무척 닮았습니다. 물론 코루틴은 리턴될 때 다음 실행할 위치를 명시적으로 지정할 수 있고, 제너레이터는 반드시 호출자로 돌아간다는 차이점도 있습니다. 이런 유사점 때문에 제너레이터를 세미 코루틴(semi coroutine)이라고 부르기도 합니다. 한국말로는 반쪽 코루틴 혹은 약한 코루틴으로 표현할 수도 있겠네요.

여기서 제너레이터-코루틴-협력형 멀티태스킹의 연결 고리가 생기게 됩니다. 제너레이터가 있으면 코루틴을 만들어낼 수 있기 때문입니다. 앞서 살펴본 produce, consume 코루틴의 예를 제너레이터로 다시 구현해 보겠습니다.

generator produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield consume
generator consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield produce

코루틴 예와 달라진 부분은 yield to consumeyield to produce가 각각 yield consumeyield produce로 바뀌었다는 점입니다. 고작 to라는 단어 하나가 빠진 것 같지만, 실제로는 큰 차이가 있습니다. 코루틴의 yield to consumeconsume 코루틴 실행을 재개하라는 뜻이고, yield consume는 이 제너레이터를 호출한 호출자로 리턴되는 값이 consume이라는 뜻입니다.

yield consume의 의미는 함수가 first-class인 자바스크립트나 함수형 언어를 생각하면 쉽게 이해됩니다. consume이라는 제너레이터를 함수 값으로 생각하고 단순히 리턴하는 것입니다. 따라서 consume을 리턴한다고 해서 자동으로 consume 제너레이터가 실행되는 것이 아닙니다. 제너레이터를 코루틴처럼 사용하기 위해서는 다음과 같이 다음으로 실행할 task를 지정해 줄 스케쥴러가 필요하게 됩니다.

subroutine dispatcher
    var d := new dictionary(generator → iterator)
    d[produce] := start produce
    d[consume] := start consume
    var current := produce
    loop
        current := next d[current]

위 코드의 dispatcher는 스케쥴러 역할을 합니다. 일단 dispatcher가 먼저 실행되어 produceconsume 제너레이터를 만들고 루프를 돌면서 제너레이터가 리턴하는 값을 이용해 다음 task를 정하는 방식입니다. 위 예제의 경우 current의 값이 produceconsume로 계속 바뀌면서 번갈아 실행되게 되므로 앞서 살펴본 코루틴의 예제와 마찬가지로 협력형 멀티태스킹이 구현됩니다.

꼭 각 제너레이터가 다음 task를 지정하는 방식이 아니어도 괜찮습니다. 스케쥴러가 전체 task 목록을 가지고 스케쥴링 하는 방식이 되면 일반적인 운영체제의 스케쥴러와 마찬가지로 다양한 스케쥴링 알고리즘을 적용할 수도 있습니다. 예를 들어 yield 문에 다음으로 실행할 제너레이터를 지정하는 대신에 해당 task가 다시 스케쥴링될 시간을 지정할 수도 있습니다. 그리고 이 방식이 바로 Unity가 협력형 멀티태스킹을 구현하는 방법입니다.

IEnumerator Fade() {
    for (float f = 1f; f >= 0; f -= 0.1f) {
        Color c = renderer.material.color;
        c.a = f;
        renderer.material.color = c;
        yield return new WaitForSeconds(.1f);
    }
}

위 코드는 yield 문이 있는 C#의 이터레이터이고 따라서 리턴 타입도 IEnumerator입니다. 하지만 이 코드가 실제로 의미하는 바는 여러 개의 입구가 존재하는 제너레이터이고 yield를 통해 해당 task가 다음에 스케쥴링될 시간을 지정하고 있습니다. Unity 내부에는 이런 제너레이터의 목록을 가지고 있는 스케쥴러가 있어서 앞서 살펴본 방식과 동일한 방식으로 스케쥴링을 해주고 있습니다. MonoBehaviour.StartCoroutine 메소드가 Unity 스케쥴러에 코루틴을 추가하는 방법입니다.

정리하면

  • C#의 이터레이터는 제너레이터이다.
  • 제너레이터는 세미 코루틴이라고도 불리며 스케쥴러의 도움을 받으면 코루틴을 구현할 수 있다.
  • 코루틴을 이용하면 협력형 멀티태스킹이 가능하다.

제너레이터로 코루틴 및 협력형 멀티태스킹을 구현하는 방식은 Unity뿐만 아니라 여러 프레임워크나 라이브러가 사용해 온 일반적인 방법입니다. C++ 대신 쉬운 C#으로 코딩하라는 모토를 가지고 나온 Unity 입장에서 프로그래머가 실수하기 쉬운 멀티쓰레드 프로그래밍보다는 코루틴을 이용한 협력형 멀티태스킹을 강조한 것도 이해가 되는 부분입니다.

하지만 제너레이터를 이용한 코루틴 구현은 어느 정도 “해킹”스러운 면이 있고, IEnumerator를 본래 의미인 여러 collection 타입에 대한 단일 인터페이스가 아닌 전혀 다른 의미로 사용하면서 프로그래머들에게 혼란을 초래하는 면이 있습니다. 게다가 이후 .NET의 Task Parallel Library, C# 5의 async 키워드 등이 나오고 .NET의 멀티쓰레드 프로그래밍 모델이 상당한 발전을 하면서 Unity의 협력형 멀티태스킹 방식의 장점이 상당 부분 희석되었다고 생각합니다. 이 부분은 다음에 다시 한 번 다루도록 하겠습니다.

코드 스멜

코드 스멜이란 문제 있는 코드에서 보이는 현상을 지칭하는데, 게임 코드를 리뷰하면서 가장 많이 발견한 코드 스멜은 Primitive Obsession입니다.

Primitive Obsession은 쉽게 말해 class나 struct를 선언하는 대신 int, double, string 같은 기본적인 타입만 사용해서 프로그램 내 모든 데이터를 표현하는 것을 의미합니다. 특히, 여러 필드와 메소드를 가지는 class를 선언해야 하는 상황에도 string에 모든 데이터를 인코딩해서 넣고, 메소드는 갈 곳 없이 코드 여기저기 흩어져 있는 경우를 자주 발견하였습니다.

Primitive Obsession을 포함하여 여러 코드 스멜이 나오는 과정을 보면 흥미롭습니다. 처음부터 문제 있는 코드를 작성하는 경우는 생각보다 드뭅니다. 프로젝트 초기에는 시간도 충분히 있고, 요구사항도 복잡하지 않기 때문에 나름의 설계도 하고 class도 적절하게 만들게 됩니다.

문제는 게임 기획 변경으로 새로운 요구사항이 나오면 이 요구사항을 수용할 수 있게 기존 코드를 어느 정도 리팩토링하는 과정이 필요한데, 대게의 개발자들은 구조 변경 없이 최소한의 변경만으로 해당 요구사항만 어떻게든 구현하고 싶어합니다. 당연히 새로운 클래스를 만드는 일도 꺼리기 마련이고, 적당히 string 타입으로 현재 존재하는 클래스 어딘가에 필드로 추가하게 됩니다.

코드에 문제가 있다고 생각되면 그때 그때 정리하는 작업이 그래서 중요합니다. 새로운 요구사항이 나오면 현재 코드가 이 요구사항을 구현하기에 적당한지 먼저 판단하고 기존 코드를 유연하게 바꾸는 작업이 선행되어야 합니다. 코드 정리가 시간 있어야만 할 수 있는 사치가 아니라, 앞으로 남은 프로젝트 기간을 단축시켜 주는 유일한 방법임도 깨달아야 합니다.

Creating a ServerSentEventServer with ReactiveWebServer

Recently, I am working on a toy project named ReactiveWebServer. It is an event-driven web server built with Rx.

Here’s a simple web server returning “Hello World”.

using (var ws = new WebServer("http://*:8080/"))
{
    const string responseBody = "Hello World";

    ws.GET("/")
        .Subscribe(ctx => ctx.Respond(responseBody));

    Console.ReadLine();
}

The code is simple, but there is nothing new compared to other web servers such as NancyFx.

The real power of ReactiveWebServer comes from its ability to handle streaming data. For example, here’s a ServerSentEvent server implementation streaming integers every second.

using (var ws = new WebServer("http://*:8000/"))
{
    ws.GET("/events").Subscribe(ctx =>
    {
        var obs = Observable.Interval(TimeSpan.FromSeconds(1))
            .Select(t => new ServerSentEvent(t.ToString()));

        ctx.Respond(new ServerSentEventsResponse(obs));
    });

    Console.ReadLine();
}

Data streaming is made simple and easy. You just need to create an instance of IObservable<ServerSentEvents>, wrap it with ServerSentEventsResponse, and pass the result to ctx.Respond method.

Rx Study Materials

I recently taught myself Rx and found the following materials are helpful in understanding the concepts and the APIs of Rx.

  • The introduction to Reactive Programming you’ve been missing
    This article explains how to think in reactive. It explains the core concepts of reactive programming with many examples. This is not-specific to Rx, but most helpful in understanding what reactive programming is.

  • Introduction to Rx
    This is a book on Rx freely available. Part 3 is most helpful as it shows how to use each operator of Rx.

  • ReactiveX
    The best way to understand a Rx operator is to draw a marble diagram. ReactiveX explains each operator with marble diagram. For example, take a look at Repeat.

  • 101 Rx Samples
    This site hosts a number of Rx operator examples.

  • Rx Workshop
    Video tutorials on Rx provided by Microsoft Channel9.

Rx RetryWithDelay extension method

Rx provides Observable.Retry which repeats the source observable sequence until it successfully terminates.

Observable.Retry is a useful combinator, but it is a bit limited when retrying an I/O request. For example, when retrying a network request, we should wait a few seconds before sending a new request.

Here is an implementation of Observable.RetryWithDelay which repeats the source observable with delay.

    public static class ObservableExtensions
    {
        public static IObservable<T> RetryWithDelay<T>(this IObservable<T> source, TimeSpan timeSpan)
        {
            if (source == null)
                throw new ArgumentNullException("source");
            if (timeSpan < TimeSpan.Zero)
                throw new ArgumentOutOfRangeException("timeSpan");
            if (timeSpan == TimeSpan.Zero)
                return source.Retry();

            return source.Catch(Observable.Timer(timeSpan).SelectMany(_ => source).Retry());
        }
    }

After validating arguments, Observable.RetryWithDelay create a new observable sequence by concatenating the following two observables:

  • Observable.Timer(timeSpan)
  • source

SelectMany is used to concatenate these two observable sequences. Observable.Timer(timeSpan) has a single value that fires after timeSpan. SelectMany ignores this value and returns source. Then it repeats the result sequence with Retry.

  • Observable.Timer(timeSpan).SelectMany(_ => source).Retry()

This is the next observable sequence we want to continue when source is terminated by an exception. Catch swallows the exception thrown by source and continues with the next observable.

  • source.Catch(Observable.Timer(timeSpan).SelectMany(_ => source).Retry())

리팩토링 회의

대게 게임 개발자들은 근무 시간이나 강도가 높은 편인데, 정작 자기 코드를 돌아보고 개선할 수 있는 시간이 거의 없어 늘 비슷한 수준의 코드만 반복해서 작성하는 경우가 있습니다. 코드 재활용이 잘 이루어지지 않다 보니 엇비슷한 프로젝트를 수행하면서 같은 코드를 반복해서 짜는 일도 많습니다.

게임은 요구 사항 변경이 잦은 편이다 보니 쉽게 엔트로피가 높아져서 유지보수가 힘든 코드가 되는 경우가 많이 있습니다. 또한 시간에 쫓기면서 작업하다 보니 일단 돌아가게만 해놓고 나중에 제대로 고쳐야겠다고 생각만 하고 넘어가는 경우가 많기도 합니다.

리팩토링은 코드 동작은 유지하면서 내부 구조를 이해하기 쉽고, 유지보수가 쉽게 바꾸는 활동을 의미합니다. 게임팀을 맡아 새로 만든 프로세스 중 하나는 “리팩토링 회의”입니다. 매일 정오에 30-40분 가량 사내 모든 개발자들이 모여서 리팩토링 혹은 코드 개선을 위한 기술 세미나를 진행했습니다. Design Pattern, Refactoring, Test Driven Development, Effective C# 등 기술 서적의 내용을 발표하기도 했고, 실제로 게임팀에서 작성한 코드를 놓고 무엇이 문제인지, 어떻게 개선할 것인지 토론도 진행하였습니다.

이런 활동을 통해 배운 가장 큰 교훈은 개발자들이 잘 작성한 코드란 무엇인지 감을 잡기 시작했다는 점입니다. 내가 짠 코드가 어떤 문제가 있는지 다른 개발자들의 의견을 들어볼 기회가 없으면 문제점을 모르기 때문에 개선도 할 수가 없습니다. 리팩토링 회의를 통해서 개발 능력이 갑자기 향상될 수는 없지만, 최소한 문제점을 인식하기 시작하면 개선하기 위한 노력이 가능해집니다.

최근 사내 개발자들의 능력이 정체되어 있다고 생각한다면 이런 “리팩토링 회의”와 같은 활동을 시작해 볼 것을 추천합니다.

클래스 이름 명명법

이해하기 쉬운 코드를 작성하려면 이름을 잘 짓는 것이 무척 중요합니다. 게임 코드를 리뷰하면서 발견한 잘못된 클래스 이름의 사례는 다음과 같습니다.

  • 무의미한 접미사

생각보다 많은 개발자들이 습관적으로 Info, Data, Manager 등의 접미사를 붙여 클래스 이름을 만드는 습관이 있다는 사실을 발견했습니다. 예를 들어, 게임 캐릭터를 나타내는 클래스 이름은 CharacterInfo, 전투에 관련된 사실상 거의 모든 일을 하는 클래스의 이름은 BattleManager로 붙입니다.

클래스 이름은 읽는 사람 입장에서는 일종의 단서이기 때문에 반드시 의미가 있어야 합니다. Character와 CharacterInfo가 읽는 사람 입장에서 아무런 차이가 없다면 Character라는 간결한 클래스 이름을 사용하는 것이 낫습니다.

  • 두루뭉술한 이름

클래스 이름 자체가 너무 두루뭉술하게 무엇을 하는 클래스인지 알 수는 경우도 있습니다. 대표적인 예가 GameData 같은 이름입니다. 대게 게임 내에서 자주 사용하는 데이터나 메소드를 한 곳에 모아둔 것인데, 용도에 따라 클래스를 구분하지 않고 전부 한 곳에 두고 사용하기 때문에 발생하는 일입니다.

클래스가 한 가지 일만 하는 게 아니다 보니 딱히 이름을 지을 방법이 없어서 두루뭉술한 이름을 붙이게 됩니다. 이 경우는 클래스 이름을 잘못 지은 게 아니라 클래스 설계를 잘못한 경우입니다. 클래스 하나가 하는 일이 여러 개이기 때문에 마땅한 이름이 없는 것입니다.

이 경우 클래스에 명료한 이름을 붙일 수 있는 수준까지 해당 클래스를 리팩토링해야 합니다. 여러 개의 클래스로 나누고 클래스 하나 하나가 하는 일이 명료하다면, 각각의 클래스의 이름을 붙이는 일은 어렵지 않습니다.