프로그래밍 공부하기

TypeScript / Project Timer 본문

Project/Practice

TypeScript / Project Timer

ihl 2021. 4. 5. 01:17

  파이널 프로젝트의 SR 작업의 결과 프로젝트에서 가장 핵심적인 부분이 Timer와 SocketIO를 이용한 실시간 통신이라는 결론을 얻게 되었다. 따라서 타입스크립트를 2일간 공부하면서 해당 부분을 간단하게 구현해 보기로 하였다.

 

1. TypeScript, React, Redux Setting

-추후 포스팅하거나 생략

폴더구조

2. Timer

const convertSecToHourString = (targetSec: number): string => {
  const hour = Math.ceil(targetSec / 3600);
  const min = Math.ceil(targetSec%3600/60);
  const sec = Math.ceil(targetSec%3600%60);
  
  return `${hour}:${min}:${sec}`;
}

const getRestTime = (endtime: Date) : string => {
  const today = new Date();
  const dateDiff = Math.ceil((endtime.getTime()-today.getTime())/ 1000);

  return convertSecToHourString(dateDiff);
}

  Timer 컴포넌트는 Date타입을 받아 hour:min:sec 형태로 변환시켜서 출력해야한다. Date의 차이를 계산하여 hour:min:sec형태로 변환시키는 로직은 위와 같다.

 

3. Redux with TypeScript

이미지

  날짜가 고정된 타이머가 아니라 사용자가 종료일을 지정하면 해당 날짜의 12:00까지를 종료시간으로 잡고 그 때까지의 시간을 타이머로 보여주고 싶었다. 또한 날짜 변경에 따라 배경이미지도 다르게 주고 싶었다. 이를 위해선 종료일이라는 변수를 전역으로 갖고 변화가 있으면 관련 컴포넌트들이 변화에 따른 처리가 수행되어야 했다. 따라서 redux에 endtime이라는 전역상태를 담기로 결정하였다.

 

1) time class

export class Time {
  private timeStr: string = '2021-04-26'; //input date를 통해 사용자가 변경시킬 수 있는 부분

  constructor(timeStr: any) {
    this.timeStr = timeStr || '2021-04-26'; //초기값
  }

  getTimeStr = () : string => {
    return this.timeStr;
  }

  getDateObject = () : Date => {
    return new Date(this.timeStr+"T12:00:00"); //날짜를 정하면 12시 기준
  }

  setTimeStr = (timeStr: string) :void => {
    this.timeStr = timeStr;
  }
}

  타이머는 Date의 차이를 Date객체로 계산하지만 <input type=date/> 의 value는 string이다. 따라서 각 컴포넌트에 따라 같은 시간을 각각이 원하는 타입으로 사용할 수 있도록 Time이라는 클래스를 작성하였다. Time이라는 클래스는 기본적으로 string 값(Calendar)을 갖고 있지만 getDateObject()라는 함수를 통해 Date타입으로 변환된 값(Timer)을 얻을 수 있다.

 

2) redux action, reducer and store

//src/actions/index.tsx
import { Time } from "../reducers/initialState";
export const CHANGE_ENDTIME = "CHANGE_ENDTIME" as const;

export interface action {
  type: string,
  payload: object
}

export const changeEndTime = (time: Time) : action => {
  return {
    type: CHANGE_ENDTIME,
    payload: time
  }
}

  redux를 사용하기 위해 먼저 action을 정의하였다. action은 type과 payload라는 속성을 가지는 객체인데 이를 action 인터페이스를 따로 정의하였고, action.type은 보통 string인데 as const를 붙임으로써 string타입이 아니라 "CHANGE_ENDTIME"으로 추론될 수 있게 만들었다.

 

//src/reducers/initialState.tsx
export const initialState =
{
  "endtime": new Time('2021-04-26')
};

  redux로 저장할 endtime의 초기 값이다.

 

//src/reducers/timeReducer.tsx
const timeReducer = (state = initialState, action: action) => {
  switch(action.type) {
    case CHANGE_ENDTIME:
      return {
        "endtime": action.payload
      };
    default:
      return state;
  }
}

  timeReducer는 initialState와 action을 이용하여 위와같이 만들어 주었다. reducer는 여러개일 수도 있으므로 reducers 폴더에 index.tsx를 따로 만들어 combineReducers해주는 과정도 있었다.

 

//src/store/store.tsx
import {createStore } from "redux";
import rootReducer from '../reducers/index';

const store = createStore(rootReducer);

export default store;

  store는 javascript와 동일하게 작성하며, 적용할 때도 똑같이 Provider를 이용하여 적용하면 된다.

 

3) dispatch and useSelector

const Calendar: React.FC = () => {
  const state = useSelector((state:RootState) => state.timeReducer);
  const {endtime} = state;
  const dispatch = useDispatch();

  const calendarHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
    dispatch(changeEndTime(new Time(e.target.value)));
  }
  return (
    <input type="date" id="end" name="project-end" value={endtime["timeStr"]} onChange={calendarHandler}/>
  )
}

  endtime을 변경할 수 있는 유일한 컴포넌트인 Calendar는 dispatch를 통해 endtime 값을 변경할 수 있어야 한다. 

 

const Timer: React.FC = () => {
  const state = useSelector((state:RootStateOrAny) => state.timeReducer);
  const {endtime} = state;
  const [restTime, setRestTime] = useState(getRestTime(endtime.getDateObject()));

  useEffect(() => {
    const countdown = setInterval(() => {
      setRestTime(getRestTime(endtime.getDateObject()));
    }, 1000);
    return () => clearInterval(countdown);
  }, [restTime, endtime]);

  return <div className="timer">{restTime}</div>
}

  변경된 endtime에 따라 타이머의 남은 시간이 달라지므로 Timer 컴포넌트는 useSelector를 통해 timeReducer의 리턴 값을 지속적으로 조회하고 있어야 한다.

 

const App: React.FC = () => {
  const state = useSelector((state:RootStateOrAny) => state.timeReducer);
  const {endtime} = state;

  useEffect(() => {
    const app = document.querySelector('.App');
    const month = endtime.getDateObject().getMonth();
    const seasons = ['spring', 'summer', 'autumn', 'winter'];
    app?.classList.remove(...seasons);

    switch(month) {
      case 11: //12월
      case 0: //1월
      case 1: //2월
        app?.classList.add("winter");
        break;
      case 2:
      case 3:
      case 4:
        app?.classList.add("spring");
        break;
      case 5:
      case 6:
      case 7:
        app?.classList.add("summer");
        break;
      case 8:
      case 9:
      case 10:
        app?.classList.add("autumn");
        break;
    }
  }, [endtime]);

  return (
    <div className="App">
      <Container></Container>
    </div>
  );
}

  endtime 변화에 따라 App 컴포넌트는 배경 이미지가 변경되어야 한다. 따라서 useSelector로 endtime을 계속 조회하고 있다가 변경되면 App 컴포넌트의 클래스를 변경함으로써 다른 css(배경이미지)가 적용되도록 구현하였다.

 

4. SocketIO

이미지

  타이머에서 여러명이 있는 아이콘을 누르면 프로젝트 참가, 한 명이 있는 아이콘을 누르면 탈퇴하는 기능을 만들어 실시간으로 접속한 클라이언트 모두에게 보여주고 싶었다. 이를 위해서 socket.io를 사용하였다.

 

1) 이벤트

socket.io 이벤트 흐름

  socket.io를 통해 위와 같은 이벤트 및 데이터 흐름이 발생하도록 계획을 세웠다.

  • 클라이언트가 접속하면 서버가 같고 있는 member의 수를 member라는 이벤트로 보내준다.
  • 클라이언트가 여러 명이 있는 아이콘을 누르면 join 이벤트가 서버에 전달되고, 서버에 연결된 모든 클라이언트에게 새로운 멤버수가 join이라는 이벤트로 전달된다.
  • 클라이언트가 한 명이 있는 아이콘을 누르면 leave 이벤트가 서버에 전달되고, 서버에 연결된 모든 클라이언트에게 새로운 멤버 수가 leave라는 이벤트로 전달된다.

2) server

let member = 0;

io.on('connection', function(socket) {
  //연결된 클라이언트에게 바로 현재 멤버수 전달
  socket.emit('member', `${member} 명`);

  socket.on('join', (data) => {
    console.log('received: "' + data + '" from client' + socket.id);
    member++;
    io.emit('join', `${member} 명`); //모든 클라이언트에게 전달
  });

  socket.on('leave', (data) => {
    console.log('received: "' + data + '" from client' + socket.id);
    member--;
    io.emit('leave', `${member} 명`); //모든 클라이언트에게 전달
  });

  socket.on('disconnect', () => {
    console.log('disconnected from ', socket.id);
  });
  socket.on('disconnect', () => { console.log("disconnect!") });
});

  server의 socket.io 이벤트 부분은 위와 같다. 위의 이벤트 흐름에서 서버측 부분을 그대로 구현하였다.

 

3) Client - singleton socket

import io from 'socket.io-client'

//socket은 객체리터럴로 만들고 필요한 컴포넌트마다 import한다.
const socket = io("http://localhost:4000", { transports: ['websocket'] });
export default socket; 

  client에서 socket.io를 구현하기 위해 먼저 socket.io-client를 설치했다. typescript를 위한 @types/socket.io-client도 함께 설치했다.

 

  클라이언트의 socket 인스턴스 구현 시 하위 컴포넌트에 socket 인스턴스가 있다면 하위 혹은 상위 컴포넌트가 렌더링될 때마다 socket 인스턴스가 새로 생성되고 서버에 새로운 클라이언트가 추가된다는 점을 고려해야한다. 즉, 하나의 클라이언트에게는 하나의 socket 인스턴스만 있어야한다는 것인데 이를 보고 singleton 패턴이 생각났다. singleton 패턴은 전체 시스템에서 하나의 인스턴스만 존재하도록 보장하는 객체 생성패턴이다. 

 

  JavaScript에선 객체 리터럴 만으로 singleton패턴이라 할 수 있다. 물론 private 상태가 함수를 정의하기 위해선 클로저 개념이 추가되어야 한다. 나의 경우 추가적인 상태나 함수가 필요 없기 때문에 객체리터럴로 지정하였다.

 

추가: stackoverflow.com/questions/47964655/js-socketio-singleton

  객체 리터럴로 구현하고 모듈화하여 singleton으로 적용하였다 생각했는데 꼭 그렇지만은 않은가보다. 여러 페이지인 경우 객체 리터럴로 일단 해보고 여러 소켓 인스턴스가 생긴다면 위의 링크의 코드를 이용해보자.

 

4) Client - send socket event

import socket from '../modules/socket'

const JoinButton : React.FC = () => {
  const [isJoin, setIsJoin] = useState(false);
  const handleClick = () : void => {
    if(isJoin) {
      setIsJoin(false);
      socket.emit('leave', 'ihl');
    } else {
      setIsJoin(true);
      socket.emit('join', 'ihl');
    }
  }

  return (
    <button className="join-button" onClick={handleClick}>
      {isJoin === false? 
      <RiTeamFill className="join" size="36"/>:
      <RiUser3Fill className="join" size="36"/>}
    </button>
  )
}

  JoinButton은 클라이언트가 프로젝트에 참가했는지 여부를 state로 갖고 있고, 버튼을 누르면 해당 상태에 따라 join 혹은 leave 메시지를 보낸다. 이 부분은 위와 같이 socket 모듈을 import하여 socket.emit 메소드를 이용하면 된다.

 

5) Client - receive socket event

import socket from '../modules/socket'

const Member : React.FC = () => {
  //socketio로 join 등의 메시지를 받으면 해당 내용으로 변경한다.
  socket.on('member', (data: string) => {
    console.log("data: ", data);
    const memberDiv = document.querySelector('.member-count') as HTMLImageElement;
    memberDiv.textContent = data;
  });

  socket.on('join', (data: string) => {
    console.log("data: ", data);
    const memberDiv = document.querySelector('.member-count') as HTMLImageElement;
    memberDiv.textContent = data;
  });

  socket.on('leave', (data: string) => {
    console.log("data: ", data);
    const memberDiv = document.querySelector('.member-count') as HTMLImageElement;
    memberDiv.textContent = data;
  })

  return (
    <div className="member-container">
      <div className="member-count"></div>
      <JoinButton/>
    </div>
  )
}

  Member 컴포넌트는 프로젝트 참가인원과 JoinButton을 포함하는 컴포넌트이다. Member 컴포넌트에서도 마찬가지로 socket 모듈을 import한 후 socket.on을 통해 이벤트를 수신하고 querySelector를 이용하여 인원 수 표시를 변경해준다.

 

'Project > Practice' 카테고리의 다른 글

GraphQL / Tour Sight Search  (0) 2021.02.21
socket.io / 실시간 채팅  (0) 2021.02.09
JSAnimation / BrawlStars GunFight!  (0) 2021.01.18
DOM / Twittller  (0) 2020.12.30
JS / 계산기  (0) 2020.12.17
Comments