Coderifleman's blog

frontend development stories.

  • 베지에 곡선과 관련된 수학적 증명 방법과 알고리즘은 1959년 프랑스의 자동차 업체 시트로엥(citroen)에서 근무하던 물리학자이자 수학자인 파울 드 카스텔조(Paul de Casteljau)가 최초 고안했다. 다른 말로 카스텔조 곡선(Casteljau curve)이라고 부른다.

    하지만 시트로엥의 정책으로 인해 카스텔조가 얻은 성과가 1974년까지 발표되지 못했고 1962년에 프랑스 자동차 회사 르노(Renault)에서 근무하던 엔지니어 피에르 베지에(Pierre Bézier)가 자동차를 디자인하는 과정에서 이 곡선을 독자적으로 개발해 사용하면서 그의 이름으로 널리 알려지게 된다.

    정의

    베지에 곡선은 간단히 말해 복수의 조절점(Control point)을 이용해 매끄러운 곡선을 그릴 수 있는 가장 일반적인 매개 변수 곡선(Parametric curve) 이다. 매개 변수 곡선이란 매개 변수를 사용해 함수를 일반화하여 곡선을 그려내는 방법을 말한다.

    베지에 곡선의 조절점과 가이드 포인트
    <그림 1. 베지에 곡선의 조절점과 가이드 포인트>

    조절점이란 곡선의 모양을 결정하는 데 사용되는 점의 집합 또는 구성원을 뜻하며 가이드 포인트는 곡선의 모양을 변경시킬 수 있는 조절 가능한 점을 뜻한다. 그림 1의 점을 왼쪽부터 차례대로 P0, P1, P2, P3라고 할 때 보통 P0와 P3는 고정해두고 P1과 P2를 조절해 곡선을 변형한다. 이때 이 P1과 P2를 가이드 포인트라 한다.

    베지에 곡선엔 차수가 붙는데 이 차수는 조절점의 개수에 따라 정해진다. 간단히 말해 N 개의 조절점으로 그려진 곡선을 N - 1차 베지에 곡선이라 한다. 예를 들어 그림 1은 조절점이 4개이므로 4 - 1 즉, 3차 베지에 곡선이다.

    베지에 곡선이 그려지는 개괄적인 원리는 이전에 포스팅했던 「중학생도 알 수 있는 베지에 곡선(Bézier Curves)」을 참고하자. GIF 애니메이션을 이용해 이해하기 쉽게 설명돼 있다.

    이번 편에서는 블렌딩(Blending)과 보간(Interpolcation)을 소개한다. 이 지식만 습득하면 나머지 n 차 베지에 곡선은 자연스럽게 이해할 수 있다.

    에버리징과 블렌딩

    점 A와 점 B의 중앙
    <그림 2. 점 A와 점 B의 중앙>

    자, 위 그림 2와 같이 서로 떨어져 있는 점 A와 점 B가 있다고 해보자. 이때 점 P를 이 두 점 사이의 평균 즉, 선분의 중앙에 두고 싶다면 어떻게 해야 할까?

    P = (A + B) / 2

    위처럼 간단히 평균을 구해 중앙에 둘 수 있다. 이 수식을 조금 다르게 전개해보자.

    P = (A + B) / 2
      = (A + B) * ½
      = ½A + ½B
      = .5A + .5B
      = (.5 * A) + (.5 * B)

    위 수식을 이용한 연산을 블렌딩(Blending)이라고 표현한다. 지정된 각각의 비율에 맞춰 적절히 혼합하는 것이다. 자, 이제 같은 값이 아닌 가중치(Weights)를 줘서 블렌딩해보자.

    점 A와 점 B의 블렌딩 비율 조절
    <그림 3. 점 A와 점 B의 블렌딩 비율 조절>
    P = (.35 * A) + (.65 * B)

    이번엔 A는 0.35(35%), B는 0.65(65%)로 비율을 조정해 블렌딩했다. 이때 두 비율의 합은 당연하겠지만 1(100%)이 돼야 한다. 이것은 반드시 지켜져야 할 필수 조건이다. 이어서 비율 값을 다음과 같이 일반화해보자.

    P = (s * A) + (t * B)

    s는 A의 비율을, t는 B의 비율을 나타낸다. 만약 s가 높으면 t는 낮아지고 반대로 t가 높으면 s는 낮아질 것이다. 잠깐, s와 t는 서로 영향을 주며 두 수의 합은 항상 1이 돼야 한다. 그렇다면 s는 1 - t와 같다고 할 수 있다.

    P = ((1-t) * A) + (t * B)

    이제 변수 t 하나만으로 비율을 조정해 블렌딩할 수 있다. 이 수식은 다음과 같이 표현될 수 있다.

    P = ((1 - t) * A) + (t * B)
      = (1 - t) * A + t * B
      = A - tA + tB
      = A + t(-A + B)
      = A + t(B - A)

    이 글에서는 수식 P = (s * A) + (t * B)를 사용해 설명을 이어가겠다. 다시 한번 말하지만 s = 1 - t다. 여기에 몇 가지 규칙이 추가된다. 만약 변수 t가 0이라면 P는 항상 A와 같으므로 다음과 같이 표현될 수 있다.

    P = ((1 - t) * A) + (t * B)
      = ((1 - 0) * A) + (0 * B)
      = (1 * A) + (0 * B)
      = A

    또 변수 t가 1이라면 P는 항상 B와 같으므로 다음과 같이 표현될 수 있다.

    P = ((1 - t) * A) + (t * B)
      = ((1 - 1) * A) + (1 * B)
      = (0 * A) + (1 * B)
      = A + B - A
      = B

    이제 수식과 몇 가지 규칙을 참고하여 블렌딩하는 자바스크립트 함수를 작성해보자.

    const A = 20;
    const B = 198;
    
    function blender(A, B, t) {
        if (t === 0) {
            return A;
        }
    
        if (t === 1) {
            return B;
        }
    
        return ((1 - t) * A) + (t * B); // or A + t * (B - A)
    }
    
    const blend = blender.bind(null, A, B);
    
    console.log(blend(.0)); // 20
    console.log(blend(.2)); // 55.6
    console.log(blend(.4)); // 91.2
    console.log(blend(.6)); // 126.8
    console.log(blend(.8)); // 162.4
    console.log(blend(1));  // 198

    See the Pen qRBdvb by Uyeong Ju (@uyeong) on CodePen.

    복합 데이터 블렌딩

    이번에는 「에버리징과 블렌딩」 절에서 이해한 수식을 이용해 복합 데이터(Compound data)를 블렌딩해보자. 블렌딩 수식만 잘 활용하면 2차원 또는 3차원 같은 복합적인 데이터도 쉽게 블렌딩할 수 있다.

    Px = (s * Ax) + (t * Bx)
    Py = (s * Ay) + (t * By)
    Pz = (s * Az) + (t * Bz)

    3차원인 경우 위처럼 개별적으로 블렌딩한 후 연산된 값을 조합해 사용한다.

    2차원에서 점 A와 점 B의 중앙
    <그림 4. 2차원에서 점 A와 점 B의 중앙>

    전 절에서는 점 A와 점 B가 동일 선상에 놓여있는 1차원적 상황이었지만 이번엔 그림 4 처럼 2차원 상황에서 P를 구해보자. 2차원에서는 x와 y 좌표로 점의 위치가 결정된다. 따라서 x와 y를 개별적으로 블렌딩한 후 구해진 값을 조합하면 P의 위치를 구할 수 있다.

    2차원에서 점 P의 위치 구하기
    <그림 5. 2차원에서 점 P의 위치 구하기>
    Px = (s * Ax) + (t * Bx)
    Py = (s * Ay) + (t * By)
    P = {Px, Py}

    위 수식은 자바스크립트 코드로 다음과 같이 표현할 수 있다.

    const Ax = 20;
    const Ay = 144;
    const Bx = 198;
    const By = 72;
    
    const blendX = blender.bind(null, Ax, Bx);
    const blendY = blender.bind(null, Ay, By);
    
    // t = .5
    // P = { blendX(t), blendY(t) }

    See the Pen EZxVVV by Uyeong Ju (@uyeong) on CodePen.

    보간

    마지막으로 보간(Interpolation)의 개념을 짤막하게 소개한다. 러핑(Lerping)이라고도 부르는 보간은 시간이 지남에 따라 블랜드 가중치를 변경하여 블렌딩을 수행하는 것을 말한다. 시간은 멈춰있지 않고 지속해서 흐르는 특징이 있으며 블랜드 가중치는 이 흐르는 시간에 의해 결정된다.

    쉽게 말해 특정 값으로 블렌딩하는 게 아닌 지속해서 흐르는 시간에 근거해 블렌딩 하는 것이다. 이러한 과정은 대개 update()로 표현된다. 아래 자바스크립트 코드를 보자.

    function interpolator(Ax, Bx, Ay, By, duration) {
      return function(update) {
      	 // x, y 블랜드 함수 준비
        const blendX = blender.bind(null, Ax, Bx);
        const blendY = blender.bind(null, Ay, By);
        
        ... 중략 ...
        
        function step(timestamp) {
          
          ... 중략 ...
          
          // 현재 시간에 해당하는 진행 값 즉, t 값 연산
          const pastTime = timestamp - startTime;
          let progress = pastTime / duration;
    
    	   // t 값을 이용해 블렌딩하고 update 콜백 함수 호출
          update(blendX(progress), blendY(progress)); // Blending...
    
          ... 중략 ...
          
          requestAnimationFrame(step);
        }
        
        requestAnimationFrame(step);
      }
    }
    
    const interpolate = interpolator(Ax, Bx, Ay, By, 1000);
    
    interpolate(function(nx, ny) {
    	// 1초간 Interpolating.
    	// P = {nx, ny}
    });

    우선 requestAnimationFrame()를 사용해 지정한 시간 만큼 흐르게 한다. 그리고 현재 시각에 해당하는 진행 값 즉, t를 구한 후 이 값을 근거해 블렌딩한다.

    See the Pen dNbgQp by Uyeong Ju (@uyeong) on CodePen.

    보간은 페이드인, 아웃 같은 애니메이션 처리나 3D 게임에서의 객체 움직임 그리고 오디오 크로스페이드 처리 등에 유용하게 사용된다.

    여기까지 2편을 마치고, 다음 편에서 이 지식을 바탕으로 1, 2차 베지에 곡선을 소개하겠다.

    참고

  • 퇴근 후 여느 때와 마찬가지로 PlayStation 4 전원에 손이 향하는 순간, 오랫동안 관리하지 못한 react-preloader-icon 컴포넌트가 돌연 떠올랐다. react-preloader-icon은 SVG Loaders의 아이콘을 React 컴포넌트로 옮기고 있는 작은 사이즈의 프로젝트다.

    SVG Loaders에 디자인된 아이콘은 12개밖에 안되지만 게으른 나머지 아직 2개밖에 옮기지 못했다. 그래서 게임은 잠시 제쳐두고 Spinning과 Puff 아이콘을 한번 React 컴포넌트로 옮겨보기로 했다.

    Spinning과 Puff
    <그림 1. Spinning과 Puff>

    SVG와 3차 베지에 곡선

    Spinning 아이콘은 손쉽게 옮겼지만, Puff 아이콘은 좀 달랐다. 이전에 옮긴 아이콘 모두 애니메이션이 선형적(linear) 이기 때문에 신경 쓸 게 없었다. 하지만 Puff 아이콘의 SVG는 조금 다른 방식으로 만들어져 있다.

    <svg width="44" height="44" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg" stroke="#fff">
        <g fill="none" fill-rule="evenodd" stroke-width="2">
            <circle cx="22" cy="22" r="1">
                <animate attributeName="r"
                    begin="0s" dur="1.8s"
                    calcMode="spline"
                    values="1; 20"
                    keyTimes="0; 1"
                    keySplines="0.165, 0.84, 0.44, 1"
                    repeatCount="indefinite" />
                    ... 중략 ...
            </circle>
        </g>
    </svg>

    위 코드에서 animate 엘리먼트를 살펴보자. 이 엘리먼트는 calcMode, values, keyTimes, keySplines 속성을 갖고 있다. 일단 이 속성에 관한 지식이 전혀 없기 때문에 우선 SMIL 스펙 문서를 살펴봤다.

    스펙 문서를 통해 calcMode값의 타이밍을 제어할 함수를 선택하는 속성임을 알 수 있다. 속성 값으로 “discrete”, “linear”, “paced”, “spline” 중 하나를 지정할 수 있다.

    위 코드의 calcMode 속성에는 “spline”이 지정돼 있는데 “spline”으로 지정하면 values, keyTimes, keySplines 속성과 함께 “3차 베지에 곡선“으로 값을 제어할 수 있다.

    잠깐, “3차 베지에 곡선”이라고? 다들 CSS로 애니메이션을 처리할 때 cubic-bezier라는 애니메이션 타이밍 함수를 한 번쯤 사용해본 적이 있을 것이다. cubic-bezier… 그렇다. 3차 베지에라는 뜻이다.

    CSS와 3차 베지에 곡선

    cubic-bezier 애니메이션 타이밍 함수는 x, y, x`, y` 즉, 4개의 값을 인자로 전달받아 에니메이션의 타이밍을 조절한다. x, y는 첫 번째 가이드 포인트(Guide Point)의 좌표, x`, y`은 두 번째 가이드 포인트의 좌표다. 가이드 포인트란 곡선의 형태에 영향을 주는 조절 가능한 점을 뜻한다.

    3차 베지에 곡선의 가이드 포인트
    <그림 2. 3차 베지에 곡선의 가이드 포인트>

    3차 베지에 곡선이란 이 두 가이드 포인트의 위치에 따라 그려지는 곡선을 말한다. cubic-bezier는 이 곡선을 이용해 에니메이션의 타이밍을 조절한다(이 속성은 cubic-bezier.com, CSS3 Bezier Curve Tester, desmos에서 간단히 테스트해 볼 수 있다).

    cubic-bezier를 이용해 선언한 Easing 함수 셋
    <그림 3. cubic-bezier를 이용해 선언한 Easing 함수 셋>

    우리가 알고 있는 easeInSine, easeInQuad 등과 같은 Easing 함수(참고)는 모두 이 베지에 곡선을 이용해 미리 만든 일종의 셋이다.

    한걸음 더…

    자, 본래 이야기로 다시 돌아와서…

    SVG의 calcMode="spline"의 의미를 살펴봤으니 bezier-easing같은 npm 모듈을 사용해 Puff 아이콘을 React 컴포넌트로 옮기면 된다. 하지만 커밋을 완료하고 따듯한 이불 속에서 편안한 마음으로 잠자리에 들기엔 모르는 것이 너무나 많다.

    왜 베지에 곡선이라 부를까? 무엇을 근거로 1차, 2차, 3차라고 나눌까? 또, 곡선이 그려지는 원리와 공식은 무엇일까? 몇 가지 물음이 잠 못 이루게 했고 결국 이 주제로 글을 작성하게 됐다.

    그럼 다음 편부터 본격적으로 베지에 곡선에 관해서 연재하도록 하겠다.

    참고

  • 읽기전에...

    이 문서는 일본어 「中学生でもわかるベジェ曲線」를 번역한 글입니다.

    “베지에 곡선”을 이용해 렌더링하게 되면 꽤 재미있고 편안하게 그림을 그릴 수 있다. 오늘은 이를 사용하는 누구라도 그 원리를 이해할 수 있도록 설명하고자 한다.

    “베지에 곡선”이라는 것은 매끄러운 곡선을 그리기 위한 것이지만 설명은 우선 단순한 직선부터 시작하겠다. 아래 그림 1 처럼 직선에서의 점의 움직임이 모든 “베지에 곡선”의 기본이 되기 때문이다.

    1차 베지에 곡선
    <그림 1. 1차 베지에 곡선>

    하나의 직선이 있고 그 위를 점 M이 일정 속도로 이동하고 있다. 이 점 M의 궤적은 당연하지만 단순한 직선으로 그려진다. 좋다. t선분 위를 비율적으로 얼마나 나아갔는지를 나타내는 수치다.

    여기에 선을 하나 더 추가하고 그 위에 M처럼 이동하는 점을 놓아보자. 그리고 원래의 점 MM0로, 새로운 점을 M1으로 부르자. M0M1이 움직이는 규칙은 이전과 같다. M1이라는 점이 하나 더 늘었다 하더라도 특별히 복잡해질 것은 없다.

    2차 베지에 곡선
    <그림 2. 2차 베지에 곡선>

    자, 여기에서 M0M1을 잇는 선을 하나 더 그을 수 있다. 그 선은 M0M1이 이동하면 자연스럽게 함께 움직이게 된다. 이제 그 선에 주목해보자. 그 선 위에 M0M1처럼 일정 속도로 이동하는 점을 놓을 수 있다. 그 점을 B라고 하자. 그리고 점 B가 그리는 궤적을 살펴보자. 그렇다. 점 B가 그리는 궤적을 2차 베지에 곡선(Quadratic Bezier Curve)이라고 한다.

    P0, P1 등 을 조절점(Control Point)이라고 한다. 이제 조절점을 하나 더 늘린 “베지에 곡선”을 상상해보자.

    아래 그림 3을 보면 새로운 점 P3가 추가돼 있고 P2P3를 선으로 잇고 있다. 그리고 M0M1과 같이 그 선 위를 이동하는 점을 생각해 볼 수 있다. 그 점의 이름은 M2라 하자.

    3차 베지에 곡선
    <그림 3. 3차 베지에 곡선>

    자, 2차 베이제 곡선에서는 P0, P1, P2의 조합으로 점 B의 위치를 정할 수 있었다. 그렇다면 마찬가지로 P1, P2, P3의 조합으로도 비슷하게 점의 위치를 정할 수 있을 것이다.

    이전에 부르던 점 BB0라고 부르기로 하고 P1, P2, P3의 조합으로 정해지는 새로운 점을 B1이라고 부르기로 하자. 그렇게 하면 이전과 마찬가지로 점 B0와 점 B1을 잇는 선과 그 선 위를 일정한 속도로 움직이는 점을 다시 생각해 볼 수 있는데 이때 그 점이 그리는 궤적을 3차 베지에 곡선(Cubic Bezier Curve)이라고 한다.

    여기에서 끝이 아니다. 조절점을 한 개 더 늘려 P0, P1, P2, P3 조합과 P1, P2, P3, P4 조합으로 결정되는 선과 그 선을 일정 속도로 이동하는 점 B2를 추가하고 B1B2를 잇는 선을 놓고 그 위를 지나가는 또 다른 점을 추가한 후 그 점이 그리는 궤적을 살펴볼 수 있다. 이런 식으로 곡선은 얼마든지 복잡하고 다양하게 만들 수 있다. P0부터 P4로 정해지는 곡선은 4차 베지에 곡선(Quartic Bezier Curve)이라고 부른다. 하지만 조절점을 5개 이상 늘려도 실용적인 측면에서 특별한 이점이 없으므로 일반적으로 3차 베지에 곡선까지 사용된다.

    여기까지 간단하게 “베지에 곡선”에 관해 이야기했다. 한마디로 “베지에 곡선”이란 선분 위를 일정 속도로 움직이는 점과 그러한 점과 점을 잇는 또 다른 선분, 그리고 그 위를 일정 속도로 이동하는 또 다른 점 등을 조합해 최종적으로 특정 점이 그리는 궤적을 이용해 곡선을 그려내는 방법을 뜻한다. 이해하는 데 도움이 됐으리라 기대한다.

  • 얼마전에 기능 개발을 하다가 애니메이션을 다룰 일이 생겼다. 처음엔 CSS를 이용했지만, IE8을 지원하고자 “raf“라는 폴리필 라이브러리를 이용해 requestAnimationFrame(이하 raf)으로 개발했다. raf의 대략적인 형태는 다음과 같다(API의 자세한 설명은 MDN의 window.requestAnimationFrame()을 참고).

    var start = null;
    
    function step(timestamp) {
      if (!start) {
        start = timestamp;
      }
    
      var progress = timestamp - start;
    
      // Use progress to do something.
    
      if (progress < 1500) {
        window.requestAnimationFrame(step);
      }
    }
    
    window.requestAnimationFrame(step);

    코드를 보면 알겠지만 그렇게 직관적인 편은 아니다. raf를 쓸 때마다 이런 식으로 코드를 작성하긴 싫었다. 그래서 사용하기 편하게 raf를 랩핑한 객체 하나를 만들었다(여기서 시작한 작은 프로젝트가 있다 - StepperJS).

    class Stepper {
      start(options) {
        const {
          duration = 0,
          easing = linear, // is easing function.
          step = () => {}
        } = options;
    
        let startTime = 0;
    
        const stepping = (timestamp) => {
          if (!startTime) {
            startTime = timestamp;
          }
    
          const pastTime = timestamp - startTime;
          const progress = pastTime / duration;
    
          if (pastTime >= duration) {
            step(1);
            return;
          }
    
          step(easing(progress));
    
          window.requestAnimationFrame(stepping);
        };
    
        window.requestAnimationFrame(stepping);
      }
    }

    이렇게 작성한 Stepper 객체는 다음과 같이 사용할 수 있다.

    const stepper = new Stepper();
    
    stepper.start({
      duration: 1500,
      step: (n) => {
        element.style.left = `${150 * n}px`
      }
    });

    See the Pen yVpNLo by Uyeong Ju (@uyeong) on CodePen.

    개인적으로 생각했을 때 raf를 곧바로 사용하는 것보다 더 직관적이고 편해 보인다(물론 다른 의견을 가진 사람이 있을 수도 있다). 이제 Stepper 객체를 어떻게 테스트할 수 있을지 살펴보자.

    Stepper는 지정한 durationeasing에 따라 현재 시점에 해당하는 n 즉, progress 값을 콜백 함수에 전달하는 단순한 역할을 담당한다. 그렇다면 다음과 같이 테스트 케이스를 작성할 수 있을 것 같다.

    should call step callback with the current progress by duration and easing.

    음, 확실히 유닛 테스트로써는 손색없지만 필자는 사용자 관점에서 서술하는 걸 좋아하니 이렇게 고쳐보자.

    The user should be able to know the current progress through the start method of Stepper

    이를 어떻게 검증할 수 있을까? 필요한 값을 설정하고 start 메서드를 호출한 후 특정 시간으로 옮긴(tick) 다음 “n”이 기대하는 값과 일치하는지 확인하면 될 것 같다. 한번 테스트 코드로 옮겨보자.

    test('The user should be able to know the current progress through the start method of Stepper', (assert) => {
      // Given
      const stepper = new Stepper();
      const duration = 300;
      const easing = linear;
      let progress;
      
      // When
      stepper.start({
        duration,
        easing,
        step: (n) => progress = n
      });
      
      // Then
      assert(progress === ???);
    });

    다른 조건들은 어려울 게 없지만, 특정 시간으로 옮기는 행위는 그렇지 않다. 함수를 호출하는 순간 시간은 흘러 버리므로 특정 시간에 해당하는 progress 값을 비교할 수 없다.

    이처럼 테스트 환경에서 시간을 조작하고 싶을 때 사용할 수 있는 테스트 더블 라이브러리가 있다. 바로 “sinon“이다. sinon의 FakeTimersetTimeoutDate 객체 등을 덮어써서 동기적으로 시간을 조작할 수 있는 수단을 제공한다. 이것을 사용해보자.

    const clock = sinon.useFakeTimers();
    
    test('The user should be able to know the current progress through the start method of Stepper', (assert) => {
        // Given
        const stepper = new Stepper();
        const duration = 300;
        const easing = linear;
        const step = sinon.spy();
    
        // When
        stepper.start({
            duration,
            easing,
            step
        });
    
        clock.tick(0);
        clock.tick(250);
    
        // Then
        assert(step.args[1][0].toFixed(2) === linear(250 / 300).toFixed(2));
    });

    See the Pen PbEaMp by Uyeong Ju (@uyeong) on CodePen.

    당연한 얘기겠지만 TypeError가 발생한다. raf는 시간이 아닌 repaint 시점을 기준으로 호출되며 독자적으로 타임스템프를 계산해 콜백에 전달하므로 sinon의 FakeTimer로 조작할 수 없다. 따라서 동기적으로 호출한 args 프로퍼티에 쌓인 값이 없으므로 에러가 발생하는 것이다.

    그렇다면 어떻게 해야 할까. 고맙게도 누군가 raf를 Stub한 “raf-stub“을 개발해 배포해놨다. 이 Stub을 사용해 테스트를 다시 작성해보자.

    const stub = createStub();
    
    sinon.stub(window, 'requestAnimationFrame', stub.add);
    
    test('The user should be able to know the current progress through the start method of Stepper', (assert) => {
        // Given
        const stepper = new Stepper();
        const duration = 300;
        const easing = linear;
        const step = sinon.spy();
    
        // When
        stepper.start({
            duration,
            easing,
            step
        });
    
        stub.step(1, 0);
        stub.step(1, 250);
    
        // Then
        assert(step.args[1][0].toFixed(2) === linear(250 / 300).toFixed(2));
    });

    See the Pen LbeJZz by Uyeong Ju (@uyeong) on CodePen.

    테스트가 통과한다. raf를 사용해 작성한 코드를 테스트하려고 할 때 다소 막막할 수 있다. 하지만 sinon과 Stub을 적절히 사용한다면 손쉽게 테스트할 수 있다.

    여기까지 raf를 테스트하는 방법을 소개했다. 비슷한 고민을 하는 사람에게 작은 팁으로나마 도움이 되길 바라며 예제 코드는 UYEONG/request-animation-frame-test에 올려놓았으니 참고하길 바란다.

  • 특정 목록 페이지에 접근할 때 사용자가 마지막으로 클릭했던 아이템이 보이도록 자동 스크롤링 해달라는 요청이 들어왔다. 개인적으로 onload 타임에 특정 목록으로 자동 스크롤링하는 기능은 지양해 왔기 때문에 웹이 가진 한계점을 설명하면서 간단히 프로토타이핑해본 후 판단하자고 의견을 냈다.

    그런데 프로토타이핑하던 도중 예전엔 경험하지 못했던 이상한 현상이 발견됐다. 아래는 사용자가 마지막으로 클릭한 아이템이 “product30” 이라고 보고 해당하는 엘리먼트의 위치로 스크롤링하는 간단한 코드다.

    window.addEventListener('load', () => {
        console.log('onloaded');
    
        const product30 = document.getElementsByClassName('product-item')[29];
        const top = product30.getBoundingClientRect().top;
    
        console.log(`scroll to ${top}(product30)`);
    
        window.scrollTo(0, top);
    });

    생각한 대로라면 지정한 위치로 스크롤링 되어야 하지만 동작하지 않았다. 아래 “그림 1”을 보면 분명히 9970.875 즉, “product30”의 위치로 스크롤링을 지시했음에도 문서는 여전히 최상단에 있음을 알 수 있다.

    동작하지 않는 scrollTo()
    <그림 1. 동작하지 않는 scrollTo()>

    간단한 코드로 재현하긴 힘들지만, 실제 서비스 페이지에서는 setTimeout(() => ..., 0)으로 지정해도 금세 원래의 위치로 크롬이 재보정한다.

    이렇게 동작하는 이유는 크롬이 사용자가 보고 있던 스크롤 위치를 저장하고 있다가 해당 페이지에 다시 접근하면 브라우저 레벨에서 자동으로 스크롤링하기 때문인데 어떻게 해야 이를 회피할 수 있을지 고민됐다. 이때 찬욱님에게 여쭤보니 아래와 같은 코드로 이를 무력화할 수 있다는 답변을 받았다.

    if ('scrollRestoration' in history) {
        // Back off, browser, I got this...
        history.scrollRestoration = 'manual';
    }

    위 코드를 삽입하고 다시 페이지에 접근하니 정확히 의도한 대로 동작했다.

    제대로 동작하는 scrollTo()
    <그림 2. 제대로 동작하는 scrollTo()>

    프로토타이핑은 무사히 완료했고 의사 결정하는데 큰 역할을 했다. 하지만 이렇게 이야기를 마무리하기엔 찜찜하다. scrollRestoration 속성을 좀 이해하고 넘어가야 할 것 같다.

    필자는 이번에 scrollRestoration이라는 속성을 처음 봤다. 그도 그럴 것이 scrollRestoration은 실험적(Experimental) API 에다가 아직 MDN에 페이지도 없고(참고), 2015년 10월에 배포된 크롬 46에서야 추가된 API다.

    scrollRestoration은 히스토리 네비게이션의 스크롤 복원 기능을 명시적으로 지정할 수 있는 속성이다. 속성값은 ‘auto’와 ‘manual’이 전부. SPA 환경과 관련이 있어 보인다. 이 글을 읽는 사람 중 대다수는 목록 페이지에서 특정 아이템을 클릭해 엔드 페이지로 갔다가 다시 되돌아오면 스크롤을 처음부터 다시 해야 하는 경험을 한 적 있을 것 같다. 그래서 스크롤 포지션 값을 LocalStorage에 저장했다가 다시 목록 페이지에 접근하면 억지로 스크롤 위치를 잡아주는 기능을 구현한다.

    대표적으로 네이버 주식 모바일 웹이 그런 방식으로 구현돼 있는데 토론 목록 페이지에서 토론 페이지로 접근하면 scrollY 값을 기억해 뒀다가, 뒤로 가기 하여 토론 목록 페이지로 되돌아오면 이 값을 이용해 스크롤 위치를 조절한다.

    보던 목록으로 스크롤링하기 위해 scrollY 값을 기억한다
    <그림 3. 사용자가 보던 곳으로 보정하기 위해 scrollY 값 저장>

    위와 같은 구현 방식은 사용자가 정확히 어느 경로를 통해 목록 페이지로 접근하는지 알기 어려워 자칫 잘못된 경험을 제공(검색을 통해 접근했는데 스크롤 위치를 조절하는 등)하기도 하는데 scrollRestoration은 history navigation을 기반으로 동작하기 때문에 이러한 부분을 해소할 수 있을 것으로 보인다. 하지만 처음에 이야기한 것처럼 자동 스크롤링 기능이 오히려 특정 기능 구현에 방해가 될 수도 있는데 세심하게 개발자에게 조절할 수 있도록 속성을 열어줘서 고마울 따름.

    테스트 코드는 UYEONG/scroll-restoration-test에 올려놓았으니 참고하길 바란다.

    참고

  • 인생을 살다 보면 괴롭지만 꼭 해야만 하는 일을 만나게 된다. 프런트엔드개발자에겐 그런 일 중 하나가 바로 UI 테스트가 아닐까 싶은데, 이 고통스러운 일을 조금이나마 덜어줄 잘 만들어진 도구나 프레임워크를 찾지만 쉽지 않다. 처음엔 좋아 보여도 실제 테스트를 작성하다 보면 금세 그 도구가 가진 한계점을 만나게 된다. 그래서 그런지 다른 일보다도 더욱 도구에 의존하게 되고 개선된 또 다른 도구를 찾게 되는 것 같다.

    TestCafe는 자바스크립트 소식을 매주 정리해 공유하는 사이트인 JSer.info2016년 10월 24일 자 소식을 통해 알게 됐다. 해당 문서에 링크된 TestCafe로 브라우저 자동 테스트(일본어)를 읽어보았는데 생각보다 느낌이 좋아서 한번 리뷰해보자는 결론을 내렸다.

    TestCafe 소개

    TestCafe는 DevExpress가 개발한 E2E 테스트 프레임워크다. InfoQ에 TestCafe와 관련된 인터뷰 글(
    TestCafe with Smart Script Injection, Traffic and Markup Analysis Tools
    )이 있으니 관심 있는 사람은 참고하길 바란다. 같은 이름의 웹 서비스 및 클라이언트 앱도 서비스 중인데 이 서비스는 셀레니움 IDE 처럼 GUI로 조작하고 행위를 기록하여 재생할 수 있다.

    TestCafe는 webdriber.io나이트왓치와는 다르게 테스트 관련 스크립트를 주입해 동작하는 셀레니움 RC(Selenium RC)와 흡사한 방식으로 개발됐다. 사실 셀레니움 RC가 가진 한계를 극복하고자 셀레니움 웹드라이버(Selenium WebDriver)를 개발했는데 다시 셀레니움 RC와 비슷한 구조로 테스트 프레임워크를 만들었다고 해서 “그렇다면 과거에 경험했던 한계를 그대로 답습하는 게 아닌가?”하고 조금 의아했다.

    TestCafe의 개발자 이반 니쿨린(Ivan Nikulin)Why not use Selenium?에서 그 이유를 밝혔는데 간단히 말해서 테스트 환경에 대한 복잡한 설정 없이 실행할 수 있고, 모바일 기기에서도 원격 접속해 테스트할 수 있는 도구를 만들고 싶어 했던 거 같다. 또, 웹드라이버의 호환성 문제를 회피하기 위한 목적도 있는 것 같다.

    셀레니움은 분명 훌륭한 도구지만 설정이 복잡하고 웹드라이버 자체의 버그로 인해 테스트 작성에 종종 걸림돌이 되는 경우가 있다. 또 테스트 코드 자체를 디버깅하기가 까다로워 복잡한 테스트 케이스를 작성하는데 어려운 면도 가지고 있다. 과거 셀레니움 RC 방식에 한계가 있어 셀레니움을 만들었지만 새로운 문제들이 나타났다. 이러한 상황에서 TestCafe의 지향점이 좋은 해결책이 될 수 있을까?

    좋은 인상

    필자는 유료 웹툰을 서비스하고 있는 레진(Lezhin)을 이용해 로그인 테스트를 작성해 봤다. 예제 코드는 저장소 UYEONG/demo-testcafe를 참고한다. 이번 절에서는 이 예제를 이용해 필자가 받은 몇 가지 좋은 인상을 소개하겠다.

    test('사용자는 GNB 메뉴에서 로그인할 수 있다.', async (t) => {
        // Given
        const popupAttendanceLogin = new PopupAttendanceLogin(t);
    
        if (await popupAttendanceLogin.exist()) {
            await popupAttendanceLogin.close();
        }
    
        await t
            .click('#main-menu-toggle')
            .typeText('#login-email', ACCOUNT.USER_NAME)
            .typeText('#login-password', ACCOUNT.PASSWORD);
    
        // When
        await t
            .click('form.login-form button[type=submit]')
            .wait(1000);
    
        // Then
        await t.click('#main-menu-toggle');
    
        const email = await getElement('sidenav-email');
    
        assert(email.visible);
        assert(email.innerText === ACCOUNT.USER_NAME);
    });

    위는 레진에서 GNB 메뉴를 이용해 로그인이 정상적으로 이뤄지는지 테스트하는 코드다. 그리고 이 코드는 await/async를 이용해 비동기적 절차를 동기적으로 표현하고 있다. TestCafe는 바벨(Babel)을 내장하고 있어 별도의 설정 없이 최신 사양을 이용할 수 있다. 최신 사양으로 코드를 작성하고자 할 때 복잡한 세팅을 해줘야 하는 기존의 테스트 프레임워크와는 다른 부분이다.

    TestCafe에서 디버깅하기
    <그림 1. TestCafe에서 디버깅하기>

    또, await/async 방식으로 테스트 코드를 작성하면 디버깅이 쉽다는 장점이 있는데 체이닝을 펼치기 쉬우므로 각 액션을 단계별로 관찰할 수 있다. 나이트왓치는 파이프라인 방식으로 디자인돼 있어 디버깅이 다소 까다롭다.

    그럼 이제 실행을 해보자. 해당 저장소를 클론하고 다음 명령어를 입력하면 바로 테스트할 수 있다.

    $ git clone git@github.com:UYEONG/demo-testcafe.git
    $ npm install
    $ npm run test

    npm scripts에 등록한 test 명령은 다음과 같다.

    $ testcafe chrome tests/

    뭔가 추가적인 설정이 없으니 오히려 불안하다. 하지만 그 안락함에 금방 익숙해진다. 이것저것 세팅해줘야 했던 셀레니움 기반 프레임워크(참고)와는 사뭇 다른 경험이다.

    로그인 테스트 실행 결과
    <그림 2. 로그인 테스트 실행 결과>

    그림 2는 로그인 테스트가 진행되는 모습이다. 이 테스트의 진행 절차는 다음과 같다.

    1. www.lezhin.com 페이지에 접근한다.
    2. 최초에 출력된 팝업이 있다면 닫는다.
    3. 우측 상단의 메뉴 버튼을 클릭한다.
    4. 이메일 / 패스워드를 입력하고 로그인 버튼을 선택한다.
    5. 페이지가 갱신되면 다시 우측 상단의 메뉴 버튼을 클릭한다.
    6. 로그인이 정상적으로 완료 됐는지 확인한다.

    특정 절차에서 다음 절차로 넘어가기 위해선 지연 시간(Delay time)이 필요하다. 예를 들어 최초 페이지에 접근할 때는 콘텐츠가 모두 출력되는 시점을 기다려야 하고 팝업을 닫을 때는 애니메이션(FadeOut)이 종료되는 시간을 기다려야 한다. 나이트왓치에서는 이런 지연 시간을 직접 명시해줘야 한다.

    // 페이지에 최초 접근 시 body 엘리먼트가 보일 때까지 5000ms 기다린다.
    this._header
        .navigate()
        .waitForElementVisible('body', 5000);
        
    // 팝업을 닫을때 애니메이션 시간을 고려해 500ms 기다린다.
    this.click('@close');
    this.api.pause(500);

    하지만 TestCafe를 이용할 땐 지연 시간을 직접 입력할 일이 상대적으로 적다. TestCafe는 지연 시간을 직접 계산하고 관리한다. 실제로 위 로그인 테스트 코드를 보면 지연 시간을 명시한 지점은 로그인 버튼을 클릭한 시점 즉, 폼을 서브밋하고 갱신되기를 기다리는 딱 한 곳뿐이다.

    await t
        .click('form.login-form button[type=submit]')
        .wait(1000);

    지연 시간이라고 해도 거의 대충 시간을 짐작해 입력하는 일에 불과하다. 물론 비기능적 요구사항도 테스트에 포함돼야 하지만 애니메이션 종료 시점까지 일일이 명시해야 한다는 것은 분명 귀찮은 일이다.

    그리고 이벤트 지점을 커서로 표현해주거나 실제 타이핑을 하는 느낌을 살려 텍스트를 입력하는 부분도 인상적이다. 셀레니움 기반 테스트 프레임워크는 이런 자연스러운 느낌이 상대적으로 적다.

    TestCafe의 에러 리포팅
    <그림 3. TestCafe의 에러 리포팅>

    마지막으로 에러 리포팅도 상당히 깔끔한 편인데 어느 지점에서 어떠한 에러가 낫는지 알기 쉽게 출력해준다. 그림 3을 보면 24번째 행의 코드에 문제가 있음을 쉽게 알 수 있다.

    구구절절 설명했지만 TestCafe를 리뷰하면서 좋은 인상을 받은 점을 간단히 정리하면 다음과 같다.

    • 바벨을 내장하고 있어서 특별한 설정 없이 ES6+ 사양을 사용할 수 있다.
    • 테스트 코드 디버깅이 상대적으로 쉽다.
    • 특별한 설정 없이 커멘드 라인 명령으로 바로 테스트할 수 있다.
    • 특정 조작에 대한 지연 시간을 자동으로 관리한다.
    • 테스트 실패 및 에러 리포팅이 깔끔한 편이다.

    아쉬운 점

    분명 기존의 E2E 테스트 프레임워크보다 몇 가지 좋은 인상을 가지고 있는건 분명하다. 하지만 아쉬운 점도 있다. 일단 다양한 상황을 테스트하기엔 액션 셋과 API가 부족하다.

    TestCafe와 그 외 프레임워크의 API 목록
    <그림 4. TestCafe와 그 외 프레임워크의 API 목록>

    왼쪽 부터 차례대로 TestCafe, 나이트왓치, webdriver.io 가 제공하고 있는 액션 및 API 목록이다. 기본적인 액션은 제공하지만, 모바일에 특화된 액션이나 스크립트 권한 밖의 액션 등은 이용하기 힘들다. 시간이 지나면서 제공될 수 있는 API도 있지만 TestCafe가 가지고 있는 구조적 한계로 인해 아예 불가능한 API도 있다.

    또, E2E 테스트를 할 때 좋은 패턴들이 있는데 그중 하나가 PageObject다. 테스트에 필요한 반복적인 행위나 엘리먼트 셀렉터 등을 밖으로 노출 시키지 않고 페이지 단위(혹은 컴포넌트 단위)로 추상화해 제공할 수 있다(참고). PageObject는 테스트 코드의 가독성이나 유지 보수 측면에서 훌륭한 패턴이지만 TestCafe에서는 제공하지 않는다.

    // page-objects/popup-attendance-login.js
    import {Selector} from 'testcafe';
     
    const querySelector = Selector(q => document.querySelector(q));
     
    class PopupAttendanceLogin {
        elements = {
            wrapper: '#popup-attendance-login',
            closeBtn: '#popup-attendance-login .attlogin__close'
        };
    
        constructor(testController) {
            this.t = testController;
        }
    
        async exist() {
            const wrapper = await querySelector(this.elements.wrapper);
            return wrapper.visible;
        }
    
        async close() {
            const closeBtn = await querySelector(this.elements.closeBtn);
            await this.t.click(closeBtn);
        }
    }
     
    export default PopupAttendanceLogin;
    
    // tests/signin-test.js
    import PopupAttendanceLogin from '../page-objects/popup-attendance-login';
    
    test('사용자는 GNB 메뉴에서 로그인할 수 있다.', async (t) => {
        // Given
        const popupAttendanceLogin = new PopupAttendanceLogin(t);
    
        if (await popupAttendanceLogin.exist()) {
            await popupAttendanceLogin.close();
        }
        // ... 생략 ...

    그래서 필자는 위 코드처럼 직접 PageObject와 비슷한 객체를 직접 만들고 테스트 코드를 작성했다. 만약 프레임워크 자체에서 이 개념을 제공한다면 조금 더 편리하게 코드를 작성할 수 있을 것 같다.

    끝으로

    여기까지 TestCafe를 소개하고 필자가 느낀 좋은 인상과 아쉬운 점을 함께 이야기했다. 이 도구가 우리의 UI 테스트 환경의 답이 돼 줄 것이라 생각하지 않는다. 분명 실제로 테스트를 작성하기 시작하면 온갖 버그와 미흡한 점을 만나게 될 것이다. 하지만 아직 시작된 지 얼마 안 된 프로젝트라는 점을 미루어 볼 때 차차 개선될 것이라고 긍정적으로 생각할 수 있다.

    중요한 건 그들이 어떤 문제를 해결하고 싶어 하고 어디에 지향점을 두고 있느냐다. 그것이 내 앞에 놓인 문제 혹은 환경과 맞아떨어진다면 더할 나위 없는 좋은 도구가 될 것이다.

  • 나는 소프트웨어 엔지니어다. 이 일로 돈을 벌고 가족을 책임지지만 여전히 엔지니어링 즉, 공학이란 무엇이냐는 단순한 질문에도 쉽게 대답하지 못한다. 나뿐만 아니다. 세상에는(적어도 내가 만난 엔지니어 중에는) 과학이나 수학의 세계를 공학과 혼동하는 사람도 있고 과학과 수학이 공학보다 더 우위에 있다고 말하는 사람도 있다. 모두 공학을 잘 이해하지 못해 일어나는 일이다.

    공학의 본질적 목표는 무엇이며, 엔지니어가 정확히 무슨 일을 하는 사람을 일컫는지 알지 못하면서 소프트웨어 공학 관련 직업에 종사하고 있다는 사실이 아이러니하게 느껴지기도 한다. 그래서 그런지 요샌 스킬-업보단 공학이란 무엇인가에 좀 더 관심을 두고 「공학 학교에서 배운 101가지」나 「맨발의 엔지니어들」 같은 조금은 덜 전문적이지만 딱딱하지 않은 책을 읽는데 시간을 내고 있다.

    이번에(사실 포스팅으로는 처음) 소개할 책은 「공학기술과 사회」다. 공학에 대한 여러 가지 잡생각을 하며 퇴근하던 중 강남 영풍문고에 들르게 됐는데, 진열장에 꽂혀 있는 이 책이 나의 눈길을 사로잡았다.

    공학 기술과 사회 책표지
    <그림 1. 공학 기술과 사회>

    책 표지나 내용 전개 방식을 보면 다소 대학 교재 같은 느낌이 난다. 실제로 한국공학교육센터에서 공학소양 교과목 DB구축사업의 목적으로 연구비를 받아 집필됐다고 한다. 교재 같은 점만 제외하면 책 내용 자체는 제법 훌륭한 편이다.

    엔지니어가 전공을 통하여 사회에 공헌하기 위해서는 자기가 하는 일이 사회와 어떤 관계가 있으며, 기술이 전반적으로 사회와 서로 어떤 영향을 주고 받는지 이해할 필요가 있다.

    소프트웨어 엔지니어는 현재에 집중하여 좀 더 나은 미래를 만들기 위해 살아가지만, 사회에 있어 의미 있는 무엇인가를 만들기 위해선 사회와 기술의 관계를 이해할 필요가 있다. 눈앞에 놓여있는 기술적 문제 해결에만 집중하기보단 조금 더 넓은 시야를 가지고 다양한 사회적 문제를 어떻게 해결할 수 있을지, 사회 속에 엔지니어와 기술의 책임은 무엇일지 고민해야만 엔지니어로서 가장 만족할 수 있는 성과를 달성할 수 있지 않을까.

    이때 이 책이 그러한 시작점으로써 좋은 지적 재료가 될 수 있다. 공학의 정의부터 엔지니어적 가치, 기술의 역사, 기술결정론이나 기술 사회적 구성론 같은 기술사회론도 일반인이 이해하고 받아드리기 쉽게 작성돼 있다.

    앞으로도 공학의 본질적인 측면과 사회와의 관계를 이해하고 엔지니어로서의 책임 범위와 중요한 덕목을 갖춘 그런 훌륭한 엔지니어가 되기 위해 끊임없이 정진해야 할 것 같다. 먼 훗날 한 사람의 엔지니어로서 훌륭한 인생을 살았다고 스스로 자신 있게 말할 수 있도록 말이다.

    그나저나 모두 베이컨을 아는가? 먹는 베이컨이 아니라 “아는 것이 힘이다”라고 말한 철학자 프랜시스 베이컨(Francis Bacon) 말이다(최근에 FRP 라이브러리로도 등장했다 -Bacon.js-). 이 책을 통해서 알게 된 사실인데 베이컨은 공학적 측면에서도 상당히 멋진 분인 걸 알 수 있었다.

    근데 초 서양에서 기술의 가치를 높게 평가하고 기술 진보를 인류의 역사에서 매우 중요한 과제로 제시함으로써 플라톤과 아리스토텔레스 이후 지속돼왔던 “기술에 대한 천대”를 종식시키려 노력했던 대표적 인물이 바로 프랜시스 베이컨(Francis Bacon)이다. … 중략 … 베이컨은 발명을 통한 기술적 진보가 사회 발전에 필수적인 요소임을 강조함으로서 과학은 고상하지만 기술은 비천하다는 편견을 없애려 했다.

    우리 모두 자부심을 가지고 유용한 제품을 만들어내는 훌륭한 공학자의 인생을 살아나가자. 기술은 절대 천하지 않고 공학은 위대하다! 끝.

  • 나이트왓치를 소개하기 전에 E2E 테스트의 정의부터 셀레니움 웹드라이버 등 기본 개념부터 간단히 소개하겠다.

    E2E 테스트

    정의

    소프트웨어 테스트는 테스트의 규모(레벨)에 따라 유닛 테스트, 통합 테스트, 시스템 테스트, 인수 테스트 이렇게 4가지로 분류한다. 여기에서 E2E 테스트는 시스템 테스트에 속한다.

    E2E(End-to-End) 테스트는 전체 시스템이 제대로 작동하는지 확인 하기 위한 테스트로 시나리오 테스트, 기능 테스트, 통합 테스트, GUI 테스트를 하는데 사용한다. API와의 연동도 테스트 항목에 포함되기 때문에 일반적으로 목(Mock)이나 스텁(Stub)과 같은 테스트 더블을 사용하지 않으며 최대한 실제 시스템을 사용하는 사용자 관점에서 시뮬레이션 한다. 그래서 테스트 속도가 서비스 규모에 따라 상당히 느릴 수 있기 때문에 유닛 테스트나 기능 테스트를 위한 일반적인 테스트 자동화와 시스템 테스트를 위한 E2E 테스트 자동화를 함께 구성한다.

    E2E 테스트 프레임워크

    E2E 테스트 프레임워크는 다양한 종류가 있는데, 크게 헤드리스 브라우저를 의존하는 것과 셀레니움 웹드라이버를 의존하는 것으로 나눌 수 있다. 셀레니움 웹드라이버는 다음 절에서 자세히 설명한다.

    헤드리스 브라우저는 커맨드 라인 명령어로 조작할 수 있는 화면이 없는 브라우저로 Jsdom 기반의 좀비(Zombie.js), 웹킷 엔진 기반의 팬텀(Pantom.js), 겟코 엔진 기반의 슬리머(Slimer.js) 등이 있다. 잘 알려진 캐스퍼(Casper.js)는 팬텀과 슬리머를 조금 더 사용하기 쉽게 만들어 놓은 유틸리티 도구다. 헤드리스 브라우저는 기본적으로 크로스 브라우징 테스트가 불가능하며 어썰트(Assert)도 내장하고 있지 않기 때문에 필요하다면 추가를 해야한다.

    셀레니움 웹드라이버를 의존하는 프레임워크로는 webdriver.io, 큐컴버(Cucumber.js), 프로트랙터, 나이트왓치 등이 있다. 이들은 크로스 브라우징 테스트가 가능하고 어썰트도 내장하고 있다. 단, 각 프레임워크 마다 내장하고 있는 어썰트 라이브러리는 다르다.

    셀레니움 웹드라이버

    나이트왓치는 셀레니움 웹드라이버(Selenium WebDriver) API를 사용해 개발된 E2E 테스트 프레임워크이기 때문에 본격적으로 사용해보기 전에 셀레니움과 웹드라이버를 먼저 이해할 필요가 있다.

    셀레니움 웹드라이버의 원래 이름은 셀레니움(또는 셀레니움 1.0)이었다. 셀레니움은 웹 브라우저를 사용하여 웹 애플리케이션을 테스트하는 오픈 소스 도구다. 이때 사람의 손으로 직접 웹 브라우저를 조작하는 것이 아니라 작성된 스크립트에 따라 자동으로 조작한다. 이러한 방법을 브라우저 자동화(Browser Automation)라고 표현한다.

    셀레니움은 시카고에 위치한 소트워크스(ThoughtWorks) 사에서 개발을 시작했다. 소트워크스는 마틴 파울러(Martin Fowler)가 속한 그룹으로 유명하다.

    웹드라이버는 셀레니움의 단점을 보완하고자 구글의 엔지니어들이 개발하고 사용한 브라우저 자동 테스트 도구이다. 2006년 경 구글에서 근무 중이던 시몬 스튜어트(Simon Stewart)가 주도해 프로젝트를 시작하고 2009년에 처음으로 공식 발표했다.

    Selenium Projects
    <그림 1. 셀레니움 프로젝트의 흐름>

    과거 셀레니움은 자체 엔진인 셀레니움 RC(Remote Control)를 이용해 브라우저와 통신했다.

    셀레니움 RC는 자바나 파이썬 등의 언어로 스크립트를 작성하면 그 스크립트를 기반으로 브라우저를 조작하는 자바스크립트를 생성하고 해당 페이지에 삽입 후 브라우저를 조작하는 간단한 구조였다. 이러한 구조는 브라우저의 보안 제약이나 자바스크립트의 한계로 인해 실효성이 떨어지는 단점이 있었다. 이 단점이 시몬 스튜어트가 웹드라이버를 만들게 된 이유이기도 하다.

    그에 반해 웹드라이버는 브라우저의 확장 기능과 OS의 기본 기능 등을 이용하여 브라우저를 조작하는 구조였다. 이는 셀레니움 RC의 단점을 충족해줄 수 있는 방식이었다.

    Selenium webdriver high level block diagram
    <그림 1. 셀레니움 웹드라이버 다이어그램>

    이 방식이 성공하여 셀레니움 RC와 웹드라이버 통합이 이루어졌고 2011년 7월에 셀레니움 웹드라이버(또는 셀레니움 2.0)를 릴리즈하게 된다. 즉, 현재 우리가 알고있는 셀레니움은 웹드라이버와 통합한 버전이다.

    그림 2를 보면 알 수 있듯이 웹드라이버는 다양한 브라우저와 환경을 대응해야하는데, 브라우저마다 이를 위한 API가 다를 경우 또 다른 문제가 발생할 수 있기 때문에 현재 표준화를 제정(W3C WebDriver) 중이다.

    현재 셀레니움 웹드라이버는 파이썬, 루비, 자바, C## 그리고 Node.js를 이용해 웹브라우저는 조작할 수 있도록 다양한 API를 제공하고 있다. 하지만 셀레니움 서버와 자바스크립트의 궁합이 좋지 않고, 돔을 조작 하거나 셀렉팅하는데 한계가 있어 셀레니움 웹드라이버와 노드를 바인딩하여 다양한 기능을 제공하는 여러가지 형태의 프로젝트가 생겨났다. 그 중 유명한 프로젝트가 바로 webdriver.io와 나이트왓치 그리고 앵귤러 프로젝트를 위한 프로트랙터다.

    이들 도구는 웹드라이버 API를 사용할 때 생기는 다양한 패턴을 추상화한 API와 신택스 슈가 등을 제공해 셀레니움 2.0 보더 더 편리하고 다양한 경험을 제공한다.

    나이트왓치

    나이트왓치는 노드 기반의 E2E 테스트 프레임워크다. 셀레니움 웹드라이버를 중개하여 각종 브라우저를 조작하고 동작이 기대한 것과 일치하는지 테스트하는데 사용한다. CSS 셀렉터로 엘리먼트를 셀렉팅하여 테스트를 작성할 수 있도록 하는 기능과 신텍스 슈가 그리고 단순하고 간결한 문법을 제공한다. 또한 테스트 러너를 포함하고 있으므로 독자적으로 그룹화한 테스트를 한번에 실행할 수 있으며 지속적인 통합의 파이프 라인과 합칠 수 있다는 특징을 가지고 있다.

    나이트왓치를 알게 된건 나보다 먼저 E2E 테스트를 리서치하고 관련 도구를 찾고있던 훈민이형(개발왕 김코딩, 블로그) 덕분이었다. 미리 삽질을 하고 계셨기 때문에 다른 도구를 선택하기 보다 같이 삽질하는 편이 고민할 시간도 적어서 큰 고민 없이 사용했다.

    설치하기

    나이트왓치 설치는 개발자 가이드 Getting Started 절에 잘 설명돼 있다. 이 문서에는 간단하게 요약해 설치 과정을 설명한다. 우선 NPM을 이용해 설치한다.

    $ npm install --save-dev nightwatch

    웹드라이버로 브라우저와 통신하기 위해서는 셀레니움 서버를 실행시켜야한다. 셀레니움 서버 다운로드 사이트에서 파일을 다운 받고 아래와 같이 서버를 실행한다. 이 글을 작성하는 현재 기준 가장 최신 버전은 2.53.0 이다.

    프로젝트 디렉터리에서 nightwatch.json을 생성하고 다음과 같이 작성한다. 옵션의 자세한 설명은 개발자 가이드 Configuration 절을 참고한다.

    {
      "src_folders" : ["tests"], // 테스트할 디렉터리, 배열로 지정
      "output_folder" : "tests/reports", // JUnit XML 리포트 파일이 저장될 위치
      "custom_commands_path" : "", // 불러올 커스텀 커맨드가 있는 위치
      "custom_assertions_path" : "", // 불러올 커스텀 어썰트가 있는 위치
      "page_objects_path" : "", // 불러올 페이지 객체가 있는 위치
      "globals_path" : "", // 불러올 외부 글로벌 모듈이 있는 위치
      "selenium" : {   // 셀레니움 서버 환경 설정
        "start_process" : true, // 테스트 시작시 셀레니움 프로세스를 자동으로 실행할 것 인지 여부
        "server_path" : "./selenium-server-standalone-2.53.0.jar", // 셀레니움 서버 jar 파일의 경로, start_process가 false면 지정하지 않아도 된다.
        "log_path" : "tests/logs", // 셀레니움의 output.log 파일이 저장될 경로
        "host" : "127.0.0.1", // 셀레니움 서버의 listen ip
        "port" : 4444, // 셀레니움 서버의 listen port
        "cli_args" : { // 셀레니움 프로세스로 넘겨질 cli 인자 목록
          "webdriver.chrome.driver" : "",
          "webdriver.ie.driver" : ""
        }
      },
      "test_settings" : { // 테스트 브라우저 별 환경 설정
        "default" : { // 모든 브라우저에 적용 될 공통 설정
          "launch_url" : "http://localhost",
          "selenium_port"  : 4444,
          "selenium_host"  : "localhost",
          "silent": true, // 셀레니움의 로그를 숨길지 여부
          "screenshots": { // 테스트가 실패 했을 때 촬영 될 스크린샷 설정
            "enabled" : true,
            "on_failure" : true,
            "on_error" : false,
            "path" : "tests/screenshots"
          },
          "desiredCapabilities": { // 셀레니움 웹드라이버로 전달할 브라우저 이름과 기능 지정
            "browserName": "firefox",
            "javascriptEnabled": true,
            "acceptSslCerts": true
          }
        }
      }
    }

    tests 디렉터리 하위에 demo.js를 생성하고 간단한 테스트 코드를 한다.

    module.exports = {
        '사용자는 검색어를 입력 후 검색어가 포함된 자동 완성 리스트를 볼 수 있다.' : function (browser) {
            browser
                .url('http://www.google.com')
                .waitForElementVisible('body', 1000)
                .setValue('input[type=text]', 'nightwatch')
                .pause(1000)
                .assert.containsText('##sbtc', 'nightwatch')
                .end();
        }
    };

    이어서 아래 명령어로 간단한 E2E 테스트를 실행할 수 있다.

    $ ./node_modules/nightwatch/bin/nightwatch

    하지만 현재 파이어폭스 버전 47에 문제가 있어 테스트가 실행되지 않을것이다. 파이어폭스에서 테스트 하고 싶다면 예전 버전(Install an older version of Firefox)으로 다운그레이드 하거나 GeckoDriver를 사용해야한다(Setting up the Marionette executable). 여기에서는 GeckoDriver를 이용하는 방법을 소개(OSX 기준)하겠다.

    먼저 mozilla/geckodriver에서 GeckoDriver를 다운로드한다.

    $ cd ~/Downloads
    $ wget https://github.com/mozilla/geckodriver/releases/download/v0.8.0/geckodriver-0.8.0-OSX.gz

    다운로드한 파일을 압축 해제하고 적당한 위치로 옮긴 후 실행가능한 파일로 변경한다.

    $ gunzip geckodriver-0.8.0-OSX.gz
    $ mkdir executable && mv geckodriver-0.8.0-OSX executable/wires
    $ chmod 755 executable/wires

    이제 .bash_profile(또는 .zshrc)에서 PATH를 지정한다.

    $ vim ~/.zshrc
    
    	GECKO_DRIVER=$HOME/Downloads/executable
    	export PATH=$HOME/bin:/usr/local/bin:/usr/local/sbin:$GECKO_DRIVER:$PATH
    
    ## rc파일을 다시 불러온다.
    $ source ~/.zshrc

    마지막으로 nightwatch.json파일에서 desiredCapabilities 속성을 다음과 같이 변경한다.

    "desiredCapabilities": {
      "browserName": "firefox",
      "marionette": true, // 추가
      "javascriptEnabled": true,
      "acceptSslCerts": true
    }

    이제 다시 실행해보면 파이어폭스 브라우저에서 정상적으로 테스트가 진행될 것이다.

    데모 테스트 실행 결과
    <그림 3. 데모 테스트 실행 결과>

    여러 브라우저에서 동시에 테스트하기

    현재 작성한 설정 파일로 나이트왓치를 실행하면 파이어폭스에서만 테스트가 진행된다. 이번엔 크롬 브라우저에서도 테스트가 진행되도록 설정을 변경하겠다. 크롬 브라우저는 셀레니움과 통신할 웹드라이버를 별도로 설치해야하는데 웹드라이버 매니저를 사용하면 쉽게 설치할 수 있다. 아래 명령어로 웹드라이버 매니저를 설치한다.

    $ npm install --save-dev webdriver-manager
     
    # 크롬 웹드라이버와 앞 절에서 다운로드 받았던 셀레니움 서버가 함께 설치된다.
    $ ./node_modules/.bin/webdriver-manager update
     
    # 또는 아래 명령어 처럼 인자를 전달해 별도로 설치할 수도 있다.
    # ./node_modules/.bin/webdriver-manager update --chrome

    이제 nightwatch.json에 셀레니움 서버 경로와 크롬 웹드라이버 서버 경로를 수정한다.

    "selenium": {
      "start_process": true,
      "server_path": "./selenium-server-standalone-2.53.0.jar",
      "log_path": "tests/logs",
      "host": "127.0.0.1",
      "port": 4444,
      "cli_args": {
        "webdriver.chrome.driver" : "node_modules/webdriver-manager/selenium/chromedriver_2.21", // 추가
        "webdriver.ie.driver": ""
      }
    },

    다음으로 default 속성에 작성했던 파이어폭스 브라우저 설정을 test_settings 속성 하위로 옮기고 크롬 브라우저 설정도 함께 추가 작성한다.

    {
      // ... 생략 ...
      "test_settings": {
        "default": {
          "launch_url": "http://localhost",
          "selenium_port": 4444,
          "selenium_host": "localhost",
          "silent": true,
          "screenshots": {
            "enabled" : true,
            "on_failure" : true,
            "on_error" : false,
            "path" : "tests/screenshots"
          }
        },
        "firefox": {
          "desiredCapabilities": {
            "browserName": "firefox",
            "marionette": true,
            "javascriptEnabled": true,
            "acceptSslCerts": true
          }
        },
        "chrome": {
          "desiredCapabilities": {
            "browserName": "chrome",
            "javascriptEnabled": true,
            "acceptSslCerts": true
          }
        }
      }
    }

    이제 아래 명령어로 실행하면 두 브라우저에서 동시에 테스트가 실행된다.

    $ ./node_modules/nightwatch/bin/nightwatch --env firefox,chrome

    사파리 브라우저에서 테스트하고자 한다면 사파리 웹드라이버를 확장 기능으로 설치해야한다. 자세한 내용은 나이트왓치 위치의 Running tests in Safari 문서를 참고한다.

    사파리의 웹드라이버 확장프로그램
    <그림 4. 사파리의 웹드라이버 확장프로그램>

    모카 사용하기

    이번엔 테스트 코드를 모카 기반으로 작성할 수 있는 환경을 만들어보겠다. 나이트왓치는 어썰트로 챠이(chai)를 내장하고 있지만 모카는 별도로 설정해 사용해야한다. 모카를 설정하는 자세한 내용은 개발자 가이드 Using Mocha 절을 참고한다. 모카를 굳이 사용하려는 이유는 JUnit XML로 리포팅 하는 기본 러너와는 달리 다양하고 보기 쉬운 리포팅을 지원하기 때문이다.

    먼저 nightwatch.json 파일에 다음과 같이 test_runner 속성을 추가한다. 옵션에 관한 자세한 설명은 모카 위키의 Set options 절을 참고한다.

    {
      "test_runner" : {
        "type" : "mocha",
        "options" : {
          "ui": "bdd",
          "reporter": "spec"
        }
      },
      // ... 생략 ...
    }

    테스트 코드를 모카 기반으로 재작성한다.

    describe('구글 메인 페이지', function() {
     
        before(function(client, done) {
            done();
        });
     
        after(function(client, done) {
            done();
        });
     
        describe('##사용자는 검색할 수 있다.', function() {
            it('사용자는 검색어를 입력 후 자동 완성된 리스트를 볼 수 있다.', function(client, done) {
                client
                    .url('http://www.google.com')
                    .waitForElementVisible('body', 1000)
                    .setValue('input[type=text]', 'nightwatch')
                    .pause(1000)
                    .assert.containsText('##sbtc', 'nightwatch')
                    .end(done);
            });
        });
    });

    다시 실행해 보면 모카 기반으로 테스트 코드가 동작하는 것을 볼 수 있다.

    모카 테스트 실행 결과
    <그림 5. 모카 테스트 실행 결과>

    브라우저 스택

    크로스 브라우징 테스트를 할 수 있도록 해주는 웹 서비스인 브라우저 스택은 다양한 플랫폼과 웹 브라우저를 지원한다. 또한, 셀레니움 서버도 제공하고 있는데 이를 이용하면 나이트왓치와 연동해 테스트를 자동화할 수 있다.

    먼저 browserstack.json 파일을 작성한다.

    {
      // ... 생략 ...
      "selenium": {
        "start_process": false
      },
      "test_settings": {
        "default" : {
          "launch_url" : "http://hub.browserstack.com",
          "selenium_host" : "hub.browserstack.com",
          "selenium_port" : 80,
          "silent" : true,
          "screenshots" : {
            "enabled" : true,
            "on_failure" : true,
            "on_error" : false,
            "path" : "tests/screenshots"
          },
          "desiredCapabilities": {
            "platform": "xp",
            "browserName": "firefox",
            "javascriptEnabled": true,
            "acceptSslCerts": true,
            "browserstack.user" : "user_id", // 브라우저 스택 아이디
            "browserstack.key" : "user_key" // 브라우저 스택 키
          }
        }
      }
    }

    platform 속성엔 XP를 browserName 속성엔 파이어폭스를 지정했고 로컬 환경에서 셀레니움 서버를 실행시킬 필요가 없기 때문에 start_process은 false로 지정했다. 이제 브라우저 스택은 윈도우즈 XP 환경의 파이어폭스 브라우저에서 테스트를 진행할 것이다. 브라우저 스택에서 지원하는 플랫폼과 브라우저는 공식 홈페이지의 Capabilities 페이지를 참고하면 알 수 있다.

    아래 명령어를 참고해 실행해본다.

    $ ./node_modules/nightwatch/bin/nightwatch --config browserstack.json

    다양한 플랫폼과 브라우저에서 E2E 테스트를 할 수 있다는 점은 큰 장점이지만 통신이나 테스트를 구동하는 속도가 아주 느리다. 따라서 테스트 배치 혹은 정기 배포 전에만 사용하기 적합해 보인다.

    웹스톰 디버깅

    웹스톰에서 노드 디버깅 도구를 사용해 나이트왓치를 디버깅할 수 있다. 자세한 내용은 Debugging Nightwatch tests in WebStorm을 참고한다. 다만, 파이프라인 방식이다 보니 브레이크 포인트를 활용한 디버깅이 다소 무의미한 느낌은 있다.

    끝으로

    여기까지 다양한 사전 지식을 설명하고 나이트왓치에 관해서 이해해봤다. E2E 테스트 특성 상 프로젝트 저장소에 테스트를 작성하고 유지하기 보단 별도의 E2E 테스트 저장소를 만들어 테스트를 작성하고 유지하는게 더 효율적이지 않을까 생각한다. 또, 나이트왓치에는 페이지 오브젝트, 커스텀 커맨드 등 테스트를 작성할 때 유용한 개념을 제공한다. 이 두 개념을 적절히 잘 사용하면 생각보다 더 관리하기 쉬운 테스트 코드를 작성할 수 있다.

    위에서 진행한 설치 및 설정 과정은 UYEONG/hello-nightwatch에 올려놓았으니 참고하길 바란다.

    참고

  • React에 대한 여러 가지 생각」이라는 글에 직접 코멘트를 달까 생각했지만, 쓰다 보니 글이 길어져서 포스팅한다. 해당 글과는 다른 개인적인 의견에 평소 이야기하고 싶었던 내용을 약간 첨부해 글을 작성했다.

    개인적으로 느꼈던 리액트의 가장 큰 장점은 사고의 단순함을 끌어내는데 있다고 생각한다(물론 애플리케이션의 성격이나 상황에 따라 다르다). 성능는 부차적인 것으로 앵귤러1 보다 빠른 메커니즘을 제공하지만, 당연 순수 자바스크립트보단 느리다.

    보통 자바스크립트 개발 시에는 일일이 변경을 검사해 해당하는 DOM을 가져와서 값을 대입해줘야 하는 번거로움(혹은 고통스러운)이 있다. 그래서 몇십 밀리세컨드는 신경쓰지 않고 처음부터 다시 그리는 방법을 택한 적도 있었지만 그것만으로 만족하긴 찜찜하다.

    HTTP의 Stateless 성을 기반으로 개발된 웹 MVC 프레임워크를 사용할 때는 요청이 들어오면 요청에 맞는 HTML을 생성해 응답해주면 끝나는 단순한 구조이기 때문인데 이런 고통스러운 부분이 적었다. 이때의 단순함을 리액트로 개발할 때 느꼈다.

    Stateless 하니 왠지 functional이라는 키워드도 떠오르는데 실제로 리액트에서 뷰는 어떤 값에 의해 생성되는 단순한 결과값(스냅샷)에 불과하다. 리액트의 가장 큰 장점은 여기에 있다고 생각한다. 이 부분이 불변, 단방향 데이터 플로우와 좋은 궁합을 보여주는 점이다. 실제로 React의 개발자인 Jordan Walke는 XHP와 함수형에서 영감을 받아 리액트를 개발했다(참고). 위와 같이 단순한 구조는 함수형에서, JSX는 XHP에서 영감을 받은 듯하다.

    JSX는 서술적으로 컴포넌트를 표현하는 데 좋은 표기법이라고 생각한다. UI의 구조를 표현하거나 각 컴포넌트를 조합하는 데는 명령형(Imperative)보다 선언형(Declarative)이 더 적합한 경우가 많다.

    아래는 jQuery로 뷰 로직을 작성할 때 자주 보이는 형태다.

    아래는 JSX로 표현했을 때의 모습이다.

    개인적으로 느꼈던 JSX의 단점은 애니메이션 표현에 있다. UI의 구조를 표현하기엔 적합하지만, 애니메이션처럼 뭔가 동적인 효과를 표현하기엔 오히려 장황하고 정확히 어떤 요소에 애니메이션을 적용하는지 한 번에 파악하기 힘들었다.

    <Animation fadeOut={true}>
    	<Something>
    		<Item/>
    		<Item/>
    	</Something>
    </Animation>
    
    // or
    
    $('.somthing').fadeOut();

    JSX의 또 다른 문제는 낯섦이다. 기존 템플릿 방식이 아닌 XML 스러운 표기법을 그대로 자바스크립트 내에 작성한다. 이러한 방식은 처음에 상당히 혼란스럽게 느껴질 수 있다. 여기에서 거부감을 느끼고, 싫어야해야만 하는 또 다른 이유를 찾아 나서는 경우도 있다. 낯섦이라는 거부감을 잠시 잊어야하는데 말 처럼 쉬운 일은 아니다. 이런 거부감은 나 역시 있었고, 눈에 익는데 시간이 걸렸다(원래 새로운 패러다임은 이해하고 받아들이기 힘든 법이다).

    해당 글에선 <hr>이나 <input>과 같은 한 줄 요소(Single-line element)도 닫아줘야 하는 이유를 HTML 규칙을 구현하는 데 한계가 있기 때문이라 말했지만, JSX는 XML-Like 한 언어이기 때문에 HTML이라기보단 XHTML에 가깝다. 그렇게 보면 당연한 부분이라고 생각한다.

    공식 홈페이지의 JSX 소개 글
    <그림 1. 공식 홈페이지의 JSX 소개 글>

    또한, 여러 문맥에 걸쳐 가상 돔의 속도를 언급한다. 가상 돔의 속도를 inferno와 같이 개선하는 방법도 있었는데 흥미로웠다. 랜더링 시 해당 DOM이 정적 요소인지 동적 요소인지 판단해 정적 요소라면 Diff 단계에서 아예 빼버린다. 이처럼 앞으로도 가상 돔의 속도를 개선할 수 있는 여지가 충분히 남아있다고 판단할 수 있다.

    네이버의 효과툰 뷰 같이 인터렉션이나 애니메이션이 복잡해 성능을 많이 신경 써야 하는 부분이라면 React의 대체재를 찾기보단 해당 부분만 순수 자바스크립트로 구현하고 react-hightchart 처럼 리액트가 읽을 수 있도록 어댑터만 제공하는 게 낫다.

    React Adapter
    <그림 2. React Adapter 다이어그램>

    하나의 라이브러리만으로 서비스 개발 전체를 보완하긴 힘들다. 리액트가 서비스 개발 전체에 정답이 돼 줄 것으로 생각해선 안된다. 그렇다고 특정 부분에 한계가 있다고 다시 전체를 보완할 만한 프레임워크를 찾는 것도 무리다.

    React의 본질은 성능이 아니다. 장점은 개인마다 느끼는 바가 다르겠지만, 개인적으로는 성능이라는 것에 너무 집중할 필요는 없다고 생각한다. React의 철학이 무엇이고 어떤 고통을 해결해주며 또, 어떤 고통은 해결해주지 못하는지 잘 이해하여 프로젝트 성격과 팀의 역량 등을 고려해 시기적절하게 사용하면 된다.

  • 이번에는 불변객체의 개념과 React에 그 개념을 적용했을 때 어떤 이점을 얻을 수 있는지 소개하고자 합니다.

    불변객체란?

    객체 지향 프로그래밍에 있어서 불변객체(Immutable object)는 생성 후 그 상태를 변경할 수 없는 객체를 말합니다. 불변객체의 반대말은 가변객체로 자바스크립트의 배열과 같이 객체 내에서 관리하는 값이나 상태를 변경할 수 있는 것을 말합니다.

    var greeting = new String('Hello World!!');
    
    greeting.replace('World', 'Gil-dong');
    greeting.valueOf(); // Hello World!!

    위 예에서 greeting 변수에 문자열 객체를 생성해 대입했습니다. 그리고 문자열 객체의 replace 메서드를 이용해 ‘World’라는 문자열을 ‘Gil-dong’으로 변경했습니다. 하지만 여전히 greeting의 값은 ‘Hello World’ 입니다.

    greeting에 생성한 문자열 객체는 불변 객체이므로 객체 자신이 소유하거나 관리하는 값 또는 상태를 바꿀 수 없습니다. 따라서 replace 메서드는 새로운 상태를 가지는 또 다른 객체를 생성합니다.

    변수에 값을 바꾸기 위해서는 아래 처럼 새로운 객체를 변수에 대입해야 합니다.

    var greeting = new String('Hello World!!');
    
    greeting = greeting.replace('World', 'Gil-dong');
    greeting.valueOf(); // Hello Gil-dong!!

    값 객체

    이러한 불변 객체의 특성은 우리가 밀접히 사용하는 Number, String, Boolean과 같은 값 객체에서 만날 수 있습니다. 값 객체란 비교 연산 시 자신의 상태보다 값(value)을 우선하는 단순한 객체를 말합니다.

    자바스크립트에서 비교 연산

    여기에서는 이해를 돕기 위해 생성자를 이용해 문자열이나 정수를 생성하고 있지만, 자바스크립트에서 생성자를 이용해 원시 타입 객체를 생성하면 비교 연산 시 참조를 이용해 비교합니다. 따라서 항상 리터럴 표기법으로 값을 다루기 바랍니다.

    
        new String('Hello') === new String('Hello'); // false
        new Number(5) === new Number(5); // false
    

    값 객체는 값을 이용해 새로운 값을 만들어 낼 수 있지만 값 자체를 변경할 수 없습니다. 즉, 불변입니다.

    var num = new Number(2);
    num = num + new Number(3);
    
    num.valueOf(); // 5

    위에서 숫자 2를 생성한 후 숫자 3을 더해 숫자 5를 얻고 있습니다. 숫자 1에 숫자 3을 더하는 것은 값 자체를 바꾸는 것이 아니라 새로운 값을 생성하는 것입니다. 이러한 특징은 상태를 변화시키지 않으며 새로운 값을 생성하는 함수형 스타일(functional style)과 닮았습니다.

    React.js와 불변객체

    React 컴포넌트의 라이프 사이클 메서드 중에는 shouldComponentUpdate 메서드가 있습니다. 이 메서드는 컴포넌트가 다시 그려지기 전에 호출되며 만약 false를 반환하면 컴포넌트의 VirtualDOM을 비교하지 않습니다.

    다량으로 엘리먼트를 출력하는 리스트나 피드와 같은 컴포넌트는 매번 VirtualDOM을 비교하게 되면 성능 문제가 발생할 수 있으므로 필수로 사용해야 하는 메서드입니다(대도록이면 모든 컴포넌트에 작성하는 습관을 들이는게 좋습니다).

    가변 객체일 때

    잘 알려진 TodoMVC를 예를 들어 설명하겠습니다.

    // todoItem.js
    shouldComponentUpdate(nextProps, nextState) {
      return (
        nextProps.todo !== this.props.todo ||
        nextState.label !== this.state.label
      );
    }

    todoItem 컴포넌트의 shouldComponentUpdate 메서드는 prop 속성으로 전달된 todo 객체를 비교하여 VirtualDOM을 비교할지 말지 결정하고 있습니다.

    // todoHome.js
    onUpdate(todoId, label) {
      this.todos.update(todoId, label);
    }
    
    // todos.js
    update(todoId, label) {
        var todo = this._todos.find((todo) => todo.id === todoId);
    
        todo.update(label);
        this.emit('update');
    }

    특정 todo의 label 값을 변경하라고 todos 모델 객체에 요청하고 있습니다. todos 모델 객체는 자신이 관리하는 todo 객체들 중 하나를 찾아서 값을 변경하고 변경 사실을 통지합니다. 하지만 todoItem 컴포넌트의 단순한 비교문으로는 todo 객체의 값이 변경됐는지 알 수 없습니다.

    todos 모델 객체에서 관리하는 todo 객체와 prop 속성으로 전달된 todo 객체의 참조가 동일하기 때문에 항상 참이되므로 의도한 결과를 얻을 수 없는 것입니다.

    // todoItem.js
    shouldComponentUpdate(nextProps, nextState) {
      return (
        nextProps.todo.label() !== this.props.todo.label() ||
        nextProps.todo.completed() !== this.props.todo.completed() ||
        nextState.label !== this.state.label
      );
    }

    shouldComponentUpdate 메서드의 비교문을 변경했습니다. 조금 복잡해졌습니다. 만약 하나의 객체에서 관리하고 있는 상태가 많을수록 이 비교문은 아주 복잡해질 것입니다.

    하지만 여전히 이 코드는 동작하지 않습니다. todos 모델 객체에서 특정 todo 객체의 상태를 변경하면 같은 todo 객체를 참조하는 todoItem 컴포넌트에도 동일하게 반영돼 상태가 변경됐는지 알 수 없습니다. 이처럼 가변 객체의 참조를 가지고 있는 어떤 장소에서 객체를 변경하면 참조를 공유하는 모든 장소에서 그 영향을 받기 때문에 객체를 참조로 다루기란 쉽지 않습니다.

    // todoHome.js
    render() {
        var todos = this.props.todos.forEach((todo) => {
            return <TodoItem key={todo.get('id')} todo={todo.clone()} />;
        });
    
        return (
            <ul>{todos}</ul>
        );
    }

    이번엔 clone 메서드를 이용해서 todo의 객체 상태를 전부 복사하여 새로운 todo 객체를 만들어 todoItem 컴포넌트에 전달하고 있습니다. 이러한 방법을 방어적 복사(defensive copy)라고 합니다.

    드디어 코드는 의도한대로 동작하겠지만, 비교문은 여전히 복잡하며 매번 객체를 전체적으로 복사하는건 성능면에서 좋지 않습니다. 또, 객체의 전달 방식이나 사용 방식을 예의주시해야하는 번거로움도 수반됩니다.

    불변 객체일 때

    이제 todos 모델 객체의 update 메서드를 Immutable.js를 이용해 불변 객체로 관리하도록 변경해보겠습니다.

    // todos.js
    class Todos extends events.EventEmitter {
        constructor() {
            this._todos = new Immutable.List();
        }
    
        // ... 생략 ...
    
        update(id, label) {
            // 새로운 List 객체를 생성한다.
            this._todos = this._todos.update(
                this._todos.findIndex(t => t.get('id') === id),
                t => t.set('label', label) // 새로운 todo 객체를 생성한다.
            )
    
            this.emit('update');
        }
    }

    todos 객체의 생성자 메서드를 통해 Immutable.js의 List 객체를 생성하고 있습니다. 특정 todo 객체의 값을 변경할 때는 List 객체의 update 메서드를 이용해 새로운 상태를 갖는 todo 객체와 List 객체를 다시 생성하여 설정합니다.

    // todoItem.js
    shouldComponentUpdate(nextProps, nextState) {
        return (
            nextProps.todo !== this.props ||
            nextState.label !== this.state.label
        );
    }

    이제 비교문이 다시 단순해졌습니다. 객체의 상태가 변하지 않는 한 참조는 항상 같을 것이고, 객체의 상태가 변경될때만 새로운 객체가 생성되므로 참조가 달라집니다. 따라서 단순히 참조만 비교하는 것 만으로도 객체의 상태가 변경됐는지 판단할 수 있습니다.

    매번 객체를 새로 생성하면 메모리 관리 시스템에 부담을 줄 수 있다고 생각할 수 있지만 이 점이 시스템 전체적인 병목을 일으키진 않습니다. 오히려 객체의 값을 전체적으로 복사하는 방어적 복사가 더 부담이 될 수 있습니다.

    정리

    불변 객체는 값을 복사할 필요 없습니다. 객체를 복사할 때는 항상 같은 객체를 참조하는 주소만 반환하면 됩니다. 즉, 객체를 하나 생성하고 이를 지속적으로 재사용할 수 있습니다(Intern) 이처럼 불변 객체는 복사를 단순화할 수 있어 성능적으로 유리할 수 있습니다. 동일한 값을 여러번 복사해도 참조를 위한 포인터 크기 만큼만 메모리가 늘어날 뿐입니다.

    또한 React.js의 shouldComponentUpdate 메서드를 통해 알 수 있듯이 비교문을 크게 단순화할 수 있습니다. 이 점이 React.js에서 불변 객체를 사용했을때 가장 피부로 체감할 수 있는 부분입니다. 단순한 비교문은 코드를 관리하기 쉽게 만들어줍니다. 반면, 가변 객체를 여러 뷰 컴포넌트에서 의존하면 이를 추적하고 관리하기 쉽지 않을 뿐더러 비교문도 작성하기 어렵습니다.

    Flux 아키텍처에서 말하는 단방향 데이터 흐름과 Immutable.js의 불변 객체, 그리고 수동적인 뷰 특징을 가진 리액트 컴포넌트가 한데 어울어지면 보다 단순하고 사고하기 쉬운 프로그램을 작성할 수 있습니다.

    참고

  • 읽기전에...

    이 문서는 koba04님이 작성한 React.js Advent Calendar를 번역한 것입니다. 본래 원문서는 캘린더 형식으로 소개하지만 여기에서는 회를 나눠 작성할 생각입니다. 또한, React 버전 0.12.1 때 작성된 문서이기 때문에 현 버전과 다른 점이 있을 수 있습니다. 최대한 다른 부분을 노트로 작성할 생각이지만, 만약 생략된 부분이 있다면 댓글로 알려주시면 감사하겠습니다.

    React.js + CSS

    React.js 개발자인 vjeux가 「React:CSS in JS」 라는 주제로 발표를 했는데 그 내용이 꽤 흥미있고 React.js와도 관계가 있는 것이기 때문에 소개하고자 합니다. 또다른 React.js 개발자 zpao의 「React Through the Ages」 라는 발표에서도 이 관점에 관해 언급하고 있습니다.

    CSS를 확장할 때의 문제점

    1. Global Namespace
    2. Dependencies
    3. Dead Code Elimination
    4. Minification
    5. Sharing Constantsn
    6. Non-deterministic Resolution
    7. Isolation

    여기에서 말하는 확장은 페이스북 정도의 규모에서 확장을 말하는 것 같습니다.

    Global Namespace

    CSS에서 모든 것은 글로벌 공간에 선언되기 때문에 명명 규칙 등으로 분할할 필요가 있습니다.(부트스트랩은 600개의 전역 이름을 정의하고 있습니다.)

    Dependencies

    컴포넌트와의 의존 관계를 관리하기 힘듭니다. 컴포넌트 내에서 requireCSS 처럼 CSS를 읽어 들이도록 했다고 하더라도 다른 곳에서 이미 그 CSS를 require 했다면 이미 동작하게 됩니다.

    Dead Code Elimination

    미사용 코드를 검출하기 어렵습니다.

    Minification

    class 명의 minification에 관한 것입니다. (저자: 할 필요가 있는지 의문입니다) 이것도 템플릿(HTML or JS)과 CSS를 대응하여 개발할 필요가 있습니다.

    Sharing Constantsn

    CSS와 JS 측에서 변수를 공유하기 어렵습니다.

    Non-deterministic Resolution

    CSS에서는 상세한 속성이 같은 경우 나중에 작성한 것이 우선됩니다. 그래서 requireCSS 등의 구조를 사용해 컴포넌트와 같이 비동기로 CSS를 읽을 경우 읽는 순서에 따라 다르게 출력돼 의도하지 않는 결과가 발생할 수 있습니다.

    <div class="foo bar">xxx</div>
    .foo {color: red}
    .bar {color: blue}
    
    /* or */
    
    .bar {color: blue}
    .foo {color: red}

    이를 회피하기 위해서 상세한 속성을 수정하는 등의 작업이 필요할 수 있습니다.

    Isolation

    React.js에서 Button 컴포넌트를 만들었을 때 이 button 태그의 스타일을 지정하려면 Button 컴포넌트가 어떤 태그 구조로 구현돼 있는지를 알아야 할 필요가 있어 컴포넌트를 잘 분리 할 수 없습니다.

    <div className="foo">
      <Button/> <!-- <div><button>xxx</button></div> -->
    </div>
    .foo > div {
      ...
    }

    그렇다면 CSS in JS

    위와 같은 문제는 Sass 같은 CSS Preprocessor 등을 사용하거나 설계 레벨에서 해결 가능한 것도 있지만, CSS를 JavaScript의 Object 형태로 컴포넌트의 스타일을 지정하는데 사용하면 문제를 해결할 수 있지 않을까 하는 접근법입니다. 즉, 템플릿(HTML)을 JS의 안에 가지고 온 것(JSX)처럼 CSS도 JS 안으로 가지고 오겠다는 뜻입니다.

    var style = {
      container: {
        backgroundColor: '#ddd',
        width: 900
      }
    }
    
    var Container = React.createClass({
      render() {
        return <div style={style.container}>{this.props.children}</div>;
      }
    });

    아래와 같은 함수를 이용하면 조금 더 유연하게 스타일을 지정할 수 있습니다.

    function m() {
      var res = {};
      for (var i=0; i < arguments.length; ++i) {
        if (arguments[i]) assign(res, arguments[i]);
      }
      return res;
    }
    
    <div style={m(
      style.container,
      { marginTop: 10 },
      this.props.isWarning && {color: 'red'}
    )}>xxx</div>

    또, Prop을 공개해 밖에서 스타일을 지정하도록 할 수 있습니다.

    propTypes: {
      style: React.PropTypes.object
    },
    render() {
      return <div style={m(style.container, this.props.style)}>xxx</div>
    }

    스타일의 우선 순위는 순서를 조절하는 것으로 간단히 변경할 수 있습니다.

    propTypes: {
      style: React.PropTypes.object
    },
    render() {
      return <div style={m(this.props.style, style.container)}>xxx</div>
    }

    이처럼 컴포넌트에 직접 지정하는 것으로 상세한 속성 등은 알 필요 없어지고 JavaScript에 가져오는 것으로 프로그래밍적으로 처리 가능하며 공통화나 상속 등도 간단히 실현할 수 있어 그 결과 처음에 언급한 여러가지 문제를 해결할 수 있습니다. 예에서는 스타일을 컴포넌트의 안에 작성했지만 다른 파일에 작성하고 require 해서 사용할 수도 있습니다.

    JavaScript 쪽으로 마크업을 가지고 온 JSX 처럼 CSS도 JavaScript로 가져오자는 이 접근에 관해 어떻게 생각하시나요? 여기까지 CSS in JS를 소개했습니다.

    React.js in future

    React.js의 향후라는 주제로 이번 절을 작성할까 합니다. React.js의 로드맵은 facebook/react와는 다른 저장소 인 react-future에서 논의되고 있습니다. 여기에 있는 것은 어디까지나 아이디어 수준이지만 구체적인 코드로 설명돼 있어서 어떤 모습일지 예측하기 쉽습니다.

    또, 이전 절에서도 소개했던 「React Through the Ages」 슬라이드에서도 React.js의 현재와 미래에 대해서 이야기하고 있으므로 참고하세요.

    지금까지의 React.js

    React.js는 원래 페이스북이 PHP + XML로 만든 XHP 프로젝트에서 시작됐습니다. 이를 JavaScript에 가져온 것이 React.js입니다. 애플리케이션 전체적으로 rerender 하는 구조는 서버 측의 rendering 방식과 비슷하다는 점에서도 이런 흐름을 예측할 수 있습니다. 또, React.js는 최초엔 Standard ML로 만들어졌다가 그 뒤 Haxe가 되어 지금의 Pure한 JavaScript가 됐습니다.

    1.0과 그 앞

    「React Through the Ages」를 보면 API의 안정화와 삭제 그리고 ES6, 7 사양을 따르려고 하는 의도를 느낄 수 있습니다. ES6, 7에서 사용할 수 있는 기능을 최대한 활용하여 React.js 자체에서는 부가적인 처리를 하지 않겠다는 방향성을 엿볼 수 있습니다.

    class Button extends React.Component {
      getInitialState() {
        return {count: 0};
      },
      render() {
        return (
          <button onClick={() => this.setState({count: this.state.count + 1}) }>
            {this.state.count}
          </button>
        );
      }
    }
    • CSS in JS : 이는 이전 절에서 소개한 CSS의 문제를 해결하기 위한 접근 방식입니다.
    • Web Workers : VirtualDOM 계산을 WebWorkers에서 하는 것으로 UI 단에 좋은 영향을 줄 수 있다면 도입하고 싶다고 합니다.
    • Layout & Animation : 어떠한 방식으로 정의하도록 하느냐가 어려운 문제이지만 중요한 기능이기 때문에 지원하고 싶다고 합니다.
    • M(V)C : 자신(페이스북)은 필요하지 않지만 많은 개발자가 React.js를 사용했을 때의 MVC의 M과 C에 대해 논의하거나 개발하고 있는 것을 보고 이에 대한 지원도 중요한 사항으로 여기는 것 같습니다. React.js가 풀-프레임워크가 되는 일은 없을 것 같습니다만…
    • Other : 이 외에도 새로운 테스트 지원이나 문서, Immutable Data 등 다양한 아이디어가 있는 것 같습니다.

    React.js의 미래

    react-future의 저장소를 보면 ES6, 7의 기능을 도입할 경우의 형태를 볼 수 있습니다. 단, 여기에서 소개하는 기능은 아직 구현돼 있지 않고 합의된 것도 아니기 때문에 이렇게 지원된다고 장담할 순 없습니다.

    역자노트

    일부 기능은 이미 사용할 수 있습니다. 원문이 2014년 12월에 작성됐다는 사실을 감안해주세요.

    Class

    import {Component} from 'react';
    
    export class Button extends Component {
      props : {
        width: number
      }
      static defaultProps = {
        width: 100
      }
      state = {
        counter: Math.round(this.props.width / 10)
      }
      handleClick = (event) => {
        event.preventDefault();
        this.setState({counter: this.state.counter + 1});
      }
      render(props, state) {
        return (
          <div>
            This button has been clicked: {state.counter} times
            <button onClick={this.handleClick} style={{ idth: props.width}}/>
          </div>
        );
      }
    }

    ES6의 Module이나 Class, ArrowFunction 등이 사용됐고 React.js 독자적인 부분이 적어졌습니다. 또 props의 형 지정 방식도 변경 됐는데 이는 facebook/flow와 연계될 수도 있을 것 같습니다. (댓글에는 TypeScript compatible syntax로 작성돼 있지만) 또, render에 props와 state를 인자로 전달하는 것 같은 형태로 돼 있습니다.

    mixin

    import { mixin } from 'react-utils';
    
    const A = {
      componentDidMount() {
        super();
        console.log('A');
      }
    };
    
    class B extends mixin(A) {
      componentDidMount() {
        console.log('B');
        super();
      }
    }
    
    new B().componentDidMount(); // B, A
    
    import { Component } from 'react';
    
    class Component extends mixin(Component, C) {
      render() {
        return <div/>;
      }
    }

    mixin은 util로써 준비하고, super로 부모의 것을 호출하는 식으로 디자인돼 있습니다. state의 merge 방식에 관한 문제가 있는 것 같습니다.

    Stateless Functions

    export function Button(props : {width: number, onClick: function}) {
      return (
        <div>
          Fancy button
          <button onClick={props.onClick} style={{width: props.width}}/>
        </div>
      );
    }

    Prop 만을 갖는 Stateless한 컴포넌트는 Prop을 전달받는 함수로써 정의할 수 있도록 돼 있습니다.

    Elements

    JavaScript 객체 문법이나 JSX 이외에도 여러가지 방법으로 React Element를 작성할 수 있도록 하고자 하는 바램이 있는 것 같습니다.

    Object 리터럴
    {
      type: Button,
      props: {
        foo: bar,
        children: [
          { type: 'span', props: { children: a } },
          { type: 'span', props: { children: b } }
        ]
      },
      // optional
      key: 'mybutton',
      ref: myButtonRef
    }
    Native Components

    React.DOM 이하의 API는 없어지고 단순한 문자열로 정의할 수 있도록 돼 있습니다. 또 Web Components의 커스텀 태그에도 호환성을 지니게 돼 있습니다.

    Template Strings

    ES6의 Template Strings을 이용해 정의할 수 있도록 돼 있습니다.

    X`
     <my-button foo=${bar} key="mybutton" ref=${myButtonRef}>
       <span>${a}</span>
       <span>${b}</span>
     </my-button>
    `

    이외에도 여러가지 소개하고 있으므로 흥미가 있다면 꼭 한번 읽어보시길 바랍니다.

    React.js에 관한 리소스, 그리고 정리

    여기까지 React.js를 소개했습니만, 조금이라도 사용하는데 참고가 됐다면 좋겠습니다. React.js는 facebook, instagram이나 Github의 AtomEditor 물론, 「Atlassian」, 「Netflix」, 「Reddit」, 「The New York Times」, 「Yahoo」 등 많은 곳에서 사용하고 있는 것 같습니다(참고).

    또, 내년 1월말에는 React.js Conf가 있으므로 여러가지 소식이 공유되고 점점 분위기도 무르익을 것으로 생각됩니다. 내년도 즐거운 한해가 될 것 같습니다.

    React.js의 공식 블로그#reactjs 해쉬 태그를 구독하면 여러가지 정보를 모을 수 있습니다.

    리소스 정리

    개인적으로 읽고 재미있었던 것이나 공식적인 사이트를 정리해봤습니다.

    공식 사이트

    컴포넌트, 샘플 모음집

    • React Coponents: 공개된 React.js 컴포넌트가 정리돼 있습니다.
    • React Rocks: React.js의 샘플이나 데모가 모여있습니다.

    입문

    Virtual DOM

    Flux

    • Isomorphic Flux: Yahoo가 Isomorphic한 Flux 애플리케이션을 만든 이야기입니다.
    • flux-meetup: 페이스북의 개발자가 하는 React.js와 Flux에 관한 설명입니다.

    Developer tool

    소개하는 것을 깜빡 잊고 있었습니다. React.js 개발을 할 때에 편리하게 사용할 수 있는 크롬 확장 도구인 React Developer Tools도 있습니다.

    아래 그림과 같이 React.js를 사용하는 페이지에 가면 개발자 도구에 React탭이 표시되고 거기서 HTML의 태그가 아니라 Component로 볼 수 있습니다. 또, Prop과 State및 EventListener와 Component의 값도 확인 할 수 있어 편리하게 디버깅할 수 있습니다.

    React Developer Tools
    <그림 1 React Developer Tools>

    정리

    역시 읽을 때와 번역해서 공유할 때 느낌은 많이 다르네요. 알아서 이해했던 것들도 신경 써야 하니 시간이 좀 걸렸습니다. koba04님의 React.js Advent Calendar는 제가 처음 React.js를 학습할때 도움을 받았던 문서였기 때문에 무엇부터 차근차근 봐야 할지 모르시는 분들이 있을 것 같아서 일본어 문서를 번역했습니다. React.js를 이해하는데 많은 도움이 되길 간절히 바라면서 이만 마치도록 하겠습니다.

    여기까지 React.js를 소개했습니다. 끝까지 읽어주셔서 감사합니다!

  • 읽기전에...

    이 문서는 koba04님이 작성한 React.js Advent Calendar를 번역한 것입니다. 본래 원문서는 캘린더 형식으로 소개하지만 여기에서는 회를 나눠 작성할 생각입니다. 또한, React 버전 0.12.1 때 작성된 문서이기 때문에 현 버전과 다른 점이 있을 수 있습니다. 최대한 다른 부분을 노트로 작성할 생각이지만, 만약 생략된 부분이 있다면 댓글로 알려주시면 감사하겠습니다.

    React.js의 테스트

    이번에는 React.js 환경에서 테스트하는 방법을 소개하겠습니다.

    React.js와 테스트

    React.js는 컴포넌트에 대응하여 테스트를 작성해야 하므로 DOM을 의존하여 힘들 것으로 예상하지만 React.addons.TestUtils라는 Addon이 테스트에 편리한 함수를 제공하고 있으므로 이를 이용하면 더 쉽게 테스트를 작성할 수 있습니다.

    DOM이 필요할까?

    React.js 컴포넌트는 서버-사이드에서도 사용할 수 있으므로 node.js 환경에서 테스트를 작성하고 싶을 수 있지만 onClick이나 onKeyUp 같은 이벤트에 실제로 반응하는지 테스트하기 위해서 DOM이 필요합니다. 단순히 Prop 값을 전달하고 renderToStaticMarkup을 사용하여 결괏값인 HTML을 테스트하는 경우엔 node.js 환경에서 작성할 수 있습니다.

    이벤트 시뮬레이트

    「버튼을 클릭하면」이라는 테스트를 작성하고자 할 때 DOM을 셀렉트하고 값을 설정하여 이벤트를 발생시키는 일련의 과정이 필요하지만, React.addons.TestUtils.Simulate를 사용하면 DOM을 지정하고, 전달하고 싶은 이벤트 객체의 형식을 지정할 수 있으므로 격식없이 사용자 액션 테스트를 작성할 수 있습니다.

    Simulate.{eventName}(DOMElement element, object eventData)
    var node = this.refs.input.getDOMNode();
    
    React.addons.TestUtils.Simulate.click(node);
    
    // 전달하고자 하는 이벤트 객체를 지정한다.
    React.addons.TestUtils.Simulate.change(node, {target: {value: 'Hello, world'}});
    React.addons.TestUtils.Simulate.keyDown(node, {key: 'Enter'});

    컴포넌트 작성 지원

    renderIntoDocument

    renderIntoDocument를 사용하면 DOM에 컴포넌트를 실제로 추가하지 않아도 테스트할 수 있습니다. 아래 예제를 보면 일단 renderintoDocument가 컴포넌트를 DOM에 추가해 나갈 것으로 보입니다.

    var Hello = require('./components/hello');
    var component = React.addons.TestUtils.renderIntoDocument(<Hello name="foo" />);

    하지만 이것은 실제 DOM 트리에 추가되는 것이 아니라 document.createElement로 생성한 div에 render 할 뿐입니다. 그래서 요소의 실제 높이나 너비 등은 알 수 없습니다. (이름이 다소 혼란스럽기 때문에 변경될 수 있을 것 같습니다)

    mockComponent

    Jest를 사용하고 있을 때 mock 컴포넌트에서 더미로 <div/>(엘리먼트 요소)를 반환하도록 하는 mockComponent도 있습니다. 이 함수를 사용하기 위해서는component.prototype.render.mockImplementation이 작성되어야 하는데 Jest를 고려한 함수(mockFn.mockImplementation(fn)) 인듯합니다. 자주 쓰일지 모르겠습니다만, 보통 Mock으로 작성한 컴포넌트에서 render를 동작시키고 싶을 때 사용하는 듯합니다.

    mockComponent: function(module, mockTagName) {
      mockTagName = mockTagName || module.mockTagName || 'div';
    
      module.prototype.render.mockImplementation(function() {
        return React.createElement(
          mockTagName,
          null,
          this.props.children
        );
      });
    
      return this;
    },

    컴포넌트 셀렉트

    findAllInRenderedTree(ReactComponent tree, function test)

    특정 컴포넌트의 하위 컴포넌트 중에서 지정한 함수의 조건을 충족한 컴포넌트만 배열로 반환합니다. 아래에서 소개할 함수를 사용할 수 없는 경우에 사용할 수 있는 가장 기본적인 구현입니다.

    console.log(
      React.addons.TestUtils.findAllInRenderedTree(
        React.render(<div><span>foo</span><span>bar</span><p>baz</p></div>, document.body),
        function(component) { return component.tagName === 'SPAN' }
      ).map(function(component){ return component.getDOMNode().textContent })
    );
     
    // ['foo', 'bar']

    scryRenderedDOMComponentsWithClass(ReactComponent tree, string className)

    특정 컴포넌트의 하위 컴포넌트 중, 지정한 className에 해당하는 컴포넌트를 배열로 반환합니다.

    console.log(
      React.addons.TestUtils.scryRenderedDOMComponentsWithClass(
        React.render(
          <div>
            <span className="foo">foo1</span>
            <span className="foo">foo2</span>
            <span className="bar">barbar</span>
          </div>,
          document.body
        ),
        'foo'
      ).map(function(component){ return component.getDOMNode().textContent })
    );
     
    // ['foo1', 'foo2']

    findRenderedDOMComponentWithClass(ReactComponent tree, string className)

    특정 컴포넌트의 하위 컴포넌트 중, 지정한 className에 해당하는 컴포넌트를 1개만 반환합니다.

    console.log(
      React.addons.TestUtils.findRenderedDOMComponentWithClass(
        React.render(
          <div>
            <span className="foo">foo1</span>
            <span className="foo2">foo2</span>
            <span className="bar">barbar</span>
          </div>,
          document.body
        ),
        'foo'
      ).getDOMNode().textContent
    );
     
    // ['foo1']

    해당하는 컴포넌트가 없거나 여러개가 매치되면 오류를 발생시킵니다.

    console.log(
      React.addons.TestUtils.findRenderedDOMComponentWithClass(
        React.render(
          <div>
            <span className="foo">foo1</span>
            <span className="foo">foo2</span>
            <span className="bar">barbar</span>
          </div>,
          document.body
        ),
        'foo'
      ).getDOMNode().textContent
    );
     
    //  Uncaught Error: Did not find exactly one match for class:foo

    scryRenderedDOMComponentsWithTag(ReactComponent tree, string tagName)

    특정 컴포넌트의 하위 컴포넌트 중, 지정한 태그 네임에 해당하는 컴포넌트를 배열로 반환합니다.

    console.log(
      React.addons.TestUtils.scryRenderedDOMComponentsWithTag(
        React.render(
          <div>
            <span>foo1</span>
            <span>foo2</span>
            <p>barbar</p>
          </div>,
          document.body
        ),
        'span'
      ).map(function(component){ return component.getDOMNode().textContent })
    );
     
    // ['foo1', 'foo2']

    findRenderedDOMComponentWithTag(ReactComponent tree, string tagName)

    특정 컴포넌트의 하위 컴포넌트 중, 지정한 className에 해당하는 컴포넌트를 1개만 반환합니다. 해당하는 컴포넌트가 없거나 여러개가 매치되면 오류를 발생시킵니다.

    console.log(
      React.addons.TestUtils.findRenderedDOMComponentWithTag(
        React.render(
          <div>
            <span>foo1</span>
            <span>foo2</span>
            <p>barbar</p>
          </div>,
          document.body
        ),
        'p'
      ).getDOMNode().textContent
    );
     
    // barbar

    scryRenderedComponentsWithType(ReactComponent tree, function componentClass)

    특정 컴포넌트의 하위 컴포넌트 중, 지정한 컴포넌트의 인스턴스에 해당하는 컴포넌트를 배열로 반환합니다.

    console.log(
      React.addons.TestUtils.scryRenderedComponentsWithType(
        React.render(
          <div>
            <Hello name="foo" key="foo" />
            <Hello name="bar" key="bar" />
            <span>xxx</span>
            <p>zzz</p>
          </div>,
          document.body
        ),
        Hello
      ).map(function(component){ return component.getDOMNode().textContent })
    );
     
    // ['foo', 'bar']

    findRenderedComponentWithType(ReactComponent tree, function componentClass)

    특정 컴포넌트의 하위 컴포넌트 중, 지정한 컴포넌트의 인스턴스에 해당하는 컴포넌트를 1개만 반환합니다. 해당하는 컴포넌트가 없거나 여러개가 매치되면 오류를 발생시킵니다.

    console.log(
      React.addons.TestUtils.findRenderedComponentWithType(
        React.render(
          <div>
            <Hello name="foo" key="foo" />
            <span>xxx</span>
          </div>,
          document.body
        ),
        Hello
      ).getDOMNode().textContent
    );
     
    // foo

    Assert

    React 컴포넌트의 상태를 확인하기 위한 함수들의 모음입니다.

    isElementOfType(ReactElement element, function componentClass)

    특정 컴포넌트가 지정한 컴포넌트의 인스턴스에 해당하는지를 판단합니다.

    React.addons.TestUtils.isElementOfType(<Hello />, Hello);

    isDOMComponent(ReactComponent instance)

    특정 컴포넌트가 div나 span과 같은 DOM 컴포넌트인지 판단합니다.

    React.addons.TestUtils.isDOMComponent(
      React.render(<div />, document.body)
    );

    isCompositeComponent(ReactComponent instance)

    특정 컴포넌트가 React.createClass에 의해 정의된 컴포넌트를 포함해 작성된 것인지 판단합니다. div나 span 등은 포함하지 않습니다.

    React.addons.TestUtils.isCompositeComponent(
      React.render(<Hello />, document.body)
    );

    isCompositeComponentWithType(ReactComponent instance, function componentClass)

    특정 컴포넌트가 지정한 Component 타입을 포함해 작성된 것인지 판단합니다.

    React.addons.TestUtils.isCompositeComponentWithType(
      React.render(<Hello />, document.body), Hello
    );

    isTextComponent(ReactComponent instance)

    특정 컴포넌트가 텍스트 컴포넌트를 반환하는지 판단합니다.

    var textComponents = React.addons.TestUtils.findAllInRenderedTree(
      React.render(
        <div>{'hello'}{'react'}</div>,
        document.body
      ),
      function(component) {
        return React.addons.TestUtils.isTextComponent(component)
      } 
    );
    console.log(textComponents[0].props + ' ' + textComponents[1].props);
    // hello react

    여기까지 TestUtils의 종류와 사용 방법을 설명했습니다. 다음 절에서는 페이스북이 만들고 배포한 테스트 프레임워크인 Jest와 조합하는 방법을 소개하고자 합니다.

    React.js와 Jest

    이전에는 TestUtils를 사용하는 방법을 중심으로 설명했습니다. 이번에는 facebook이 개발하고 있는 Jest라고 하는 프레임워크와 함께 구성해 보고자 합니다.

    Painless JavaScript Unit Testing

    Jest는 공식 홈페이지에서 「Painless JavaScript Unit Testing」 문구를 대표적으로 소개하고 있으며 도입하기 쉽다는 특징을 가지고 있습니다. 그 특징으로는 「Mock By Default」가 있는데 기본적으로 Jest에서는 CommonJS Style의 require 구문이 Mock을 반환하도록 설정합니다. 조금 과격한 느낌입니다만 테스트 대상이 되는 동작에만 민감한 테스트를 간단하게 작성할 수 있습니다. 반대로 테스트 대상 이 외는 모두 Mock으로 대체 되므로 인터페이스 밖에 테스트 할 수 없지만, 그것은 Unit Test의 범위 밖으로 볼 수 있어서 큰 문제가 되지 않습니다.

    Jasmine

    Jest는 Jasmine을 기반으로 만들어졌습니다. 따라서 Assert 등과 같은 기본적인 문법은 Jasmine과 같습니다. 단, Jasmine 2.0에서 비동기 테스트를 작성하기 보다 쉬워졌지만 1.3을 기반으로 하고 있어 이를 이용할 수 없습니다(issues/74).

    DOM

    Jest는 jsdom으로 생성한 DOM 위에서 실행되므로 Node.js 환경처럼 CLI로 테스트를 실행할 수 있습니다. 즉, Jest를 사용하면 Karma 같은 Test Runner를 사용할 필요가 없으므로 간단하게 도입할 수 있습니다.

    Install

    jest-cli만 설치하면 됩니다.

    $ npm install --save-dev jest-cli

    tests

    기본적으로 tests 디렉터리를 찾습니다. 그리고 그 디렉터리 내의 파일을 테스트로써 실행합니다. 따라서 Getting Started에서도 알 수 있듯이 tests 디렉터리를 내에 테스트 파일를 두고 jest를 실행하면 테스트가 진행됩니다. 만약 jest-cli를 전역이 아닌 devDependencies에 설치한다면 package.json의 scripts 프로퍼티에 npm test로 실행할 수 있도록 아래처럼 작성하면 편리하게 사용할 수 있습니다.

    "scripts": {
      "test": "jest"
    }

    React.js를 테스트한다.

    Jest의 Tutorial – React 문서에 React.js를 사용한 애플리케이션을 테스트하는 경우도 작성돼 있습니다. 테스트하기 위해서는 두 가지 설정을 할 필요가 있습니다.

    JSX의 변환

    JSX를 사용해 애플리케이션을 작성한 경우에는 테스트를 위해 JSX를 변환할 필요가 있습니다. package.json의 Jest 프로퍼티에 scriptPreprocessor로 사전에 동작해야할 script를 지정합니다.

    // package.json
    "jest": {
      "scriptPreprocessor": "preprocessor.js"
    },
    
    // preprocessor.js
    var ReactTools = require('react-tools');
    module.exports = {
      process: function(src) {
        return ReactTools.transform(src, {harmony: true});
      }
    };

    Mock의 해제

    위에서 언급한 것처럼 Jest에서는 모든 require 구문이 Mock을 반환합니다. 단, React도 Mock으로 대체되면 테스트할 수 없으므로 react를 Mock으로 대체하지 않도록 경로를 설정할 필요가 있습니다. 이러한 설정도 package.json에 속성을 추가하는 것으로 간단하게 할 수 있습니다. 테스트 파일에서도 Mock하지 않을 파일을 지정할 수 있지만, 만약 모든 테스트에서 Mock 하고 싶지 않은 파일이 있다면 아래와 같이 작성합니다.

    "jest": {
      "scriptPreprocessor": "preprocessor.js",
      "unmockedModulePathPatterns": ["node_modules/react"]
    },

    테스트 작성해보기

    아래와 비슷한 느낌으로 React 컴포넌트의 테스트를 작성할 수 있습니다.(참고)

    jest.dontMock('../InputArtist');
     
    var React = require('react/addons'),
        InputArtist = require('../InputArtist'),
        AppTracksActionCreators = require('../../actions/AppTracksActionCreators')
    ;
     
    describe('inputArtist', function() {
      var inputArtist;
      beforeEach(function() {
        inputArtist = React.addons.TestUtils.renderIntoDocument(<InputArtist />);
      });
     
      describe('state',  function() {
        it('set inputArtist radiohead', function() {
          expect(inputArtist.state.inputArtist).toBe('radiohead');
        });
      });
     
      describe('handleSubmit', function() {
        var preventDefault;
        beforeEach(function() {
          preventDefault = jest.genMockFunction();
          inputArtist.setState({ inputArtist: 'travis' });
          React.addons.TestUtils.Simulate.submit(inputArtist.getDOMNode(), {
            preventDefault: preventDefault
          });
        });
        it ('calls AppTracksActionCreators.fetchByArtist with state.inputArtist', function() {
          expect(AppTracksActionCreators.fetchByArtist).toBeCalled();
          expect(AppTracksActionCreators.fetchByArtist).toBeCalledWith('travis');
        });
        it ('calls e.preventDefault', function() {
          expect(preventDefault).toBeCalled();
        });
      });
    });
    Jest 동작 테스트
    <그림 1 Jest 동작 테스트>

    그럼 코드를 자세히 살펴보겠습니다.

    jest.dontMock('../InputArtist');

    Mock으로 대체할 필요가 없는 module은 dontMock에 명시적으로 지정합니다.

    var React = require('react/addons'),
        InputArtist = require('../InputArtist'),
        AppTracksActionCreators = require('../../actions/AppTracksActionCreators')
    ;

    React는 package.json의 unmockedModulePathPatterns의 지정했으므로 Mock으로 대체되지 않습니다. 그 외 다른 모듈은 Mock으로 대체됩니다.

    describe('inputArtist', function() {
      var inputArtist;
      beforeEach(function() {
        inputArtist = React.addons.TestUtils.renderIntoDocument(<InputArtist />);
      });
     
      describe('state',  function() {
        it('set inputArtist radiohead', function() {
          expect(inputArtist.state.inputArtist).toBe('radiohead');
        });
      });

    이 코드는 보통의 Jasmine 테스트 코드와 같습니다. React.addons.TestUtils.renderIntoDocument를 사용하여 Component를 DOM에 붙여서 테스트하고 있습니다.

    describe('handleSubmit', function() {
      var preventDefault;
      beforeEach(function() {
        preventDefault = jest.genMockFunction();
        inputArtist.setState({ inputArtist: 'travis' });
        React.addons.TestUtils.Simulate.submit(inputArtist.getDOMNode(), {
          preventDefault: preventDefault
        });
      });
      it ('calls AppTracksActionCreators.fetchByArtist with state.inputArtist', function() {
        expect(AppTracksActionCreators.fetchByArtist).toBeCalled();
        expect(AppTracksActionCreators.fetchByArtist).toBeCalledWith('travis');
      });
      it ('calls e.preventDefault', function() {
        expect(preventDefault).toBeCalled();
      });

    위는 submit 버튼이 클릭 됐을 때 fetchByArtist와 e.preventDefault가 호출되는지 테스트하는 코드입니다. React.addons.TestUtils.Simulate.submit를 사용해 submit 이벤트를 발생시켜 이벤트 객체의 jest.genMockFunction 생성한 preventDefault Mock 함수을 통해서 호출됐지 확인합니다. fetchByArtist는 실제로 Ajax 요청을 하지만 Jest가 Mock으로 대체했으므로 특별히 의식하지 않고 간단하게 테스트를 작성할 수 있습니다.

    Mock

    Mock은 jest.genMockFunction과 같은 API로 직접 만드는 것도 가능하며 mock property에 calls나 instances 등의 호출 정보가 기록되므로 이 기록을 사용해 테스트를 작성할 수 있습니다. 또, Mock Function의 mockReturnValue를 사용해 지정한 값을 반환하도록 할 수 있고 mockimplementation에 callback을 전달하는 것으로 직접 Mock을 구현할 수도 있습니다.

    Mock Assert

    Mock을 확인하기 위한 assert도 준비돼 있습니다. expect(mockFunc).toBeCalled와 같이 테스트를 작성할 수 있습니다.

    module 교체

    mocks 디렉터리를 생성하여 그 안에 module 구현을 작성하는 하면 테스트 시 모듈 자체를 항상 대체할 수 있습니다. superagent를 Mock으로 대체하면 에러가 발생하는 이슈가 있는데, 이를 방지하기 위해 mocks/superagent.js에서 workaround로 Mock을 두고 있습니다.

    Timer

    setTimeout이나 setInterval을 사용하는 구현을 테스트하는 경우 jset.runAllTimers나 jset.runOnlyPendingTimers를 사용하여 동기적으로 테스트를 작성할 수 있습니다. runAllTimers는 setTimeout이나 setInterval 큐에 존재하는 모든 태스크를 실행하고 runOnlyPendingTimers는 호출한 시점에서 대기중인 태스크만 실행합니다. setTimeout으로 반복하고 있는 구현의 경우 runAllTimers를 사용하면 무한 루프에 빠지므로 runOnlyPendingTimers를 사용해 한 번에 하나씩 테스트를 진행하도록 작성합니다.

    API

    API는 공식 홈페이지의 API Reference에 정리돼 있습니다. 여기에서 전부 소개하진 않지만 여러 상황에 대응한 API를 제공하고 있음을 알 수 있습니다.

    불편한 점

    이것저것 설정하여 해결할 수 있을지 모르지만, Karma와 비교할 때 상대적으로 테스트 실행이 느립니다. 이슈(issues/116)로도 등록돼 있으므로 빨리 개선되길 바랍니다.

    여기까지 Jest를 소개하겠습니다. 다음 절에서는 Flux를 소개하겠습니다.

    React.js와 Flux

    이번에는 React.js와 관계가 깊은 Flux를 소개하겠습니다.

    Flux is Architecture

    Flux 아키텍처
    <그림 2 Flux 아키텍처>

    위는 깃-허브 저장소에 명시된 그림입니다. Flux는 위와 같은 아키텍처의 명칭이기도 합니다. 조금 더 살펴보면 알겠지만, Dispatcher 부분만 구현하고 있습니다.

    Unidirectional data flow

    위 아키텍처를 보면 알 수 있듯이 Flux는 애플리케이션의 복잡함을 없애기 위해서 데이터의 흐름을 단방향 운영합니다. 이런 방식은 전체적인 처리 흐름을 알기 쉽지만 Angular.js 등과 비교했을 때 상대적으로 표현이나 문법이 장황한 느낌이 있습니다. 그렇지만 데이터의 흐름을 단순하게 만드는 것으로 애플리케이션의 규모가 커져 복잡화돼도 데이터나 이벤트의 흐름이 엉키지 않고 파악하기 쉬운 구조를 유지할 수 있다고 합니다. (실제로 Flux를 사용해 대규모 애플리케이션을 구현해보지 않아서 단언할 순 없습니다)

    자, 그럼 react-boilerplate를 예제를 사용해 본격적으로 Flux를 소개하겠습니다.

    Flux의 구성 요소

    Constants

    Flux에서는 각 요소 간 주고 받을 타입을 상수처럼 정의합니다.

    var keyMirror = require('react/lib/keyMirror');
     
    module.exports = {
      ActionTypes: keyMirror({
        RECEIVE_TRACKS_BY_ARTIST: null,
        RECEIVE_TRACKS_BY_COUNTRY: null
      }),
      PayloadSources: keyMirror({
        VIEW_ACTION: null
      })
    };

    참고로 keyMirror는 key를 사용해 value로 설정해주는 Util 입니다.

    Dispatcher

    Dispatcher는 Action을 받아 등록된 callback을 실행합니다. 여기에서는 facebook/flux가 유일하게 제공하고 있는 Dispatcher를 확장하는 느낌으로 오브젝트를 생성해 싱들톤으로 반환합니다. 여기에서는 ActionCreators부터 Dispatcher에 Acton을 던지기 위한 handleViewAction을 정의하고 있습니다.

    var Dispatcher    = require('flux').Dispatcher,
        assign        = require('object-assign'),
        AppConstants  = require('../constants/AppConstants')
    ;
     
    var PayloadSources = AppConstants.PayloadSources;
     
    module.exports = assign(new Dispatcher(), {
      handleViewAction: function(action) {
        this.dispatch({
          source: PayloadSources.VIEW_ACTION,
          action: action
        });
      }
    });

    Store

    Store는 애플리케이션의 데이터와 비즈니스 로직을 담당합니다. Store에서 담당하는 데이터는 메시지 목록과 같은 집합도 다룹니다.

    var AppDispatcher = require('../dispatcher/AppDispatcher'),
        AppConstants  = require('../constants/AppConstants'),
        EventEmitter  = require('events').EventEmitter,
        assign        = require('object-assign')
    ;
     
    var ActionTypes = AppConstants.ActionTypes;
    var CHANGE_EVENT = 'change';
    var tracks = [];
     
    var TrackStore = assign({}, EventEmitter.prototype, {
     
      emitChange: function() {
        this.emit(CHANGE_EVENT);
      },
      addChangeListener: function(callback) {
        this.on(CHANGE_EVENT, callback);
      },
      removeChangeListener: function(callback) {
        this.removeListener(CHANGE_EVENT, callback);
      },
      getAll: function() {
        return tracks;
      },
    });
     
    TrackStore.dispatchToken = AppDispatcher.register(function(payload) {
      var action = payload.action;
     
      switch (action.type) {
        case ActionTypes.RECEIVE_TRACKS_BY_ARTIST:
          tracks = action.tracks;
          TrackStore.emitChange();
          break;
        case ActionTypes.RECEIVE_TRACKS_BY_COUNTRY:
          tracks = action.tracks;
          TrackStore.emitChange();
          break;
      }
    });
     
    module.exports = TrackStore;

    여기에서 눈여겨 봐야 할 포인트는 다음과 같습니다.

    • getter 메서드만 정의하여 외부에서 데이터에 접근할 수 없는 형태로 유지합니다.
    • 데이터의 갱신은 ActionCreator에서 Despatcher에 전달하여 등록한 callback 함수를 호출하여 실시합니다.
    • Dispatcher에 callback을 등록하여 처리 할 수 있도록 합니다.
    • Store는 EventEmiiter의 기능을 가지고 있어 데이터가 갱신되면 이벤트를 발행합니다.
    • View는 Store의 이벤트를 구독합니다.

    ActionCreators (Action)

    Action을 생성해 Dispatcher에 전달합니다. 이 문서의 예제에서는 Ajax 요청도 ActionCreators 내에서 담당하고 있지만 facebook/flux의 예제에서는 Utils 이라고 하는 네임스페이스를 만들어 그 안에서 담당하도록 디자인돼 있습니다. Ajax이 끝난 시점뿐만 아니라 시작한 시점에도 Action을 발생시켜 로딩하는 View를 출력할 수도 있을 것 같습니다.

    var request = require('superagent'),
        AppDispatcher = require('../dispatcher/AppDispatcher'),
        AppConstants  = require('../constants/AppConstants')
    ;
     
    var ActionTypes = AppConstants.ActionTypes;
    var urlRoot = 'http://ws.audioscrobbler.com/2.0/?api_key=xxxx&format=json&';
     
    // TODO Loading
    module.exports = {
      fetchByArtist: function(artist) {
        request.get(
          urlRoot + 'method=artist.gettoptracks&artist=' + encodeURIComponent(artist),
          function(res) {
            AppDispatcher.handleViewAction({
              type: ActionTypes.RECEIVE_TRACKS_BY_ARTIST,
              tracks: res.body.toptracks.track
            });
          }.bind(this)
        );
      },
      fetchByCountry: function(country) {
        request.get(
          urlRoot + 'method=geo.gettoptracks&country=' + encodeURIComponent(country),
          function(res) {
            AppDispatcher.handleViewAction({
              type: ActionTypes.RECEIVE_TRACKS_BY_ARTIST,
              tracks: res.body.toptracks.track
            });
          }.bind(this)
        );
      }
    };

    Action은 아래와 같은 형태의 리터럴 객체입니다.

    {
      type: ActionTypes.RECEIVE_TRACKS_BY_ARTIST,
      tracks: res.body.toptracks.track
    }

    View (ReactComponent)

    데이터를 출력하는 View와 Action을 발생하는 View를 나누어서 소개하겠습니다.

    Store의 데이터를 출력하는 컴포넌트

    View에서는 componentDidMount로 Store의 change 이벤트를 구독하고 componentWillUnmount에서 구독을 해제하고 있습니다. change 이벤트가 발행되면 Store에서 다시 데이터를 가져와 setState에 설정합니다. 여기에서 Store 데이터는 동기적으로 취득할 수 있다고 전제하고 있습니다.

    module.exports = React.createClass({
      getInitialState() {
        return {
          tracks: TrackStore.getAll(),
        };
      },
      componentDidMount: function() {
        TrackStore.addChangeListener(this.<onChange);
      },
      componentWillUnmount: function() {
        TrackStore.removeChangeListener(this.<onChange);
      },
      <onChange: function() {
        this.setState({ tracks: TrackStore.getAll() });
      },
      render() {
        var tracks = this.state.tracks.map( (track, index) => {
          return (
            <li className="list-group-item" key={index}>
              <span className="label label-info">{index+1}</span>
              <a href={track.url} target="<blank"><span className="track">{track.name}</span></a>
              <span className="artist">{track.artist.name}</span>
              <small className="listeners glyphicon glyphicon-headphones">{track.listeners}</small>
            </li>
          );
        });
        return (
          <div className="tracks">
            <ul className="list-group">
              {tracks}
            </ul>
          </div>
        );
      }
    });
    Action을 발생시키는 컴포넌트

    이번에는 이벤트를 받아서 ActionCreator에 전달하는 컴포넌트입니다.

    AppTracksActionCreators.fetchByArtist(artist);
        }
      },
      render() {
        return (
          <form className="form-horizontal" role="form" onSubmit={this.handleSubmit} >
            <div className="form-group">
              <label htmlFor="js-input-location" className="col-sm-1 control-label">Artist</label>
              <div className="col-sm-11">
                <input type="text" className="form-control" placeholder="Input Atrist Name" valueLink={this.linkState('inputArtist')} required />
              </div>
            </div>
            <div className="form-group">
              <div className="col-sm-offset-1 col-sm-11">
                <button type="submit" className="btn btn-primary"><span className="glyphicon glyphicon-search">search</span></button>
              </div>
            </div>
          </form>
        );
      }
    });
    역자노트

    Flux를 조금 더 알고 싶다면 「페이스북의 결정: MVC는 확장에 용이하지 않다. 그렇다면 Flux다.」와 「다같이! FluxUtils 한바퀴」를 참고해주세요.

    이 모두를 종합해보면 Dispatcher -> Store -> View -> ActionCreator -> Dispatcher 순으로 데이터가 단방향으로 흘러간다는 사실을 알 수 있습니다.

    그 외 Flux 구현

    Flux의 아키텍처는 비교적 단순합니다. 실제로 애플리케이션을 개발하고 있는 개발자는 각각 확장하여 여러 가지 형태의 Flux를 구현하고 있습니다. 몇 가지 소개해드리겠습니다. Flux를 구현할 때 참고하세요.

    Flux + server-side rendering

    Flux의 경우, Store의 데이터가 싱글톤이 되지만 Server-Side Rendering의 경우는 싱글-톤으로 생성하면 안 되기 때문에 리퀘스트마다 Store를 생성할 필요가 있으므로 주의가 필요합니다. 이 문제를 어떻게 해결햇는지는 Yahoo의 개발자가 작성한 isomorphic-flux 슬라이드를 참고하시길 바랍니다.

    데이터 검증

    개인적으로 데이터 검증을 담당하는 곳은 Store라고 생각합니다. View가 Action을 발생시키고 Store가 받았을 때 부정확한 데이터의 경우 오류를 발생시켜 View에 전달하고 View는 필요하다면 에러를 출력하는 흐름이 좋은 것 같습니다.

    ------        ------------        ---------------------        ------
    |View|--------|Dispatcher|--------|Store에서 Validation|--------|View|--- 에러 표시
    ------ action ------------ action --------------------- error  ------

    에러를 전달하는 방법은 여러가지가 있습니다만 Node.js에서 첫 번째 인자로 err를 전달하는 패턴을 사용하면 좋을 것 같습니다.

    // Store
    var TrackStore = assign({}, EventEmitter.prototype, {
     
      emitChange: function(err) {
        this.emit(CHANGE_EVENT, err);
      },
      addChangeListener: function(callback) {
        this.on(CHANGE_EVENT, callback);
      },
      removeChangeListener: function(callback) {
        this.removeListener(CHANGE_EVENT, callback);
      },
      getAll: function() {
        return tracks;
      },
    });
     
    TrackStore.dispatchToken = AppDispatcher.register(function(payload) {
      var action = payload.action;
     
      switch (action.type) {
        case ActionTypes.RECEIVE_TRACKS_BY_ARTIST:
          var err = null;
          if (action.tracks.length === 0) {
            err = 'no tracks';
          } else {
            tracks = action.tracks;
          }
          TrackStore.emitChange(err);
          break;
        case ActionTypes.RECEIVE_TRACKS_BY_COUNTRY:
          tracks = action.tracks;
          TrackStore.emitChange();
          break;
      }
    });
     
    // View
    module.exports = React.createClass({
      getInitialState() {
        return {
          tracks: TrackStore.getAll(),
          err: null
        };
      },
      componentDidMount: function() {
        TrackStore.addChangeListener(this._onChange);
      },
      componentWillUnmount: function() {
        TrackStore.removeChangeListener(this._onChange);
      },
      _onChange: function(err) {
        if (err) {
          this.setState({err: err});
        } else {
          this.setState({ tracks: TrackStore.getAll() });
        }
      },

    또는 err가 아니라 객체를 전달하여 type으로써 error을 지정하거나 에러는 별도의 이벤트로써 발행하는 방법(CHANGE_EVENT가 아닌 ERROR_EVENT 등)도 있을 것 같습니다. Flux는 개념을 제공한 부분이 많으므로 이 개념을 잘 이용해 자신에게 맞는 최적의 환경을 구성하는 게 좋을 것 같습니다. 하지만 전혀 다른 형태의 Flux가 난립하여 혼란스러울 수도 있으니 조심히 접근합시다.

    정리

    이번 편에서는 React.js에서 테스트를 작성하는 방법과 테스트 프레임워크인 Jest 그리고 Flux 아키텍처를 간단히 소개했습니다. 다음 편에서는 React.js와 CSS의 관계를 CSS in JS 개념과 함께 소개하면서 최종적으로 정리하며 이 시리즈를 마무리하도록 하겠습니다.