리액트 공식 튜토리얼

리액트 공식 홈페이지를 가보면 튜토리얼을 제공하고 있다.
공식 홈페이지에서 한글 언어를 지원하길래 튜토리얼을 의미하는 자습서를 눌러보니
여긴 뭐 그대로 영어라 사람들이 편히 이해할 수 있도록 포스팅을 해보기로 했다.
튜토리얼은 tic tac toe 라고 불리는 게임을 만들어보는 내용이다. 굳이 번역을 해보자면 삼목(?)이라고 할 수 있겠다.
(크롬을 열고 tic tac toe 를 검색 해보면 바로 tic tac toe 게임을 즐겨볼 수도 있다.)
링크를 보면 알겠지만 전체적으로 JSX나 Babel, ES6 등 문법적 내용도 있고 Component, props, state와 같은 리액트 개발에 필수적인 내용도 있다.
그래서 적지 않은 양이기 때문에 이 포스팅으로 편히 따라가길 바란다. 구성도 원본과 동일하게 하였다.
(튜토리얼 한글화 작업 중이긴 하다. https://reactjs-org-ko.netlify.com/tutorial/tutorial.html)

튜토리얼 섹션

튜토리얼은 아래와 같이 구성되어 있다고한다.

  • Setup for the Tutorial will give you a starting point to follow the tutorial.
  • Overview will teach you the fundamentals of React: components, props, and state.
  • Completing the Game will teach you the most common techniques in React development.
  • Adding Time Travel will give you a deeper insight into the unique strengths of React.

한글로 표현하자면,

  • 튜토리얼 환경설정어떻게 리액트를 시작할 지 알려줄 것이다.
  • 개요에서는 리액트의 기본Components, props, state에 대해 알려줄 것이다.
  • 게임 완성하기에서는 리액트 개발에서 자주 쓰이는 기술들에 대해 배울 수 있다.
  • 시간 여행 추가하기에서는 리액트만의 강점에 대해 더 깊게 이해할 수 있다.

그래서 우리가 만드는 게 뭐죠?

결과물CodePen 이라고 하는 곳에 올려져 있다.
CodePen은 굳이 환경설정을 하지 않아도 웹 상에서 쉽게 코딩해보고 결과물을 확인 할 수 있게 도와주는 곳이라고 보면 되겠다.

필요한 선행 지식

튜토리얼은 HTML, JavaScript에 대해 기본적으로 알고 있다고 생각하고 만들어졌다.
사실 그렇게 어려운 건 없어서 아무 언어로 프로그래밍을 해봤다면 쉽게 따라할 수 있을 것이다.
그리고 ES6(Babel)에서 추가된 let, const, arrow function과 관련된 내용도 있기 때문에 모른다면
가이드를 참고하기 바란다.

튜토리얼 환경설정

두 가지 방식이 있는데, 하나는 앞서 말한 CodePen에서 진행하는 방법, 하나는 로컬에서 진행하는 방법이다.
로컬에서 진행하려면 Visual Studio Code와 같은 에디터, Node.js 설치가 필요한데, 이건 기존에 작성한 나의 포스트로 대체하겠다.
로컬에서 진행하는 것은 코딩 시작하기에서 더 자세히 쓰겠다.

  1. CodePen으로 웹에서 시작하기
  2. 로컬에서 시작하기

개요

환경설정이 다 끝났으면 이제 본격적으로 시작해보자. 개요에서는 대략적으로 리액트와 리액트 문법에 대해서 작성되었다.

리액트가 뭐죠?

리액트는 유저 인터페이스를 만들어주는 JavaScript 라이브러리다. 리액트는 components 라고 불리는 각각의 조각을 붙여 화면을 만들게 도와준다.
리액트는 여러 컴포넌트 종류가 있는데, React.Component 로 설명을 하려고 한다.

class ShoppingList extends React.Component {
  render() {
    return (
      <div className="shopping-list">
        <h1>Shopping List for {this.props.name}</h1>
        <ul>
          <li>Instagram</li>
          <li>WhatsApp</li>
          <li>Oculus</li>
        </ul>
      </div>
    );
  }
}

// Example usage: <ShoppingList name="Mark" />

리액트는 컴포넌트를 통해 화면을 구성하게 되는데, 데이터가 바뀔 때 stateprops(properties의 준말)를 통하면
리액트가 알아서 효율적으로 데이터를 업데이트하여 화면에 다시 보여주게 된다.
render 메소드(method)는 return 에 화면에 보여줄 것을 담게 되는데, 리액트에서는 React element가 된다.
이것을 앞서 작성한 JSX 방식으로도 할 수 있고, 아래와 같이 React API 를 이용할 수도 있다.(보통 JSX를 추천!)

return React.createElement('div', {className: 'shopping-list'},
  React.createElement('h1', /* ... h1 children ... */),
  React.createElement('ul', /* ... ul children ... */)
);

코딩 시작하기

튜토리얼 환경설정에서 1.CodePen으로 시작한 경우에는
여기를 눌러 시작하면 된다.

  1. 로컬에서 시작한 경우에는 VSC에서 프로젝트 폴더를 열어서 시작하자.
    CSS를 공부할 건 아니라서 CSS는 튜토리얼에서 제공해주는 그대로 사용하자.

로컬에서 환경설정하기

  1. Node.js 최신버전 설치
  2. 프로젝트 폴더를 구성할 위치에서 아래 명령어 실행
npx create-react-app my-app
  1. src/ 폴더 아래에 있는 파일들 전체 삭제(src 폴더 자체는 남겨두자). 아래 명령어를 순서대로 실행해도 된다.
cd my-app
cd src

# Mac / Linux:
rm -f *

# Windows:
del *

# 프로젝트 폴더로 복귀
cd ..
  1. CSS code를 복사해서 src/index.css 에 저장

  2. JS code를 복사해서 src/index.js 에 저장

  3. src/index.js에 아래의 3줄 추가하기:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';

그러면 프로젝트 폴더에서 npm start 명령을 통해 실행이 가능하다. 실행 후, http://localhost:3000 에서 3x3의 사각형들이 보이면 끝.
나중에 코드를 수정하고 저장하면 별도로 명령을 재실행하지 않아도 반영된다.

완료 화면

코드들을 살펴보면, 튜토리얼은 3개의 컴포넌트로 구성되어 있다.

  • Square
  • Board
  • Game

Square 컴포넌트에는 하나의 <button> 태그가 있고, Board 컴포넌트를 분석해보면 9개의 Square 컴포넌트가 들어간다.
우리는 /* TODO *//* status */를 채워서 게임을 완성시키자.

props를 통해 데이터 전달하기

튜토리얼에서는 복사/붙여넣기를 하지말고 직접 타이핑하는 것이 개발 실력 향상에 도움이 될 거라고 한다.

우선, Board class의 renderSquare 메소드를 다음 내용으로 바꾸자:

class Board extends React.Component {
  renderSquare(i) {
    return <Square value={i} />;
  }

Sqaurerender 메소드에서 /* TODO */{this.props.value} 로 바꾸자;

class Square extends React.Component {
  render() {
    return (
      <button className="square">
        {this.props.value}
      </button>
    );
  }
}

[Before]

Before

[After]

After

Before에서 After로 화면이 바뀌었으면 성공이다. Square가 상위 컴포넌트인(혹은 parent) Board에서 데이터를 받아 화면으로 보여주는 방식이다.
만약, 결과가 다르다면 링크의 코드와 비교하여 뭘 빠트렸는 지 확인하면 되겠다.

컴포넌트에 이벤트 추가하기

이제 Square를 클릭했을 때 X를 보여주도록 만들어보자. Squarerender 메소드를 다음과 같이 수정하자:

class Square extends React.Component {
 render() {
   return (
     <button className="square" onClick={() => alert('click')}>
       {this.props.value}
     </button>
   );
 }
}

Square를 누르면 click 메세지와 함께 경고창이 뜬다. 여기서는 => 처럼 ES6 문법인 arrow function이 사용되었다.
원래 목표는 사각형에 X를 띄우는 거란 걸 잊지말자. 이걸 위해서 state를 사용할 것이다.
리액트 컴포넌트는 생성자(constructor)에 this.state 를 써서 상태를 저장할 수 있다.
현재의 값을 this.state에 저장하고, Square가 클릭되었을 때 이 값을 바꿔주자.

우선, Square 컴포넌트에 생성자를 추가하고 초기화해주자:

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button className="square" onClick={() => alert('click')}>
        {this.props.value}
      </button>
    );
  }
}

여기서 잠깐!
모든 생성자를 가지는 리액트 컴포넌트 클래스는 반드시 super(props) 를 호출해야한다.

이제, Squarerender 메소드를 수정하자.

  • 태그의 this.props.valuethis.state.value 로 교체
  • onClick={...} 내용을 onClick={() => this.setState({value: 'X'})} 로 교체
  • 가독성을 위해 className onClick 라인 분리

완성본 :

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }
}

Square를 클릭하면 render 메소드의 onClick으로 인해 Squarethis.state.valueX가 될 것이다.
컴포넌트의 setState를 호출하면, 리액트는 자동적으로 하위 컴포넌트(혹은 자식, child)의 것도 바꾼다.
실행해서 클릭했을 때, 사각형에 X가 표시되는 지 확인하자.
안될 경우, 링크 확인!

개발 툴

크롬이나 파이어폭스에서
리액트로 만든 페이지를 검사할 수 있다.

검사 화면

리액트 개발툴에서 마우스 우클릭-검사를 하면 브라우저 오른쪽에 탭이 열리면서 propsstate를 확인할 수 있다.

CodePen에서 진행하는 방법:

  1. 로그인 / 회원가입 후 e-mail 인증(스팸 방지)
  2. Fork 버튼 클릭
  3. Change View 클릭 후 Debug mode 선택

게임 완성하기

이제는 Square를 클릭했을 때 XO를 번갈아가면서 나오게하고, 승자를 판단하는 것을 추가해야한다.

state를 부모(parent) 컴포넌트에게 전달하기

지금은 각각의 Squarestate를 가지고 있다. 누가 승자인지 판단하기 위해 9개의 Squarestate를 한 곳에서 체크해야한다.

Board가 각각의 Squarestate를 확인할 수도 있지만 이해하기가 좀 어렵고, 버그 걸리기 쉽고, refactor 하기 어려워 추천하지 않는다.
대신에, 우리는 각각의 Square가 아닌 Board에 게임의 state를 저장할 것이다. 그리고 이전에 우리가 각각의 사각형의 번호를 넘겼던 것처럼
BoardSquareprops를 전달할 것이다.

여러 자식들로부터 데이터를 가져오거나, 자식 컴포넌트 간 데이터를 주고 받아야할 때 공통된 state를 부모 컴포넌트에 선언해야한다.
부모 컴포넌트는 props를 통해 state를 자식 컴포넌트에게 전달할 수 있다. 이걸로 자식 컴포넌트들이 서로 동기화(sync) 될 수 있다.

리액트 컴포넌트를 refactor 할 때, state를 부모 컴포넌트로 올리는 것을 자주 하게 될 것이니 이번 기회에 해보는 것이 좋다.

Board에 생성자를 추가하고, 생성자 state에 9개의 Square에 대응되도록 squares라는 사이즈 9짜리 배열을 선언하고 null 로 초기화하자:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  renderSquare(i) {
    return <Square value={i} />;
  }

게임이 진행되면 this.state.squares는 이런 모습이 될 것이다:

[
  'O', null, 'X',
  'X', 'X', 'O',
  'O', null, null,
]

지금 BoardrenderSquare 메소드는 이럴 것이다:

  renderSquare(i) {
    return <Square value={i} />;
  }

이건 처음에 우리가 value를 props를 통해 전달해서 사각형에 0부터 8까지 숫자를 넣을 때 작성한 코드이다.
하지만 우리가 그 다음에 클릭한 경우 Xvalue를 가진 state를 가지도록 했고,
{this.state.value} 를 읽도록 했기 때문에 숫자는 볼 수 없었다.

이제 BoardrenderSquare 메소드를 수정해서 우리가 만든 squares 배열을 이용하도록 할 것이다.

 renderSquare(i) {
    return <Square value={this.state.squares[i]} />;
  }

현재까지 코드 완성본

이제는 Square를 클릭했을 때의 이벤트를 바꿔줘야한다.(지금은 X값 세팅만 있다.) 그러기 위해서는 Boardstate를 업데이트 해줘야한다.
하지만 state는 해당 컴포넌트 내부에 선언되어 있어 private하기 때문에, SquareBoardstate를 직접 변경할 수는 없다.
대신에, 우리는 함수(function)를 호출하는 것으로 이를 해결할 것이다.

BoardrenderSquare를 아래와 같이 바꾸자:

renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

이제 우리는 Board에서 Square로 2개의 props를 내려줄 것이다: valueonClick.
onClick 이란 propSquare가 클릭 되었을 때 호출 될 것이다.
그리고 Square에 아래 3가지를 수정하자:

  • Squarerender 메소드에서 this.state.valuethis.props.value로 변경
  • Squarerender 메소드에서 this.setState()this.props.onClick()으로 변경
  • Square의 생성자 삭제(이제 게임의 state를 여기서 관리하지 않는다)

다 반영하면, Square 컴포넌트는 이렇게 될 것이다:

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => this.props.onClick()}
      >
        {this.props.value}
      </button>
    );
  }
}

Square를 클릭했을 때, 어떻게 동작하게 되는 지 살펴보자:

  1. <button> 안의 onClick이 리액트가 클릭 이벤트 리스너를 세팅하도록 명령한다.
  2. 버튼이 클릭되었을 때, 리액트는 Squarerender()메소드 안에 있는 onClick 이벤트 핸들러를 호출한다.
  3. 이벤트 핸들러는 this.props.onClick()을 호출한다. SquareonClick prop은 Board에서 선언되어 있다.
  4. 클릭되었을 때 BoardSquareonClick={() => this.handleClick(i)} 하도록 했기때문에, Squarethis.handleClick(i)를 호출한다.
  5. 아직 handleClick() 메소드를 구현하지 않아서 사각형을 클릭하면 다음 에러가 발생할 것이다.
    ``TypeError: _this3.handleClick is not a function''

<button>은 built-in 컴포넌트이기 때문에 onClick 을 써도 리액트가 이해할 수 있다.
하지만 Square처럼 커스텀 컴포넌트(이름을 새로 만든 경우)는 SquareonClickprop이나 BoardhandleClick 메소드 중 아무거나 써도 된다.
리액트에서는 props의 경우에는 on[Event]로, method의 경우에는 handle[Event]를 쓰는 것이 일반적이다.

자 그럼 Board클래스 안에 handleClick 이벤트를 구현해보자:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = 'X';
    this.setState({squares: squares});
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

  render() {
    const status = 'Next player: X';

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

완성본 링크

수정을 하고 실행해보면, 이전처럼 클릭했을 때 사각형에 X가 표시된다. 그럼 여태까지 뭐한거지라고 생각할 수도 있겠다.
다시 정리해보자면, 이전에는 각각의 Square에서 state를 관리했지만 이제는 Board에서 관리되기 때문에 여기서 승자를 결정할 수 있게 되었다.
BoardstateSquare가 내려 받아 사용하므로 Square는 이제 controlled components라고 할 수 있다.
Boardstate가 바뀌면 Square는 자동적으로 state를 업데이트하여 다시 화면에 보여주게 된다.

handleClick을 보면 .slice()라는 함수를 통해 기존 배열을 수정하는 대신 squares의 복사본을 만들게 되었다.
이 이유는 다음 섹션에 설명하도록 하겠다.

변경불가성이 중요한 이유

바로 앞에서 squares 배열을 그대로 쓰지 않고 .slice() 를 써서 배열의 복사본을 만들어서 접근하였다. 왜 그렇게 했는지 알아보면서 변경불가성에
대해 이해해보자.

데이터를 바꾸는 두 가지의 방식이 있는데, 하나는 데이터의 값을 직접 변경하는 것이다.
다른 하나는 바뀌었으면 하는 값의 새로운 복사본으로 교체하는 것이다.

데이터의 값을 직접 변경
var player = {score: 1, name: 'Jeff'};
player.score = 2;
// Now player is {score: 2, name: 'Jeff'}
바뀌었으면 하는 값의 새로운 복사본으로 교체
var player = {score: 1, name: 'Jeff'};

var newPlayer = Object.assign({}, player, {score: 2});
// Now player is unchanged, but newPlayer is {score: 2, name: 'Jeff'}

// Or if you are using object spread syntax proposal, you can write:
// var newPlayer = {...player, score: 2};

두 가지 방법의 결과는 동일하지만, 방식의 차이로 인해 아래의 차이가 난다.

히스토리 관리와 재사용

우리는 나중에 시간 여행 추가하기에서 tic tac toe 게임의 히스토리를 관리하고, 게임 진행을 "점프" 하게 할 것이다.
이렇게 했던 것을 또 하거나 취소하는 기능은 어플리케이션에서 자주 쓰이기 때문에, 데이터의 값을 직접 변경하지 않는 것이 좋다.
이전 버전의 값을 가지고 있을 수 있기 때문에 히스토리 관리나 재사용하기에 유리하다.

변화 감지

값을 직접 바꿨으면 변화를 감지하는 건 어렵게 된다. 하지만 새로운 복사본으로 교체하는 방식은 과거의 데이터와 바꾼 데이터가 있기 때문에 서로 비교하면 바뀌었는지 아닌지 알 수 있게 된다.

언제 화면에서 다시 그릴지 결정

우리는 변화 감지에서 본 것 처럼 데이터가 언제 바뀌었는지 알기 때문에 언제 컴포넌트가 다시 화면에 그려져야 하는지도 쉽게 정할 수 있다. shouldComponentUpdate() 를 어떻게 사용하는 지 성능 최적화 를 읽어보길 바란다.

함수 컴포넌트(Function Components)

Square 부분을 함수 컴포넌트(function component)로 바꿔보자.

리액트에서 함수 컴포넌트는 자신의 state를 따로 관리하지 않고 render() 메소드만 가지는 경우 간단히 쓰기 위해 사용된다.
React.Component 를 상속받지 않는 대신, props의 인자를 가지고 render될 부분을 return 하는 메소드를 만들 수 있다.
컴포넌트들을 무조건 React.Component를 상속받는 클래스로 선언하지 말고 위에 해당될 경우 함수형태로 간단히 만들자.

Square 클래스를 다음 함수로 바꾸자:

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

여기서 기존의 this.propsprops로 바꾸었다.
또한, 기존의 onClick={() => this.props.onClick()}onClick={props.onClick} 로 변경하였다.

현재까지 완성본

순서 바꾸기 추가

지금은 사각형에 X만 표시된다. tic tac toe 게임에 맞게 순서대로 X, O 가 번갈아가면서 나오도록 수정하자.

처음 클릭했을 때는 X가 나오도록 할 것이기 때문에 Board의 생성자를 아래처럼 수정하자:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

플레이어의 턴에 따라서 xIsNext의 값을 truefalse로 계속 바꿀 것이다. BoardhandleClick 함수를 수정하자:

handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

수정이 되었으면 실행해서 순서대로 X, O 가 번갈아가면서 나오는 지 확인해보자.

그리고 Boardrender()를 수정하여 어떤 플레이어의 차례인 지 보여주도록 수정하자:

render() {
    const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

    return (
      // the rest has not changed

다 수정했을 때의 Board 클래스의 모습이다:

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

  render() {
    const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

현재까지 완성본

승자 결정하기

이제 플레이어의 턴이 누군지 알 수 있게 되었다. 이번에는 게임이 끝나는 상황을 위한 프로그램 작성을 하려고 한다.
이 함수를 index.js 파일 마지막에 붙여넣자:

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

lines 배열에 흔히 빙고가 이뤄지는 경우를 미리 적어두었다.(배열은 index가 0부터 인 것을 잊지말자)
Boardrender 함수에서 calculateWinner(squares) 를 호출하여 승자를 확인할 것이다.
승자가 정해지면 Winner: XWinner: O 로 보여줄 것이다.
Boardrender 함수에서 status 부분을 아래처럼 바꾸자:

render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      // the rest has not changed

그리고 BoardhandleClick에서 이미 클릭되어 XO로 채워진 사각형을 클릭하거나, 승자가 이미 결정된 경우
클릭을 무시하도록 다음과 같이 변경하자:

handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

현재까지 완성본

이제 정상적으로 동작하는 tic tac toe 게임이 완성되었다. 충실히 따라했다면 리액트의 기본도 어느 정도 이해할 수 있었을 것이다.

'개발 > React.js' 카테고리의 다른 글

리액트 공식 튜토리얼을 쉽게 해보자 2  (0) 2019.04.01
React.js 개발 환경  (0) 2019.03.19

+ Recent posts