자바스크립트

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

이 글은 모질라 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

프로그래밍

[번역] 일급(First-Class) 테스트

Clean Code 블로그의 “First-Class Tests” 라는 글을 번역했다. 글을 참 짖궂게 쓴다. 개인적으로 엉클밥을 참 좋아하는데, 요즘은 뭔가 TDD교를 신봉하는 꼰대 아저씨의 느낌이 나려고 하는 것 같다. 하지만 TDD에 대한 이론이나 경험에 있어 엉클밥만큼 신뢰할만한 사람은 몇 안되지 않는다고 생각하고, 개인적으로 이 글을 통해 테스트에 대한 개념을 정리하는 데에 많은 도움을 받았다고 생각한다.

사실 프론트엔드 개발자로서, 나는 최근에 TDD에 대해 조금 회의적인 생각이 들기 시작했다. 좀더 균형잡힌 시각을 위해서는 이 글 뿐만 아니라 이 글의 소재가 된 블로그 글, 그리고 거기에 링크되어 있는 “Why most unit testing is a waste“도 읽어보면 도움이 될 것이다.

이 주제에 대해서는 조만간 생각이 정리되면 포스팅을 하도록 하겠다.


 

부적절한 학습의 희생양이 되어 단위 테스트를 포기하게 되는 사람들의 블로그를 찾아내는 건 어쩌면 나의 숙명일지도 모르겠다. 이 블로그도 바로 그 중의 하나이다.

저자는 모든 협력 객체(collaborator)를 모킹함으로 인해 단위 테스트가 얼마나 깨지기 쉬운 상태가 되었는지를 이야기한다. (한숨). 협력 객체가 변경될 때마다 모의(mock) 개체들이 변경되어야만 한다. 그리고 당연히 그로인해 단위 테스트는 깨지기 쉬운 상태가 된다.

더 나아가서 저자는 어떻게 단위 테스트를 버리고 대신 흔히들 말하는 “시스템 테스트”를 시작했는지에 대해서 이야기한다. 그의 어휘에 따르면 “시스템 테스트”는 단순히 “단위 테스트”보다 좀더 종단간 (end-to-end) 에 가까운 테스트이다.

자 먼저, 몇가지 정의를 내려보자. 거만하게 굴어 미안하지만, “단위 테스트”, “시스템 테스트”, “인수 (acceptance) 테스트” 등에 대해서는 수많은 정의가 존재하기 때문에, 누군가가 하나의 권위있는 정의를 제공해야만 할 것 같다. 이들 정의가 정착될지는 모르겠지만, 가까운 미래에 이 중의 몇가지는 그렇게 되길 바란다.

  •  단위 테스트 : 프로덕션 코드가 프로그래머가 기대하는 대로 동작하는지를 보장하기 위해 프로그래머가 작성하는 테스트 코드이다. (단위 테스트가 디자인을 도와준다는 등의 개념은 잠시 무시하기로 하겠다)
  • 인수 테스트 : 프로덕션 코드가 사업자가 기대하는 대로 동작하는지를 보장하기 위해 사업자가 작성하는 테스트이다. 이 테스트의 작성자는 사업부 인력이거나, 혹은 사업부를 대표하는 기술 인력이다. (예: 사업 분석가, QA)
  • 통합 테스트 : 시스템 컴포넌트들의 하부 조합(sub-assembly)이 제대로 작동하는지를 보장하기 위해 아키텍트나 기술 리더가 작성하는 테스트이다. 이 테스트는 Plumbing 테스트이다. (역: 기술적인 테스트라는 의미인 듯). 사업 규칙에 대한 테스트가 아니다. 이 테스트의 목적은 하부 조합이 제대로 통합되고 설정되었는지를 확인하는 것이다.
  • 시스템 테스트 : 통합된 모든 시스템의 내부가 계획한 대로 동작하는지를 보장하기 위해 작성하는 통합 테스트이다. 이 테스트는 Plumbing 테스트이다. 사업 규칙에 대한 테스트가 아니다. 이 테스트의 목적은 시스템이 제대로 통합되고 설정되었는지를 확인하는 것이다.
  • 마이크로 테스트 : Mike Hill (@GeePawHill)에 의해 만들어진 용어이다. 아주 작은 범위에서 작성된 단위 테스트이다. 단일 함수나 작은 그룹의 함수들을 테스트하기 위해서 작성한다.
  • 기능(Functional) 테스트 : 넓은 범위에서 작성된 단위 테스트이며, 느린 컴포넌트에 대한 모의 객체를 포함한다.

이들 정의를 통해 보면, 위 블로그의 저자는 잘못 작성된 마이크로 테스트를 포기하고, 대신에 잘못 작성된 기능 테스트를 작성했다. 왜 잘못 작성되었을까? 왜냐하면 저자의 설명에 비춰볼 때, 두가지 경우 모두 테스트들이 결합되어서는 안되는 것들과 결합되었기 때문이다. 그는 마이크로 테스트에서 너무 많은 모의 객체를 사용하고 있었고, 테스트 코드가 프로덕션 코드의 구현과 깊이 결합되어 있었다. 그러면 당연히 깨지기 쉬운 테스트가 될 수 밖에 없다.

기능 테스트를 살펴보면, 저자는 이들 테스트를 UI부터 데이터베이스에 이르기까지를 모두 포함하는 것처럼 설명하면서, 테스트가 느렸다는 사실을 언급했다. 그는 테스트가 가끔 15분내에 실행된다는 사실에 기뻐했다. 15분은 단위 테스트가 제공해야 하는 즉각적인 피드백을 위해서는 너무 긴 시간이다. TDD 개발자들에게 지속적 빌드 시스템이 테스트가 통과되었는지를 확인해줄 때까지 기다리는 습관같은 건 없다.

숙련된 TDD 개발자들은 마이크로 테스트이든 기능 테스트이든 (인수 테스트 또한 마찬가지로) 시스템의 구현과 결합되어서는 안된다는 것을 잘 알고 있다. 이들 테스트는 (위 블로그 저자가 강조했듯이) 시스템의 일부로써 어겨져야 하며, “일급 시민처럼 다루어야 한다 : 프로덕션 코드를 다루는 방식으로 다루어야 한다“.

위 블로그 저자는 시스템의 일급 시민은 결합되어서는 안된다는 점은 알지 못했던 것 같다. 자신의 테스트를 일급 시민처럼 다루는 사람은 이들 테스트가 프로덕션 코드와 강하게 결합되지 않도록 하기 위해 엄청난 노력을 기울인다.

마이크로 테스트와 기능 테스트를 프로덕션 코드로부터 감결합(decoupling) 시키는 것은 특별히 어려운 일이 아니다. 몇가지 소프트웨어 디자인 기술과 몇가지 감결합 기법에 대한 지식이 있으면 된다. 일반적으로 좋은 객체지향 디자인과 의존성 역전, 그리고 몇가지 디자인 패턴(파사드 혹은 전략 패턴과 같은)의 신중한 사용 정도면 대부분의 해로운 테스트들을 감결합 시키기에 충분하다.

불행하게도, 너무나 많은 프로그래머들이 단위 테스트에 적용되는 규칙은 다르며, 편한대로 급하게 만든 “쓰레기 코드”라도 상관없다고 생각한다. 또한, 너무나 많은 프로그래머들이 모의 객체에 대한 책을 읽고 모의 객체 도구들이 본질적이고 필요하며 단위 테스트의 일부라는 관념을 믿어오고 있다. 진실과는 한없이 멀게도 말이다.

내 경우에는 모의 객체 도구를 거의 사용하지 않는다. 만약 모의 객체(혹은 오히려 테스트 대역(Test Double))가 필요할 때면, 직접 작성한다. 테스트 대역을 작성하는 것은 그리 어려운 일이 아니다. IDE가 많은 도움을 준다. 게다가 테스트 대역을 스스로 작성하는 것은 정말 필요한 경우가 아니면 테스트 대역을 작성하지 않도록 도와준다. 테스트 대역을 사용하는 대신, 나는 마이크로 테스트에서 살짝 물러나서 좀더 기능 테스트에 가까운 테스트를 작성한다. 이 또한 내가 프로덕션 코드의 내부와 테스트 코드 사이의 결합을 감소시키는 것을 도와준다.

결국, 사람들이 단위 테스트를 포기하는 이유의 대부분은 위의 저자가 말한 조언을 따르지 않았기 때문이다. 그들은 테스트를 일급 시민으로 다루지 않았다. 그들은 테스트를 시스템의 일부인 것처럼 다루지 않았다. 그들은 다른 시스템에 적용하는 것과 동일한 기준을 갖고 테스트를 관리하지 않았다. 대신에, 그들은 테스트가 부패하고 결합되고 굳어가고 깨지기 쉽고 느리게 되도록 방치해왔다. 그리고 나서, 그들은 좌절하며 테스트를 포기한다.

교훈 : 테스트를 깔끔하게 유지하라. 테스트를 시스템의 일급 시민처럼 다루어라.

자바스크립트

[번역] 자바스크립트의 재귀, 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 프로젝트에서 훌륭한 해결책이 될 것이라 기대한다.

프로그래밍

[번역] 클라이언트 측의 저장소 살펴보기

원문 : https://bitsofco.de/an-overview-of-client-side-storage/

브라우저 내부에 직접 데이터를 저장하게 되면 많은 장점이 있다. 가장 중요한 장점은 네트워크 없이도 빠르게 “데이터베이스”에 접근할 수 있다는 점이다. 클라이언트 측에 데이터를 저장하는 방식은 현재 다음의 4가지가 있다. (하나가 더 있지만 디프리케이트 되었다)

  1. 쿠키 (Cookies)
  2. 로컬 저장소 (Local Storage)
  3. 세션 저장소 (Session Storage)
  4. IndexedDB
  5. WebSQL (deprecated)

쿠키

쿠키는 문서 내부에 간단한 문자열 데이터를 저장하는 고전적인 방식이다. 일반적으로 쿠키는 서버에서 클라인트로 전송되어 저장되었다가, 이후의 요청을 보낼 때 서버로 다시 전송된다. 쿠키는 계정의 세션을 관리하거나 사용자의 정보를 추적하는 데에 사용될 수 있다.

뿐만 아니라 쿠키는 순수하게 클라이언트 측에서 데이터를 저장하기 위해서도 사용될 수 있다. 이런 이유로 쿠키는 사용자 설정값 등의 데이터를 저장하는 데에 사용되어 왔다.

쿠키를 이용한 기본 CRUD

다음의 문법을 이용해서 쿠키를 생성/읽기/변경/삭제할 수 있다.

// 생성
document.cookie = "user_name=Ire Aderinokun";  
document.cookie = "user_age=25;max-age=31536000;secure";

// 읽기 (전체)
console.log( document.cookie );

// 변경
document.cookie = "user_age=24;max-age=31536000;secure";

// 삭제
document.cookie = "user_name=Ire Aderinokun;expires=Thu, 01 Jan 1970 00:00:01 GMT";

쿠키의 장점

  • 서버와의 커뮤니케이션을 위해 사용될 수 있다.
  • 직접 삭제하지 않아도, 원하는 시기에 자동으로 만기(expire) 되도록 설정할 수 있다.

쿠키의 단점

  • 페이지 부하를 증가시킨다
  • 적은 양의 데이터만 저장할 수 있다
  • 문자열만 저장할 수 있다
  • 잠재적인 보안 이슈가 있다
  • 웹 저장소 API (로컬 저장소와 세션 저장소)가 소개된 이후로는 더이상 클라이언트 측의 저장소로 권장되지 않는다

지원

모든 메이저 브라우저에서 기본적으로 지원된다.

로컬 저장소

로컬 저장소는 웹 저장소 API 중 한가지 형식으로, 브라우저 내부에 키-값 쌍을 저장하기 위한 API이다. 로컬 저장소는 단순한 데이터를 저장하기 위해 쿠키보다 더 직관적이고 안전한 API를 제공함으로써, 쿠키의 문제들을 해결하기 위해 만들어졌다.

기술적으로는 로컬 저장소에 오직 문자열만을 저장할 수 있지만, JSON 데이터를 문자열로 변환하여 저장하면 이를 보완할 수 있다. 이는 로컬 저장소에 쿠키로 할 수 있는 것 보다 조금 더 복잡한 데이터를 저장할 수 있도록 해 준다.

로컬 저장소를 이용한 기본 CRUD

다음의 문법을 이용해서 로컬 저장소에 데이터를 생성/읽기/변경/삭제할 수 있다.

// 생성
const user = { name: 'Ire Aderinokun', age: 25 }  
localStorage.setItem('user', JSON.stringify(user));

// 읽기 (단일)
console.log( JSON.parse(localStorage.getItem('user')) )

// 변경
const updatedUser = { name: 'Ire Aderinokun', age: 24 }  
localStorage.setItem('user', JSON.stringify(updatedUser));

// 삭제
localStorage.removeItem('user');

로컬 저장소의 장점

  • 데이터를 저장하기 위한 (쿠키보다 더) 단순하고 직관적인 인터페이스를 제공한다
  • (쿠키보다 더) 안전한 클라이언트 측의 저장소이다
  • (쿠키보다 더) 많은 데이터를 저장할 수 있다

로컬 저장소의 단점

  • 오직 문자열만 저장할 수 있다

지원

Web Storage API Browser Support

출처 : http://caniuse.com

세션 저장소

세션 저장소는 웹 저장소 API의 두번째 형식이다. 세션 저장소는 데이터가 오직 브라우저의 탭 세션에만 저장된다는 것만 빼고는 로컬 저장소와 정확히 동일하다. 사용자가 다른 페이지로 이동하거나 브라우저를 닫으면, 데이터가 삭제된다.

세션 저장소를 이용한 기본 CRUD

다음의 문법을 이용해서 세션 저장소에 데이터를 생성/읽기/변경/삭제할 수 있다.

// 생성
const user = { name: 'Ire Aderinokun', age: 25 }  
sessionStorage.setItem('user', JSON.stringify(user));

// 읽기 (단일)
console.log( JSON.parse(sessionStorage.getItem('user')) )

// 변경
const updatedUser = { name: 'Ire Aderinokun', age: 24 }  
sessionStorage.setItem('user', JSON.stringify(updatedUser));

// 삭제
sessionStorage.removeItem('user');

세션 저장소의 장점, 단점, 지원

로컬 저장소와 동일하다.

IndexedDB

IndexedDB는 브라우저에 데이터를 저장하기 위한 더욱 더 복잡하고 다방면의 해결책이다. 이는 “많은 양의 구조화된 데이터를 클라이언트 측에 저장하기 위한 저수준의 API” (Mozilla) 이다. 이는 자바스크립트를 기반으로 하는 객체지향 데이터베이스로서, 키를 이용해 인덱스되는 데이터를 쉽게 저장하거나 인출할 수 있게 해 준다.

내 아티클인 프로그레시브 웹 어플리케이션 만들기 에서, IndexedDB를 사용해서 어떻게 오프라인 기반의 어플리케이션을 만들 수 있는 지를 자세하게 다루고 있다.

IndexedDB를 이용한 기본 CRUD

모든 예제에서 나는 IndexedDB의 메소드들을 프라미스 형태로 변경한 버전인 제이크 아치발드의 IndexedDB 프라미스 라이브러리 를 사용하고 있다.

IndexedDB를 사용하는 것은 다른 브라우저 저장 방식을 사용하는 것보다 더 복잡하다. 어떤 데이터를 생성/읽기변경/삭제하기 전에, 먼저 데이터베이스를 열고, 필요한 스토어(데이터베이스의 테이블과 유사한)를 생성해야 한다.

function OpenIDB() {  
    return idb.open('SampleDB', 1, function(upgradeDb) {
        const users = upgradeDb.createObjectStore('users', {
            keyPath: 'name'
        });
    });
}

스토어 내부에 데이터를 생성 (혹은 변경) 하기 위해서는, 다음의 단계들을 따라야 한다.

// 1. 데이터베이스를 연다
OpenIDB().then((db) => {  
    const dbStore = 'users';

    // 2. 데이터베이스 내부에 스토어와 함께 새로운 읽기/쓰기 트랜잭션을 연다
    const transaction = db.transaction(dbStore, 'readwrite');
    const store = transaction.objectStore(dbStore);

    // 3. 스토어에 데이터를 추가한다
    store.put({
        name: 'Ire Aderinokun',
        age: 25
    });

    // 4. 트랜잭션을 완료한다
    return transaction.complete;
});

데이터를 인출하기 위해서는, 다음을 따라야 한다.

// 1. 데이터베이스를 연다
OpenIDB().then((db) => {  
    const dbStore = 'users';

    // 2. 데이터베이스 내부에 스토어와 함께 새로운 읽기 전용 트랜잭션을 연다
    const transaction = db.transaction(dbStore);
    const store = transaction.objectStore(dbStore);

    // 3. 데이터를 반환한다
    return store.get('Ire Aderinokun');
}).then((item) => {
    console.log(item);
})

마지막으로 데이터를 삭제하기 위해서는, 다음을 따라야 한다.

// 1. 데이터베이스를 연다
OpenIDB().then((db) => {  
    const dbStore = 'users';

    // 2. 데이터베이스 내부에 스토어와 함께 새로운 읽기/쓰기 트랜잭션을 연다
    const transaction = db.transaction(dbStore, 'readwrite');
    const store = transaction.objectStore(dbStore);

    // 3. 주어진 키에 대응하는 데이터를 삭제한다
    store.delete('Ire Aderinokun');

    // 4. 트랜잭션을 완료한다
    return transaction.complete;
})

만약 IndexedDB를 어떻게 사용하는지에 대해 더 공부하고 싶다면, 내 아티클를 참고하기 바란다.

IndexedDB의 장점

  • 더 복잡하고 구조적인 데이터를 다룰 수 있다
  • 여러개의 “데이터베이스”, 그리고 각 데이터베이스 내부에 여러개의 “테이블”을 가질 수 있다
  • 더 많은 양의 데이터를 저장할 수 있다
  • 상호작용 시에 더 많은 제어를 할 수 있다

IndexedDB의 단점

  • 웹 저장소 API보다 사용법이 더 복잡하다

지원

IndexedDB Browser Support

출처 : http://caniuse.com

WebSQL

WebSQL은 클라이언트 측의 관계형 데이터베이스를 위한 API로, SQLite와 유사하다. 2010년 이후로 W3C 웹 어플리케이션 워킹 그룹은 이 스펙에 대한 작업을 중단했다. WebSQL은 이제 더이상 HTML 스펙이 아니므로, 사용하지 말아야 한다.

비교

특징 쿠키 로컬 저장소 세션 저장소 IndexedDB
저장소 제한 ~4KB ~5MB ~5MB 하드디스크의 절반까지
영구적 저장? Yes Yes No Yes
데이터 저장 형식 문자열 문자열 문자열 모든 구조적 데이터
인덱스화 No No No Yes
자바스크립트

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는 자바스크립트에 타입 시스템을 추가하여 위의 두가지 문제를 해결해줄 것이다.