React로 끝말잇기 웹게임 구현하기

본 포스트는 '[웹 게임을 만들며 배우는 React]' 강의의 내용을 듣고 정리하였습니다.


1. React Hooks의 등장계기

기존 React에서는 아래처럼 함수형을 사용하면, 안에 Class의 setState나 state를 사용할 수 없었기 때문에 아래와 같이 setState나 state가 사용되지 않는 구문에만 함수형을 사용하였다.

const GuGuDan = () => {
            return <div>Hello, Hooks</div>;
}

그런데 점점 함수형 안에 state를 사용하게끔 해달라는 요구가 많아지면서 React에서 함수형 컴포넌트 안에 state를 사용할 수 있게 개선하였다. 그것이 바로 React Hooks라고 불린다. 기존에는 클래스 컴포넌트를 기반으로 한 React 코드가 중심을 이뤘지만 최근에는 React 공식문서에서부터 클래스 컴포넌트를 사용하지 말고 Hooks를 사용할 것을 권장하고 있다. Hooks의 기본 포멧은 아래와 같다.]

// 아래 코드는 웹팩을 사용했다고 가정한 코드이다.

const GuGuDan = () => {
	const [first, setFirst] = useState(Math.ceil(Math.random()*9));
	const [second, setSecond] = useState(Math.ceil(Math.random()*9));
	// 필요한 추가적인 state들 작성	

	return (
		<>
			<div>{first}곱하기{second}?</div>
		</>
	);
}

2. Ref의 사용법

Ref의 경우에는 클래스와 Hooks 간에 사용법이 서로 다르기 때문에 주의해야한다.

2.1.클래스 컴포넌트의 Ref

(1)

const onRefInput = (c) => { this.taewoong = c }
this.taewoong.focus();
<input ref={ this.onRefInput } ... />

(2)

import { createRef } from 'react';
inputRef = createRef();
this.inputRef.current.focus();
<input ref={this.inputRef} ... />

2번과 같은 방법을 통해 클래스의 ref를 Hooks의 ref와 유사하게 사용할 수 있다. 다만, 2번과 같은 방법은 ref 안에 다른 동작을 추가할 수 없기 때문에 ref를 더 세부적으로 이용하고 싶다면 (1)의 방법으로 ref을 만들어준다.

2.2.Hooks의 Ref

const React = require('react');
const { useRef } = React;
const inputRef = useRef(null);
inputRef.current.focus();
<input ref={ inputRef } ... />

Hooks의 Ref의 경우에는 일반값을 기억하는데 사용하는 방법도 존재하나 이는 후에 추가로 다시 설명한다.


3. Hooks의 리랜더링

앞서 코딩했던 class 구구단 컴포넌트를 Hooks로 바꾸면 아래와 같다.

const React = require('react');
const { useState, useRef } = React;

const GuGuDan = () => {
    const [first, setFirst] = useState(Math.ceil(Math.random() * 9));
    const [second, setSecond] = useState(Math.ceil(Math.random() * 9));
    const [value, setValue] = useState('');
    const [result, setResult] = useState('');
    const inputRef  = useRef();

    const onChangeInput = (e) => {
        setValue(e.target.value);
    };

    const onSubmitForm = (e) => {
        e.preventDefault();
        if(parseInt(value) === first * second){
            setResult('정답: '  + value);
            setFirst(Math.ceil(Math.random() * 9));
            setSecond(Math.ceil(Math.random() * 9));
            setValue('');
            inputRef.current.focus();
        } else {
            setResult('떙');
            setValue('');
            inputRef.current.focus();
        }
    }

    return  <>
        <div>{first}곱하기{second}?</div>
        <form onSubmit={onSubmitForm}>
            <input ref={inputRef} type="number" onChange={onChangeInput} value={value} />
            <button>입력!</button>
        </form>
        <div id="result">{result}</div>
    </>
}

module.exports = GuGuDan;

Hooks는 기본적으로 state가 바뀌면서 리랜더링될 때 화살표 함수 안에 있는 부분 전부가 리랜더링된다. 즉, 리랜더링될 때 함수 전체가 다시 생성되기 때문에 class 컴포넌트에 비해 속도가 느릴 수 있다. state가 바뀔 때마다 리랜더링 된다면 아래와 같은 경우는 어떨까? 아래는 state가 4번 바뀌니깐 4번 리랜더링되는걸까?

const onSubmitForm = (e) => {
      e.preventDefault();
      if(parseInt(value) === first * second){
          **setResult('정답: '  + value);
          setFirst(Math.ceil(Math.random() * 9));
          setSecond(Math.ceil(Math.random() * 9));
          setValue('');**
          inputRef.current.focus();
      } else {
          setResult('떙');
          setValue('');
          inputRef.current.focus();
      }
  }

답은 '그렇지 않다'이다. class 컴포넌트에서도 마찬가지지만 useState류(위의 setResult, setFirst 등)는 비동기함수이다. 따라서 병렬적으로 실행되기 때문에 위에서 state를 4번 바꿔준다할지라도 1번만 리랜더링된다.

...

<body>
    <div id="root"></div>
    <script type="text/babel">
        const e = React.createElement;
        class Taewoong extends React.Component {
            state = {
                c: 0,
            };
            
            onClick = () => {
                this.setState({
                    c: this.state.c + 1,
                });
                this.setState({
                    c: this.state.c + 1,
                });
                this.setState({
                    c: this.state.c + 1,
                });
            };
            render() {
                return (
                    <React.Fragment>
                        <button onClick={this.onClick}>{this.state.c}</button>
                    </React.Fragment>
                );
            }
        }
    </script>
    <script type="text/babel">
        ReactDOM.render(<Taewoong />, document.querySelector('#root'));
    </script>
</body>

예를 들어서 위의 코드처럼 setState를 써주면 c가 3이 나올 것 같지만 setState는 병렬적으로 실행되기 때문에 3이 나오지 않고, 1이 나올수도 있다.


4. 웹팩이란 & 웹팩과 핫로딩의 사용법

웹팩이란 최신 프런트엔드 프레임워크에서 가장 많이 사용되는 모듈 번들러. 모듈 번들러란 웹 애플리케이션을 구성하는 자원(HTML, CSS, Javscript, Images 등)을 모두 각각의 모듈로 보고 이를 조합해서 병합된 하나의 결과물을 만드는 도구를 의미한다.

쉽게 말해서 웹팩을 쓰는 이유는 아래와 같다!

쉽게 생각하기 위해 페이스북을 예로 들어보자. 페이스북은 2만 개 이상의 컴포넌트(자바스크립트)가 있다고 한다. 그럼 이 2만 개의 컴포넌트를 어떻게 합쳐서 하나의 페이지로 만들어줄 것인가? 이 기능을 구현한 것이 바로 웹팩이다. 컴포넌트들을 하나로 합치면서 중복을 없애주고 babel까지 적용해준다.

웹팩 & 핫로딩 사용법

(1) 노드가 깔려있어야한다.

(2) npm init을 통해 package.json 생성

(3) npm i react react-dom

(4) npm i -D webpack webpack-cli

(5) npx webpack을 통해 webpack을 실행할 수 있다.

(6) 그런데 webpack에서 JSX를 사용하기 위해서는 아래 babel 관련 모듈을 설치해야됨.

(7) npm i -D @babel/core @babel/preset-env @babel/preset-react babel-loader

(8) 이후 webpack.config.js 설정 (아래에 정리해두었다)

(9) package.json의 "scripts"은 아래와 같이 설정해주고 num run dev로 핫로딩 실행

// package.json

{
  "name": "webpackgo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  ***"scripts": {
    "dev": "webpack"
  },***
  "author": "taewoong",
  "license": "MIT",
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "devDependencies": {
    "@babel/core": "^7.14.6",
    "@babel/preset-env": "^7.14.7",
    "@babel/preset-react": "^7.14.5",
    "babel-loader": "^8.2.2",
    "webpack": "^5.44.0",
    "webpack-cli": "^4.7.2"
  }
}

webpack.config.js의 기본적인 설정은 아래와 같다. webpack 5버전으로 바뀌면서 몇 가지 설정들이 달라졌으니 주의하도록 하자!

const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'development',
  devtool: 'inline-source-map', // hidden-source-map
  resolve: {
    extensions: ['.jsx', '.js'],
  },

  entry: {
    app: './client',
    // 나중에 app: ['./client.jsx', './csas.css', './asdadaf.json'], 
		// 와 같이 여러 형식의 파일들을 app.js에 하나로 합쳐줄 수 있는데, 
		// 파일명을 일일이 쓰기 귀찮다면 위에 resolve: {}에 확장자명을 넣어주면 된다. 
		// 그럼 알아서 웹팩에서 각각의 확장자명의 파일들을 찾아준다.
  },
  module: {
    rules: [{
      test: /\.jsx?$/,
      loader: 'babel-loader',
      options: {
        presets: [
          ['@babel/preset-env', {
            targets: {
              browsers: ['> 1% in KR'], // browserslist
							// 원하는 브라우저 버전에만 바벨을 적용할 수도 있다.
              // 많은 브라우저를 지원할수록 속도가 그만큼 느려지기 때문
	            // 위 코드는 한국에서 5% 이상 지원하는 브라우저를 타겟으로 하는 것이다.
              // https://github.com/browserslist/browserslist
							// 위 주소에서 더 자세한 정보를 확인할 수 있다.
            },
            debug: true,
          }],
          '@babel/preset-react',
        ],
        plugins: [],
      },
    }],
  },
  output: {
    filename: 'app.js',
    path: path.join(__dirname, 'dist'),
  },
};

전부 설정해준 이후에 아래와 같이 react와 react-dom을 불러온다.

(1)
import React from 'react';
import ReactDOM from 'react-dom';

(2)
const React = require('react');
const ReactDOM = require('react-dom');

두 방법 모두 가능하다. (1)은 Next.js 쓸 때 자주 쓰고, (2)는 Node.js 쓸 때 자주 쓰는 것 같다.

create-react-app을 쓰면 위의 설정을 자동으로 해주긴 하는데, 처음부터 create-react-app을 쓰면 과정이 정확히 이해가 되지 않는다. 이렇게 직접 세팅을 해주면서 공부하다가 나중에 CRA를 써주는 것이 좋다.


5. 복습문제 (답은 아래에 있음)

  1. 아래와 같은 구구단 컴포넌트를 만드는 코드를 작성하시오. (Hooks를 사용하시오)
  1. 1번의 코드를 웹팩을 이용해 실행하는 코드를 말하시오.

  2. 웹팩을 이용해 아래와 같은 끝말잇기를 제작하는 코드를 말하시오 (Hooks를 사용하시오)

  1. 웹팩을 이용해 아래와 같은 끝말잇기를 제작하는 코드를 말하시오 (Class를 사용하시오)


6. 추가로 생각해보기

뒤의 개념을 추가로 본 뒤에 생각해보자. 위 사진에서 하이라이트는 랜더링을 의미한다. 현재 input 창에서 숫자를 입력하고 있는 상황에서 input 창 위의 '2곱하기2'나 아래의 '정답' 부분은 랜더링 될 필요가 없음에도 불구하고 랜더링되고 있다. 이런 상황은 이후 성능 문제를 야기할 수 있다. 따라서 반드시 해결해야될 문제라고 볼 수 있는데, 해결 방법에 대해 생각해보자.


7. 복습문제 정답

  • 1번 정답
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
        <script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
        <script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
        <script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
    </head>
    <body>
        <div id="root"></div>
        <script type="text/babel">
    
        const GuGuDan = () => {
    
            const [first, setFirst] = React.useState(Math.ceil(Math.random()*9));
            const [second, setSecond] = React.useState(Math.ceil(Math.random()*9));
            const [value, setValue] = React.useState('');
            const [result, setResult] = React.useState('');
            const inputRef = React.useRef();
    
            const onChangeInput = (e) => {
                setValue(e.target.value);
            };
    
            const onSubmitForm = (e) => {
                e.preventDefault();
                if(parseInt(value) === first * second){
                    setResult('정답: '  + `${first} 곱하기 ${second}${first * second}!`);
                    setFirst(Math.ceil(Math.random()*9));
                    setSecond(Math.ceil(Math.random()*9));
                    setValue('');
                    inputRef.current.focus();
                } else {
                    setResult('땡');
                    setValue('');
                    inputRef.current.focus();
                }
            }
    
            return <React.Fragment>
                <div>{first}곱하기{second}?</div>
                <form onSubmit={onSubmitForm}>
                    <input ref={inputRef} type="number" onChange={onChangeInput} value={value} />
                    <button>입력!</button>
                </form>
                <div>{result}</div>
                </React.Fragment>
        }
        </script>
        <script type="text/babel">
            ReactDOM.render(<GuGuDan/>, document.querySelector('#root'));
        </script>
    </body>
    </html>
  • 2번 정답
    client.jsx
    const React = require('react');
    const ReactDOM = require('react-dom');
    
    const GuGuDan = require('./GuGuDan');
    
    ReactDOM.render(<GuGuDan/>, document.querySelector('#root'));
    GuGuDan.jsx
    const React = require('react');
    const { useState, useRef } = React;
    
    const GuGuDan = () => {
    
        const [first, setFirst] = useState(Math.ceil(Math.random()*9));
        const [second, setSecond] = useState(Math.ceil(Math.random()*9));
        const [value, setValue] = useState('');
        const [result, setResult] = useState('');
        const inputRef = useRef();
        
        const onSubmitForm = (e) => {
            e.preventDefault();
            if(parseInt(value) === first * second){
                setResult('정답: '  + `${first} 곱하기 ${second}${first * second}!`);
                setFirst(Math.ceil(Math.random()*9));
                setSecond(Math.ceil(Math.random()*9));
                setValue('');
                inputRef.current.focus();
            } else {
                setResult('땡');
                setValue('');
                inputRef.current.focus();
            }
        }
    
        const onChangeInput = (e) => {
            setValue(e.target.value);
        }
    
        return (
            <>
            <div>{first}곱하기{second}?</div>
            <form onSubmit={onSubmitForm}>
                <input ref={inputRef} type="number" onChange={onChangeInput} value={value} />
                <button>입력!</button>
            </form>
            <div>{result}</div>
            </>
        )
    }
    
    module.exports = GuGuDan;
    index.html
    <!DOCTYPE html>
    <html lang="ko">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>GuGuDan</title>
    </head>
    <body>
        <div id="root"></div>
        <script src="./dist/app.js"></script>
    </body>
    </html>
  • 3번 정답
    client.jsx
    const React = require('react');
    const ReactDOM = require('react-dom');
    
    const WordRelay = require('./WordRelay');
    
    ReactDOM.render(<WordRelay/>, document.querySelector('#root'));
    WordRelay.jsx
    const React = require('react');
    const { useState, useRef } = React;
    
    const WordRelay = () => {
    
        const [word, setWord] = useState('제로초');
        const [value, setValue] = useState('');
        const [result, setResult] = useState('');
        const inputRef = useRef();
    
        const onSubmitForm = (e) => {
            e.preventDefault();
            if(word.substr(-1) === value.substr(0,1)){
                setWord(value);
                setResult('딩동댕');
                setValue('');
                inputRef.current.focus();
            } else {
                setResult('땡');
                setValue('');
                inputRef.current.focus();
            }
        }
    
        const onChangeInput = (e) => {
            setValue(e.target.value);
        }
    
        return (
            <>
            <div>{word}</div>
            <form onSubmit={onSubmitForm}>
                <label htmlFor="taewoong">글자를 입력하세요 : </label>
                <input ref={inputRef} type="text" onChange={onChangeInput} value={value} />
                <button>입력!</button>
            </form>
            <div>{result}</div>
            </>
        )
    }
    
    module.exports = WordRelay;
    index.html
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <div id="root"></div>
        <script src="./dist/app.js"></script>
    </body>
    </html>
  • 4번 정답
    client.jsx
    const React = require('react');
    const ReactDOM = require('react-dom');
    
    const WordRelay = require('./WordRelayClass');
    
    ReactDOM.render(<WordRelayClass/>, document.querySelector('#root'));
    WordRelayClass.jsx
    import React, { Component } from 'react';
    
    class WordRelay extends Component {
    
        state = {
            word: '강태웅',
            value: '',
            result: '',
        }
    
        onSubmitForm = (e) => {
            e.preventDefault();
            if (this.state.word[this.state.word.length-1] === this.state.value[0]) {
                this.setState({
                    word: this.state.value,
                    value: '',
                    result: '딩동댕',
                });
                this.taewoong.focus();
            } else {
                this.setState({
                    value: '',
                    result: '땡',
                });
                this.taewoong.focus();
            }
        }
    
        onChangeInput = (e) => {
            this.setState({
                value: e.target.value,
            });
        }
    
        onRefInput = (c) => {this.taewoong = c};
    
        render() {
            return (
                <>
                    <div>{this.state.word}</div>
                    <form onSubmit={this.onSubmitForm}>
                        <input ref={this.onRefInput} type="text" value={this.state.value} onChange={this.onChangeInput} />
                        <button>입력</button>
                    </form>
                    <div>{this.state.result}</div>
                </>
            );
        }
    }
    
    export default WordRelay;
    index.html
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <div id="root"></div>
        <script src="./dist/app.js"></script>
    </body>
    </html>

좋은 웹페이지 즐겨찾기