React로 숫자야구 웹게임 구현하기


1. import와 require 비교

require은 node의 모듈 시스템이다. require과 module.exports가 한 묶음이다.

import는 ES2015에서 새롭게 도입된 키워드이다. import와 export default가 한 묶음이다.

최근 ES6(ES2015) 모듈 시스템인 import가 많이 사용되고 있지만, 아직까지는 import 키워드가 100% 대체되어 사용될 수는 없다.

그리고 가끔씩 다음과 같은 import 혹은 require을 볼 수 있다.

import { Component } from 'react';

const { Component } = require('react');

이는 구조분해 문법으로, exports되는 것이 객체나 배열이면 구조 분해할 수 있다. 예를 들어서,

export const hello = 'hello';import { hello } 로 가져올 수 있고,

export default NumberBaseball;import NumberBaseball 로 가져올 수 있다.


2. 숫자야구의 원리

나무위키 설명을 참조하면 좋음 : https://namu.wiki/w/숫자야구

  1. 사용 state : result, value, tries, answer
  2. 겹치지 않게 숫자 0~9 중에 4개를 뽑는다 (= answer)
  3. 만약 4개의 숫자를 모두 맞췄으면 화면에 ‘홈런!’을, 알림창에 ‘게임을 다시 시작합니다’를 출력해주고 게임을 새로 시작한다.
  4. 답과 내가 입력한 숫자가 위치까지 같으면 '스트라이크', 답이 내가 입력한 숫자를 포함하고는 있지만 서로 위치가 다르면 '볼'이다.
  5. 만약 10번 답을 틀렸으면, ‘답은 ~ 였습니다’라고 화면에 띄워주고 알림창에 ‘게임을 다시 시작합니다’를 출력해주고 게임을 새로 시작한다.
  6. 10번 보다 적게 답을 틀렸을 때는 화면에 ‘ㅇ스트라이크 ㅇ볼입니다’를 출력해준다.

3. React의 반복문

3.1. map 반복문 함수

React의 JSX 반복에서 반복문을 돌리고 싶다면, map 함수를 이용해야한다.

React의 map 반복문을 돌릴 때는 가장 위에 key를 써줘야한다. 그리고 key 안에 들어가는 값은 반드시 고유한 값이여야한다.(겹치면 안됨) 그렇다고 index를 넣는 짓을 하면 안된다. 성능 최적화할 때 문제가 생기기 때문이다 (key의 경우, 성능 최적화할 때 사용)

<ul>
    <li><b>사과</b> - 맛있다</li>
    <li><b>바나나</b> - 맛없다</li>
    <li><b>포도</b> - 시다</li>
    <li><b></b> - 떫다</li>
    <li><b></b> - 쓰다</li>
    <li><b></b> - 달다</li>
    <li><b></b> - 몰라</li>
</ul>

위와 같은 <ul> <li> 태그는 아래와 같이 반복문을 이용해 바꿔줄 수 있다.

<ul>
    {[
        ['사과', '맛있다'], 
        ['바나나', '맛없다'], 
        ['포도', '시다'], 
        ['귤', '떫다'],
        ['감', '쓰다'], 
        ['배', '달다'], 
        ['밤', '몰라']
    ].**map**((v)=>{
        return (
            <li key={v[0] + v[1]}>
		<b>{v[0]}</b> -{v[1]}
	    </li>
        );
    })}
</ul>

3.2. props

props란 컴포넌트 끼리 값을 전달하는 수단이다.

근데 위와 같은 리액트의 반복문은 여러모로 가독성이 안 좋고 성능도 좋지 않다. 그래서 이를 해결한 것이 바로 props이다.

마치 HTML 태그의 속성처럼, 한 컴포넌트의 요소에 props value를 지정하면 다른 컴포넌트에서 그 value에 값을 넣어 사용할 수 있다. 즉, props란 컴포넌트 끼리 값을 전달하는 수단이다. 아래 예제에서 props는 반복문을 자식 컴포넌트로 보내 부모 컴포넌트에서 자식 컴포넌트를 불러와 반복문을 이용하기 위해 사용된다. 아래 코드를 봐보자.


부모 컴포넌트

import Try form './Try';

...

return (
    <>
        <ul>
            {tries.map((v, i) => (
            ***<Try key={`${i + 1}차 시도 : ${v.try}`} tryInfo={v} />***
            ))}
        </ul>
    </>
);

// tries는 배열로 [ { try : ???, result: ??? } ] 이런 식으로 이루어져있다고 가정하자.

자식 컴포넌트

const React = require('react');
const { memo } = require('react');
const Try = memo(({ tryInfo }) => {
    return (
        <li>
            <div>{tryInfo.try}</div>
            <div>{tryInfo.result}</div>
        </li>
    );
});

module.exports = Try;

위와 같이 props를 이용해 부모 컴포넌트에서 자식 컴포넌트로 tryInfo={v}를 넘겨줬고, 이를 이용해 map 반복문을 돌려줬다. 그 결과, 아래와 같은 결과가 출력된다.

props에서 주의할 점은 props를 사용할 때는 자식의 값을 임의로 바꿔줘선 안된다는 것이다. 만약 props의 값을 바꾸고 싶다면 반드시 부모 컴포넌트에서 바꿔줘야한다.

ex. tryInfo.try = 'hello';

그런데 실무에서 가끔씩 props를 바꾸고 싶은 경우가 있다. 그럴 때는 state를 이용해서 바꿔줘야한다.

Hooks에서 props 바꾸기

const Try = memo(({ tryInfo }) => {
		***const [result, setResult] = useState(tryInfo.result);

		const onClick = () => {
			setResult('1');
		};***

    return (
        <li>
            <div>{tryInfo.try}</div>
            <div>{tryInfo.result}</div>
        </li>
    );
});

Class에서 props 바꾸기

class Try extends PureComponent {
		***state = {
			result: this.props.result,
			try: this.props.try,
		}

		const onClick = () => {
			setState({
				result: '1',
			})
		};***

    return (
        <li>
            <div>{tryInfo.try}</div>
            <div>{tryInfo.result}</div>
        </li>
    );
});

그런데 props를 보다보면 한 가지 의문이 생길 것이다. 만약 부모의 부모의 부모 컴포넌트(고조 할아버지 컴포넌트?)에서 자식의 자식의 자식(증손자 컴포넌트?)로 props를 물려주고 싶다면 어떻게 할까? 이를 일일이 해주려면 매우 귀찮고, 헷갈리는 작업이 될 것이다. 이 작업을 쉽게 할 수 있도록 도와주는 것이 Redux, Mobx, Context 등이다.


4. 랜더링 문제

React의 경우에는 state가 바뀔 때마다 랜더링이 실행된다. 이에 따라 원하지 않는 부분이 랜더링되는 경우가 있다. 예를 들어, input 창에 숫자를 입력하고 있다고 가정하면, 시도 부분과 밑에 ul 부분의 경우에는 랜더링 될 필요가 없다. 이렇게 필요없는 부분이 계속해서 랜더링된다면 이후 성능 문제를 야기할 수 있기 때문에 해결해줄 필요가 있다.

랜더링이 됐는지 안됐는지를 확인하기 위해서는 크롬 확장 프로그램인 React Developer Tools를 설치해서 확인해보면된다. 랜더링될 때마다 하이라이트가 발생하는 것을 볼 수 있다.

4.1. 클래스의 랜더링 문제 해결법 (2가지)

4.1.1. PureComponent

아래와 같이 사용해준다.

import React, { PureComponent } from 'react';
class Test extends PureComponent {
    state = {
        counter: 0,
        string: 'hello',
        number: 1,
        boolean: true,
        object: {},
        array: [],
    }

위처럼 PureCompoent를 사용하면 counter나 string 값 등이 바뀌지 않으면 랜더링되지 않는다. 상당히 간편하게 이용할 수 있으나 단점이 존재한다. React (PureComponent)는 Object나 array와 같은 복잡한 자료구조의 경우에는 값이 바뀌었는지 안 바뀌었는지 정확하게 구분하지 못할 때가 있다. 아래가 그 예이다.

onClick = () => {
    this.setState({});
    const array = this.state.array;
    array.push(1);
    this.setState({
        array: array,
    }); 
};

array라는 배열에 1 값을 넣고 setState를 이용해 array state를 1을 넣어준 새로운 array로 바꿔줬는데, React에서는 이를 다른 배열이라고 인식하지 못한다(= 같은 배열이라고 인식한다) 따라서 React에서 배열을 사용할 때는 반드시 불변성을 지켜줘야한다. 불변성을 지킨 해결법은 아래와 같다.

onClick = () => {
    this.setState((prevArray) => {
    return {
        array: [...prevArray, 1],
        };
    })
}

따라서 PureComponent를 자주 사용하되, 만약 객체나 배열 등을 사용할 때에는 불변성을 정확하게 지켜줄 수 있도록 해줘야만 한다. 또, 컴포넌트가 복잡할 때에는 PureComponent가 동작하지 않는 경우도 있다. 따라서 아래의 shouldComponentUpdate와 적절하게 혼용해서 사용할 수 있도록 하자!

4.1.2. shouldComponentUpdate(nextProps, nextState, nextContext)

아래와 같이 사용한다.

shouldComponentUpdate(nextProps, nextState, nextContext){
    if (this.state.counter !== nextState.counter) {
        return true;
    }
    return false;
}

만약에 지금의 counter와 다음 상태의 counter가 다를 경우에 랜더링하라는 뜻이다. 이처럼 shouldComponentUpdate를 이용하면 PureComponent에 비해 더 세부적인 설정을 해줄 수 있다.


4.2. Hooks의 랜더링 문제 해결법

4.2.1. memo 이용

memo는 memoization의 줄임말이다. 메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다.

memo를 이용하면 Hooks에서 불필요한 랜더링을 막을 수 있다. 아래와 같이 사용해준다.

import React, { memo } from 'react';
const Try = memo(({tryInfo})=>{
    return (
        <li>
            <div>{tryInfo.try}</div>
            <div>{tryInfo.result}</div>
        </li>
    )
});

export default Try;

자식들이 모두 PureComponent 혹은 memo이면 부모에도 PureCompnent 혹은 memo를 사용할 수 있다.


5. 숫자야구 웹 게임 구현

위 내용들을 종합해서 숫자야구 웹 게임을 구현한 코드는 아래와 같다.

Baseball.jsx (Hooks 이용)

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

**// this를 사용하지 않은 함수이기 때문에 바깥으로 빼줬다**
const getNumbers = () => {
    // 숫자 4개를 랜덤하게 뽑는 함수 (겹치지 않게)
    let output = [];
    for(let i=0; i<4; i++){
        output.push(Math.ceil(Math.random() * 9));
        for(let j=0;j<i;j++){
            if(output[i]===output[j]){
                output.splice(j,1);
                i--;
            };
        };
    };
    return output;
};

const Baseball = () => {

    const [result, setResult] = useState('');
    const [value, setValue] = useState('');
    const [tries, setTries] = useState([]);
    const [answer, setAnswer] = useState(getNumbers());
    const inputRef  = useRef();
    

    const onSubmitForm = (e) => {
        e.preventDefault();
        if(value === answer.join('')){
            setResult('홈런!');
            setTries((prevTries)=> (
                [...prevTries, { try: value, result: '홈런!' }]
            ));
            alert('게임을 다시 시작합니다!');
            setValue('');
            setAnswer(getNumbers());
            setTries([]);
        } else {    // 답을 틀렸으면 
            const answerArray = value.split('').map((v)=> parseInt(v));
            let strike = 0;
            let ball = 0;
            if(tries.length >= 9) {
								// 10번 넘게 답을 틀렸으면
                setResult(`10번 넘게 실패! 답은 ${answer.join(',')}였습니다`);
                alert('게임을 다시 시작합니다');
                setValue('');
                setAnswer(getNumbers());
                setTries([]);
                inputRef.current.focus();
            } else {
								// 10번보다 답을 적게 틀렸으면
                console.log('답은', answer.join(''));
                for(let i=0; i<4; i++){
                    if(answerArray[i] === answer[i]){
												// 내가 쓴 답과 실제 답의 위치와 값이 같으면
                        console.log('strike', answerArray[i], answer[i]);
                        strike++;
                    } else if (answer.includes(answerArray[i])){
                        console.log('ball', answerArray[i], answer.indexOf(answerArray[i]));
                        ball++;
                    }
                }
                setTries((prevTries) => (
                    [...prevTries, { try: value, result: `${strike} 스트라이크 ${ball} 볼입니다.`}]
                ));
                setValue('');
                inputRef.current.focus();
            }
        }
    };

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

    return (
        <>
            <h1>{result}</h1>
            <form onSubmit={onSubmitForm}>
                <input ref={inputRef} maxLength={4} value={value} onChange={onChangeInput} />
                <button>입력!</button>
            </form>
            <div>시도: {tries.length}</div>
            <ul>
                {tries.map((v, i) => (
                <Try key={`${i + 1}차 시도 : ${v.try}`} tryInfo={v}/>
                ))}
            </ul>      
            </>
        );
    };
              
module.exports = Baseball;

BaseballClass.jsx (클래스 이용)

import React, { Component, createRef } from 'react';
import Try from './Try';

function getNumbers() { // 숫자 네 개를 겹치지 않고 랜덤하게 뽑는 함수
  const candidate = [1,2,3,4,5,6,7,8,9];
  const array = [];
  for (let i = 0; i < 4; i += 1) {
    const chosen = candidate.splice(Math.floor(Math.random() * (9 - i)), 1)[0];
    array.push(chosen);
  }
  return array;
}

class Baseball extends Component {
  state = {
    result: '',
    value: '',
    answer: getNumbers(), // ex: [1,3,5,7]
    tries: [],
  };

  onSubmitForm = (e) => {
    const { value, tries, answer } = this.state;
    e.preventDefault();
    if (value === answer.join('')) {
      this.setState((prevState) => {
        return {
          result: '홈런!',
          tries: [...prevState.tries, { try: value, result: '홈런!' }],
        }
      });
      alert('게임을 다시 시작합니다!');
      this.setState({
        value: '',
        answer: getNumbers(),
        tries: [],
      });
      this.inputRef.current.focus();
    } else { // 답 틀렸으면
      const answerArray = value.split('').map((v) => parseInt(v));
      let strike = 0;
      let ball = 0;
      if (tries.length >= 9) { // 10번 이상 틀렸을 때
        this.setState({
          result: `10번 넘게 틀려서 실패! 답은 ${answer.join(',')}였습니다!`,
        });
        alert('게임을 다시 시작합니다!');
        this.setState({
          value: '',
          answer: getNumbers(),
          tries: [],
        });
        this.inputRef.current.focus();  // 훅스와 사용법이 동일해졌다!
      } else {
        for (let i = 0; i < 4; i += 1) {
          if (answerArray[i] === answer[i]) {
            strike += 1;
          } else if (answer.includes(answerArray[i])) {
            ball += 1;
          }
        }
        this.setState((prevState) => {
          return {
            tries: [...prevState.tries, { try: value, result: `${strike} 스트라이크, ${ball} 볼입니다`}],
            value: '',
          };
        });
        this.inputRef.current.focus();
      }
    }
  };

  onChangeInput = (e) => {
    console.log(this.state.answer);
    this.setState({
      value: e.target.value,
    });
  };

  inputRef = createRef();

  render() {
    const { result, value, tries } = this.state;
    return (
      <>
        <h1>{result}</h1>
        <form onSubmit={this.onSubmitForm}>
          <input ref={this.inputRef} maxLength={4} value={value} onChange={this.onChangeInput} />
        </form>
        <div>시도: {tries.length}</div>
        <ul>
          {tries.map((v, i) => {
            return (
              <Try key={`${i + 1}차 시도 :`} tryInfo={v} />
            );
          })}
        </ul>
      </>
    );
  }
}

export default Baseball;

client.jsx

const React = require('react');
const ReactDom = require('react-dom');

const Baseball = require('./Baseball');

ReactDom.render(<Baseball/>, document.querySelector('#root'));

Try.jsx

const React = require('react');
const { memo } = require('react');

const Try = memo(({ tryInfo }) => {
    return (
        <li>
            <div>{tryInfo.try}</div>
            <div>{tryInfo.result}</div>
        </li>
    );
});

module.exports = Try;

6. 더 나아가기

위 코드로 코딩을 하고 나서 getNumbers 함수에 console을 찍어보면 state가 바뀌면서 리랜더링 될 때마다 getNumbers 함수가 함께 재실행되는 것을 볼 수 있다. 만약 getNumbers() 함수가 10초씩 걸리는 함수라고 가정하면 문제는 매우 커진다. 이후 6장 로또 추첨기를 구현하면서 배울 개념이지만 미리 확인해보자면, useMemo를 이용함으로써 getWinNumbers의 return 값을 기억할 수 있게 해주면 (함수의 결괏값 기억) 위와 같은 문제를 방지할 수 있다. useMemo의 경우, 두 번째 인자가 바뀌기 전까지 다시 랜더링 되지 않는다(기억만 함).

...
const { useState, useRef, useMemo } = React;
...

const Baseball = () => {
	...
	const answerNumbers = useMemo(()=> getNumbers(), []);
	const [answer, setAnswer] = useState(answerNumbers);
	...
}

실제로 위와 같이 코드를 바꾸고 나면 아래와 같이 처음 랜더링 때만 getNumbers 함수가 호출되고 그 이후에 랜더링 될 때는 getNumbers 함수가 다시 랜더링 되지 않는 모습을 볼 수 있다.

좋은 웹페이지 즐겨찾기