블로그를 변경하면서 간단한 템플릿 엔진이 필요했다. 템플릿 엔진을 사용하려고 살펴보니 새 문법을 배우는 것도 번거롭고 정말 작은 부분 때문에 의존성을 추가하는 것도 별로 맘에 들지 않았다. 그동안 잘 사용해온 템플릿 리터럴을 그냥 사용할 방법은 없을까 찾아보다가 Function 생성자를 사용해서 다음처럼 작성할 수 있었다.

"use strict";

function template(html, params = {}) {
  const keys = Object.keys(params);
  const ps = keys.map(k => params[k]);
  return new Function(
    ...keys,
    'return (`' + html + '`)')
  (...ps);
}

Function 생성자는 파라미터 목록과 함수 내에 들어갈 내용을 문자열로 받고 그 함수를 반환한다. 위에서 보면 전달한 params에서 키 값을 수집해 함수를 생성하는 일에 사용하고 또 키 이름 순서에 맞게 ps 배열을 만들어 함수에 전달했다.

이제 html로 문자열을 전달하면 템플릿 리터럴로 이용할 수 있다.

const welcomePage = '<h1>${name}님 환영합니다.</h1>'
template(welcomePage, {name: '거북이'})
// "<h1>거북이님 환영합니다.</h1>"

다만 "`" 문자 사용에 주의해야 한다.

const welcomePage = '<h1>\\`${name}\\`님 환영합니다.</h1>'
template(welcomePage, {name: '거북이'})
// "<h1>`거북이`님 환영합니다.</h1>"

다른 도움 함수

부분함수도 손쉽게 만들 수 있다.

function partial(html) {
  return function render(params) {
    return template(html, params);
  }
}
const accountPage = '<button>${name}님의 계정 정보</button>'
const accountPartial = partial(accountPage)

const me = accountPartial({name: '나'})
// "<button>나님의 계정 정보</button>"
const koala = accountPartial({name: '코알라'})
// "<button>코알라님의 계정 정보</button>"

템플릿 함수가 필요하다면 다음처럼 params에 함께 전달할 수 있다.

const balance = () => 3000 // 또는 좀 더 복잡한 코드

const balanceInfo = template('<strong>잔고: ${balance()}</strong>')({balance})

nodejs에서 사용하고 있기 때문에 nodejs의 모듈을 사용해 html을 불러오는 함수를 다음처럼 작성했다. 또한 템플릿 내부에서 별도의 파일을 불러올 수 있도록 load 함수를 경로 맥락과 함께 템플릿 내로 전달했다.

import fs from 'fs'
import path from 'path'

function load(filename, basePath = '.') {
  const dirname = path.dirname(filename);
  const html = fs.readFileSync(path.join(basePath, filename));
  const partialHtml = partial(html);

  return function loadPartial(params) {
    return partialHtml({
      ...params,
      load: function loadFromSubDir(filename) {
        return load(filename, dirname);
      }
    })
  }
}
<!-- ./templates/index.html -->
<h1>안녕하세요!</h1>

${load('./partials/info.html')({user})}
<!-- ./templates/partials/info.html -->
<p>${user.name}님의 계정 정보</p>
load('./template/index.html')({user: {name: '당근'}})
// "<h1>안녕하세요!</h1>\n\n<p>당근님의 계정 정보</p>"

템플릿 내에서 다른 유틸리티 함수가 더 필요하다면 위 함수와 같이 더 추가하면 되겠다.

보안 고려하기

사용자 입력을 직접 템플릿에 사용하는 것은 당연히 위험하다! 상황에 맞게 적절한 예비 조치가 필요하다.

function sanitize(text) {
  return text.replace(/[^ㄱ-ㅎ|가-힣|a-z|A-Z|0-9| ]+/gi, "")
}

template(
  "<p>${sanitize(name)}님 안녕하세요!</p>", {
    name: "<strong>헤헤</strong>",
    sanitize,
  })
// "<p>strong헤헤strong님 안녕하세요!</p>"

이렇게 작은 템플릿 함수를 작성해봤다. 템플릿 리터럴을 활용하면 몇 줄 안되는 코드로도 템플릿 함수를 구현할 수 있었다. 간단한 수준에서라면 이 함수로도 충분히 활용 가능하겠지만 이 접근 방식의 한계(Function 내에서 바깥 스코프에 접근할 수 있는 등)로 보안 문제가 발생할 수 있다. 이런게 가능하다 정도로만 이해하고 제대로 된 라이브러리를 활용하는 것이 더 바람직하다.

프로그래밍을 한다면 컴파일러는 빼놓을 수 없는 부분입니다. 항상 사용하지만 어떻게 내부적으로 구현되어 있는지는 잘 알기 어려울 수 있습니다. 이 글은 작은 컴파일러를 직접 만들어보는 과정을 통해서 현대적인 컴파일러가 어떤 방식으로 동작하는지 설명합니다. 적은 양의 코드지만 구조나 동작 원리를 이해하는 데에는 부족함이 없습니다. 더 자세히 알고 싶다면 찾아볼 수 있도록 각각의 키워드를 잘 알려주고 있어서 아주 유익합니다.

이 포스트는 jamiebuilds/the-super-tiny-compiler의 번역글입니다. 그리고 전체 코드는 the-super-tiny-compiler.js에서 확인할 수 있습니다.


아주 조그마한 컴파일러 만들기

오늘은 함께 컴파일러를 작성하려고 합니다. 하지만 그냥 아무 컴파일러가 아닌 엄청나게 작고 조그만 컴파일러를 만들겁니다! 컴파일러가 엄청 작은 나머지 파일에 있는 주석을 모두 지운다면 코드는 200여 줄만 남습니다.

여기서는 lisp 스타일의 함수 호출을 C 스타일의 함수 호출로 컴파일 하려고 합니다. 물론 이 스타일에 익숙하지 않을 수 있으니 짧게 설명하고 지나갈게요! 만약 두 함수 addsubtract가 각 스타일로 작성되었다고 하면 다음과 같습니다.

               LISP 스타일                 C 스타일
2 + 2          (add 2 2)                 add(2, 2)
4 - 2          (subtract 4 2)            subtract(4, 2)
2 + (4 - 2)    (add 2 (subtract 4 2))    add(2, subtract(4, 2))

간단하죠?

이게 바로 우리가 컴파일 할 내용입니다. 완벽한 LISP이나 C 문법은 아니긴 하지만 요즘 현대적인 컴파일러가 어떤 역할을 하고 있는지 대략적으로 보여주기엔 적당한 예제입니다.

대부분 컴파일러는 분석, 변환, 코드 생성 같은 단계를 거칩니다.

  1. 분석(parsing) 단계에서는 코드 그대로를 좀 더 추상화된 코드로 변환합니다.
  2. 변환(transformation) 단계는 이 추상화된 코드를 컴파일러가 하려는 작업에 용이하도록 조작합니다.
  3. 코드 생성(code generation) 단계는 이 변환된 코드 표현을 갖고서 새로운 코드 형태로 변환하는 일을 합니다.

컴파일 단계

분석 (Parsing)

분석 단계는 일반적으로 어휘 분석과 구문 분석 단계로 나눠집니다.

  1. 어휘 분석(Lexical Analysis) 단계는 코드를 더 작은 형태인 토큰(token) 단위로 나누는 작업을 합니다. 토크나이저(tokenizer) 또는 렉서(lexer)가 이 작업을 수행합니다.

    토큰은 배열 형태의 작은 개체로 한 조각의 문법을 담고 있습니다. 숫자나 꼬리표(labels), 구두법, 연산자 등 어떤 것이든 이렇게 저장됩니다.

  2. 구문 분석(Syntatic Analysis) 단계는 앞 단계에서 만든 토큰을 각각의 문법이나 서로 관계를 잘 표현하는 형태로 재구성하게 됩니다. 이 과정으로 만든 결과물을 중간 표현(intermediate representation) 또는 추상 구문 트리(Abstract Syntax Tree)이라고 말합니다.

    추상 구문 트리(줄여서 AST)는 깊숙하게 중첩된 형태의 개체로 존재합니다. 그 형태로 코드가 쉽게 동작할 수 있으며 동시에 많은 정보를 알려줍니다.

다음 구문을 봅시다.

(add 2 (subtract 4 2))

이 구문에서 생성한 토큰은 다음과 같은 모습입니다.

[
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'add'      },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'subtract' },
  { type: 'number', value: '4'        },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: ')'        },
  { type: 'paren',  value: ')'        },
]

그리고 추상 구문 트리(AST)는 이런 모습이 될 겁니다.

{
  type: 'Program',
  body: [{
    type: 'CallExpression',
    name: 'add',
    params: [{
      type: 'NumberLiteral',
      value: '2',
    }, {
      type: 'CallExpression',
      name: 'subtract',
      params: [{
        type: 'NumberLiteral',
        value: '4',
      }, {
        type: 'NumberLiteral',
        value: '2',
      }]
    }]
  }]
}

변환 (Transformation)

컴파일러의 다음 단계는 변환입니다. 다시 말하면 앞 단계에서 생성한 AST를 갖고서 변환 작업을 수행합니다. AST를 동일한 언어로 조작하거나 완전히 다른 언어로 번역할 수도 있습니다.

이제 이 AST를 어떻게 변환하는지 확인해봅시다.

AST를 보면 비슷하게 생긴 요소가 많은걸 알 수 있습니다. 각 개체마다 타입 속성(property)를 포함하고 있습니다. 각각 개체를 AST 노드라고 부릅니다. 이 각각의 노드는 여러 속성이 있으며 동시에 트리의 일부를 각자 정의하는 역할을 하고 있습니다.

"NumberLiteral" 노드를 상상해봅시다.

{
  type: 'NumberLiteral',
  value: '2',
}

또는 "CallExpression" 이라는 노드도 존재할 수 있죠.

  {
    type: 'CallExpression',
    name: 'subtract',
    params: [...여기에 중첩 노드가 위치합니다...],
  }

AST를 변환하면서 속성을 추가하거나 제거, 치환하는 식으로 노드를 조작할 수 있습니다. 그러면서 새로운 노드를 추가하거나 제거하거나 아니면 아예 AST를 그대로 두고 완전 새로운 트리를 만들어낼 수도 있습니다.

여기서는 새로운 언어로 변환하는 것이 목표이기 때문에 목표가 되는 언어에 딱 맞춰서 새로운 AST를 만들기로 합니다.

순회 (Traversal)

이 노드를 모두 탐색하려면 일일이 순회 할 필요가 있습니다. 이 순회 과정은 AST의 각 노드를 깊이 우선으로 탐색합니다.

{
  type: 'Program',
  body: [{
    type: 'CallExpression',
    name: 'add',
    params: [{
      type: 'NumberLiteral',
      value: '2'
    }, {
      type: 'CallExpression',
      name: 'subtract',
      params: [{
        type: 'NumberLiteral',
        value: '4'
      }, {
        type: 'NumberLiteral',
        value: '2'
      }]
    }]
  }]
}

이 AST라면 다음 같은 순서로 접근하게 됩니다.

  1. Program - AST의 가장 윗 단계에서 시작
  2. CallExpression (add) - Program의 첫 요소로 이동
  3. NumberLiteral (2) - CallExpression 속성의 첫 번째 요소로 이동
  4. CallExpression (subtract) - CallExpression 속성의 두 번째 요소로 이동
  5. NumberLiteral (4) - CallExpression 속성의 첫 번째 요소로 이동
  6. NumberLiteral (2) - CallExpression 속성의 두 번째 요소로 이동

만약 분리된 AST를 생성하는 것 대신에 AST를 직접 변환한다면 여기서 온갖 종류의 추상적 접근을 소개해야 합니다. 다만 여기서의 목적으로는 단순히 트리 내 각 노드를 일일이 보는, 방문하는 정도면 충분하겠습니다.

여기서 "방문하다(visiting)"이란 표현을 사용한건 이유가 있습니다. 바로 개체 구조의 요소를 대상으로 연산하게 되는데 거기서 사용하는 패턴이 비지터 패턴을 사용하기 때문입니다.

방문자(Visitors)

여기서 "방문자" 개체를 만드는데 이 개체에 각각 메소드로 다른 노드 타입을 처리하도록 하는게 기본 아이디어입니다.

var visitor = {
  NumberLiteral() {},
  CallExpression() {},
};

AST를 순회하면서 노드에 "입장"할 때면 그 노드 타입에 맞춰서 이 방문자 개체에 있는, 동일한 이름의 메소드를 호출할 겁니다.

이걸 유용하게 만들려면 해당 노드와 함께 부모 노드의 참조도 그 메소드에 전달해야 합니다.

var visitor = {
  NumberLiteral(node, parent) {},
  CallExpression(node, parent) {},
};

하지만 "퇴장"하는 경우에 무언가를 호출해야 하는 가능성도 있습니다. 앞서 트리 구조를 목록 형태로 다시 확인해봅시다.

  • Program
    • CallExpression
      • NumberLiteral
      • CallExpression
        • NumberLiteral
        • NumberLiteral

트리를 순회해서 가지(branch) 끝까지 내려가면 더이상 갈 곳이 없는 곳에 도달하게 됩니다. 각 가지 끝에 도달하면 그 가지에서 "퇴장"해야 합니다. 즉 트리를 타고 내려가면 각 노드에 "입장"해야 하고 다시 올라오면서 "퇴장"해야 하는 겁니다.

-> Program (입장)
  -> CallExpression (입장)
    -> Number Literal (입장)
    <- Number Literal (퇴장)
    -> Call Expression (입장)
        -> Number Literal (입장)
        <- Number Literal (퇴장)
        -> Number Literal (입장)
        <- Number Literal (퇴장)
    <- CallExpression (퇴장)
  <- CallExpression (퇴장)
<- Program (퇴장)

최종적으로 입장과 퇴장을 처리할 수 있는 방문자 개체의 모습은 다음과 같습니다.

var visitor = {
  NumberLiteral: {
    enter(node, parent) {},
    exit(node, parent) {},
  }
};

코드 생성 (Code Generation)

컴파일러 최종 단계는 코드 생성입니다. 컴파일러는 종종 변환 단계서 하는 작업과 겹치는 작업을 여기서 하게 되는데 대부분 코드 생선 단계에서는 AST를 가지고 문자열 같은 코드 형태로 출력하는 일을 하게 됩니다.

코드 생성기는 여러 다른 방식으로 동작하는데 어떤 컴파일러는 앞서 생성한 토큰을 재활용하기도 하고 또 다른 방식은 완전히 코드와 분리된 표현식을 생성해서 노드를 선형적으로 생성하기도 합니다. 하지만 여기서 얘기하자면 대부분은 동일한 AST를 생성하기 때문에 여기서도 그 방법에 집중하려고 합니다.

코드 생성기는 모든 다른 노드 타입을 어떻게 "출력"하는지 실질적으로 알고 있게 될 겁니다. 또한 중첩된 노드를 하나의 긴 문자열 코드로 전부 출력할 때까지 스스로를 재귀적으로 호출하도록 작성하려고 합니다.


여기까지! 컴파일러에 필요한 모든 부분을 확인했습니다.

물론 모든 컴파일러가 여기서 설명한 것처럼 완전 동일하게 동작하진 않을 겁니다. 컴파일러는 각각 다른 용도에 따라 쓰이기도 하고 여기서 설명보다 더 많은 단계로 동작하기도 합니다.

하지만 컴파일러 대부분에서 찾을 수 있는 고수준의 개념은 여기서 다 얘기했습니다. 이제 모든 내용을 설명했으니 가서 컴파일러를 직접 만들 수 있으시겠죠?

물론 농담입니다 :) 여기서 함께 작성해보도록 합시다!

코드 작성하기

토크나이저 (Tokenizer)

컴파일러의 가장 첫 단계인 분석에서 어휘 분석을 토크나이저로 시작합니다. 다음 코드 문자열을 갖고 토큰 배열 형태로 변환할 겁니다.

(add 2 (subtract 4 2))   =>   [{ type: 'paren', value: '(' }, ...]

이제 코드를 작성해봅시다.

// 여기서 문자열 형태로 코드를 받을 겁니다. 먼저 변수 둘을 준비합니다.
function tokenizer(input) {

  // `current` 변수는 커서처럼 코드에 어느 위치에 있는지 저장합니다.
  let current = 0;

  // `tokens`는 토큰을 보관할 배열입니다.
  let tokens = [];

  // 먼저 반복문 내에서 증가하는 `current` 변수를 검사하도록 `while` 반복문을
  // 만듭니다.
  //
  // 토큰이 어떤 길이가 되든 처리할 수 있도록 하기 위해서 이렇게 작성했습니다.
  // 즉 반복문을 한 번만 거치더라도 원하는 대로 커서의 위치를 변경하는 것이
  // 가능합니다.
  while (current < input.length) {

    // 먼저 `current` 위치에 존재하는 문자를 `input`에 저장합니다.
    let char = input[current];

    // 가장 먼저 열린 소괄호를 확인하려고 합니다. 이 부분은 나중에
    // `CallExpression`로 다뤄질 부분인데 일단 지금은 문자만 신경쓰도록
    // 합니다.
    //
    // 열린 소괄호가 있나요?
    if (char === '(') {

      // 있다면 `paren` 타입의 새 토큰을 만들어서 집어넣습니다.
      // 값으로 열린 소괄호를 넣습니다.
      tokens.push({
        type: 'paren',
        value: '(',
      });

      // 한 글자를 확인했으니 `current`를 증가해서 커서를 옮깁니다.
      current++;

      // 반복문을 다음 사이클로 넘어가기 위해 `continue`를 사용합니다. 
      continue;
    }

    // 다음으로 확인할 문자는 닫힌 소괄호입니다. 앞서 수행한 방식과 동일하게
    // 닫힌 소괄호를 확인하고, 새로운 토큰을 만들고, `current`를 옮기고,
    // `continue`로 넘어갑니다.
    if (char === ')') {
      tokens.push({
        type: 'paren',
        value: ')',
      });
      current++;
      continue;
    }

    // 다음 차례로 넘어갑니다. 이제 공백을 확인하려고 합니다. 이 과정이 조금
    // 흥미롭게 보일 수 있습니다. 문자 사이에 공백이 있는지 없는지는 중요하긴
    // 하지만 토큰으로 저장할 만큼 중요하진 않다는 부분인데요. 토큰으로 만들어도
    // 나중에 그 토큰을 버리는 일이나 하게 되기 때문에 그렇습니다.
    //
    // 그러니까 여기서는 단순히 공백이 존재하는지 확인만 합니다. 존재한다면
    // 커서를 다음 문자로 옮기고 반복문을 다음 사이클로 넘깁니다.
    let WHITESPACE = /\s/;
    if (WHITESPACE.test(char)) {
      current++;
      continue;
    }

    // 다음 토큰 타입은 숫자입니다. 여기서는 앞서 본 방식과는 조금 다르게 처리
    // 하게 되는데요. 그 이유는 한 글자만 확인해서 숫자라면 그게 한 자리 숫자인지
    // 여러 자리 숫자인지 확인해서 일련의 숫자를 모두 하나의 토큰에 저장해야
    // 하기 때문입니다.
    //
    //   (add 123 456)
    //        ^^^ ^^^
    //        즉, 여기가 숫자 토큰 두 개로 처리가 되어야 합니다
    //
    // 먼저 숫자가 존재하는지 확인부터 합니다.
    let NUMBERS = /[0-9]/;
    if (NUMBERS.test(char)) {

      // `value` 변수를 만들어서 문자가 숫자라면 여기에 쌓도록 문자열로
      // 지정했습니다.
      let value = '';

      // 그런 후에 작은 반복문으로 그 이후에 나오는 문자를 하나씩 확인해서
      // 숫자가 아닌 글자가 나올 때까지 확인합니다. 확인 할 때마다 숫자가 나오면
      // 그 숫자는 `value` 변수에 붙여서 저장하고 `current`를 증가하며 다음
      // 문자를 검사하게 됩니다. 숫자가 아니라면 이 작은 반복문은 종료됩니다.
      while (NUMBERS.test(char)) {
        value += char;
        char = input[++current];
      }

      // 이 과정이 끝나면 `number` 토큰을 숫자와 함께 `tokens` 배열에 저장합니다.
      tokens.push({ type: 'number', value });

      // 그리고 반복문을 다음 사이클로 넘깁니다.
      continue;
    }

    // 이 언어의 문자열 처리를 위해서 쌍따옴표(")로 감싼 문자열을 검사합니다.
    //
    //   (concat "foo" "bar")
    //            ^^^   ^^^ 문자열 토큰 둘
    //
    // 먼저 열린 따옴표를 확인합니다.
    if (char === '"') {
      // 문자열 토큰을 만들기 위해 `value` 변수를 준비합니다.
      let value = '';

      // 먼저 열린 쌍따옴표를 건너 뜁니다.
      char = input[++current];

      // 그리고 각 문자를 다음 쌍따옴표가 나올 때까지 `value`에 저장하며
      // 커서를 계속 옮깁니다. 쌍따옴표가 나오면 멈춥니다.
      while (char !== '"') {
        value += char;
        char = input[++current];
      }

      // 닫는 쌍따옴표도 건너 뜁니다.
      char = input[++current];

      // 이제 `string` 토큰을 만들어서 `tokens` 배열에 저장합니다.
      tokens.push({ type: 'string', value });

      continue;
    }

    // 마지막 토큰 타입은 `name` 토큰입니다. 숫자 대신 이 일련의 문자는
    // lisp 문법에서 함수의 이름을 의미하게 됩니다.
    //
    //   (add 2 4)
    //    ^^^
    //    이름 토큰
    //
    let LETTERS = /[a-z]/i;
    if (LETTERS.test(char)) {
      let value = '';

      // 앞서 방법과 동일하게 반복문을 사용해서 `value` 값을 만듭니다.
      while (LETTERS.test(char)) {
        value += char;
        char = input[++current];
      }

      // 그리고 값을 `name` 타입 토큰으로 저장하고 반복문을 돌립니다.
      tokens.push({ type: 'name', value });

      continue;
    }

    // 최종적으로 앞에서 확인하지 못한 문자는 오류를 내고 거기서 종료해버립니다.
    throw new TypeError('I dont know what this character is: ' + char);
  }

  // 토큰 배열을 반환하며 `tokenizer`를 끝냅니다.
  return tokens;
}

파서 (Parser)

파서에서는 토큰이 담긴 배열을 AST로 변환하려고 합니다.

[{ type: 'paren', value: '(' }, ...]  =>  { type: 'Program', body: [...] }

코드를 작성해봅시다.

'use strict';
// 먼저 `tokens` 배열을 받는 `parser` 함수를 정의합니다.
function parser(tokens) {

  // 앞서 방법처럼 `current` 변수에 현재 위치를 저장할 겁니다.
  let current = 0;

  // 하지만 이번에는 `while` 반복문 대신에 재귀를 사용하려고 합니다. 그래서
  // `walk` 함수를 정의합니다.
  function walk() {

    // 이 함수에서 `current` 위치에 있는 토큰을 가져오는 것으로 작업을
    // 시작합니다.
    let token = tokens[current];

    // 각각의 토큰을 다른 코드 경로로 분리하려고 합니다. 먼저 `number`
    // 토큰부터 시작합니다.
    //
    // 먼저 `number` 토큰인지 검사부터 합니다.
    if (token.type === 'number') {

      // 숫자 토큰이면 `current`를 증가해서 다음 토큰으로 커서를 옮깁니다.
      current++;

      // 그리고 새 AST 노드인 `NumberLiteral`을 반환하면서 토큰에 담긴 값을
      // 이 노드에 저장합니다.
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }

    // 문자열 토큰이 있다면 위에서 숫자 토큰을 처리했던 방식처럼
    // `StringLiteral` 노드를 만들어서 토큰의 값을 저장합니다.
    if (token.type === 'string') {
      current++;

      return {
        type: 'StringLiteral',
        value: token.value,
      };
    }

    // 다음은 CallExpessions를 확인할 차례입니다. 먼저 열린 소괄호를 확인
    // 하는 것으로 시작합니다.
    if (
      token.type === 'paren' &&
      token.value === '('
    ) {
      
      // AST에서는 괄호가 의미 없으므로 `current`를 증가해서 다음 토큰으로
      // 넘어갑니다.
      token = tokens[++current];

      // 이제 `CallExpression`이라는 기반 노드를 생성합니다. 그리고 현재 토큰
      // 값으로 이름을 지정합니다. 열린 괄호 뒤에 오는 이름이 바로 호출하려는
      // 함수의 이름이기 때문입니다. (예를 들어 `(add 2 3)`을 보면 `(` 뒤에
      // 바로 함수 이름이 나오는 걸 볼 수 있습니다.)
      let node = {
        type: 'CallExpression',
        name: token.value,
        params: [],
      };

      // 이제 이름 토큰 다음 토큰을 얻기 위해 `current`를 한번 더 옮깁니다.
      token = tokens[++current];

      // 이제는 닫힌 소괄호가 나올 때까지 각 토큰을 반복적으로 검사해서
      // `CallExpression`에 있는 `params`에 계속 넣으려고 합니다.
      //
      // 여기서부터 코드는 재귀로 동작합니다. 중첩된 노드를 직접 무한대로 열어서
      // 처리하는 것 대신에 재귀로 문제를 해결할 수 있습니다.
      //
      // 이 방식을 설명하기 위해 Lisp 코드를 다시 봅니다. 이제 `add` 함수를
      // 보면 하나의 숫자와 숫자가 포함된 `CallExpression`이 중첩되어 있는
      // 것을 확인할 수 있습니다.
      //
      //   (add 2 (subtract 4 2))
      //
      // 이 코드로 생성한 토큰을 보면 닫힌 소괄호가 여러 차례 나타난다는 점을
      // 확인할 수 있습니다.
      //
      //   [
      //     { type: 'paren',  value: '('        },
      //     { type: 'name',   value: 'add'      },
      //     { type: 'number', value: '2'        },
      //     { type: 'paren',  value: '('        },
      //     { type: 'name',   value: 'subtract' },
      //     { type: 'number', value: '4'        },
      //     { type: 'number', value: '2'        },
      //     { type: 'paren',  value: ')'        }, <<< 닫힌 소괄호
      //     { type: 'paren',  value: ')'        }, <<< 닫힌 소괄호
      //   ]
      //
      // `walk` 함수를 중첩해서 호출하는 방식으로 `current` 변수를 계속
      // 증가시키는데 이 방법으로 중첩된 `CallExpression`을 처리합니다.
      
      // 그런 이유로 `while` 반복문을 사용해서 계속 `walk` 함수를 호출하는데
      // `type`이 `'paren'`이고 `value`에 닫힌 소괄호가 나올 때까지만
      // 반복합니다.
      while (
        (token.type !== 'paren') ||
        (token.type === 'paren' && token.value !== ')')
      ) {
        // `walk` 함수를 호출해서 반환되는 `node`를 `node.params` 배열에
        // 추가합니다.
        node.params.push(walk());
        token = tokens[current];
      }

      // 최종적으로 `current`를 한 번 옮기는 것으로 닫는 소괄호를 건너 뜁니다.
      current++;

      // 그리고 노드를 반환합니다.
      return node;
    }

    // 만약 인식할 수 없는 토큰을 만나면 오류로 처리합니다.
    throw new TypeError(token.type);
  }

  // 이제 AST를 만드려고 합니다. 이 AST의 뿌리로 볼 수 있는 `Program`노드를
  // 다음처럼 작성합니다.
  let ast = {
    type: 'Program',
    body: [],
  };

  // 이제 `walk` 함수를 호출합니다. 호출해서 생성한 노드를 `ast.body`
  // 배열에 저장합니다.
  //
  // 여기서 반복문으로 이 호출을 수행하는 이유는 `CallExpression`이 중첩되지
  // 않고 다음처럼 나란히 존재할 경우도 있기 때문입니다.
  //
  //   (add 2 2)
  //   (subtract 4 2)
  //
  while (current < tokens.length) {
    ast.body.push(walk());
  }

  // 최종적으로 생성한 AST를 반환합니다.
  return ast;
}

트래버서 (Traverser, 순회자)

AST까지 만들었으니 방문자가 각 노드를 방문하는 작업을 해야 합니다. 매 노드를 방문하면서 노드의 타입과 일치하는 방문자의 메소드를 호출하는 코드를 작성해야 합니다.

traverse(ast, {
  Program: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  },

  CallExpression: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  },

  NumberLiteral: {
    enter(node, parent) {
      // ...
    },
    exit(node, parent) {
      // ...
    },
  },
});

이제 코드로 적어봅시다.

// 이제 AST와 방문자를 전달할 수 있는 순회 함수를 작성합니다.
// 내부에서는 두 함수를 정의합니다.
function traverser(ast, visitor) {

  // `traverseArray` 함수는 배열을 대상으로 `traverseNode` 함수를
  // 반복해서 실행합니다. 이 함수는 아래서 정의합니다.
  function traverseArray(array, parent) {
    array.forEach(child => {
      traverseNode(child, parent);
    });
  }

  // `traverseNode`는 `node`와 부모 노드인 `parent` 노드를 받습니다.
  // 그래서 이 둘을 방문자 메소드에 전달하게 됩니다.
  function traverseNode(node, parent) {

    // 방문자에 노드의 `type`과 일치하는 메소드가 있는지 확인합니다. 
    let methods = visitor[node.type];

    // 만약 그 메소드에 입장 할 때 실행할 내용이 있다면 `enter` 메소드를
    // `node`와 `parent`를 사용해서 실행합니다.
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }

    // 노드 타입에 따라 다른 방식으로 처리합니다. 
    switch (node.type) {

      // 최상위 레벨인 `Program`으로 시작합니다. 프로그램 노드는 body라는
      // 속성에 노드 배열을 보관하고 있습니다. 이 배열을 순회하며 확인하기
      // 위해 `traverseArray`를 호출합니다.
      //
      // (`traverseArray`는 `traveseNode`를 호출하니까 트리 전체를
      // 재귀적으로 순회하게 됩니다.)
      case 'Program':
        traverseArray(node.body, node);
        break;

      // 다음으로 `CallExpression`을 만나면 `params` 배열을 순회하도록
      // 코드를 작성합니다.
      case 'CallExpression':
        traverseArray(node.params, node);
        break;

      // `NumberLiteral`과 `StringLiteral`를 만나면 순회해서 확인할 자식
      // 노드가 없기 때문에 별도 처리 없이 끝냅니다.
      case 'NumberLiteral':
      case 'StringLiteral':
        break;

      // 알 수 없는 노드 타입을 만나면 오류로 처리합니다.
      default:
        throw new TypeError(node.type);
    }

    // 만약 해당 노드 타입에 `exit` 메소드, 즉 퇴장 메소드가 정의되어 있다면
    // 해당 메소드를 `node`, `parent`와 함께 호출합니다.
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }

  // 이제 모든 함수가 준비되었습니다. AST와 null을 `traverseNode` 함수에 넣어
  // 실행합니다. 왜 parent 자리가 null일까요? AST에서 가장 위에 있다면 이미 더
  // 이상 위로 올라갈 곳이 없기 때문입니다.
  traverseNode(ast, null);
}

트랜스포머 (transformer, 변환자)

다음은 트랜스포머입니다. 생성한 AST를 방문자와 함께 순회 함수로 호출하면 새로운 AST를 생성하게 됩니다.

----------------------------------------------------------------------------
  원본 AST                         |   변환된 AST
----------------------------------------------------------------------------
  {                                |   {
    type: 'Program',               |     type: 'Program',
    body: [{                       |     body: [{
      type: 'CallExpression',      |       type: 'ExpressionStatement',
      name: 'add',                 |       expression: {
      params: [{                   |         type: 'CallExpression',
        type: 'NumberLiteral',     |         callee: {
        value: '2'                 |           type: 'Identifier',
      }, {                         |           name: 'add'
        type: 'CallExpression',    |         },
        name: 'subtract',          |         arguments: [{
        params: [{                 |           type: 'NumberLiteral',
          type: 'NumberLiteral',   |           value: '2'
          value: '4'               |         }, {
        }, {                       |           type: 'CallExpression',
          type: 'NumberLiteral',   |           callee: {
          value: '2'               |             type: 'Identifier',
        }]                         |             name: 'subtract'
      }]                           |           },
    }]                             |           arguments: [{
  }                                |             type: 'NumberLiteral',
                                   |             value: '4'
---------------------------------- |           }, {
                                   |             type: 'NumberLiteral',
                                   |             value: '2'
                                   |           }]
 (미안하지만 변환된 쪽이 더 길어요..)      |         }
                                   |       }
                                   |     }]
                                   |   }
----------------------------------------------------------------------------

이제 AST를 받는 변환 함수를 작성합니다.

function transformer(ast) {

  // 먼저 `newAst`를 생성하는데 이전 AST와 같이 프로그램 노드로 시작합니다.
  let newAst = {
    type: 'Program',
    body: [],
  };

  // 여기서는 약간 변칙적인 방법을 사용하려고 하는데요. 여기서 `context`라는
  // 속성을 부모 노드에 만들고 새로운 노드를 여기에 추가하려고 합니다.
  // 일반적으로는 이 방법보다 더 나은 추상화가 필요하지만 지금 컴파일러를
  // 작성하는 목적에 맞게 최대한 단순하게 만들고 있습니다.
  //
  // 단순하게 이전 AST에서 새 AST를 참조하는 역할을 한다고 생각하면 됩니다.
  //
  ast._context = newAst.body;

  // AST와 방문자를 순회 함수에 넣어 호출하는 작업으로 시작합니다.
  traverser(ast, {

    // 첫 방문자 메소드는 `NumberLiteral`을 처리합니다.
    NumberLiteral: {
      // 입장할 때 호출하는 메소드입니다.
      enter(node, parent) {
        // `NumberLiteral` 이름으로 새 노드를 만들어 부모 컨텍스트에 추가합니다.
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },

    // 다음으로 `StringLiteral`을 처리합니다.
    StringLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },

    // 이제 `CallExpression`을 처리합니다.
    CallExpression: {
      enter(node, parent) {

        // 중첩된 `Identifier`와 함께 `CallExpression` 노드를 생성합니다.
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };

        // 다음으로 기존 `CallExpression` 노드에 새 `context`를 정의해서
        // `expression`의 인자를 참조하는데 사용합니다. 이제 여기에
        // 새 인자를 집어넣을 수 있습니다.
        node._context = expression.arguments;

        // 이제 부모 노드가 `CallExpression`인지 아닌지 확인합니다.
        // 아니라면...
        if (parent.type !== 'CallExpression') {

          // `CallExpression` 노드를 `ExpressionStatement`라는 노드로
          // 감쌉니다. 이렇게 처리하는 이유는 자바스크립트에서 최상위
          // `CallExpression`은 실제로 명령문으로 다뤄지기 때문입니다.
          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          };
        }

        // 끝으로 (아마도 감싸져 있는) `CallExpression`을 부모 노드의
        // `context`에 넣으며 끝냅니다.
        parent._context.push(expression);
      },
    }
  });

  // 마지막으로 이 변환 함수에서 방금 새로 만든 AST를 반환합니다.
  return newAst;
}

코드 제너레이터 (Code generator, 코드 생성기)

이제 마지막 단계인 코드 생성기를 살펴봅니다.

이 코드 생성기는 함수 스스로를 재귀적으로 호출해서 트리에 있는 각 노드를 하나의 긴 문자열로 출력하게 됩니다.

function codeGenerator(node) {

  // 이제 각 `node`의 `type`으로 구분해 동작합니다.
  switch (node.type) {

    // `Program` 노드를 만났습니다. `body`에 있는 각 노드에 코드 생성 함수를
    // 맵핑해서 구동합니다. 그리고 각각의 결과를 개행 문자로 합칩니다.
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');

    // `ExpressionStatement`를 만나면 중첩된 노드를 대상으로 코드 생성
    // 함수를 실행합니다. 그 결과에 세미콜론을 더해서 반환합니다.
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';' // << (...왜냐하면 코드가 제대로 동작되려면 필요하니까요.)
      );

    // `CallExpression`에서는 `callee`를 출력하고 열린 괄호를 추가합니다.
    // 그리고 노드의 `arguments` 배열에 코드 생성 함수를 맵핑합니다.
    // 그렇게 생성한 각각의 결과를 쉼표로 합친 후에 닫힌 괄호를 더해 반환
    // 합니다.
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );

    // `Identifier`를 만나면 `node`의 이름을 반환합니다.
    case 'Identifier':
      return node.name;

    // `NumberLiteral`을 만나면 `node`의 값을 반환합니다.
    case 'NumberLiteral':
      return node.value;

    // `StringLiteral`을 만나면 `node`의 값을 쌍따옴표로 감싸서 반환합니다.
    case 'StringLiteral':
      return '"' + node.value + '"';

    // 만약 인식하지 못하는 노드라면 오류를 냅니다.
    default:
      throw new TypeError(node.type);
  }
}

컴파일러 (compiler)

드디어 끝났습니다! 이제 compiler 함수를 만듭니다. 지금까지 만든, 모든 함수를 하나의 함수로 묶습니다.

  1. 입력 => 토크나이저 => 토큰 묶음
  2. 토큰 묶음 => 파서 => 추상 구문 트리(AST)
  3. AST => 트랜스포머 => 새 AST
  4. 새 AST => 코드 생성기 => 출력

함수와 인자명으론 다음처럼 정리할 수 있습니다.

1. input  => tokenizer   => tokens
2. tokens => parser      => ast
3. ast    => transformer => newAst
4. newAst => generator   => output

이제 함수로 작성해볼까요?

function compiler(input) {
  let tokens = tokenizer(input);
  let ast    = parser(tokens);
  let newAst = transformer(ast);
  let output = codeGenerator(newAst);

  // 그리고 결과물을 반환합니다!
  return output;
}

모두 완성되었습니다! (테스트 코드도 확인해보세요.)

이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to react context?를 번역한 글입니다.

2018년 초, 리액트 팀은 처음으로 공식 컨텍스트(context) API를 소개했습니다. 이 새 API에 대해서 글을 쓰기도 했고 많은 사람들이 이 기능에 꽤나 흥분했습니다.

이 API에 대한 가장 흔한 푸념 중 하나는 컨텍스트를 실질적으로 사용하려면 렌더링 프로퍼티 API를 사용해야 한다는 점이었습니다. 여러 컨텍스트를 사용하려고 하면 결국 (로직 재활용을 위해) 여러 렌더링 프로퍼티 API를 사용하게 되고 결과적으로 많은 중첩(nesting)이 발생하게 됩니다. 그래서 여러 렌더링 프로퍼티 기반 API를 조합해서 하나의 함수 컴포넌트를 만들어 컨텍스트를 소비하는 방식을 이전 블로그 포스트에서 제시했습니다.

const ThemeContext = React.createContext('light')
class ThemeProvider extends React.Component {
  /* code */
}
const ThemeConsumer = ThemeContext.Consumer
const LanguageContext = React.createContext('en')
class LanguageProvider extends React.Component {
  /* code */
}
const LanguageConsumer = LanguageContext.Consumer

function AppProviders({children}) {
  return (
    <LanguageProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </LanguageProvider>
  )
}

function ThemeAndLanguageConsumer({children}) {
  return (
    <LanguageConsumer>
      {language => (
        <ThemeConsumer>{theme => children({language, theme})}</ThemeConsumer>
      )}
    </LanguageConsumer>
  )
}

function App() {
  return (
    <AppProviders>
      <ThemeAndLanguageConsumer>
        {({theme, language}) => (
          <div>
            {theme} and {language}
          </div>
        )}
      </ThemeAndLanguageConsumer>
    </AppProviders>
  )
}

리액트 컴포넌트의 강력한 합성 덕분에 위 예시처럼 문제를 해결할 수 있습니다. 이런 해법이 썩 맘에 들지 않습니다. 게다가 저만 그렇게 생각한 것이 아닙니다.

클래스 컴포넌트에서는 새 렌더링 프로퍼티 API를 적용하기 어렵다는 피드백을 받았습니다. 그래서 클래스 컴포넌트에서도 쉽게 컨텍스트 값을 사용할 수 있도록 새로운 편의 API를 추가했습니다. - React v16.6.0: lazy, memo와 contextType

이 새로운 편의 API는 클래스 컴포넌트에서 단 하나의 컨텍스트만 소비한다면 contextType이라는 정적 프로퍼티를 정의하는 것으로 사용하고 싶은 컨텍스트를 할당 받아 this.context로 접근할 수 있도록 기능을 제공합니다. 단일 컨텍스트만 필요로 한다면 일반적인 상황에서 꽤 깔끔하고 멋진 방법입니다.

이 편의 API는 정말 만족스럽습니다. 하지만 저는 리액트 컨텍스트의 미래에 영향을 줄 리액트 훅이 더 기대됩니다. 위에서 봤던 코드를 useContext 훅을 사용해서 다시 작성해보겠습니다.

const ThemeContext = React.createContext('light')
class ThemeProvider extends React.Component {
  /* code */
}
const LanguageContext = React.createContext('en')
class LanguageProvider extends React.Component {
  /* code */
}

function AppProviders({children}) {
  return (
    <LanguageProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </LanguageProvider>
  )
}

function App() {
  const theme = useContext(ThemeContext)
  const language = useContext(LanguageContext)
  return (
    <div>
      {theme} and {language}
    </div>
  )
}

ReactDOM.render(
  <AppProviders>
    <App />
  </AppProviders>,
  document.getElementById('root'),
)

멋지지 않습니까? 렌더링 프로퍼티 기반으로 만든 코드만큼 강력하면서도 훨씬 쉽게 읽고, 이해할 수 있고, 리팩토링하고 관리하기 좋은 구현입니다. 단순히 적은 코드가 아닙니다. 간혹 코드 양을 줄이는 과정에서 코드가 주는 명료성을 해쳐 의사소통에 문제를 만들 때도 있습니다. 하지만 이 경우에는 적은 코드이면서도 동시에 쉽게 이해할 수 있습니다. 이 컨텍스트 API는 강력하며 동시에 새 훅 API에 큰 기능을 제공합니다.

리액트 훅의 또 다른 큰 기능은 완전히 선택적인 기능이며 하위호환성이 보장됩니다. 아마 페이스북은 세계에서 가장 크고 오래된 리액트 코드베이스를 운용하고 있으니 엔지니어에게 고통을 주는 결정을 할 수는 없었을 겁니다. 리액트가 점진적으로 새로운 훅의 세계로 이끌고 있다는 점이 너무나도 멋집니다. 리액트 팀에게 감사합니다. 공식 릴리즈가 기대되네요. (역자 주: useContext는 현재 16.8에 릴리즈되었습니다.)

결론

리액트의 멋진 점은 세세한 구현에 개의치 않고 실제 세계의 문제를 해결하는데 집중할 수 있도록 한다는 점입니다. 크로스 브라우징이나 성능 문제를 오랜 기간동안 겪었습니다. 이제는 리액트를 통해 더 나아가 문제를 단순화했습니다. 이제 간단히 읽고 이해할 수 있는 코드를 작성하고 리팩토링하며 유지관리만 하면 됩니다. 저도 제 코드를 통해서 다른 이들의 작업을 단순하게 할 수 있을 지 궁금하네요 🤔.

이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to render props?를 번역한 글입니다.

작년에 "프로퍼티 게터(prop getters)를 사용해서 사용자에게 컴포넌트 렌더링 제어를 넘겨주는 방법"이란 포스트를 작성했습니다. 이 글에서 (당시) react-toggled의 전체 구현을 보여줬는데 downshift에서 사용했던 패턴을 가르치기 위해서 작성했던 라이브러리입니다. 제가 downshift에서 훨씬 간단하고 단순한 컴포넌트를 동일한 패턴으로 구현해서 사용했기 때문에 프로퍼티 게터 패턴을 가르치기 아주 좋은 방법이었습니다.

react-toggled와 downshift는 둘 다 렌더링 프로퍼티 패턴을 사용해서 리액트 컴포넌트 간의 로직 코드를 공유했습니다. 다른 포스트인 "렌더링 프로퍼티를 사용하지 말아야 하는 경우"에서 렌더링 프로퍼티 패턴을 적용하기 좋은 경우를 확인했습니다. 하지만 리액트 훅을 적용하기 좋은 경우도 동일합니다. 리액트 훅은 클래스 컴포넌트와 렌더링 프로퍼티 조합보다 훨씬 간단합니다.

리액트 훅이 안정 버전에서 제공되기 시작하면 더이상 렌더링 프로퍼티를 쓸 필요가 없다는 의미인가요? 아닙니다! 여전히 렌더링 프로퍼티 패턴이 아주 유용하게 쓰일 수 있는 부분이 있으며 그 부분을 곧 살펴보도록 하겠습니다. 하지만 현재의 react-toggled를 훅 기반으로 구현한 코드와 비교해서 왜 훅이 더 간단하다고 주장하는지 비교해보도록 하겠습니다.

코드가 궁금하다면 현재의 react-toggled를 확인해보기 바랍니다.

가장 일반적인 react-toggled 사용 방법입니다.

function App() {
  return (
    <Toggle>
      {({on, toggle}) => <button onClick={toggle}>{on ? 'on' : 'off'}</button>}
    </Toggle>
  )
}

단순히 간단한 토글 기능이 필요한 경우라면 훅으로 작성한 코드는 다음과 같습니다.

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  return {on, toggle}
}

이제 다음과 같은 방식으로 사람들이 사용할 수 있게 됩니다.

function App() {
  const {on, toggle} = useToggle()
  return <button onClick={toggle}>{on ? 'on' : 'off'}</button>
}

훨씬 간단하고 멋집니다. 하지만 react-toggled의 Toggle 컴포넌트는 이보다 더 많은 기능을 제공합니다. 하나를 예로 들자면 getTogglerProps라는 헬퍼(helper)를 제공합니다. 이 함수는 토글하는 엘리먼트에 올바른 프로퍼티를 전달하기 위해 사용합니다. (접근성을 위한 aria 속성도 포함합니다.) 이 기능도 포함해봅니다.

// 주어진 함수 목록을 호출하는 함수를 반환
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args))

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  const getTogglerProps = (props = {}) => ({
    'aria-expanded': on,
    tabIndex: 0,
    ...props,
    onClick: callAll(props.onClick, toggle),
  })
  return {
    on,
    toggle,
    getTogglerProps,
  }
}

이제 useToggle 훅에서 getTogglerProps 헬퍼도 사용할 수 있습니다.

function App() {
  const {on, getTogglerProps} = useToggle()
  return <button {...getTogglerProps()}>{on ? 'on' : 'off'}</button>
}

기존 방식보다 더 접근하기 좋고 편리합니다. 만약 getTogglerProps가 필요 없는 경우라면 어떨까요? 다음처럼 코드를 조금 더 나눌 수 있습니다.

// 주어진 함수 목록을 호출하는 함수를 반환
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args))

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  return {on, toggle}
}

function useToggleWithPropGetter(initialOn) {
  const {on, toggle} = useToggle(initialOn)
  const getTogglerProps = (props = {}) => ({
    'aria-expanded': on,
    tabIndex: 0,
    ...props,
    onClick: callAll(props.onClick, toggle),
  })
  return {on, toggle, getTogglerProps}
}

이제 react-toggled가 지원하는 getInputTogglerPropsgetElementTogglerProps도 같은 방법으로 구현할 수 있습니다. 이 접근 방식이라면 앱에서 사용하지 않는 추가적인 유틸리티를 쉽게 떨어낼 수 있고 어떤 면에서는 렌더링 프로퍼티 해결책이 오히려 사용하기 조잡하다고 느껴질 수도 있겠습니다. (불가능한 것은 아니지만 그냥 좀 깔끔하지 않습니다.)

하지만! 앱에 있는 모든 렌더링 프로퍼티 API를 새 훅 API로 모두 리팩토링하고 싶지 않아요! 라고 말할 수 있습니다.

두려워하지 마세요! 다음 코드를 보기 바랍니다.

const Toggle = ({children, ...props}) => children(useToggle(props))

여기 렌더링 프로퍼티 컴포넌트가 있습니다. 그냥 간단하게 예전 코드를 사용하면서도 시간이 흐른 후에 고쳐도 됩니다. 사실, 이 방식이 제가 커스텀 훅을 만든 후에 테스트할 때 추천하는 방법입니다.

여기에는 좀 더 내용이 있습니다. (마치 렌더링 프로퍼티 패턴에서 리액트 훅으로 바꾼 방식과 비슷합니다.) 더 설명하지 않고 이 글을 읽고 있는 당신이 발견하도록 조금 남겨두려고 합니다. 무엇인지 조금 더 생각해본 후에 제가 작성한 과정을 확인해보세요. 훅을 사용하면서 테스팅하는데 약간 달라지는 부분을 확인해보기 바랍니다. (자바스크립트 클로저 만세!)

렌더링 프로퍼티 패턴을 적용할 만한 경우

자, 이제 컴포넌트에 훅을 사용해서 리팩토링을 하면서도 여전히 렌더링 프로퍼티 기반 API로 리액트 컴포넌트를 공유할 수 있습니다. (관심 있다면 하이드라 패턴을 사용할 수도 있습니다.) 하지만 미래를 상상해봅시다. 렌더링 프로퍼티를 더이상 로직 재사용에 사용하지 않고 모두가 훅을 사용하고 있는 시대 말이죠. 그렇다면 계속 렌더링 프로퍼티 API를 작성하거나 사용해야 하는 이유가 있을까요?

그렇습니다! 보세요! 여기에 downshift에서 react-virtualized와 함께 사용한 예시입니다. 연관된 부분을 아래서 확인해보세요.

<List
  // ... some props
  rowRenderer={({key, index, style}) => (
    <div
    // ... some props
    />
  )}
/>

여기 rowRenderer 프로퍼티를 확인해보세요. 이 코드가 무엇인지 아세요? 바로 렌더링 프로퍼티입니다. 뭐라고요?! 🙀 바로 제어 역전을 실현한 렌더링 프로퍼티가 여기에 있습니다. 이 프로퍼티는 react-virtualized에서 목록에서 각 행을 렌더링하는 데 있어서 제어를 사용자에게 넘겨주기 위해 존재하는 코드입니다. 만약 react-virtualized를 훅으로 다시 작성한다고 하면 아마도 rowRendereruseVirtualized 훅의 인자로 받을 수 있을 겁니다. 하지만 이런 경우라면 렌더링 프로퍼티 패턴보다 이득이 있다고 말하기는 조금 어렵습니다. 그러니 이런 사용 예시에서는 이 패턴(과 이 방식의 제어 역전)을 사용하는 방법이 더 적합하다고 봅니다.

결론

이 내용이 흥미 있고 도움이 되었기를 바랍니다. 리액트 훅은 아직 알파 단계이며 변경 예정에 있습니다. (역자 주: 현재 16.8.0에 포함되어 배포되었습니다.) 이 기능은 철저히 선택 기능이며 리액트의 다른 API에 문제를 만들지 않습니다. 제가 보기엔 정말 좋은 기능이라고 생각합니다. 앱을 다시 작성하지 마세요! 대신 리팩토링을 하기 바랍니다. (훅이 안정 단계에 들어오면 말입니다.)

이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to my tests?를 번역한 글입니다.

새로 나오는 리액트의 훅(hook) 기능에 대해 가장 많이 받는 질문 중 하나가 바로 테스팅입니다. 만약 지금 테스트가 다음과 같은 모양이라면 그 걱정을 충분히 이해할 수 있습니다.

// 이전 블로그 포스트에서 빌린 예제
// https://kcd.im/implementation-details
test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)
  expect(wrapper.state('openIndex')).toBe(0)
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndex')).toBe(1)
})

이 enzyme 테스트는 Accordion이 클래스 컴포넌트며 instance가 존재하는 경우에만 동작합니다. 하지만 함수 컴포넌트라면 "인스턴스"라는 개념이 존재하지 않습니다. 그러므로 상태와 생명주기가 있는 클래스 컴포넌트에서 훅을 사용한 컴포넌트로 리팩토링했을 때에는 이 테스트의 .instance().state()가 동작하지 않을 겁니다.

이제 Accordion 컴포넌트를 함수 컴포넌트로 리팩토링 한다면 이 테스트는 고장 나게 됩니다. 그럼 테스트를 던지거나 다시 작성하지 않고 훅을 사용해서 코드를 리팩토링할 방법은 없을까요? 위 테스트처럼 컴포넌트의 인스턴스를 참조하는 enzyme API의 사용을 피하는 일에서부터 시작할 수 있습니다. 이 내용은 제 "구현 상세(implementation details)" 포스트에서 확인할 수 있습니다.

이제 클래스 컴포넌트의 간단한 예제를 확인해봅시다. 제가 가장 좋아하는 <Counter /> 컴포넌트 예제는 다음과 같습니다.

// counter.js
import React from 'react'

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

export default Counter

이제 이 컴포넌트를 테스트하는데 어떤 방식으로 작성하면 훅이든 클래스 컴포넌트든 상관없이 테스트할 수 있을까요?

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent} from 'react-testing-library'
import Counter from '../counter.js'

test('counter increments the count', () => {
  const {container} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})

이 테스트는 통과할 겁니다. 이제 동일한 컴포넌트를 훅으로 작성해보겠습니다.

// counter.js
import React from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  const incrementCount = () => setCount(c => c + 1)
  return <button onClick={incrementCount}>{count}</button>
}

export default Counter

이 테스트는 구현 상세를 피해서 테스트하고 있기 때문에 훅으로 만든 테스트도 문제 없습니다. 정말 멋지지 않나요? :)

useEffectcomponentDidMount + componentDidUpdate + componentWillUnmount가 아닙니다.

useEffect 훅을 사용하기 전에 고려해야 하는 점이 있습니다. 이 훅은 조금 특별하고 다르고 멋지기 때문인데요. 클래스 컴포넌트에서 훅으로 리팩토링할 때에 componentDidMount, componentDidUpdate, componentWillUnmount를 하나 이상의 useEffect 콜백 함수로 바꾸게 될 겁니다. (컴포넌트의 수명주기에 포함된 컴포넌트의 관심사에 따라 다릅니다.) 하지만 이 코드는 엄밀히 말해서 리팩토링이 아닙니다. "리팩토링"이 무엇인지 빠르게 살펴보도록 하겠습니다.

코드를 리팩토링할 때는 사용자가 볼 수 있는 변화를 만들지 않고 내부 구현을 바꿔야 합니다. 다음은 위키피디아가 말하는 "코드 리팩토링"입니다.

코드 리팩토링은 존재하는 컴퓨터 코드를 구조를 조정하는 과정으로 외부 동작을 바꾸지 않고 팩토링 즉, 분해(decomposition)를 수행하는 일을 의미합니다.

이제 다음 코드에서 개념을 살펴봅니다.

const sum = (a, b) => a + b

이제 이 함수를 리팩토링해 보도록 하겠습니다.

const sum = (a, b) => b + a

앞서 함수와 동일하게 동작하지만 구현은 약간 다릅니다. 근본적으로 이 과정이 "리팩토링"입니다. 다음은 리팩토링이 아닌 경우입니다.

const sum = (...args) => args.reduce((s, n) => s + n, 0)

멋진 코드입니다. sum은 더 멋지게 동작하지만, 엄밀히 따지면 리팩토링이 아니라 개선입니다. 비교해봅시다.

호출 이전 결과 이후 결과
sum() NaN 0
sum(1) NaN 1
sum(1, 2) 3 3
sum(1, 2, 3) 3 6

왜 이 변경은 리팩토링이 아닌가요? 그 이유는 "외부 동작을 변경"했기 때문입니다. 새 코드는 이전 코드보다 바람직하지만 기존 동작을 바꿨습니다.

그러면 왜 이 내용이 useEffect를 사용하는 것과 어떤 관련이 있을까요? 앞서 본 카운터 클래스 컴포넌트에서 새로운 기능을 추가한 예제를 살펴보도록 하겠습니다.

class Counter extends React.Component {
  state = {
    count: Number(window.localStorage.getItem('count') || 0),
  }
  increment = () => this.setState(({count}) => ({count: count + 1}))
  componentDidMount() {
    window.localStorage.setItem('count', this.state.count)
  }
  componentDidUpdate(prevProps, prevState) {
    if (prevState.count !== this.state.count) {
      window.localStorage.setItem('count', this.state.count)
    }
  }
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

이 코드에서는 count의 값을 componentDidMountcomponentDidUpdate를 사용해서 localStorage에 저장합니다. 구현 상세에 영향받지 않는 테스트는 다음과 같습니다.

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent, cleanup} from 'react-testing-library'
import Counter from '../counter.js'

afterEach(() => {
  window.localStorage.removeItem('count')
})

test('counter increments the count', () => {
  const {container} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})

test('reads and updates localStorage', () => {
  window.localStorage.setItem('count', 3)
  const {container, rerender} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('3')
  fireEvent.click(button)
  expect(button.textContent).toBe('4')
  expect(window.localStorage.getItem('count')).toBe('4')
})

테스트가 통과했습니다! 이제 이 컴포넌트를 "리팩토링"하는데 훅을 사용해보겠습니다.

import React, {useState, useEffect} from 'react'

function Counter() {
  const [count, setCount] = useState(() =>
    Number(window.localStorage.getItem('count') || 0),
  )
  const incrementCount = () => setCount(c => c + 1)
  useEffect(() => {
    window.localStorage.setItem('count', count)
  }, [count])
  return <button onClick={incrementCount}>{count}</button>
}

export default Counter

좋습니다. 사용자의 관점에서 보면 이 컴포넌트는 이전과 똑같이 동작할 겁니다. 하지만 이 변경은 이전의 동작과는 다릅니다. 여기서 useEffect 콜백은 이후에 구동하도록 예정 되었습니다. 이전 코드에서는 컴포넌트가 렌더링 된 후에 값을 localStorage에 동기적으로 저장했습니다. 새 코드에서는 렌더링 된 후에 호출되도록 변경했습니다. 왜 그럴까요? 리액트 훅 문서에 설명된 부분을 확인해봅니다.

componentDidMountcomponentDidUpdate와 다르게 useEffect로 예정한 효과는 브라우저가 화면을 업데이트하는 것을 막지 않습니다. 앱이 더 높은 응답성을 제공할 수 있도록 이렇게 동작합니다. 대부분의 효과는 동기적으로 구동될 필요가 없습니다. 동기적으로 동작하는 경우는 레이아웃을 측정해야 한다거나 하는 등 특수한 경우에 해당합니다. 이런 코드는 useLayoutEffect에 넣을 수 있으며 useEffect와 동일하게 동작하는 API입니다.

이제 useEffect를 사용해서 더 나은 성능을 얻을 수 있게 되었습니다! 멋지지 않나요! 향상된 컴포넌트를 작성한 것 뿐만 아니라 더 가볍게 구동되는 컴포넌트 코드를 작성했습니다.

하지만 이 과정은 리팩토링이 아닙니다. 실제로는 컴포넌트의 동작을 변경했기 때문입니다. 최종 사용자의 입장에서는 이 변화를 눈치채기 힘듭니다. 테스트를 작성할 때 구현 상세를 피하려고 노력했다면 이런 변화 또한 테스트에서 관측되지 않을겁니다.

다른 두 컴포넌트를 문제 없이 테스트가 가능한 이유는 react-dom/test-utils에서 제공하는 새 act 유틸리티 덕분입니다. react-testing-library는 이 유틸리티와 통합되어 있습니다. 그 덕분에 위 테스트가 문제 없이 통과할 수 있는 것입니다. 그래서 구현 상세에 의존하지 않는 테스트를 계속 작성할 수 있으며 소프트웨어를 기존에 개발하던 방식과 큰 차이 없이 개발을 계속 할 수 있게 되었습니다.

렌더링 프로퍼티 컴포넌트는 어떻게 테스트하죠?

이 부분이 제가 좋아하는 부분입니다. 프로퍼티를 렌더링하는 간단한 카운터 컴포넌트를 확인합니다.

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return this.props.children({
      count: this.state.count,
      increment: this.increment,
    })
  }
}
// 사용방법:
// <Counter>
//   {({ count, increment }) => <button onClick={increment}>{count}</button>}
// </Counter>
// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent} from 'react-testing-library'
import Counter from '../counter.js'

function renderCounter(props) {
  let utils
  const children = jest.fn(stateAndHelpers => {
    utils = stateAndHelpers
    return null
  })
  return {
    ...render(<Counter {...props}>{children}</Counter>),
    children,
    // 이 부분으로 컴포넌트 내부의 증감과 횟수에 접근할 수 있는 방법을 제공합니다
    ...utils,
  }
}

test('counter increments the count', () => {
  const {children, increment} = renderCounter()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 0}))
  increment()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 1}))
})

이제 이 컴포넌트를 훅을 사용해서 리팩토링 합니다.

function Counter(props) {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return props.children({
    count: count,
    increment,
  })
}

좋습니다. 앞서 작성한 테스트를 문제 없이 통과합니다. 하지만 리액트 훅: 렌더링 프로퍼티는 어떻게 되나요? (역자 주: 번역)에서 배운 것처럼 커스텀 훅(custom hook)은 리액트에서 코드 공유에 더 나은 자료 형식(primitive)입니다. 위 코드를 커스텀 훅으로 다시 작성해보도록 하겠습니다.

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return {count, increment}
}

export default useCounter

// 사용방법:
// function Counter() {
//   const {count, increment} = useCounter()
//   return <button onClick={increment}>{count}</button>
// }

멋집니다... 하지만 이제 useCounter는 어떻게 테스트 하나요? 새 useCounter 훅을 사용한다고 코드 전체를 갱신할 수는 없습니다. <Counter />라는 렌더링 프로퍼티 기반 컴포넌트가 수 백 군데에 위치하면 어떻게 하죠? 정말 최악일 겁니다.

제가 도와드리죠. 다음처럼 해결하세요.

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return {count, increment}
}

const Counter = ({children, ...props}) => children(useCounter(props))

export default Counter
export {useCounter}

이제 새로 작성한 <Counter /> 렌더링 프로퍼티 기반 컴포넌트는 기존에 사용하던 컴포넌트와 완전히 동일한 형태입니다. 이 방법이 진정한 의미의 리팩토링이죠. 또한 이제 누구든지 useCounter 커스텀 훅을 사용해서 코드를 개선하는 것도 가능하게 되었습니다.

더 대단한 점은 앞서 작성한 테스트가 여전히 통과한다는 점입니다. 멋지지 않습니까?

이제 모든 코드를 새로운 훅을 사용해서 갱신하고 나면 더이상 Counter 함수는 필요가 없어지겠죠? 물론 이 함수를 제거할 수도 있지만 그대로 __tests__ 폴더로 옮겨도 좋을 겁니다. 이 방법이 바로 커스텀 훅을 테스트하는 방법이기 때문입니다. 저는 커스텀 훅을 사용해서 렌더링 프로퍼티 기반 컴포넌트를 만드는 것을 선호하는데 실제로 렌더링 된 결과를 함수 호출 결과와 비교하는 데 유용하기 때문입니다.

흥미로운 기법이죠? 저의 새로운 egghead.io 코스에서도 확인할 수 있습니다.

그럼 훅 라이브러리는 어떻게 하죠?

일반적인 훅이나 오픈소스 훅을 작성한다면 특정 컴포넌트를 생각하지 않고서 테스트를 작성해야 합니다. 그런 경우에는 react-hooks-testing-library를 사용하길 권합니다.

결론

코드를 리팩토링하기 전에 할 수 있는 가장 좋은 일은 좋은 테스트 슈트(suite)와 타입 정의를 준비해서 우연히 무언가를 고장 내더라도 그 실수를 바로 알아차릴 수 있도록 하는 일입니다. 하지만 리팩토링을 할 때 테스트 슈트를 집어 던진다면 아무런 의미 없는 일이 되고 맙니다. 제가 드릴 수 있는 조언은 테스트 내에서 구현 상세를 피하라입니다. 지금 클래스 컴포넌트와도 동작하고 미래에 훅으로 만든 함수가 오더라도 동작하는 테스트를 작성하세요.

이 포스트는 Kent C. DoddsWhen to break up a component into multiple components를 번역한 글입니다.

리액트 애플리케이션을 작성할 때 하나의 리액트 컴포넌트로 작성해도 된다는 점을 알고 있나요? 하나의 거대한 컴포넌트 안에 애플리케이션 전체를 넣는다고 해도 기술적으로 불가능한 것은 전혀 아닙니다. 거대한 render 메소드와 엄청 많은 인스턴스 메소드, 수많은 상태가 있을 것이고 아마도 모든 생명주기 훅(lifecycle hook)을 사용해야 할 겁니다. (shouldComponentUpdatecomponentWillUnmount는 예외겠네요. 항상 상태가 갱신된 데다 컴포넌트가 언마운트 될 일이 없으니까요!)

이 방법을 사용하면 다음 문제를 마주하게 됩니다.

  1. 성능이 저하될 수 있습니다. 상태 변화가 일어날 때마다 애플리케이션 전체를 새로 그리게 됩니다.
  2. 코드 공유와 재사용성이... 쉽지 않을 겁니다.
  3. 상태 관리도 어렵습니다. 어느 상태와 이벤트 핸들러가 어느 JSX 부분에 사용되는지 보다 보면 두통 😬이 올겁니다. 그리고 버그 🐜를 추적하는 일도 쉽지 않습니다. (이 부분은 관심사 분리의 장점 중 하나입니다.)
  4. 테스트는 100% 통합 테스트입니다. 물론 반드시 나쁜 것은 아닙니다. 하지만 엣지 케이스를 테스트하기 쉽지 않고 테스트를 하려는 부분을 격리하는 일이 어렵습니다. 그래서 이런 테스트를 유지하는 것 자체가 큰 도전입니다.
  5. 여러 엔지니어와 함께 이 코드 위에서 작업하는 일은 끔찍합니다. git diff의 결과가 어떨지, merge로 발생하는 코드 충돌이 상상이라도 가나요?!
  6. 서드파티 컴포넌트 라이브러리를 사용하는 게 불가능하지 않을까요? 단일 컴포넌트에 모든 것을 작성하는 것이 목표라면 서드파티 라이브러리 사용 자체가 좀 이상한 상황이 될 겁니다. 서드파티 컴포넌트를 사용한다고 하더라도 고차컴포넌트를 사용하는 react-emotion 같은 라이브러리는 어떨까요? 쓸 수 없겠죠!
  7. 선언적 컴포넌트 안에서 명령형 추상/API를 캡슐화하는 일은 어렵습니다. 한 거대한 컴포넌트 속 생명주기 훅에 널리 흩뿌려져 있게 되어 코드를 따라가기 더욱 어려워집니다.

이런 이유에서 컴포넌트로 나눠 작성합니다. 컴포넌트를 나누면 위 문제를 해결할 수 있게 됩니다.

한동안 AMA에서 앱을 컴포넌트로 나누는 좋은 방법/패턴이 있는가에 대한 질문을 받았습니다. 이게 제 답변입니다. "만약 위에서 살펴본 문제를 직면하게 될 때, 컴포넌트를 여러 작은 컴포넌트로 나눠야 할 때입니다. 미리 나누지 말고요." 단일 컴포넌트를 여러 컴포넌트로 나누는 일을 우리는 "추상(abstraction)"이라고 부르고 있습니다. 추상은 멋지지만 모든 추상은 비용이 따릅니다. 그래서 추상에 되려 당하기 전에 이 비용과 이득에 대해 유의하고 있어야 합니다.

중복은 잘못된 추상보다 훨씬 저렴합니다.Sandi Metz

저는 제 컴포넌트의 render 메소드가 정말 길어져도 크게 개의치 않습니다. JSX는 JavaScript 표현식의 모음이고 컴포넌트를 위해 선언적 API를 사용한다는 점을 기억하세요. 컴포넌트를 작은 크기로 나눠 이곳저곳에 프로퍼티 내리꽂기 (역자 주: 번역)를 하지 않고 render 메소드를 그대로 유지한다고 해서 그렇게 코드가 엄청 잘못되거나 하는 것은 아니라는 의미입니다.

결론

컴포넌트를 더 작은 크기로 쪼개는 것은 자유입니다. 하지만 진짜 문제를 경험하기 전까지는 크기가 커지는 컴포넌트에 대해 두려워하지 마세요. 너무 이르게 추상적으로 나누는 것보다 정말로 필요할 때까지 기다렸다가 적용하는 것이 코드 관리에 훨씬 편리합니다.

이 글은 Robin Wieruch8 things to learn in React before using Redux 번역입니다.


React에서 Redux 전에 배워야 할 8가지

상태 관리(State management)는 어렵습니다. React같은 뷰 라이브러리는 지역 컴포넌트 상태를 관리하는 일이 가능합니다. 하지만 이 상태는 특정 시점에서 확장해야 하는 일이 생깁니다. 리액트는 단순히 뷰 계층 라이브러리 입니다. 언젠가는 Redux와 같이 더 수준 높은 상태 관리 솔루션으로 넘어가는 결정을 하게 될 겁니다. 하지만 이 글에서는 Redux 열차에 올라타기 전에 React에서 알아야 하는 부분에 대해서 지적하고 싶습니다.

사람들은 간혹 React와 Redux를 함께 배웁니다. 하지만 거기에 문제점이 있습니다.

  • 지역 상태 (this.state)만 사용하는 경우에 왜 상태 관리에 확장 문제가 발생하는지 겪어보지 못합니다
    • 그래서 왜 Redux 같은 상태 관리 라이브러리가 필요한지 이해하지 못합니다
    • 그래서 너무 많은 보일러플레이트에 대해 불평합니다
  • React에서 지역 상태를 관리하는 방법을 배우지 못합니다
    • 그래서 모든 상태를 Redux에서 제공하는 상태 컨테이너에 담아두고 관리하려고 합니다
    • 그래서 지역 상태 관리를 전혀 사용하지 않게 됩니다

이런 문제점으로 인해서 React를 먼저 배우고 나중에 필요하다고 느낄 때 Redux를 배우도록 조언합니다. 확장 문제는 대형 애플리케이션에서만 나타납니다. 가끔 Redux를 사용하고 있으면서도 상태 관리 라이브러리가 필요하지 않은 경우가 있습니다. 책 The Road to learn React에서는 Redux와 같은 외부 의존성 없이 있는 그대로의 React로 애플리케이션을 만드는 방법을 설명합니다.

하지만 당신은 지금 Redux 열차에 올라타기로 결정했습니다. 그래서 이 글에서는 Redux를 쓰기 전에 React에서 알아야 할 내용을 살펴봅니다.

  • React에서의 지역 상태는 자연스럽다
  • React 함수형 지역 상태
  • React의 상태와 프로퍼티
  • React 상태 옮기기
  • React의 고차 컴포넌트
  • React의 Context API
  • React의 상태 컴포넌트
  • 컨테이너와 프레젠터 패턴
  • MobX 아니면 Redux?

React에서의 지역 상태는 자연스럽다

이미 언급했지만 가장 중요한 조언은 React를 먼저 학습하라는 점입니다. 컴포넌트에 지역 상태 즉, this.setState()this.state를 사용해서 생명을 불어 넣는 일을 피할 수는 없습니다. 이 방식에 익숙해져야 합니다.

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { counter: 0 };
  }

  render() {
    return (
      <div>
        Counter: {this.state.counter}

        <button
          type="button"
          onClick={() => this.setState({ counter: this.state.counter + 1 })}>
          Click!
        </button>
      </div>
    );
  }
}

React 컴포넌트는 초기 상태를 생성자(constructor)에서 정의하고 있습니다. 그런 후에 this.setState() 메소드를 사용해서 갱신할 수 있습니다. 상태 객체의 갱신은 얕은 병합(shallow merge)으로 수행됩니다. 그러므로 지역 상태 객체를 부분적으로 갱신하고도 상태 객체의 다른 프로퍼티는 손대지 않고 그대로 유지할 수 있습니다. 상태가 갱신된 후에는 컴포넌트가 다시 렌더링을 수행합니다. 앞에서 예로 든 코드에서는 this.state.counter의 갱신된 값을 보여줄 것입니다. 이 예제에서는 React의 단방향 데이터 흐름을 사용해 하나의 닫힌 루프(loop)를 작성했습니다.

React 함수형 지역 상태

this.setState() 메소드는 지역 상태를 비동기적으로 갱신합니다. 그러므로 언제 상태가 갱신되는지에 대해 의존해서는 안됩니다. 상태 갱신은 결과적으로 나타납니다. 대부분의 경우에는 이런 방식이 별 문제 없습니다.

하지만 컴포넌트의 다음 상태를 위해 연산을 하는데 현재 지역 상태에 의존한다고 가정해봅시다. 앞서 작성했던 예제에서는 다음처럼 작성했습니다.

this.setState({ counter: this.state.counter + 1 });

지역 상태(this.state.counter)는 연산에서 바로 그 시점의 상태로 사용했습니다. 그러므로 this.setState()를 사용해서 상태를 갱신하긴 했지만 지역 상태는 비동기 실행이 수행되기 전에 신선하지 않은 상태값을 사용해 연산하게 됩니다. 이런 점은 처음 보고 나서는 바로 파악하기 어렵습니다. 천 마디 말 보다 다음 코드를 보는게 더 빠를 것 같습니다.

this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }
this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }
this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }

// 예상한 상태: { counter: 3 }
// 실제 갱신된 상태: { counter: 1 }

이 코드에서 확인할 수 있는 것처럼 지역 상태를 갱신할 때 현재 상태에 의존해서는 안됩니다. 이런 접근 방식은 버그를 만듭니다. 그래서 이런 상황에서는 다음과 같은 방식으로 지역 상태를 갱신합니다.

this.setState()에는 객체 대신 함수도 사용할 수 있습니다. 함수는 비동기적으로 this.setState()가 실행될 때, 함수 시그니처에 지역 상태를 전달합니다. 그래서 이 함수는 콜백 함수로 정확한 시점에 올바른 상태를 갖고 실행되기 때문에 문제 없이 사용할 수 있게 됩니다.

this.setState(previousState => ({ counter: previousState.counter + 1 }));

이 방법으로 this.setState()를 여전히 이용하면서도 객체 대신 함수를 사용해서 이전 상태를 활용할 수 있습니다.

추가적으로 프로퍼티(props)에 의존적인 갱신이 필요한 경우에도 이 접근 방식을 따라야 합니다. 비동기적 실행이 수행되기 이전에 부모 컴포넌트에서 받은 프로퍼티가 변경되어서 값이 이전 정보가 되는 경우가 있기 때문입니다. 그래서 this.setState()의 두 번째 인자로 프로퍼티가 전달됩니다.

this.setState((prevState, props) => ...);

이제 올바른 상태와 프로퍼티를 사용해서 상태를 갱신할 수 있게 됩니다.

this.setState((prevState, props) => ({ counter: prevState.counter + props.addition }));

객체 대신에 함수를 사용하면서 얻을 수 있는 또 다른 장점은 바로 상태를 갱신하는 방법을 격리된 상태에서 테스트 해볼 수 있다는 점입니다. 단순히 this.setState(fn)을 사용하는 함수를 추출한 다음에 독립적으로 둔 다음에 테스트가 가능하도록 작성할 수 있습니다. 이 함수는 입력으로 간단히 출력을 확인할 수 있는 순수 함수여야 합니다.

React의 상태와 프로퍼티

상태는 컴포넌트 안에서 관리됩니다. 이 상태는 다른 컴포넌트에 프로퍼티로 내려줄 수 있습니다. 이 컴포넌트는 프로퍼티를 사용하거나 더 깊히 자식 컴포넌트로 전달할 수 있습니다. 덧붙여 자식 컴포넌트는 부모 컴포넌트로부터 콜백 함수를 전달 받을 수 있습니다. 이렇게 전달 받은 함수를 사용하면 부모 컴포넌트의 지역 상태를 변경하는 일도 가능합니다. 기본적으로 프로퍼티는 컴포넌트 트리를 타고 내려갑니다. 상태는 하나의 컴포넌트에서 관리합니다. 하위 컴포넌트에서는 프로퍼티로 전달한 함수를 사용해서 상태를 관리하는 컴포넌트까지 거슬러 올라와 상태를 변경할 수 있습니다. 갱신된 상태는 프로퍼티로 다시 하위 컴포넌트로 전달됩니다.

컴포넌트는 전체적인 상태를 관리할 수 있으며 자식 컴포넌트에게 프로퍼티를 전달할 수 있습니다. 프로퍼티에 함수를 전달하는 방법으로 자식 컴폰넌트가 부모 컴포넌트의 상태를 변경할 수 있게 합니다.

하지만 자식 컴포넌트는 전달된 함수의 출처가 어디인지, 프로퍼티로 받은 함수가 어떤 동작을 하는지 알지 못합니다. 이 함수는 부모 컴포넌트의 상태를 변경할 수도 있지만 다른 일을 할 가능성도 있습니다. 자식 컴포넌트는 단순히 실행하는 역할을 합니다. 프로퍼티도 동일합니다. 컴포넌트는 받은 프로퍼티가 프로퍼티인지, 상태인지, 또는 부모 컴포넌트에서 파생된 프로퍼티인지 알 방법이 없습니다. 자식 컴포넌트는 그저 사용할 뿐입니다.

프로퍼티와 상태의 개념을 이해하는 일은 중요합니다. 컴포넌트 트리에서 사용하는 모든 속성은 프로퍼티와 상태로 (그리고 프로퍼티와 상태에서 파생된 속성으로) 나눌 수 있습니다. 무엇이든 상호작용이 필요한 경우에는 상태에 보관되어야 합니다. 그 외 나머지는 모두 프로퍼티 형식으로 전달합니다.

수준 높은 상태 관리 라이브러리를 사용하기 전에 컴포넌트 트리를 따라 프로퍼티를 보내본 적이 있어야 합니다. 가장 끝에 있는 자식 컴포넌트에서 특정 값을 사용하려고 중간 컴포넌트에서는 전혀 쓰지 않는, 수많은 프로퍼티를 전달하는 코드를 작성하면서 "분명 이보다 더 나은 방법이 있을 거야" 생각해본 적이 었어야 합니다.

React 상태 옮기기

이미 지역 상태 계층(local state layer)을 옮겼나요? 이 방식은 일반 React에서 지역 상태 관리를 확장하는데 가장 중요한 전략입니다. 상태 계층은 올릴 수도, 내릴 수도 있습니다.

다른 컴포넌트에서의 접근을 줄이기 위해 지역 상태 계층을 하위로 내릴 수 있습니다. 컴포넌트 A가 자식 컴포넌트로 B와 C를 갖고 있다고 상상해봅시다. B와 C는 A의 자식 컴포넌트로 동등합니다. 컴포넌트 A는 유일하게 지역 상태를 관리하며 자식 컴포넌트에 프로퍼티를 전달합니다. 덧붙여 B와 C에서 A의 상태를 변경할 수 있는 함수도 전달합니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |    Stateful    |
          |                |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |        C       |
|                |    |                |
|                |    |                |
+----------------+    +----------------+

이제 컴포넌트 A의 지역 상태 절반은 컴포넌트 C에서 프로퍼티를 통해 쓰고 있으며 컴포넌트 B에서는 전혀 사용하고 있지 않습니다. 게다가 C는 A 컴포넌트에서 C에서만 사용하는 상태만 제어할 수 있는 함수를 프로퍼티로 전달했습니다. 여기서 볼 수 있는 것처럼 컴포넌트 A는 컴포넌트 C를 대신해서 상태를 관리하고 있습니다. 대부분의 경우에는 한 컴포넌트가 자식 컴포넌트의 모든 상태를 관리하는 일에 큰 문제가 없습니다. 하지만 컴포넌트 A와 컴포넌트 C 사이에 다른 컴포넌트가 추가된다고 생각해봅시다. 컴포넌트 A에서 컴포넌트 C에 전달해야 하는 프로퍼티를 컴포넌트 트리에 따라 전달합니다. 컴포넌트 A는 여전히 컴포넌트 C의 상태를 관리하고 있습니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |        +       |
|       B        |    |        |Props  |
|                |    |        v       |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |        +       |
                      |        |Props  |
                      |        v       |
                      |                |
                      +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |                |
                      +----------------+

이런 경우가 React의 상태를 아래로 내려야 하는 완벽한 경우입니다. 컴포넌트 A는 컴포넌트 C의 상태를 관리하고 있지만 이 상태 일부는 컴포넌트 C가 스스로 관리해도 문제가 없습니다. 즉, 각각의 상태에 대해 각 컴포넌트가 자율적으로 움직일 수 있습니다. 지역 상태 관리를 컴포넌트 C로 옮기면 더이상 컴포넌트 트리를 따라 프로퍼티를 전달하지 않아도 됩니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |                |
|                |    |                |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |                |
                      |                |
                      |                |
                      +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |     Stateful   |
                      +----------------+

컴포넌트 A의 상태도 덩달아 깔끔해졌습니다. 이 컴포넌트는 필요에 따라 자신의 상태와 가장 가까운 자식 컴포넌트의 상태만 관리하게 됩니다.

React에서 상태 옮기기는 다른 방향, 즉 상태 위로 옮기기도 가능합니다. 부모 컴포넌트인 컴포넌트 A와 자식 컴포넌트인 컴포넌트 B, C로 다시 돌아와서 살펴봅니다. A, B, C 사이에 얼마나 많은 컴포넌트가 있는지 상관 없습니다. 하지만 이번에는 컴포넌트 C가 이미 자신의 상태를 관리하고 있습니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |                |
|                |    |                |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |    Stateful    |
                      +----------------+

만약 컴포넌트 B가 C에서 관리하는 상태가 필요하다면 어떻게 해야 할까요? 이 상황에서는 공유할 수 없습니다. 상태는 프로퍼티 형태로 아래로만 넘겨줄 수 있기 때문인데요. 이런 이유에서 상태 계층을 위로 이동시킬 필요가 있습니다. 컴포넌트 C의 상태를 컴포넌트 B와 C가 공통으로 갖는 부모 컴포넌트의 위치로 올릴 수 있습니다. (여기서는 A가 해당되겠군요.) 만약 C가 관리하는 상태를 B에서 필요로 한다면 C는 상태 없는 컴포넌트가 됩니다. 상태는 A에서 관리되며 B와 C에 공유됩니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |        +       |
|       B        |    |        |Props  |
|                |    |        v       |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |                |
                      +----------------+

상태를 위로, 또는 아래로 옮기는 전략에서 단순 React를 사용할 때는 어떻게 상태 관리를 확장하는지 배울 수 있습니다. 더 많은 컴포넌트가 특정 상태에 관심을 가져야 하는 경우에은 상태에 접근해야 하는 컴포넌트 간의 공통 부모 컴포넌트까지 거슬러 올라가 상태를 둬야 합니다. 덧붙여 지역 상태 관리에서 충분히 관리할 수 있다면 컴포넌트는 필요한 만큼 상태를 관리하고 있기 때문입니다. 만약 컴포넌트 자체나 자식 컴포넌트에서 사용하지 않는 상태가 있다면 그 상태는 상태가 필요한 컴포넌트의 위치로 이동해야 합니다.

React의 상태 들어 올리기는 공식 문서에서 더 자세히 살펴볼 수 있습니다.

React의 고차 컴포넌트

고차 컴포넌트 (Higher order components, HOCs)는 React의 고급 패턴입니다. 이 패턴은 추상적인 기능이 필요할 때 사용할 수 있으며 여러 컴포넌트에서 선택적으로 기능이 필요할 때 활용할 수 있습니다. 고차 컴포넌트는 컴포넌트를 받아서 선택적 설정을 입력으로 받아 강화된 버전의 컴포넌트를 반환합니다. 이 기능은 JavaScript의 고차 함수 원칙인 함수를 반환하는 함수처럼 구현되었습니다.

만약 고차 컴포넌트가 익숙하지 않다면 React의 고차 컴포넌트 안내를 읽어보길 추천합니다. 이 글은 React의 고차 컴포넌트를 React의 조건부 렌더링의 용례와 함께 설명합니다.

고차 컴포넌트는 뒤에서 더 중요해지는데 Redux와 같은 라이브러리를 사용하게 되면 마주하게 되기 때문입니다. Redux 같은 라이브러리는 React의 뷰 계층(view layer)와 라이브러리의 상태 관리 계층과 "연결"하게 되며 이 과정에서 고차 컴포넌트를 사용해 처리하게 됩니다. (고차 컴포넌트로 이뤄지는 연결은 react-redux를 사용합니다.)

MobX와 같은 다른 상태 관리 라이브러리도 동일한 방식으로 적용합니다. 고차 컴포넌트는 라이브러리에서 제공하는 상태 관리 계층과 React의 뷰 계층을 붙이는데 사용합니다.

React의 Context API

React의 context API는 드물게 사용됩니다. 이 API를 사용하라 충고하지 않는 편인데 이 API는 안정적이지 않고 애플리케이션의 묵시적 복잡도(implicit complexity)를 높이기 때문입니다. 하지만 어떤 기능을 하는지 들어보면 왜 이런 기능이 있는지 충분히 이해할 수 있을 겁니다.

왜 이 기능을 알아야 할까요? React의 context는 컴포넌트 트리에서 속성을 묵시적으로 전달할 때 사용됩니다. 부모 컴포넌트에서 속성을 context로 선언하면 컴포넌트 트리 아래에 있는 자식 컴포넌트에서 활용할 수 있습니다. 명시적으로 각각의 컴포넌트 계층에 일일이 전달할 필요 없이 단순히 부모-자식 관계라면 부모 컴포넌트가 생성한 context를 자식 컴포넌트가 집어 사용할 수 있습니다. 모든 컴포넌트 트리에 걸쳐 언제든 꺼내서 쓸 수 있는, 보이지 않는 컨테이너가 존재합니다. 이 컨테이너 덕분에 컴포넌트에서 필요하지 않는 프로퍼티는 접근할 일이 없어지기 때문에 React에서 "프로퍼티 내려꽂기(props drilling)"라고 하는 일을 피할 수 있게 됩니다. 다시 원래 주제로 돌아와서 왜 이런 API를 알아야 할까요?

Redux나 MobX와 같은 세련된 상태 관리 라이브러리를 사용하다보면 어떤 시점에서 상태 관리 계층을 React 뷰 계층에 붙여야 하는 상황이 생깁니다. React의 고차 컴포넌트를 언급한 이유가 여기에 있습니다. 이 붙이는 과정을 통해 상태에 접근하고 수정할 수 있게 됩니다. 상태 자체는 일종의 상태 컨테이너 안에서 관리됩니다.

하지만 어떻게 모든 컴포넌트에서 이 상태 컨테이너에 접근할 수 있도록 붙일 수 있을까요? 이런 상황에서 React의 context를 사용할 수 있습니다. 최상위 컴포넌트 즉, React의 루트 컴포넌트(root component)에서 상태 컨테이너를 context로 지정합니다. 그래서 컴포넌트 트리에 있는 모든 컴포넌트에 명시적으로 전달하지 않으면서도 모두 접근할 수 있게 됩니다. 이 모든 과정은 React의 프로바이더 패턴으로 적용할 수 있습니다.

물론 이런 방식을 사용한다는 게 Redux 같은 라이브러리를 사용할 때마다 React의 context를 직접 제어해야 할 필요가 있다는 의미는 아닙니다. 이런 라이브러리는 이미 모든 컴포넌트에서 상태 컨테이너에 접근 가능하도록 모든 기능이 함께 제공되고 있습니다. 하지만 이 기능이 보이지 않는 곳에서 어떤 방식으로 동작하고 있는지 이해하게 된다면 여러 컴포넌트에서 상태를 제어하면서 도대체 이 상태 컨테이너는 어디서 오는 것일까 걱정할 필요도 없어지게 됩니다.

React의 상태 컴포넌트

React는 두 종류의 컴포넌트 선언이 존재합니다. ES6 클래스 컴포넌트와 함수형 상태 없는 컴포넌트(functional stateless component)입니다. 함수형 상태 없는 컴포넌트는 props를 인자로 받고 JSX를 반환하는 단순한 함수입니다. 이 함수는 어떤 상태도 갖지 않으며 React의 생애주기(lifecycle) 메소드에도 접근하지 않습니다. 이 컴포넌트는 이름 붙여진 그대로 상태가 없습니다.

function Counter({ counter }) {
  return (
    <div>
      {counter}
    </div>
  );
}

반면, ES6 클래스 컴포넌트는 지역 상태와 생애주기 메소드를 사용할 수 있습니다. 이 컴포넌트는 this.statethis.setState() 메소드에 접근 가능합니다. ES6 클래스 컴포넌트는 상태 컴포넌트로 사용할 수 있다는 의미입니다. 물론 이 컴포넌트가 꼭 지역 상태를 사용해야 한다는 뜻은 아니며 상태 없는 컴포넌트로도 작성할 수 있습니다. 일반적으로 상태가 없는 ES6 클래스 컴포넌트라면 생애주기 메소드를 사용하기 위해 클래스 형태로 작성한 경우입니다.

class FocusedInputField extends React.Component {
  constructor(props) {
    super(props);
  }

  componentDidMount() {
    this.input.focus();
  }

  render() {
    return (
      <input
        type="text"
        value={this.props.value}
        ref={node => this.input = node}
        onChange={event => this.props.onChange(event.target.value)}
      />
    );
  }
}

결론적으로 ES6 클래스 컴포넌트만 상태를 가질 수도 가지지 않을 수도 있습니다. 함수형 상태 없는 컴포넌트는 항상 상태가 없습니다.

덧붙여 고차 컴포넌트도 React 컴포넌트에 상태를 덧붙일 수 있습니다. 상태를 관리하기 위해 직접 고차 컴포넌트를 만들거나 recompose와 같은 라이브러리에서 제공하는 withState 고차 컴포넌트를 사용하는 것도 가능합니다.

import { withState } from `recompose`;

const enhance = withState('counter', 'setCounter', 0);

const Counter = enhance(({ counter, setCounter }) =>
  <div>
    Count: {counter}
    <button onClick={() => setCounter(n => n + 1)}>Increment</button>
    <button onClick={() => setCounter(n => n - 1)}>Decrement</button>
  </div>
);

고차 컴포넌트를 사용하면 어떤 컴포넌트에든 지역 상태를 추가할 수 있습니다.

컨테이너와 프레젠터 패턴

컨테이너와 프레젠터 패턴은 Dan Abramov의 블로그 포스트 이후 유명해졌습니다. 이 패턴에 익숙하지 않다면 지금이 살펴 볼 차례입니다. 컴포넌트를 컨테이너와 프레젠터로 구분합니다. 컨테이너 컴포넌트는 어떻게 동작하는가를, 프레젠터 컴포넌트는 어떻게 보이는가를 정의합니다. 컨테이너 컴포넌트는 ES6 클래스 컴포넌트로 구현되어 지역 상태를 관리합니다. 프레젠터 컴포넌트는 함수형 상태 없는 컴포넌트로 작성하여 프로퍼티로 받은 내용을 표현하고 부모 컴포넌트로부터 받은 함수 몇 가지를 실행하는 역할을 합니다.

Redux로 뛰어들기 전에 이 패턴 뒤에 있는 원칙을 이해할 필요가 있습니다. 상태 관리 라이브러리는 컴포넌트를 상태와 "연결"해줍니다. 상태 관리 계층과 연결된 컴포넌트를 **연결된 컴포넌트(connected component)**라는 용어로 부르기도 합니다. 이 컴포넌트는 어떻게 보이는가는 신경쓰지 않지만 어떻게 동작하는가에 집중하게 됩니다. 이런 컴포넌트가 바로 컨테이너 컴포넌트의 역할을 합니다.

MobX 아니면 Redux?

모든 상태 관리 라이브러리를 통틀어 Redux가 가장 유명하고 MobX는 살펴 볼 가치 있는 대안입니다. 두 라이브러리는 다른 철학과 프로그래밍 패러다임을 따라가고 있습니다.

둘 라이브러리 중 하나로 고르기 전에 이 글에서 설명한 내용에 대해 이해하고 있어야 합니다. 기본 React의 상태 관리를 확장하기 위해 다른 개념을 적용할 정도로 지역 상태 관리에 익숙해야 합니다. 미래에 규모가 커질 애플리케이션을 염두해서 상태 관리도 확장해야 한다는 점을 기억합시다. 상태 관리 위치를 변경하거나 React의 프로바이더 패턴을 활용해 context를 사용하는 것으로 이미 어느 정도 문제를 해결할 수 있어야 합니다.

Redux나 MobX를 사용하기로 결정했다면 Redux 또는 MobX: 혼란을 해결하려는 시도에서 더 심층적으로 다루고 있으니 읽어보기 바랍니다. 두 라이브러리를 비교하고 어떻게 적용할지 설명하고 있습니다. 아니면 React + Redux 학습 팁으로 바로 Redux를 시작할 수 있습니다.

이 글이 상태 관리 라이브러리를 사용하기 전에 어떤 역할을 하는지 이해하는데 도움이 되었으면 좋겠습니다. Redux와 MobX에 대해 더 궁금하다면 전자책/코스인 Taming the State in React를 확인해보세요.


2018-11-24: Rinae님의 피드백으로 프로퍼티 구멍내기를 프로퍼티 내려꽂기로 수정했습니다. 피드백 감사드립니다.

lazlojuly의 글 Node.js module.exports vs. exports을 번역했다.


node.js의 module.exports와 exports

(노트: 이 글은 Node.js 6.1.0 릴리즈 이후에 작성되었습니다.)

요약

  • module.exportsrequire() 함수를 사용했을 때 반환 받는 변수라고 생각해봅시다. 비어 있는 객체가 기본값이며 어떤 것으로도 자유롭게 변경할 수 있습니다.
  • exports 자체는 절대 반환되지 않습니다! exports는 단순히 module.exports를 참조하고 있습니다. 이 편리한 변수를 사용하면 모듈을 작성할 때 더 적은 양의 코드로 작성할 수 있습니다. 이 변수의 프로퍼티를 사용하는 방법도 안전하고 추천하는 방법입니다.
exports.method = function () { /* ... */ }
// vs.
module.exports.method = function () { /* ... */ }

간단한 모듈 예제

먼저 예제로 사용할 코드가 필요합니다. 간단한 계산기로 시작합니다.

// calculator.js

module.exports.add = (a, b) => a + b

다음처럼 사용할 수 있습니다.

// app-use-calculator.js

const calculator = require('./calculator.js')
console.log(calculator.add(2, 2) // 출력: 4

모듈 감싸기

Node.js는 require()로 모듈을 불러올 때 함수 래퍼(wrapper) 형태를 사용해 내부적으로 감싸서 호출합니다.

(function (exports, require, module, __filename, __dirname) {
  // calculator.js의 내용은 여기에 추가해 실행합니다.
  module.exports.add = (a, b) => a + b
});

module 객체

변수 **“module”**은 객체로 현재 모듈을 나타냅니다. 이 변수는 각 모듈에 지역적이며 비공개(private) 입니다. (모듈 코드에서만 접근할 수 있습니다.)

// calcualtor-printed.js

module.exports.add = (a, b) => a + b
console.log(module)

// 이 모듈을 require('./calculator-printed.js')로 호출하면 다음과 같은 결과를 볼 수 있습니다.
//
// Module {
//   id: '/Users/laz/repos/module-exports/calculator-printed.js',
//   exports: { add: [Function] },
//   parent: 
//     Module { ... }
//   filename: '/Users/laz/repos/module-exports/calculator-printed.js',
//   loaded: false,
//   children: [],
//   paths: [ ... ]
// }

module.exports

  • 객체 참조로 require() 호출을 하면 받는 값입니다.
  • Node.js에 의해 자동으로 생성됩니다.
  • 일반 JavaScript 객체를 참조합니다.
  • 또한 기본값은 비어 있습니다. (앞서 코드에서는 add() 메소드를 추가했습니다.)

다음 두 방법으로 module.exports를 사용합니다.

  1. 공개(public) 메소드를 붙여 사용합니다. (앞서 작성한 예제가 이 방법입니다.)
  2. 직접 작성한 객체나 함수로 대체하는 방식을 사용합니다.

왜 대체하는 방식으로 사용할까요? 대체하는 방식으로 사용하면 다른 클래스를 임의적으로 수정해 반환하는 것도 가능합니다. 다음 ES2015 예제를 보겠습니다.

// calculator-base.js

module.exports = class Calculator {
  add(a, b) {
    return a + b
  }
  substract(a, b) {
    return a - b
  }
}

이 calculator-base 예제에서는 클래스를 내보냈습니다. 다음 예제에서는 “Calcaulator” 클래스를 확장한 후, 클래스의 개체를 내보냅니다.

// calculator-advanced.js

const Calculator = require('./calculator-base.js')

class AdvancedCalculator extends Calculator {
  multiply(a, b) {
    return a * b
  }
  devide(a, b) {
    return a / b
  }
}

module.exports = new AdvancedCalculator()
// app-use-advanced-calculator.js

const calculator = require('calculator-advanced.js')

console.log(calculator.add(2, 2))      // 출력: 4
console.log(calculator.multiply(3, 3)) // 출력: 9

exports 별칭(alias)

  • exports는 편의 변수로 모듈 작성자가 코드를 덜 작성하도록 돕습니다.
  • 이 변수의 프로퍼티를 사용하는 방식은 안전하고 추천하는 방법입니다. (예를 들면 exports.add = function () { /* ... */ } 식으로)
  • exportsrequire() 함수에서 반환되지 않습니다. (module.exports는 반환하지만요!)

좋은 예제와 나쁜 예제를 확인합니다.

// calculator-exports-exmaples.js

// 좋음
module.exports = {
  add(a, b) { return a + b }
}

// 좋음
module.exports.subtract = (a, b) => a - b

// 가능함
exports = module.exports

// 위에서 작성한 코드보다 간단하고 짧은 코드
exports.multiply = (a, b) => a * b

// 나쁨, exports는 바깥으로 전달되지 않음
exports = {
  divide(a, b) { return a / b }
}

노트: module.exports를 함수나 객체로 대체하는 경우는 일반적인 접근법입니다. 이렇게 대체하면서도 여전히 exports 축약을 사용하고 싶다면 exports를 새로 대체할 객체를 가리키도록 설정해야 합니다. (위에서도 이런 방법을 사용헀습니다.)

exports = module.exports = {}
exports.method = function () { /* ... */ }

결론

변수명은 exports지만 실제로 내보내지 않는다는 사실은 좀 혼란스러울 수 있습니다. 막 node.js를 시작한 사람이라면 특히 그럴겁니다. 공식 문서에서도 이 부분이 조금 이상합니다.

exports와 module.exports의 관계가 마술처럼 느껴진다면 exports는 무시하고 module.exports만 사용하도록 지침을 드립니다.

제 경우엔 이런 코드가 마술이라고 생각하지 않습니다. 개발자라면 사용하는 플랫폼과 언어가 어떻게 동작하는지 깊게 이해하려는 노력을 항상 해야합니다. 이처럼 깊게 이해하는 과정에서 프로그래머는 값진 자신감과 지식을 얻으며 코드 품질, 시스템 구조와 생산성에 긍정적인 영향을 주게 됩니다.

제 글을 읽어주셔서 감사합니다. 의견이나 생각은 언제나 환영하니 덧글로 남겨주세요.

리액트의 Advanced guides 페이지를 따라하면서 노트한 내용이다. 가이드 쪽은 옴니버스 같은 기분이라서 반반으로 나눠 읽기로 했다. 기록하고 싶은 부분만 남겼기 때문에 자세한 내용은 각 페이지를 참고한다.

Reconciliation

React는 선언형 API를 사용하고 있어서 변경에 대해 일일이 신경쓰지 않아도 된다. 이 가이드에서는 React가 어떤 비교 알고리즘을 사용해서 고성능을 내는지 설명한다.

모든 컴포넌트를 다 새로 그리면 O(n3)인데 다음 두 가정으로 발견적(휴리스틱, heuristic) O(n) 알고리즘을 사용한다. 대부분의 경우는 이 가정에 문제가 없다.

  1. 다른 타입의 두 엘리먼트는 다른 트리를 만듬
  2. 개발자가 key로 힌트를 제공해서 자식 엘리먼트가 반복되는 렌더링에서 안정된 상태인걸 확인할 수 있음

엘리먼트가 갱신될 때, 어떤 식으로 갱신이 발생하는가는 다음과 같다.

  • 다른 타입의 엘리먼트인 경우, 트리 전체를 다시 그림 (언마운트 && 마운트 발생)
  • 동일 타입의 DOM 엘리먼트의 경우, 어트리뷰트만 갱신함, 어트리뷰트 일부만 갱신된 경우 변경된 부분만 갱신 (e.g. stylefontWeight)
  • 동일 타입의 컴포넌트 엘리먼트의 경우, 인스턴스가 유지되며 state도 보존됨. 대신 하위 컴포넌트에는 변경 사항을 componentWillReceiveProps()componentWillUpdate()로 전파함.

자식노드가 갱신될 때는 신경써야 한다.

// 1.
<ul>
  <li>Edward</li>
</ul>

// 2. 뒤로 추가되는 경우에는 기존 엘리먼트가 유지됨
<ul>
  <li>Edward</li>
  <li>Mindy</li>
</ul>

// 3. 앞으로 추가되는 경우에는 노드 전체를 다시 그림
//    당연히 성능 하락 발생하며 컴포넌트 엘리먼트 경우
//    언마운트 마운트하게 된다
<ul>
  <li>Mindy</li>
  <li>Edward</li>
</ul>

// 4. 앞서의 가정 2에 따라서 `key`를 제공하면
//    새로 그리지 않고 반영할 수 있게 됨
<ul>
  <li key="1029">Edward</li>
</ul>

<ul>
  <li key="2012">Mindy</li>
  <li key="1029">Edward</li>
</ul>

id가 없다면 적당히 hash를 생성해서 쓴다. 동일 계층에서만 유일값을 가지면 된다. 최후의 수단은 배열의 index인데 배열 순서가 바뀌지 않는다는 가정이 있어야 한다. 순서가 바뀌면 key를 써도 느리다. 별로 권장하지 않는다.

최종적인 결과는 동일하지만 어떻게 구현되어 있는지 아는 것으로 성능 향상을 할 수 있다. 휴리스틱에 기반한 알고리즘이라서 다음 경우엔 좋지 않다.

  1. 하위 트리의 컴포넌트가 일치하는지 검사하지 않는다. 두 컴포넌트가 비슷한 결과를 낸다면 하나로 만드는걸 고려한다.
  2. 키는 안정적이고 예측 가능하며 유일해야 한다. 이 규칙을 지키지 않으면 성능 하락이나 자식 컴포넌트가 state를 잃어버리는 경우가 발생한다.

Context

props로 일일이 내려주기 번거로울 때 contextTypes로 지정하면 하위 트리에 전역으로 전달된다. prop-types가 필요하다. 흑마법이므로 사용하지 말 것. 실험적인 API로 차후 문제가 될 가능성이 높다. 문서 내내 쓰지 말라는 말이 반복된다.

Fragments

엘리먼트를 반환할 때 컨테이너 역할을 하는 <div> 등을 쓰기 마련인데 테이블 같은 걸 조립할 때는 마크업에 맞지 않다. 이런 경우를 위해 정말 컨테이너 역할만 하는 <React.Fragment>를 사용할 수 있다.

class Columns extends React.Component {
  render() {
    return (
      <React.Fragment>
        <td>Hello</td>
        <td>World</td>
      </React.Fragment>
    );
  }
}

약식 표기로 <>, </>를 사용할 수 있지만 현재 툴에서 지원하지 않을 수도 있으니 위 방식으로 사용한다.

<React.Fragment>로 묶을 때 key도 지정할 수 있다.

function TodoList(props) {
  return (
    <dl>
    {props.items.map(item => (
      <React.Fragment key={item.id}>
        <dt>{item.thing}</dt>
        <dd>{item.done}</dd>
      </React.Fragment>
    ))}
    </dl>
  );
}

Portals

컴포넌트를 부모 컴포넌트의 DOM 트리 바깥에 붙일 때 사용하는 방법이다. 전체화면에 모달 띄우기 이런 동작 필요할 때 쓴다. 아래 코드에서 child는 렌더링 가능한 리액트 엘리먼트고 container는 DOM 엘리먼트다.

ReactDOM.createPortal(child, container)

DOM에서 이벤트 전파(Event bubbling)는 실제 노드를 타고 올라가는 식이지만 포털은 다른 DOM 노드에 위치하고 있더라도 React 컴포넌트의 노드 트리를 타고 전파된다. DOM의 바인딩만 트리 바깥에서 일어나고 실제 모든 컴포넌트는 기존과 동일한 방식으로 동작한다.

에러 바운더리

기존엔 에러가 발생하면 컴포넌트 트리가 멍텅구리 되었는데 에러 바운더리를 사용해서 해결할 수 있다. 오류가 발생했을 때 동작을 componentDidCatch(error, info)에 선언한다. error는 발생한 오류고 infocomponentStack 키를 포함한 객체로 오류 발생 시, 스택 정보를 제공한다.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // 오류 발생 UI 표시
    this.setState({ hasError: true });
    // 로그로 남김
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

에러 바운더리는 다음처럼 컨텍스트 바깥에서 발생하는 오류는 잡지 못한다.

  • 이벤트 핸들러
  • 비동기 코드
  • 서버측 렌더링
  • 하위 컴포넌트가 아닌 에러 바운더리 자체에서 발생한 오류

react 16부터는 에러 바운더리로 에러가 잡히지 않았을 때 리액트 컴포넌트 트리 전체를 언마운트한다. 문제 있는 UI를 그대로 두면 사용자의 잘못된 조작을 야기할 수 있기 때문이라고 한다.

create react app 사용하면 스택 추적에서 어디서 오류가 났는지 명확히 보인다. create react app을 사용하지 않는다면 플러그인을 설치하면 된다. 다만 프로덕션에서는 꺼야 한다.

try ... catch는 명령행 코드에서만 동작한다. 이벤트 핸들러의 에러는 이벤트 바운더리에서 잡지 못한다. 이벤트 핸들러의 에러는 try ... catch를 사용한다.

15에서는 unstable_handleError였다고 한다.

웹 컴포넌트

웹 컴포넌트를 react 내에서 사용할 경우에는 일반 DOM 컴포넌트를 사용하는 것처럼 쓸 수 있다. 웹 컴포넌트는 명령형 API를 쓰는 경우가 종종 있는데 ref로 참조를 받아와 DOM 노드를 직접 호출해야 할 수도 있다. 또한 웹 컴포넌트에서 발생한 이벤트가 리액트 컴포넌트에 제대로 전이되지 않을 수 있으므로 직접 핸들러를 연결해야 할 수도 있다.

웹 컴포넌트에서 React 컴포넌트를 사용하려면 ReactDOM.render직접 렌더링 해야한다.

고차 컴포넌트

고차 컴포넌트는 컴포넌트를 입력 받아 새로운 컴포넌트를 반환하는 함수를 의미한다. (고차 함수와 같은 접근 방식이다.) Redux의 connect, Relay의 createFragmentContainer도 동일한 방식이다.

function withSubscription(WrappedComponent, selectedData) {
  return class extends React.Component {
    // ...
  }
}

고차 컴포넌트 내에서 기존 컴포넌트를 변경하지 않도록 주의한다. 고차 컴포넌트 내에서 기존 컴포넌트를 변경하면 추상성이 무너진다. 순수 함수처럼 작성해야 한다. 이 접근 방식은 책임을 분리한다는 점에서 컨테이너 컴포넌트와 유사한 점이 있다.

render() {
  // 불필요한 prop을 제거하거나 추가적으로 필요한 prop을 전달한다.
  const { extraProp, ...passThroughProps } = this.props;
  const injectedProp = someStateOrinstanceMethod;
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

compose 같은 합성 함수를 사용하면 편리하다. lodashRedux, Ramda에서도 제공한다.

고차 컴포넌트에서 반환하기 전에 displayName을 추가하면 디버그를 쉽게 할 수 있다.

function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component { /* ... */ };
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

고차 컴포넌트에서 지켜야 할 사항이 있다.

  1. render() 내에서는 고차 컴포넌트를 사용하지 않는다. 렌더링 할 때마다 새로운 컴포넌트를 만들고 최악의 경우 노드의 모든 상태를 잃게 된다.
  2. 고차 컴포넌트를 만들 때는 정적 메소드도 직접 복사해야 한다. hoist-non-react-statics 같은 패키지를 사용해도 된다.
  3. 고차 컴포넌트는 ref를 전달하지 못한다. ref는 일반 prop이 아니기 때문인데 부모 컴포넌트에 ref 노출하는 식으로 별도의 prop을 만들어서 전달해야 한다. 깔끔한 해결책은 아니다.

Render Props

Render props은 prop에 엘리먼트를 반환하는 함수를 전달해서 재사용성을 높이는 방법이다. React Routerdownshift에서 사용하는 방식이라고 한다.

<Mouse render={mouse => (
  <Cat mouse={mouse} />
)}/>

구체적으로 구현하는 방식보다 동작을 사용자에게 위임하는 방식으로 구현하는 접근법으로 Mouse 커서 위치를 전달하는 예를 들었다.

이런 방식으로 사용하는걸 render props라고 하지만 꼭 props가 render일 필요는 없다. 패턴 이름일 뿐이다.

설명에는 ShallowEquals 때문에 React.PureComponent에서는 익명함수가 계속 새로 생성된다고 나오는데 내가 테스트를 제대로 못하는건지 React.Component에서 하는거랑 차이가 없어 보인다. 여튼 익명함수를 반복해서 생성하고 싶지 않다면 익명함수 대신 선언한 메소드를 전달해주는 방식으로 해결할 수 있다.

constructor(props) {
  super(props);
  this.renderTheCat = this.renderTheCat.bind(this);
}
// ...
renderTheCat(mouse) {
  return <Cat mouse={mouse} />;
}
// ...
return <Mouse render={this.renderTheCat} />;

다른 라이브러리와 함께 사용하기

DOM을 직접 제어하는 플러그인과 함께 사용하려면 ref로 DOM 엘리먼트를 노출하고 직접 제어한다.

componentDidMount() {
  this.$el = $(this.el);
  this.$el.chosen();
}

componentWillUnmount() {
  this.$el.chosen('destroy');
}
render() {
  return (<select ref={el => this.el = el}>{this.props.children}</select>);
}

다른 뷰 라이브러리와 연동하기에서는 리액트로 포팅하는 방법이랑 Backbone.View에 리액트 컴포넌트를 어떻게 넣는지 설명한다.

React state, flux, redux를 권하긴 하지만 모델 레이어와도 통합이 가능하다.

Backbone의 모델을 컴포넌트에서 사용하려면 backbone에서 사용하는 방식대로 사용하면 되고 Backbone의 모델에서 데이터를 가져오는 방식으로는 고차 컴포넌트 형태로 활용할 수 있다.

설명은 Backbone으로 했지만 여기서 사용한 기법 자체는 제한적이지 않다.

접근성

WAI-ARIA 적용, 시멘틱 HTML, 폼 접근성, 포커스 컨트롤 등 접근성 관련된 내용을 설명하는데 읽어봐야 하는 문서를 전부 나열하고 있다. 리액트에 특정한 부분이 아니라서 각 링크는 본문을 확인한다.

// for 대신 htmlFor를 사용
<label htmlFor="namedInput">Name:</label>
<input id="namedInput" type="text" name="name"/>

포커스 제어는 ref로 직접 DOM을 받아서 처리한다.

코드 분할

import() 등 nodejs에서 사용하는 일반적인 코드 분할 기법을 설명한다.

리액트의 Advanced guides 페이지를 따라하면서 노트한 내용이다. 가이드 쪽은 옴니버스 같은 기분이라서 반반으로 나눠 읽기로 했다. 기록하고 싶은 부분만 남겼기 때문에 자세한 내용은 각 페이지를 참고한다.

JSX in Depth

리액트 엘리먼트 타입 정의

JSX는 React.createElement(component, props, ...children)의 편의 문법이다. 그래서 JSX를 사용할 때는 스코프 내에 React가 꼭 필요하다.

다음처럼 점 표기법을 사용할 수 있다. 한번에 여러 컴포넌트 내보낼 때 편리하다.

const MyComponents = {
  DatePicker: function DatePicker(props) {
    return <div>Imagine a {props.color} datepicker here.</div>;
  }
}

function BlueDatePicker() {
  return <MyComponents.DatePicker color="blue" />;
}

사용자 정의 컴포넌트는 꼭 Capitalized 되어야 한다. 소문자로 된 컴포넌트라면 사용하기 전에 Capitalized 하는 방식으로 사용할 수 있다. 동적으로 사용할 때도 이런 방식으로 사용한다.

const components = {
  photo: PhotoStory,
  video: VideoStory
};

function Story(Props) {
  // Wrong
  return <components[props.storyType] story={props.story} />;

  // Correct
  const SpecificStory = components[props.storyType];
  return <SpecificStory story={props.story} />;
}

Props

아래는 각각 동일한 표현이다.

// 문자열 리터럴
<MyComponent message="hello world" />
<MyComponent message={'hello world'} />

// 문자열 리터럴은 HTML-unescaped로 처리됨
<MyComponent message="<3" />
<MyComponent message={'<3'} />

// Prop의 기본 값은 `True`
<MyComponent autocomplete />
<MyComponent autocomplete={true} />

Spread Attribute로 간편하게 표현할 수 있다.

<Greeting firstName="John" lastName="Dorian" nickName="Bambi" />

const props = {firstName: 'John', lastName: 'Dorian', nickName: 'Bambi'};
<Greeting {...props} />

const { nickName, ...other } = props;
const nick = nickName === 'Bambi' ? 'Newbie' : 'Scooter';
<button nickName={nick} {...other} />

자식 노드

문자열은 문자열로 처리되고 개행은 공백으로 처리된다.

render()에서 배열로 반환하면 합쳐서 렌더링한다.

JS 표현식도 자식 노드에 사용할 수 있다. 배열도 렌더링 하기 때문에 다음처럼 쓸 수 있다.

<ul>
  {todos.map((message) => <Item key={message} message={message} />)}
</ul>

children에 함수도 전달할 수 있다. Lifting state up이랑 비슷한 느낌이다. 세부 구현을 사용자에게 위임할 수 있을 것 같다.

function Repeat(props) {
  let items = [];
  for (let i = 0; i < props.numTimes; i++) {
    items.push(props.children(i));
  }
  return <div>{items}</div>;
}

function TodoList() {
  const todos = ['finish doc', 'submit pr', 'review'];
  return (
  <Repeat numTimes={10}>
    {(index) => <div key={index}>This is item {index} in the list</div>}
  </Repeat>
  );
}

Boolean, null, undefined는 화면에 렌더링하지 않는다.

// 조건부 표현
{showHeader && <Header />}

// false가 아닌 falsy한 값을 반환하는 경우에는 렌더링되는 점을 주의, 명확하게 boolean으로 반환할 것
{props.messages.length > 0 && <MessageList messages={props.messages} />}

Boolean, null, undefined를 표시하려면 {String(value)} 식으로 작성한다.

PropTypes로 타입 확인하기

정적 타입을 사용하지 않는다면 사용할 만한 검증 라이브러리다. 원래는 React에 포함되어 있다가 분리된 모양이다. 개발 모드에서만 값을 검사한다. 자세한 사용법은 prop-types 참고한다.

class Greeting extends React.Component {
  // ...
}

Greeting.propTypes = {
  name: PropTypes.string,
  nicknames: PropTypes.arrayOf(PropTypes.string),
  children: PropTypes.element.isRequired
};

Greeting.defaultProps = {
  name: 'Stranger'
};

클래스 프로퍼티 문법으로도 사용할 수 있다.

정적 타입 검사

FlowTypeScript를 설정하고 사용하는 방법을 설명한다.

코틀린도 js를 타겟 플랫폼으로 사용 가능하다고 한다. Kotlin Wrappers, create-react-kotlin-app을 참고한다.

Refs와 DOM

일반적으로 데이터 흐름은 props를 사용하게 되어 있지만 몇몇 경우에는 이런 방식에 적합하지 않다.

  • 커서 위치, 텍스트 선택, 미디어 재생
  • 애니메이션 처리
  • 서드파티 DOM 라이브러리와 연동

선언적으로 해결할 수 있는 부분에서는 ref를 쓰지 않는 것을 권한다. 예를 들면 Dialog 컴포넌트에 open(), close() 메소드를 만드는 것보다 isOpen prop을 넘겨주는 식으로 처리한다. 안되는걸 되게 하려고 ref를 쓸 수는 있지만 쓰기 전에 컴포넌트 위계를 보고 상태를 어디에 위치해야 하는지 잘 고려해야 한다. ref를 쓰는 방식보다 상위 계층에 상태가 위치하는게 더 적절하다면 Lifting State Up 방식을 적용해서 해결한다.

Ref는 DOM 컴포넌트와 클래스 컴포넌트에서만 사용할 수 있다. 컴포넌트 자체를 레퍼런스로 넘기 때문인데 함수형 컴포넌트 내에서 DOM 컴포넌트나 클래스 컴포넌트에는 사용할 수 있다.

마운트 될 때는 인자에 해당 엘리먼트를 전달하고 언마운트에는 null을 전달한다. 이 refcomponentDidMount, componentDidUpdate 전에 호출된다.

일반적으로 DOM 엘리먼트에 접근해야 할 일이 있을 때 많이 쓴다. ref={input => this.textInput = input}

class AutoFocusTextInput extends React.Component {
  componentDidMount() {
    this.textInput.focusTextInput();
  }

  render() {
    return <CustomTextInput
      ref={(input) => { this.textInput = input; }}/>;
  }
}

하위 엘리먼트의 DOM ref를 상위에서 사용하려면 props 체인을 따라서 함수를 내려주면 된다. 여기서 inputRef는 일반 prop을 정의해서 쓴 것이지 ref처럼 특별한 기능이 있는 prop이 아니다.

function CustomTextInput(props) {
  return <div><input ref={props.inputRef} /></div>;
}

function FormLayout(props) {
  return (
    <div>
      Name: <CustomTextInput inputRef={props.inputRef} />
    </div>
  );
}

class AwesomePage extends React.Component {
  render() {
    return <FormLayout inputRef={el => this.inputElement = el} />;
  }
}

가능하면 DOM을 노출해서 사용하지 않는 것이 좋다고 한다. 어쩔 수 없이 필요할 때만 사용하고 극단적으로는 findDOMNode()라는 흑마법도 존재한다고.

ref가 두 차례씩 호출되는 것(마운트 && 언마운트)은 null을 전달해서 기존에 연결된 레퍼런스를 지우는 역할도 겸하고 있기 때문이다. (DOM 레퍼런스를 냅두면 DOM은 해제되어도 GC가 지우지 않고 남겨둔다. 그래서 복잡한거 하지 않도록 간단한 함수 형태로만 소개하는 것 같다.)

Uncontrolled 컴포넌트

폼 데이터를 React 컴포넌트에서 다루는 controlled 컴포넌트와 반대로 DOM 자체에서 다루도록 하는 방식의 컴포넌트를 뜻한다.

DOM 엘리먼트를 사용하면 내장된 동작을 그대로 사용할 수 있는 특징이 있다. Controlled and uncontrolled form inputs in React don’t have to be complicated 글에서 비교 도표를 볼 수 있다.

<input defaultValue="Bob" type="text" ref={(input) => this.input = input} />
// checkbox, radio는 defaultChecked, 그 외는 defaultValue

input[type="file"]은 읽기 전용으로 항상 uncontrolled 컴포넌트다. ref를 사용해서 DOM을 직접 다룬다.

성능 최적화

프로덕션 빌드를 사용한다.

  • Create React App (이래서 다 이거 얘기하는듯)
  • 단일 파일로 빌드
  • Brunch -p 옵션 빌드
  • Browserify의 envify, uglifyify, uglify-js
  • Rollup의 replace, commonjs, uglify
  • webpack

크롬 개발자 도구의 성능 탭에서 컴포넌트를 프로파일링한다. 프로파일링 전에 크롬 확장과 React DevTool 끄는 것 잊지 않는다. 성능 테스트 과정 참조.

긴 목록은 한번에 로드하지 말고 동적으로 처리해야 성능이 좋다. react virtualized 같은 패키지가 있고 Virtualize, windowing가 검색 키워드.

React devtool에서 Highlight updates 기능으로 불필요하게 렌더링이 되는 지점을 찾아서 수정한다.

shouldComponentUpdate()의 반환값으로 렌더링 여부를 수동으로 제어할 수 있다. 이 부분을 직접 작성하는 것보다 React.PureComponent를 상속받는게 낫다. PureComponent는 기존 Component 구현에 prop과 state의 shallow 비교를 포함하고 있다. shouldComponentUpdate() 메소드의 동작 방식과 구현은 본문을 읽는다. 갱신이 필요하면 노드를 타고 올라가서 모두 갱신하게 만든다.

불필요한 갱신은 가변 데이터에서 주로 나타나기 때문에 불변 데이터를 사용하면 이 문제를 쉽게 피할 수 있다. Array.push()로 기존 배열을 조작하는 것보다 Array.concat(), [...words, 'new data']을 사용해서 원 데이터가 변형되지 않도록 한다. Immutable.js을 써도 된다.

ES6 없이 React

ES6 없이 쓸 일이 있을 때 읽는다.

JSX 없이 React

JSX 없이 쓸 일이 있을 때 읽는다.


가이드 나머지 다 보고 나면 몇 가지 먼저 만들려고 한다. 그리고나서 enzyme이랑 상태 관리하는 패키지 redux랑 mobx? 찾아서 볼 생각이다. 많은 분들이 열심히 쓰고 있어서 주워들은 것만 공부해도 좀 걸릴 것 같다.

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.