Coderifleman's blog

frontend development stories.

  • 나이트왓치를 소개하기 전에 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 개념과 함께 소개하면서 최종적으로 정리하며 이 시리즈를 마무리하도록 하겠습니다.

  • 읽기전에...

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

    Server-side rendering

    이번에는 Server-Side Rendering을 소개하겠습니다. 이 기능 때문에 React.js를 사용하는 사람이 있을 정도로 React.js의 큰 특징 중 하나입니다. Server-Side Rendering은 말 그대로 서버 측에서 HTML을 생성하여 응답하는 방법을 말합니다. SPA(SinglePageApplication) 같은 자바스크립트에서 DOM을 조작하는 애플리케이션의 경우, 서버에서 만들어주는 HTML에는 빈 태그들만 있고 자바스크립트로 템플릿을 이용해 렌더링하는데 이러한 방법에는 두 가지의 큰 문제점이 있습니다.

    • 초기 로드 시간 : HTML이 반환된 후 자바스크립트를 컴파일하고 템플릿을 이용해 렌더링하므로 서버 측에서 HTML을 만들어 응답하는 것에 비해 처리 시간이 조금 더 걸립니다. 그래서 로딩-바를 보여주는 등의 UI/UX 연구도 함께 필요합니다.
    • SEO : 최근에 구글의 크롤러가 자바스크립트를 컴파일할 수 있으므로 괜찮을지 모르겠지만 다른 크롤러에 대응하기 위해서는 PhantomJS를 사용해 서버 측에서 렌더링한 HTML을 응답하는 등의 별도의 기법이 필요합니다.

    React.js와 Server-Side Rendering

    React.js는 실제 DOM을 의존하지 않고 컴포넌트의 VirtualDOM을 HTML로 반환하는 메서드를 갖고 있습니다. 이 메서드를 이용해 Node.js 서버에서 React.js의 컴포넌트를 HTML로 만들어 응답할 수 있습니다. 요즘은 Browserify 를 이용해 브라우저 측에서도 CommonJS 스타일을 많이 사용하고 있으므로 도입하는 데 큰 문제가 없을 것으로 보입니다.

    한번 간단한 샘플(저장소)을 이용해 한번 시험해 봤습니다.

    원칙(renderToString()를 사용할 경우)

    Server-Side Rendering 시, 서버 측에서 React.rednerToString()을 사용해 생성한 HTML의 DOM 구조와 브라우저 측에서 React.redner()하여 생성된 컴포넌트의 DOM 구조가 같아야하는데, HTML에 부여되는 data-react-checksum이라는 값을 사용해 두 상태가 같은지 비교합니다. checksum이 다를 경우엔 아래와 같은 경고 메시지가 출력됩니다.

    React attempted to use reuse markup in a container but the checksum was invalid. This generally means that you are using server rendering and the markup generated on the server was not what the client was expecting. React injected new markup to compensate which works but you have lost many of the benefits of server rendering. Instead, figure out why the markup being generated is different on the client or server.

    경고가 출력되면 서버에서 응답한 HTML에 의해 만들어진 DOM은 폐기되고 자바스크립트에 의해 다시 생성됩니다. 참고로 checksum은 HTML을 기준으로 Adler-32 알고리즘을 이용해 생성됩니다.

    컴포넌트

    컴포넌트 자체는 크게 의식할 필요가 없습니다만 componenetWillMount()는 서버 측에서도 호출되고, componentDidMount()는 브라우저에서만 호출되는 Lifecycle 메서드의 특징은 알아둘 필요가 있습니다.

    var React = require('react');
     
    var App = React.createClass({
      getInitialState() {
        return {
          message: 'loading...'
        };
      },
     
      componentDidMount() {
        this.setState({message: 'welcome!'});
      },
     
      render() {
        var list = this.props.data.map(obj => <li key={obj.id}>{obj.id}:{obj.name}</li>);
        return (
          <div>
            <p>server-side rendering sample</p>
            <p>{this.state.message}</p>
            <ul>{list}</ul>
          </div>
        );
      }
    });
    
    module.exports = App;

    위에서 “loading…” 이라는 메시지를 로드가 끝날 때 “welcome!” 으로 바꾸고 있는데 이 작업은 브라우저에서만 동작합니다.

    서버

    서버 측에서 주목해야 할 부분은 node-jsx와 renderToString() 입니다. 아래 예제에서는 /bundle.js를 요청할 때 동적으로 browserify 하고 있지만, 실제 서비스 시엔 사전에 browserify한 bundle.js를 준비해 두는 편이 좋습니다.

    var express     = require('express'),
        app         = express(),
        fs          = require('fs'),
        browserify  = require('browserify'),
        reactify    = require('reactify'),
        Handlebars  = require('handlebars'),
        React       = require('react')
    ;
     
    require('node-jsx').install({harmony: true});
     
    var App = require('./components/app');
    var data = [
      { id: 1, name: 'backbone' },
      { id: 2, name: 'react' },
      { id: 3, name: 'angular' },
    ];
     
    var template = Handlebars.compile(fs.readFileSync('./index.hbs').toString());
     
    app.get('/', function(req, res) {
      res.send(template({
        initialData: JSON.stringify(data),
        markup: React.renderToString(React.createElement(App, {data: data}))
      }));
    });
     
    app.get('/bundle.js', function(req, res) {
      res.setHeader('content-type', 'application/javascript');
      browserify('./browser')
        .transform({ harmony: true }, reactify)
        .bundle()
        .pipe(res)
      ;
    });
     
    var port = process.env.PORT || 5000;
     
    console.log('listening...' + port);
    app.listen(port);
    node-jsx

    먼저 JSX로 작성한 컴포넌트 파일도 require 할 수 있도록 require('node-jsx').install({harmony: true})를 선언하고 있습니다(harmony option도 유효).

    renderToString()

    React.renderToString()에는 React.createElement()를 이용해 작성한 컴포넌트를 전달합니다. 초기 설정 시 사용하는 데이터가 있다면 이것도 컴포넌트에 Prop으로 전달합니다. 또 전달한 데이터는 클라이언트 측에도 공유해야 하므로 템플릿으로 전달하기 위해 별도의 JSON.stringify 하여 initialData 로써 설정합니다.

    템플릿

    여기에서 포인트는 {{{}}}(이스케이프 하지 않는다) 에 renderToString()으로 HTML 문자열을 바인드하고, script 태그의 속성값으로 initialData를 바인드한 것입니다. 초기 데이터는 <script id="initial-data">{{{data}}}</script>로도 바인드해 사용할 수 있지만, data를 사용자가 조작 가능할 경우 XSS이 가능하므로 주의가 필요합니다 (참고(일본어))

    <html>
    <head>
      <title>React.js server-side rendering sample</title>
    </head>
    <body>
      <div id="app">{{{markup}}}</div>
      <script id="initial-data" type="text/plain" data-json="{{initialData}}"></script>
      <script src="/bundle.js"></script>
    </body>

    브라우저

    브라우저 측의 엔트리 포인트에서는 initialData의 값을 취득하고 그 값을 사용해 컴포넌트를 작성합니다.

    var React = require('react'),
        App   = require('./components/app')
    ;
    
    var data = JSON.parse(document.getElementById('initial-data').getAttribute('data-json'));
    React.render(<App data={data} />, document.getElementById('app'));
    생성되는 소스

    생성된 소스는 아래와 같은 느낌으로 root의 요소엔 data-react-checksum이, 각각 요소엔 data-reactid가 지정됩니다.

    <body>
      <div id="app"><div data-reactid=".25wfuv5brb4" data-react-checksum="-1037109598"><p data-reactid=".25wfuv5brb4.0">server-side rendering sample</p><p data-reactid=".25wfuv5brb4.1">loading...</p><ul data-reactid=".25wfuv5brb4.2"><li data-reactid=".25wfuv5brb4.2.$1"><span data-reactid=".25wfuv5brb4.2.$1.0">1</span><span data-reactid=".25wfuv5brb4.2.$1.1">:</span><span data-reactid=".25wfuv5brb4.2.$1.2">backbone</span></li><li data-reactid=".25wfuv5brb4.2.$2"><span data-reactid=".25wfuv5brb4.2.$2.0">2</span><span data-reactid=".25wfuv5brb4.2.$2.1">:</span><span data-reactid=".25wfuv5brb4.2.$2.2">react</span></li><li data-reactid=".25wfuv5brb4.2.$3"><span data-reactid=".25wfuv5brb4.2.$3.0">3</span><span data-reactid=".25wfuv5brb4.2.$3.1">:</span><span data-reactid=".25wfuv5brb4.2.$3.2">angular</span></li></ul></div></div>
      <script id="initial-data" type="text/plain" data-json="[{'id':1,'name':'backbone'},{'id':2,'name':'react'},{'id':3,'name':'angular'}]"></script>
      <script src="/bundle.js"></script>
    </body>

    이 상태에서 브라우저 측에서 React.render()를 사용해 컴포넌트를 붙이면 checksum을 확인하여 문제가 없으면 DOM은 그대로 두고 이벤트 리스너만 등록합니다. 이러한 원리로 Server-Side Rendering 할 때는 HTML이 서버에서 반환되고 자바스크립트가 컴파일되어 이벤트 리스너가 등록되기 전까지 이벤트에 반응하지 않으므로 주의가 필요합니다.

    renderToString()과 renderToStaticMarkup()

    이 두 메서드는 필요에 따라 구별해 사용합니다. renderToStaticMarkup()은 data-reactid를 부여하지 않고 순수 HTML을 반환합니다. 즉, 정적 페이지로 출력해도 문제 없으면 사용합니다. 이때 renderToString()과 마찬가지로 renderToStaticMarkup()으로 HTML을 반환하고 브라우저 측에서도 컴포넌트를 실행할 수 있지만, 그 경우 renderToStaticMakrup()이 만든 HTML은 재사용되지 않고 브라우저 측에서 다시 HTML을 만들게 됩니다.

    renderToStaticMarkup()에서 출력되는 HTML은 다음과 같습니다.

    <body>
      <div id="app"><div><p>server-side rendering sample</p><p>loading...</p><ul><li>1:backbone</li><li>2:react</li><li>3:angular</li></ul></div></div>
      <script id="initial-data" type="text/plain" data-json="[{'id':1,'name':'backbone'},{'id':2,'name':'react'},{'id':3,'name':'angular'}]"></script>
      <script src="/bundle.js"></script>
    </body>

    Flux를 사용할 때 주의할 점

    Flux을 사용할 때, 컴포넌트가 싱글-톤의 Store를 가지고 있는 애플리케이션을 그대로 서버 측에서 사용하면 사용자마다 같은 Store가 공유돼 버리므로 주의가 필요합니다. 이 문제를 해결하기 위해 요청마다 Store를 만들 필요가 있습니다.

    라우팅

    라우팅하고 싶은 경우엔 어떻게 할지 고민될 것 같습니다. 이는 다음에 라우팅을 소개할 때 함께 이야기하겠습니다.

    express-react-views

    라이브러리는 본 문서에서도 소개하고 있는 renderToStaticMarkup()을 사용하고 있으므로 주의가 필요합니다.

    Node.js 이외의 서버에서 사용

    여기까지 React.js의 Server-Side Rendering의 구조를 간단하게 소개했습니다. 다음 절에서는 React.js의 라우팅을 소개하겠습니다.

    Server-Side Rendering에 대응한 Routing

    Server-Side Rendering을 이어서 이번에는 Routing을 이야기하겠습니다. React.js는 컴포넌트를 만드는 라이브러리이므로 Router는 당연히 구현돼 있지 않습니다. 그래서 Backbone.Router나 Driector 등 좋아하는 Router 라이브러리를 조합해 사용합니다. 하지만 Page 단위로 컴포넌트를 만들어 갱신하는 경우엔 작성하기 거추장스럽고 장황하게 될 가능성이 있습니다. 그래서 여기에서는 react-router라는 것을 소개할까 합니다.

    React Router

    React Router는 이전까지 Server-Side Rendering을 지원하지 않았기 때문에 react-router-component를 사용했지만, 현재는 지원하므로 Server-Side Rendering이 필요한 경우에도 사용할 수 있습니다. React Router Component가 조금 더 단순하게 디자인돼 있으므로 조금 더 접근하기 편한 라이브러리를 찾는다면 분은 이 라이브러리를 참고해주세요. React Router는 기존 Component와 마찬가지로 Routing을 컴포넌트로 정의하는 형태가 됩니다. React Router는 중첩한 라우팅, 링크에 active class, Scroll Top 등 다양한 라우팅 기능을 지원합니다. README에 작성된 내용을 기준으로 사용 방법을 간단히 소개합니다.

    정의

    라우트를 정의할떄는 Route 컴포넌트를 사용하여 선언하고 라우팅 정의를 작성한 후 Router.run으로 시작합니다.

    var routes = (
      <Route handler={App} path="/">
        <DefaultRoute handler={Home} />
        <Route name="about" handler={About} />
        <Route name="users" handler={Users}>
          <Route name="recent-users" path="recent" handler={RecentUsers} />
          <Route name="user" path="/user/:userId" handler={User} />
          <NotFoundRoute handler={UserRouteNotFound}/>
        </Route>
        <NotFoundRoute handler={NotFound}/>
        <Redirect from="company" to="about" />
      </Route>
    );
     
    Router.run(routes, function (Handler) {
      React.render(<Handler/>, document.body);
    });

    앵커(Anchor)는 Link 컴포넌트를 이용합니다.

    <Link to="users">Users</Link>
    Handler

    위 예의 경우 App의 Handler의 안에 각각의 Route가 정의 되어 있으므로 App 컴포넌트 안에 RouteHandler 컴포넌트를 정의해야합니다. 이 부분이 Routing을 응답하는 Handler로 대체됩니다.

    var App = React.createClass({
      render() {
        return (
          <div>
            <h1>title</h1>
            <RouteHandler/>
          </div>
        );
      };
    });
    History API

    HTML5의 History API를 사용하고 싶다면 아래와 같이 두번째 인수에 Router.HistoryLocation을 지정하고 Router.run을 실행합니다.

    Router.run(routes, Router.HistoryLocation, function (Handler) {
      React.render(<Handler/>, document.body);
    });
    Server-Side Rendering

    Server-Side rendering 시에는 서버 측에서 아래와 같이 두번째 인수에 path를 전달하고 Router.run 합니다.

    //express
    app.use(function (req, res) {
         // pass in `req.path` and the router will immediately match
         Router.run(routes, req.path, function (Handler) {
             var markup = React.renderToString(<Handler/>);
             res.render('index', {markup: markup});
         });
    });

    예제 코드

    그럼, 예제 코드를 한번 살펴보겠습니다. 이 예제 코드는 다음 글에서도 사용합니다. Vimeo와 YouTube의 비디오 리스트를 라우팅으로 전환할 수 있도록 작성했습니다. 초기의 데이터를 서버와 브라우저에서 공유하도록 설계했습니다. 예제에서는 React Router뿐만 아니라 react-bootstrapreact-video를 사용하고 있습니다.

    curl http://react-ssr-sample.herokuapp.com/youtubecurl http://react-ssr-sample.herokuapp.com/vimeo 으로 요청하면 이에 대응하는 video 정보를 data-react-id와 함께 반환하는 사실을 알 수 있습니다.

    서버

    코드 일부만 설명하겠습니다. Handler의 Prop에 params으로 초기 데이터를 전달합니다.

    app.use(function(req, res) {
         Router.run(routes, req.path, function(Handler) {
             res.send(template({
                 initialData: JSON.stringify(data),
                 markup: React.renderToString(React.createElement(Handler, {params: {videos: data}}))
             }));
        });
    });

    이 예에서는 항상 같은 데이터를 반환하게 돼 있지만, req.path에 대응한 데이터를 반환하는 것도 가능합니다.

    Browser entry point

    Server-Side Rendering 때에도 동일하게 작성했습니다. JSON을 받아 Handler의 Props에 params로 전달합니다.

    var initialData = JSON.parse(document.getElementById('initial-data').getAttribute('data-json'));
     
    Router.run(routes, Router.HistoryLocation, (Handler) => {
      React.render(<Handler params={{videos: initialData}} />, document.getElementById('app'));
    });
    Routing

    아래 코드는 이제 특별한 설명 없이도 이해할 수 있을것입니다.

    module.exports = function() {
      return (
        <Route name="app" path="/" handler={App}>
          <Route name="youtube" handler={YouTube} />
          <Route name="vimeo" handler={Vimeo} />
          <DefaultRoute handler={Top} />
        </Route>
      );
    };
    App

    아래와 같은 느낌으로 RouteHandler에 spread attributes({…this.props})을 이용해 초기 데이터를 Prop으로 전달합니다.

    var App = React.createClass({
      render() {
        return (
          <div>
            <h1><Link to="app">React server-side rendering sample</Link></h1>
            <ListGroup>
              <Link to="youtube" key="youtube"><ListGroupItem>youtube</ListGroupItem></Link>
              <Link to="vimeo" key="vimeo"><ListGroupItem>vimeo</ListGroupItem></Link>
            </ListGroup>
            <RouteHandler {...this.props} />
          </div>
        );
      }
    });
    Handler

    그러면 Handler에서 전달한 초기 데이터를 Prop으로 받을 수 있으므로 이를 이용해 화면을 랜더링합니다.

    var YouTube = React.createClass({
      mixins: [VideoMixin],
      render() {
        return (
          <Grid>
            <h2>youtube</h2>
            <Row className="show-grid">{this.renderVideos('youtube')}</Row>
          </Grid>
        );
      }
    });
     
    // VideoMixin
    module.exports = {
      getDefaultProps() {
        return {
          params: {
            videos: {
              youtube: [],
              vimeo: []
            }
          }
        };
      },
      renderVideos(type) {
        return this.props.params.videos[type].map( video => {
          return (
            <Col xs={6} md={4} key={video.id}>
              <Jumbotron>
                <Video from={type} id={video.id} />
                <p>{video.title}</p>
              </Jumbotron>
            </Col>
          );
        });
      }
    };
    초기 데이터를 전부 전달하지 않는 경우

    위 예를 조금 바꿔서 /api/youtube/api/vimeo로 각각의 데이터를 반환하는 API를 만들고 /youtube에 접근 시 /api/youtube가 반환하는 데이터를 초기 데이터로 사용할 때, 각각의 컴포넌트의 componentDidMount()에서 초기 데이터가 있는지 없는지 확인하여 없을 때만 Ajax 요청을 하도록 작성하면 됩니다.(componentDidMount()는 Server-Side에서 실행되지 않습니다) 이것으로 React Router를 사용한 Routing 작성법에 대해 알아봤습니다.

    공개된 React 컴포넌트를 사용해보자

    이번에는 조금 화제를 전환해 웹에 공개된 React 컴포넌트를 사용하는 것에 관해 이야기하겠습니다. 컴포넌트는 기본적으로는 Prop이 I/F가 됩니다. 따라서 문서를 통해서 컴포넌트의 특성을 알 수 없는 경우엔 Prop을 보면 어떤 I/F로 형성돼 있는지 알 수 있습니다. 직접 컴포넌트를 공개하는 경우엔 PropTypes나 getDefaultProps()를 사용해 I/F를 명확하게 작성해야 합니다.

    부트스트랩

    먼저 많이 사용하는 라이브러리인 부트스트랩입니다. React 컴포넌트로 구현한 라이브러리는 react-bootstrap입니다. 이 라이브러리를 사용하고자 할 땐 별도로 부트스트랩 CSS를 로드할 필요가 있습니다.

    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css">

    부트스트랩의 각 컴포넌트가 React의 컴포넌트로 구현돼 있으며 다른 React의 컴포넌트와 마찬가지로 사용할 수 있습니다. (참고)

    var {Jumbotron, Col} = require('react-bootstrap');
     
    module.exports = React.createClass({
      render() {
        <Col xs={6} md={4} key={video.id}>
          <Jumbotron>
            <Video from={type} id={video.id} />
           <p>{video.title}</p>
          </Jumbotron>
        </Col>
      }
    });

    react-bootstrap의 component 페이지에서 데모와 Prop의 사양 등을 확인할 수 있습니다

    컴포넌트 찾기

    React Components

    React Components는 npm의 키워드로 react-component를 등록한 React의 컴포넌트를 모아놓은 사이트입니다. 여기에서 원하는 컴포넌트를 찾아볼 수 있습니다.

    React Rocks

    React Rocks는 여러 가지의 React의 컴포넌트를 사용한 샘플을 소개하고 있는 사이트입니다. 실제로 동작하는 데모(참고)를 볼 수 있습니다.

    깃-허브의 위키

    리액트의 깃허브 저장소에 작성된 공식 위키에도 여러가지 UI 컴포넌트를 소개하고 있으니 참고할 수 있습니다.

    실전, 컴포넌트를 찾아서 사용해보자.

    이번 절에서는 react-video라고 하는 유투브와 비메오(vimeo) 플레이어를 출력하는 컴포넌트를 실제로 사용해보고자 합니다. 이를 사용해 구현한 샘플을 공개해놨습니다.

    사용 방법

    type(from)과 id를 속성을 지정하는 방식으로 간단하게 사용할 수 있습니다.

    var Video = require('react-video');
     
    type = 'youtube' // or 'vimeo'
    <Video from={type} id={video.id} />

    CSS

    컴포넌트를 사용하기 위해서는 아래 CSS를 로드해야 합니다. require하는 것만으로 CSS도 함께 작업해줬으면 하지만 아쉬운 점입니다.

    I/F 확인

    react-video의 README.md를 보면 알 수 있지만, I/F를 어떻게 디자인했는지 알아보겠습니다. propTypes과 getDefaultProps을 살펴보면 다음과 같은 사항을 알 수 있습니다.

    • from에는 youtube나 vimeo를 지정하며 필수 인자다.
    • id는 문자열이며 필수 인자다.
    • className도 지정할 수 있으며 기본값은 video다.

    propTypes를 잘 명시하면 이해하는데 용이합니다.(참고)

    propTypes:{
      from: React.PropTypes.oneOf(['youtube', 'vimeo']).isRequired,
      id: React.PropTypes.string.isRequired
    },
    getDefaultProps() {
      return {
        className: 'video'
      };
    },

    이번 편에서는 React.js의 Server-Side rendering과 Routing 그리고 공개된 React.js의 컴포넌트를 사용하는 방법을 소개했습니다. Server-Side rendering은 최근 Ember(참고)와 Angular2(참고)에서도 지원할 정도로 인기있는 기능입니다. 다음 편에서는 React.js를 테스트하는 방법과 Flux 아키텍처를 소개하겠습니다.

  • 읽기전에...

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

    이번에는 React.js의 VIRTUAL DOM을 간단히 소개하겠습니다. VIRTUAL DOM의 자세한 설명은 VirtualDOM Advent Calendar 2014(일본어)를 참고하세요. 사실 이 캘린더 만으로도 Virtual DOM을 충분히 이해할 수 있지만 흐름 상 한번 다뤄야 할 것 같아 작성합니다.

    React.js가 VIRTUAL DOM을 채택하고 있어 좋은 점

    VIRTUAL DOM의 좋은 점

    자바스크립트를 사용해 DOM을 조작하여 UI를 변경하는 애플리케이션의 경우, 사용자 경험을 해치지 않기 위해서라도 갱신되는 DOM을 최소한으로 유지합니다. 예를 들어 Backbone.js를 사용한다면 기본적으로 뷰 단위로 렌더링 하므로 뷰를 아주 잘게 나누는 것이 중요합니다. 그러면 뷰의 개수가 늘어나고 관계가 복잡해져 관리하기 힘듭니다. Angular.js의 경우는 Dirty Checking 하여 변경이 있을 때 다시 랜더링 되는 형식입니다. 이런 방법은 감시 대상이 늘어날수록 성능이 떨어지는 문제가 있습니다.(이 성능 문제를 개선하고자 버전 2부터는 Object.observe를 사용하도록 변경됩니다.)

    React.js의 경우는 setState(forceUpdate)가 호출되면 그 컴포넌트와 하위 컴포넌트가 다시 랜더링되는 대상이 됩니다. 이 말을 듣게 되면 매번 광범위하게 DOM이 갱신된다고 느껴지지만 React.js에서는 VIRTUAL DOM이라고 하는 형태로 메모리상에 DOM의 상태를 유지하고 있고 전/후 상태를 비교하여 달라진 부분만 실제 DOM에 반영합니다. 참고로 CSS도 마찬가지로 객체 형식으로 지정해 변경된 Style만 갱신합니다.

    var Hoge = React.createClass({
      getInitialState() {
        return {
          style: {
            color: '#ccc',
            width: 200,
            height: 100
          }
        };
      },
      onChange() {
        var style = _.clone(this.state.style);
        style.color = '#ddd';
        this.setState({ style: style});
      },
      render() {
        return (
          <div style={this.state.style} onClick={this.onChange}>xxx</div>
        );
      }
    }

    이러한 방식으로 성능 문제를 해결한 것은 물론, 성능이 중요하지 않은 애플리케이션에서도 상위 레벨의 요소에 애플리케이션의 상태를 갖게 하고 그것을 setState()로 점점 갱신하는 것과 같은 조금은 거친 느낌으로 아키텍처도 할 수 있습니다. 서버 사이드의 렌더링과 비슷하네요. 즉, DOM을 다룰 때 신경 써야 하는 귀찮고 성능에 영향을 주는 부분을 React.js에 맡기는 것으로 애플리케이션의 구현을 단순하게 할 수 있는 특징이 있습니다.

    애플리케이션 개발자가 VIRTUAL DOM을 직접 신경 쓰는 경우는 key 속성 지정과 성능 향상의 목적으로 shouldComponentUpdate()를 구현할 때입니다.

    shouldComponentUpdate

    shouldComponenetUpdate()에 관해서는 Component Lifecycle을 다룰 때 설명했습니다. 이 메서드를 구현(재정의)하지 않는 경우엔 UI를 항상 갱신하도록 구현돼 있습니다. 이 메서드가 false를 반환하면 그 컴포넌트와 하위 컴포넌트의 UI를 갱신하지 않습니다.(참고)

    var shouldUpdate =
          this._pendingForceUpdate ||
          !inst.shouldComponentUpdate ||
          inst.shouldComponentUpdate(nextProps, nextState, nextContext);

    최소한의 DOM만 갱신되는 메커니즘으로 인해 항상 UI를 갱신하도록 구현해도 문제가 안 될 것 같지만, 매번 VIRTUAL DOM 트리를 만들어 실제 DOM을 비교하는 작업을 하게 되므로 실제 DOM은 갱신되지 않더라도 비용 들어 갑니다. 따라서 컴포넌트의 State와 Prop의 전/후 상태를 비교하여 변경이 있는 경우에만 컴포넌트와 하위 컴포넌트의 VIRTUAL DOM의 트리를 만들어 실제 DOM과 비교하여 UI를 갱신하도록 하는 것이 조금 더 비용을 낮추는 방법입니다.

    React.js 이 외의 VIRTUAL DOM

    React.js 외에도 VIRTUAL DOM을 채용하고 있는 라이브러리로는 mercuryMithril 등 여러 가지가 있고, Ember.js도 버전 2.0에서 VIRTUAL DOM의 구현을 검토(참고)하고 있습니다. 또한, 구현에 관해 알고 싶은 사람들은 vdom이나 deku의 소스부터 살펴나가는 것을 추천합니다.

    여기까지 VIRTUAL DOM을 소개했습니다. 다음 절에서는 spread attributes를 사용하여 컴포넌트를 작성하는 방법을 소개하겠습니다.

    Spread Attributes

    이번에는 기존의 컴포넌트를 Spread Attributes를 사용하여 간단하게 컴포넌트를 확장하는 방법을 가볍게 소개하려고 합니다. Spread Attributes는 React.js 버전 0.12에 추가된 기능입니다.

    텍스트와 함께 출력되는 이미지 컴포넌트

    예로써, 텍스트와 이미지를 한데 묶은 ImageText 컴포넌트를 사용합니다. 이 컴포넌트의 I/F는 이미지 경로와 텍스트를 전달할 수 있도록 디자인했습니다.

    var ImageText = React.createClass({
      render() {
        return (
          <span>
            {this.props.text}
            <img src={this.props.src} width={this.props.width} height={this.props.height} />
          </span>
        );
      }
    });
    
    <ImageText text="이름" src="/img/foo.png" width="100" height="200" />

    위와 같은 느낌으로 단순하게 구현할 수 있습니다. 하지만 이미지 태그를 표기할 때는 alt 어트리뷰트가 필요합니다. 여기에 또 추가하자니 귀찮습니다. 이런 문제는 Spread Attributes를 사용하면 다음과 같이 작성할 수 있습니다.

    var ImageText = React.createClass({
      render() {
        var {text, ...other} = this.props;
        return (
          <span>{text}<img {...other} /></span>
        );
      }
    });

    Spread Attributes를 이용해 text와 ohter를 나누어 전달하면 이미지 어트리뷰트 갯수나 형식에 상관없이 사용할 수 있습니다. 자바스크립트로도 _.omit()을 이처럼 사용할 수 있습니다. 하지만 이렇게 작성할 경우 컴포넌트의 I/F를 알기 어려워지므로 PropTypes를 될 수 있으면 지정해두는 편이 좋다고 생각합니다.

    클릭 이벤트 발생 시 Ajax 요청

    이번에는 클릭 이벤트 발생 시 Ajax를 요청하도록 해보겠습니다.

    var request = require('superagent');
    var ImageText = React.createClass({
      onClick() {
        request.get('/click_img', { img: this.props.src });
      },
      render() {
        var {text, ...other} = this.props;
        return (
          <span>{text}<img {...other} onClick={this.onClick} /></span>
        );
      }
    });

    위와 같이 onClick()을 추가하면 Prop의 값과 자동으로 merge 합니다. 만약 {…other} 앞에 onClick()을 선언하면 Prop의 onClick을 우선시하여 덮어쓰므로 주의가 필요합니다.

    var Hello = React.createClass({
        onClick() {
            alert('inner');
        },
        render: function() {
            var {name, ...other} = this.props;
            // 클릭시 inner 출력
            return <div>Hello <span {...other} onClick={this.onClick}>{name}</span></div>;
            // 클릭시 outer 출력
            return <div>Hello <span onClick={this.onClick} {...other}>{name}</span></div>;
        }
    });
    function onClick() {
        alert('outer');
    }
    React.render(<Hello name="World" onClick={onClick}/>, document.getElementById('container'));

    Spread Attributes는 JSX 없이도 _.extend(), Object.assign() 등을 사용하여 구현할 수 있습니다. 하지만 JSX의 spread attributes 사용하는 편이 조금 더 편리한 것 같습니다. 다음 절에서는 mixin을 소개하겠습니다.

    React.js의 믹스-인

    이번에는 컴포넌트의 믹스-인 기능을 소개하겠습니다. 보통 믹스-인은 이름 그대로 기능을 수집하는 수단을 말하고 React.js에서 믹스-인은 컴포넌트의 공통 로직을 Object로 분리하여 공통적으로 사용할 수 있도록 하는 기능 뜻합니다. React.js 자체도 LinkedStateMixin이나 PureRenderMxin 등의 믹스-인을 제공하고 있습니다. 덧붙여 Marionette.js에서는 Behavior로, Vue.js에서는 믹스-인이라는 이름으로 같은 기능이 존재합니다.

    역자노트

    아쉽지만 ES6 문법에서는 Mixin을 사용할 수 없습니다.(참고), react-mixin으로 사용할 수 있지만, 개인적으로 깔끔하진 않은 거 같습니다.

    사용 방법

    Object를 배열로 지정하는 방식으로 사용합니다. 배열을 보면 알 수 있듯이 복수 지정이 가능합니다.

    var Logger = {
      logging(str) {
        console.log(str);
      },
      componentDidMount() {
        this.logging('component did mount');
      }
    };
    
    var Hello = React.createClass({
      mixins: [Logger],
      render() {
        this.logging('render');
        return <div>Hello</div>
      }
    });

    믹스-인이 로드되는 순서

    복수의 믹스-인을 지정할 수 있다고 말씀드렸습니다. 그럼 어떤 순서로 로드될까요? 예상대로 배열의 순서대로 믹스-인이 호출된 후 마지막에 컴포넌트의 메서드가 호출되는 것을 확인할 수 있습니다.

    var MixinA = {
      componentWillMount() {
        console.log('mixinA');
      }
    };
     
    var MixinB = {
      componentWillMount() {
        console.log('mixinB');
      }
    };
     
    var Hello = React.createClass({
      mixins: [MixinA, MixinB],
      componentWillMount() {
        console.log('hello');
      },
      render() {
        return <div>hello</div>
      }
    });
    
    React.render(<Hello />, document.body);
    // mixinA
    // mixinB
    // hello

    Conflict State or Prop

    getInitialState와 getDefaultProps 등을 믹스-인으로 지정하면 어떻게 될까요?

    getInitialState

    아래 예제를 보면 알 수 있듯이 State 값을 합칩니다.

    var Mixin = {
      getInitialState() {
        return {
          mixinValue: 'mixin state'
        };
      }
    };
    
    var Hello = React.createClass({
      mixins: [Mixin],
      getInitialState() {
        return {
          componentValue: 'component state'
        };
      },
      render() {
        console.log(this.state);
        return <div>hello</div>
      }
    });
    
    React.render(<Hello />, document.body);
    //  Object {mixinValue: 'mixin state', componentValue: 'component state'}

    getDefaultProps

    Props도 State와 마찬가지로 값을 합칩니다.

    var Mixin = {
      getDefaultProps: function() {
        return {
          mixinValue: 'mixin prop'
        };
      }
    };
     
    var Hello = React.createClass({
      mixins: [Mixin],
      getDefaultProps: function() {
        return {
          componentValue: 'component prop'
        };
      },
      render: function() {
        console.log(this.props);
        return <div>hello</div>
      }
    });
     
    React.render(<Hello />, document.body);
    // Object {mixinValue: 'mixin prop', componentValue: 'component prop'}

    getInitialState에서 같은 key를 지정

    만약 믹스-인과 같은 key를 지정할 경우엔 에러가 발생합니다.

    var Mixin = {
      getInitialState() {
        return {
          value: 'mixin state'
        };
      }
    };
     
    var Hello = React.createClass({
      mixins: [Mixin],
      getInitialState() {
        return {
          value: 'component state'
        };
      },
      render() {
        console.log(this.state);
        return <div>hello</div>
      }
    });
     
    React.render(<Hello />, document.body);
    //  Uncaught Error: Invariant Violation: mergeObjectsWithNoDuplicateKeys(): Tried to merge two objects with the same key: `value`. This conflict may be due to a mixin; in particular, this may be caused by two getInitialState() or getDefaultProps() methods returning objects with clashing keys.

    메서드 재정의

    믹스-인과 동일한 이름의 메서드를 컴포넌트에서 선언해 재정의 할때도 에러가 발생합니다.

    var Mixin = {
      foo: function() {
        console.log('mixin foo');
      }
    };
     
    var Hello = React.createClass({
      mixins: [Mixin],
      foo: function() {
        console.log('component foo');
      },
      render: function() {
        return <div>hello</div>
      }
    });
     
    React.render(<Hello />, document.body);
    // Uncaught Error: Invariant Violation: ReactCompositeComponentInterface: You are attempting to define `foo` on your component more than once. This conflict may be due to a mixin.

    믹스-인을 이용하면 코드를 줄일 수 있습니다. 로직을 어렵게 하지 않을 수준에서 잘 사용하길 바랍니다. 여기까지 믹스-인을 소개했습니다. 다음 절에서는 애드온을 소개하겠습니다.

    React.js의 애드온

    이번에는 에드온을 소개하겠습니다. 엔드온은 코어에 들어갈 수준은 아닌 편리한 믹스-인이나 테스트 유틸, 성능 측정 도구 등을 모아 놓은 부가 기능입니다.

    사용 방법

    애드온은 require하거나 js 파일을 로드하는 것으로 사용할 수 있습니다.

    var React = require('react/addons');
    <script src=//cdnjs.cloudflare.com/ajax/libs/react/0.12.1/react-with-addons.js"></script>

    TestUtils

    React.js를 테스트할 때 편리하게 사용할 수 있는 애드온이며 개발 환경에서만 사용할 수 있습니다. click 이벤트와 같은 이벤트를 시뮬레이터 하는 TestUtils.Simurate나 isElementOfType과 isDOMComponent 등 컴포넌트의 상태를 확인할 수 있는 함수까지 여러 가지 있습니다.(React.js 테스트는 추후 다시 소개하겠습니다.)

    cloneWithProps

    이 애드온을 사용하는 경우는 많지 않습니다. 어떤 컴포넌트에서 다른 Prop에 의한 새로운 컴포넌트를 만들고 싶을 때 사용합니다.

    var cloneWithProps = React.addons.cloneWithProps;
     
    var Item = React.createClass({
      render: function() {
        var text = this.props.text + (this.props.index != null ? ':' + this.props.index : '');
        return <div>{text}</div>
      }
    });
     
    var Loop = React.createClass({
      render: function() {
        var items = _.map(_.range(this.props.count), function(i) {
          return cloneWithProps(this.props.children, { key: i, index: i });
        }.bind(this));
        return <div>{items}</div>
      }
    });
     
    React.render(<Loop count="10"><Item text="hoge" /></Loop>, document.body);

    위는 횟수만큼 children 컴포넌트를 만드는 과정을 cloneWithProps 애드온을 사용해 작성한 것입니다.

    update

    Object를 Immutable하게 조작하기 위한 애드온입니다. 뒤에서 설명할 PureRenderMixin() 또는 Prop과 State를 비교해 최적화하는 용도의 shouldComponentUpdate와 함께 조합해서 사용하면 편리합니다.

    var update = React.addons.update;
    
    var obj = {
      list: [1,2,3],
    };
    
    var obj2 = update(obj, {
      list: {
        $push: [4]
      }
    });
    
    console.log(obj2.list);     // ['a','b','c','d']
    console.log(obj === obj2);  // false

    참고로 페이스북은 별도의 Immutable.js를 만들고 있습니다. 이를 다음과 같이 사용할 수도 있습니다.

    var obj = Immutable.Map({
        list: Immutable.List.of(1, 2, 3)
    });
    
    var obj2 = obj.set('list', obj.get('list').push(4));
    
    console.log(obj2.get('list').toArray()); // ['a','b','c','d']
    console.log(obj === obj2); // false

    PureRenderMixin

    성능을 최적화하기 위한 믹스-인입니다. 아래 코드를 살펴보겠습니다.(참고)

    var ReactComponentWithPureRenderMixin = {
      shouldComponentUpdate: function(nextProps, nextState) {
        return !shallowEqual(this.props, nextProps) ||
               !shallowEqual(this.state, nextState);
      }
    };

    위 믹스-인이 사용하는 shallowEqual은 다음과 같이 작성돼 있습니다. 중첩된 값까지는 고려하지 않고 단순하게 비교합니다.(참고)

    function shallowEqual(objA, objB) {
      if (objA === objB) {
        return true;
      }
      var key;
      // Test for A's keys different from B.
      for (key in objA) {
        if (objA.hasOwnProperty(key) &&
            (!objB.hasOwnProperty(key) || objA[key] !== objB[key])) {
          return false;
        }
      }
      // Test for B's keys missing from A.
      for (key in objB) {
        if (objB.hasOwnProperty(key) && !objA.hasOwnProperty(key)) {
          return false;
        }
      }
      return true;
    }

    Perf

    성능 측정을 위한 애드온입니다. 개발 환경에서만 사용할 수 있습니다. Perf.start()와 Perf.stop()으로 성능을 측정하고 싶은 로직을 둘러싸고 수치화할 수 있습니다.

    React.addons.Perf.start();
    this.setState({ items: items }, function() {
      React.addons.Perf.stop();
      React.addons.Perf.printInclusive();
    });

    어떤 식으로 수치화되는지 확인하기 위해 Item 컴포넌트를 100개 추가하는 로직을 성능 측정하는 예제를 작성했습니다. 측정 결과는 개발자 콘솔에서 확인할 수 있습니다.

    측정 결과의 수치가 매우 작은 경우엔 출력이 무시되니 참고바랍니다.

    printInclusive

    측정 중인 컴포넌트 처리에 걸린 시간을 알기 쉽게 출력합니다.

    printInclusive의 성능 측정 결과
    <그림 1 printInclusive>
    printExclusive

    컴포넌트 처리에 걸린 시간을 더 상세히 출력합니다.

    printExclusive의 성능 측정 결과의 성능 측정 결과
    <그림 2 printExclusive>
    printWasted

    실제 렌더링 처리 이외에 걸린 시간을 출력합니다. shouldComponenetUpdate()를 적용하는 타이밍을 찾기 위한 단서로 사용합니다.

    printWasted의 성능 측정 결과의 성능 측정 결과
    <그림 3 printWasted>
    printDOM(measurements)

    돔을 추가하거나 삭제한 내역을 출력합니다.

    printDOM의 성능 측정 결과의 성능 측정 결과
    <그림 4 printDOM>
    getLastMeasurements

    성능 측정 결과를 Object 형식으로 가져올 수 있습니다. 서버에 결과를 보내거나 위에서 소개한 각 메서드에 값을 넘겨줄 수도 있습니다. 측정 후 보기 좋게 정리하기 위해서도 사용할 수 있습니다.

    React.js에서 애니메이션 처리하기

    이번에는 React.js에서 애니메이션을 다루는 방법을 소개하겠습니다. React.js에서는 애니메이션을 Addon으로 지원하고 있으며 CSS 애니메이션과 CSSTransitionGroup addon을 사용하는 방식과 컴포넌트의 Lifecycle 메서드와 같은 메서드에서 훅(hook)하여 작성하는 두 가지 패턴으로 애니메이션을 처리할 수 있습니다.

    CSSTransitionGroup

    CSSTransitionGroup을 이용하면 컴포넌트를 추가/삭제 시 CSS 애니메이션을 줄 수 있습니다. 방법은 Angular.js와 Vue.js와 비슷합시다. 추가/삭제 시 클래스를 추가하여 CSS 애니메이션을 처리하는 방식입니다. {transitionName}-{enter, leave} 패턴으로 클래스 명이 추가된 뒤, 다음 이벤트 루프에서 {transitionName}-{enter, leave}-active의 className이 추가되는데 이때 이 클래스 명을 사용하여 CSS애니메이션을 처리합니다.(참고)

    var CSSTransitionGroup = React.addons.CSSTransitionGroup;
     
    var Hello = React.createClass({
      getInitialState: function() {
        return {
          value: '(´・ω・`)'
        };
      },
      onClick: function() {
        var value = this.state.value === '(´・ω・`)' ? '(`・ω・´)ゞ' : '(´・ω・`)';
        this.setState({ value: value });
      },
      render: function() {
        var value = <span className="sample" key={this.state.value}>{this.state.value}</span>;
        return (
          <div>
            <div>Animation!!<button onClick={this.onClick}>click!!</button></div>
            <CSSTransitionGroup transitionName="sample">
              {value}
            </CSSTransitionGroup>
          </div>
        );
      }
    });
     
    React.render(<Hello />, document.body);
    .sample-enter {
         -webkit-transition: 1s ease-in;
    }
    .sample-enter.sample-enter-active {
        font-size: 80px;
    }
    .sample-leave {
        -webkit-transition: .5s ease-out;
    }
    .sample-leave.sample-leave-active {
        font-size: 10px;
    }

    주의할 점

    애니메이션 되는 요소에는 반드시 key를 지정해야 합니다. 애니메이션 되는 요소가 1개라도 반드시 지정해야 합니다. 이는 컴포넌트가 추가됐는지 아니면 갱신됐는지를 알려주기 위함입니다. 이것을 이용하면 앞에서 소개한 예처럼 컴포넌트가 1개라도 key를 변경하는 것으로 애니메이션을 적용할 수 있습니다.(key를 변경했다는 뜻은 컴포넌트를 추가[또는 갱신]/삭제했다는 뜻이므로)

    애니메이션은 추가(enter) 시와 삭제(leave) 시 두 경우 모두에 지정할 필요가 있습니다. 만약 한 경우에만 애니메이션을 지정하고 싶다면 transitionEnter={false}, transitionLeave={false}를 지정합니다.

    <CSSTransitionGroup transitionName="sample" transitionLeave={false}>
      {value}
    </CSSTransitionGroup>

    CSSTransitionGroup의 컴포넌트는 애니메이션 시작 시엔 이미 랜더링 돼 있어야 합니다. 추가되는 요소와 함께 CSSTransitionGroup의 컴포넌트를 추가하면 애니메이션하지 않습니다. 예를 들어 아래의 경우 처음 click 시엔 CSSTransitionGroup이 없으므로 애니메이션하지 않습니다.

    var Hello = React.createClass({
      getInitialState: function() {
        return {
          value: ''
        };
      },
      onClick: function() {
        var value = this.state.value === '(´・ω・`)' ? '(`・ω・´)ゞ' : '(´・ω・`)';
        this.setState({ value: value });
      },
      render: function() {
        var value ;
        if (this.state.value) {
          value = (
            <CSSTransitionGroup transitionName="sample">
              <span className="sample" key={this.state.value}>{this.state.value}</span>
            </CSSTransitionGroup>
          );
        }
        return (
          <div>
            <div>Animation!!<button onClick={this.onClick}>click!!</button></div>       
              {value}
          </div>
        );
      }
    });

    ReactTransitionGroup

    CSS 애니메이션이 아니라 직접 유연하게 애니메이션 작성하고 싶은 경우엔 ReactTransitionGroup을 사용합니다. componentWillEnter(callback), componentDidEnter(), componentWillLeave(callback), componentDidLeave() 이 4개의 Lifecycle 메서드를 이용해 작성합니다. 또 ReactTransitionGroup은 기본으로 span 요소를 DOM에 추가하는데 <ReactTransitionGroup compoenent="ul"> 문법으로 추가하는 요소를 지정할 수 있습니다.(참고)

    var TransitionGroup = React.addons.TransitionGroup;
    var duration = 1000;
    var AnimationComponent = React.createClass({
      componentWillEnter: function(callback) {
        console.log('component will enter');
        $(this.getDOMNode()).hide();
        callback();
      },
      componentDidEnter: function() {
        $(this.getDOMNode()).show(duration);
        console.log('component did enter');
      },
      componentWillLeave: function(callback) {
        console.log('component will leave');
        $(this.getDOMNode()).hide(duration, callback);
      },
      componentDidLeave: function() {
        console.log('component did leave');
      },
      render: function() {
        return <div>{this.props.text}</div>
      }
    });
     
    var Hello = React.createClass({
      getInitialState: function() {
        return {
          value: '(´・ω・`)'
        };
      },
      onClick: function() {
        var value = this.state.value === '(´・ω・`)' ? '(`・ω・´)ゞ' : '(´・ω・`)';
        this.setState({ value: value });
      },
      render: function() {
        var value = <AnimationComponent key={this.state.value} text={this.state.value} />;
        return (
          <div>
            <div>Animation!!<button onClick={this.onClick}>click!!</button></div>
            <TransitionGroup>
              {value}
            </TransitionGroup>
          </div>
        );
      }
    });
     
    React.render(<Hello />, document.body);

    주의할 점

    componentWillEnter()와 componentWillLeave() 처리가 끝나게 되면 반드시 callback을 호출해야 합니다.

    여기까지 애니메이션에 관해서 간단히 소개했습니다. 이러한 방식으로 애니메이션을 처리하는데 익숙치 않기 때문에 보통 쓰기 어려운 감이 들 수 있습니다. React.js 측에서도 향후 개선점으로 애니메이션도 다루고 있으므로 앞으로는 더욱 쉬워질 것이로 생각합니다.

    정리

    이번 편에서는 React.js에서 VIRTUAL DOM을 채택해서 가능한 메커니즘과 간단하게 Props를 전달할 수 있는 Spread Attribute 그리고 믹스-인과 애드온, 마지막으로 애니메이션을 처리하는 방법을 소개했습니다. 여기까지 기본적인 React.js 사용법은 모두 소개했습니다. React.js를 이해하는 비용은 그리 비싸지 않습니다. 이 정도의 특징만 숙지해도 큰 무리 없이 컴포넌트를 개발할 수 있습니다. React.js 자체를 사용하는 것보다 컴포넌트를 설계하는 것이 더 어렵고 개발자의 역량에 따라 컴포넌트 효율성이나 디자인이 크게 좌우될 수 있습니다. 많이 만들고 고민해서 좋은 컴포넌트를 만들 수 있길 바랍니다.

    다음편에서는 Server-side rendering과 컴포넌트를 테스트하는 방법 등을 소개하겠습니다.

  • 읽기전에...

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

    이번에는 컴포넌트의 라이프사이클(Lifecycle)을 소개하겠습니다.

    Component Lifecycle

    React.js는 컴포넌트의 상태 변화에 맞춰 호출되는 여러 가지 메서드를 제공 합니다. 그 메서드를 사용해 초기화나 후처리 등을 할 수 있습니다. 자주 사용하는 메서드는 componenetDidMount()나 componentWillUnmount() 입니다. componentDidMount()에서 이벤트를 등록하고 componentWillUnmount()에서 이벤트를 해제하는 패턴을 많이 사용합니다.

    componentWillMount()

    컴포넌트가 DOM 트리에 추가되기 전 한 번만 호출됩니다. 초기화 처리를 하는 데 사용할 수 있습니다. 이 안에서 setState하면 render 시에 사용됩니다. Server-side rendering 시에도 호출되므로 어느 쪽에서도 동작할 수 있는 코드를 작성해야 합니다.

    역자노트

    Server-side rendering 시에도 호출 되므로 대도록 이 Lifecycle 메서드에서 DOM을 컨트롤 하는 브라우저에서만 동작하는 로직을 작성하면 안됩니다. Node.js 환경에서는 DOM이 없으므로 에러가 발생하게 됩니다.

    componentDidMount()

    컴포넌트가 DOM 트리에 추가된 상태에 호출됩니다. DOM과 관련된 초기화를 하고 싶을 때 편리하게 사용할 수 있습니다. componentWillMount()와 다른 게 Server-side rendering 시에 호출되지 않습니다. 따라서 DOM을 다루는 처리 외에, Ajax 요청이나 setInterval 등의 Server-side rendering 시에는 불필요한 초기화 처리는 이 메서드를 통해 진행합니다.

    componentWillReceiveProps(nextProps)

    Prop이 갱신될 때 호출됩니다. 컴포넌트가 새로운 DOM 트리에 추가될 때는 호출되지 않습니다. 부모 컴포넌트의 State가 Prop으로 전달되고, 그 값이 변화한 할 때 화면의 표시 이외 Notification 같은 추가 작업을 이 메서드를 통해 할 수 있습니다. 마지막으로 Prop의 값에 따라 State의 값을 갱신 할 때에도 사용합니다.

    shouldComponentUpdate()

    이 메서드는 다른 메서드 Lifecycle 메서드와 달리 true나 false를 반환할 필요가 있습니다. 컴포넌트가 rerender 하기 전에 호출되며, 만약 false를 반환하면 VirtualDOM 비교를 하지 않고 rerender도 하지 않습니다. 즉, 독자적으로 Prop이나 State 비교 처리를 구현하는 것으로 불필요한 계산을 하지 않을 수 있습니다. 보통 성능 향상을 목적으로 사용합니다. 이 메서드가 반환하는 기본값은 true 이므로 재정의 하지 않으면 항상 rerender 합니다. 강제적으로 rerender 하고자 할땐 forceUpdate()를 사용합니다. forceUpdate()가 호출되는 경우엔 shouldComponentUpdate()는 호출되지 않습니다.

    Porp과 State가 Immutable한 데이터라면 다음과 같이 단순한 객체 비교로 구현이 가능합니다.

    shouldComponentUpdate: function(nextProps, nextState) {
      return nextProps.user !== this.props.user || nextState.user !== this.state.user;
    }

    componentWillUpdate(nextProps, nextState)

    컴포넌트가 갱신되기 전에 호출됩니다. 최초엔 호출되지 않습니다. 이 안에서는 setState를 호출할 수 없으므로 Prop의 값을 이용해 setState 하고 싶은 경우엔 componentWillReceiveProps()를 사용합니다.

    componentDidUpdate(prevProps, prevState)

    컴포넌트가 갱신된 뒤에 호출됩니다. 최초엔 호출되지 않습니다. DOM의 변화에 hook 하여 또 다른 작업을 하고 싶을 때 사용할 수 있습니다.

    componentWillUnmount()

    컴포넌트가 DOM에서 삭제될 때 호출됩니다. 이벤트 해제 같은 clean-up 처리 시 할 때 사용합니다. ComponentDidMount()에서 등록한 Timer의 처리나 DOM의 이벤트 등은 여기에서 해제해야 합니다.

    추가

    isMounted()

    개발 시 Ajax를 요청하고 그 결과를 setState 하는 패턴이 자주 발생합니다. 그때 Ajax의 응답이 왔을 때 컴포넌트가 이미 Unmount 된 경우가 있는데, 바로 setState나 forceUpdate를 호출하면 에러가 발생하게 됩니다. 따라서 isMounted()를 사용해 방어 코드를 작성할 필요가 있습니다.

    componentDidMount() {
      request.get('/path/to/api', res => {
        if (this.isMounted()) {
          this.setState({data: res.body.data});
        }
      });
    }

    여기까지 컴포넌트의 Lifecycle를 소개했습니다. 다음절에서는 이벤트를 소개하겠습니다.

    React.js의 이벤트

    이번 절에서는 DOM 이벤트 처리를 소개하겠습니다.

    SyntheticEvent

    React.js에서는 DOM을 VIRTUAL DOM으로 랩핑한 것처럼 DOM의 이벤트 객체도 SyntheticEvent이라는 객체로 랩핑하여 크로스 브라우저에 대응하고 있습니다. SyntheticEvent의 인터페이스는 아래와 같습니다.

    • boolean bubbles
    • boolean cancelable
    • DOMEventTarget currentTarget
    • boolean defaultPrevented
    • Number eventPhase
    • boolean isTrusted
    • DOMEvent nativeEvent
    • void preventDefault()
    • void stopPropagation()
    • DOMEventTarget target
    • Date timeStamp
    • String type

    이처럼 preventDefault()나 stopPropagation() 그리고 target 등을 지금까지 다뤘던 방식으로 사용할 수 있습니다. 추가로 이벤트 리스너에서 false를 반환하는 방법으로 이벤트의 전파를 정지할 수 있었지만, 이 방법은 이해하기 어렵다는 이유로 React.js 버전 0.12에서는 사용할 수 없도록 변경됐습니다.

    이벤트 핸들러

    기본적인 이벤트는 모두 지원하고 있습니다. 예를 들어 click 이벤트를 처리하고 싶은 경우엔 아래와 같이 작성합니다.

    var Counter = React.createClass({
      getInitialState() {
        return {
          count: 0
        };
      },
      onClick(e) {
        // e is SyntheticEvent
        this.setState({ count: this.state.count + 1 });
      },
      render() {
        return (
          <div>
            <span>click count is {this.state.count}</span>
            <button onClick={this.onClick}>click!</button>
          </div>
        );
      }
    });

    onClick={this.onClick}으로 클릭 이벤트를 받고 있습니다. 이때 React.js는 컴포넌트의 문맥을 리스너에 bind 해주므로 따로 this.onClick.bind(this)와 같은 별도의 바인딩 작업이 필요하지 않습니다. 따라서 리스너 내에서 바로 this.setState()와 같은 메서드를 사용할 수 있습니다. 참고로 자동으로 this를 바인딩하는 동작은 앞으로 ES6의 ArrowFunction를 사용하도록 권고하고 지원하지 않을 수 있습니다.

    역자노트

    객체 리터럴로 컴포넌트를 생성할때는 실행 문맥 바인드가 필요 없지만 ES6 Classes 문법으로 작성할 땐 필요합니다.(참고)

    Event delegation

    Event Delegation은 jQuery에도 널리 알려진 대중적인 개념입니다. React.js는 자동으로 최상위 요소에만 이벤트를 등록하고 그곳에서 이벤트를 취합하여 내부에서 관리하는 맵핑 정보를 바탕으로 대응하는 컴포넌트에 이벤트를 발행합니다. 이때 이벤트는 캡처링, 버블링 되는데, 각 리스너마다 SyntheticEvent의 객체가 만들어지기 때문에 메모리의 얼로케이트를 여러 번 할 필요가 있습니다. 이 문제를 해결하기 위해 React.js는 객체를 풀(pool)로 관리하고 재사용하여 가비지 컬렉터의 횟수를 줄일 수 있도록 구현돼 있습니다. 추가로 DOM에 설정된 data-reactid을 사용해서 맵핑하고 있는 것 같습니다. 그리고 id로 부모와 자식 관계를 알 수 있도록 디자인돼 있습니다.(참고)

    <ul class="nav nav-pills nav-justified" data-reactid=".1px6jd5i1a8.1.0.0.0.1.0">
      <li class="" data-reactid=".1px6jd5i1a8.1.0.0.0.1.0.0">
        <a href="/artist" data-reactid=".1px6jd5i1a8.1.0.0.0.1.0.0.0">Artist</a>
      </li>
      <li class="" data-reactid=".1px6jd5i1a8.1.0.0.0.1.0.1">
        <a href="/country" data-reactid=".1px6jd5i1a8.1.0.0.0.1.0.1.0">Country</a>
      </li>
    </ul>

    Not provided event

    React.js가 지원하는 기본적인 이벤트 외에 window의 resize 이벤트나 jQuery Plugin의 독자 포멧 이벤트를 사용하고 싶은 경우 componentDidMount()에서 addEventListener를 통해 이벤트를 등록하고 componentWillUnmount()를 이용해 removeEventListener 하여 이벤트를 해제해 사용합니다.(참고) 참고로 이 경우 역시 this를 자동으로 bind 합니다.

    var Box = React.createClass({
      getInitialState() {
        return {
          windowWidth: window.innerWidth
        };
      },
      handleResize(e) {
        this.setState({windowWidth: window.innerWidth});
      },
      componentDidMount() {
        window.addEventListener('resize', this.handleResize);
      },
      componentWillUnmount() {
        window.removeEventListener('resize', this.handleResize);
      },
      render() {
        return <div>Current window width: {this.state.windowWidth}</div>;
      }
    });
    
    React.render(<Box />, mountNode);

    글로벌 이벤트를 선언하는 방법에 여러 논의가 있었습니다. 이슈285을 참고하세요.

    touch event

    터치 이벤트는 기본적으로 비활성화 돼 있습니다. 활성화하고 싶은 경우엔 React.initializeTouchEvents(true)를 호출합니다.

    여기까지 Event를 정리했습니다. 다음으로 Form을 다루는 방법을 소개하겠습니다.

    React.js에서 폼 다루기

    이번 절에서는 React.js에서 폼을 다루는 방법을 소개하겠습니다. React.js에서는 아래와 같이 Input 폼을 작성하면 변경할 수 없는 텍스트 필드가 생성됩니다.(데모)

    <input type="text" value="initial value" />
    <input type="text" value={this.state.textValue} />

    Controlled Component

    Controlled Component는 State에 따라 값을 관리하는 Componenet 입니다. 이를 이용해 텍스트 필드를 재작성합니다.

    var Text = React.createClass({
      getInitialState() {
        return {
          textValue: "initial value"
        };
      },
      changeText(e) {
        this.setState({textValue: e.target.value});
      },
      render() {
        return (
          <div>
            <p>{this.state.textValue}</p>
            <input type="text" value={this.state.textValue} onChange={this.changeText} />
          </div>
        );
      }
    });

    value를 State로 관리하고, onChange()에서 setState()하여 명시적으로 값을 갱신하고 전달합니다.

    UnControlled Component

    UnControlled Componenent는 반대로 값을 관리하지 않는 컴포넌트로 초기값을 설정한 값은 defaultValue로 지정합니다. 이 경우는 앞 절에서처럼 onChange()에서 항상 값을 state에 반영해도 되고, 반영하고 싶을 때만 DOM에서 value를 취득하여 갱신하는 것도 가능합니다.

    var LiveText = React.createClass({
      getInitialState() {
        return {
          textValue: "initial value"
        };
      },
      changeText(e) {
        this.setState({textValue: this.refs.inputText.getDOMNode().value });
      },
      render() {
        return (
          <div>
            <p>{this.state.textValue}</p>
            <input type="text" ref="inputText" defalutValue="initial value" />
            <button onClick={this.changeText}>change</button>
          </div>
        );
      }
    });

    textarea

    textarea의 경우도 텍스트 필드와 마찬가지로 value를 지정합니다. HTML 처럼 <textarea>xxx</textarea> 으로 작성하면 xxx는 defaultValue로 취급됩니다.(데모)

    var OreTextArea = React.createClass({
      getInitialState() {
        return {
          textAreaValue: 'initial value'
        };
      },
      onChangeText(e) {
        this.setState({textAreaValue: e.target.value});
      },
      onClick() {
        this.setState({textAreaValue: this.refs.textArea.getDOMNode().value});
      },
      render() {
        return (
          <div>
            <div>{this.state.textAreaValue}</div>
            <div>
              <textarea value={this.state.textAreaValue} onChange={this.onChangeText} />
            </div>
            <div>
              <textarea ref="textArea">this is default value</textarea>
              <button onClick={this.onClick}>change</button>
            </div>
          </div>
        );
      }
    });

    셀렉트 박스

    셀렉트 박스도 역시 value를 지정합니다. multiple={true}와 같이 Prop을 지정하면 요소를 복수로 선택할 수 있습니다.(데모)

    var OreSelectBox = React.createClass({
      getDefaultProps() {
        return {
          answers: [1, 10, 100, 1000]
        };
      },
      getInitialState() {
        return {
          selectValue: 1,
          selectValues: [1,100]
        };
      },
      onChangeSelectValue(e) {
        this.setState({selectValue: e.target.value});
      },
      // 더 좋은 방법이 있을지...
      onChangeSelectValues(e) {
        var values = _.chain(e.target.options)
          .filter(function(option) { return option.selected })
          .map(function(option) { return +option.value })
          .value()
        ;
        this.setState({selectValues: values});
      },
      render() {
        var options = this.props.answers.map(function(answer) {
          return <option value={answer} key={answer}>{answer}</option>;
        });
        return (
          <div>
            <div>selectValue: {this.state.selectValue}</div>
            <div>
              <select value={this.state.selectValue} onChange={this.onChangeSelectValue}>
                {options}
              </select>
            </div>
            <div>selectValues: {this.state.selectValues.join(',')}</div>
            <div>
              <select multiple={true} defaultValue={this.state.selectValues} onChange={this.onChangeSelectValues}>
                {options}
              </select>
            </div>
          </div>
        );
      }
    });

    LinkedStateMixin

    LinkedStateMixin이라는 addon을 사용하면 앞에서 처럼 onChange()를 일일이 구현하지 않아도 state에 반영할 수 있습니다. 체크박스에 사용할 때는 checkLink를 사용합니다.

    var React = require('react/addons');
    var LinkedStateMixin = React.createClass({
      mixins: [React.addons.LinkedStateMixin],
      getInitialState() {
        return {
          textValue: 'initial value'
        }
      },
      render() {
        return (
          <div>
            <div>value: {this.state.textValue}</div>
            <input type="text" valueLink={this.linkState('textValue')} />
          </div>
        );
      }
    });

    이 mixin이 하고 있는 것은 간단합니다. 내부 로직을 한번 살펴보는 것도 재미있을 것 같습니다.

    LinkedStateMixin의 동작 방식

    우선 Mixin해서 사용하는 linkState의 내부 로직을 보면 value와 무엇인가 작성한 Setter를 전달해서 ReactLink 객체의 인스턴스를 생성해 반환하고 있습니다.(참고)

    linkState: function(key) {
      return new ReactLink(
        this.state[key],
        ReactStateSetters.createStateKeySetter(this, key)
      );
    }

    ReactStateSetters.createStateKeySetter의 내부를 보면 전달된 State의 키에 대응해서 setState를 하는 함수를 반환하고 있습니다.(참고)

    createStateKeySetter: function(component, key) {
        // Memoize the setters.
        var cache = component.__keySetters || (component.__keySetters = {});
        return cache[key] || (cache[key] = createStateKeySetter(component, key));
      }
    };
    
    function createStateKeySetter(component, key) {
      // Partial state is allocated outside of the function closure so it can be
      // reused with every call, avoiding memory allocation when this function
      // is called.
      var partialState = {};
      return function stateKeySetter(value) {
        partialState[key] = value;
        component.setState(partialState);
      };
    }

    ReactLink의 Constructor(생성자)에서는 값(value)과 requestChange(createStateKeySetter에서 반환한 함수)를 프로퍼티로 설정합니다.(참고)

    function ReactLink(value, requestChange) {
      this.value = value;
      this.requestChange = requestChange;
    }

    여기에서, valueLink의 Prop을 살펴보면 requestChange에 전달하는 인자는 e.target.value라는 사실을 알 수 있습니다.(참고)

    function _handleLinkedValueChange(e) {
      /*jshint validthis:true */
      this.props.valueLink.requestChange(e.target.value);
    }
    
    /**
      * @param {SyntheticEvent} e change event to handle
      */
    function _handleLinkedCheckChange(e) {
      /*jshint validthis:true */
      this.props.checkedLink.requestChange(e.target.checked);
    }

    input의 컴포넌트를 보면, onChange 이벤트에 valueLink가 있으면 _handleLinkedValueChange를 호출하여 그 결과, setState 한다는 것을 알 수 있습니다.(참고1, 참고2)

    getOnChange: function(input) {
      if (input.props.valueLink) {
        _assertValueLink(input);
        return _handleLinkedValueChange;
      } else if (input.props.checkedLink) {
        _assertCheckedLink(input);
        return _handleLinkedCheckChange;
      }
      return input.props.onChange;
    }
    _handleChange: function(event) {
      var returnValue;
      var onChange = LinkedValueUtils.getOnChange(this);
      if (onChange) {
        returnValue = onChange.call(this, event);
      }

    여기까지 폼을 다루는 방법을 소개했습니다. 마지막에 간단한 Mixin을 살펴봄으로써 Mixin이 동작하는 방식도 알 수 있을 것이라 생각합니다. 다음 절에서는 React.js의 VIRTUAL DOM 구현에서 중요한 역할을 맡고 있는 key 속성을 소개하겠습니다.

    React.js에서 중요한 key

    이번 절에서는 React.js의 Virtual DOM 구현의 내에서도 유저가 인지할 수 있는 Key를 소개하겠습니다. React.js에서는 Prop에 key라는 값을 지정할 수 있고 컴포넌트의 리스트를 렌더링할 때 이를 지정하지 않으면 Development 환경에서 아래와 같은 경고가 출력됩니다.

    Each child in an array should have a unique "key" prop. Check the render method of KeyTrap. See http://fb.me/react-warning-keys for more information.

    이 key는 VIRTUAL DOM과 비교하여 실제 DOM에 반영할 때 최소한으로 변경하기 위해 사용됩니다. key를 사용하는 예는 다음과 같습니다.(참고)

    var KeySample = React.createClass({
      getInitialState() {
        return {
          list: [1,2,3,4,5]
        };
      },
      add() {
        this.setState({ list: [0].concat(this.state.list) });
      },
      render() {
        var list = this.state.list.map(function(i) { return <li key={i}>{i}</li> });
        return (
          <div>
            <ul>{list}</ul>
            <button onClick={this.add}>add</button>
          </div>
        );
      }
    });

    위와 같은 원소로 유니크한 ID가 지정돼 있는 배열을 리스트로 출력하는 컴포넌트가 있다고 했을때, 새로 추가 시 배열의 앞에 0을 추가하면 DOM에도 실제로 변경이 필요한 부분만 반영됩니다. 만약 key를 사용하지 않으면 이런 비교가 불가능하여 전체 리스트를 갱신하게 됩니다. 이 예제에는 문제가 있는데 한번 추가한 후 다시 추가하면 0이라는 key를 가진 배열이 계속 추가되므로 실제로 변경된 사항이 없다 판단하여 DOM은 바뀌지 않습니다. 이러 형태의 문제가 발생했을때는 아래와 같은 경고가 출력됩니다.

    Warning: flattenChildren(...): Encountered two children with the same key, .$0. Child keys must be unique; when two children share a key, only the first child will be used.

    key를 제거하고 예제를 실행하면 같은 값을 가지는 엘리먼트가 계속 추가됩니다. 이와 비슷한 아이디어는 Angular.js의 track by와 Vue.js의 trackby 등 다른 라이브러리나 프레임워크에서도 만날 수 있습니다.

    key must by unique

    위 경고로 알 수 있듯이 key는 해당 리스트에서 반드시 유니크한 값으로 지정할 필요가 있습니다. 예를 들어 사용자 목록을 출력한다면 사용자의 ID가 key로 사용될 수 있습니다. 배열의 index를 key로 지정하는 것은 사실 큰 의미가 없습니다.

    ReactCSSTransitionGroup

    React.js에는 CSS 애니메이션을 위한 addon이 있습니다. 이는 애니메이션 대상이 되는 요소가 1개인 경우에도 key를 지정해야합니다. 이는 ReactCSSTransitionGroup에서 요소의 추가, 삭제를 추적해야 하기 때문에 key를 필요로 하는 것 같습니다.(실제 구현을 살펴보진 않았습니다.) ReactCSSTransitionGroup에 관해서는 추후 다시 소개하겠습니다.

    추가 내용

    마지막으로 React.js and Dynamic Children - Why the Keys are Important을 참고해 key에 관해 생략된 부분을 소개하겠습니다.

    <CountriesComponent>
      <TabList />  {/* 나라 리스트 */}
      <TabList />  {/* 위 나라에 해당하는 도시 리스트 */}
    </CountriesComponent>

    위와같은 컴포넌트를 구성하고 있고 TabList는 각각 활성화된 탭의 index를 State로 가지고 있다고 합시다. 그리고 국가 목록을 변경했을 때 도시 목록의 활성화된 index도 0으로 되돌리고 싶지만 의도한대로 동작하지 않습니다. getInitialState()에 활성화 index가 0으로 초기화 되도록 작성돼 있습니다. 따라서 나라가 변경됐을 때 도시 목록의 TabList는 나라에 대응한 도시의 리스트로 갱신되면서 초기화 될 것으로 보이지만 실제로 TabList를 재사용하므로 목록만 갱신됩니다. 즉, getInitialState()가 호출되지 않아 활성화 index가 갱신되지 않아 발생하는 문제입니다.

    이 문제는 TabList에 key를 지정하고 국가가 달라졌을 때 도시 컴포넌트가 다시 생성되도록 하는 방식으로 해결할 수 있습니다.즉, key를 명시함으로써 새로운 컴포넌트를 만들도록 할 수 있습니다.

    <CountriesComponent>
      <TabList key="countriesList" />
      <TabList key={this.state.currentCountry} />
    </CountriesComponent>

    위 블로그에도 언급돼 있지만 이런 경우엔 TabList 컴포넌트에서 활성화 index를 State로 관리하는게 아니라 ContriesComponent가 관리하고 Prop으로 활성화 index를 TabList 컴포넌트에 전달하는게 더 맞는 방법인 것 같습니다.

    정리

    여기까지 React.js 컴포넌트의 Lifecycle과 이벤트 그리고 폼과 Key를 소개했습니다. 다음 편에서는 VIRTUAL DOM의 장점과 믹스-인 등을 소개하겠습니다.

  • 읽기전에...

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

    전편에서 잠깐 등장한 Props을 소개하겠습니다.

    React.js의 Prop

    기본 사용법

    Prop은 컴포넌트의 속성(어트리뷰트)으로 정의하고 컴포넌트 내에서는 this.props.xxx로 참조해 사용합니다. 이것이 전부입니다. Prop으로는 객체, 함수 등 어떤 타입이든 지정할 수 있습니다.

    var Avatar = React.createClass({
      render() {
        var avatarImg = `/img/avatar_${this.props.user.id}.png`;
    
        return(
          <div>
            <span>{this.props.user.name}</span>
            <img src={avatarImg} />
          </div>
        );
      };
    });
    
    var user = {
      id: 10,
      name: 'Hoge'
    };
    
    // <Avatar user={user} />

    I/F(인터페이스)로써의 Prop

    Prop은 외부에서 전달하는 값이지 그 컴포넌트가 자체적으로 관리하는 값이 아니므로 내부에서 변경하면 안 됩니다. 컴포넌트가 관리할 필요가 있는 값은 다음 절에서 소개할 State로 정의해야 합니다. 즉, Prop은 Immutable(불변) 하며 외부와 I/F로써 작용합니다.

    PropTypes

    컴포넌트의 Prop은 외부로부터 값을 지정받기 때문에 검증(벨리데이션)이 필요합니다. 이때 React.js에서는 PropsTypes으로 Prop에 대한 타입 제약을 지정할 수 있습니다. 화려하진 않지만 좋은 기능입니다.

    var Avatar = React.createClass({
      propTypes: {
        name:   React.PropTypes.string.isRequired,
        id:     React.PropTypes.number.isRequired,
        width:  React.PropTypes.number.isRequired,
        height: React.PropTypes.number.isRequired,
        alt:    React.PropTypes.string
      },
      render() {
        var src = `/img/avatar/${this.props.id}.png`;
        return (
          <div>
            <img src={src} width={this.props.width} height={this.props.height} alt={this.props.alt} />
            <span>{this.props.name}</span>
          </div>
        );
      }
    });
    
    <Avatar name="foo" id=1 width=100 height=100 />

    위와 같은 느낌으로 작성합니다. PropTypes을 지정하는 것으로 컴포넌트의 I/F를 조금 더 명확하게 표현할 수 있습니다. PropTypes의 지정은 아래와 같은 느낌으로 유연하게 지정할 수 있습니다.

    React.PropTypes.array           // 배열
    React.PropTypes.bool.isRequired // Boolean, 필수
    React.PropTypes.func            // 함수
    React.PropTypes.number          // 정수
    React.PropTypes.object          // 객체
    React.PropTypes.string          // 문자열
    React.PropTypes.node            // Render가 가능한 객체
    React.PropTypes.element         // React Element
    React.PropTypes.instanceOf(XXX) // XXX의 instance
    React.PropTypes.oneOf(['foo', 'bar']) // foo 또는 bar
    React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.array]) // 문자열 또는 배열
    React.PropTypes.arrayOf(React.PropTypes.string)  // 문자열을 원소로 가지는 배열
    React.PropTypes.objectOf(React.PropTypes.string) // 문자열을 값으로 가지는 객체
    React.PropTypes.shape({                          // 지정된 형식을 충족하는지
      color: React.PropTypes.string,
      fontSize: React.PropTypes.number
    });
    React.PropTypes.any.isRequired  // 어떤 타입이든 가능하지만 필수
    
    // 커스텀 제약도 정의 가능
    customPropType: function(props, propName, componentName) {
      if (!/^[0-9]/.test(props[propName])) {
        return new Error('Validation failed!');
      }
    }

    아래와 같이 제일 처음 소개한 예제 코드에 PropTypes를 정의할 수 있습니다.

    var Avatar = React.createClass({
      propTypes: {
        user: React.PropTypes.shape({
          id:   React.PropTypes.number.isRequired,
          name: React.PropTypes.string.isRequired
        })
      },
      render() {
        var avatarImg = `/img/avatar_${this.props.user.id}.png`;
        return(
          <div>
            <span>{this.props.user.name}</span>
            <img src={avatarImg} />
          </div>
        );
      }
    });

    주의점으로는 React.js의 제약은 성능적인 이유로 실 서비스 환경에서는 검증하지 않습니다. 또 개발 환경에서도 에러가 발생하는 것이 아닌 console.warn으로 출력됩니다. 에러가 발생하도록 변경해 달라는 issue도 등록됐었기 때문에 앞으로 어떻게 변경될진 모르겠습니다.

    역자노트

    ES6에서 PropTypes을 지정하는 방식은 다음과 같습니다.

    
    class Avatar extends React.Component {
      render() {
        var avatarImg = `/img/avatar_${this.props.user.id}.png`;
    
        return(
          <div>
            <span>{this.props.user.name}</span>
            <img src={avatarImg} />
          </div>
        );
      }
    }
    
    Avatar.propTypes =  {
      user: React.PropTypes.shape({
        id:   React.PropTypes.number.isRequired,
        name: React.PropTypes.string.isRequired
      })
    };
    
    export default Avatar;
    

    기본값 지정

    getDefaultProps()에서 리터럴 객체를 반환하면 기본값으로 지정됩니다. 이는 컴포넌트 인스턴스가 만들어질 때 호출되는 것이 아니라 컴포넌트가 정의될 때만 호출되므로 주의가 필요합니다. 다음 절에서 소개할 getInitialState()은 다릅니다.

    var Hello = React.createClass({
      getDefaultProps() {
        return {
          name: 'React'
        };
      },
      render() {
        return <div>Hello {this.props.name}</div>
      }
    });
    
    // <Hello />
    역자노트

    ES6에서 PropTypes을 지정하는 방식은 다음과 같습니다.

    
    class Hello extends React.Component {
      render() {
        return <div>Hello {this.props.name}</div>
      }
    }
    
    Hello.defaultProps = {
      name: 'React'
    };
    
    export default Hello;
    
    // <Hello />
    

    setProps & replaceProps

    컴포넌트에 새로운 Prop을 전달하고 다시 rerender 하고 싶은 경우엔 setProps()와 replaceProps()를 사용합니다. 이 메서드를 이용하면 Prop을 갱신하면서 rerender 할 수 있습니다.

    var Test = React.createClass({
      getDefaultProps: function() {
        return {
          id: 1
        };
      },
      render: function() {
        return (
          <div>{this.props.id}:{this.props.name}</div>
        );
      }
    });
    
    var component = React.render(<Test name="bar" />, document.body);
    
    component.setProps({ name: "foo" });      // <div>1:foo</div>
    component.replaceProps({ name: "hoge" }); // <div>:hoge</div>

    setProps()은 기존의 Prop과 새로운 Prop을 합치(merge)지만 replaceProps()는 대체합니다. 그리고 각각 두 번째 인수에 콜백 함수를 지정할 수 있습니다.

    역자노트

    replaceProps()는 ES6에서 사용할 수 없으며, 곧 제거될 예정입니다.

    여기까지 React.js의 Prop을 살펴봤습니다. 다음 절에서는 State를 소개하겠습니다.

    React.js의 State

    Porp은 Immutable하지만 State는 Mutable(이변)한 값을 정의할 수 있습니다.

    기본 사용법

    getInitialState()을 이용해 state의 초기값을 반환하고 데이터 변경이 있는 경우 this.setState()로 갱신합니다. 상태가 갱신되면 컴포넌트가 rerender 되어 UI가 갱신됩니다. 이때, 자식 컴포넌트도 함께 rerender 됩니다.

    var Counter = React.createClass({
      getInitialState() {
        return {
          count: 0
        };
      },
      onClick() {
        this.setState({ count: this.state.count + 1});
      },
      render() {
        return (
          <div>
            <span>{this.state.count}</span>
            <button onClick={this.onClick}>click</button>
          </div>
        );
      }
    });

    setState()의 두 번째에 인수에는 setProps() 처럼 콜백 함수를 지정할 수 있습니다. 또 replaceProps()와 비슷한 replaceState()도 있습니다.

    역자노트

    replaceState()는 ES6에서 사용할 수 없으며, 곧 제거될 예정입니다.

    State를 사용한 UI

    State는 텍스트 필드 같은 컴포넌트 내에서 사용자 인터렉션에 따라 변경되는 값을 관리하는 경우에 가장 자주 사용됩니다. 또 컴포넌트 내에서 Ajax로 데이터를 요청하고 성공 시 콜백 함수에서 응답 데이터를 setState() 하는 방식으로도 사용합니다.

    주의할 점

    state의 값을 프로퍼티로 접근해 직접 변경하면 안 되고 반드시 setState()를 사용해 갱신해야 합니다. 이는 setState()가 호출되어야 rerender 되기 때문입니다. this.state 값 자체도 Immutable 하다라고 생각하는 것이 좋습니다. 만약, this.state.list라는 배열이 있고 list에 요소를 추가하고 싶은 경우도 push()하고 setState()하는 것이 아니라 this.setState({list: this.state.list.concat([value]})로 새로운 값(배열)을 지정하는 것이 좋습니다. 이 방법이 shouldComponentUpdate()로 성능 최적화 할 때와 undo의 구현 시에 좀 더 유용합니다.

    State는 최소화

    Prop만 가지고 있는 Immutable한 컴포넌트가 조작하거나 이해하기 쉬우므로, 기본적으로는 Prop을 고려하고, State를 가진 컴포넌트는 최소화 하는 게 좋습니다. 최상위 컴포넌트만 State를 갖게 하고, 하위 컴포넌트는 전부 Prop만을 가지는 Immutable한 컴포넌트로 구성하여 어떤 변경이 있을 때 최상위 컴포넌트에서 setState()하여 rerender 하는 설계도 가능합니다. 이는 VirtualDOM의 기술을 이용한 설계 방법입니다. 이와 관련된 내용은 다음에 소개하겠습니다.

    여기까지 State를 소개했습니다. 다음으로 Prop과 State를 사용한 컴포넌트 간의 상호작용을 소개하겠습니다.

    Prop과 State를 사용한 컴포넌트 상호작용

    이번에는 지금까지 소개한 Prop과 State를 사용해 컴포넌트 간 상호작용 하는 방법에 대해서 작성하겠습니다.

    부모의 State를 자식의 Prop으로 전달

    컴포넌트 설계 시 인터페이스를 고려해서 Prop을 설계하고 그 컴포넌트가 관리할 값 중 변경되는 값을 추려 State로 정의합니다. 컴포넌트 간의 부모와 자식 관계를 의식해서 설계해야 합니다. 부모는 State를 갖고 있고, 자식의 Prop으로 값을 전달하는 것이 기본 흐름입니다. 자식은 값을 사용하기만 할 뿐 관리는 부모가 합니다.

    var User = React.createClass({
      propTypes: {
        name: React.PropTypes.string.isRequired,
        id:   React.PropTypes.number.isRequired
      },
      render() {
        return (
          <div>{this.props.id}:{this.props.name}</div>
        );
      }
    });
     
    var request = require('superagent');
     
    var Users = React.createClass({
      getInitialState() {
        return {
          users: [{id: 1, name: 'foo'}, {id: 2, name: 'bar'}]
        }
      },
      componentDidMount() {
        request.get('http://example.com/users/', (res) => {
          this.setState({users: res.body.users});
        });
      },
      render() {
        var users = this.state.users.map((user) => {
          return <User id={user.id} name={user.name} key={user.id}/>
        });
        return (
          <div>
            <p>사용자 목록</p>
            {users}
          </div>
        );
      }
    });

    자식의 이벤트를 부모에서 처리

    자식 컴포넌트 내에서 발생하는 이벤트를 부모에서 처리하고 싶은 경우엔 자식이 이벤트를 처리하기 위한 함수를 Prop 즉, I/F로 공개하고 부모가 자식의 Prop을 이용해 리스너를 전달하는 형태로 처리합니다. 예를 들어 TodoList에서 각 Todo는 자식 컴포넌트가 되고 자식 컴포넌트에 삭제나 편집 기능이 있을 때 삭제와 편집 처리 로직은 부모 컴포넌트에 정의하고 이벤트는 자식 컴포넌트에서 버블링되는 느낌으로 동작합니다.

    var Todo = React.createClass({
      propTypes: {
        todo: React.PropTypes.shape({
          id: React.PropTypes.number.isRequired,
          text: React.PropTypes.string.isRequired
        }),
        // 삭제 처리를 I/F로 정의
        onDelete: React.PropTypes.func.isRequired
      },
      // 부모에게 이벤트 처리를 위임한다.
      _onDelete() {
        this.props.onDelete(this.props.todo.id);
      },
      render() {
        return (
          <div>
            <span>{this.props.todo.text}</span>
            <button onClick={this._onDelete}>delete</button>
          </div>
        );
      }
    });
     
    var TodoList = React.createClass({
      getInitialState() {
        return {
          todos: [
            {id:1, text: 'advent calendar1'},
            {id:2, text: 'advent calendar2'},
            {id:3, text: 'advent calendar3'}
          ]
        };
      },
      // TodoList는 이 컴포넌트가 관리하고 있으므로 삭제 처리도 여기에 존재한다.
      deleteTodo(id) {
        this.setState({
          todos: this.state.todos.filter((todo) => {
            return todo.id !== id;
          })
        });
      },
      render() {
        var todos = this.state.todos.map((todo) => {
          return <li key={todo.id}><Todo onDelete={this.deleteTodo} todo={todo} /></li>;
        });
        return <ul>{todos}</ul>;
      }
    });
     
    React.render(<TodoList />, document.body);

    State 초기값을 Prop에서 전달

    State의 초기값을 Prop에서 전달해야 하는 경우엔 아래와 같이 처리합니다.

    var Counter = React.createClass({
      propTypes: {
        count: React.PropTypes.number
      },
      getDefaultProps() {
        return {
          count: 0
        };
      },
      getInitialState() {
        return {
          count: this.props.count
        }
      },
      onClick() {
        this.setState({ count: this.state.count + 1 });
      },
      render() {
        return (
          <div>
            <p>{this.state.count}</p>
            <button onClick={this.onClick}>click</button>
          </div>
        );
      }
    });
     
    // <Counter count=10 />

    하지만 위와 같은 형태로 작성하면 값이 증가할 때마다 Prop의 count도 함께 증가할 것으로 보이기 때문에 Prop을 초기값으로 사용할 때는 의도를 명확하게 드러내는 이름으로 작성합니다.

    var Counter = React.createClass({
      propTypes: {
        initialCount: React.PropTypes.number
      },
      getDefaultProps() {
        return {
          initialCount: 0
        };
      },
      getInitialState() {
        return {
          count: this.props.initialCount
        }
      },
      onClick() {
        this.setState({ count: this.state.count + 1 });
      },
      render() {
        return (
          <div>
            <p>{this.state.count}</p>
            <button onClick={this.onClick}>click</button>
          </div>
        );
      }
      :
    });
     
    // <Counter initialCount=10 />

    ref

    컴포넌트 내에서 ref 프로퍼티를 사용하여 자식 컴포넌트를 참조할 수 있습니다. 이 프로퍼티를 사용하면 부모에서 자식의 메서드를 호출할 수 있습니다. 하지만 한번 사용하기 시작하면 컴포넌트 간의 관계를 알기 어려워지므로 기본적으로 div나 button 등과 같은 내장 컴포넌트를 참조할 때만 사용하는 게 좋습니다. 보통 다음 절에서 설명할 getDOMNode()와 함께 사용하는 경우가 많습니다.

    var Test = React.createClass({
      componentDidMount() {
        console.log(this.refs.myDiv.props.children);  // xxx
      },
      render() {
        return (
          <div ref="myDiv">xxx</div>
        );
      }
    });

    getDOMNode

    React.js에서 DOM은 VirtualDOM에 감춰져 있어서 직접 DOM을 조작하지 않습니다. 하지만 focus 하거나, jQuery Plugin을 쓰고자 할 때는 직접 DOM을 조작해야 하는 경우도 있습니다. 그런 경우에는 ref와 함께 getDOMNode()를 사용하여 DOM을 참조합니다. 다만, DOM을 직접 수정하게 되면 VirtualDOM과의 관계가 틀어지기 때문에 읽기 전용으로 사용해야 합니다.

    var Focus = React.createClass({
      componentDidMount() {
        this.refs.myText.getDOMNode().focus();
      },
      render() {
        return (
          <div>
            <p>set focus</p>
            <input type="text" ref="myText" />
          </div>
        );
      }
    });
    역자노트

    getDOMNode()는 deprecated 됩니다(참고). 대신 다음과 같이 사용하세요.

    
    componentDidMount() {
      React.findDOMNode(this.refs.myText).focus();
    }
    

    Props.children

    <myComponent>xxx</myComponent>와 같이 작성할 때 xxx를 얻고자 할때는 this.props.children 프로퍼티를 사용합니다.

    var Hello = React.createClass({
      render() {
        return <div>{this.props.children}</div>;
      }
    });
     
    console.log(
      React.render(
        <Hello>xxx</Hello>,
        document.body
      ).props.children
    );
    // => xxx
     
    console.log(
      React.render(
         <Hello><span>1</span><span>2</span></Hello>,
         document.body
      ).props.children
    );
    // => [React.Element, React.Element]
    
    console.log(
      React.render(
        <Hello></Hello>,
        document.body
      ).props.children
    );
    // undefined

    위와 같이 props.children은 지정 방식에 따라 문자열이거나 원소가 React Element로 이뤄진 배열이거나 undefined 일 수도 있습니다. 그래서 배열이라는 가정에 따라 원소의 개수를 확인하기 위해 children.length 한 경우 만약 문자열이 전달되면 String.length의 값이 반환되므로 chdilren을 사용할 때는 어떤 타입인지 검사할 필요가 있습니다. React.Children에는 count, forEach, map, only 등 편리한 함수를 제공하고 있습니다. 이 메서드를 잘 사용하면 자식을 조작할 때 발생하는 문제를 잘 회피할 수 있습니다.

    var Hello = React.createClass({
      render() {
        return <div>{this.props.children}</div>;
      }
    });
    
    [
      <Hello>xxx</Hello>,
      <Hello><span>1</span><span>2</span></Hello>,
      <Hello></Hello>
    ].forEach( jsx => {
      var children = React.render(jsx, document.body).props.children;
      console.log(######### + children + ##########);
      console.log(React.Children.count(children));
      React.Children.forEach(children, (child) => { console.log(child) });
    });
    
    // #########xxx##########
    // 1
    // xxx
    // #########[object Object],[object Object]##########
    // 2
    // ReactElement {type: "span", key: null, ref: null, _owner: null, _context: Object…}
    // ReactElement {type: "span", key: null, ref: null, _owner: null, _context: Object…}
    // #########undefined##########

    위 예제를 보면 알 수 있듯이 React.Children의 메서드를 사용하여 배열과 문자열의 문제를 해결하고 있습니다. 참고로 React.Children.only는 children의 React.element가 하나 이상일 때 오류를 발생시키는 함수입니다.

    정리

    여기까지 Prop과 State를 알아보고 그 속성을 사용해 컴포넌트에서 상호작용하는 방법도 알아봤습니다. prop과 state는 컴포넌트에서 데이터와 상태를 관리하는 데 중요한 속성이므로 꼭 기억해두시길 바랍니다. 다음편에서는 React 컴포넌트 작성 시 유용하게 사용할 수 있는 Lifecycle와 이벤트 등을 소개하겠습니다.

  • 읽기전에...

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

    올해(2014년) 들어 갑자기 대세가 된 듯한 React.js 지만, “조금 전까지만 해도 Angular.js가 대세라고 하더니!”라며 혼란스러워하는 사람도 많을 거라 생각해서 Advent Calendar 형식으로 간단히 소개하고자 합니다. React.js에서 중요한 개념인 VIRTUAL DOM(가상돔) 별도의 Adevent Calendar에 작성돼 있으니 꼭 봐주시길 바랍니다.

    React.js 란?

    왜 이 라이브러리가 뜨거운 감자가 됐는지는 솔직히 잘 모르겠지만, 필자는 개인적으로는 Github의 atom에서 성능 향상의 이유로 React.js를 사용하기로 했다는 기사(Moving Atom To React)를 보고 흥미를 갖게 됐습니다. React.js는 Facebook이 만들고 있는 이른바 MVC 프레임워크에서의 뷰 부분을 컴포넌트로 만들기 위한 라이브러리입니다. Handlebars 같은 템플릿 엔진이 아닙니다. Facebook은 물론 instagram, AirBnb, Yahoo, Atlassian 등 여러 곳에서 사용하고 있습니다.

    특징

    공식 사이트에서는 특징을 크게 세 가지로 나눠 소개하고 있습니다.

    • JUST THE UI
    • VIRTUAL DOM
    • DATA FLOW

    이 세 가지 특징에 관해서 간단히 설명해 드리겠습니다.

    JUST THE UI

    React.js는 UI 컴포넌트를 만들기 위한 라이브러리입니다. 컴포넌트 지향 프레임워크는 여러 가지가 있지만 React.js는 정말 UI 컴포넌트만 지원합니다. 비록 지원하는 범위는 작지만, 애플리케이션을 만드는 방법을 크게 바꿀 수 있다는 점이 재미있습니다. 또한, 이해 비용이 적어 도입하기 쉬우며 Backbone.js의 뷰 부분을 React.js로 구현하거나 Angular.js의 directives를 React.js를 사용해 구현하는 등 여러 환경과 조합해 사용할 수 있습니다.

    VIRTUAL DOM

    React.js는 자바스크립트 내에 DOM Tree와 같은 구조체를 VIRTUAL DOM으로 갖고 있습니다. 다시 그릴 때는 그 구조체의 전후 상태를 비교하여 변경이 필요한 최소한의 요소만 실제 DOM에 반영합니다. 따라서 무작위로 다시 그려도 변경에 필요한 최소한의 DOM만 갱신되기 때문에 빠르게 처리할 수 있습니다.

    DATA FLOW

    React.js는 단방향 데이터 흐름을 지향합니다. 따라서 Angular.js의 양방향 데이터 바인딩을 사용할 때처럼 작성할 코드의 양이 확연히 줄거나 하지는 않습니다. 그렇지만, 애플리케이션의 데이터를 관리하는 모델 컴포넌트가 있고 그 데이터를 UI 컴포넌트에 전달하는 단순한 데이터 흐름으로 이해하고 관리하기 쉬운 애플리케이션을 만들 수 있습니다.

    역자노트

    과거엔 데이터가 변경되면 전체를 새로 그리는 간편하고 단순한 방법으로 애플리케이션을 구현했습니다. 현대에 들어 애플리케이션을 개발하는 방법이 많이 복잡해졌다고 생각합니다. Angular.js의 양방향 데이터 바인딩은 코드를 줄여주고 사용하기 편하지만, 규모가 커질수록 데이터의 흐름을 추적하기 힘듭니다. React.js는 근원(根源)으로 돌아가는 개발 방법입니다. 그리고 그 과정에서 발생하는 비효율적인 부분, 예를 들어 DOM 전체를 갱신해야하는 문제를 VIRTUAL DOM과 비교(diff)로 해결했습니다.

    기타

    JSX

    추가로 두 가지 더 설명하겠습니다. 첫 번째로 JSX입니다. React.js에서는 JSX라고 하는 XML과 비슷한 문법을 이용할 수 있습니다. 이는 선택적으로 사용할 수 있는 문법이므로 JSX가 마음에 들지 않는다면 자바스크립트로 작성할 수도 있습니다. JSX는 다음에 자세히 소개하겠습니다.

    역자노트

    JSX는 페이스북에서 스펙을 정의한 ECMAScript 친화적인 XML 스러운 문법입니다. React.js에서는 이 문법을 VIRTUAL DOM(또는 컴포넌트의 계층)을 선언적(또는 명시적)으로 서술하여 표현하기 위해 사용됐습니다. 선택적으로 사용할 수 있다고는 하나 JSX가 React.js를 사용하는 이유 중 하나이기 때문에 사용하지 않는 건 개인적으로 추천하지 않습니다.

    Flux

    React.js에 조금 관심이 있는 분은 React.js와 Flux를 세트로 구성하는 방법에 관해 들은 적 있을 겁니다. 이것은 MVC와 같은 아키텍처를 구성하는 이야기이며 단지 Flux의 구성 요소로서 React.js를 사용하는 방법일 뿐 React.js에 포함된 것은 아닙니다. 이와 관련한 내용도 다음에 자세히 소개하겠습니다.

    다음 절에서는 Hello World를 컴포넌트로 만드는 간단한 예제와 함께 React.js를 사용하는 방법을 소개하겠습니다. 예제 코드를 작성할 때는 아래 공식 jsfiddle 링크를 사용하면 더욱 쉽게 테스트할 수 있으니 참고하시길 바랍니다.

    Hello React.js

    이번 절에서는 Hello World를 컴포넌트로 만들어보겠습니다. 기본적으로 React.createClass로 컴포넌트를 만듭니다. 그리고 그 컴포넌트들을 조합해 페이지를 만들고 React.render를 이용해 DOM과 짝을 맞춰 출력합니다.

    JSX 사용

    JSX에 관해서는 다음 절에서 조금 더 자세히 소개할 예정입니다. 보통 아래와 같은 느낌으로 자바스크립트 내에 XML과 비슷한 마크업을 직접 사용할 수 있습니다.

    var React = require('react');
    
    var Hello = React.createClass({
      render: function() {
        return (
          <div className="container">Hello {this.props.name}</div>
        );
      }
    });
    
    React.render(<Hello name="React" />, document.getElementById("app"));

    위 코드를 브라우저에서 실행하면 당연히 에러가 발생합니다. 따라서 react-tools를 사용하여 사전에 컴파일하거나 JSXTransformer를 불러와야 합니다. 또한, browserify와 reactify를 조합해 사용하는 변환 방법도 있습니다. 참고로 말씀드리면 div(division)은 흔히 우리가 생각하는 HTML 태그가 아니라 React의 컴포넌트입니다.

    역자노트

    JSX에서 보이는 div, a 등과 같은 HTML 태그는 사실 HTML 태그가 아니라 모두 React.js의 컴포넌트입니다. 기본 HTML 태그를 React.js에서 미리 컴포넌트로 작성해 제공할 뿐입니다. JSX로 작성되는 모든 요소는 React.js 컴포넌트로 보시면 됩니다.

    JSX + ES6, 7의 문법(일부)

    JSX의 transform에는 harmoney 옵션이 있습니다. 이 옵션을 켜면 ES6, 7의 문법을 일부 사용할 수 있습니다. ES6의 문법인 Arrow function은 map, filter 등과 조합해 사용하면 정말 편리합니다.

    var items = this.props.items.map((item) => {
      return <div>{item.name}</div>;
    });
    역자노트

    위에서 언급된 react-tools와 JSXTransformer는 곧 Babel로 옮겨집니다(참고). 자바스크립트 발전 속도에 대응하기 힘들었던 거로 보입니다. 반면, Babel은 잘 대응하고 있고 또 많은 개발자가 기본적으로 채택하는 빌드 도구이기 때문에 결정한 것 같습니다. 따라서 이를 사용하기보단 Babel과 함께 프로젝트를 구성하길 바랍니다.

    without JSX

    JSX 없이 코드를 작성하면 아래와 같습니다. Hello 컴포넌트의 render 메서드 이외에도 React.render에 Hello 컴포넌트를 전달하는 방식도 바뀌었습니다.

    var React = require('react');
    
    var Hello = React.createClass({
      render: function() {
        return React.DOM.div({className: 'container', 'Hello ' + this.props.name);
      }
    })
    
    React.render(
      React.createFactory(Hello)({name: 'React'}), document.getElementById("app")
    );

    이 Advent Calendar에서 소개하는 코드는 JSX에서 harmony 옵션을 켠 상태에서 작성했음을 알려드립니다. 그럼, 다음 절에서 JSX에 관해 조금 더 넓게 설명하도록 하겠습니다.

    React.js의 JSX

    위 Hello React.js 절에서 JSX를 잠깐 소개했습니다. 이번에는 조금 더 넓게 살펴보도록 하겠습니다.

    JSX

    var Hello = React.createClass({
      render: function() {
        return (
          <div>Hello {this.props.name}</div>
        );
      }
    });

    위 코드에서 한눈에 HTML로 보이는 부분 <div>...</div>이 JSX 문법입니다. XML과 비슷한 형태로 태그를 작성해 나가면 됩니다. 따로 학습하고 기억해야 할 내용은 거의 없습니다. 이 문법에 관한 자세한 설명은 JSX Specification에 작성돼 있습니다. 하나 주의해야 할 점으로는 JSX는 HTML이 아니므로 div에 container라는 클래스를 지정하고 싶은 경우, <div class="container">...</div>가 아니라 <div className="container">...</div>로 작성해야 한다는 것입니다. 자바스크립트의 예약어 문제를 회피하기 위해서 이런 문법으로 디자인됐습니다. 추가로 label의 for 속성은 htmlFor로 작성해야 합니다. 이와 관련한 내용은 Tags and Attributes에 정리돼 있습니다. HTML은 태그가 제대로 닫히지 않아도 에러가 발생하지 않지만 JSX는 태그를 닫지 않은 경우 에러가 발생하므로 문법 문제를 쉽게 알아차릴 수 있습니다.

    사용법

    Realtime로 변환

    JSX Transformer를 불러오면 JSX 문법을 실시간으로 변환할 수 있습니다. 다만, 이 방법을 제품(서비스) 환경에서 사용하는 것은 성능면에서 좋지 않아 권장하지 않습니다. 보통 개발 및 디버깅의 편의를 위해 사용합니다.

    Precompile로 변환

    npm install -g react-tools 커맨드 라인으로 react-tools를 설치하면 jsx 명령을 사용할 수 있습니다.

    $ jsx src/build/

    파일을 감시하는 것도 가능합니다.

    $ jsx --watch src/build/

    browserify나 webpack으로 변환

    browserify와 reactify를 사용하여 변환할 수 있습니다.

    "browserify": {
      "transform": [
        ["reactify", {"harmony": true} ]
      ]
    }

    node-jsx로 변환

    Server-Side rerendering과 같이 ndoe.js 환경에서 변환하고 싶은 경우엔 node-jsx를 사용할 수 있습니다. require하고 install 하는 것으로 간단히 변환할 수 있습니다.

    역자노트

    Browserify와 babelify(babel)를 조합하면 ES6는 물론 React.js의 JSX 문법을 사용할 수 있습니다. ECMAScript 6 compatibility table의 core.js + babel 항목을 보면 사용할 수 있는 ES6 문법을 확인할 수 있습니다. 여기에 Grunt를 이용해 자동화하면 조금 더 안락한 개발 환경을 구성할 수 있습니다.

    JSX 사용 의미

    JSX를 사용하면 HTML 문법과 비슷한 느낌으로 작성할 수 있어 비엔지니어도 이해하기 쉽다는 장점이 있습니다. 개인적인 성향 차이가 있다고는 하지만 개인적으로 React.DOM.div(null, 'hello') 보다 <div>hello</div>와 같은 방식이 더 좋다고 생각합니다. 또, 버전 0.12는 버전 0.11에 비해 React.createClass의 동작 방식(인터페이스)이 바뀌었지만(이것에 관한 내용은 다음 절에서 소개하겠습니다.) JSX를 사용하고 있는 경우엔 코드를 그대로 사용 가능합니다. 즉, JSX에 대한 지원이 조금 더 좋습니다. JSX를 사용했을 때의 이 점은 이 정도로 생각하고 있으므로 자바스크립트로 작성하고 싶은 사람은 굳이 JSX를 사용하지 않아도 괜찮을 것 같습니다. JSX 이외로 커피스크립트 환경을 고려해 만들어진 react-kup도 있습니다.

    변환 코드 확인

    특정 코드를 변환한 결과를 확인하고 싶은 경우엔 아래 문서를 참고하세요.

    ES6, 7

    harmony 옵션을 켜면 JSX의 변환할 시 Class나 Arrow Function 등 ES6, 7의 기능 일부를 사용할 수 있습니다. 개인적으로 아래와 같이 ES6, 7 문법으로 작성하는 것을 좋아해 옵션을 켜두고 사용하고 있습니다.

    var Items = React.createClass({
      itemName(item) {
        return `${item.name}:${item.count}`;
      },
      render() {
        var items = this.props.items.map(item => <span>{this.itemName(item)}</span>);
    
        return (
          <div>{items}</div>
        );
      }
    });

    아래와 같은 기능들을 사용할 수 있습니다.(참고)

    • es6-arrow-functions
    • es6-object-concise-method
    • es6-object-short-notation
    • es6-classes
    • es6-rest-params
    • es6-templates
    • es6-destructuring
    • es7-spread-

    React.js를 처음 접하면 JSX라는 불가사의한 언어를 사용할 필요가 있어서 꺼리는 사람도 있을 것 같습니다만 컴포넌트를 알기 쉽게 정의하기 위한 문법이므로 조금 더 가볍게 생각했으면 좋겠습니다. 다음 절에서는 컴포넌트에 관해 조금 더 설명하겠습니다.

    React.js의 컴포넌트

    이번에는 컴포넌트를 소개하겠습니다. React.js에서는 기본적으로 컴포넌트를 만들고 조합하여 애플리케이션을 만듭니다.

    render

    컴포넌트는 React.createClass()에 render 메서드를 가진 리터럴 객체를 전달해 작성할 수 있습니다.

    var Hello = React.createClass({
      render() {
        return (
          <div><span>hello</span></div>
        )
      }
    });

    그러면서 render()는 컴포넌트를 하나만 반환해야 합니다. 아래 처럼 복수의 컴포넌트를 반환할 수 없습니다.

    // NO
    render() {
       return (
         <div>title</div>
         <div>contents</div>
       );
    }
     
    // OK
    render() {
      return (
        <div>
          <div>title</div>
          <div>contents</div>
        </div>
      );
    }

    또, render()는 어떤 타이밍에 몇번 호출될지 모르기 때문에 반드시 멱등성을 지키는 방법으로 구현해야합니다.

    Separation of concerns?

    React.js는 컴포넌트로써 마크업과 뷰의 로직을 createClass()의 안에 작성합니다. 하지만 마크업은 HTML이나 mustache로 작성하고 뷰의 로직은 자바스크립트로 나눠서 작성하는 기존의 방식을 취하지 않아 마음에 들지 않는 사람도 있을 것 같습니다. 이 사안에 대해 React.js의 개발자인 Pete Hunt는 “그것은 관심사의 분리(Separation of concerns)가 아니라 기술의 분리(Speparation of technologies)”라며 마크업과 뷰의 로직은 긴밀해야 한다고 언급했습니다. 거기에 템플릿의 문법으로 불필요하게 코드를 작성하는 것보다 자바스크립트로 작성하는 것이 더 좋다고 말하고 있습니다.

    역자노트

    HTML, CSS, 자바스크립트를 분리하는 건 관심사의 분리가 아니라 단순한 기술의 분리일 뿐, 그래서 React.js는 관심사를 컴포넌트 단위로 해석했다고 이해할 수 있습니다.

    컴포넌트 간의 상호작용

    Prop을 I/F로써 외부와 주고 받을 수 있습니다. <Hello name="foo"/> 처럼 작성하면, this.props.name 으로 참조할 수 있습니다.

    var Hello = React.createClass({
      render() {
        return (
          <div>Hello {this.props.name}</div>
        )
      }
    });
    
    // <Hello name="React"/>
    // <div>Hello React</div>

    Prop에 관해서는 다음 편에서 소개할 예정입니다.

    동적으로 갱신

    유저의 액션이나 Ajax 요청 등으로 값이 동적으로 변화하는 경우는 State를 사용합니다. 특정 this.state.xxx을 갱신할 때는 this.state를 사용해 갱신하는 것이 아니라 반드시 this.setState를 사용해 갱신합니다.

    var Counter = React.createClass({
      getInitialState() {
        return {
          count: 0
        };
      },
      onClick() {
        this.setState({count: this.state.count + 1});
      },
      render() {
        return (
          <div>
            <div>count:{this.state.count}</div>
            <button onClick={this.onClick}>click!</button>
          </div>
        );
      }
    });

    State에 관한 내용은 다음 편에서 소개할 예정입니다.

    React.createClass

    React.createClass()는 컴포넌트를 작성할 때 사용하는 함수입니다. 이 함수는 버전 0.12에서 동작 방식이 바뀌었습니다. 0.11에서는 컴포넌트의 정의하고 컴포넌트의 엘리먼트를 반환하는 두 가지의 일을 담당했지만 0.12부터 컴포넌트를 정의하는 작업만 담당하도록 분리됐습니다. 즉, 엘리먼트가 아니므로 사용할 때는 React.createElement(Component, {name: 'xxx'}) 처럼 React Element로 변환할 필요가 있습니다. 이 작업은 React.createFactory(Component)로 해도 같습니다. 다만, JSX를 사용하고 있는 경우는 이전과 똑같이 React.createClass의 반환 값을 <Component />로 직접 전달해도 괜찮습니다.

    var Hello = React.createClass({
      render() {
        return <div>{this.props.name}</div>;
      }
    });
     
    React.render(React.createElement(Hello, {name: "foo"}), document.body);
    // or
    React.render(React.createFactory(Hello)({name: "foo"}), document.body);
    
    // JSX는 이전과 같은 방식
    React.render(<Hello name="foo" />, document.body);

    이 변경은 createClass()라는 이름 외에 또 다른 일을 담당하고 있었다는 문제를 해결하기도 하지만, createElement를 통해 컴포넌트를 만들도록 함으로써 최적화할 수 있도록 하고 장기적으로 React.createClass로 작성한 문법을 ES6의 class로 대체 할 수 있도록 하려는 뜻도 있습니다.

    역자노트

    최근에 릴리즈된 버전 0.13에는 ES6의 class 문법을 사용해 컴포넌트를 정의할 수 있게 됐습니다. (참고) ES6 Classes 문법을 이용해 컴포넌트를 작성할 때 몇 가지 주의점이 필요합니다. 이런 사항은 천천히 소개해 드리겠습니다.

    정리

    여기까지 React.js의 기본적인 특징과 컴포넌트를 명시적으로 서술하기 위한 JSX 문법 등을 알아봤습니다. 컴포넌트는 이 밖에도 Lifecycle을 이용해 hook을 하는 방법도 있습니다. 그 방법에 대해서는 추후 천천히 소개하도록 하고 다음편에서는 Prop와 State 그리고 이 두 속성을 이용해 컴포넌트를 작성하는 방법을 소개하겠습니다.

  • 읽기전에...

    이 문서는 InfoQ의 「Facebook: MVC Does Not Scale, Use Flux Instead(일본어, 영어)」를 번역한 글입니다. 주로 일본어 문서를 번역했으며 영어 문서는 참고 자료로써 사용했습니다.

    이 문서는 개발자 커뮤니티와 Jing Chen(페이스북)의 반응을 바탕으로 업데이트하고 있다.(아래 Update 절 참고)

    페이스북은, MVC 패턴으로는 자신들이 원하는 명세를 수용할 수 없다고 결론을 내리고 대신 또 다른 패턴인 Flux 사용을 결정했다.

    지난 F8의 「Hacker Way: Rethinking Web App Development at Facebook」 세션에서 페이스북의 기술 매니저인 Tom Occhino는 일정 수준 이상의(sufficiently) 코드 베이스와 대규모 개발 조직에 관해 설명하고 거듭 「MVC는 정말 눈 깜짝할 사이에 복잡해진다」 라고 말하며 MVC는 큰 시스템에 어울리지 않는다고 결론지었다. 어떤 새로운 기능을 추가하려고 할 때마다 시스템의 복잡도는 기하급수적(지수, exponential)으로 증가하며 「깨지기 쉽고 예측 불가능한 코드」가 된다, 이것은 거대한 코드 베이스에 참여한 개발자에게 「이미 존재하는 기능에 문제를 발생시킬까 두려워 코드를 수정하지 못하는 새로운 심각한 문제」를 일으킨다고 이어서 말했다. 그 결과 페이스북은 MVC와 결별하게 된 것이다.

    이 문제를 해결하기 위해서는 「좀 더 예측 가능한 형태로 코드를 구조화하는 것」이 필요하며 이것은 Flux와 React를 이용해서 달성할 수 있다고 한다. Flux는 애플리케이션 내의 데이터 흐름을 단방향(single directional data flow)으로 흐를 수 있도록 도와주는 시스템 아키텍처다. React는 예측 가능하며 선언적(또는 서술적)으로 웹 애플리케이션을 구축하기 위한 자바스크립트 프레임워크이며 페이스북의 웹 애플리케이션 개발을 더욱 빠르게 할 수 있도록 한다고 Tom Occhino는 말했다.

    Jing Chen씨는 이어서 MVC는 소규모 애플리케이션에는 적합하지만 아래 이미지처럼 Model이나 Model과 관련한 View가 대량으로 시스템에 추가되면 복잡도가 폭발적으로 증가한다고 말했다.

    MVC의 데이터 흐름
    <그림 1. MVC의 데이터 흐름>

    이러한 애플리케이션은 Model과 View 사이의 데이터를 양방향(bidirectional data flow)으로 흐르게 할 가능성이 있고, 따라서 이해하고 디버깅하기 어렵다고 말하면서 대신 아래 Flux와 같은 설계를 제안했다.

    Flux의 데이터 흐름
    <그림 2. Flux의 데이터 흐름>

    그림 2의 Store는 애플리케이션의 모든 데이터를 포함한다. Dispatcher는 MVC의 Controller를 대체하며 어떠한 Action이 발생(trigger)했을 때 어떻게 Store를 갱신할지를 결정한다. Store가 변경될 때에는 View도 동시에 갱신된다. 또, 선택적으로 Dispatcher가 처리할 Action을 발생시킬 수도 있다. 이처럼 시스템의 컴포넌트 간 데이터 흐름은 단방향으로 유지됨을 알 수 있다. 데이터는 단방향으로만 흐르고 각각의 Store와 View는 서로 직접적인 영향을 주지 않기 때문에 여러 개의 Store나 View를 갖는 시스템도, 하나의 Store나 View 갖는 시스템과 같다고 볼 수 있다.

    페이스북 깃-허브의 Flux Overview 페이지에 Flux나 Dispatcher 그리고 Store에 관해 자세히 작성돼 있다.

    Dispatcher와 Store

    Dispatcher는 Flux 아키텍처의 모든 데이터 흐름을 관리하는 중앙 허브다. 이는 본질적으로 Store 내에서 콜백을 등록할 때 사용하는 장소다. 각 Store는 Dispatcher에 등록할 콜백을 제공한다. 이 Dispatcher가 발생시킨 Action에 응답할 때 애플리케이션 내의 모든 Store는 Dispatcher에 등록한 콜백을 통해 Action에 의해 생긴 데이터를 송신한다.

    등록된 콜백을 정해진 순서로 실행하여 Store 간의 의존 관계를 관리할 수 있으므로 애플리케이션이 커질수록 더욱 없어선 안 될 존재가 된다. 선언에 따라 Store는 다른 Store의 갱신이 완료될 때까지 기다린 다음 자기 자신을 갱신할 수 있다.

    Store는 애플리케이션의 상태나 논리를 포함한다. Store의 역할은 전통적인 MVC의 Model 역할과 조금 비슷하다. 하지만 다수의 객체의 상태를 관리하는 MVC와 달리 단일 객체 인스턴스(싱글-톤)로 관리한다. 또, Backbone 프레임워크의 컬렉션과도 같지 않다. ORM 형식의 객체를 집합으로 관리하기보다 조금 더 단순하게 애플리케이션 내의 한 특정 도메인에 관한 애플리케이션의 상태를 관리한다.

    중요한 것은 데이터 계층에서 다른 Action이 발생하기 전에 자신과 관계를 맺고 있는 View의 갱신을 끝내는 것이라고 Jing Chen씨는 말했다. Dispatcher는 이전 Action의 처리를 완료하지 않은 상태라면 다음 Action의 처리를 거부할 수 있다. 이 설계 방식은 다른 View도 함께 갱신할 때 발생할 수 있는 부작용을 가지고 있는 Action에 대응할 때 유용하며 코드를 좀 더 명확하게 이해할 수 있고 새로운 개발자도 디버깅을 간단하게 할 수 있도록 한다고 했다. Flux는 페이스북 채팅의 버그(가짜 신규 메시지 통지를 발생시키는 버그)를 수정하는 역할로 사용됐다.

    Flux TodoMVC 튜토리얼소스 코드는 깃-허브에 공개돼 있다.

    물론 페이스북이 그들이 옳다고 생각하는 설계 방식을 따르는 것은 그들의 자유지만, 여전히 의문은 남아있다. 과연 정말 MVC는 확장에 용이하지 않을까? 이미 주변의 많은 웹사이트는 성장하고 확장되고 있다.

    Update. 이 기사를 공개한 뒤, MVC에 관한 페이스북의 결정에 관해 많은 개발자가 Reddit을 통해 의견을 보냈다. 여기에서 댓글 몇 개를 소개하겠다. 애초에 페이스북이 MVC를 오용했다고 생각하는 사람도 있지만, 페이스북이 올바른 결정을 했다고 생각하는 사람도 있다.

    giveupitscrazy :

    이건 전혀 의미가 없다. 먼저 그들의 MVC 구성에는 두드러지는 결함이 있다. 그들은 컨트롤러가 상호 작용하고 있는 Model에 따라서 혹은 논리적인 이유로 누구나 분할이 필요하다고 느껴지는 때조차 여러 Model을 조작하는데 단 한 개의 컨트롤러를 사용하고 있다. 물론 그들이 묘사하고 있는 MVC는 동작하지 않겠지만 그래도 그것은 진짜 MVC가 아니다. 만약 그들의 Flux의 다이어그램과 실제 MVC의 다이어그램을 비교한다면 웹 애플리케이션에 있어 MVC가 본질적으로 문제 될 것이 없다는 것을 분명히 알 수 있다.

    balefrost :

    그렇다. 그들의 Flux 다이어그램은 모두가 알고 있는 MVC의 다이어그램과 매우 닮아있다. 그들은 실용적인 MVC를 재발명(re-invented)했을 것이다. 그리고 그것에 새로운 이름을 붙이기로 결정한 것이다. 아하!

    hackinthebochs :

    이 아키텍처는 이벤트 주도 방식으로 기존 MVC를 조금 변경한 것이다. 「Store」는 자기 자신(그리고 아마도 호출 순서의 의존성)을 Dispatcher에 등록하고 그 Dispatcher는 Action을 처리하여 올바른 호출 순서가 달성되도록 보증한다. 이것은 올바른 호출 순서를 보증해야 하는 부담을 컨트롤러에서 Dispatcher와 Store로 분리한 것이다. 이것은 동작을 수정하는 데 필요한 지식을 줄여준다.

    runvnc :

    이 아키텍처를 아주 깊게 이해했다고 아직 말할 수는 없지만, 어느 정도는 이해했다고 생각하고 있고 전반적인 아이디어에 찬성한다.

    Reddit 유저 jingc09는 댓글 내용을 보아하니 Jing Chen으로 보인다. 아래에 몇 가지 답변하고 있다.

    jingc09 :

    확실히 저것(그림 1)은 까다로운 슬라이드였다(하나의 컨트롤러가 다수의 Model이나 View와 결합하고 있고, 데이터는 양방향으로 흐르고 있다). 이 논쟁의 원인의 일부는 MVC가 엄밀히 무엇인지에 대해 충분히 일치된 의견 없이 많은 사람이 각각의 의견을 가지고 있기 때문이다. 본래 우리가 논해야 할 주제는 양방향 데이터 흐름에 대해서다. 그것은 한쪽의 데이터 변경이 또 다른 쪽에 영향을 줄 수 있고(loop back), 한편 연쇄적인 효과도 가지고 있다.

    그녀의 말을 명확하게 이해하기 위해서 「Flux의 Dispatcher는 MVC 컨트롤러가 아니다」라는 글을 보는게 좋다.

    한가지 내가 밝히고 싶은 것은 Dispatcher가 Controller와 같은 역할을 담당하는 것은 아니라는 것이다. Dispatcher는 비즈니스 로직을 가지고 있지 않고 우리는 Dispatcher 코드를 복수의 애플리케이션에서 재사용하고 있다. 그것은 단지 이벤트에 대응하는 구독자(대개 Store)를 등록하기 위한 중앙 허브에 불과하다. 그러나 단방향 데이터 흐름을 가능케 하는 장소이기 때문에 Flux 아키텍쳐에 있어서 중요한 요소다.

    Wikipedia의 MVC 컨트롤러에 관한 설명 글에는 다음과 같이 작성돼 있다.

    컨트롤러는 Model의 상태를 갱신하기 위해(예를 들어 문서 편집 등) 명령을 할 수 있다. 또 그것은 Model에 관계한 View의 출력을 변경하기 위해(여를 들어 문서의 스크롤) View에게도 명령을 할 수 있다.

    Jing Chen은 말했다.

    Dispatcher에서는 이러한 작업을 할 수 없다. 그 명령은 다른 어딘가(View나 서버의 응답, 라이브 갱신 등)에서 출발하여 Dispatcher에 전송해야 한다. Todomvc-flux의 Dispatcher.js(아마 그 코드는 Actions로 옮겨진 것 같다)를 보면 이러한 사실을 이해할 수 있을 것이다.

    Reddit의 댓글을 살펴보면 MVC가 어떤 것이며 어떤 방법으로 구현 해야 하는지 대해 잠시 혼란이 있어 보인다.

    Facebook의 MVC에 관한 결정에 관해 우리는 아래와 같은 두 가지 관점을 가지고 있다.

    1) 첫 번째 슬라이드의 다이어그램은 너무 많은 Model과 View가 관계를 맺는 억지스러운 예이기 때문에 독자에게 이런 경우가 실존하는 것인지 의문점을 안긴다. 페이스북이 Flux로 해결한 문제는 고작 3개의 View를 가진 채팅 애플리케이션이었다.

    2) 그들의 MVC 예에서는 왜 View가 데이터 흐름을 생성해 양방향 데이터 흐름을 만들어 낼까? 또 왜 이 Flux의 다이어그램에서는 View가 Action을 발생시키는 것일까? View는 그냥 View 일뿐이므로 View는 아무것도 발생시키지 않아야 할 것이다. 페이스북은 MVC를 오용하고 있는 것은 아닐까?

  • 이 글은 예전에 사내 메일로 공유했던 내용입니다. 하지만 아직도 그 이유와 방법을 모르시는 분이 많은거 같아서 정리해 공유드립니다.

    왜 파일 마지막에 개행을 해야할까?

    이유는 POSIX 명세가 그러하기 때문입니다. 명세에는 프로세스 환경, 파일과 디렉터리 등 다양한 개념을 규격화하고 있습니다.
    여기에 텍스트 파일(Definitions - 3.392 Text File)과 라인(Definitions - 3.205 Line)에 대한 규격도 정의돼 있습니다.

    한번 살펴보겠습니다.

    • Definitions - 3.392 Text File : A file that contains characters organized into one or more lines. The lines do not contain NUL characters and none can exceed {LINE_MAX} bytes in length, including the Although IEEE Std 1003.1-2001 does not distinguish between text files and binary files (See the ISO C Standard), many utilities only produce predictable or meaningful output when operating on text files. The standard utilities that have such restrictions always specify “text files”in their STDIN or INPUT FILES sections.
    • Definitions - 3.205 Line : A sequence of zero or more non- s plus a terminating .

    이 두가지 명세를 종합해보면 재미있는 사실을 알 수 있습니다.

    • 행의 끝(terminating)은 개행(EOL, end-of-line)
    • 텍스트 파일은 행의 집합이며 행은 반드시 개행으로 끝난다.

    따라서 많은 시스템과 도구들이 이 표준을 따라 구현되어 있습니다.
    이를 지키지 않을 시 예기치 않은 동작을 일으킬 수 있다는 것이죠.

    특히, 파일 마지막에 개행이 없다면 파일간의 차이를 알기 어렵습니다.

    a.html과 b.html을 하나의 문장으로 출력
    <그림 1. a.html과 b.html을 하나의 문장으로 출력>

    파일 마지막에 빈공간을 넣는다면 두 파일의 차이를 알 수 있습니다.

    a.html과 b.html을 다른 문장으로 출력
    <그림 2. a.html과 b.html을 다른 문장으로 출력>

    컴파일러인 gcc 역시 파일의 마지막에 개행이 없다면 경고합니다.

    컴파일 에러
    <그림 3. 컴파일 에러>

    깃허브도 마찬가지군요.

    깃허브 경고
    <그림 4. 깃허브 경고>

    이런 연유로 VIM과 같은 유닉스 에디터들은 자동적으로 파일의 마지막에 개행을 추가합니다.

    만약 개발자 A가 개행을 하지 않고 커밋했는데, 개발자 B의 시스템이 자동으로 개행을 한다면 저장소에는 아무 정보도 없는 개행에 대한 로그가 남게 됩니다. 따라서 파일의 맨 마지막에 개행을 하나 추가하는 것은 필수라는 사실을 알 수 있습니다.

    웹스톰에서 자동으로 개행 추가하기

    웹스톰에서 자동으로 개행을 추가하는 방법은 아주 간단합니다.

    Preferences(윈도우는 Setting)에서 Editor > General 메뉴를 클릭해 General 설정 패널을 엽니다. 그리고 하단에 Ensure line feed at file on Save 를 체크하고 저장하면 끝입니다.

    WebStorm의 자동 개행 설정
    <그림 5. WebStorm의 자동 개행 설정>

    이상으로 웹스톰에서 자동으로 개행하는 방법에 대해 말씀 드렸습니다. 천천히 다른 에디터 환경에서도 설정하는 방법을 추가할 생각입니다. 감사합니다.