관심사의 분리(Separation of Concerns)

게임 개발뿐만 아니라 모든 소프트웨어 개발의 핵심은 복잡성을 극복하는 것입니다. 프로그램은 작은 프로그램의 조합으로 만들어지는데, 다른 엔지니어링과 달리 이런 조합에 물리적인 제약이 존재하지 않기 때문에 훨씬 쉽게 복잡해지는 경향이 있습니다. 예를 들어, 초고층 건물이나 우주 비행선은 물리 법칙의 제약 때문에 더 복잡해지기 어렵지만, 소프트웨어는 이런 제약이 없습니다.

소프트웨어가 복잡해지면 가장 먼저 병목이 되는 건 사람입니다. 인간의 머리는 한 번에 생각할 수 있는 양에 한계가 있기 때문입니다. 인지 심리학자인 George A. Miller은 The Magical Number Seven, Plus or Minus Two라는 논문에서 인간은 아무리 똑똑해도 한 번에 처리할 수 있는 정보가 7개 정도(혹은 1,2개 더)로 제한되어 있다고 말합니다.

소프트웨어 엔지니어링에서 강조하는 원리 중 하나는 관심사의 분리(Separation of Concerns)입니다. 여러 가지를 동시에 신경 쓰면 복잡하니깐, 각각을 따로 분리해서 생각하자는 이야기입니다. Concern을 보통 관심사로 번역하고 있지만, 복잡성의 관점에서는 걱정거리로 생각할 수도 있습니다.

그런데 걱정거리는 쉽게 늘어납니다. 예를 들어, 게임 출시 전에 튜토리얼 기능을 추가한다고 합시다. 튜토리얼이라는 게 게임 전반에 걸쳐서 게임 진행 방법을 설명해야 하기 때문에, 기존 코드 여기 저기에서 Tutorial.IsTutorialMode()를 확인하고 true이면 튜토리얼 코드를 진행하고 false이면 기존 코드를 진행하는 코드가 필요하게 됩니다. 프로그램 전반에 걱정거리가 하나 추가된 셈입니다.

서버 코드도 마찬가지입니다. 요즘 출시되는 대부분의 게임은 업적 기능이 있는데, 업적 확인도 튜토리얼과 마찬가지로 하나의 기능이라기 보다는 서버 코드 전반에 걸쳐서 삽입되기 때문입니다. 플레이어가 친구를 3명 맺었으면 보상을 주는 업적이 있다고 하면, 친구 맺는 코드 입장에서는 “업적 확인”이라는 걱정거리가 하나 늘어난 셈입니다.

말하듯이 코딩하라가 생각처럼 쉽지 않은 이유도 마찬가지입니다. 아래 코드는 간단합니다. 유저 등록을 위해 필요한 절차를 말하듯이 나열하기만 하면 코딩이 끝나기 때문입니다.

function registerUser()
{
    var request = receiveRequest();
    validateRequest(request);
    canonicalizeEmail(request);
    updateDbFromRequest(request);
    sendEmail(request.Email);
    return sendResponse("Success");
}

그런데 걱정거리는 쉽게 늘어납니다. 위 코드에 예외 처리를 넣으면 어떻게 될까요? 각 함수마다 리턴 값을 확인하거나 try/catch 블록을 주렁주렁 달아줘야 합니다. 위 코드가 성능 때문에 비동기로 작성되어야 한다면 어떨까요? node.js 스타일이라면 다음 할 일을 일일이 콜백을 통해서 넘겨줘야 합니다. 위 코드가 C++로 작성되었다면 어떨까요? 이제 언제 메모리를 할당하고, 언제 해제할지 일일이 신경써야만 프로그램이 제대로 동작합니다. 인자를 넘기는 방식은 어떻게 해야 하나요? call-by-value, call-by-reference? sendEmail이 멀티쓰레드 접근에 안전하지 않아서 락을 잡아줘야 한다면? 여기에 매 100번째 등록 유저에게는 선물을 주는 이벤트를 진행한다면?

  • 기본 로직
  • 예외 처리
  • 비동기 프로그래밍
  • 메모리 할당/해제
  • 인자 전달 방식
  • 락(lock)
  • 이벤트

이렇게 간단한 코드에서도 우린 이미 인간의 인지 능력의 한계인 7개의 걱정거리에 도달했습니다. 이것보다 더 복잡해지면 대부분의 개발자는 더 이상 자기가 작성한 프로그램이 대체 어떻게 해서 돌아가는지 이해하지 못하게 됩니다. 하나의 걱정거리가 추가되면 다른 걱정거리 하나가 머리 속에서 지워지기 때문에 어디선가 문제가 생길 수밖에 없는 상태가 됩니다.

프로그래밍을 잘한다는 건 이런 수많은 걱정거리를 한 번에 처리하는 능력이 아니라, 코드에서 여러 걱정거리를 분리해 낼 수 있는 능력입니다. 우리가 알고 있는 수많은 객체지향 프로그래밍 원리, 디자인 패턴 등도 결국은 관심사 혹은 걱정거리를 분리하고 한 번에 하나씩만 생각하자는 이야기에 불과합니다. 소프트웨어 엔지니어링이란 인간의 인지 능력의 한계를 극복하기 위한 체계적인 방법론이라고 생각할 수 있습니다. 프로그래밍 언어 또한 인간의 인지 능력의 한계 내에서 복잡성을 극복하기 위한 다양한 수단을 제공하는 도구라고 생각할 수 있습니다. 프로그래밍이란 결국 복잡한 문제를 작은 문제로 나눠서 풀고, 다시 재조합하는 과정인 셈입니다.

말하듯이 코딩하라

코드 리뷰하면서 자주 있었던 상황 중 하나가 실제로 작성한 코드와 코드 설명이 전혀 다른 경우입니다. 물론 전혀 엉뚱한 코드를 작성했다는 의미는 아닙니다. 최소한의 테스트 및 동작 확인은 하고 리뷰를 요청하기 때문에 코드 자체는 의도한 대로 동작합니다. 다만, 코드를 읽는 사람에게 코드의 의도를 제대로 전달하지 못하는 게 문제입니다. 이 문제는 코드를 설명하는 추상화 수준과 실제로 코드가 작성된 추상화 수준이 전혀 다르기 때문에 발생합니다.

이메일로 유저를 등록하는 간단한 서버 요청/응답 코드를 예를 들어 보겠습니다. 이 요청/응답 프로토콜의 명세를 간략히 적어보면 다음과 같습니다. (편의를 위해 에러 처리는 생략했습니다.)

  • 요청을 받음
  • 요청 검증
  • 이메일 정규화(canonicalize)
  • DB의 유저 정보 업데이트
  • 검증 이메일 발송
  • 응답을 보냄

위 명세를 코드로 옮기면 다음과 같은 코드가 나와야 합니다.

function registerUser()
{
    var request = receiveRequest();
    validateRequest(request);
    canonicalizeEmail(request);
    updateDbFromRequest(request);
    sendEmail(request.Email);
    return sendResponse("Success");
}

바꿔 말해, 명세와 코드의 추상화 수준이 같아야 합니다. 이렇게 작성하면 코드를 읽는 사람 입장에서 validateRequestcanonicalizeEmail 같은 함수가 내부적으로 어떤 일을 수행하는지 살펴보지 않고도 전체 코드의 의도를 이해할 수 있습니다. 만약, 이런 함수를 별도의 함수로 분리하지 않고 registerUser 함수 안에 넣어서 작성했다면 코드를 의도를 읽기가 훨씬 힘들어졌을 겁니다.

재미 있는 사실은 대부분의 개발자가 코드 리뷰 시에는 본인이 실제로 코드를 어떻게 작성했는지와 상관 없이 명세와 유사한 추상화 수준으로 설명을 합니다. 코드 리뷰어가 설명을 듣고 있기 때문에 이해할 수 있게 설명하려면 계속 일정한 수준으로 설명을 하는 수밖에 없기 때문입니다. 코드를 다른 개발자에게 말로 설명해 보는 것은 적절한 추상화 수준의 가독성 높은 코드를 짜는 좋은 방법입니다. 즉, 말하듯이 코딩해야 합니다.

성능 최적화를 위해 꼭 알아야 할 숫자들

모바일 게임은 핸드폰에서 돌릴 수 있는 가장 무거운 소프트웨어 중 하나이기 때문에 출시 전 성능 최적화가 반드시 필요합니다. Peter Norvig이 쓴 Teach Yourself Programming in Ten Years을 보면 성능 최적화를 위해 개발자가 반드시 숙지하고 있어야 할 숫자들이 나옵니다.

  • execute typical instruction: 1/1,000,000,000 sec = 1 nanosec
  • fetch from L1 cache memory: 0.5 nanosec
  • branch misprediction: 5 nanosec
  • fetch from L2 cache memory: 7 nanosec
  • Mutex lock/unlock: 25 nanosec
  • fetch from main memory: 100 nanosec
  • send 2K bytes over 1Gbps network: 20,000 nanosec
  • read 1MB sequentially from memory: 250,000 nanosec
  • fetch from new disk location (seek): 8,000,000 nanosec
  • read 1MB sequentially from disk: 20,000,000 nanosec
  • send packet US to Europe and back: 150 milliseconds = 150,000,000 nanosec

최적화에서 위 숫자가 중요한 이유는 각 최적화마다 기대되는 효과가 천차만별이기 때문입니다. 예를 들어, 실제로는 네트워크 병목이 문제인 게임에서 뮤텍스 락을 최적화해서 기대되는 효과는 무척 낮을 수밖에 없습니다. 위 표를 보면 미국에서 유럽으로 패킷을 보내고 받을 때 걸리는 지연 시간(latency)는 뮤텍스 락을 잡고 푸는데 걸리는 시간보다 무려 6,000,000배나 깁니다.

하드웨어 성능은 매년 발전하기 때문에 위 표의 숫자를 기계적으로 다 외울 필요는 없습니다. 다만, 각 연산에 걸리는 시간의 차이를 상대적으로는 기억할 필요가 있습니다. L1 캐시(cache)에서 데이터를 가져오는 데 걸리는 시간은 0.5 nanosec이고, 캐시 미스(cache miss)가 나서 메인 메모리에서 데이터를 가져오게 되면 100 nonosec이 걸립니다. 캐시 미스가 나면 페널티로 200배 가까운 시간이 더 걸리는 셈입니다.

그런데 저 표를 외우기가 쉽지 않습니다. 보통 사람은 nanosec이라는 시간에 대해 전혀 감이 없습니다. 1초도 눈 깜짝할 사이기 때문에 1초의 1/1,000이나 1/1,000,000이나 1/1,000,000,000이나 다 그냥 짧은 시간처럼 느껴지기 때문입니다. 최적화에서는 어차피 상대적인 시간이 중요하기 때문에 위 표를 외우는 좀 더 쉬운 방법은 1 nanosec을 1 sec로 생각하고 우리가 일상적으로 쓰는 단위로 환산해 보는 것입니다.

  • execute typical instruction: 1/1,000,000,000 sec = 1 초
  • fetch from L1 cache memory: 0.5 초
  • branch misprediction: 5 초
  • fetch from L2 cache memory: 7 초
  • Mutex lock/unlock: 0.5 분
  • fetch from main memory: 1.5 분
  • send 2K bytes over 1Gbps network: 5.5 시간
  • read 1MB sequentially from memory: 3 일
  • fetch from new disk location (seek): 13 주
  • read 1MB sequentially from disk: 6.5 달
  • send packet US to Europe and back: 5 년

이렇게 놓고 보면 nanosec으로 보는 것에 비해 시간의 상대적이 길이가 한 눈에 들어옵니다. 뮤텍스 락을 잡고 푸는데 걸리는 시간은 0.5분이고, 미국에서 유럽으로 패킷을 보내고 받는데 걸리는 시간은 무려 5년입니다. 비유를 하자면, 네트워크가 병목인 소프트웨어에서 뮤텍스 락 잡는 시간을 줄이는 최적화를 하는 것은, 유럽 미국 왕복 여행을 하는데 비행기 시간 줄일 생각은 안 하고 아침에 세수하는 시간 줄이는 수준의 일을 하는 것입니다.

최적화 작업을 수행하기 전에는 최적화의 오래된 격언을 항상 상기하시기 바랍니다.

Premature optimization is the root of all evil (섣부른 최적화는 모든 악의 근원이다.)

프로그래밍 언어의 발전 방향

게임 코딩 스쿨에서는 “C# 바로 알기”, “JavaScript 바로 알기” 시리즈를 통해서 각 언어를 제대로 사용하는 방법에 대해 설명하고 있습니다. 수많은 언어 중에서 하필 두 언어를 고른 이유는 단순히 두 언어가 게임 프로그래밍에 많이 사용되기 때문만은 아닙니다. 두 언어를 선택한 또 다른 이유는 두 언어가 빠른 속도로 발전하고 있고, 여기서 배울 점이 많이 있기 때문입니다. 재미있는 사실은 서로 전혀 다른 배경과 목적으로 탄생한 두 언어가 놀랍게도 유사한 방향으로 발전하고 있다는 점입니다.

우리가 자주 사용하는 프로그래밍 언어로는 C, C++, Java, C#, Python, Ruby, JavaScript 등이 있습니다. 대부분의 주류 언어는 객체지향 프로그래밍 언어로 분류되고, 문법이나 용법, 표준 라이브러리 등에 차이는 있어도 큰 맥락에 프로그래밍을 하는 방법은 대동소이합니다. 가장 큰 차이라면 C#, Java와 같이 정적 타이핑을 하느냐, Python, Ruby, JavaScript처럼 동적 타이핑을 하느냐의 차이 정도만 있을 뿐입니다.

그런데 학계에서 프로그래밍 언어를 전공한 사람들은 이런 주류 언어를 연구하지 않습니다. 학계에서는 주로 연구하는 언어는 “함수형 언어”로 분류되는데, 함수형 언어 중에 대중적으로 잘 알려진 언어는 Lisp, Scheme, ML, OCaml, Clojure, F#, Scala, Haskell이 있습니다. 대중적으로 잘 알려지지 않았지만 학계에서 많이 사용하는 언어로는 Coq, Idris, Agda 같은 다소 특이한 언어들도 있습니다. 물론 이외에도 수많은 연구용 언어들이 존재합니다.

주류 언어와 함수 언어는 오랜 시간 공존하며 직간접적으로 많은 영향을 미쳐왔습니다. 일례로, Java가 나오면서 대중화된 가비지 콜렉션(garbage collection)은 함수 언어에서는 이미 Lisp 시절부터 존재한 오래된 기술입니다. 함수 언어는 주류 언어와 달리 immutable 타입을 강조하고, 기존 메모리를 갱신하는 대신 새로운 값을 만들어내는 방식으로 계산을 수행하므로, 일일이 메모리를 수동으로 할당하고 해제하는 것이 불가능했습니다. 이런 언어 특징 때문에 일찌감치 자동 메모리 관리 기술이 발달할 수밖에 없었습니다.

반대로, 메모리를 한 번 할당한 다음 할당된 메모리를 갱신하는 방법으로 계산을 수행하고, 성능을 강조했던 주류 언어는 상대적으로 가비지 콜렉션의 도입이 늦을 수밖에 없었습니다. 초기 절차형 언어 Fortran, Algol, C에서는 메모리 관리를 수동으로 하는 것이 크게 문제가 되지 않았으나, 점차 복잡해지는 소프트웨어를 작성하기 위한 객체지향 프로그래밍이 나오면서 수동 메모리 관리는 점점 부담이 됩니다. 다행히 해결책은 이미 함수 언어에서 수십 년 전에 고민한 가비지 콜렉션에 있었고, Java 이후 현재의 주류 프로그래밍 언어는 대부분 가비지 콜렉션을 채택하고 있습니다.

함수 언어가 주류 언어에 영향은 미친 또 다른 사례로는 Java와 C#에서 Generic으로 알려진 Parametric Polymorphism이 있습니다. 한국말로 다형성으로 번역하는 Polymorphism에는 Subtype Polymorphism, Parametric Polymorphism, Ad-hoc Polymorphism 등 여러 방식이 존재하는데, Java 5, C# 2 이전전의 객체지향 프로그래밍 언어는 Polymorphism이 곧 Subtype Polymorphism인 것처럼 용어를 사용했습니다.

Java 5 이전 버전의 Java를 사용해 보신 분들은 아시겠지만, Generic이 없으면 인자 타입만 다른 똑같은 함수를 여러 개 정의하거나, java.util.Arrayssort 메소드처럼 타입 정보 없이 가장 범용적인 타입인 Object[]를 처리하는 메소드를 작성하는 수밖에 없었습니다. 당씨 선과 마이크로소프트는 이런 불편함을 해결하기 위한 방법을 찾기 시작했고, 역시나 해결책은 이미 함수 언어에서 수십 년 전에 개발한 Parametric Polymorphism에 있었습니다.

이후에도 주류 언어는 끊임 없이 함수 언어의 개념들을 빌려오기 시작합니다. C#과 Java의 람다(lamba)가 또 다른 대표적인 예제입니다. 함수 언어의 가장 큰 특징은 함수가 first-order라는 점인데, 쉽게 말해 함수를 다른 타입과 마찬가지로 함수의 인자로 넘기거나 리턴 값으로 받을 수 있고, 변수에 저장할 수 있는 특징을 말합니다. JavaScript가 수많은 결점들에도 불구하고, 지금까지 살아남고 발전하고 있는 가장 큰 이유도 함수가 first-order라는 점을 꼽을 수 있습니다. 왜냐하면 first-class 함수로 할 수 있는 일이 상상을 초월할 정도로 많기 때문입니다.

서두에 JavaScript와 C#은 서로 전혀 다른 배경에서 탄생하고 발전하는 언어임에도 불구하고 발전 방향이 놀랍게도 유사하다고 말씀드렸습니다. 그 이유는 간단합니다. 두 언어 모두 함수 언어의 개념들을 차용해 오고 있기 때문입니다.

앞으로 주류 언어가 어떤 방향으로 발전할지 예측하는 일도 어렵지 않습니다. 함수 언어가 이룩한 성과 중에서도 아직 주류 언어가 가져오지 않은 게 무엇인지만 살펴보면 되기 때문입니다. 물론, 이미 지금 프로그래밍 언어 학계가 연구하고 있는 따끈따근한 연구 주제가 아닌 이미 10-20년 이상 시간을 가지고 검증한 기술이어야 합니다. 주류 언어는 수많은 대중을 상대하는 만큼 보수적일 수밖에 없기 때문입니다.

사실 변화는 이미 나타나고 있습니다. C# 3 LINQ, C# 5 async 함수, C# 6 null propagator, ES 7의 async 함수 등의 공통점이 무엇일까요? 정답은 Monad입니다. LINQ, async 함수, null propagator 등은 전혀 다른 기능처럼 보이지만, Monad라는 일종의 수학적인 구조를 따르고 있습니다. LINQ는 List Monad의 다른 이름, async 함수는 Future Monad의 다른 이름, null propagator는 Maybe Monad의 다른 이름일 뿐입니다.

임의의 Monad를 직접 만들어 쓸 수 있는 함수 언어와 달리, 주류 프로그래밍 언어는 이 중에서 유용성이 검증된 일부 Monad를 언어 기능으로 제공하는 단계라고 생각할 수 있습니다. 앞으로 한동안 주류 프로그래밍 언어는 여러 Monad를 가져오는 작업을 할 것으로 보입니다. 이 단계를 지나고 나면, Haskell의 do 표기법이나 F#의 computation expression처럼 언어 사용자가 직접 임의의 Monad를 정의할 수 있는 방법을 제공할 가능성도 있습니다.

저는 지금이 변화의 시기라고 생각합니다. 소프트웨어는 계속 복잡해지고 있고, 과거 절차형 언어에서 객체지향 언어로 넘어간 것과 마찬가지로 지금은 함수 언어가 이런 복잡성을 해결할 수 있는 대안으로 떠오르고 있습니다. Clean Code의 저자이며 객체지향 디자인 패턴과 원리를 강조한 Robert Martin이 지금은 Functional Programming을 전도하는 것을 보며 무엇을 느끼시나요?

자바스크립트의 약속(Promise)

의문점

앞서 게임 서버: node.js의 장점과 단점이라는 글에서 node.js의 문제점으로 불편한 비동기 프로그래밍 모델을 꼽았습니다. 글의 말미에 비동기 프로그래밍의 불편함을 극복하기 위한 노력으로 ES6의 Promise와 (아직 논의 중인) ES7의 async 함수에 대한 이야기를 잠깐 언급했었습니다. 이 시리즈에서는 ES6 Promise에 대해 좀 더 자세히 설명하겠습니다.

일단 한 가지 의문점으로 시작하겠습니다. 이미 웹브라우저나 node.js에서 모두 콜백 방식으로 API를 제공하고 있고, 자바스크립트 개발자라면 당연히 이런 방식에 익숙할 수밖에 없는데, 왜 ES6에서는 이와는 다른 Promise라는 스타일을 표준으로 만든 걸까요? 물론 PromiseJS, Q, RSVP, Bluebird, when.js 등의 자바스크립트 Promise 라이브러리가 많이 나와있지만, 일부 개발자만이 사용할뿐 대세와는 거리가 멀었던 게 사실입니다.

ES6 Promise를 소개하고 있는 글들은 대부분 ES6 Promise가 새로운 스타일의 비동기 프로그래밍 모델을 제공하고 있고, 기존의 콜백 방식에 비해서 더 좋다고 주장하고 있습니다. 하지만 ES6 Promise를 실제로 사용해 보신 분들은 이 지점에서 고개를 갸웃거릴 수밖에 없습니다. 기존 콜백 방식에서도 async 모듈 등을 사용해서 나름대로 불편함을 해결해왔기 때문에 말 그대로 “스타일의 차이” 외에 ES6 Promise 확실히 더 좋은 비동기 프로그래밍 모델이라고 주장할 근거가 약하기 때문입니다.

특히, HTML5 Rocks에 올라온 JavaScript Promises There and back again와 같은 글은 비교 방식에 문제가 있습니다. ES6 Promise에는 then(), map(), foreach() 등 sequencing과 parallelism을 표현하는 함수가 존재하고 콜백 방식에는 없는 것처럼 설명하고 있는데, 콜백 방식에서도 이미 async 모듈의 series, parallel, map 등을 사용해 같은 수준의 추상화를 하고 있기 때문입니다.

물론 ES6 Promise의 장점은 분명히 존재합니다. 특히, err 인자를 모든 함수에 넘기는 방식에 비해 ES6 Promise의 에러 처리 방식은 분명히 개선된 점이 있습니다. 하지만 ES6 Promise를 단순히 기존 콜백 방식의 문제점을 약간 개선한 새로운 비동기 프로그래밍 스타일 정도로 설명해서는 안 됩니다. ES6 Promise 꿈은 더 원대합니다. ES6 Promise는 불편한 “비동기 프로그래밍” 세상을 떠나 다시 우리가 살던 낙원인 “동기 프로그래밍”으로 돌아가기 위한 노력이기 때문입니다.

비교

앞에서 ES 6의 Promise 도입 배경에 대해 의문을 제기했습니다. 이미 웹브라우저나 node.js에서 콜백 스타일을 많이 쓰고 있음에도 불구하고 자바스크립트 표준화 단체인 Ecma는 Promise라는 새로운 비동기 프로그래밍 방식을 표준으로 채택했기 때문입니다.

ES 6 Promise를 설명하는 대부분의 글들은 기존 콜백 스타일에 비해 ES 6가 가지는 장점을 설명하고 있습니다. 일례로, ECMAScript 6 promises (1/2): foundations라는 글은 콜백의 단점을 다음과 같이 정리하고 있습니다.

* Error handling becomes more complicated: There are now two ways in which errors are reported – via callbacks and via exceptions. You have to be careful to combine both properly.

* Less elegant signatures: In synchronous functions, there is a clear separation of concerns between input (parameters) and output (function result). In asynchronous functions that use callbacks, these concerns are mixed: the function result doesn’t matter and some parameters are used for input, others for output.

* Composition is more complicated: Because the concern “output” shows up in the parameters, it is more complicated to compose code via combinators.

  • 에러 처리가 더 복잡해진다. 에러가 보고되는 방식이 콜백과 예외 두 가지가 된다. 두 가지를 모두 적절하게 처리하기 위해서 주의를 기울여야 한다.
  • 시그너처가 아름답지 못하다. 동기 함수에서는 입력(인자)과 출력(함수 결과) 사이의 명확한 구분이 존재한다. 콜백을 쓰는 함수에서는 입력과 출력이 섞여 있다. 함수의 결과는 중요하지 않고, 일부 인자는 입력에 나머지 인자는 출력에 사용된다.
  • 합성이 더 어렵다. 출력이 인자로 표현되기 때문에, 컴비네이터를 통해 합성하는 게 더 복잡해진다.

물론 다 일리가 있는 설명이긴 하지만, 이런 몇 가지 장점만으로 이미 JavaScript 개발자들에게 익숙한 콜백 방식을 버리고 Promise 방식을 채택했다는 사실은 여전히 쉽게 납득하긴 어렵습니다.

사실 이해가 어려운 이유는 비교 대상에 있습니다. 콜백과 Promise를 비교할 것이 아니라 동기 프로그래밍 모델과 Promise를 비교해보면 콜백 대비 Promise의 장점이 명확히 보이기 때문입니다.

이해를 돕기 위해 간단한 예제로 시작하겠습니다. 아래 node.js 프로그램은 config.json 파일을 읽어 domain 키의 값을 domain.txt 파일에 쓰는 간단한 프로그램입니다.

var fs = require('fs');

try {
  var text = fs.readFileSync('config.json');
  try {
    var obj = JSON.parse(text);
    try {
      fs.writeFileSync('domain.txt', obj.domain);
      console.log("Done");
    } catch (e) {
      console.error('Error while writing domain text file');
    }
  } catch (e) {
    console.error('Invalid JSON in file');
  }
} catch (e) {
  console.error('Error while reading config file');
}

위 프로그램은 fs 모듈이 제공하는 readFile, writeFile 함수 대신에 readFileSync, writeFileSync 함수를 써서 우리게에 익숙한 동기 프로그래밍 방식으로 작성했습니다. 함수의 입력과 출력이 명확히 분리되어 있고, 모든 예외 처리는 try/catch 블록을 통해 하기 때문에 정상적인 실행 경로와 예외 처리 경로도 명확히 구분되어 있습니다. var obj = JSON.parse(text)에서 볼 수 있는 것처럼 한 함수의 출력을 다른 함수의 입력으로 넘기는 것도 간단합니다.

이 프로그램을 node.js의 콜백 방식으로 다시 작성해보면 다음과 같습니다.

var fs = require('fs');

fs.readFile('config.json',
  function (error, text) {
    if (error) {
      console.error('Error while reading config file');
    } else {
      try {
        var obj = JSON.parse(text);
        fs.writeFile('domain.txt', obj.domain, function (err) {
          if (err)
            console.error('Error while writing domain text file');
          else
            console.log("Done");
        });
      } catch (e) {
        console.error('Invalid JSON in file');
      }
    }
  });

이 프로그램은 앞서 이야기한 장점이 상당 부분 사라졌습니다. 함수 인자에 입력과 출력이 모두 표현되고 있어, 더 이상 한 함수의 출력을 다른 함수의 입력으로 넘기는 것이 쉽지 않고, 예외 처리도 에러 콜백과 try/catch 블록 양쪽에서 모두 이루어지기 때문에 복잡해졌습니다.

자 그럼 콜백 예제는 잊고 이번에는 Promise로 짠 프로그램을 살펴보겠습니다. 참고로 node.js의 콜백 함수를 Promise 스타일로 바꾸기 위해 node의 promise 모듈이 제공하는 denodeify 함수를 사용하였습니다.

var fs = require('fs');
var Promise = require('promise');

var readFile= Promise.denodeify(fs.readFile);
var writeFile= Promise.denodeify(fs.writeFile);

readFile('config.json')
  .then(function (text) {
    try {
      var obj = JSON.parse(text);
      writeFile('domain.txt', obj.domain).then(function ()
      {
        console.log("Done");
      }).catch(function (reason) {
        console.error('Error while writing domain text file');
      });
    } catch (e) {
      console.error('Invalid JSON in file');
    }
  }).catch(function (reason) {
    console.error('Error while reading config file');
  });

이 프로그램을 콜백 방식이 아닌 동기 프로그래밍 예제와 비교해보시기 바랍니다. 뭔가 느껴지시나요? 네. 이 프로그램의 전체적인 구조는 동기 프로그래밍 예제와 상당히 닮아 있습니다. 물론 try/catch 블록 대신 catch 함수를 사용하거나 함수의 결과가 리턴값이 아닌 then 함수를 통해 넘어오긴 하지만 콜백 방식에 비해 동기 프로그래밍에 훨씬 가깝다는 사실을 느낄 수 있습니다.

이번에는 이 프로그램을 다시 ES 7 async 함수를 사용하도록 바꿔보겠습니다. (참고로 이 코드는 async 함수 지원이 필요하므로, 아직 node.js에서 동작하지 않습니다.)

async function () {
  try {
    var text = await readFile('config.json');
    try {
      var obj = JSON.parse(text);
      try {
        await writeFile('domain.txt', obj.domain);
        console.log("Done");
      } catch (e) {
        console.error('Error while writing domain text file');
      }
    } catch (e) {
      console.error('Invalid JSON in file');
    }
  } catch(e) {
    console.error('Error while reading config file');
  }
}();

async, await 키워드만 제외하면, 이 프로그램은 사실상 동기 프로그래밍과 똑같은 걸 확인할 수 있습니다. 앞서 디슈가링(desugaring)이라는 글에서 프로그래밍 언어의 새로운 기능을 이해하기 위해서는 새로운 기능이 기존 언어로 어떻게 표현되는지를 알면 된다고 말씀드렸는데, ES 7 async 함수는 디슈가링을 통해 ES 6 Promise로 표현됩니다. async 함수는 then 함수를 통해 넘어가던 함수의 출력을 await 키워드를 이용해 함수의 출력으로 바꿔주는 것입니다.

정리하면, ES 6 Promise는 기존 콜백 스타일에 비해 더 나은 비동기 프로그래밍을 제공할 뿐만 아니라 ES 7에서 준비하고 있는 async 함수를 제공하기 위한 준비 단계이기도 합니다. 다시 말해, Promise는 단순히 더 나은 비동기 프로그래밍 스타일이 아니라 불편한 비동기 프로그래밍 방식을 버리고, 다시 편리한 동기 프로그래밍 방식으로 돌아가기 위한 중간 과정으로 이해할 수 있습니다.

관심사의 분리

앞에서 JSON 파일을 읽어 domain key 값을 다시 파일에 쓰는 코드를 sync, callback, promise, async function 총 4가지 버전으로 작성해 보았습니다. 이 과정에서 알게 된 사실은 callback을 제외한 sync, promise, async function은 코드의 구조가 거의 똑같다는 사실입니다. 그래서 promise는 단순히 스타일이 다른 비동기 프로그래밍 방식이 아니라 코드를 좀 더 sync하게 작성하기 위한 방법이고, async function으로 넘어가기 위한 중간 단계라고 설명했습니다.

promise 예제를 다시 한 번 살펴보겠습니다.

var fs = require('fs');
var Promise = require('promise');

var readFile= Promise.denodeify(fs.readFile);
var writeFile= Promise.denodeify(fs.writeFile);

readFile('config.json')
  .then(function (text) {
    try {
      var obj = JSON.parse(text);
      writeFile('domain.txt', obj.domain).then(function () {
        console.log("Done");
      }).catch(function (reason) {
        console.error('Error while writing domain text file');
      });
    } catch (e) {
      console.error('Invalid JSON in file');
    }
  }).catch(function (reason) {
    console.error('Error while reading config file');
  });

이 코드는 sync 코드를 그대로 옮겼기 때문에 try/catch 블록과 catch() 함수가 같이 사용되어 조금 복잡해 보입니다. 코드를 좀 더 간결하게 만들기 위해 다음과 같이 에러 처리를 한 곳으로 모으고, then()을 중첩해서 부르는 대신 chaining하도록 바꾸겠습니다.

var fs = require('fs');
var Promise = require('promise');

var readFile= Promise.denodeify(fs.readFile);
var writeFile= Promise.denodeify(fs.writeFile);

readFile('config.json')
  .then(function (text) {
    var obj = JSON.parse(text);
    return writeFile('domain.txt', obj.domain);
  }).then(function () {
    console.log("Done");
  }).catch(function (reason) {
    if (reason.code === 'EACCES')
      console.error('Error while writing domain text file');
    else if (reason.code === 'ENOENT')
      console.error('Error while reading config file');
    else
      console.error('Invalid JSON in file');
  });

이 코드를 다시 sync 코드로 바꾸면 다음과 같습니다.

var fs = require('fs');

try {
  var text = fs.readFileSync('config.json');
  var obj = JSON.parse(text);
  fs.writeFileSync('domain.txt', obj.domain);
  console.log("Done");
} catch (reason) {
  if (reason.code === 'EACCES')
    console.error('Error while writing domain text file');
  else if (reason.code === 'ENOENT')
    console.error('Error while reading config file');
  else
    console.error('Invalid JSON in file');
}

이 두 코드를 비교하면 then의 의미가 좀 더 명확해집니다. sync 코드에서는 암묵적으로 코드가 한줄씩 실행된다고 가정하고, 다음줄을 실행할 때 이전 계산값이 이미 존재한다고 가정합니다. async 코드에서는 이런 보장이 없기 때문에 then()을 명시적으로 사용해 이전 줄의 실행 결과가 다음 줄을 실행할 때 필요하다고 표시합니다.

sync와 async의 차이점은 ‘a’가 있냐 없냐이고, 사실 종이 한 장 차이입니다. 코드의 로직은 전혀 차이가 없고, 단순히 동기냐 비동기냐의 차이만 있기 때문에 실제 코드도 거의 차이가 없어야 당연합니다. 하지만 node.js의 콜백 스타일은 ‘비동기’라는 단 하나의 걱정거리 때문에 코드 스타일이 완전히 달라졌기 때문에 좋지 않은 프로그래밍 스타일이라고 이야기할 수 있습니다.

then()함수의 의미는 동기/비동기의 차이를 하나의 함수로 캡슐화(encapsulation)했다는데 있습니다. promise를 이용해 작성한 코드는 then()의 구현체만 바꾸면 코드 변경 없이 같은 코드를 비동기가 아닌 동기로 구동할 수도 있습니다. 즉, 관심사의 분리(Separation of Concerns)라는 측면에서 promise는 “비동기 프로그래밍”이라는 관심사를 분리해냈다고 이해할 수도 있습니다.

Unity가 직면한 기술적 문제들 2

Unity가 직면한 기술적 문제들을 생각보다 많은 분들이 보시고 피드백을 주셔서 관련하여 제 의견을 한 번 더 정리합니다.

일단 오해의 소지를 줄이기 위해 한 가지 말씀드리면, 이 글은 제가 지난 1년 2개월 Unity를 이용한 모바일 게임 개발에 참여하며 개발자로서 느낀 기술적인 문제점을 정리한 것입니다. 모바일 게임 엔진의 향후 전망 같이 거창한 이야기는 절대 아니고, Unity 사용자가 작성한 피드백 정도로 이해하시면 됩니다. 또한 게임 엔진으로서 Unity 전체에 대한 종합적인 평가가 아니라 .NET 런타임 지원에 국한한 기술적인 문제만 제기한 것이므로 이 부분도 감안해서 읽으시길 권해 드립니다.

글을 올린 후에 많이 받은 피드백 중에 하나가 C#은 유니티 게임 엔진에서 단순히 스크립팅 언어로 사용될 뿐인데, 최신 .NET 런타임을 완벽히 지원하는 것이 중요하느냐라는 질문입니다.

이 피드백에 대해 제 생각을 말씀드리기 위해서는 일단 용어 정의부터 명확하게 해야할 것 같습니다. 프로그래밍 언어는 크게 프로그래밍 언어 자체와 런타임으로 나뉩니다. 런타임은 표준 라이브러리라고도 불리는데, 프로그래밍 언어와 불가분의 관계에 있어서 보통은 언어와 런타임을 통칭하여 프로그래밍 언어로 부릅니다. 예를 들어, C 언어는 C 언어와 C 표준 라이브러리고 구성이 되고, 자바는 자바 언어와 JDK로, C#은 C#과 .NET 런타임으로 구성이 되는 것이죠.

우리가 스크립팅 언어로 흔히 사용하는 언어로 JavaScript와 Lua가 있는데, 이들 언어의 공통점은 런타임이라고 부를 수 있는 요소가 굉장히 작다는 점입니다. 바꿔 말하면, 프로그래밍 단독으로는 거의 할 수 있는 일이 없습니다. 웹브라우저에서 DOM에 접근할 수 있다거나, node.js에서 HTTP 서버를 띄울 수 있는 이유는 JavaScript가 이런 기능을 제공하기 때문이 아니라, 웹브라우저나 node.js가 제공하는 기능을 JavaScript라는 스크립팅 언어를 통해 접근하는 할 수 있기 때문입니다. 이런 언어들은 언어 런타임이 굉장히 작기 때문에 이식성도 높고, 임베딩도 쉽게 할 수 있다는 장점이 있습니다.

이런 제약이 싫다면 Python이나 Ruby를 스크립트 언어로 사용할 수 있습니다. 이들 언어는 JavaScript나 Lua와 달리 상당히 방대한 양의 언어 런타임(표준 라이브러리)을 제공하고 있습니다. 예를 들어, Python은 로컬 파일을 읽는 기능이나, 쓰레드 생성, 네트워크 통신 등을 별도의 통합 작업 없이도 언어 런타임이 기본으로 지원합니다. 이런 기능은 필요에 따라 상당히 편리하게 이용될 수도 있으나, 반대로 임베더 입장에서는 열어줘서는 안 되는 기능을 의도치 않게 열게 될 수도 있습니다. 일례로, 만약 웹브라우저가 파이썬을 스크립팅 언어로 제공했다면 갑자기 웹브라우저의 기능과 상관 없이 Python의 표준 라이브러리를 사용하여 로컬 파일을 읽어가거나 네트워크 통신을 하는 일이 가능해집니다.

따라서 언어 런타임에 많은 기능이 포함된 언어를 스크립팅 언어로 사용하기 위해서는 어떤 API를 열어줄지 엄밀하게 고민해서 API를 부분적으로 잘라내거나, 기능에 접근할 수 없게 막는 장치가 필요하게 됩니다. 사내에서만 사용하는 스크립팅 언어라면 내부적으로 합의만 하면 되지만, Unity처럼 SDK 형태로 나가는 경우에는 상황이 달라집니다. 공개된 모든 API는 사용될 가능성이 있기 때문입니다.

Unity는 .NET을 스크립팅 언어로 선택했습니다. 명시적으로는 C#, UnityScript, Boo 등 구체적인 프로그래밍 언어를 지원한다고 하지만, 이들 언어 모두 .NET 기반이고 F#과 같이 지원이 명시되지 않은 언어도 Unity에서 동작에 문제가 없으므로 Unity의 스크립팅 언어는 사실상 .NET이라고 봐야 합니다. 그리고 .NET을 스크립팅 언어로 선택한 것은 축복이면서 동시에 저주가 되었다고 생각합니다.

Unity가 초기 모바일 게임 시장에 빠르게 진압할 수 있었던 이유에는 게임 엔진의 우수함이나 에디터의 편리함도 있겠지만, .NET 플랫폼을 활용한 크로스플랫폼 개발 환경이라는 요소를 무시할 수 없다고 생각합니다. C#이라는 편리한 개발 언어, .NET이 제공하는 수많은 API는 런타임 없이 간단한 스크립팅 언어만 제공하는 엔진에 비해 높은 생산성을 제공했기 때문입니다. 또한 .NET에 존재하는 수많은 써드파티 라이브러리도 별다른 노력없이 확보할 수 있었습니다.

하지만 Unity의 .NET 기술 지원이 뒤처지면서 초기의 이런 장점들은 점점 빚이 됩니다. .NET 4 이후로는 사실상 별도의 섬처럼 생태계가 고립되기 시작하였고, 더 이상 .NET 라이브러리를 Unity에서 그대로 사용할 수 없어 포팅 작업을 하거나 별도의 라이브러리를 제작해야 하는 상태가 되었습니다. 또한 LINQ, async/await 등 C#과 .NET의 최신 기능을 제공하지 않으면서 그 간극은 점점 더 멀어지고 있는 상태입니다.

일례로, 이전 글에서 이야기한 것처럼 LINQ를 지원하는 것도 아니고 지원하지 않는 것도 아닌 채로 방치하는 것은 심각한 문제입니다. .NET을 스크립팅 언어로 채택했으면, 아에 LINQ를 사용할 수 없게 API를 잘라냈거나 제대로 지원을 했어야 합니다. 이런 상황은 껍데기는 예쁘게 포장했는데 막상 포장을 뜯어보니 정작 내용물은 여기 저기 상해 있는 과일 상자와 다를 바가 없습니다.

쓰레드를 사용하지 말라고 이야기하는 것도 무책임한 말입니다. .NET을 스크립팅 언어로 채택했으면 쓰레드를 지원하거나 쓰레드 지원이 Unity 게임 엔진 사용에 문제가 된다면 아에 쓰레드 API를 제외했어야 합니다. LINQ와 마찬가지로 100% 지원하는 것도 아니고, 그렇다고 안 되는 것도 아닌 상태로 출시를 하면 당연히 .NET에 익숙한 개발자들이 쓰레드를 사용하게 되고 여러 가지 문제점을 겪으며 고생할 수밖에 없습니다.

물론 게임 엔진이라는 게 내부적으로 태스크(task) 혹은 잡(job)을 관리하기 위한 스케쥴러가 있고, 병렬적으로 동작하므로 단순히 게임 로직만 작성하는 스크립팅 언어에서 굳이 쓰레드를 사용하거나 복잡한 일을 해서는 안 된다고 조언할 수는 있습니다. 하지만 어린 아이는 다칠 위험이 없는 안전한 방에서 놀게 해야지, 온갖 위험한 물건들이 즐비한 방에서 놀게 하고선 위험한 물건은 만지지 말라고 조언해서는 안 된다고 생각합니다.

또한 async/await 키워드로 대표되는 C#의 비동기 프로그래밍은 단순히 멀티쓰레드 프로그래밍과는 개념이 다릅니다. 오히려 Unity가 코루틴을 이용해 어설프게 해결하고 있는 동시성(concurrency) 혹은 멀티태스킹(multitasking)을 쉽게 작성하기 위해 고안된 방법에 가깝습니다. 게임이라는 게 로직만 작성하더라도 동시성을 표현하지 않을 수 없는데, 이런 동시성을 표현하는 가장 효과적인 방법이 async를 이용한 비동기 프로그래밍이기 때문입니다. 참고로 대표적인 싱글 쓰레드 모델의 프로그래밍 언어인 JavaScript 또한 ES7에서 C#의 async와 유사한 async 함수 도입을 논의하고 있습니다.

Unity는 어쨌거나 .NET을 채택했으므로 이왕이면 발전하고 있는 .NET 기술의 혜택도 누리는 게 맞는데, 이러지도 저러지도 못하고 구버전 .NET에 갖혀 있는 상태가 안타깝다는 생각이 듭니다. 마이크로소프트나 Xamarin과 협상이 잘 되어서 돌파구를 찾거나 IL2CPP 개발에 더 박차를 가해서 당면한 문제를 풀기를 기대해 봅니다.

JavaScript 바로 알기

JavaScript는 세상에서 가장 많이 쓰이는 언어 중 하나입니다. “시작은 미약하였으나 끝은 창대하리라”는 말이 딱 들어맞는 언어가 JavaScript인 것 같습니다. 초기 웹브라우저의 간단한 스크립트 언어로 출발한 뭔가 어설픈 언어가 인터넷의 발전과 더불어 지금은 전세계에서 가장 많은 개발자를 확보하고 있는 메인스트림 언어가 되었으니 말입니다.

전 웹브라우저 개발을 하면서 2008-2010년에 JavaScript의 표준을 정하는 Ecma의 TC39 미팅에 정기적으로 참석을 했었습니다. 당시 모질라, 구글, 야후, MS, IBM, 오페라 등이 참여하여 10년 이상 정체된 JavaScript의 다음 버전을 논의하고 있었는데, 그렇게 정리되서 나온 것이 EcmaScript 5(이하 ES5)입니다. 얼마 전에 ES6가 나왔고, ES 7을 이미 논의하기 시작했으니 벌써 시간이 많이 흘렀습니다. 당시 저는 30초반의 경험 부족 엔지니어라 세계적인 대가인 Brendan Eich, Douglas Crockford, Mark Miller, Waldemar Horwat, Allen Wirfs-Brock 등과 같은 자리에서 이야기 나누고, 같이 밥 먹는 것만으로 신기했던 기억이 납니다.

Douglas Crockford가 JavaScript: The Good Parts에서 이야기한 것처럼 JavaScript는 좋은 점도, 나쁜 점도 많은 언어입니다. 하지만 좋은 점이 나쁜 점을 모두 상쇄하고도 남을 만큼 강력하였기 때문에 다른 언어로 대체되지 않고, 지금도 계속 발전하고 있다고 생각합니다.

제가 생각하는 JavaScript의 좋은 점은 “객체지향 프로그래밍”이 아닌 “함수형 프로그래밍”에 있습니다. Java나 C#이 람다(lambda)를 도입하기 훨씬 전에, JavaScript는 이미 함수를 인자로 넘기고, 리턴값으로 돌려주고, 변수에 저장할 수 있었습니다. JavaScript는 이 first-class 함수를 이용하여 JavaScript의 많은 단점(block scoping의 부재, class의 부재 등)을 극복해 냅니다. 그렇게 때문에 JavaScript를 바로 아는 것은 JavaScript에 숨어 있는 함수형 프로그래밍을 이해하고 활용하는 방법을 아는 것이 됩니다.

JavaScript는 게임 개발자와도 분리할 수 없는 언어입니다. Unity가 지원하는 언어 중 하나(Unity JavaScript)이기도 하고, node.js 프레임워크가 많이 사용되면서 게임 서버 프로그래밍에서도 중요한 언어가 되었기 때문입니다. 그래서 앞으로 “C# 바로 알기” 시리즈와 더불어서 “JavaScript 바로 알기” 시리즈를 블로그에 연재하려고 합니다. 많은 관심 부탁드립니다.

프로그래밍과 글쓰기

지난 프로그래밍과 수학에 이어 오늘은 프로그래밍과 글쓰기에 대한 이야기를 하고자 합니다.

게임을 포함한 모든 소프트웨어는 요구사항 및 기획, 설계, 코드 작성, 테스트, 출시 ,유지 보수 등의 과정을 거쳐야만 하고, 다른 공학 분야와 마찬가지로 해당 분야에 대한 전문 지식, 고도의 사고력, 제품 경험 등 종합적인 능력이 요구됩니다. 따라서 소프트웨어를 잘하기 위해 어떤 능력이 필요하냐는 질문에는 보는 관점에 따라 “해당 분야의 전문 지식”을 말하는 사람도 있고, “수학이나 논리적 사고력”을 말하는 사람도 있고, 더 나아가 “인문학적 소양”을 말하는 사람도 있게 됩니다.

저는 프로그래밍이란 행위는 글쓰기와 가장 유사하다고 생각합니다. 주제를 선정하고 개요를 작성하고 글을 쓰고 퇴고하는 일련의 행위는, 어떤 소프트웨어를 만들지 정하고 중요 모듈을 설계하고 코드를 작성하고 리팩토링하는 과정과 닮아 있습니다. 좋은 코드를 작성하기 위한 팁을 담고 있는 Clean CodeCode Complete과 같은 책들은 내용과 구성 면에서 글쓰기 책과 다를 바가 없고, Refactoring은 글을 다 쓴 후에 퇴고하는 과정을 설명합니다.

대부분의 소프트웨어는 무척 복잡합니다. 소프트웨어 작성의 핵심은 결국 복잡한 문제를 얼마나 사람이 이해하기 쉽게 풀어쓸 것인가에 대한 문제입니다. 운영체제나 웹브라우제, 프로그래밍 언어 런타임 같은 소프트웨어가 아니더라도 웬만큼 성공한 소프트웨어는 1명의 인간이 이해할 수 있는 한계를 넘어서 있고, 이러한 복잡한 소프트웨어를 계속 유지 보수하고 개선해 나가기 위해서는 코드가 얼마나 이해하기 쉽게 작성되었는지가 가장 중요합니다. 새로운 기능을 추가하거나 성능 최적화를 하려고 해도 코드 이해가 선행되어야만 합니다.

제가 지난 1년 2개월간 현업의 게임 개발자들과 같이 이야기하면서 알게된 것은 게임을 잘 만들기 위한 도메인의 전문 지식에 비해, 프로그래밍을 잘 작성하기 위해 필요한 “글쓰기”로서의 프로그래밍 능력이 현저히 부족하다는 사실입니다.

여기에는 이유는 여러 가지가 있을 수 있습니다만, 가장 큰 문제는 도메인에 대한 전문 지식과 프로그래밍 능력을 구분하지 않는 풍조에 있습니다. 예를 들어, 머신 러닝 관련 프로젝트를 진행한다고 하면 당연히 해당 분야를 전공한 전문 지식이 필요합니다. 하지만 머싱 러닝 알고리즘을 오랜 시간 연구하였다고 해서 반드시 프로그래밍을 잘하는 것은 아닙니다. 프로그래밍은 글쓰기와 비슷해서 잘 쓴 글을 많이 보고, 많이 작성해 보고, 작성한 코드를 고쳐보고 고민하면서 조금씩 발전하기 때문입니다. 오히려 전문 분야 연구에 시간을 많이 쓴 개발자일수록 프로그래밍 경험이 부족해서 코드 작성을 잘 못하는 경우가 더 빈번합니다.

물론 현업 개발자라면 어쨌거나 하루 최소 8시간은 코드 작성에 시간을 쓰고 있고(물론 회의에 불려가거나 잡무를 하는 경우는 제외하고), 코드 작성 경험 부족이 프로그래밍을 못 하는 이유라고 보기는 어렵습니다. 실제로 개발에 많은 시간을 쓰는 데도 실력이 늘지 않는 또 다른 이유도 글쓰기 비유에서 답을 찾 수 있습니다. 유명 작가가 되려면 글을 많이 써보는 것도 중요하지만, 이른바 고전이라고 불리는 잘 쓴 글을 많이 읽는 것이 무척 중요합니다. 코드 작성도 마찬가지라서 잘 쓴 코드를 읽는 것이 코딩을 배울 수 있는 가장 좋은 방법입니다.

대부분의 개발자는 현업에서 선배 개발자들이 작성한 코드를 유지보수하면서 코드 작성 방법을 배우게 됩니다. 회사에 따라 다르긴 하겠지만, 이렇게 접한 대부분의 코드는 겨우 겨우 동작만 하지, 코드의 가독성이나 유지보수를 거의 생각하지 않고 작성된 코드일 확률이 높습니다. 이런 코드를 읽고, 어떻게든 돌아가게 수정하는 훈련만 하다 보면 애시당초 코드를 잘 짠다는 게 무엇인지 알기가 어렵게 됩니다.

방법이 없지는 않습니다. 잘 짠 코드는 인터넷에 이미 오픈소스로 많이 공개되어 있기 때문입니다. 관심 있는 분야의 오픈소스 프로젝트를 골라서 코드를 읽고 공부하는 것은 고전을 읽고 공부하는 것과 마찬가지라 정말 많은 것을 배울 수 있습니다. 물론 진입 장벽이 있습니다. 고전이란 모두가 읽고 싶어하지만 아무도 안 읽은 책인 것처럼 이런 성공한 오픈소스 프로젝트들도 사람들의 관심에 비해 실제로 코드를 읽고 참여하는 사람이 지극히 소수이기 때문입니다. 하지만 해답도 고전 읽기에서 찾을 수 있습니다. 원전을 두고 해설서만 찾아 헤맬 것이 아니라 원전을 사서 한 줄이라도 읽기 시작하는 것입니다.

게임 서버: node.js의 장점과 단점

그간 Unity, C# 등 클라이언트 기술에 대한 이야기만 했는데, 오늘은 서버에 대한 이야기도 해볼까 합니다.

제가 게임 개발을 맡았을 때 두 팀 중 한 팀은 이미 1년 가까이 프로젝트를 진행 중이었고, 다른 MMORPG에 사용했던 C++로 작성된 게임 서버를 이용해 서버 로직을 작성하고 있었습니다. 당시 서버/서버팀에는 다음과 같은 몇 가지 문제점이 있었습니다.

  • 개발자 생산성 저하 (C++로 서버 로직 작성)
  • 서버 엔진에 대한 경험 부족
  • 서버 자체의 안정성 문제

사실 더 큰 문제는 프로젝트 성격에 맞지 않는 게임 서버 엔진을 사용하고 있다는 점였습니다. MMORPG 게임 서버는 보통 DB 접근의 오버헤드를 줄이기 위해 로직 서버가 게임 진행에 대한 상태 정보를 메모리에 가지고 있는 구조이고, 여러 서버 간의 상태를 동기화를 위한 방법도 제공합니다. 하지만 우리가 제작하는 게임은 네트워크 플레이를 지원하지 않는 모바일 RPG게임이고 서버가 하는 일은 단순히 요청을 처리하고 DB에 기록하고 응답을 주는 일뿐인데, 불필요하게 복잡한 서버 아키텍처를 사용하고 있는 상황이었습니다.

아직 개발 기간도 충분히 남은 상태이고, 앞으로 추가해야 할 서버-클라이언트 간의 프로토콜도 많이 남아있던 상황이라 결국 출시까지 이 문제를 계속 끌고 가는 부담을 지는 대신에 서버를 교체하기로 결정내렸습니다. 개발자의 생산성, 서버의 규모 가변성(scalability) 등 여러 면을 검토한 결과 내린 결론은 DB만 증설하면 게임 로직 서버 자체의 규모 가변성을 쉽게 확보할 수 있는 웹서버를 채택하는 것이었습니다.

웹서버의 장점은 명확합니다. 일단 규모가변성 면에서는 DB 외에는 상태를 저장하지 않기 때문에 로직 서버에 부하가 걸리면 로직 서버만 증설해주면 됩니다. (물론 자주 쓰는 데이터는 RedisMemcached로 캐싱하는 옵션이 있습니다.) node.js, Django, Ruby on Rails 등 이미 수많은 웹서버 프레임워크가 존재하기 때문에 이 중 하나를 골라서 쉽게 로직 코드를 작성할 수 있습니다. Unity는 HTTP 요청을 쉽게 하기 위한 WWW 클래스를 제공합니다.

node.js, Django, Ruby on Rails 등은 모두 검증된 웹서버 프레임워크지만 저의 선택은 node.js였습니다. 일단 게임 로직 서버는 REST API 서버와 유사한 구조이고, Django나 Ruby on Rails가 제공하는 템플릿 엔진이나 ORM 등이 크게 필요하지 않았기 때문입니다. node.js에 REST API를 쉽게 작성할 수 있는 모듈인 restify와 MySQL에 접근하기 위한 드라이버인 node-mysql 정도면 충분히 필요한 기능을 다 제공한다고 판단하였습니다.

node.js를 선택한 또 다른 이유는 성능입니다. node.js 엔진인 V8의 성능이 Python이나 Ruby 보다 뛰어나다는 점도 고려 대상이지만, 더 중요한 이유는 node.js가 제공하는 비동기 IO 방식에 있었습니다. C10K 문제로 알려져 있는 서버 성능 이슈의 결론은 요청 하나 하나를 별도의 쓰레드를 만들어서 동기 IO를 하는 방식으로 서버 성능을 확보할 수가 없고, 운영체제가 제공하는 epoll이나 kqueue 같은 시스템을 콜을 사용해서 비동기 IO를 해야한다는 것입니다. node.js는 운영체제에 상관 없이 비동기 IO 메커니즘을 제공하는 libuv라는 라이브러리를 이용해 작성되어 있고, 내부적으로 epoll, kqueue, IOCP 등을 사용하고 있습니다.

물론 세상에 공짜는 없습니다. node.js가 비동기 I/O를 통해 확보한 성능 이점은 불편한 비동기 프로그래밍 모델로 그대로 노출되기 때문입니다. 다음 할 일을 계속 콜백(callback) 함수로 넘기는 스타일로 코딩을 하다 보니 Pyramid of Doom이라고 알려져 있는 다음과 같은 스타일의 코드가 나오게 됩니다. 이 문제를 해결하기 위해 비동기 제어 흐름을 좀 더 쉽게 표현하기 위한 async 모듈, underscore 등도 사용했지만, 이러한 프로그래밍 스타일은 Django나 Ruby on Rails에 비교해서 코드의 가독성을 현저히 떨어뜨리고 있는 것이 사실입니다.

step1(function (value1) {
    step2(function (value2) {
        step3(function (value3) {
            step4(function (value4) {
                step5(function (value5) {
                    step6(function (value6) {
                        // Do something with value6
                    });
                });
            });
        });
    });
});

다음 할 일을 계속 콜백으로 넘기는 방식은 프로그래밍 언어에서 Continuation Passing Style(CPS)로 알려져 있습니다. CPS는 제너레이터(generator), 코루틴(coroutine), 예외(exception) 등 다양한 프로그래밍의 제어 흐름을 표현할 수 있는 강력한 개념이지만, 사실 컴파일러가 아닌 사람이 사용하기에 편리한 방식이 아닙니다. node.js가 제공하는 프로그래밍 모델은 따지고 보면 비동기 IO를 통해 성능 이점을 제공할 테니, CPS 스타일의 불편함을 감수하라는 얘기가 됩니다.

다행히 이 문제는 여러 차원에서 해결책이 논의 중입니다. 자바스크립트 Promise 라이브러리와 EcmaScript 6의 제너레이터(Generator)의 결합으로 이 문제를 해결하기 위한 방법들이 논의되고 있고, EcmaScript 7은 아에 프로그래밍 언어 자체에 비동기 프로그래밍 지원하기 위한 기능을 추가할 움직임을 보이고 있습니다. (이 부분에 대해서는 다음에 조금 더 자세히 다루겠습니다.)

결론은 모바일 RPG 게임 서버로 node.js를 선택한 것에 대해서는 나름대로 만족하고 있습니다. 다만, 비동기 프로그래밍이 프로그램을 복잡하게 만들고 가독성을 떨어뜨리는 문제에 대해서는 좀 더 고민이 필요하다는 교훈도 얻었습니다. 서버 프로그래밍의 본질은 결국 성능 혹은 규모 가변성이라는 걱정거리(concern)를 어떻게 코드 내에서 분리(seperation)할 수 있는지에 대한 고민인 것 같습니다.

프로그래밍과 수학

모든 공학은 기초 과학에 뿌리를 두고 있습니다. 따라서 해당 학문의 뼈대를 구성하는 기초 과학을 잘 이해하는 것이 엔지니어링을 잘하는 가장 빠른 지름길이기도 합니다. 프로그래밍도 예외는 아니어서 프로그래밍을 잘하기 위해서는 컴퓨터 과학, 더 나아가서 논리학과 수학에 대한 이해가 필수입니다.

저는 게임 개발자를 뽑을 때 주로 컴퓨터공학과(혹은 컴퓨터과학과) 학부에서 배우는 전공 지식에 대해 주로 질문하였습니다. 게임 프로그래밍 경험이 아무리 출중해도 알고리즘의 시간/공간 복잡성(time/space complexity)에 대한 개념도 이해를 못 하고 있다면 개발자로서 앞으로의 발전을 기대하기는 힘들다고 생각하기 때문입니다.

컴퓨터 과학이 수학과 논리학에서 출발학 학문임에도 불구하고 개발자들은 수학과 프로그래밍의 상관 관계를 피부로 체감하지 못하는 경우가 많은 것 같습니다. 저 또한 대학을 수학과로 입학했음에도 불구하고, 대수학 같은 추상적인 수학을 공부하면서 지루해 했고 결국 컴퓨터공학과로 전과 후에는 수학 공부를 멀리 했었습니다. 오히려, 졸업 후에 프로그래밍을 더 공부하다 보니 다시 수학의 필요성을 절감하고 뒤늦게 수학 공부하느라 진땀을 빼고 있는 중입니다.

이번 글에서는 수학 공부에 대한 흥미를 유발하기 위해 수학과 프로그래밍의 재미있는 상관 관계 하나를 보여드리고자 합니다. 일단 우리가 알고 있는 간단한 덧셈부터 시작해 보겠습니다.

1 + 2 = 3

이런 수식의 구성 요소는 숫자(1, 2, 3 등)과 연산자(+, – 등)이 있고, 숫자 대신 변수(x, y, z 등)을 사용할 수도 있습니다. 또한 다음과 같은 법칙들도 존재합니다.

0 + x = x

1 * x = x

위 예에서 알 수 있듯이 대수학이란 크게 3가지 요소로 구성됩니다.

  • 오브젝트(object) – 대수학이 다루는 것.
  • 연산자(operation) – 오브젝트로 새로운 오브젝트를 만드는 것.
  • 법칙(law) – 오브젝트와 연산자들의 상관 관계

여기까지 복습을 했으면 이제 프로그래밍으로 돌아가 봅시다. 1+1=2라는 사실과 프로그래밍이 어떤 관계가 있을까요? C# 타입 시스템을 예로 들어 설명해 보겠습니다.

일단 C# 타입 시스템으로 숫자 0, 1, 2를 만들어 보겠습니다. 타입 시스템과 숫자를 연결시키기 위해 어떤 타입이 가지는 인스턴스의 수를 세는 방법을 사용하겠습니다.

우리가 가장 사랑하는 디자인 패턴인 싱글톤(singletone)은 하나의 인스턴스만 만들 수 있으므로 숫자 1이라고 생각할 수 있습니다. 다음과 같이 Unit이라는 싱글톤 클래스를 선언하였습니다.

class Unit
{
    public static Unit Instance = new Unit();

    private Unit()
    {
    }
}

숫자 0은 어떻게 만들 수 있을까요? 클래스는 있지만 인스턴스를 전혀 만들 수 없는 클래스를 만들면 됩니다.

class Void
{
    private Void()
    {
    }
}

숫자 2는 어떻게 만들 수 있을까요? 해당 타입의 인스턴스를 단 2개만 만들 수 있어야 하는데, 우리는 이런 타입을 이미 알고 있습니다. 바로 bool이죠. 아니면 Unit과 유사한 방식이지만 인스턴스가 2개 있는 타입을 만들 수도 있습니다.

class Bool
{
    public static Bool True = new Bool();
    public static Bool False = new False();

    private Bool()
    {
    }
}

자 이제 숫자는 충분히 있으니 덧셈을 만들어 보겠습니다.

abstract class Sum
{
}

sealed class SumLeft : Sum
{
    private readonly TL value;

    public SumLeft(TL left)
    {
        this.value = left;
    }
}

sealed class SumRight : Sum
{
    private readonly TR value;

    public SumRight(TR right)
    {
        this.value = right;
    }
}

</tl,></tl,></tl,></tl,></tl,>

Sum이라는 클래스는 SumLeftSumRight라는 두 개의 서브클래스만이 존재하는 추상 클래스입니다. 바꿔 말해 Sum은 반드시 SumLeft이거나 SumRight입니다.

이게 왜 덧셈이 되는 걸까요? Sum 타입의 인스턴스를 나열해 보면 알 수 있습니다. 예를 들어, Sum[bool, Unit] 타입의 인스턴스는 SumLeft(true), SumLeft(false), SumRight(Unit) 세 개의 인스턴스를 만들 수 있습니다. bool은 2, Unit은 1이므로 Sum[bool, Unit]은 2+1을 뜻하고 실제로 인스턴스의 수도 3인 것을 확인할 수 있습니다.

곱셈은 더 간단합니다. C#의 클래스 자체가 곱셈을 표현하는 타입이기 때문입니다.

class Product
{
    private readonly TL left;
    private readonly TR right;

    public TL Left { get { return left; } }
    public TR Right { get { return right; } }

    public Product(TL left, TR right)
    {
        this.left = left;
        this.right = right;
    }
}

</tl,>

Product[TL, TR]leftright 2개의 필드를 가지고 있습니다. 이게 곱셈이 되는 이유는 Product[bool, Unit]의 인스턴스 수를 세어 보면 알 수 있습니다. 이 타입은 Product(true, Unit)Product(false, Unit) 두 개의 값을 가집니다. left 값 하나에 대해서 모든 right 값이 반복되므로 Cartesian product이라고 생각할 수도 있습니다.

이제 오브젝트(Void, Unit, bool 등)와 연산자(Sum, Product)를 모두 정의했으므로 우리가 알고 있는 대수학의 법칙이 그대로 적용되는지도 확인해 보겠습니다.

0 + x = x

위 식을 C#의 타입으로 풀어보면 Sum[Void, X] = X가 됩니다. Void는 인스턴스가 존재하지 않는 타입으므로 SumLeft[Void]의 인스턴스는 존재할 수가 없습니다. 따라서 모든 인스턴스는 SumRight[X] 타입이 되고, 전체 인스턴스의 수는 X 타입의 인스턴스 수와 일치하게 됩니다. 0은 덧셈에 대한 항등원이고, VoidSum 연산에 대한 항등원임을 확인할 수 있습니다.

x+y=y+x

위 식도 C# 타입으로 풀어보면 Sum[X, Y] = Sum[Y, X]가 됩니다. Sum[X, Y]의 의미는 인스턴스가 SumLeft[X]이거나 SumRight[Y]라는 뜻이므로 X 타입의 인스턴스 수 + Y 타입의 인스턴스의 수가 됩니다. 반대로 Sum[Y, X]는 인스턴스가 SumLeft[Y]이거나 SumRight[X]라는 뜻이므로 마찬가지로 X 타입의 인스턴스 수 + Y 타입의 인스턴스의 수가 됩니다. C# 타입 시스템에서 Sum[X,Y]Sum[Y,X]는 엄밀히 말해 다른 타입이긴 하지만, 덧셈으로서 의미는 일치함을 알 수 있습니다.

곱셈도 마찬가지 방식으로 법칙을 확인할 수 있습니다.

0 * x = 0

Product[Void, X] 타입의 인스턴스는 존재할 수 없습니다. Void의 인스턴스가 존재하지 않기 때문입니다. 인스턴스의 수가 0이므로 이 타입은 0을 뜻하는 Void와 의미가 일치함을 알 수 있습니다.

1 * x = x

Product[Unit, X] 타입의 인스턴스는 Product(Unit, X)가 됩니다. Unit은 인스턴스가 1개밖에 없으므로 이 타입의 인스턴스 수는 X 타입의 인스턴스 수와 동일합니다.

x * y = y * x

Product[X, Y]Product[Y, X]는 모두 X 타입의 인스턴스 수 * Y 타입의 인스턴스 수를 가지므로 곱셈으로서 의미가 일치한다는 사실도 쉽게 확인할 수 있습니다.

같은 방법으로 배분 법칙(distributive law)과 같은 좀 더 복잡한 법칙도 성립함을 알 수 있습니다.

x * (y + z) = x * y + x * z

이번에 반대 방향으로 C#의 함수 타입이 우리가 알고 있는 대수학과 어떤 관계가 있는지 살펴봅시다. bool -> bool 함수 타입은 C# 타입으로 Func[bool, bool]입니다. 이 타입의 인스턴스 수는 몇 개일까요? booltrue 혹은 false 두 개의 값을 가지므로 Func[bool, bool]은 2 * 2, 즉 4개의 값을 가집니다. 이렇게 보면 곱셈과 비슷한 것 같기도 한데, 확인을 위해 인스턴스가 3개인 타입을 정의해서 다시 한 번 확인해 봅시다. (enum 타입은 임의의 int로 만들어 낼 수 있으므로 엄밀하 말해 인스턴스가 3개는 아니지만, 여기서는 편의를 위해 인스턴스가 3개라고 가정합니다.)

enum Num
{
   One,
   Two,
   Three
}

Func[Num, bool] 타입의 인스턴스 수는 Num의 인스턴스 각각에 대해 true, false 두 개의 값을 가질 수 있으므로 2 * 2 * 2 = 8개임을 알 수 있습니다. 곱셈이 여러 번 반복되는 것은 지수이므로 2^3 = 8로 표현할 수도 있습니다. 바꿔 말해, 인스턴스의 수가 각각 a, b인 Func[A,B]의 값은 b^a이 됩니다.

함수 타입에서도 몇 가지 법칙을 확인할 수 있습니다. 예를 들어, Func[Unit, X]X와 같습니다. Unit 값 하나에 대해 X 타입 인스턴스 수만큼 인스턴스가 존재하기 때문입니다. 반대로 Func[X, Unit]Unit과 같습니다. X의 값에 상관 없이 Unit이 나오기 때문입니다. 이는 우리가 아는 대수학으로 표현하면 각각 a^1 = a, 1^a = 1이 됩니다. 이처럼 함수와 지수는 그 수학적 성질이 같기 때문에 타입 이론에서는 함수 타입을 지수 타입(exponential type)이라고도 부릅니다.

이런 상관 관계를 이용하면 재미있는 사실을 하나 발견할 수 있습니다. 우리가 함수 언어에서 말하는 currying은 인자 여러 개의 함수를 인자 하나짜리 함수로 표현하는 것을 말합니다. 예를 들어, (A, B) => C 타입의 함수는 A => B => C 타입으로 표현될 수 있습니다. C#의 람다 표현식(lamba expression)으로 적어보면 다음과 같습니다.

Func add = (x, y) => x + y;
int a = add(2, 3);  // a = 5

Func<int, func> curriedAdd = x => y => x + y;
int b = curriedAdd(2)(3); // b = 5

</int,></int,>

add 함수는 인자 x, y를 받아서 x+y를 계산하는 함수입니다. curriedAdd 함수는 인자 ‘x’를 받아서 인자 y를 받아 x+y를 리턴하는 함수를 리턴합니다. 여기에 인자 2,3을 차례대로 호출하면 add와 마찬가지로 5가 리턴되는 것을 확인할 수 있습니다.

참고로 (int, int)가 인자인 함수는 Product[int, int] 타입을 받는 인자 하나짜리 함수로 생각할 수 있습니다. 따라서 (A, B) => C 타입의 함수를 지수로 쓰면 c^(ab)가 됩니다. 이는 c^(ba)와 같고 (c^b)^a와 같습니다. 여기서 다시 함수 타입으로 돌아가면 A => (B => C)가 나옵니다. 인자 여러 개를 받는 함수를 인자 하나를 받는 함수를 반복해서 표현할 수 있다는 currying을 지수로 표현하면 이미 우리가 알고 있는 간단한 연산만으로 유도할 수 있는 셈입니다.

한 발 더 나가서 이번에는 재귀적 데이터 타입인 리스트(List)를 정의해 보겠습니다. C#에서 리스트는 여러 방식으로 구현할 수 있는데, 여기서는 함수 언어에서 사용하는 전통적인 방식의 리스트를 정의하겠습니다. List[T]는 두 가지 경우의 수가 있는데, 빈 리스트를 뜻하는 Nil이거나 T 타입의 tail과 List[T] 타입의 tail로 구성된 Cons가 있습니다.

이를 우리가 앞서 정의한 SumProduct 타입으로 표현해 보면 List[T]Sum[Unit, Product[T, List[T]]]가 됩니다. 풀어서 설명하면 리스트는 빈 리스트를 뜻하는 SumLeft[Unit]이거나 TList[T]로 이루어진 SumRight[Product[T, List[T]]]입니다.

SumProduct는 각각 덧셈과 곱셈, Unit은 1로 대체할 수 있으므로 위 리스트 타입을 대수학으로 표현하면 다음과 같이 됩니다.

L(T) = 1 + T * L(T)

수식을 풀어보면 다음과 같습니다.

L(T) – T * L(T) = 1

(1 – T) * L(T) = 1

L(T) = 1 / (1 – T)

여기까지는 간단한 계산입니다. 1 / (1 – T)를 테일러 시리즈(Taylor Series)로 풀어보면 다음과 같습니다.

L(T) = 1 + T + T^2 + T^3 + T^4 + …

이 수식을 다시 타입으로 들고 와서 해석해 보면 List[T]Unit이거나 T이거나 Product[T, T]이거나 Product[T, T, T]이거나… 라는 식으로 해석이 되고 이는 정확히 리스트 타입이 의미하는 바를 나타냅니다. Tint를 대입해보면 각각의 타입이 [], [1], [1,2], [1,2,3]와 같은 리스트의 값을 표현하고 있기 때문입니다.

여기서 가장 재미있는 점은 우리가 타입에 대해서는 뺄셈이나 나눗셈 연산을 전혀 정의하지 않았음에도 불구하고, 타입을 대수로 옮긴 다음에 뺄셈, 나눗셈 및 테일러 시리즈까지 수행한 결과를 다시 타입으로 들고 와도 여전히 유효하다는 사실입니다. 단순히 우연일까요? 이게 가능한 이유는 타입과 대수가 모두 semiring이라는 수학적 구조를 따르고 있기 때문입니다.

설명이 길어졌는데, 우리가 매일 매일 사용하는 프로그래밍 언어에도 우리가 생각치 못한 수많은 수학적 원리들이 숨어 있습니다. 앞서 살펴본 것처럼 우리가 늘 사용하는 간단한 사칙 연산조차도 프로그래밍 언어의 타입 시스템과 깊은 관계가 있음을 알 수 있습니다. 따라서 프로그래밍을 좀 더 깊이 있게 공부하려면 이런 수학, 논리학과 같은 기초에 관심을 가지고 조금 더 공부해 볼 것을 권합니다.

참고로 이 내용은 헤스켈로 설명한 The Algebra of Algebraic Data Types 강연 내용을 이해하기 쉽게 C#으로 정리하였습니다. 좀 더 깊이 있는 내용을 원하시면 해당 강연을 들어보시기 바랍니다.