자바스크립트

[번역] 카툰으로 소개하는 웹어셈블리

이 글은 모질라 Hacks의 웹어셈블리 시리즈 중 첫 번째 글인 A cartoon intro to WebAssembly의 번역글이다. 전 시리즈에 걸쳐 웹어셈블리 뿐만 아니라 JIT나 어셈블리 등에 대해서도 체계적으로 잘 설명하고 있고, 카툰까지 곁들여서 이해하기도 쉽다.

총 6부로 되어 있으며, 원작자의 허가를 얻어 전체 시리즈를 모두 번역했으니 처음부터 차례대로 모두 읽어보길 권한다.

  1. 카툰으로 소개하는 웹어셈블리 (현재글)
  2. 저스트-인-타임(JIT) 컴파일러 집중 코스
  3. 어셈블리 집중 코스
  4. 웹어셈블리 모듈의 생성과 동작
  5. 웹어셈블리는 왜 빠를까?
  6. 웹어셈블리의 현재 위치와 미래

웹어셈블리는 빠르다. 아마 당신도 들어봤을 것이다. 하지만, 웹어셈블리가 왜 빠른걸까?

이 연재물에서 나는 왜 어셈블리가 빠른지에 대해 설명해보려고 한다.

잠깐, 그래서 웹어셈블리가 뭔데?

웹어셈블리는 자바스크립트가 아닌 다른 프로그래밍 언어로 작성된 코드를 브라우저에서 실행시키기 위한 방법이다. 그래서 사람들이 웹어셈블리가 빠르다고 이야기하는 건, 자바스크립트와 비교했을 때 빠르다는 말이다.

지금 나는 자바스크립트나 웹어셈블리 둘 중의 하나만 선택해야 한다고 말하고 싶은 것이 아니다. 사실, 우리는 개발자들이 동일한 어플리케이션에서 웹어셈블리와 자바스크립트를 모두 사용하기를 기대한다.

하지만 두 언어를 비교해서 웹어셈블리가 가진 잠재적 효과를 이해할 수 있다면 유용할 것이다.

성능에 대한 약간의 역사

자바스크립트는 1995년에 만들어졌다. 자바스크립트는 빠르게 동작하도록 디자인되지 않았으며, 처음 10년동안은 실제로 빠르지 않았다.

그 이후에 브라우저들의 경쟁이 시작되었다.

2008년에 사람들이 성능 전쟁이라고 말하는 기간이 시작되었다. 많은 브라우저들이 저스트-인-타임(Just-in-time) 컴파일러, 즉 JIT를 장착했다. 자바스크립트가 실행되는 동안, JIT는 패턴들을 살펴볼 수 있었고, 이들 패턴을 기반으로 코드를 더 빨라지도록 만들었다.

JIT가 등장하면서 자바스크립트의 성능에는 변곡점이 생겼다. 자바스크립트의 실행 속도가 10배는 빨라진 것이다.

01-01-perf_graph05-500x409.png

이런 성능 향상에 힘입어, 자바스크립트는 Node.js를 이용한 서버사이드 프로그래밍 등과 같이 이전까지는 누구도 상상할 수 없었던 곳에서 사용되기 시작했다. 성능의 향상은 자바스크립트를 전혀 새로운 영역에서도 사용할 수 있도록 만들어 주었다.

지금 우리는 아마도 또다른 변곡점에 있는지도 모른다. 웹어셈블리로 인해.

01-02-perf_graph10-500x412.png

자, 그럼 좀더 자세한 내용으로 들어가서 웹어셈블리가 왜 빠른지를 이해해보도록 하자.

배경지식:

웹어셈블리, 현재:

웹어셈블리, 미래:

 

Lin Clark 에 대해

Lin은 모질라 개발자 관계 팀의 엔지니어이다. 그녀는 자바스크립트, 웹어셈블리, Rust, Servo 등을 끄적거리며, 코드 카툰을 그린다.

코드카툰 : http://code-cartoons.com/
트위터 : @linclark

자바스크립트

[번역] 자바스크립트의 재귀, PTC, TCO, STC 에 대한 모든 것

원문 : http://lucasfcosta.com/2017/05/08/All-About-Recursion-PTC-TCO-and-STC-in-JavaScript.html

요즘은 모두가 함수형 프로그래밍과 그 개념에 대해서 열광적인 것 같다. 하지만 많은 사람들이 재귀(Recursion)나, 특히 적절한 꼬리 호출(Tail Call)에 대해서는 이야기하지 않는 것 같다. 이는 스택이 넘치는 일 없이 깔끔하고 간결한 코드를 작성하기 위해 매우 중요한데도 말이다.

이 글에서는 재귀를 더 잘 시각화하고 생각할 수 있는 팁을 제공하고, 적절한 꼬리 호출, 꼬리 호출 최적화, 문법적 꼬리 호출이 무엇인지와 각각의 차이점, 작동 방식, 그리고 주요 자바스크립트 엔진에서 어떤 식으로 구현되어있는지에 대해 설명하도록 하겠다.

콜스택과 스택 트레이스에 대한 이야기도 많이 하겠지만, 너무 상세한 내용까지는 다루지 않을 예정이므로, 만약 이 주제에 대해 더 자세히 알고싶다면 내가 쓴 이 글(지금까지 이 사이트에서 가장 유명한 글이다)을 읽어보길 권한다.

재귀란 무엇일까

재귀는 특정 문제의 해결책이 해당 문제의 다른 예에 동일한 해결책을 적용하는 것에 의존하는 경우 발생한다.

예를 들어 4의 factorial 은 3의 factorial에 4를 곱하는 것으로 정의될 수 있으며, 이런 식으로 계속될 수 있다.

이것은 팩토리얼 연산이 자기 자신을 이용해서 정의될 수 있다는 것을 의미한다.

factorial(5) = factorial(4) * 5
factorial(5) = factorial(3) * 4 * 5
factorial(5) = factorial(2) * 3 * 4 * 5
factorial(5) = factorial(1) * 2 * 3 * 4 * 5
factorial(5) = factorial(0) * 1 * 2 * 3 * 4 * 5
factorial(5) = 1 * 1 * 2 * 3 * 4 * 5

간략하게 말해서, 함수가 자기 자신을 호출할 때 재귀를 갖는다고 할 수 있을 것이다.

재귀에 대해 효과적으로 생각하기

나는 재귀에 대해서 생각할 때 첫번째 실행에서 파생된 다수의 브랜치들이 실행된 다음, 초기 호출에게 결과값을 순차적으로 돌려주는 (bubbling up) 것을 상상해본다.

앞의 팩토리얼 예제에서 우리는 첫번째 호출에서 파생된 다수의 호출이, 이미 스스로 존재하는 정의(이 경우에는 0의 팩토리얼, 즉 1을 의미)에 다다를때까지 발생하는 것을 볼 수 있다. 그 후에는 이 정의의 결과값이 반환되면서 (bubbles up) 그 값으로 다른 작업을 할 수 있게 되고, 그 작업이 또다시 값을 반환하고, 이런 식의 과정이 “최초” 호출에 다다를때까지 반복된다.

만약 우리가 factorial 함수를 인수 5를 넘겨주면서 호출했을 때를 표현하려 한다면 다음과 같을 것이다.

factorial-calls

컴파일러 이론과 함께 생각해 보면, 이 과정은 문맥 자유 문법을 사용해서 최종 값에 다다를때까지 문장을 만들어내는 과정과 아주 유사하다.

처음이라 추상적으로 느껴질 수도 있겠지만, 이번에는 피보나치 수열에서 N번째 수를 계산해 내는 함수를 분석하면서 이러한 사고가 어떻게 다른 방식으로 적용되는지를 시각화해서 보여주도록 하겠다.

우리의 피보나치 함수 코드는 다음과 같다.

// N은 N번째 피보나치를 의미한다
function fibonacci(n) {
   if (n < 2) {
     return 1
   } else {
     return fibonacci(n - 2) + fibonacci(n - 1)
   }
}

기본적으로 피보나치 함수 호출 각각은 두개의 호출을 더 발생시키는데, 이들 또한 2보다 작은 수에 다다를 때까지 스스로를 호출할 수 있다. (피보나치 수열은 1, 1로 시작되며, 둘이 더해졌을 때 2가 되므로)

2보다 작은 수에 도달하면, 결과값을 반환해서 상위에 있는 호출이 사용할 수 있도록 하며, 거품이 올라가듯이 최초 호출까지 계속해서 반복된다.

아래의 이미지가 명확하게 보여주듯이, fibonacci(4) 를 호출하면 우리는 “스스로를 포함하는” 정의 (기본 케이스)에 다다를때까지 새로운 호출들을 파생시키는데, 이 경우 기본 케이스는 피보나치 수열의 처음 두 수 : 1 (0번째)과 1 (1번째)가 된다.

recursion-bubble-up

각각의 재귀 호출이 다른 두개의 재귀호출의 결과에 의존적이기 때문에 (넘겨진 값이 2보다 작은 “기본 케이스”가 아니라면), 우리는 말단 호출에서부터 값을 반환하기 시작하고, 상위 호출에서 사용할 수 있도록 이들을 더해서 넘겨준다.

위의 예제들에서 발견할 수 있듯이, 선형 재귀(팩토리얼 예제처럼, 재귀 호출이 단일 분기를 가질 때)와, 분기형 재귀(피보나치 예제처럼, 재귀 호출이 하나 이상의 분기를 가질 때) 를 만들 수 있다.

재귀를 만들 때, 생각해야 할 두 가지 사항이 있다.

  1. 탈출 조건, 즉 스스로 존재하는 원자적 정의를 만든다. (“기본 케이스” 라고 부른다)
  2. 알고리즘의 어떤 부분이 재귀적인지를 정의한다.

탈출 조건을 정의하고 나면, 함수가 언제 스스로를 다시 호출해야 하는지와 그 결과를 가지고 무엇을 해야 하는지를 결정하기가 쉽다.

만약 좀더 재귀에 대한 좀더 실용적이고 흥미로운 적용사례를 알고 싶다면, 트리나 그래프 관련 알고리즘을 들여다보길 권한다.

재귀와 콜스택

일반적으로 재귀를 사용할 때는 함수를 차례로 스택에 쌓아올리게 되는데, 이는 이들 함수가 이전 호출의 결과에 의존적이기 때문이다.

만약 콜스택이 어떻게 작동하는지 혹은 스택 트레이스를 어떻게 읽는지에 대해 제대로 이해하고 싶다면 이 글을 읽어보길 바란다.

재귀가 발생할 때의 콜스택이 어떤 상태인지를 나타내기 위해, 간단한 factorial 재귀 함수를 예제로 사용해보자.

코드는 다음과 같다.

function factorial(n) {
    if (n === 0) {
        return 1
    }
 
    return n * factorial(n - 1)
}

이제, 이 코드를 실행해서 3의 팩토리얼을 확인해보자.

앞의 예제를 기억하고 있을지도 모르겠지만, 팩토리얼 3은 factorial(2)factorial(1)factorial(0)을 가져오는 것과 이들을 곱하는 것으로 구성된다. 즉, 팩토리얼 3를 찾기위해 factorial 함수를 한번 호출하면 factorial 함수 호출은 3번 더 발생한다.

이들 호출 각각은 새로운 프레임을 콜스택에 추가하므로, 모두가 스택에 추가되고 나면 다음과 같을 것이다.

factorial(0) // 0의 팩토리얼은 1로 정의되어 있다 (기본 케이스)
factorial(1) // 이 호출은 factorial(0)에 의존적이다
factorial(2) // 이 호출은 factorial(1)에 의존적이다
factorial(3) // 이 첫번째 호출은 factorial(2)에 의존적이다

이제, 팩토리얼 함수가 호출될 때마다 스택에 있는 현재의 프레임들을 확인하기 위해 console.trace를 추가해 보자.

코드는 다음과 같을 것이다.

function factorial(n) {
    console.trace()
    if (n === 0) {
        return 1
    }
 
    return n * factorial(n - 1)
}
 
factorial(3) // 팩토리얼 함수를 실행해서 결과를 확인해보자

이제 이 코드를 실행하고, 출력된 콜스택을 분석해보자.

첫번째 출력이다.

Trace
    at factorial (repl:2:9)
    at repl:1:1 // 이 라인 아래는 세부 구현에 관한 것이므로 무시하면 된다
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:513:10)
    at emitOne (events.js:101:20)

여기서 볼 수 있듯이, 첫번째 콜스택은 factorial 함수의 첫번째 호출, 즉 factorial(3)만을 포함하고 있다. 하지만 상황은 점점 흥미로워진다.

Trace
    at factorial (repl:2:9)
    at factorial (repl:7:12)
    at repl:1:1 // 이 라인 아래는 세부 구현에 관한 것이므로 무시하면 된다
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:513:10)

이제 factorial 함수의 마지막 호출 바로 위에 또다른 호출이 보이는데, 이 호출이 factorial(2) 이다.

그리고 factorial(1) 을 호출하면 스택은 다음과 같다.

Trace
    at factorial (repl:2:9)
    at factorial (repl:7:12)
    at factorial (repl:7:12)
    at repl:1:1
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12

여기서 볼 수 있듯이, 이전 호출 위에 또다른 호출이 추가되었다.

그리고, 마지막으로 factorial(0) 에 도달하면 콜스택은 다음과 같다.

Trace
    at factorial (repl:2:9)
    at factorial (repl:7:12)
    at factorial (repl:7:12)
    at factorial (repl:7:12)
    at repl:1:1
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)

이 절의 도입부에서 언급했듯이, factorial(3) 의 첫번째 호출은 factorial(2)factorial(1) 그리고 factorial(0) 호출을 필요로 한다. 그것이 콜스택에 factorial 함수의 엔트리가 4개 존재하는 이유이다.

이제, 너무 많은 재귀가 발생했을 경우 발생할 문제가 무엇인지 알 수 있을 것이다. 콜스택이 너무 커지게 되면 결국 스택 버퍼 오버플로우를 맞이하게 되는데, 이는 우리가 콜스택이 한계치에 달했을 때 또다른 엔트리를 추가하려고 할 때 발생한다.

만약 어떤 환경에서 자바스크립트 코드를 실행하는지에 따라 얼마나 많은 프레임을 가질 수 있는지를 알고싶다면, Dr. Axel Rauschmayer의 끝내주는 글을 읽어보길 권한다. (나는 이친구의 왕팬이다).

적절한 꼬리 호출 (PTC: Proper Tail Calls)

적절한 꼬리 호출은 ES6가 나왔을 때 구현되었어야 하지만, 나중에 설명할 이유들로 인해 여전히 주요 브라우저들에서 사용이 불가능하다.

적절한 꼬리 호출은 재귀 호출을 할 때 스택이 넘치는 것을 피할 수 있게 해 준다. 하지만 적절한 꼬리 호출을 실행하기 위해서는, 먼저 꼬리 호출을 해야 한다.

근데, 꼬리 호출이 뭐지?

꼬리 호출은 스택을 증가시키지 않고 실행될 수 있는 함수이다. 이들은 항상 마지막에 실행되는데, return문 직전에 평가되고, 호출된 함수의 결과값이 호출하는 함수의 결과값으로 반환된다. 호출하는 함수는 또한 제너레이터 함수가 될 수 없다.

만약 컴파일러 이론이나 이런 종류의 하드코어한 내용을 좋아한다면, ECMA 스펙의 공식 정의를 읽어보면 된다.

적절한 꼬리 호출이 어떻게 작동하는지를 나타내려면, 기존의 factorial 함수를 수정해서 꼬리 재귀 형태로 만들어야 한다.

// total이 제공되지 않으면 기본값으로 1을 할당한다
function factorial(n, total = 1) {
    if (n === 0) {
        return total
    }
 
    return factorial(n - 1, n * total)
}

이제 이 함수가 마지막에 하는 일은 자기 자신을 호출해서 그 결과를 반환하는 것 외에는 없으므로, 꼬리 재귀가 되었다.

눈치챘을 수도 있겠지만, 우리는 이제 2개의 인자를 전달하고 있다. 다음 팩토리얼을 계산하기 위한 수인 (n - 1), 그리고 누적된 총합 n * total 이다.

이제, 우리는 더이상 파생된 함수들의 마지막까지 찾아갈 필요가 없는데 (이전 예제에서 우리가 했던것 처럼), 왜냐하면 이제 우리는 현재 상태를 실행하기 위한 모든 값 (누적된 값과 다음 팩토리얼 값)을 다 갖고 있기 때문이다.

이제, 이 함수가 어떻게 다수의 재귀 호출을 스택에 쌓지 않고 이 작업을 할 수 있는지 분석해 보자.

  1. factorial 호출이 스택의 최상단에 추가된다.
  2. 4가 0 (기본 케이스)가 아니기 때문에 우리는 다음으로 계산할 값 (3)과 현재까지 누적된 값 (4 * total (기본값 1))을 지정한다.
  3. 이제 factorial 함수가 다시 호출되면, 이 함수는 연산에 필요한 모든 데이터 : 다음에 계산할 팩토리얼과 누적된 총합 모두를 넘겨받게 된다. 이 덕분에 우리는 이전 스택 프레임이 더이상 필요없게 되며, 해당 프레임을 스택에서 제거한 후에 새로운 factorial(3, 4) 호출을 스택에 추가할 수 있게 된다.
  4. 이 호출도 여전히 0보다 크기 때문에, 다음 팩토리얼을 구하면서 기존의 누적된 총합(4)과 현재 값 (3)을 곱한다.
  5. 이전 호출이 (또다시) 더이상 필요없기 때문에, 기존 프레임을 스택에서 제거한 후에 또다시 factorial 함수를 호출하면서 2와 12를 넘겨준다. 한번더 총합을 갱신하여 24가 되고, 1의 팩토리얼을 구한다.
  6. 이전 프레임이 스택에서 제거되고 24(총합)과 1을 곱하면서 0의 팩토리얼을 구한다.
  7. 드디어 0의 팩토리얼은 누적된 총합인 24를 반환한다. (이 값이 4의 팩토리얼이다)

간략히 정리해보면 결국 다음과 같은 과정이 발생한다.

factorial(4, 1) // 1 은 아무값도 넘겨지지 않았을 때의 기본값이다.
factorial(3, 4) // 이 호출은 이전 호출이 필요없으며 모든 필요한 데이터를 갖고 있다.
factorial(2, 12) // 이 호출은 이전 호출이 필요없으며 모든 필요한 데이터를 갖고 있다.
factorial(1, 24) // 이 호출은 이전 호출이 필요없으며 모든 필요한 데이터를 갖고 있다.
factorial(0, 24) // -> 총합인 24를 반환하며, 이 또한 이전 호출은 필요없다.

이제, n 개의 프레임을 스택에 쌓아올리는 대신, 하나의 스택만 있으면 되는데, 이는 다음 호출이 더이상 이전 호출에 의존적이지 않기 때문이며, 이로 인해 새로운 factorial 함수는 O(N)대신 O(1)의 메모리 복잡도를 갖게 된다.

Node 에서 적절한 꼬리 호출 사용하기

만약 위의 함수에서 스택의 상태를 보기 위해 다음과 같이  console.trace 를 추가한 후에 factorial(3)를 호출한다면 :

function factorial(n, total = 1) {
    console.trace()
    if (n === 0) {
        return total
    }
 
    return factorial(n - 1, n * total)
}
 
factorial(3)

꼬리 재귀임에도 불구하고 여전히 factorial 함수가 스택에 쌓이는 모습을 볼 수 있을 것이다 :

// ...
// 다음은 마지막 2개의 factorial 함수 호출이다
Trace
    at factorial (repl:2:9) // 스택에 호출 3개가 쌓여있다
    at factorial (repl:7:8)
    at factorial (repl:7:8)
    at repl:1:1 // 아래부터는 세부 구현이다
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
Trace
    at factorial (repl:2:9) // 마지막 호출이 스택에 프레임 하나를 추가했다
    at factorial (repl:7:8)
    at factorial (repl:7:8)
    at factorial (repl:7:8)
    at repl:1:1 // 아래부터는 세부 구현이다
    at realRunInThisContextScript (vm.js:22:35)
    at sigintHandlersWrap (vm.js:98:12)
    at ContextifyScript.Script.runInThisContext (vm.js:24:12)
    at REPLServer.defaultEval (repl.js:313:29)
    at bound (domain.js:280:14)

Node 에서 적절한 꼬리 호출을 사용하기 위해서는 반드시 use strict를 .js 파일의 최상단에 추가해서 strict mode 를 활성화시키고, --harmony_tailcalls 플래그와 함께 실행시켜야 한다.

이 플래그가 우리의 factorial 함수를 개선시키도록 하기 위해서는, 스크립트 파일이 다음과 같아야 한다.

'use strict'
 
function factorial(n, total = 1) {
    console.trace()
    if (n === 0) {
        return total
    }
 
    return factorial(n - 1, n * total)
}
 
factorial(3)

이제 플래그와 함께 실행해보자.

$ node --harmony_tailcalls factorial.js

실행 결과, 스택 트레이스는 다음과 같다.

Trace
    at factorial (/Users/lucasfcosta/factorial.js:4:13)
    at Object. (/Users/lucasfcosta/factorial.js:12:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9)
Trace
    at factorial (/Users/lucasfcosta/factorial.js:4:13)
    at Object. (/Users/lucasfcosta/factorial.js:12:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9)
Trace
    at factorial (/Users/lucasfcosta/factorial.js:4:13)
    at Object. (/Users/lucasfcosta/factorial.js:12:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9)
Trace
    at factorial (/Users/lucasfcosta/factorial.js:4:13)
    at Object. (/Users/lucasfcosta/factorial.js:12:1)
    at Module._compile (module.js:571:32)
    at Object.Module._extensions..js (module.js:580:10)
    at Module.load (module.js:488:32)
    at tryModuleLoad (module.js:447:12)
    at Function.Module._load (module.js:439:3)
    at Module.runMain (module.js:605:10)
    at run (bootstrap_node.js:420:7)
    at startup (bootstrap_node.js:139:9)

여기서 볼 수 있듯이, 더이상 factorial 호출이 동시에 하나 이상 스택에 쌓이지 않는데, 왜냐하면 매번 호출할 때마다 이전 프레임이 더이상 필요없기 때문이다.

꼬리 재귀 함수를 만들기 위한 팁은 이전 프레임을 제거할 수 있도록 다음 호출을 할 때 필요한 모든 “상태”를 넘겨주는 것이다. 단일 함수만을 가지고 항상 가능하지는 않으므로, 꼬리 재귀가 가능한 중첩 함수를 만들 수 있는지도 고려해볼 수 있을 것이다.

또하나 명심해야 할 것은, 적절한 꼬리 호출이 코드 실행을 반드시 빠르게 해 주는 것은 아니라는 것이다. 사실은, 오히려 느리게 만드는 경우가 대부분이다.

하지만, 꼬리 함수를 사용하게 되면 스택을 위한 메모리를 더 적게 사용할 수 있을 뿐만 아니라 국지적으로(locally) 할당된 객체들을 갖게됨으로써, 적은 메모리만 갖고도 재귀 함수를 실행할 수 있게 된다. 왜냐하면 현재 프레임 내부에 다음 재귀 호출을 위한 변수들이 필요없기 때문에, 가비지 컬렉터로 하여금 현재 프레임에 할당된 모든 객체를 수집해 가도록 할 수 있기 때문이다. 반면에, 꼬리 재귀가 아닌 함수들은 마지막 재귀함수 (기본 케이스)가 반환할 때까지 모든 호출들이 스택에 유지되어야 하기 때문에 매번 메모리 할당이 일어날 수 밖에 없다.

꼬리 호출 최적화 (TCO : Tail Call Optimization)

적절한 꼬리 호출때 발생하는 일들과 다르게, 꼬리 호출 최적화는 꼬리 재귀 함수들의 성능을 향상시켜서 빠르게 실행될 수 있도록 한다.

꼬리 호출 최적화는 컴파일러가 재귀 호출을  jumps 를 이용한 루프 형태로 변경시키는 기법이다.

이미 우리는 꼬리 재귀 함수가 어떻게 작동하는지 알고 있기 때문에, 꼬리 최적화에 대해 설명하기가 아주 쉬워졌다.

앞서 사용했던 factorial 함수를 이용해서, 꼬리 호출 최적화가 활성화된 자바스크립트 엔진에서 어떤 일이 발생하는지를 살펴보자.

다음 코드로 시작하자.

function factorial(n, total = 1) {
    if (n === 0) {
        return total
    }

    return factorial(n - 1, n * total)
}

탈출 조건(“기본 케이스”)이 만족될때까지 반복되는 코드라는 것을 생각한다면, 함수를 다시 호출하는 대신에 코드 안에 레이블을 넣어서 해당 위치로 바로 점프할 수 있을 것이다. 그러면 다음과 같은 코드가 될 것이다.

function factorial(n, total = 1) {
    LABEL:
        if (n === 0) {
            return total
        }
        total = n * total
        n = n - 1
        goto LABEL
}

즉, 꼬리 호출 최적화는 적절한 꼬리 호출과는 별개의 개념이다!

적절한 꼬리 호출과 꼬리 호출 최적화의 단점

이전 예제에서 보았듯이, 적절한 꼬리 호출은 모든 함수 호출의 이력을 “저장”하지 않는다는 의미가 될 수 있다. 즉, 현재 상황을 만들어낸 모든 호출들의 정보를 갖고있지 않게 되는데, 이로 인해 스택 트레이스를 읽어서 버그를 발견해내기가 어려워질 수도 있다.

이는 console.trace 구문뿐 아니라  Error.stack 프라퍼티에도 영향을 미치는데, 여기에 관해서는 아까 언급했던 글에서 다루고 있다.

한가지 가능한 해결책은 개발 환경에서 “가상 스택(Shadow Stack)”을 만드는 것이다.

가상 스택은 “제2의 스택” 처럼 작동한다. 일반 스택이 적절한 꼬리 재귀 호출이 만들어졌을 때 프레임들을 보존하지 않기 때문에, 이 호출들을 “가상 스택”에 쌓으면 디버깅 목적으로 활용할 수 있으면서도 실행 스택에는 추가되지 않도록 할 수 있다.

하지만 이를 쉽게 사용할 수 있도록 잘 만들어진 도구가 부족하며, 이 방식 또한 모든 프레임들을 다른 장소에 저장하기 위해서 추가적인 메모리가 필요하게 된다. (이는 개발 환경에서는 문제가 아닐 수도 있다)

마지막으로, 가상 스택을 사용한다고 해도 꼬리 호출 최적화를 사용한다면 Error.stack 프라퍼티 관련 문제를 해결해주지는 못하는데, 이 경우 goto 구문을 이용해서 더이상 스택 트레이스에 프레임을 추가하지 않게 되기 때문이다. 즉, 에러가 발생했을 때 스택안에 해당 함수가 없을 수도 있는데, 왜냐하면 그 구문에 도달하기 위해 함수 호출을 한 것이 아니라 레이블 위치로 점프했기 때문이다.

만약 더 관심이 있다면, Webkit이 어떻게 꼬리 호출을 다루는지에 대해 Michael Saboff가 쓴 훌륭한 글을 읽어보길 권한다.

문법적 꼬리 호출 (STC: Syntactic Tail Calls)

문법적 꼬리 호출은 컴파일러에게 언제 적절한 꼬리 호출이나 꼬리 호출 최적화를 원하는지 알려주는 방법이다.

이 방식으로 개발자들은 해당 기능을 사용할지 말지를 선택할 수 있다. 이는 기본적으로 정말 단순히 명시적인 방식이다.

이는 생략된 스택 프레임들의 복잡도를 관리할 수 있도록 해 주며, 또한 “덜 간섭적인 해결책 (혹은 전혀 해결책이 아닌)” (제안서에 따르면) 에 대한 새로운 가능성을 열어준다.

문법에 대해서는 몇가지 대안들이 논의되고 있는데, 여기에서 바로 확인할 수 있다.

지금 현재 이 제안은 스테이지 0 상태이다.

관련 문서

자바스크립트

[번역] CSS의 진화 : CSS 부터 SASS, BEM, CSS 모듈, 스타일드 컴포넌트 까지

원문 : https://m.alphasights.com/css-evolution-from-css-sass-bem-css-modules-to-styled-components-d4c1da3a659b

인터넷이 시작된 이후로 우리는 항상 웹사이트를 꾸며야 했고, CSS는 언제나 우리 주변에 있으면서 몇년동안 자신만의 페이스로 진화해 왔다. 이 글은 여기에 대한 자세한 내용을 여러분에게 알려주려고 한다.

먼저 우리는 CSS가 무엇인지에 대해서 공감대를 형성해야 하는데, CSS가 마크업 언어로 쓰여진 문서의 표현을 기술하기 위해 사용된다는 점에 대해서는 우리 모두 동의하고 있을거라 생각한다.

CSS가 꾸준히 진화해 왔으며 최근에 더욱 강력해졌다는 것은 새로울 것이 없지만, CSS를 팀단위의 작업에서 사용하기 위해서는 부가적인 도구가 필요하다는 것도 다들 알고있을 것이다.

CSS 서부 개척 시대 (wild-west)

90년대에는 “장식적인” 인터페이스를 만들어내는 것에 집중했고, 이목을 끄는 것이 가장 중요한 일이었는데, 이를 위해 인라인 스타일이 사용되었다. 우리는 페이지가 다르게 보여지는 것에 대해서는 신경쓰지 않았으며, 결국 웹페이지들은 몇가지 GIF 이미지나 마퀴(marquee), 혹은 다른 끔직한 (당시로선 인상적이었던) 요소들을 던져넣어서 방문자들의 이목을 끌려고 했던 귀여운 장난감 같았다.

Microsoft's first website

그 이후에 우리는 동적인 사이트들을 만들기 시작했지만, CSS는 여전히 복잡한 채로 남아있었고, 모든 개발자들이 저마다의 방식으로 CSS를 작성했다. 우리들 중 몇몇은 새로운 코드를 작성했을 때 기존 스타일이 영향을 받는 문제 때문에 고생했고, !important를 사용해서 UI요소가 특정한 방식으로 보여지는 것을 강제할 수 있었다. 하지만 우리는 곧 깨달았다 :

!important Does not fix your bad css

이러한 모든 시도들은 프로젝트의 규모나 복잡도가 커져갈수록 혹은 팀원들의 수가 많아질수록 더 명백하고 큰 문제들이 되었다. 스타일링을 위한 일관된 패턴이 없다는 것은 CSS를 작성하는 올바른 방법을 찾으려고 노력하던 개발자들에게 가장 큰 걸림돌이었다. 결국은 올바르거나 나쁜 방식이란 없었고, 우리는 그저 페이지가 제대로 표시되는 것에만 신경쓰게 되었다.

CSS

SASS 의 구원

SASS는 CSS를 네스팅(nesting), 변수, 믹스인, 확장, 로직 등을 장착한 전처리 엔진 형태의 프로그래밍 언어로 변형시켰다. 이를 통해 CSS 파일들을 잘 구조화할 수 있게 되었고, 최소한 CSS 파일을 몇개의 작은 파일로 분리할 수 있는 방법이 생겼다. 당시로선 엄청난 일이었다.

SASS는 본질적으로 SCSS 코드를 읽어서 전처리한 다음 컴파일 해서 전역 CSS 번들 파일을 만들어준다. 멋지지 않은가? 하지만 꼭 그렇지만은 않았다. 얼마 지나지 않아, 전략이나 모범 사례 없이는 SASS가 문제를 해결해준다기 보다는 더 많은 문제를 만들어낸다는 것이 명백해졌다.

결국 우리는 전처리기가 내부에서 어떤 작업을 하는지는 알지 못한 채 스타일이 겹치는 문제를 해결하기 위해 단순히 계층 구조를 만들어 내는 것에 의지하게 되었고, 그 결과 컴파일된 CSS는 어마어마하게 커지게 되었다.

BEM이 등장하기 전까지 말이다…

BEM과 컴포넌트 기반 사고

BEM의 등장은 우리가 재사용성과 컴포넌트에 대해서 생각할 수 있게 해 주는 신선한 공기였다. BEM은 새로운 수준의 의미를 부여할 수 있게 해 주었고,
단순한 블록(Block), 요소(Element), 변경자(Modifier) 컨벤션을 사용함으로써 클래스명이 유일함(unique)을 보장하여 스타일이 겹치는 위험성을 줄일 수 있게 해 주었다.

다음의 예제를 보자.

<body class="scenery">
  <section class="scenery__sky">
    <div class="sky [sky--dusk / sky--daytime] [sky--foggy]">
      <div class="sky__clouds">div>
      <div class="sky__sun">div>
    div>
  section>
  <section class="scenery__ground">section>
  <section class="scenery__people">section>
body>

마크업을 조금 분석해보면 블록, 요소, 변경자 컨벤션이 적용되고 있는 것을 볼 수 있을 것이다.

위의 코드에는 매우 명시적인 두 개의 블록: .scenery와 .sky이 있으며 각각은 자신만의 블록을 가지고 있다. Sky는 유일하게 수정자를 갖고 있는데, foggydaytime 혹은 dust가 될 수 있으며 이들은 각기 다른 특성이 같은 요소에게 적용될 수 있도록 한다.

이 마크업에 적용되는 CSS를 살펴보면 좀더 잘 이해할 수 있을 것이다.

// Block
.scenery {
   //Elements
  &__sky {
    fill: screen;
  }
  &__ground {
    float: bottom; 
  }
  &__people {
    float: center;
  }
}

//Block
.sky {
  background: dusk;
  
  // Elements
  
  &__clouds {
    type: distant;
  }
  
  &__sun {
    strength: .025;
  }
  
  // Modifiers
  &--dusk {
    background: dusk;
    .sky__clouds {
      type: distant;
    }
    .sky__sun {
      strength: .025;
    }
  }
  
  &--daytime {
    background: daylight;
    .sky__clouds {
      type: fluffy;
      float: center;
    }
    .sky__sun {
      strength: .7;
      align: center;
      float: top;
    }
  }
}

BEM이 어떻게 동작하는지 좀더 깊은 이해를 하고 싶다면, 내 동료이자 친구인 Andrei Popa가 작성한 이 아티클을 살펴보기를 추천한다.

BEM은 컴포넌트를 유일하고 재사용 가능하게 만들어 준다는 점에서 유용하다. 이런 방식의 사고를 통해 기존의 오래된 스타일시트들을 이 새로운 컨벤션으로 변경하면서, 몇몇 막연하던 패턴들이 좀더 명확해졌다.

하지만, 다른 종류의 문제들이 발생하기 시작했다:

  • 클래스명 선택자가 장황해졌다
  • 이런 긴 클래스명 때문에 마크업이 불필요하게 커졌다
  • 재사용하려고 할 때마다 모든 UI 컴포넌트들 명시적으로 확장해야만 했다
  • 마크업이 불필요한 의미를 갖게 되었다

CSS 모듈과 로컬 스코프

SASS와 BEM도 고치지 못했던 몇가지 문제들은 언어 로직상에 진정한 캡슐화(encapsulation)의 개념이 없다는 것이었고, 이로 인해 개발자들이 유일한 클래스명을 선택하는 것에 의존할 수 밖에 없었다. 이런 과정은 컨벤션 보다는 도구에 의해 해결될 수 있었다.

이것이 바로 CSS 모듈이 했던 일인데, 로컬단위로 정의된 모든 스타일에 동적으로 클래스명을 만들어내어 추가된 CSS 속성들에 의해 스타일이 겹치지 않도록 해 주었으며, 모든 스타일들이 적절하게 캡슐화되도록 해 주었다.

CSS 모듈은 React 생태계에서 빠르게 인기를 얻었으며, 이제 많은 React 프로젝트에서 이를 사용하고 있는 것을 흔히 볼 수 있다. CSS 모듈 또한 나름의 장점과 단점이 있지만 전반적으로는 사용하기에 좋은 패러다임이라는 것이 증명되었다.

하지만 CSS 모듈 자체로는 CSS의 핵심 문제들을 해결하지 못했는데, 이는 단지 스타일 정의를 로컬화 할 수 있는 방법을 보여주었을 뿐이었다. 그 방법이란 바로 BEM을 자동화함으로써 더이상 클래스명을 결정하기 위해 고민할 필요가 없게 하는 (혹은 최소한 덜 고민하게 만드는) 것이었다.

CSS 모듈 역시 쉽게 확장이나 재사용이 가능하고 최소한의 노력으로 제어가 가능한 더 예측 가능한 스타일 아키텍쳐의 필요성을 완화시켜주지는 못했다.

로컬 CSS는 이렇게 생겼다.

@import '~tools/theme';

:local(.root) {
  border: 1px solid;
  font-family: inherit;
  font-size: 12px;
  color: inherit;
  background: none;
  cursor: pointer;
  display: inline-block;
  text-transform: uppercase;
  letter-spacing: 0;
  font-weight: 700;
  outline: none;
  position: relative;
  transition: all 0.3s;
  text-transform: uppercase;
  padding: 10px 20px;
  margin: 0;
  border-radius: 3px;
  text-align: center;
}


@mixin button($bg-color, $font-color) {
  background: $bg-color;
  color: $font-color;
  border-color: $font-color;

  &:focus {
    border-color: $font-color;
    background: $bg-color;
    color: $font-color;
  }

  &:hover {
    color: $font-color;
    background: lighten($bg-color, 20%);
  }

  &:active {
    background: lighten($bg-color, 30%);
    top: 2px;
  }
}

:local(.primary) {
  @include button($color-primary, $color-white)
}

:local(.secondary) {
  @include button($color-white, $color-primary)
}

일반적인 CSS와 별반 다르지 않아 보이는데, 중요한 차이점은 :local 로 시작하는 모든 클래스명들이 다음과 같은 유일한 클래스명으로 생성된다는 것이다:

.app-components-button-\_root–3vvFf {}

localIdentName 질의 파라미터를 사용하면, 생성되는 고유한 이름을 설정할 수도 있다. 예를 들어 css-loader?localIdentName=[path][name]---[local]---[hash:base64:5] 와 같이 설정하면 좀더 쉽게 디버깅할 수 있다.

이것이 바로 로컬 CSS 모듈이 동작하는 간단한 원리이다. 여기서 볼 수 있듯이 로컬 모듈들은 BEM 표기법을 자동화해주는 방법이 되었고, 유일한 클래스명을 생성해내어 심지어 같은 이름을 사용했더라도 서로가 겹치지 않게 보장해주었다. 아주 편리하다.

스타일드(Styled) 컴포넌트로 CSS를 JS와 (완전히) 섞기

스타일드 컴포넌트는 순수한 시각적 기본요소로서 실제의 HTML 태그와 맵핑될 수 있으며, 자식 컴포넌트들을 스타일드 컴포넌트로 감싸주는 역할을 한다.

다음 코드가 더 잘 설명해 줄 것이다 :

import React from "react"
import styled from "styled-components"
// Simple form component

const Input = styled.input`
  background: green
`

const FormWrapper = () => <Input placeholder="hola" />

// What this compiles to:
<input placeholder="hola" class="dxLjPX">Send</input>

스타일드 컴포넌트는 CSS 속성을 정의하기 위해 템플릿 문자열 구문을 사용하는데, 이것이 아주 간단하게 이해된다면 스타일드 컴포넌트의 핵심 개발팀이 ES6와 CSS의 능력을 잘 섞는 데에 성공했다고 생각한다.

스타일드 컴포넌트는 기능적(Functional) 혹은 상태를 가진(StateFul) 컴포넌트들로부터 UI를 완전히 분리해 내어 재사용할 수 있는 아주 단순한 패턴을 제공한다. 브라우저의 HTML이나 React Native 에서 사용될 수 있는 네이티브 태그들에 접근할 수 있는 API를 만들어내는 것이다.

커스텀 props(혹은 변경자)를 스타일드 컴포넌트에게 넘겨주는 방법은 다음과 같다.

import styled from "styled-components"

const Sky = styled.section`
  ${props => props.dusk && 'background-color: dusk' }
  ${props => props.day && 'background-color: white' }
  ${props => props.night && 'background-color: black' }
`;

// You can use it like so:
<Sky dusk />
<Sky day />
<Sky night />

props가 각각의 컴포넌트가 받을 수 있는 변경자가 되어 각기 다른 CSS를 처리하게 된 것을 볼 수 있을 것이다. 멋지지 않은가?

이렇게 함으로써 영속성과 재사용성을 보장하면서도 좀더 빠르게 JS의 모든 능력을 이용해서 스타일을 생성해 낼 수 있게 되었다.

누구나 재사용할 수 있는 핵심 UI

CSS 모듈과 스타일드 컴포넌트 모두 혼자만으로는 완벽한 해결책이 될 수 없다는 것은 금새 분명해졌다. 제대로 작동하고 확장 가능하게 만들기 위해서는 어떠한 패턴 같은 것이 필요했다. 그러한 패턴은, 컴포넌트를 로직으로부터 완전히 분리하여 스타일링 외에는 아무런 목적도 갖지 않는 핵심 컴포넌트들을 정의하는 방식으로 만들어졌다.

CSS 모듈을 사용하는 이러한 컴포넌트의 예제는 다음과 같다.

import React from "react";

import classNames from "classnames";
import styles from "./styles";

const Button = (props) => {
  const { className, children, theme, tag, ...rest } = props;
  const CustomTag = `${tag}`;
  return (
    <CustomTag { ...rest } className={ classNames(styles.root, theme, className) }>
      { children }
    </CustomTag>
  );
};

Button.theme = {
  secondary: styles.secondary,
  primary: styles.primary
};

Button.defaultProps = {
  theme: Button.theme.primary,
  tag: "button"
};

Button.displayName = Button.name;

Button.propTypes = {
  theme: React.PropTypes.string,
  tag: React.PropTypes.string,
  className: React.PropTypes.string,
  children: React.PropTypes.oneOfType([
    React.PropTypes.string,
    React.PropTypes.element,
    React.PropTypes.arrayOf(React.PropTypes.element)
  ])
};

export default Button;

예제를 보면 특별한 것은 없어 보인다. 그저 props를 받아서 자식 컴포넌트들에게 맵핑 시켜주는 컴포넌트일 뿐이다. 다른 말로 하면 : 감싸는 컴포넌트는 모든 props를 자식 컴포넌트에게 전송한다.

이제 이 컴포넌트는 다음과 같이 사용될 수 있다.

import React from "react"
import Button from "components/core/button"

const = Component = () => <Button theme={ Button.theme.secondary }>Some Button</Button>

export default Component

스타일드 컴포넌트를 사용하는 버튼을 완전히 구현한 유사한 예제를 보여주겠다.

import styled from "styled-components";

import {
  theme
} from "ui";

const { color, font, radius, transition } = theme;

export const Button = styled.button`
  background-color: ${color.ghost};
  border: none;
  appearance: none;
  user-select: none;
  border-radius: ${radius};
  color: ${color.base}
  cursor: pointer;
  display: inline-block;
  font-family: inherit;
  font-size: ${font.base};
  font-weight: bold;
  outline: none;
  position: relative;
  text-align: center;
  text-transform: uppercase;
  transition:
    transorm ${transition},
    opacity ${transition};
  white-space: nowrap;
  width: ${props => props.width ? props.width : "auto"};
  &:hover,
  &:focus {
    outline: none;
  }
  &:hover {
    color: ${color.silver};
    opacity: 0.8;
    border-bottom: 3px solid rgba(0,0,0,0.2);
  }
  &:active {
    border-bottom: 1px solid rgba(0,0,0,0.2);
    transform: translateY(2px);
    opacity: 0.95;
  }
  ${props => props.disabled && `
    background-color: ${color.ghost};
    opacity: ${0.4};
    pointer-events: none;
    cursor: not-allowed;
  `}
  ${props => props.primary && `
    background-color: ${color.primary};
    color: ${color.white};
    border-color: ${color.primary};
    &:hover,
    &:active {
      background-color: ${color.primary}; 
      color: ${color.white};
    }
  `}
  ${props => props.secondary && `
    background-color: ${color.secondary};
    color: ${color.white};
    border-color: ${color.secondary};
    &:hover,
    &:active {
      background-color: ${color.secondary}; 
      color: ${color.white};
    }
  `}
`;

이 패턴의 흥미로운 점은 컴포넌트가 dumb 이며 단지 부모 컴포넌트로 맵핑되는 CSS 정의를 감싸는 역할을 할 뿐이라는 것이다. 이 방식은 한가지 장점이 있다.

마음대로 끼워넣을 수 있는 기본 UI API를 정의하고, 모든 UI가 전체 어플리케이션 내에서 영속적임을 보장할 수 있게 해 준다.

이 방식을 통해 구현 절차로부터 디자인 절차를 완벽하게 분리해 낼 수 있으며, 원한다면 두 가지 절차를 동시에 진행할 수 있게 해 준다. 한 명의 개발자는 기능 구현에 집중하고, 다른 한 명은 UI를 다듬는 데에 집중함으로써 완전한 관심사의 분리(Separation of Concern)를 달성할 수 있다.

지금까지는 훌륭한 해결책으로 보여서, 우리는 내부적으로 논의를 거친 후에 이 패턴을 따르는 것이 좋겠다고 생각했다. 이 패턴과 함께 우리는 다른 유용한 패턴들도 만들어내기 시작했다.

Prop 수신자 (receivers)

props를 다른 컴포넌트들로 전달하는 함수를 정의함으로써, 다른 컴포넌트들이 이들 함수를 쉽게 사용할 수 있게 한다. 이를 통해 재사용성을 높이고 주어진 컴포넌트의 능력을 확장할 수 있게 된다. 변경자를 상속받는 방법이라고 생각하면 되는데, 다음 예제를 보면 의미를 알 수 있을 것이다.

// Prop passing Shorthands for Styled-components
export const borderProps = props => css`
  ${props.borderBottom && `border-bottom: ${props.borderWidth || "1px"} solid ${color.border}`};
  ${props.borderTop && `border-top: ${props.borderWidth || "1px"} solid ${color.border}`};
  ${props.borderLeft && `border-left: ${props.borderWidth || "1px"} solid ${color.border}`};
  ${props.borderRight && `border-right: ${props.borderWidth || "1px"} solid ${color.border}`};
`;

export const marginProps = props => css`
  ${props.marginBottom && `margin-bottom: ${typeof (props.marginBottom) === "string" ? props.marginBottom : "1em"}`};
  ${props.marginTop && `margin-top: ${typeof (props.marginTop) === "string" ? props.marginTop : "1em"}`};
  ${props.marginLeft && `margin-left: ${typeof (props.marginLeft) === "string" ? props.marginLeft : "1em"}`};
  ${props.marginRight && `margin-right: ${typeof (props.marginRight) === "string" ? props.marginRight : "1em"}`};
  ${props.margin && `margin: ${typeof (props.margin) === "string" ? props.margin : "1em"}`};
  ${props.marginVertical && `
    margin-top: ${typeof (props.marginVertical) === "string" ? props.marginVertical : "1em"}
    margin-bottom: ${typeof (props.marginVertical) === "string" ? props.marginVertical : "1em"}
  `};
  ${props.marginHorizontal && `
    margin-left: ${typeof (props.marginHorizontal) === "string" ? props.marginHorizontal : "1em"}
    margin-right: ${typeof (props.marginHorizontal) === "string" ? props.marginHorizontal : "1em"}
  `};
`;
// An example of how you can use it with your components

const SomeDiv = styled.div`
  ${borderProps}
  ${marginProps}
`

// This lets you pass all borderProps to the component like so:

<SomeDiv borderTop borderBottom borderLeft borderRight marginVertical>

이 방식을 사용하면 더이상 각각의 컴포넌트를 위해 모든 테두리 속성을 또다시 하드코딩 하지 않아도 되어, 많은 시간을 절약할 수 있을 것이다.

플레이스 홀더 / 유사 믹스인 기능

스타일드 컴포넌트에서는 JS를 그대로 이용해서 함수를 생성할 수 있는데, 이를 단지 props 수신자의 역할 뿐만 아니라 다른 컴포넌트들 간의 코드를 공유하기 위한 방법으로도 사용될 수 있다. 다음의 예제를 보자.

// Mixin like functionality

const textInput = props => `
  color: ${props.error ? color.white : color.base};
  background-color: ${props.error ? color.alert : color.white};
`;

export const Input = styled.input`
  ${textInput}
`;

export const Textarea = styled.textarea`
  ${textInput};
  height: ${props => props.height ? props.height : '130px'}
  resize: none;
  overflow: auto;
`;

레이아웃 컴포넌트

우리는 어플리케이션을 만들 때 가장 먼저 필요한 것이 UI 요소들의 레이아웃을 지정하는 일이라는 것을 발견했다. 이를 위해서, 우리는 레이아웃 목적의 몇가지 컴포넌트들을 만들었다.

이 컴포넌트들은 CSS로 위치를 잡는 기법에 대해 익숙하지 않아서 구조를 잡는데 어려움을 겪는 개발자들에게 매우 유용하다는 것이 증명되었다. 예제는 다음과 같다.

import styled from "styled-components";
import {
  theme,
  borderProps,
  sizeProps,
  backgroundColorProps,
  marginProps
} from "ui";

const { color, font, topbar, gutter } = theme;

export const Panel = styled.article`
  ${marginProps}
  padding: 1em;
  background: white;
  color: ${color.black};
  font-size: ${font.base};
  font-weight: 300;
  ${props => !props.noborder && `border: 1px solid ${color.border}`};
  width: ${props => props.width ? props.width : "100%"};
  ${props => borderProps(props)}
  transition: 
    transform 300ms ease-in-out,
    box-shadow 300ms ease-in-out,
    margin 300ms ease-in-out;
  box-shadow: 0 3px 3px rgba(0,0,0,0.1);
  ${props => props.dark && `
    color: ${color.white};
    background-color: ${color.black};
  `}
  &:hover {
    transform: translateY(-5px);
    box-shadow: 0 6px 3px rgba(0,0,0,0.1);
  }
`;

export const ScrollView = styled.section`
  overflow: hidden;
  font-family: ${font.family};
  -webkit-overflow-scrolling: touch;
  overflow-y: auto;
  ${props => props.horizontal && `
    white-space: nowrap;
    overflow-x: auto;
    overflow-y: hidden;
    `
  }
  ${props => sizeProps(props)}
`;

export const MainContent = styled(ScrollView)`
  position: absolute;
  top: ${props => props.topbar ? topbar.height : 0};
  right: 0;
  left: 0;
  bottom: 0;
  font-size: ${font.base};
  padding: ${gutter} 3em;
  ${props => props.bg && `
    background-color: ${props.bg};
  `}
`;

export const Slide = styled.section`
  ${backgroundColorProps}
  font-weight: 400;
  flex: 1;
  height: ${props => props.height ? props.height : "100%"};
  width: ${props => props.width ? props.width : "100%"};
  justify-content: center;
  flex-direction: column;
  align-items: center;
  text-align: center;
  display: flex;
  font-size: 3em;
  color: ${color.white};
`;

export const App = styled.div`
  *, & {
    box-sizing: border-box;
  }
`;

예제의  컴포넌트는 props로 width와 height를 받을 뿐 아니라 horizontal 을 받아서 스크롤바가 아래에 위치할 수 있게 한다.

헬퍼 컴포넌트

헬퍼 컴포넌트는 재사용성을 강화시켜 우리의 삶을 편하게 만들어준다. 모든 공통적인 패턴을 이곳에 저장하면 된다.

다음은 지금까지 내가 찾아낸 꽤 유용한 헬퍼들이다.

import styled, { css } from "styled-components";

import {
  borderProps,
  marginProps,
  backgroundColorProps,
  paddingProps,
  alignmentProps,
  positioningProps,
  sizeProps,
  spacingProps,
  theme
} from "ui";

const { screenSizes } = theme;

export const overlay = `
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0,0,0,0.5);  
`;

// You can use this like ${media.phone`width: 100%`}

export const media = Object.keys(screenSizes).reduce((accumulator, label) => {
  const acc = accumulator;
  acc[label] = (...args) => css`
    @media (max-width: ${screenSizes[label]}em) {
      ${css(...args)}
    }
  `;
  return acc;
}, {});

// Spacing

export const Padder = styled.section`
  padding: ${props => props.amount ? props.amount : "2em"};
`;

export const Spacer = styled.div`
  ${spacingProps}
`;

// Alignment

export const Center = styled.div`
  ${borderProps}
  ${marginProps}
  ${backgroundColorProps}
  ${paddingProps}
  ${alignmentProps}
  ${positioningProps}
  ${sizeProps}
  text-align: center;
  margin: 0 auto;
`;

// Positioning

export const Relative = styled.div`
  ${props => borderProps(props)};
  position: relative;
`;

export const Absolute = styled.div`
  ${props => marginProps(props)};
  ${props => alignmentProps(props)};
  ${props => borderProps(props)};
  position: absolute;
  ${props => props.right && `right: ${props.padded ? "1em" : "0"}; `}
  ${props => props.left && `left: ${props.padded ? "1em" : "0"}`};
  ${props => props.top && `top: ${props.padded ? "1em" : "0"}`};
  ${props => props.bottom && `bottom: ${props.padded ? "1em" : "0"}`};
`;

// Patterns
export const Collapsable = styled.section`
  opacity: 1;
  display: flex;
  flex-direction: column;
  ${props => props.animate && `
    transition: 
      transform 300ms linear,
      opacity 300ms ease-in,
      width 200ms ease-in,
      max-height 200ms ease-in 200ms;
    max-height: 9999px;
    transform: scale(1);
    transform-origin: 100% 100%;
    ${props.collapsed && `
      transform: scale(0);
      transition: 
        transform 300ms ease-out,
        opacity 300ms ease-out,
        width 300ms ease-out 600ms;
    `}
  `}
  ${props => props.collapsed && `
    opacity: 0;
    max-height: 0;
  `}
`;

export const Ellipsis = styled.div`
  max-width: ${props => props.maxWidth ? props.maxWidth : "100%"};
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
`;

export const Circle = styled.span`
  ${backgroundColorProps}
  display: inline-block;
  border-radius: 50%;
  padding: ${props => props.padding || '10px'};
`;

export const Hidden = styled.div`
  display: none;
`;

테마

테마는 전체 어플리케이션에서 재사용될 수 있는 값들을 한곳에서 관리할 수 있게 해 준다. 어플리케이션에서 자주 재사용되는 색상 팔레트나 일반적인 룩앤필(Look and Feel)과 같은 값들을 저장해 두는 것은 매우 유용하다.

export const theme = {
  color: {
    primary: "#47C51D",
    secondary: '#53C1DE',
    white: "#FFF",
    black: "#222",
    border: "rgba(0,0,0,0.1)",
    base: "rgba(0,0,0,0.4)",
    alert: '#FF4258',
    success: 'mediumseagreen',
    info: '#4C98E6',
    link: '#41bbe1'
  },
  icon: {
    color: "gray",
    size: "15px"
  },
  font: {
    family: `
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    Roboto,
    Helvetica,
    Arial,
    sans-serif,
    'Apple Color Emoji',
    'Segoe UI Emoji',
    'Segoe UI Symbol'`,
    base: '13px',
    small: '11px',
    xsmall: '9px',
    large: '20px',
    xlarge: '30px',
    xxlarge: '50px',
  },
  headings: {
    family: 'Helvetica Neue',
  },
  gutter: '2em',
  transition: '300ms ease-in-out'
};

export default theme;

장점

  • JS의 모든 능력을 사용할 수 있다. 즉, 컴포넌트의 UI와 완전히 커뮤니케이션 할 수 있다.
  • 클래스명을 사용해 컴포넌트와 스타일을 맵핑시킬 필요가 없다 (내부에서 자동 처리된다)
  • 클래스명을 정하기 위해 고민하거나 클래스명을 컴포넌트와 맵핑시키는 시간을 절약해준다.

단점

  • 실전에서 좀더 테스트되어야 한다.
  • React 에서만 사용 가능하다
  • 이제 막 사용되기 시작했다
  • aria-label 이나 클래스명 등을 통해 테스트되어야 한다

결론

SASS, BEM, CSS 모듈, 스타일드 컴포넌트 등 어떤 기술을 사용하더라도, 다른 개발자들이 당신의 코드를 직관적으로 이해하고 많은 고민할 필요 없이 코드를 수정하여 공헌할 수 있게 하기 위해서는 잘 정의된 스타일 아키텍쳐가 가장 중요하다.

제대로 확장가능하게 만들기 위해서는 이 점이 가장 중요하며, 이는 기본 CSS든 BEM이든 어떤 것으로도 달성할 수 있다. 중요한 차이점은 각각의 구현을 위해 얼마나 많은 작업이나 코드량이 필요한가일 것이다. 스타일드 컴포넌트는 아직 더 실전 테스트가 더 필요하긴 하지만, 모든 React 프로젝트에서 훌륭한 해결책이 될 것이라 기대한다.

자바스크립트

Cycle.js 소개

이 글은 팀 내에서 진행했던 “자바스크립트 프레임워크 스터디”의 결과물을 정리한 글이다. 다른 프레임워크 (React, Angular, Vue) 에 대한 설명은 Toast Meetup 에서 확인할 수 있고, 원문은 http://meetup.toast.com/posts/97 에서 확인할 수 있다.

소개

Cycle.js는 이벤트 스트림 방식을 기반으로 프론트 엔드 어플리케이션을 만들 수 있도록 해 주는 프레임워크이다. RxJS의 컨트리뷰터 중 한 명인 André Staltz가 만들었으며, RxJS를 기반으로 완전한 반응형(Reactive) 프로그래밍을 할 수 있게 해 준다. André Staltz는 Cycle.js를 소개하며 React가 이름과는 다르게 완전히 Reactive 하지 않다는 비판을 하기도 했는데, 그런 의미에서 Cycle.js는 React가 일부 도입한 반응적, 함수형 특징을 좀 더 극단까지 밀어붙인 프레임워크라고도 할 수 있을 것이다.

Cycle.js는 함수형, 반응형이라는 두 가지 프로그래밍 방식을 강조한다. 함수형 프로그래밍은 이제 자바스크립트에서 익숙한 개념이 되었지만, 반응형 프로그래밍이라는 용어는 생소한 분들도 있을 텐데, 그런 분들은 이 글을 읽어보시기 바란다. 사실 반응형 프로그래밍의 정의는 경우에 따라 조금씩 다른데, 저자가 말하는 반응형 프로그래밍이란 결국 비동기 데이터 스트림을 이용해 프로그래밍하는 것을 말하며, 이를 위해 RxJS와 같은 이벤트 스트림 라이브러리를 적극 활용한다. 이런 방식의 프로그래밍을 함수 반응형 프로그래밍 (Functional Reactive Programming) 이라고 부르기도 하는데, 이런 특징으로 인해 Cycle.js 로 만들어진 코드는 this 키워드가 없고, setState()foo.update() 와 같은 명령형 호출을 하지 않는다.

사실 일명 함수 반응형 프로그래밍(FRP)이라는 용어에 대해서도 여러 이견이 있다. 여기에 대해서도 André Staltz가 자신의 의견을 정리한 글이 있으니 관심 있으신 분들은 참고하길 바란다.

RxJS

사실 RxJS를 설명하지 않고 Cycle.js를 설명하기는 힘들 것 같다. RxJS는 ReactiveX의 Javascript 구현 라이브러리이며, Observable 이라는 데이터 모델을 사용해 이벤트 기반의 비동기 코드를 쉽게 다룰 수 있게 해 준다. ReactiveX는 MS나 Netflix같은 회사에서 적극 사용해온 것으로 유명하며, 특히 최근에 정식 발표된 Angular2 에서도 RxJS를 도입하는 등 점점 사용층이 두터워지고 있다.
추가로 ReactiveX에 대해 한글로 아주 잘 정리된 글도 있으니 관심있는 분들은 참고하면 좋을 것 같다.

사실 RxJS와 같은 스트림 기반의 반응형 프로그래밍에 대한 이해가 없이는 Cycle.js를 제대로 이해하기가 힘들다. 하지만 이 글에서 상세한 내용을 다루기는 힘들기 때문에, 관심있는 분들은 앞에서 언급된 링크들을 꼭 먼저 읽어보길 바란다.

개발/구동 환경

의존성

Cycle.js는 단 하나의 외부 의존성을 가지는데, 위에서 말한 것처럼 특정한 이벤트 스트림 라이브러리를 필요로 한다. RxJS를 기반으로 만들어졌기 때문에 기본적으로는 RxJS에 의존성이 있었는데, 최근에 별도의 스트림 라이브러리가 필요하다는 판단하에 xstream 이라는 라이브러리를 만들었으며, RxJS 대신 사용하기를 권장하고 있다. xstream은 RxJS 보다 작고, 빠르며 hot stream만 지원하는 등 Cycle.js에 특화되어 있다. 하지만 여전히 RxJS (v4)를 사용할 수 있으며, 뿐만 아니라 RxJS (v5)most.js 등의 라이브러리로 대체할 수도 있다.

이 외에 Cycle.js는 내부적으로 Virtual-DOM을 위해 Snabbdom 이라는 라이브러리를 사용하는데, 외부 의존성으로 분리되어 있지 않기 때문에 별도로 다운로드 받을 필요는 없다.

개발 환경

꼭 필요한 것은 아니지만, Cycle.js의 튜토리얼 문서를 보면 Babel 이나 TypeScript 와 같은 트랜스파일러와 함께 browserify나 webpack 와 같은 번들링 툴을 사용하기를 권장하고 있다. 이는 라이브러리 자체가 npm과 ES6의 모듈을 활용해서 의존성 관리, 번들링 등을 하기 쉽도록 구성되어 있을 뿐만 아니라, 함수형의 코드를 작성함에 있어서 ES6의 문법이 많은 도움이 되기 때문이다. 또한 Babel 플러그인과 snabbdom-jsx를 활용하면, JSX 문법을 사용해서 Virtual-DOM을 만들어낼 수도 있다.

단, Cycle.js는 7.0.0(Diversity)에서부터 TypeScript로 완전히 재작성되었기 때문에 Babel 보다는 TypeScript와의 궁합이 더 좋을 수도 있을 것 같다.

구동 환경

사실 Cycle.js의 약점이라고 할 수도 있는 부분인데, 브라우저 지원 범위가 그리 넓지 않다. Cycle DOM 리파지토리에 명시된 바에 의하면 정식으로 지원하는 IE 버전은 Window7의 IE10과 IE11 뿐이다.

screen shot 2016-09-27 at 12 34 34 pm

(출처: cycleDOM)

물론 위의 표에서 제외된 브라우저에서도 실행가능할 수는 있지만, 공식 지원범위에 포함되지 않기 때문에 100% 정상 동작을 보장할 수는 없을 것이다.

아키텍처

기본 Data-Flow

Cycle.js은 기본적으로 사용자와 컴퓨터가 스트림을 기반으로 입력(Input)과 출력(Output)을 주고받는 함수라고 가정한다. 코드로 표현하면 다음과 같을 것이다.

function computer(inputDevices) {
  return outputDevices;
}

function human(senses) {
  return actuators;
}

이를 그림으로 표현하면 다음과 같다.

screen shot 2016-09-27 at 10 19 35 am

(출처: cycle.js.org)

즉, 컴퓨터는 마우스나 키보드와 같은 디바이스를 사용해 입력을 받고, 모니터 화면, 스피커 등을 사용해 출력을 내보낸다. 사용자는 눈이나 귀 등의 감각기관으로 컴퓨터의 출력을 입력받은 다음에 특정 행위를 함으로써 출력을 내보낼 것이다. 이 행위들은 위해 컴퓨터의 디바이스들을 사용하기 때문에 이는 다시 컴퓨터의 입력이 된다.

이러한 입력/출력 데이터를 일련의 스트림으로 처리할 수 있도록 도와주는 것이 RxJS와 같은 라이브러리이며, 이렇게 컴퓨터와 사용자의 입력과 출력을 연결하여 일종의 순환 싸이클을 만들어주는 것이 바로 Cycle.js의 역할이다. (이제 왜 이름이 Cycle인지 이해할 수 있을 것이다.)

André Staltz의 만약 사용자가 하나의 함수라면?라는 발표를 보면 이러한 기본 개념을 아주 잘 설명하고 있으니 꼭 확인해 보기 바란다.

Cycle.run(main, drivers)

이러한 순환 싸이클을 만들기 위해 Cycle.js 에서는 두 개의 파라미터를 받는 run 함수를 제공한다. 첫 번째 파라미터는 보통 main 이라고 불리는 함수이며, 위에서 computer로 표현된 함수처럼 입력 디바이스로부터의 데이터 스트림을 파라미터로 받고 출력 디바이스로 내보낼 데이터 스트림을 반환하는 함수이다. 두 번째 인자는 드라이버라는 개념의 함수들인데, main 함수에서 출력된 이벤트 스트림을 받아서 사용자에게 보여줄 수 있는 형태로 변환하고, 반대로 사용자로부터 이벤트를 받아서 스트림으로 반환할 수 있는 API를 제공해 주기도 하는 역할을 한다. 대표적인 드라이버로 DOM Driver가 있으며, 이는 main 함수에서 반환되는 Virtual-DOM 구조의 스트림을 실제 DOM으로 변환해주고 실제 DOM에서 사용자의 이벤트를 읽어 들일 수 있는 API를 제공해 준다.

이 드라이버라는 이름은 실제로 운영체제에서 사용되는 드라이버의 개념과 유사하다. 운영체제에서 특정 하드웨어와의 연결을 위해 중간에서 어댑터의 역할을 하는 것을 드라이버라고 하듯이, Cycle.js의 드라이버는 main 함수와 외부 환경 (DOM, Browser API, HTTP통신 등)을 연결해 주는 어댑터의 역할을 한다고 할 수 있을 것이다.

Cycle.js에서 특별히 유념할 점은 모든 부작용(Side Effect)이 드라이버에서 처리된다는 점이다. 예를 들어 DOM Driver는 DOM 변경과 같은 실제 브라우저에 종속적인 모든 부작용들을 다 내부에서 처리한다. 이로 인해 main 함수를 단순히 입력/출력을 스트림으로 받는 순수 함수로 유지할 수 있으며, 단위 테스트나 유지보수에 있어 큰 이점이 될 수 있다.

screen shot 2016-09-27 at 12 25 56 pm

(출처: cycle.js.org)

위의 그림에 있는 하단의 박스에서 DOM, HTTP 등의 부작용을 처리하는 것이 드라이버이다. main 함수의 출력 스트림을 받아서 부작용을 처리하고 main 함수에게 입력 스트림을 제공하는 역할을 하는 것이다. run 함수는 여기서 main 함수와 드라이버들의 입출력 스트림을 서로 연결해 주는 역할을 한다.

그럼 아주 간단한 예제를 한번 살펴보자. 아래 코드는 체크박스의 상태가 변경될 때마다 p 태그 내부의 텍스트를 ON 혹은 off 로 변경한다.

import xs from 'xstream';
import {run} from '@cycle/xstream-run';
import {div, input, p, makeDOMDriver} from '@cycle/dom';

function main(sources) {
  return {
    DOM: sources.DOM
      .select('input').events('change')
      .map(ev => ev.target.checked)
      .startWith(false)
      .map(toggled =>
        div([
          input({attrs: {type: 'checkbox'}}), 'Toggle me',
          p(`${toggled ? 'ON' : 'off'}`)
        ])
      )
  };
}

Cycle.run(main, {
  DOM: makeDOMDriver('#app')
});

위의 코드의 run 함수에서 두 번째 파라미터로 넘긴 객체의 DOM 프라퍼티가 바로 DOM Driver 이다. makeDOMDriver 함수를 이용해 주어진 CSS 선택자로 DOM Driver를 만들 수 있으며, 이렇게 넘겨진 DOM Driver는 출력값으로 DOMSource 객체를 만들어 반환한다. 이 DOMSource 객체는 main 함수의 입력값인 sources.DOM 으로 넘겨지며, 이 DOMSource에서 제공되는 selectevents API를 사용하면 실제 DOM에서 발생하는 이벤트를 스트림으로 만들어 낼 수 있다. 그리고 이러한 이벤트 스트림을 변환하여 main 함수에서 최종적으로 반환되는 Virtual-DOM 스트림은 다시 DOM Driver의 입력값으로 들어가게 되며, 실제 DOM으로 변환되어 화면에 표시된다.

Model-View-Intent

위에서 살펴보았듯이 Cycle.js의 API는 사실 run 함수가 전부라고 할 수 있고, 이를 위해 구현해야 할 어플리케이션 코드는 사실 main 함수가 전부이다. main 함수를 어떤 식으로 구현할 지는 코드를 작성하는 사람이 임의대로 결정하면 되며, Angular나 React/Redux 처럼 따라야만 하는 특정한 API가 없다.
하지만 main 함수가 너무 복잡해지는 것을 대비해 Cycle.js에서 권장하는 구조가 있는데, 이름하여 MVI 즉, Model-View-Intent이다.

screen shot 2016-09-27 at 10 22 11 am

(출처: cycle.js.org)

위와 같이 main 함수에서 하는 일을 순서대로 Intent -> View -> Model 의 순서로 나누어 작성할 수 있다. 앞서 보았던 코드를 다시 작성하면 다음과 같이 만들 수 있을 것이다.

function main(sources) {
  const actions = intent(sources.DOM);
  const state$ = model(actions);
  const vdom$ = view(state$);

  return {
    DOM: vdom$
  }
}

세부 내용은 잠시 후 살펴보기로 하고 일단 여기서는 몇 가지 주요 특징들을 살펴보자.

  • intentmodelview 모두 순수 함수이다.
  • state$vdom$ 마지막의 $는 일종의 컨벤션이며 이 변수가 스트림임을 의미한다.
  • intent 함수는 DOMSource로부터 이벤트 스트림들을 만들어 actions 객체로 묶어 반환한다.
  • model 함수는 intent로부터 받은 이벤트 스트림들을 변환해서 실제 어플리케이션의 상태(State) 변화를 나타내는 하나의 스트림을 만들어 반환한다.
  • view 함수는 model로부터 받은 상태 스트림을 변환해서 VNode(Virtual Node: Virtual-DOM을 구성하는 Node) 트리의 스트림을 만들어 반환한다.

Intent

Intent는 번역하자면 의도 정도가 될 것 같은데, 말 그대로 사용자의 의도를 이벤트 스트림을 이용해 정의한다고 이해하면 될 것 같다. 먼저 위의 예제에 맞는 intent 함수를 만들어보자.

function intent(domSource) {
  const change$ = domSource.select('input')
      .events('change')
      .map(ev => ev.target.checked);

  return {change$};
}

domSource는 위에서 설명했듯이 DOM Driver가 반환하는 객체이며, select 메소드는 CSS 셀렉터를 사용해 스코프를 제한해 주고, events 메소드는 주어진 DOM 이벤트가 발생할 때마다 이벤트 데이터가 발생하는 스트림을 반환해준다. 마지막에 있는 map 함수는 RxJS나 xstream의 스트림 객체에서 제공하는 API이며 해당 스트림을 변환해 새로운 스트림으로 반환한다. 즉 위의 intent 함수는 체크박스의 상태가 변경될 때마다 true/false 데이터가 발생하는 스트림을 만들어 반환되는 객체의 change$ 라는 속성으로 할당하는 것이다.

Model

Model은 어플리케이션의 상태를 관리한다는 점에서 우리가 흔히 알고 있는 MVC 패턴의 Model과 유사하다고 볼 수 있다. 중요한 다른 점은 아마 스트림을 입력받아 스트림을 반환한다는 점일 것이다. 정확히는 여러 개의 이벤트 스트림을 입력받아 하나의 상태 스트림을 반환한다고 할 수 있다. 코드를 살펴보자.

function model(actions) {
  const toggled$ = actions.change$.startWith(false);

  return toggled$;
}

코드가 엄청 간단한데, 실제로 여기서는 Intent에서 반환된 이벤트 스트림이 하나밖에 없고, Model이 단지 초기값을 할당하는 정도의 역할만을 할 뿐이기 때문이다. 만약 Intent에서 반환되는 객체가 change$ 외에 다른 이벤트 스트림을 가진다면 Model은 이들 스트림들을 합쳐서 하나의 상태 스트림으로 반환해야만 한다. 체크박스 외에 별도의 인풋 요소가 존재하고 키가 입력될 때마다 텍스트가 추가되는 이벤트 스트림을 actions.keydown$ 이라고 가정해보자.

function model(actions) {
  const toggled$ = actions.change$.startWith(false);
  const text$ = actions.keydown$.startWith('');

  return xs.combine(toggled$, text$)
    .map(([toggled, text]) => ({
      toggled, text, 
    }));
}

위의 xs.combine은 xstream의 API이며 여러개의 스트림을 합쳐서 하나의 스트림으로 반환해준다. 이처럼 Model에서 반환되는 스트림의 데이터는 어플리케이션의 전체 상태를 나타내는 객체이며 이는 스트림인 것만 빼면 Redux에서 사용하는 단일 상태 (Single State)의 개념과 유사하다고 볼 수 있다. 실제로 이렇게 스트림을 합치는 부분을 제외한 나머지 부분을 Redux 처럼 상태 객체를 변화하는 Reducer 함수로 분리해서 사용할 수 있다.

View

View역시 MVC 패턴의 View와 유사하다고 볼 수 있다. 차이점은 Model과 마찬가지로 스트림을 입력받아 스트림을 반환한다는 점이다. 코드를 살펴보자.

function view(state$) {
  return state$.map(toggled => 
    div([
      input({attrs: {type: 'checkbox'}}), 'Toggle me',
      p(`${toggled ? 'ON' : 'off'}`)
    ])
  );
}

이때 입력값은 Model에서 넘어온 상태 스트림이고, 반환하는 값은 VNode 트리의 스트림이다. 앞에서 언급했듯이 Cycle.js는 내부적으로 Snabbdom 라이브러리를 사용하는데, Snabbdom은 기본적으로 VNode 트리를 생성하기 위해 hyperscript 문법을 사용한다. 코드에 보이는 divpinput과 같은 함수들은 hyperscript 를 좀더 쉽게 사용할 수 있도록 cycleDOM 에서 제공해주는 헬퍼 함수들이다. 자세한 API는 Snabbdom 문서 에서 확인할 수 있다.

React 와의 비교

사실 스트림 기반의 구조라는 것을 제외하면 Virtual-DOM을 사용하거나 단일 상태 객체를 사용하는 점 등은 React/Redux 구조와 유사하다고 볼 수 있을 것이다. 한가지 명확하게 다른 점은 바로 Intent인데, React에서 <button onclick={handler}> 와 같은 식으로 Virtual-DOM 구조에 직접 이벤트 핸들러를 정의하는 것과는 반대의 접근방법을 취하고 있다. 오히려 예전에 jQuery와 같은 라이브러리에서 직접 셀렉터를 사용해 이벤트 핸들러를 할당하던 방식에 좀 더 가깝다고 할 수 있을 것 같다.

이는 View의 역할을 단순하게 Model의 상태 변경에 반응하여 화면을 그려주는 역할로 한정시켜 단일 책임의 원칙에 좀 더 충실하게 하고, 좀 더 반응적(Reactive)으로 만들기 위함이다. 또한 이렇게 함으로써 유저의 의도(Intent)를 추가하는 작업이 View에 영향을 끼치지 않게 되어 두 모듈의 역할을 명확하게 분리할 수 있게 된다.

사실 이벤트 핸들링이 필연적으로 View의 구조에 영향을 받을 수밖에 없다는 점을 생각해 보면, 두 가지를 분리하는 이러한 방식은 오히려 코드 관리를 힘들게 할 수가 있다. Cycle.js 에서는 이러한 단점을 최소화할 수 있도록 사용자의 Intent를 정의할 때에 DOM 구조에 종속적이기 보다는 className을 적극 활용할 수 있도록 isolate()와 같은 헬퍼 함수들을 제공해 주고 있다.

컴포넌트

Cycle.js는 모든 것을 스트림으로 다루기 때문에, 컴포넌트 단위로 구조화를 할 때도 스트림을 기반으로 작성해야 한다. 단순히 모듈 단위로 나누는 작업과는 차이가 있어서 간단히 이해하기가 쉽지 않은데, 일단 아래 그림을 보자.

screen shot 2016-09-27 at 10 23 51 am

(출처: cycle.js.org)

외부의 큰 박스가 main 함수라면 내부에 있는 작은 박스가 컴포넌트라고 할 수 있을 것이다. 혹은 이런 식으로 특정 컴포넌트가 다른 컴포넌트를 포함할 수도 있다. 이렇게 외부 컴포넌트(혹은 main)로 들어온 스트림에서 내부 컴포넌트에 필요한 부분만 분리해서 넘겨주고, 내부 컴포넌트의 출력으로 나온 스트림을 외부 컴포넌트의 최종 출력 스트림과 조합해서 반환하면 된다. 이 때 내부 컴포넌트는 이벤트 외에 Model의 데이터도 함께 스트림으로 받아야 할 것이고, 처리된 데이터도 VNode 트리와 함께 스트림으로 반환해야 할 것이다.

screen shot 2016-09-27 at 10 27 37 am

(출처: cycle.js.org)

위의 그림을 보면 컴포넌트가 유저 이벤트 스트림 외에 필요한 데이터를 props$ 스트림으로 입력 받고, 출력 값으로 vtree$ 스트림 뿐만 아니라 처리된 값에 대한 value$ 스트림을 함께 내보내는 것을 확인할 수 있을 것이다.

이렇게 컴포넌트를 만들 때 전체 DOM 영역이 아닌 컴포넌트에 필요한 DOM 영역만 스코프를 한정하기 위해서는 일일이 특정한 클래스명을 지정하는 등의 처리를 해 주어야 하는데, 이러한 작업을 돕기 위해 Cycle.js 에서는 isolate() 함수를 제공한다.

const ComponentA = isolate(MyComponent, 'comp-a');
const ComponentB = isolate(MyComponent, 'comp-b');

MyComponent는 main 함수와 같이 입력 스트림을 받아서 출력 스트림을 반환하는 순수 함수이다. 위와 같이 isolate를 사용하면 MyComponent를 각각 comp-acomp-b 클래스 내부로 스코프를 한정하는 두 개의 독립된 슬라이더로 만들어서 사용할 수 있다. 또한 두 번째 인자를 사용하지 않으면 내부적으로 랜덤한 클래스명을 할당해 주기 때문에, CSS에 영향을 받는 경우가 아니라면 굳이 클래스명을 명시적으로 지정할 필요도 없이 사용할 수 있다.

그림으로 보면 개념적으로는 단순하지만, 실제로 스트림을 나누고 합치는 과정은 RxJS 등을 이용한 FRP에 익숙하지 않다면 이해하기가 어렵다. 여기서 이러한 내용을 모두 다루기엔 너무 길어질 것 같으니 자세한 내용은 Cycle의 컴포넌트 설명 문서를 참고하기 바란다.

테스트

Cycle.js의 어플리케이션은 대부분이 순수 함수로 만들어지기 때문에 테스트하기가 굉장히 쉽다. 객체를 생성해서 상태를 관리할 필요가 없고, 함수별로 입력/출력에 대한 테스트만 작성하면 된다.

다만 이벤트 스트림에 대한 의존도가 굉장히 높고, 이들 스트림을 합치거나 분리하는 작업이 많은데 이러한 작업은 테스트를 작성하기가 간단하지만은 않으며, 해당 스트림 라이브러리가 테스트를 지원하는 방식의 영향을 많이 받는다. 예를 들면 RxJS 5부터는 Marble Test를 지원하고, xstream 에서는 fromDiagram 함수를 제공하는데, 이러한 기능을 사용하면 스트림에 대한 테스트를 다음과 같이 Marble Diagram 형태로 작성할 수 있다.

var e1 = hot('----a--^--b-------c--|');
var e2 = hot(  '---d-^--e---------f-----|');
var expected =      '---(be)----c-f-----|';

expectObservable(e1.merge(e2)).toBe(expected);

이처럼 Cycle.js에서는 이벤트 스트림 자체를 다루는 부분과 실제 데이터를 다루는 부분을 분리해서 프로그램을 작성해야 좀더 테스트하기 쉬운 코드를 만들어낼 수 있을 것이다.

또한 DOM과 같은 외부 환경과 관련된 부작용은 모두 드라이버 내부에서 다루어지기 때문에 외부 환경을 모킹(Mocking)하여 테스트를 작성하기도 용이하다. 단 해당 드라이버가 모킹을 도와주는 API를 제공해 주어야 하는데, 예를 들어 DOM Driver 에서는 mockDOMSource 함수를 제공해서 DOMSource를 모킹할 수 있도록 해 준다. 이를 활용하면 다음과 같이 테스트를 작성할 수 있다.

const eventDummy = {
  target: {
    parentNode: {
      dataset: {
        id: 5
      }
    }
  }
}

it('removeSong: ', function() {
  const domSource = mockDOMSource({
    '.btn-remove': {
      'click': Observable.of(eventDummy)
    }
  })

  removeSong(domSource).subscribe(id => {
    expect(id).toBe(5)
  })
})

removeSong 함수는 Intent 내부에서 삭제 버튼을 클릭했을 때 해당 버튼과 관련된 ID를 반환하는 스트림을 만들어서 반환해주는 함수이다. 위와 같이 mockDOMSource를 사용하면 .btn-remove의 click에 대한 이벤트 스트림을 직접 만들어 설정할 수 있으며, 이렇게 만들어진 domSource를 사용해서 removeSong을 호출하면 새로운 스트림을 반환하게 되고 이 스트림을 subscribe 하여 테스트 코드를 작성할 수 있다.

성능

Cycle.js 모든 것을 스트림으로 처리한다는 특징 때문에 다른 라이브러리에 비해 약간의 오버헤드가 있으며, 스트림 처리를 위해 어떤 라이브러리를 사용하느냐에 따라 성능에 영향을 많이 받는다. 사실 버전 7.0.0 이전에는 RxJS 자체의 성능 문제와 더불어 느리다는 비판이 꽤 있었다. 하지만 버전 7.0.0 부터 xstream과 Snabbdom 기반으로 전체 코드 베이스가 변경되면서 많은 성능 향상이 있었다. 실제로 최근에 자바스크립트 프레임워크의 성능을 비교한 글을 보면 몇몇 테스트에서 React 보다도 빠른 성능을 보여주는 부분도 꽤 많다. 특히 메모리 사용량에 있어서 좋은 결과를 보이고 있는데, 함수형 특징으로 인해 불필요한 인스턴스화가 많이 없기 때문(특히 View가 순수 함수이므로)이라고 유추할 수 있을 것 같지만, 명확한 이유는 사실 좀 더 살펴봐야 알 수 있을 것 같다.

Virtual-DOM을 사용한다는 특징 또한 성능에 영향을 미치는데, 이벤트가 발생할 때마다 전체 VNode가 변경되는 구조상 특정한 경우에 성능이 많이 느려질 수 있다. 이러한 경우에는 Snabbdom의 Thunk 함수를 활용하여 VNode를 캐싱하여 사용한다면 성능을 개선시킬 수 있을 것이다.

정리

사실 Cycle.js는 어렵다. 스트림 기반의 반응형 프로그래밍에 익숙하지 않다면 제대로 사용할 수가 없는데, 이러한 함수 반응형 프로그래밍(FRP) 방식은 이해하기가 쉽지 않아서 많은 공부와 연습을 필요로 한다. 하지만 한번 이러한 방식을 잘 이해하고 나면 비동기 방식의 코드를 다루기가 아주 용이하며, 최근에 곳곳에서 관심이 커지고 있는 만큼 공부할만한 가치는 충분하다고 생각한다.

또한 Cycle.js는 잘 설계된 아키텍처를 갖고 있다. André Staltz와 Cycle.js의 컨트리뷰터들은 일관된 철학을 갖고 몇 년 동안 꾸준히 설계를 발전시켜 왔으며, Elm이나 Haskell 등의 함수형 언어가 가진 여러 가지 장점들을 공유하고 있다. 특히 순수함수와 부작용을 확실하게 구분하여 다룰 수 있기 때문에, 어플리케이션의 상태를 좀 더 단순하고 명확하게 관리할 수 있으며 테스트하기 쉬운 코드를 만들어낼 수 있다.

Cycle.js의 홈페이지에 가 보면 이러한 설계 철학에 대해 자세한 설명을 볼 수 있으며, 무료 동영상 강의도 시청할 수 있다. 이렇게 정말 공들여서 관리되고 있는 것에 비해 여전히 사용자층이 많지 않은 것은 아쉽지만, 만약 함수 반응형 프로그래밍이나 ReactiveX 등에 관심이 있다면 꼭 한번 사용해 보길 권한다.

자바스크립트

[번역] 왜 Flow를 사용하는가?

원문 링크 : https://blog.aria.ai/post/why-use-flow/

Flow는 Facebook이 오픈소스로 만든 자바스크립트를 위한 정적 타입 검사기이다. Flow는 자바스크립트의 많은 약점들을 해결해 주며, 더 낫고 이해하기 쉬운 코드를 작성할 수 있도록 해 준다.

Flow의 홈페이지에 따르면:

Flow는 아래와 같은 자바스크립트의 일반적인 버그들을 프로그램을 실행하기 전에 잡아낼 수 있다.
- 암묵적 타입 변환
- Null 참조
- 그리고 무시무시한 'undefined is not a function'

또한

Flow는 코드에 점진적으로 타입 선언을 추가할 수 있도록 해준다.

즉, Flow는 많은 일반적인 자바스크립트 문제에 대한 해결책이며, 코드 베이스에 점진적으로 도입할 수도 있다. 좋구나!

타입

Flow에 대해 살펴보기 전에, 먼저 타입이 무엇인지를 명확하게 알아보자. 위키피디아의 데이터 타입에 대한 글을 살펴보자.

타입은 실수, 정수, 불린 등과 같이 여러 종류의 데이터 중 하나를 식별해 내는 분류로서, 
더 나아가 해당 타입에 사용 가능한 값, 해당 타입에 사용할 수 있는 명령들, 데이터의 의미, 
해당 타입의 값을 저장하는 방식을 결정한다.

내 나름대로 정리해 보자면, 타입은 프로그램 안에서의 데이터에 대한 규칙이며, 컴퓨터로 하여금 당신이 그 데이터로 할 수 있는 일과 할 수 없는 일을 결정하도록 해 주며, 이는 당신이 의도치 않게 이들 규칙을 깨려고 할 때 많은 도움이 될 것이다.

여러가지 언어로 코드를 작성해 보았다면, 타입이 사용되는 방식이 언어에 따라 다르다는 것을 알 것이다. 명시적으로 반드시 필요할 수도 있고, 선택적으로 사용될 수도 있으며, 거의 존재하지 않을 수도 있다. 일반적으로 프로그래밍 언어의 타입 시스템은 두 개의 카테고리로 구분된다. 강한(Strong) vs 약한(Weak) 그리고 정적(Static) vs 동적(Dynamic) 이다.

강한 타입 vs 약한 타입

위키피디아에 아주 잘 설명이 되어 있다. 둘에 대한 보편적인 구분은 약간 모호한데, 이는 강한 타입 vs 약한 타입에 대한 합의된 정의가 없기 때문이다. 나는 위의 위키피디아 문서에서 제목으로 쓰인 다음의 정의를 사용하도록 하겠다.

암묵적 타입 변환과 "타입 퍼닝(punning)"

파이썬과 같은 강한 타입 언어에서는 호환되지 않는 두 가지 값을 결합할 때 타입 에러가 발생한다. 타입 에러를 피하기 위한 유일한 방법은 두 값이 어울릴 수 있도록 명시적으로 변환시켜 주는 것이다.

x = 5
print x + "" # 정수형에 문자열을 더할 수 없음

는 다음의 에러를 발생시킨다.

TypeError: unsupported operand type(s) for +: 'int' and 'str'

하지만 다음 코드는 문제없다.

x = 5
print str(x) + "" # x를 문자열로 변환했기 때문에 문제없음

자바스크립트와 같은 약한 타입 언어에서는 변수들이 사용될 때 암묵적으로 자신의 타입을 변환하기 때문에 아무런 문제가 없다. 문자열을 객체에 더하거나, 배열에 객체를, 숫자형에 Null을 더할 수도 있다. 심지어 실수로 잘못된 값을 사용한 것에 대해서 에러조차 발생하지 않는다.

console.log({} + {}) // NaN
console.log({} + []) // 0
console.log([] + []) // ''
console.log({} + 2) // [object Object]2
console.log({} + 'hello') // [object Object]hello

이처럼 어떤 에러도 발생하지 않는 상황으로 인해 어떤 문제들이 발생할 지 아마 상상할 수 있을 것이다.

정적 타입 vs 동적 타입

정적 타입 vs 동적 타입은 강한 타입 vs 약한 타입에 비해 좀 더 논란의 여지가 있다. 나는 어떤 특정한 타입이 다른 타입보다 더 낫다고 말하거나, 각각의 장점에 대해 심도깊은 분석을 하지는 않을 것이다. 대신 각각에 대해 짧게 소개를 해볼까 한다. 만약 어떤 타입이 더 나은지에 대한 논쟁을 좀 더 살펴보고 싶다면 다음의 링크를 확인해보기 바란다.

정적 타입

내가 알고 있는 한, 정적 타입을 사용하는 대부분의 언어들은 강한 타입을 사용한다. 뿐만 아니라 정적 타입을 사용하는 언어들에서는, 명시적으로 변수의 타입을 지정한다. 대부분의 사람들이 알고 있듯이, 정적 타입을 사용하는 언어인 자바에서는 int나 String와 같이 변수의 타입을 지정하고, int add(int a, int b)와 같이 함수의 리턴값이나 파라미터의 타입들을 지정한다.

public class Hello {
  public static void main(String[] args) {
    int x = 5;
    int y = 10;
    String s = "1.23131";
 
    System.out.println(add(x, y)); // 15
    System.out.println(add(x, s)); // Incompatible types: String cannot be converted to int
  }
 
  public static int add(int a, int b) {
    return a + b;
  }
}

이 코드를 컴파일 하면 8번째 라인에서 에러가 발생하는데, 왜냐하면 String 타입을 int 타입에 더할 수 없기 때문이다.

다음의 사항에 주목하자.

  • 에러는 실행되는 시점(run-time)이 아닌 아닌 컴파일하는 시점(compile-time)에 발생하며, 이는 에러를 고치지 않으면 코드를 실행할 수 없다는 의미이다.
  • 만약 IDE를 사용한다면, add(x, s)가 불가능하다는 메시지를 보게 될 것이다. 변수의 타입을 미리 지정해 두었기 때문에 당신의 코드는 상위 레벨에서 분석될 수 있으며, 실수를 찾기 위해 컴파일할 필요가 없다.
  • 만약 add 함수가 sfjkasjf 라는 이름을 갖고 있어도, 당신은 여전히 이 함수가 두 개의 정수를 받아서 하나의 정수를 반환한다는 것을 알 수 있으며, 이는 유용한 정보이다.
정적 타입 언어에서의 타입 추론(Type inference)

앞서 언급했던 ‘정적 타입의 언어들은 명시적으로 타입을 지정해야 한다’는 말은 사실 100% 진실이 아니다. 자바처럼 타입 추론이 없는 언어에서는 사실이지만, 타입 추론을 지원하는 언어에서는 당신이 사용하는 타입을 컴퓨터가 알아내도록 내버려둘 수 있다. 아래의 예제는 위의 예제와 같은 코드를 하스켈로 작성한 것인데, 하스켈은 강력한 타입 시스템으로 유명하면서도 let x = 1 과 let add' = (+)를 같이 작성하면 타입을 명시적으로 지정하지 않아도 스스로 타입을 추론해 낸다.

Haskell
main :: IO()
main = do
  let x = 1
  let y = 2
  let s = ""
 
  -- Type inference
  let add' = (+)
 
  print (add x y)  -- 3
  print (add' x y) -- 3
  print (add x s)  -- throws error
 
-- With Explicit Types
add :: Int -> Int -> Int
add = (+)

타입 추론은 Flow의 타입 시스템을 비롯한 다른 많은 타입 시스템에서 지원한다. 하지만 일반적인 견해에서 타입 추론이 코드량을 줄여주어 삶을 편하게 해 주는 것은 맞지만, 모든 것을 타입 추론에 의존할 수는 없으며, 그래서도 안된다.

동적 타입

동적 타입의 언어에서는 오직 코드내의 값(value)에 의해서만 타입이 정해진다. 타입을 직접 지정할 일은 전혀 없다. 이 방식의 주된 장점은 코드가 더 깔끔해진다는 점과, 프로그래밍을 할 때 타입에 대해 신경쓸 일이 없다는 것이며, 이로 인해 단기적으로는 생상성이 향상될 수 있다. 위의 코드를 파이썬으로 작성하면 다음과 같을 것이다.

def main():
  x = 5
  y = 10
  s = "1.23131"
 
  print add(x, y) # 15
  print add(x, s) # TypeError: unsupported operand type(s) for +: 'int' and 'str'
 
def add(a, b):
  return a + b

이 코드를 실행하면 라인 7에서 에러가 발생하는데, 이는 string 타입을 int 타입에 더할 수 없기 때문이다. 이것이 에러인 이유는 파이썬이 강한 타입 언어이기 때문임을 기억하기 바란다. 약한 타입 언어인 자바스크립트에서는 add(5, "1.23123213")가 100% 유효한 코드일 것이다.

다음의 사항에 주목하자.

  • 코드가 더 간결하다.
  • 타입 추론이 없다. 동적 타입 언어에서 변수들은 단지 값을 담고 있는 컨테이너일 뿐이며, 다른 어떠한 특성도 없다. add(x, s)가 실패하는 이유는 실행되는 시점에 int와 string을 더하려고 하기 때문이지 x와 s가 서로 같이 연산될 수 없음을 인터프리터가 미리 알아냈기 때문이 아니다.
  • a와 b의 타입이 실제로 무엇인지 말할 수 없다. intstringfloat 혹은 어떤것이든 모두 가능하다.
  • 에러를 발생시킨다는 것은 같지만, 컴파일 시점이 아닌 실행 시점이라는 것이 큰 차이이다. 이는 동적 타입 언어에서 테스트가 더욱 중요하다는 것을 의미하는데, 왜냐하면 코드가 타입 에러를 포함하고 있어도 일단 문제없이 실행될 것이기 때문이다.

자바스크립트와 Flow

이제 타입에 대해서 더 잘 알게 되었으니, 다시 원래의 문제로 돌아와서 자바스크립트 코드에서 어떻게 실수를 줄일 수 있을지를 알아보자.

자바스크립트는 약한 타입이면서 동적 타입인 언어인데, 이는 유연하지만 에러를 극도로 유발하는 조합이다. 위에서 살펴보았듯이, 암묵적 형변환 때문에 다른 타입간의 모든 연산은 이들 연산이 유효한지 아닌지와 무관하게 에러없이 실행되며 (약한 타입), 타입을 직접 지정하는 일도 결코 없다. (동적 타입)

다음의 예제에서 살펴볼 수 있듯이 이러한 약한 타입과 동적 타입의 혼합은 아주 큰 불행이며, 수없이 많은 사람들이 이러한 특징을 비판하고 있다.

이런 대부분의 문제들에 대한 해결책은 Flow이다. Flow는 정적 타입, 타입 추론을 통해 위에서 본 것과 같은 자바스크립트의 많은 문제들을 처리한다.

이 글은 튜토리얼이 아니므로, 만약 더 자세한 내용을 알고 싶다면 Flow 시작하기 가이드를 참고하기 바란다.

이제 좀 더 나아가 보자. 처음에 ‘약한 타입’ 절에서 보았던 자바스크립트 예제로 돌아가서 기존 코드에 Flow를 적용해 보겠다.

타입 기능을 사용하기 위해 첫번째 줄에 // @flow를 추가하고, 커맨드라인 도구인 flow를 실행해서 코드를 검사한다. (물론 IDE에서 지원한다면 그걸 사용해도 된다) :

// @flow
// ^^^^^ that's necessary to activate flow
// flow is opt-in to allow you to gradually add types

console.log({} + {}) // NaN
console.log({} + []) // 0
console.log([] + []) // ''
console.log({} + 2) // [object Object]2
console.log({} + 'hello') // [object Object]hello

즉시 모든 각각의 라인이 아래와 비슷한 타입 에러를 발생시킬 것이다.

index.js:3
  3: console.log({} + {}) // NaN
                 ^^ object literal. This type cannot be added to
  3: console.log({} + {}) // NaN
                 ^^^^^^^ string

타입 어노테이션과 같은 추가적인 작업이 전혀 없이도, Flow는 이미 뭔가가 잘못되었다는 것을 알려준다. 위의 wat 영상은 더이상 적용되지 않는다.

타입 어노테이션의 이점

Flow가 위의 예제에서처럼 에러를 잡는 것을 도와주긴 하지만, 진정한 이점을 누리기 위해서는 직접 자신만의 타입 어노테이션을 작성해야 한다. 즉, 값에 특정한 타입을 지정하기 위해 Flow의 내장된 타입인 numberstringnullbooleanetc. 등을 사용하거나 다음과 같이 자신만의 타입 별칭을 만들어내야 한다.

type Person = {
  age: number,
  name: string,
  gender: 'male' | 'female'
}

다음과 같은 함수를

function xyz(x, y, z) {
  return x + y + z
}

이렇게 변경할 수 있다.

// @flow

function xyz(x: number, y: number, z: number): number {
  return x + y + z
}

이 경우에 우리는 xyz가 3개의 숫자형을 받아서 하나의 숫자형을 반환한다는 것을 알 수 있다. 만약 xyz({}, '2', [])를 시도한다면 자바스크립트에서는 100% 유효한 코드이지만(lol), Flow가 에러를 발생시킬 것이다! 이런 식의 코드를 점점 더 작성하기 시작하면, Flow는 당신이 작성한 코드에 대해 좀 더 알 수 있게 되고 당신이 만들어낸 실수에 대해 좀 더 나은 조언을 해 줄 것이다.

몇가지 예제

함수에 전달하는 파라미터 개수 오류를 잡아낸다

코드:

// @flow

function xyz(x: number, y: number, z: number): number {
  return x + y + z
}

xyz(1, 2)

에러:

index.js:7
  7: xyz(1, 2)
     ^^^^^^^^^ function call
  7: xyz(1, 2)
     ^^^^^^^^^ undefined (too few arguments, expected default/rest parameters). This type is incompatible with
  3: function xyz(x: number, y: number, z: number): number {
                                           ^^^^^^ number
잘못된 파라미터 타입 오류를 잡아낸다
// @flow

function xyz(x: number, y: number, z: number): number {
  return x + y + z
}

xyz(1, 2, '')

에러:

index.js:7
  7: xyz(1, 2, '')
     ^^^^^^^^^^^^^ function call
  7: xyz(1, 2, '')
               ^^ string. This type is incompatible with
  3: function xyz(x: number, y: number, z: number): number {
                                           ^^^^^^ number
Null 체크를 잊지 않도록 해준다

코드:

// @flow

function xyz(x: number, y: number, z: number): ?number {
  return Math.random() < 0.5 ? x + y + z : null
}

function printNumber(x: number): void {
  console.log(x)
}

printNumber(xyz(1, 2, 3))

에러:

 index.js:11
 11: printNumber(xyz(1, 2, 3))
     ^^^^^^^^^^^^^^^^^^^^^^^^^ function call
 11: printNumber(xyz(1, 2, 3))
                 ^^^^^^^^^^^^ null. This type is incompatible with
  7: function printNumber(x: number): void {
                             ^^^^^^ number
올바른 타입을 반환하도록 해준다

코드:

// @flow

function xyz(x: number, y: number, z: number): number {
  return Math.random() < 0.5
    ? x + y + z
    : null
}

에러:

index.js:6
  6:     : null
           ^^^^ null. This type is incompatible with the expected return type of
  3: function xyz(x: number, y: number, z: number): number {
                                                    ^^^^^^ number
객체가 필요한 모든 프라퍼티들을 갖도록 만든다

코드:

// @flow

type Person = {
  age: number,
  name: string,
  gender: 'male' | 'female'
}

const person: Person = { name: 'joe', age: 10, gender: 'male' }
console.log(person.job)

에러:

index.js:9
  9: console.log(person.job)
                        ^^^ property `job`. Property not found in
  9: console.log(person.job)
                 ^^^^^^ object type

더 깊게 들어가기

아마도 내가 빼먹은 몇가지 이점들이 있을 테지만, 위의 예제들은 대부분의 보편적인 이점들을 다루고 있다. 만약 ‘이게 다야?’ 라고 생각한다면, 더 깊게 파고들수록 훨씬 많은 것들이 있다는 것을 알아두자. 이는 단순히 변수나 반환값의 타입에 관한 것이 아닌, 타입으로 개념화할 수 있는 모든 것들에 대한 것이다.

Giulio Canti가 쓴 몇가지 아티클에서 Flow로 할 수 있는 좀 더 많은 것들에 대해 다루고 있다. 읽어보면 Flow를 사용해서 당신이 작성한 코드의 모든 부분이 의도한 대로 동작하도록 만들 수 있을 것이다.

그는 정말 인상깊은 flow-static-land의 저자이기도 하다.

결론

  • 자바스크립트는 약한 타입과 동적 타입을 사용하는데, 이는 에러를 유발하기 쉬우며 언어가 나쁜 평가를 받는 데에 한몫 하고 있다.
  • 약간의 비용을 미리 들여서 천천히 적용해 본다면, Flow는 자바스크립트에 타입 시스템을 추가하여 위의 두가지 문제를 해결해줄 것이다.
자바스크립트

자바스크립트와 이벤트 루프

회사 기술블로그인 Toast Meetup 에 기고했던 글을 옮겨왔다. 원문은  http://meetup.toast.com/posts/89 에서 확인할 수 있다.

자바스크립트의 큰 특징 중 하나는 ‘단일 스레드’ 기반의 언어라는 점이다. 스레드가 하나라는 말은 곧, 동시에 하나의 작업만을 처리할 수 있다라는 말이다. 하지만 실제로 자바스크립트가 사용되는 환경을 생각해보면 많은 작업이 동시에 처리되고 있는 걸 볼 수 있다. 예를 들면, 웹브라우저는 애니메이션 효과를 보여주면서 마우스 입력을 받아서 처리하고, Node.js기반의 웹서버에서는 동시에 여러 개의 HTTP 요청을 처리하기도 한다. 어떻게 스레드가 하나인데 이런 일이 가능할까? 질문을 바꿔보면 ‘자바스크립트는 어떻게 동시성(Concurrency)을 지원하는 걸까’?

이때 등장하는 개념이 바로 ‘이벤트 루프’이다. Node.js를 소개할 때 ‘이벤트 루프 기반의 비동기 방식으로 Non-Blocking IO를 지원하고..’ 와 같은 문구를 본 적이 있을 것이다. 즉, 자바스크립트는 이벤트 루프를 이용해서 비동기 방식으로 동시성을 지원한다. 동기 방식의(Java 같은) 다른 언어를 사용하다가 Node.js 등을 통해 자바스크립트를 처음 접하게 되는 사람들은 이 ‘이벤트 루프’의 개념이 익숙하지 않아서 애를 먹는다. 뿐만 아니라 자바스크립트를 오랫동안 사용해서 비동기 방식의 프로그래밍에 익숙한 사람들조차 이벤트 루프가 실제로 어떻게 동작하는지에 대해서는 자세히 모르는 경우가 많다.

좀 지난 동영상이지만 최근에 Help, I’m stuck in an event-loop를 우연히 보게 되었는데, 내가 이벤트 루프에 대해 잘못 이해하고 있는 부분들이 많다는 것을 알게 되었다. 그래서 이번 기회에 이벤트 루프에 대해 좀더 자세히 공부해 보았는데, 정리도 할 겸 중요한 사실 몇 가지를 공유해볼까 한다.

ECMAScript에는 이벤트 루프가 없다

웬만큼 두꺼운 자바스크립트 관련 서적들을 뒤져봐도 이벤트 루프에 대한 설명은 의외로 쉽게 찾아보기가 힘들다. 그 이유는 아마, 실제로 ECMAScript 스펙에 이벤트 루프에 대한 내용이 없기 때문일 것이다. 좀더 구체적으로 표현하면 ‘ECMAScript 에는 동시성이나 비동기와 관련된 언급이 없다’고 할 수 있겠다(사실 ES6부터는 조금 달라졌지만, 나중에 좀더 설명하겠다). 실제로 V8과 같은 자바스크립트 엔진은 단일 호출 스택(Call Stack)을 사용하며, 요청이 들어올 때마다 해당 요청을 순차적으로 호출 스택에 담아 처리할 뿐이다. 그렇다면 비동기 요청은 어떻게 이루어지며, 동시성에 대한 처리는 누가 하는 걸까? 바로 이 자바스크립트 엔진을 구동하는 환경, 즉 브라우저나 Node.js가 담당한다. 먼저 브라우저 환경을 간단하게 그림으로 표현하면 다음과 같다.

b1493856-379d-11e6-9c16-a9a4cf841567.png

위 그림에서 볼 수 있듯이 실제로 우리가 비동기 호출을 위해 사용하는 setTimeout이나 XMLHttpRequest와 같은 함수들은 자바스크립트 엔진이 아닌 Web API 영역에 따로 정의되어 있다. 또한 이벤트 루프와 태스크 큐와 같은 장치도 자바스크립트 엔진 외부에 구현되어 있는 것을 볼 수 있다. 다음은 Node.js 환경이다.

Bt5ywJrIEAAKJQt.jpg

(출처: http://stackoverflow.com/questions/10680601/nodejs-event-loop)

이 그림에서도 브라우저의 환경과 비슷한 구조를 볼 수 있다. 잘 알려진 대로 Node.js는 비동기 IO를 지원하기 위해 libuv 라이브러리를 사용하며, 이 libuv가 이벤트 루프를 제공한다. 자바스크립트 엔진은 비동기 작업을 위해 Node.js의 API를 호출하며, 이때 넘겨진 콜백은 libuv의 이벤트 루프를 통해 스케쥴되고 실행된다.

이제 어느 정도 감이 잡힐 것이다. 각각에 대해 좀더 자세히 알아보기 전에 한가지만 확실히 짚고 넘어가자. 자바스크립트가 ‘단일 스레드’ 기반의 언어라는 말은 ‘자바스크립트 엔진이 단일 호출 스택을 사용한다’는 관점에서만 사실이다. 실제 자바스크립트가 구동되는 환경(브라우저, Node.js등)에서는 주로 여러 개의 스레드가 사용되며, 이러한 구동 환경이 단일 호출 스택을 사용하는 자바 스크립트 엔진과 상호 연동하기 위해 사용하는 장치가 바로 ‘이벤트 루프’인 것이다.

단일 호출 스택과 Run-to-Completion

이벤트 루프에 대해 좀더 알아보기 전에, 먼저 자바스크립트 언어의 특징을 하나 살펴보자. 자바스크립트의 함수가 실행되는 방식을 보통 “Run to Completion” 이라고 말한다. 이는 하나의 함수가 실행되면 이 함수의 실행이 끝날 때까지는 다른 어떤 작업도 중간에 끼어들지 못한다는 의미이다. 앞서 말했듯이 자바스크립트 엔진은 하나의 호출 스택을 사용하며, 현재 스택에 쌓여있는 모든 함수들이 실행을 마치고 스택에서 제거되기 전까지는 다른 어떠한 함수도 실행될 수 없다. 다음의 예제를 보자.

function delay() {
  for (var i = 0; i < 100000; i++);
}
function foo() {
  delay();
  bar();
  console.log('foo!'); // (3)
}
function bar() {
  delay();
  console.log('bar!'); // (2)
}
function baz() {
  console.log('baz!'); // (4)
}
setTimeout(baz, 10); // (1)
foo();

자바스크립트를 경험해본 사람이라면, 아무리 delay 함수가 10ms 보다 오래 걸린다고 해도 ‘baz!’가 ‘foo!’ 보다 먼저 콘솔에 찍히는 일은 없을 거라는 것을 알 것이다. 즉, foo 내부에서 bar를 호출하기 전에 10ms이 지났다고 해도 baz가 먼저 호출되지는 않는다는 말이다. 그러므로 위의 예제를 실행하면 콘솔에는 ‘bar!’ -> ‘foo!’ -> ‘baz!’의 순서로 찍히게 된다. 위의 코드가 전역 환경에서 실행된다고 가정하고 코드내 주석으로 숫자가 적힌 각 시점의 호출 스택을 그림으로 그려보면 다음과 같을 것이다.

46cb891a-36d6-11e6-8728-231d5bce2f36.png

(전역 환경에서 실행되는 코드는 한 단위의 코드블록으로써 가상의 익명함수로 감싸져 있다고 생각하는 것이 좋다. 따라서 위의 코드의 첫 줄이 실행될 때에 호출 스택의 맨 아래에 익명 함수가 하나 추가되며, 마지막 라인까지 실행되고 나서야 스택에서 제거된다.)

setTimeout 함수는 브라우저에게 타이머 이벤트를 요청한 후에 바로 스택에서 제거된다. 그 후에 foo 함수가 스택에 추가되고, foo 함수가 내부적으로 실행하는 함수들이 차례로 스택에 추가되었다가 제거된다. 마지막으로 foo 함수가 실행을 마치면서 호출 스택이 비워지게 되고, 그 이후에 baz 함수가 스택에 추가되어 콘솔에 ‘baz!’가 찍히게 된다.

(결과적으로 baz는 10ms보다 더 늦게 실행되게 될 것이다. 즉, 자바스크립트의 타이머는 정확한 타이밍을 보장해주지 않는데, 이와 관련해서 잘 설명된 John Resig의 글이 있으니 관심 있으신 분들은 클릭!)

태스크 큐와 이벤트 루프

여기서 하나의 궁금증이 생긴다. setTimeout 함수를 통해 넘긴 baz 함수는 어떻게 foo 함수가 끝나자 마자 실행될 수 있을까? 어디서 대기하고 있다가 누구를 통해 실행될까? 바로 이 역할을 하는 것이 태스크 큐와 이벤트 루프이다. 태스크 큐는 말 그대로 콜백 함수들이 대기하는 큐(FIFO) 형태의 배열이라 할 수 있고, 이벤트 루프는 호출 스택이 비워질 때마다 큐에서 콜백 함수를 꺼내와서 실행하는 역할을 해 준다.

앞선 예제를 살펴보자. 코드가 처음 실행되면 이 코드는 ‘현재 실행중인 태스크’가 된다. 코드를 실행하는 도중 10ms이 지나면 브라우저의 타이머가 baz를 바로 실행하지 않고 태스크 큐에 추가한다. 이벤트 루프는 ‘현재 실행중인 태스크’가 종료되자 마자 태스크 큐에서 대기중인 첫 번째 태스크를 실행할 것이다. foo가 실행을 마치고 호출 스택이 비워지면 현재 실행중인 태스크는 종료되며, 그 때 이벤트 루프가 태스크 큐에 대기중인 첫 번째 태스크인 baz를 실행해서 호출 스택에 추가한다.

MDN의 이벤트 루프 설명을 보면 왜 ‘루프’라는 이름이 붙었는지를 아주 간단한 가상코드로 설명하고 있다.

while(queue.waitForMessage()){
  queue.processNextMessage();
}

위 코드의 waitForMessage() 메소드는 현재 실행중인 태스크가 없을 때 다음 태스크가 큐에 추가될 때까지 대기하는 역할을 한다. 이런 식으로 이벤트 루프는 ‘현재 실행중인 태스크가 없는지‘와 ‘태스크 큐에 태스크가 있는지‘를 반복적으로 확인하는 것이다. 간단하게 정리하면 다음과 같을 것이다.

  • 모든 비동기 API들은 작업이 완료되면 콜백 함수를 태스크 큐에 추가한다.
  • 이벤트 루프는 ‘현재 실행중인 태스크가 없을 때'(주로 호출 스택이 비워졌을 때) 태스크 큐의 첫 번째 태스크를 꺼내와 실행한다.

좀더 명확하게 이해하기 위해 앞의 예제를 조금 바꿔보자.

function delay() {
  for (var i = 0; i < 100000; i++);
}
function foo() {
  delay();
  console.log('foo!');
}
function bar() {
  delay();
  console.log('bar!');
}
function baz() {
  delay();
  console.log('baz!');
}

setTimeout(foo, 10);
setTimeout(bar, 10);
setTimeout(baz, 10);

이 코드를 실행하면 아무런 지연 없이 setTimeout 함수가 세 번 호출된 이후에 실행을 마치고 호출 스택이 비워질 것이다. 그리고 10ms가 지나는 순간 foobarbaz 함수가 순차적으로 태스크 큐에 추가된다. 이벤트 루프는 foo 함수가 태스크 큐에 들어오자 마자, 호출 스택이 비어있으므로 바로 foo를 실행해서 호출 스택에 추가한다. foo 함수의 실행이 끝나고 호출 스택이 비워지면 이벤트 루프가 다시 큐에서 다음 콜백인 bar를 가져와 실행한다. bar의 실행이 끝나면 마찬가지로 큐에 남아있는 baz를 큐에서 가져와 실행한다. 그리고 baz까지 실행이 모두 완료되면 현재 진행중인 태스크도 없고 태스크 큐도 비어있기 때문에, 이벤트 루프는 새로운 태스크가 태스크 큐에 추가될 때까지 대기하게 된다.

(코드는 다르지만 그림으로 표현하면 대략 다음과 같을 것이다)

event_loop.jpg

(출처: http://www.2ality.com/2014/09/es6-promises-foundations.html)

(글의 서두에 언급했던 영상을 보면 발표자가 직접 만든 인터랙션 환경을 사용해 이 과정을 정말 이해하기 쉽게 잘 설명하고 있다. 안보신 분들은 꼭 확인해 보길 바란다.)

비동기 API와 try-catch

setTimeout 뿐만 아니라 브라우저의 다른 비동기 함수들(addEventListenerXMLHttpRequest… )이나 Node.js의 IO 관련 함수들 등 모든 비동기 방식의 API들은 이벤트 루프를 통해 콜백 함수를 실행한다. 자, 그러면 다음과 아래와 같은 코드가 왜 에러를 잡아낼 수 없는지 이제는 확실히 알 수 있을 것이다.

$('.btn').click(function() { // (A)
  try {
    $.getJSON('/api/members', function (res) { // (B)
      // 에러 발생 코드
    });
  } catch (e) {
    console.log('Error : ' + e.message);
  }
});

위의 코드에서 버튼이 클릭되어 콜백 A가 실행될 때 $.getJSON 함수는 브라우저의 XMLHttpRequest API를 통해 서버로 비동기 요청을 보낸 후에 바로 실행을 마치고 호출 스택에서 제거된다. 이후에 서버에서 응답을 받은 브라우저는 콜백 B를 태스크 큐에 추가 하고 B는 이벤트 루프에 의해 실행되어 호출 스택에 추가된다. 하지만 이때 A는 이미 호출 스택에서 비워진 상태이기 때문에 호출 스택에는 B만 존재할 뿐이다. 즉 B는 A가 실행될 때와는 전혀 다른 독립적인 컨텍스트에서 실행이 되며, 그렇기 A 내부의 try-catch 문에 영향을 받지 않는다.

(마찬가지 이유로 에러가 발생했을 때 브라우저의 개발자 도구에서 호출 스택을 들여다봐도 B만 덩그라니 놓여있는 것을 볼 수 있을 것이다.)

(이런 이유로 Node.js의 비동기 API들은 중첩된 콜백 호출에 대한 에러 처리를 위해 ‘첫 번째 인수는 에러 콜백 함수’ 라는 컨벤션을 따르고 있다)

이를 해결하기 위해서는 콜백 B의 내부에서 try-catch를 실행해야 한다. (물론, 이렇게 해도 네트워크 에러나 서버 에러는 잡을 수 없다. 이를 위해서는 에러 콜백을 따로 제공해야 한다.)

$('.btn').click(function() { // (A)
  $.getJSON('/api/members', function (res) { // (B)
    try {
      // 에러 발생 코드
    } catch (e) {
      console.log('Error : ' + e.message);
    }
  });
});

setTimeout(fn, 0)

프론트엔드 환경의 자바스크립트 코드를 보다 보면 setTimeout(fn, 0)와 같은 코드를 종종 보게 된다. 관용적으로 쓰이는 코드이지만, 사실 처음 보는 사람에게는 직관적으로 이해하기 힘든 코드일 것이다. 0초 이후에 실행을 한다는 건 실제로 그냥 실행하는 것과 다를 게 없으니 말이다. 하지만 실제로 이 코드는 그냥 fn을 실행하는 것과는 상당히 다른 결과를 가져온다. 위의 예제에서도 보았겠지만 setTimeout 함수는 콜백 함수를 바로 실행하지 않고 (호출 스택이 아닌)태스크 큐에 추가한다. 그렇기 때문에 아래의 코드는 콘솔에 B -> A 순서로 출력하게 될 것이다.

setTimeout(function() {
  console.log('A');
}, 0);
console.log('B');

프론트엔드 환경에서는 렌더링 엔진과 관련해서 이런 코드가 특히 요긴하게 쓰일 때가 있다. 브라우저 환경에서는 자바스크립트 엔진뿐만 아니라 다른 여러 가지 프로세스가 함께 구동되고 있다. 렌더링 엔진도 그 중의 일부이며, 이 렌더링 엔진의 태스크는 대부분의 브라우저에서 자바스크립트 엔진과 동일한 단일 태스크 큐를 통해 관리된다. 이로 인해 가끔 예상치 못한 문제가 생길 경우가 있는데, 다음의 코드를 살펴보자.

$('.btn').click(function() {
  showWaitingMessage();
  longTakingProcess();
  hideWaitingMessage();
  showResult();
});

longTakingProcess가 너무 오래 걸리는 작업이기 때문에 그 전에 showWaitingMessage를 호출해서 로딩 메시지(‘로딩중…’과 같은)를 보여주려고 한다. 하지만 실제로 이 코드를 실행해 보면 화면에 로딩 메시지가 표시되는 일은 없을 것이다. 이유는 showWaitingMessage 함수의 실행이 끝나고 렌더링 엔진이 렌더링 요청을 보내도 해당 요청은 태스크 큐에서 이미 실행중인 태스크가 끝나기를 기다리고 있기 때문이다. 실행중인 태스크가 끝나는 시점은 호출 스택이 비워지는 시점인데, 그 때는 이미 showResult 까지 실행이 끝나 있을 것이고, 결국 렌더링이 진행되는 시점에는 hideWaitingMessgae로 인해 로딩 메시지가 숨겨진 상태일 것이다. 이를 해결하기 위해서 다음처럼 setTimeout를 사용할 수 있다.

$('.btn').click(function() {
  showWaitingMessage();
  setTimeout(function() {
    longTakingProcess();
    hideWaitingMessage();
    showResult();
  }, 0);
});

이 경우에는 longTakingProcess가 바로 실행되지 않고 태스크 큐에 추가될 것이다. 하지만 showWaitingMessage로 인해 태스크 큐에는 렌더링 요청이 먼저 추가되기 때문에 longTakingProcess는 그 다음 순서로 태스크 큐에 추가될 것이다. 이제 이벤트 루프는 태스크 큐에 있는 렌더링 요청을 먼저 처리하게 되고 로딩 메시지가 먼저 화면에 보여지게 된다.

꼭 렌더링 관련이 아니라도, 실행이 너무 오래 걸리는 코드를 setTimeout을 사용하여 적절하게 다른 태스크로 나누어 주면 전체 어플리케이션이 멈추거나 스크립트가 너무 느리다며 경고창이 뜨는 상황을 방지할 수도 있을 것이다.

한가지 짚고 넘어갈 사실은 ‘0’ 이라는 숫자가 실제로 ‘즉시’를 의미하지 않는다는 점이다. 브라우저는 내부적으로 타이머의 최소단위(Tick)를 정하여 관리하기 때문에 실제로는 그 최소단위만큼 지난 후에 태스크 큐에 추가되게 된다. 그리고 이 최소단위는 브라우저별로 조금씩 다른데, 예를 들어 크롬 브라우저의 경우 최소단위로 4ms 사용하기 때문에 크롬에서 setTimeout(fn, 0)은 setTimeout(fn, 4)와 동일한 의미를 갖게 될 것이다.

이런 문제를 해결하기 위해 setImmediate라는 API가 제안되었지만, 안타깝게도 표준의 반열에 오르지는 못하고 IE10 이상에만 포함되어 있다. 실제로 이 메소드는 setTimeout 와 같은 최소단위 지연이 없이 바로 태스크 큐에 해당 콜백을 추가한다. EsLint로 유명한 N.C.Zakas도 이 메소드가 표준화 되지 않은 것에 대해 비판하는 글을 올린 적이 있다. 비슷한 효과를 위해 postMessage 나 MessageChanel을 사용하기도 하는데, 관련된 내용은 setImmediate의 폴리필을 구현한 라이브러리 페이지에 잘 정리되어 있다.

(Node.js 에는 이런 용도를 위해 nextTick이라는 함수가 있지만 0.9버전 부터는 약간 다른 개념으로 사용된다. 다음 절에서 좀더 설명하겠다.)

프라미스(Promise)와 이벤트 루프

이런 이벤트 루프의 개념은 실제로 HTML 스펙에 정의되어 있다. 문서에서 이벤트 루프, 태스크 큐의 개념에 대해 잘 정의되어 있는 것을 볼 수 있을 것이다. 그런데 문서 중간에 마이크로 태스크(microtask) 라는 생소한 용어가 보인다. 이런… 이제 겨우 이벤트 루프에 대해 이해한 것 같은데 뭔가 상황이 더 복잡해질 것 같은 불길한 예감이 든다. 마음을 가다듬고, 다음 코드를 살펴보자.

setTimeout(function() { // (A)
  console.log('A');
}, 0);
Promise.resolve().then(function() { // (B)
  console.log('B');
}).then(function() { // (C)
  console.log('C');
});

콘솔에 찍히는 순서는 어떻게 될까? 프라미스도 비동기로 실행된다고 할 수 있으니 태스크 큐에 추가돼서 순서대로 A -> B -> C 가 될까? 아니면 프라미스는 setTimeout처럼 최소단위 지연이 없으니 B -> C -> A 일까? 체인 형태로 연속해서 호출된 then() 함수는 어떤 식으로 동작할까? 결론부터 말하자면 정답은 B -> C -> A 인데, 이유는 바로 프라미스가 마이크로 태스크를 사용하기 때문이다. 그럼 마이크로 태스크가 대체 뭘까?

마이크로 태스크는 쉽게 말해 일반 태스크보다 더 높은 우선순위를 갖는 태스크라고 할 수 있다. 즉, 태스크 큐에 대기중인 태스크가 있더라도 마이크로 태스크가 먼저 실행된다. 위의 예제를 통해 좀더 자세히 알아보자. setTimeout() 함수는 콜백 A를 태스크 큐에 추가하고, 프라미스의 then() 메소드는 콜백 B를 태스크 큐가 아닌 별도의 마이크로 태스크 큐에 추가한다. 위의 코드의 실행이 끝나면 태스크 이벤트 루프는 (일반)태스크 큐 대신 마이크로 태스크 큐가 비었는지 먼저 확인하고, 큐에 있는 콜백 B를 실행한다. 콜백 B가 실행되고 나면 두번째 then() 메소드가 콜백 C를 마이크로 태스크 큐에 추가한다. 이벤트 루프는 다시 마이크로 태스크를 확인하고, 큐에 있는 콜백 C를 실행한다. 이후에 마이크로 태스크 큐가 비었음을 확인한 다음 (일반) 태스크 큐에서 콜백 A를 꺼내와 실행한다. (이런 일련의 작업은 HTML 스펙에서 perform a microtask checkpoint 라는 항목에 명시되어 있다.)

잘 와 닿지 않는 분들은 이와 관련해서 인터랙션과 함께 아주 잘 정리된 글이 있으니 꼭 확인해 보길 바란다. 원문 글에서는 브라우저마다 프라미스의 호출 순서가 다른 문제를 지적하고 있는데, 이유는 프라미스가 ECMAScript에 정의되어 있는 반면, 마이크로 태스크는 HTML 스펙이 정의되어 있는데, 둘의 연관관계가 명확하지 않기 때문이다. (ECMAScript에는 ES6부터 프라미스를 위해 잡 큐(Job Queue)라는 항목이 추가되었지만, HTML 스펙의 마이크로 태크스와는 별도의 개념이다.) 하지만 최근에 Living Standard 상태인 HTML 스펙을 보면 자바스크립트의 잡큐를 어떻게 이벤트 루프와 연동하는지에 대한 항목이 포함되어 있다. 또한 현재는 대부분의 브라우저에서 해당 문제가 수정되어 있는 걸 확인할 수 있다.

(프라미스A+ 스펙문서의 Note를 보면 구현 시에 일반(macro) 태스크나 마이크로 태스크 둘 다 사용할 수 있다고 적혀 있다. 실제로 프라미스가 처음 자바스크립트에 도입되는 시점에는 프라미스를 어떤 순서로 실행할 것인가에 대한 논의가 꽤 있었던 것으로 보인다. 하지만 앞서 언급한 것처럼 현재는 프라미스를 마이크로 태스크라고 정의해도 무리가 없을 것 같다.)

휴우. 정리를 하고 다시 봐도 복잡해 보인다. 하지만, 실제로 마이크로 태스크이냐 일반 태스크이냐에 따라 실행되는 타이밍이 달라지기 때문에 둘을 제대로 이해하고 구분해서 사용하는 것은 중요하다. 예를 들어 마이크로 태스크가 계속돼서 실행될 경우 일반 태스크인 UI 렌더링이 지연되는 현상이 발생할 수도 있을 것이다. 관련해서 잘 정리된 스택오버플로우의 답변도 있으니 참고하면 좋을 것 같다.

마무리 하기 전에, 마이크로 태스크를 사용하는 다른 API들도 살짝 살펴보자.

  • MutationObserver는 DOM의 변화를 감지할 수 있게 해 주는 클래스이며, es6-promise와 같은 폴리필에서 마이크로 태스크를 구현하기 위해 사용되기도 한다.
  • 이전 절에서 살짝 언급했던 Node.js의 nextTick은 기존에는 일반 태스크를 이용해 구현되었지만, 0.9 버전부터 마이크로 태스크를 이용하도록 변경되었다.

마치며

이벤트 루프는 실제로 자바스크립트 언어의 명세보다는 구동 환경과 더 관련된 내용이기 때문에 다른 프로세스들(렌더링, IO 등)과 밀접하게 연관되어 있어 잘 정리된 자료를 찾기가 쉽지만은 않다. 또한 Node.js의 libuv는 HTML 스펙을 완벽히 따르지는 않기 때문에 브라우저 환경의 이벤트 루프와 상세 구현이 조금씩 다르다(심지어 브라우저 별로도 구현이 조금씩 다르다). 또한, 최근에는 ES6에 프라미스와 잡 큐라는 항목이 추가되며 마이크로 태스크의 개념과 혼동되며 이해하기가 한층 더 복잡해졌다. 여기서 끝이 아니다. 사실 이 글에서는 브라우저가 ‘단일 이벤트 루프’를 사용한다고 가정하고 설명했지만, 웹 워커(Web Worker)는 각각이 독립적인 이벤트 루프를 사용하며(Worker Event Loop라는 이름으로 구분되어 있다), 이와 관련된 내용을 추가한다면 더더욱 복잡해질 것이다. (하아…)

하지만 자바스크립트의 비동기적 특성을 잘 활용하기 위해서는 이벤트 루프를 제대로 이해하는 것이 중요하다. 특히 (이 글에서는 다루지 못했지만) 웹 워커나 Node.js의 클러스터를 사용하는 멀티 스레드 환경에서는 이벤트 루프에 대한 탄탄한 이해가 없다면 예상치 못한 버그 앞에 좌절하게 될 지도 모른다. 사실 개인적으로도 계속 스펙문서를 부분 부분 뒤져가며 글을 작성하느라 완벽하게 이해하고 정리하지는 못한 기분이다. 하지만 이 글이 조금이나마 도움이 되었기를 바라며, 여기서 만족하지 말고 관련 링크들을 짬짬이 살펴 보면서 이벤트 루프에 대해 제대로 이해하는 기회가 되었으면 좋겠다.

참고 링크

자바스크립트

ES6의 제너레이터를 사용한 비동기 프로그래밍

회사 블로그인 Toast Meetup 에 기고했던 글을 옮겨왔다. 원문은  http://meetup.toast.com/posts/73 에서 확인할 수 있다.

자바스크립트가 다른 언어들과 구분되는 큰 특징 중의 하나는 바로 싱글스레드를 기반으로 하는 비동기 방식의 언어라는 점이다. 이런 특징에 힘입어 Non-blocking IO을 사용하는 Node.js의 언어로 사용되면서 최근에는 서버사이드에서도 큰 인기를 얻고 있다. 하지만 이런 구조적 특징에서 오는 단점도 적지 않은데, 대표적인 것이 바로 연속적 전달 방식(CPS)으로 인한 콜백 지옥이다 (굳이 설명 안 해도 알 거라 믿는다. 밑에서 지옥을 한번 소환할 예정이니 혹시 몰라도 일단 넘어가자).

이 콜백 지옥을 해결하기 위해 많은 시도가 있었는데, 최근에 ES6에 프라미스(Promise)가 포함되면서 콜백 지옥의 문제를 상당 부분 완화할 수 있게 되었다. 하지만 많은 사람들이 기대하는 것과는 다르게 프라미스는 콜백 지옥을 해결하기 위해 나온 도구가 아니며, 단지 완화시킬 수 있는 방법을 제공해 줄 뿐이다. 그리고 상대적으로 주목을 덜 받고 있는 것 같지만, ES6에는 비동기 프로그래밍을 위한 더 중요한 도구가 있다. 바로 제너레이터(Generator)이다.

사실 제너레이터를 처음 접했을 때 받은 인상은 ‘좋은 기능인 거 같긴 한데… 이걸 어디다 쓰지?’ 정도의 느낌이었다. 하지만 최근에 개인 프로젝트에서 Koa를 사용하면서 제너레이터의 유용함에 대해서 눈을 뜨게 되었고, 좀 더 공부해 본 결과 비동기 프로그래밍에 있어서 아주 중요한 개념이라는 걸 알게 되었다. 심지어 이미 많은 곳에서 사용되고 있었다. (나만 몰랐던겨? ㅠㅠ) 그런 의미에서, 혹시나 아직까지 모르는 분들을 위해 최근까지 알게 된 내용을 정리해 보도록 하겠다.

제너레이터란?

제너레이터에 대해서는 예전에 FE개발팀 위클리에서 관련된 글 이터레이터와 제너레이터을 번역하여 공유한 적이 있다. 이 글에서 제너레이터의 상세 스펙에 대해서는 다루지 않을 예정이니, 제너레이터(혹은 이터레이터)에 대한 개념이 생소하신 분들은 먼저 앞의 링크를 눌러 내용을 숙지하면 좋을 것 같다.

여기서는 몇 가지 개념만 짚고 넘어가도록 하겠다. 제너레이터는 함수의 실행을 중간에 멈추었다가 필요한 시점에 다시 재개할 수 있다. 일종의 코루틴(Coroutine) 이라고 볼 수 있는데, 이 위키 페이지에도 설명되어 있듯이, 코루틴과는 다르게 멈출 때 돌아갈 위치를 직접 지정할 수 없고, 단순히 호출자에게 제어권을 넘겨주게 된다(그래서 세미-코루틴이라 불린다). 아래 예제를 보자.

function* myGen() {
  yield 1;
  yield 2;
  yield 3;
  return 4;
}

const myItr = myGen();
console.log(myItr.next());  // {value:1, done:false}
console.log(myItr.next());  // {value:2, done:false}
console.log(myItr.next());  // {value:3, done:false}
console.log(myItr.next());  // {value:4, done:true}

myGen 제너레이터는 실행될 때 이터레이터를 반환한다. 그리고 이터레이터의 next() 함수가 호출될 때마다 호출되는 곳의 위치를 기억해둔 채로 실행된다. 그리고 함수 내부에서 yield를 만날 때마다 기억해둔 위치로 제어권을 넘겨준다. 이런 식으로 next() -> yield -> next() -> yield 라는 순환 흐름이 만들어 지고, 이 흐름을 따라 해당 함수가 끝날 때까지 (return을 만나거나 마지막 라인이 실행될 때까지) 진행된다.

여기서 중요한 점은 next()와 yield가 서로 데이터를 주고받을 수 있다는 점이다. 위의 예제에서 볼 수 있듯이 yield 키워드 뒤의 값은 next() 함수의 반환값으로 전달된다 (정확히는 value 프라퍼티의 값으로). 그럼 반대로 호출자가 제너레이터에게 값을 전달할 수도 있을까? 물론 가능하다. next()를 호출할 때 인수를 넘기면 된다. 다음의 예제를 보자.

function *myGen() {
    const x = yield 1;       // x = 10
    const y = yield (x + 1); // y = 20
    const z = yield (y + 2); // z = 30
    return x + y + z;
}

const myItr = myGen();
console.log(myitr.next());   // {value:1, done:false}
console.log(myitr.next(10)); // {value:11, done:false}
console.log(myitr.next(20)); // {value:22, done:false}
console.log(myitr.next(30)); // {value:60, done:true}

next()를 호출할 때 인수로 값을 지정하면 yield 키워드가 있는 대입문에 값이 할당되는 것을 볼 수 있다. 이런 식으로 제너레이터와 호출자는 서로 제어권 뿐만 아니라 데이터까지 주고받을 수 있다. 자, 여기까지 알고 나서 다시 myGen 내부를 들여다보자. 분명 함수의 내부에서는 콜백도 없고 프라미스도 없지만, 비동기적으로 데이터를 주고받으며 실행되고 있다. 이 무슨 비동기인 듯 비동기 아닌 비동기 같은 코드인가.

왠지 이쯤 되면 느낌이 오시는 분들이 있을 것이다. 뭔가… 이걸 잘 활용하면 콜백 지옥을 넘어서 신세계를 경험할 수 있을 것만 같은 느낌적 느낌말이다. (음… 나만 그런가?)

콜백 지옥 소환

이 느낌적 느낌을 직접 동작하는 코드로 승화시키기 위해 아주 간단하고 비효율적인 커피 주문 시스템을 만들어 보기로 하자. 우선 이 시스템은 굉장히 비효율적이라서, 핸드폰 번호를 알아야 아이디를 알 수 있고, 아이디를 알아야 이메일을 알 수 있고, 이메일을 알아야 이름을 알 수 있고, 이름을 알아야만 주문을 할 수 있다(뭐 이런 슬픈 시스템이…ㅠㅠ). 슬프지만 그냥 예제니까 가벼운 마음으로 코드를 작성해 보자.

function getId(phoneNumber) { /* … */ }
function getEmail(id) { /* … */ }
function getName(email) { /* … */ }
function order(name, menu) { /* … */ }

function orderCoffee(phoneNumber) {
    const id = getId(phoneNumber);
    const email = getEmail(id);
    const name = getName(email);
    const result = order(name, 'coffee');
    return result;
}

이렇게 간단하게 할 수 있으면 얼마나 좋을까. 하지만 상황을 더 슬프게 만들기 위해 각각의 데이터들을 외부 네트워크에 있는 서버에서 받아와야 한다고 가정해 보자(아… 왜… ㅠㅠ). 싱글 스레드인 자바스크립트에서 네트워크 요청을 위해 이런 코드를 짠다면 하루에 커피 100잔도 못팔고 망하는 수가 있다. 지금이 바로 비동기 방식의 진가를 발휘하기 위해 콜백 지옥을 소환할 때다.

function getId(phoneNumber, callback) { /* … */ }
function getEmail(id, callback) { /* … */ }
function getName(email, callback) { /* … */ }
function order(name, menu, callback) { /* … */ }

function orderCoffee(phoneNumber, callback) {
    getId(phoneNumber, function(id) {
        getEmail(id, function(email) {
            getName(email, function(name) {
                order(name, 'coffee', function(result) {
                    callback(result);
                });
            });
        });
    });
}

쨘. 지옥을 소환하는 게 이렇게 쉽다니 (지옥인데 친숙하다는 게 더 슬프다). 참고로 여기서 콜백의 문제점은 사실 단순히 들여쓰기와 가독성의 문제만은 아니다. 더 중요한 문제점은 콜백함수를 다른 함수로 전달하는 순간 그 콜백함수에 대한 제어권을 잃는 점이다. 즉, 내가 제공한 콜백이 언제 실행되는지, 몇 번 실행되는지 등에 대해 신뢰할 수가 없게 된다. 그리고 위의 코드에서 보다시피 내가 처음에 제공한 콜백 함수는 한없이 위임되어 저 지옥 구멍의 끝에 파묻혀 있다 (왜 거기있니 얘야…ㅠㅠ). 이로 인해 프로그램이 더 예측하기 어렵게 되고 에러가 발생하기 쉽게 되며, 디버깅 또한 만만치 않게 된다.

프라미스의 구원

하지만 알다시피, 프라미스의 등장으로 인해 이러한 문제는 상당 부분 완화되었다. 위의 슬픈 예제를 달래기 위해 프라미스로 보듬어 보자. 일단 모든 getXXX 함수에서 콜백 파라미터를 제거하고, 실행 결과로 프라미스를 반환한다고 가정하자.

function getId(phoneNumber) { /* … */ }
function getEmail(id) { /* … */ }
function getName(email) { /* … */ }
function order(name, menu) { /* … */ }

function orderCoffee(phoneNumber) {
    return getId(phoneNumber).then(function(id) {
        return getEmail(id);
    }).then(function(email) {
        return getName(email);
    }).then(function(name) {
        return order(name, 'coffee');
    });
}

일단 가독성이 한결 나아 보인다. 뿐만 아니라, 이제 해당 함수가 처리를 성공적으로 완료했을 경우 항상 then()에 넘겨진 함수가 단 한번 실행될 거라는 신뢰가 생겼다. 어마어마한 발전이다. 여기서 만족하지 말자. 프라미스를 쓸 수 있다면 ES6의 시대에 살고 있을 테니 Arrow 함수를 써서 좀 더 세련되게 만들어 보도록 하겠다.

function orderCoffee(phoneNumber) {
    return getId(phoneNumber)
        .then(id => getEmail(id))
        .then(email => getName(email))
        .then(name => order(name, 'coffee'));
}

후후. 이 맛에 Arrow 함수를 쓴다. 이제 기존의 콜백을 사용한 코드와 비교하면 훨씬 나아 보인다. 오, 프라미스. 콜백의 구원자여!

자, 이 정도면 된 것 같은데… 과연 더 이상 나아질 곳이 있는 걸까?

비동기 코드를 동기식 코드처럼 작성하기

잠시 마음을 가라앉히고, 제일 처음에 우리가 작성했던 코드를 보자.

function orderCoffee(phoneNumber) {
    const id = getId(phoneNumber);
    const email = getEmail(id);
    const name = getName(email);
    const result = order(name, 'coffee');
    return result;
}

그리고 다시 프라미스로 만든 세련된(?) 코드를 살펴보자. 자, 둘을 비교했을 때 어떤 코드가 더 이해하기 쉬울까? 당연하게도 바로 위의 코드가 훨씬 직관적이고 알아보기 쉽다. 잠시 프라미스의 능력에 감복하고 있었지만 냉정하게 말하면 이렇게 할 수가 없으니깐 차선을 선택했을 뿐이다(아… 프라미스 어쩔 ㅠㅠ). 그리고 이렇게 할 수 없는 이유는 아까도 말했듯이 자바스크립트가 싱글-스레드 기반의 언어이기 때문이다. 당연한 얘기지만, 각각의 네트워크 요청이 값을 반환하기 전까지 프로그램 전체가 멈춰서 대기를 해야 한다면 이 프로그램은 너무 느려서 사용할 수가 없을 것이다.

하지만 여기서 제너레이터를 활용한다면 어떨까? 아까 보았던 “비동기인 듯 비동기 아닌 비동기 같은 코드”를 떠올려 보자. 제너레이터는 함수를 실행 도중에 멈추고 제어권을 다른 곳으로 넘겨줄 수 있고 값도 전달할 수 있다. 그렇다면 전체 프로그램을 멈추지 않고도 이런 방식의 코드를 작성할 수 있지 않을까?

한번 시도해 보자. 우선 간단하게 기존의 함수 선언에 * 을 추가해서 제너레이터로 변경하고, 각 할당문에 yield를 추가해 보도록 하자. 다음과 같은 모습이 될 것이다.

function* orderCoffee(phoneNumber) {
    const id = yield getId(phoneNumber);
    const email = yield getEmail(id);
    const name = yield getName(email);
    const result = yield order(name, 'coffee');
    return result;
}

오!! 그럴듯하다. 딱 원하던 그대로이다. 정말 이게 되는 걸까?

하지만 세상에 쉽게 얻어지는 것어 어디 있으랴. yield를 통해 실행을 멈추고 제어권을 넘겨준 것 까지는 좋았지만, getId()가 작업을 완료하는 순간 다시 반환값과 함께 제어권을 가져오려면 누군가가 이터레이터의 next() 함수를 호출해 주어야만 할 것이다. 현재로서는 작업을 완료하는 시점을 알고 있는 getId() 함수 내부에서 직접 호출할 수 밖에 없는데, 그렇게 되면 이터레이터와의 밀접한 의존도가 생기게 된다. 즉 다음과 같이 데이터를 반환하는 모든 함수의 마지막에 next() 를 호출하는 코드가 추가되어야 할 것이다.

const iterator = orderCoffee('010-1234-1234');
iterator.next();

function getId(phoneNumber) {
    // …
    iterator.next(result);
}

function getEmail(id) {
    // …
    iterator.next(result);
}

function getName(email) {
    // …
    iterator.next(result);
}

function order(name, menu) {
    // …
    iterator.next(result);
}

(실제로 iterator.next()는 등의 함수 내부에서 의존하고 있는 사용하는 라이브러리에 따라 콜백 형식으로 호출되겠지만, 여기서는 설명을 위해 단순하게 제일 아랫줄에 추가했다.)

아… 이래서는 제너레이터에서 범용적인 함수를 사용할 수가 없다. 또한 콜백 방식과는 또 다른 의미로 제너레이터가 본인의 제어권을 상실했다(제어권을 넘겨드릴게요. 하지만… 돌려주실 건가요? ㅠㅠ). 왠지 콜백보다 더 나빠진 기분이다. 대실망. 구원자인 줄 알았건만. 제너레이터, 넌 결국 계륵이었니.

제너레이터와 프라미스의 만남

하지만 여기서 끝이 아니다. 아까 아주 잠깐 홀대했던 프라미스를 좀 달래서 도움을 요청해 보자(프라미스, 아깐 미안… 도와줘). 만약 모든 함수가 프라미스를 반환한다면 각각의 함수가 제어권을 직접 다루지 않고, 제3자에게 위임할 수 있지 않을까? 시도해 보자. 우선 프라미스의 예제에서처럼 모든 getXXX 함수는 프라미스를 반환한다고 가정하자. 이제 누군가가 이터레이터를 생성해서 함수가 끝날때까지 반복해서 실행시켜 주면 된다.

const iterator = orderCoffee('010-1010-1111');
let ret;

(function runNext(val) {
    ret = iterator.next(val);

    if (!ret.done) {
        ret.value.then(runNext);
    } else {
        console.log('result : ', ret.value);
    }
})();

코드가 살짝 복잡해 보이니 잠깐 살펴보겠다. 이터레이터를 생성해서 next()를 실행하면 결과의 value 값으로 프라미스를 반환하고, 프라미스의 then() 메서드에서 다시 이터레이터의 next() 함수를 실행한다. 이런 식으로 이터레이터가 done:true를 반환할 때까지 순환하면서 호출하게 된다. 즉,  next() -> yield -> then() -> next()의 순환흐름에 따라 실행되는 것이다.
(runNext() 함수가 재귀적으로 호출되고 있다. 만약 흐름이 잘 이해가 안 가면 위의 제너레이터 예제를 참고해 가며 살펴보길 바란다.)

자, 결과는?? 된다!! 프라미스와 제너레이터를 함께 사용하면 각각의 함수에서 제너레이터를 신경 쓰지 않고도 외부에서 제어할 수가 있다. 이제 제너레이터를 활용하여 비동기인 코드를 마치 동기식 코드인 것처럼 작성할 수 있는 길이 열린 것이다!! (제너레이터, 아깐 미안…)

여기서는 코드를 단순하게 만들기 위해 예외처리와 같은 작업들이 생략되었지만, 여기서 조금만 더 코드를 발전시키면 범용적으로 쓸 수 있는 함수를 만들어 낼 수 있을 것이다. 좀 더 욕심을 부려서 제너레이터의 실행결과로 프라미스를 반환하도록 하면, 좀 더 유용하게 사용할 수도 있을 것 같다 (위의 코드를 보면 알겠지만, 지금 상태에선 반환값을 전달할 수가 없다). 하지만 신이 난 김에 계속 달려가지 말고 잠깐 생각을 해 보자. 이렇게 좋은 기능이라면 당연히 누군가가 이미 만들어 놓은 라이브러리가 있지 않을까? 바야흐로 오픈소스의 시대. 어디든 금손이가 존재하는 시대. 외쳐보자 npm!

co

(npm이 응답하셨다.)

$ npm install co

당연한 얘기지만, 이미 이러한 기능을 구현해 놓은 라이브러리가 여럿 있다. 그중 가장 널리 쓰이는(것 같은…) co를 살펴보자. co는 200 라인 정도 밖에 안되는 아주 작은 라이브러리인데(이름도 귀엽다. 코-라니), 제너레이터를 쉽게 사용할 수 있는 아주 편리한 두 가지 함수를 제공한다. 먼저, 다음과 같이 co 함수에 제너레이터를 인수로 넘기면 제너레이터를 마지막까지 실행하고, 실행결과로 프라미스를 반환해 준다.

co(function* () {
    const id = yield getId('010-1234-5678');
    const email = yield getEmail(id);
    const name = yield getName(email);
    return yield order(name, 'coffee');
}).then(result => {
    console.log(result);
});

이렇게 쉽다니. 굳이 번거롭게 제너레이터를 직접 제어하지 않아도 된다. 한걸음 더 나아가 wrap 함수를 사용하면 제너레이터 함수를 프라미스를 반환하는 함수로 변환할 수도 있다.

const orderCoffee = co.wrap(function *() {
    const id = yield getId('010-1234-5678');
    const email = yield getEmail(id);
    const name = yield getName(email);
    return yield order(name, 'coffee');
});

orderCoffee.then(result => {
    console.log(result);
});

이제 이렇게 생성된 함수를 또 다른 제너레이터가 yield로 사용할 수 있을 것이다. 바야흐로 프라미스로 대동단결! 또한 co 에서는 프라미스뿐만 아니라 함수, 배열, 객체, 제너레이터 등을 모두 yield 할 수 있다 (자세한 내용은 co의 README 페이지에서 확인하자).

한가지 더 보너스가 있다. 에러 핸들링은? 서… 설마? 그렇다. 그 설마다. 콜백의 세계에서 소외받고 있던 그리운 옛 친구 try/catch를 다시 불러올 수 있다. (안녕. 오랜만이야 ㅠㅠ)

co(function* () {
    let result = false;
    try {
        const id = yield getId('010-1234-5678');
        const email = yield getEmail(id);
        const name = yield getName(email);
        result = yield order(name, 'coffee');
    } catch(err) {
        console.log('이 또한 지나가리라…', err); // 에러처리 로직
    }
    return result;
}).then(result => {
    console.log(result);
});

co 이외에도 프라미스를 확장한 bluebird 의 coroutine() 혹은 node-fibers를 사용한 asyncawait 같은 라이브러리도 있으니, 관심 있으신 분들은 한 번씩 둘러보길 바란다.

Koa

사실 글의 첫 부분에서 말했듯이, 이 글을 처음 쓰게 된 동기는 바로 Koa였다. Koa는 Express의 업그레이드 버전이라고 보면 되는데, Express와 동일한 기능이 제너레이터 기반으로 작성되었기 때문에 아주 편하게 비동기 코드를 작성할 수 있다. 예를 들어 koa-router를 사용해서 특정 URL을 핸들링 할 때 제너레이터를 사용해서 다음과 같이 작성할 수 있다.

router.post('/login', function*() {
    const {email, password} = this.request.body;
    const user = yield userDB.get(email);
    const valid = yield crypter.compare(password, user.password);
  //     …
});

저기 저 yield의 자태를 보라. Koa가 제너레이터를 기반으로 미들웨어에 대한 제어를 해 주기 때문에, 우리는 굳이 다른 것 신경 쓸 필요 없이 yield 를 사용해서 아주 간편하게 비동기 코드를 작성할 수 있다. 기존 Express의 router를 사용할 때와 비교해 보면 차이를 알 수 있을 것이다.

router.post('/login', function(req, res, next) {
    const {email, password} = req.body;
    return userDB.get(email)
        .then(user => crypter.compare(password, user.password)
        .then(valid => {
            // …
            next();
        });
});

단순히 function 뒤에 * 하나 추가했을 뿐인데 코드가 이렇게 달라진다. 여기에 예외처리를 더한다고 생각해보자(아… 아, 앙돼!). 이쯤 하면 인정. Koa 승.

Node.js의 변절자로 불리는 TJ가 바이바이- Node.js 하실 때 계속 메인테인 하겠다고 언급했던 프로젝트가 바로 Koa이다 (co도 마찬가지). 차마 버릴 수 없는 그런 매력덩어리란 의미이지 않을까.

One More Thing

사실 여기서 끝내도 멋진 마무리가 될 것 같지만, 정말 그냥 넘어가기 힘든 “원 모어 띵”이 있다. 제너레이터를 제어하는 게 번거로워서 싫다면. 근데 그냥 막 왠지 이유 없이 외부 라이브러리는 싫다면. 아님 co.wrap(function *() { 문법이 못생겨서 싫다면. C# 개발자들이 “우리는 async await하면 땡인데?” 하고 놀린다면. 이제 당당하게 말하자. 자바스크립트도 된다고.

async function orderCoffee(phoneNumber) {
    const id = await getId(phoneNumber);
    const email = await getEmail(id);
    const name = await getName(email);
    return await order(name, 'coffee')
}

orderCoffee('011-1234-5678').then(result => {
    console.log(result);
});

위의 co.wrap 을 이용한 코드에서 달라진 점은 co.wrap(function *() -> async function, 그리고 yield -> await 두 가지뿐이다. 나머지는 프라미스를 반환하는 것까지 동일하다. 이제 라이브러리 도움 없이도 이런 식의 코드를 작성할 수가 있다. 오!! (이런 것까지 바란 건 아닌데… 그래도 주신다면 감사 감사 덥썩) 이 async-await가 바로 자바스크립트 개발자들이 눈빠지게 기다리고 있는 바로 그 스펙이다. 현재 Stage 3 단계로써 아직 표준의 반열에 올라서진 못했지만, 이미 regenerator 같은 라이브러리에 구현되어 있으며, Babel이나 TypeScript 같은 트랜스파일러에서도 지원하고 있다. 그리고 위에서 Koa의 링크를 타고 문서를 확인해 보신 분들은 이미 알겠지만, Koa도 2.0에서부터 async/await 기반으로 완전히 변경될 예정이다(이미 개발은 완료되었지만 node.js가 정식 지원하기 전까지 Alpha를 달고 있을 것으로 보인다).

이미 마음만 먹으면 얼마든지 사용할 수 있다는 얘기다. 단지 아직 표준이 아니라는 것을 염두에 두고, 선택과 그에 따른 책임은 각자의 몫이다.

마치며

지금까지 자바스크립트에서 제너레이터로 비동기 프로그래밍을 하는 방법에 대해 알아보았다. 제너레이터와 프라미스를 같이 사용하면 비동기 코드를 마치 동기식 코드를 작성하는 것처럼 작성할 수 있다. 실제 사용해보면 알겠지만, 복잡한 비동기 코드를 다룰 때 이를 활용하면 이전과 비교할 수 없이 편하게 코드를 작성할 수 있을 것이다. (그리고 많은 자바스크립트 개발자들이 async-await가 하루빨리 표준스펙이 되길 기다리고 있다.)

쓰고 보니 무슨 ‘찬양하라 제너레이터’ 정도가 되어 버린 것 같지만, 프라미스가 만병통치약이 아니듯이 제너레이터도 마찬가지다. 모두가 아는 사실이지만 (그리고 자주 까먹는 사실이지만), 프로그래밍에서 만병 통치약(a.k.a 은총알)은 없다. 콜백도 잘 쓰면 약이 될 것이고, 제너레이터도 못 쓰면 독이 될 것이다. 훌륭한 도구가 주어지면 그것을 상황에 맞게 잘 활용하는 것은 프로그래머의 몫이다. 그리고 훌륭한 도구가 있는데도 사용하지 않고 방치해 둔다면 그 또한 프로그래머의 도리가 아닐 것이다. 이제, 새로운 도구를 손에 넣었으니 마음껏 활용해 보자 🙂