관심사의 분리(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 바로 알기” 시리즈를 블로그에 연재하려고 합니다. 많은 관심 부탁드립니다.