리액트를 이용해서 휴식 타이머 만들기

서론

필자는 취미가 하나 있다.
그것은 헬스

필자는 휴식시간을 재고 끝나면 알림해주는 그런 타이머를 찾아봤지만
필자를 만족하는 타이머를 찾지 못해서 그냥 만들기로 했다.


설계

필자는 오랜만에 리액트를 써보고 싶었으므로 프론트는 리액트로 정한다.
또 타입스크립트가 흥하니까 한번 써봐야되므로 타입스크립트도 곁들인다.
CSS는 간단하게 부트스트랩을 이용한다.

사용한 것

환경설정

지금껏 Create React App (이하 CRA)을 써서 편하게 해왔지만
언제까지고 CRA를 쓸 수 만은 없을 것이다.

따라서 yarn명령을 이용해서 직접 리액트와 바벨, 웹팩을 설치해서 진행해보겠다.

yarn init -y
yarn add react react-dom bootstrap @types/react @types/react-dom
yarn add -D @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript babel-loader css-loader html-webpack-plugin style-loader ts-loader typescript webpack webpack-cli webpack-dev-server

그 후 필자는 다음과 같은 프로젝트 구조를 만들었다.

dist는 빌드하면 나올 파일들을 저장하는 공간
public에는 index.html
src에는 작성할 애플리케이션 파일들을 둘 것이다.

환경설정을 위해 루트 디렉토리에 .babelrc, tsconfig.json, webpack.config.js는 만들도록 한다.

tsconfig.json파일은 yarn tsc --init을 통해 작성할 수도 있다.

먼저 .babelrc를 설정하겠다

{
  "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
}

다음으로 tsconfig.json을 설정하겠다.

{
  "compilerOptions": {
    "target": "es2016",
    "lib": [
      "DOM",
      "DOM.Iterable",
      "ES2016"
    ],
    "jsx": "react",
    "module": "commonjs",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "allowJs": true,
    "outDir": "./dist",
    "isolatedModules": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitAny": true,
    "skipLibCheck": true
  },
  "include": [
    "./src"
  ],
  "exclude": [
    "./dist"
  ]
}

다음으로 webpack.config.js를 설정하겠다.

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const mode = process.env.NODE_ENV || 'development';

module.exports = {
  mode,
  devServer: {
    historyApiFallback: true,
    host: 'localhost',
    port: 3000,
    hot: true,
  },
  entry: {
    app: path.join(__dirname,  'index.tsx'),
  },
 
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx'],
  },

  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: ['babel-loader', 'ts-loader'],
      },
      {
        test:/\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },

	output: {
    path: path.join(__dirname, '/dist'),
    filename: 'bundle.js',
  },

  plugins: [
		new webpack.ProvidePlugin({
      React: 'react',
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
    new webpack.HotModuleReplacementPlugin(),
  ],
};

마지막으로 package.jsonscripts 구문을 추가하여 마무리하겠다.

{
  ...
  "scripts": {
    "dev": "webpack server --mode development --open --hot",
    "build": "webpack --mode production",
    "start": "webpack --mode development"
  },
  ...
}

자세한 설명을 해주고 싶은 마음은 굴뚝같지만 필자도 공부해야한다.
정말 미안하다.

작성하는데 참고했던 사이트 링크만이라도 올리겠다.

프로젝트 초기화

먼저 public/index.html파일을 만들고 아래와 같이 작성하겠다.

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Toyproject-Timer</title>
</head>

<body>
  <div id="app"></div>
</body>

</html>

다음으로 루트 디렉토리에 index.tsx파일을 만들고 아래와 같이 작성하겠다.

import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import 'bootstrap/dist/css/bootstrap.css';

const container = document.getElementById('app');
const root = createRoot(container!); // createRoot(container!) if you use TypeScript
root.render(<App />);

마지막으로 src/App.tsx파일을 만들고 아래와 같이 작성하겠다.

import React from 'react';

const App = () => {
  return (
    <div>
      초기화
    </div>
  );
};

export default App;

작성을 완료했으면 아래 명령어를 실행하여 devServer를 구동해본다

yarn dev

이러한 모습이 나온다면 잘 설정이 완료된 것이다!


프로토타입 작성

필자의 프로젝트 구조는 다음과 같다.

App.tsx
|-- TimerTemplate.tsx
|-- CounterTemplate.tsx

App.tsx에서 전체를 감싸고 데이터를 관리하도록 한다.
그 후 하위 컴포넌트에 전달하여 기능을 수행하도록 한다.

카운터 구현

먼저 카운터 기능부터 구현해보도록 하겠다.
이 카운터는 세트 수가 될 예정이므로 0 이하의 숫자는 입력되지 않도록 하겠다.

먼저 App.tsx파일을 수정해보도록 하겠다.

import React, { useState } from 'react';
import CounterTemplate from './components/CounterTemplate';

const App = () => {
  const [number, setNumber] = useState(1);

  return (
    <div>
      <CounterTemplate number={number} setNumber={setNumber}/>
    </div>
  );
};

export default App;

App.tsx에서 모든 데이터를 관리할 생각이므로 useState 함수를 통해 데이터를 생성해준다.
그리고 이를 다루는 작업은 CounterTemplate에 맡기도록 하겠다.

이제 CounterTemplate.tsx를 작성해보도록 하겠다.

import React from 'react';

type Props = {
  number: number;
  setNumber: Function;
};

const Counter = ({ number, setNumber }: Props) => {
  const handleIncrease = () => {
    if (typeof number === 'string') return;
    setNumber(number + 1);
  };

  const handleDecrease = () => {
    if (typeof number === 'string') return;
    if (number <= 1) return;
    setNumber(number - 1);
  };

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    if (value === '') {
      setNumber('');
      return;
    }

    const number = parseInt(value);
    if (number <= 0) return;
    setNumber(number);
  };


    return (
      <div>
        <h1>세트 수</h1>
        <form>
        <input
          name="number"
          type="number"
          inputMode="numeric"
          pattern="\d+"
          value={number}
          onChange={onChange}
        />
        </form>
        <div>
          <button onClick={handleIncrease}>+</button>
          <button onClick={handleDecrease}>-</button>
        </div>
      </div>
    );
};

export default Counter;

카운터는 numbersetNumber함수를 받아 데이터를 조작한다.
카운터의 숫자를 <input>태그로 관리하기에 사용자가 지우고 입력할 수 있도록
기본적으론 숫자만 허용하지만 예외로 빈 문자열을 허용하도록 한다.

이렇게 기본적인 카운터가 완성되었다.

타이머 구현

타이머는 구현된 오픈소스를 가져와서 만들어보도록 하겠다.
react-timer-hook

아래의 명령어를 입력해 프로젝트에 추가해준다.

yarn add react-timer-hook

먼저 App.tsx파일을 수정하도록 하겠다

import React, { useState } from 'react';
import CounterTemplate from './components/CounterTemplate';
import TimerTemplate from './components/TimerTemplate';

const App = () => {
  const [number, setNumber] = useState(1);
  const [second, setSecond] = useState(5);

  const onExpire = () => {
    if (number > 0) {
      setNumber(number - 1);
    }
  };

  return (
    <div>
      <CounterTemplate number={number} setNumber={setNumber}/>
      <TimerTemplate second={second} setSecond={setSecond} onExpire={onExpire}/>
    </div>
  );
};

export default App;

useState를 통해 시간(초)을 설정해주고 이를 TimerTemplate에게 넘기도록 하겠다.
onExpire함수는 타이머가 종료됐을 때 실행되는 함수이다.
여기선 세트 수인 number를 줄이도록 하겠다.

이제 TimerTemplate.tsx를 작성해보도록 하겠다.

import React from 'react';
import { useTimer } from 'react-timer-hook';

type Props = {
  second: number;
  setSecond: Function;
  onExpire: () => void;
};

const Timer = ({ second, setSecond, onExpire }: Props) => {
  const formattingNumber = (num: number) => {
    return (num).toLocaleString('en-US', {minimumIntegerDigits: 2, useGrouping:false})
  };

  const expiryTimestamp = new Date();
  expiryTimestamp.setSeconds(expiryTimestamp.getSeconds() + second);

  const { seconds, minutes, isRunning, start, pause, resume, restart } =
    useTimer({ expiryTimestamp, onExpire: onExpire, autoStart: false });

  return (
    <div>
      <h1>휴식 타이머</h1>
      <div>
        <span>{formattingNumber(minutes)}</span>:<span>{formattingNumber(seconds)}</span>
      </div>
      <p>{isRunning ? '휴식 중' : '운동 중'}</p>
      <div>
        <button
          onClick={() => {
            const time = new Date();
            time.setSeconds(time.getSeconds() + second);
            restart(time, true);
          }}>
          휴식 시작
        </button>
        <button onClick={pause}>휴식 일시 정지</button>
        <button onClick={resume}>휴식 재개</button>
      </div>
    </div>
  );
};

export default Timer;

formattingNumber는 시간, 초를 두자리 수로 표현하도록 포맷팅하는 함수이다.
나머지는 react-timer-hook의 문서를 읽고 가볍게 작성했다.

이와 같은 화면이 나오고 타이머를 돌리고 만료됐을 때 세트 수가 줄어들었으면 정상적이다.


프로젝트 시작

필자는 헬스 타이머의 초기화 화면을 먼저 보이고 사용자가 이를 세팅한 다음 운동을 시작하면 화면이 전환되고 휴식 타이머를 볼 수 있게끔 할 것이다.

  1. 세트 수, 타이머 세팅
  2. 운동 시작하면 타이머 화면으로 전환
  3. 세트 수가 0이 될 때까지 화면 유지
  4. 세트 수가 0이 됐다면 다시 초기화면으로..

App.tsx

이를 위해 먼저 App.tsx를 수정하도록 하겠다.

import React, { useState, useEffect } from 'react';
import TimerTemplate from './components/TimerTemplate';
import CounterTemplate from './components/CounterTemplate';

const App = () => {
  const [number, setNumber] = useState(3);
  const [second, setSecond] = useState(30);
  const [initialize, setInitialize] = useState(true);

  const go = () => {
    if (typeof number === 'string') return;
    if (number <= 0) return;
    setInitialize(false);
  };
  const stop = () => {
    setInitialize(true);
  };

  const onExpire = () => {
    if (number > 0) {
      setNumber(number - 1);
    }
  };

  useEffect(() => {
    if (number === 0) {
      setInitialize(true);
    }
  }, [number]);

  return (
    <div>
      <CounterTemplate
        init={initialize}
        number={number}
        setNumber={setNumber}
      />
      <TimerTemplate
        init={initialize}
        second={second}
        setSecond={setSecond}
        onExpire={onExpire}
      />
      <div>
        {initialize === true ? (
          <button onClick={go}>운동 시작</button>
        ) : (
          <button onClick={stop}>운동 중지</button>
        )}
      </div>
    </div>
  );
};

export default App;

새롭게 추가된 것이 좀 많은데 차근차근 살펴보겠다.

먼저 initializeuseState로 만들어 현재 화면이 초기 설정 화면인가 아닌가를 판별한다.
이를 각 하위 컴포넌트에 넘겨줘서 if로 분기 렌더링을 할 것이다.

이를 설정하기위한 go, stop함수는 입력받은 초기값들을 검증하고 운동을 시작하거나 멈추는 기능을 수행한다.

useEffect함수는 세트 수가 0이 되었을때 다시 초기화면으로 돌려주는 역할을 한다.

카운터

다음은 CounterTemplate.tsx를 수정하도록 하겠다.

import React from 'react';

type Props = {
  init: boolean;
  number: number;
  setNumber: Function;
};

const Counter = ({ init, number, setNumber }: Props) => {
  const handleIncrease = () => {
    if (typeof number === 'string') return;
    setNumber(number + 1);
  };

  const handleDecrease = () => {
    if (typeof number === 'string') return;
    if (number <= 1) return;
    setNumber(number - 1);
  };

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    if (value === '') {
      setNumber('');
      return;
    }

    const number = parseInt(value);
    if (number <= 0) return;
    setNumber(number);
  };

  if (init === true) {
    return (
      <div>
        <h1>세트 수</h1>
        <input
          name="number"
          type="number"
          inputMode="numeric"
          pattern="\d*"
          value={number}
          onChange={onChange}
        />
        <div>
          <button onClick={handleIncrease}>+</button>
          <button onClick={handleDecrease}>-</button>
        </div>
      </div>
    );
  }
  return (
    <div>
      <h1>남은 세트 수</h1>
      <p>{number}</p>
    </div>
  );
};

export default Counter;

init변수를 통해 초기화면인지 운동화면인지 나눠서 렌더링한다.

타이머

마지막으로 TimerTemplate.tsx를 수정하도록 하겠다.

import React from 'react';
import { useTimer } from 'react-timer-hook';

type Props = {
  init: boolean;
  second: number;
  setSecond: Function;
  onExpire: () => void;
};

const Timer = ({ init, second, setSecond, onExpire }: Props) => {
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    if (value === '') {
      setSecond('');
      return;
    }

    const number = parseInt(value);
    if (number <= 0) return;
    setSecond(number);
  };

  const formattingNumber = (num: number) => {
    return (num).toLocaleString('en-US', {minimumIntegerDigits: 2, useGrouping:false})
  };

  if (init === true) {
    return (
      <div>
        <h1>휴식 타이머 (초)</h1>
        <input
          name="second"
          type="number"
          inputMode="numeric"
          pattern="\d*"
          value={second}
          onChange={onChange}
        />
      </div>
    );
  }

  const expiryTimestamp = new Date();
  expiryTimestamp.setSeconds(expiryTimestamp.getSeconds() + second);

  const { seconds, minutes, isRunning, start, pause, resume, restart } =
    useTimer({ expiryTimestamp, onExpire: onExpire, autoStart: false });

  return (
    <div>
      <h1>휴식 타이머</h1>
      <div>
        <span>{formattingNumber(minutes)}</span>:<span>{formattingNumber(seconds)}</span>
      </div>
      <p>{isRunning ? '휴식 중' : '운동 중'}</p>
      <div>
        <button
          onClick={() => {
            const time = new Date();
            time.setSeconds(time.getSeconds() + second);
            restart(time, true);
          }}>
          휴식 시작
        </button>
        <button onClick={pause}>휴식 일시 정지</button>
        <button onClick={resume}>휴식 재개</button>
      </div>
    </div>
  );
};

export default Timer;

입력받는 폼은 카운터와 마찬가지 숫자만 입력받게 했다.
입력받는 폼만 추가하고 나머지는 같다.

결과물은 다음과 같다.


CSS 입히기

기능 구현이 얼추 되었으니 이제 옷입을 시간이다.
간단하게 bootstrap을 사용한다.

스타일을 어떻게 적용했는지는 서술하지 않겠다.
단지 bootstrap을 어떻게 적용하는지 서술하겠다.

먼저 bootstrap을 설치하고

yarn add bootstrap

루트 폴더에 있는 index.tsx에 bootstrap css파일을 import해야한다.

import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './src/App';
import 'bootstrap/dist/css/bootstrap.css';

const container = document.getElementById('app');
const root = createRoot(container!); // createRoot(container!) if you use TypeScript
root.render(<App />);

엘리먼트에 className을 사용해서 적절히 클래스를 부여한다

return (
  <div className="container">
    <CounterTemplate
      init={initialize}
      number={number}
      setNumber={setNumber}
    />
    <TimerTemplate
      init={initialize}
      second={second}
      setSecond={setSecond}
      onExpire={onExpire}
    />
    <div className="trigger-wrapper">
      {initialize === true ? (
        <button className="btn btn-primary" onClick={go}>운동 시작</button>
      ) : (
        <button className="btn btn-secondary" onClick={stop}>운동 중지</button>
      )}
    </div>
  </div>
);

결과물

결과물을 빌드하고 싶다면 아래 명령어를 입력하자.

yarn build

dist 폴더에 빌드 완료한 것이 생길 것이다.

다음은 실제 결과물이다.
테스트 기기는 데스크탑, 아이패드, 안드로이드 폰이다.

운동이 끝났다는 걸 시작적으로 알리기 위해 body의 색을 깜박이는 기능을 추가했다.
내가 사용하는 모바일 기기에도 잘 돌아가고 급한대로 쓸만하게 만들어서 만족한다.

전체 소스는 여기에 있다.
Toyproject-Timer

보완해야할 기능이 몇가지 있는데

이 정도 있는 것 같다.