반응속도체크 웹게임 사이트를 제작하면서 공부한 내용 정리 & 코드에 대한 리뷰입니다
1. 간단 정리 :
> 이용 스택 : React, Node.js, MYSQL, Redux, Redux-saga, Next.js
> 주변 사람들이 쉽고 재밌게 할 수 있는 게임을 생각하다 만들기 시작
> Ant-Design와 styled-components를 이용해 빠르게 개발
> 반응속도 점수를 MYSQL로 관리해 1등부터 10등까지 확인할 수 있게끔 구현
> 실제로 친구들에게 배포해 서비스가 정상적으로 작동하는지 테스트 (위 주소를 통해 접속 가능)
2. 세부 정리 :
1. 구현하고자 하는 UI 스케치
벤치마킹 사이트 : https://simritest.com/reaction
해당 사이트는 테스트 시작 버튼을 누르면 총 5회의 반응속도 체크를 진행하고, 5회를 진행한 후에 평균 속도를 매기고 있다. 또, 정확한 로직은 모르겠지만 인간의 평균적 반응 속도인 250밀리초(0.25초)를 기준으로 '반응속도가 느리시네요', '반응속도가 빠르시네요' 등과 같은 반응속도에 대한 검사 결과를 내주고 있는 듯하다.
하지만 해당 사이트는 따로 점수를 기록할 수 있는 시스템은 제공하고 있지 않은데, 해당 사이트와 유사한 로직의 반응속도 체크 사이트를 만들고 더 나아가 이름(닉네임) 입력 기능까지 함께 구현해서 사용자들의 점수를 기록할 수 있는 기능까지 제작해봤다. 또, MYSQL db와 연결해서 1위부터 10위까지의 순위를 공개하였으며 더 나아가 서버사이드 렌더링을 적용한 뒤, 지인들에게 배포해 실제 서비스 이용이 가능할지까지 점검하였다.
<메인 페이지>
<게임이 완료된 후 메인 페이지>
UI는 위와 같은 느낌으로 만들어보고자 하였다. 위 스케치는 초기 설계를 위해 그려놨던 것으로, 제작 진행 도중 근소하게 달라진 부분들이 있다.
2. 이용한 기술 스택
최근 React와 Node.js에 대해 중점적으로 공부하고 있다. 따라서 React, Node.js, Redux, Redux-saga, Next.js, MYSQL 을 이용해 실제 운영 가능한 반응속도체크 웹 사이트를 제작해보려고 한다.
React를 비롯한 node, next, redux 등의 기술 스택들을 공부하기 시작한지 얼마되지 않은만큼 꽤나 많은 고난들이 있었다...
3. 개발 진행 플로우 정리
먼저 React, Redux, Redux-saga, Next.js를 이용해 프론트 서버를 먼저 구축해놓고, 이후에 백엔드 서버를 구축하였다. React, Redux, Redux-saga, Next.js를 이용할 때는 더미데이터를 통해 화면의 레이아웃 등을 잡는데 사용하였고, 이후에 DB를 구축한 뒤 MYSQL DB의 데이터로 대체해주었다.
back 부분과 front 부분은 위와 같이 구성했다. 아직 Redux와 Redux-saga를 공부한지 얼마되지 않았기 때문에 Reducer와 Saga를 정확하게 만드는데 집중해서 제작하였다.
4. 코드 및 로직 소개
전체 코드를 전부 소개하기에는 무리가 있기 때문에 일부 중요하다고 생각한 코드만 소개하겠다.
const onClickScreen = useCallback((e) => {
if (state === 'waiting') {
timeout.current = setTimeout(() => {
setState('now');
setMessage('지금 클릭!');
startTime.current = new Date();
}, Math.floor(Math.random() * 1000) + 2000);
setState('ready');
setMessage('초록색이 되면 클릭하세요!');
} else if (state === 'ready') { // 성급하게 클릭
clearTimeout(timeout.current);
setState('waiting');
setMessage('너무 성급하시네요 ㅡ.ㅡ 초록색이 된 후에 클릭하세요!');
} else if (state === 'now') { // 반응속도 체크
if(result.length !== 4) {
endTime.current = new Date();
setState('waiting');
// eslint-disable-next-line react/jsx-key
setMessage(['기회는 5번! 다음 화면에서 배경이 초록색이 되는 순간 클릭하세요.', <br/>, <br/>, '시작하려면 클릭해주세요.']);
setResult((prevResult) => {
return [...prevResult, endTime.current - startTime.current];
});
} else {
endTime.current = new Date();
setResult((prevResult) => {
return [...prevResult, endTime.current - startTime.current];
});
setState('finish');
setMessage('게임이 종료됐습니다. 게임을 다시하려면 아래 다시 버튼을 눌러주세요!');
e.preventDefault();
}
}
}, [state]);
const onReset = useCallback(() => {
setResult([]);
setState('waiting');
// eslint-disable-next-line react/jsx-key
setMessage(['기회는 5번! 다음 화면에서 배경이 초록색이 되는 순간 클릭하세요.', <br/>, <br/>, '시작하려면 클릭해주세요.']);
dispatch(resetRequestAction());
}, []);
JavaScript
반응속도 체크 테스트에서는 사실상 이 2가지 useCallback 함수가 다라고 해도 과언이 아니다. state를 waiting, ready, now, finish 4가지로 쪼개고, 처음 initialState를 waiting으로 둬서 게임이 진행된다. waiting 상태에서 2 ~ 3초 사이 random한 타이밍으로 state가 now로 바뀌고 이 now에서 screen을 클릭하면 result에 점수가 기록된다. 만약 state가 now로 바뀌기 전, 즉 ready 상태일 때 screen을 클릭하면 다시 상태를 waiting으로 바꿔주고 너무 성급하다는 메세지를 보내준다. 그리고 result의 length가 5가 돼서 5회차 점수까지 result 배열에 삽입이 되면 state가 finish가 되면서 게임이 종료됐다는 문구를 띄워준다.
onReset은 '다시' 버튼에 걸어준 callback 함수인데 '다시' 버튼을 누르면 result를 전부 비워버리고 state를 waiting으로 바꿔주며 resetRequestAction이 reducer를 거쳐 dispatch되면서 reset이 성공적으로 이루어졌는지 아닌지를 판별한다.
const onSubmitForm = useCallback(() => {
dispatch(addNicknameRequestAction({ nickname, score }));
alert('점수 제출이 완료됐습니다!');
alert('잠시 후 게임이 다시 시작됩니다!');
setNickname('');
let i = 4;
const id = setInterval(() => {
setMessage(`${i}초 후에 게임이 다시 시작됩니다!`);
i--
},1000);
setTimeout(() => {
clearInterval(id);
onReset();
}, 5000);
}, [nickname, score]);
JavaScript
function addNicknameAPI(data) {
return axios.post('/user', data);
}
// data: nickname & score
function* addNickname(action) {
try {
const result = yield call(addNicknameAPI, action.data);
yield put({
type: ADD_NICKNAME_SUCCESS,
data: result.data,
});
} catch (err) {
console.error(err);
yield put({
type: ADD_NICKNAME_FAILURE,
error: err.response.data,
});
}
}
JavaScript
닉네임 제출이 완료된 다음에는 addNicknameRequestAction이 dispatch 되면서 nickname과 score를 보내주고 saga에서 이를 POST /user 백엔드 서버로 nickname과 score를 보내준다. 또, 4 3 2 1초 후에 게임이 시작됩니다라는 문구를 띄우면서 onReset 콜백 함수를 실행해서 모든 정보를 Reset해준다.
router.post('/', async (req, res, next) => { // POST /user
try {
await User.create({
nickname: req.body.nickname,
score: req.body.score,
});
const user = await User.findOne({
where: { nickname: req.body.nickname },
});
return res.status(201).json(user);
} catch(error) {
console.error(error);
next(error);
}
});
JavaScript
POST /user 라우터에서는 req.body.nickname과 req.body.score로 nickname과 score를 받아주고 이를 이용해 user 테이블을 만들어준다. 그리고 만든 테이블에서 보내준 nickname과 동일한 nickname의 정보를 프론트로 보내주면서 nickname과 score과 화면에 출력된다.
import styled, { createGlobalStyle } from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export const GlobalFont = createGlobalStyle`
body {
@import url('https://fonts.googleapis.com/css2?family=Jua&display=swap');
font-family: 'Jua', sans-serif;
}
`;
export const Header = styled.div`
width: 100%;
height: 50px;
background: black;
color: gold;
display: flex;
align-items: center;
font-weight: bold;
margin-bottom: 30px;
`;
export const Style = styled.span`
position: absolute;
right: 5%;
`;
export const Wrapper = styled(FontAwesomeIcon)`
margin-left: 5%;
font-size: 20px;
`;
export const Center = styled.span`
margin-left: 10px;
font-size: 20px;
`;
export const A = styled.a`
color: gold;
`;
export const Footer = styled.footer`
width: 100%;
height: 50px;
background: black;
position: fixed;
bottom: 0;
left: 0;
`;
export const Github = styled(FontAwesomeIcon)`
font-size: 30px;
color: white;
`;
export const Instagram = styled(FontAwesomeIcon)`
font-size: 30px;
color: white;
`;
export const Facebook = styled(FontAwesomeIcon)`
font-size: 30px;
color: white;
`;
export const Ul = styled.ul`
width: 100%;
height: 50px;
list-style: none;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
z-index: -1;
`;
export const Li = styled.li`
flex: 1;
text-align: center;
`;
JavaScript
CSS의 경우에는 위와 같이 styled-components를 이용해 적용해주었다.
5. 완성 결과물
성공적으로 완성해 친구들에게 배포했고, 40개가 넘는 점수제출이 이루어졌다.
여담이지만 이렇게 1등한 친구 인터뷰도 하고 베스킨라빈스 기프티콘도 줬다는....
6. 트러블슈팅 중 기억에 남는 것들
아무래도 React, Redux, Redux-sage, Next.js를 공부한지 얼마되지 않은 상태에서 진행한 개인 프로젝트였던만큼 너무 수많은 트러블슈팅들이 있었다. 크고 작은 트러블슈팅들이 너무 많았기 때문에 전부 기록하기는 쉽지 않았고, 기억에 남는 몇몇만 적어놓도록 하겠다.
6-1. Footer 문제
데스크탑에서는 Footer를 비롯해 전체 레이아웃이 정상적으로 배치가 돼있었는데 모바일 웹으로 보면 Footer로 인해 화면이 짤리는 문제가 발생했다. 스크롤도 생기지 않아 아래 텍스트를 제대로 확인할 수 없었다.
왜 이런 문제가 발생했는지 코드에서 찾아보니 각 컴포넌트들의 부모 태그(position: relative)가 존재하지 않아서 margin-top: 110px;로 강제로 위치를 맞춰놨고, 이 때문에 아무리 아래쪽으로 padding을 주어도 Footer와의 간격이 벌어지지 않는 문제가 발생한 것이다.
<div>
<div style={{ position: 'relative', paddingBottom: '50px' }}>
{children}
</div>
<Footer>
<div>
<Ul>
<Li>
<a href="https://github.com/Taewoong1378" target="_blank" rel="noreferrer noopener">
<Github icon={faGithub} />
</a>
</Li>
<Li>
<a href="https://www.instagram.com/tae_coding/" target="_blank" rel="noreferrer noopener">
<Instagram icon={faInstagram} />
</a>
</Li>
<Li>
<a href="https://www.facebook.com/profile.php?id=100008233455158" target="_blank" rel="noreferrer noopener">
<Facebook icon={faFacebook} />
</a>
</Li>
</Ul>
</div>
</Footer>
</div>
JavaScript
따라서 Footer 위에 props로 다른 components를 받아오던 {children}에 새롭게 div 태그를 추가해주었고, 이 태그에 position: relative와 padding을 Footer의 height만큼 줌으로써 문제를 해결하였다.
6-2. onClick VS onMouseDown
첫 배포 전에 아는 친한 형에게 먼저 사이트를 보여주고 원활하게 진행이 된다고 느껴지는지 물어보았다. 형이 몇 번 게임을 해보더니 마우스를 미리 눌러놓고 있다가 떼면 기록이 체크가 되며 이것이 마우스를 그냥 눌렀다 떼는 것보다 기록이 훨씬 좋게 나온다고 하였다. 일종의 '버그'였던 것이다.
그래서 코드를 수정하였다. 기존의 onClick 이벤트가 발생하면 state가 변하도록 하던 코드에서 onMouseDown 이벤트가 발생하면 state가 변하도록 하는 코드로 코드를 변경하였다. 그렇게 코드를 바꾸고 나니 마우스를 클릭할 때에만 state가 변경되고 마우스를 미리 눌러놨다가 떼는 행위를 할 떼에는 state가 변경되지 않았다.
6-3. 배포 문제
배포 중에는 매번 계속해서 에러들이 발생했다. 한 예로 아래와 같은 에러가 발생했는데 대부분은 항상 window만 사용했다보니 Ubuntu 사용이 익숙치 않아 여러 모듈 설치 중에 발생한 에러였다.
이번 기회에 Ubuntu 기준 배포 코드만 수 백번 친 것 같아 앞으로는 보다 빠르고 에러 없는 배포가 가능하지 않을까 생각한다. 'sudo su'를 꼭 치는 습관을 가지도록 하자. 권한이 없어서 발생한 에러가 너무 많았다...
6-4. favicon.ico 문제
분명 public 폴더에 favicon.ico를 넣어놨고, 프론트 서버에서 이를 반영하도록 설정을 해놨는데 아무리 새로고침을 하여도 localhost에서 favicon.ico가 반영되지 않았다. network 상에서는 favicon.ico를 받아왔는데 말이다. 이때 알게된 것이 '강력 새로고침'이다. ctrl + shift + r 을 통해 강력 새로고침을 할 수 있었고 강력 새로고침 후에는 favicon.ico가 정상적으로 반영되는 것을 볼 수 있었다.
7. 추가적으로 해결해야할 문제나 구현하고 싶은 기능
7-1. 5회차 점수가 보이지 않는 문제
지금 사이트에서 게임을 해보면 5회차를 보여주지 않고 바로 평균 점수로 넘어가는 문제가 발생하고 있다. 이 문제를 해결하기 위해 setTimeout로 HTML을 1초 후에 return하는 방법을 시도해봤으나 setTimeout을 HTML에 적용하는 것은 불가능했다.
그렇다면 추가적으로 생각할 수 있는 방법이 뭐가 있을까? 현재 result의 length가 5일 때 평균 점수를 보여주도록 설정돼있기 때문에 2가지 방법을 생각해보았다.
첫 번째로 생각해본 방법은 result의 length가 5가 되기 전에 5회차 점수를 보여주고, 그 다음에 result에 5회차 점수를 넣어주는 것이다. 즉, setResult를 1초 후에 실행되도록 한다는 것인데, 이렇게 되면 문제가 <Li key="fifth">5회차 : {result[4] ? `${result[4]}ms` : null}</Li>와 같이 5회차 점수를 프론트에 보여주는 것이 불가능하다. result 배열에 5회차 점수를 넣지 않고 프론트에 반환하는 방법을 아직 정확히 모르겠다.
두 번째로 생각해본 방법은 result.length === 0 ? ... : result.length === 5 ? ... : ... 로 구성돼있는 삼항연산자의 result.length를 state 값으로 바꿔주는 것이다. result가 아닌 state를 사용하기 때문에 result에 5회차 점수를 그대로 넣어줄 수 있고, 프론트에 5회차 점수를 반환할 수 있다. 따라서 setState('finish')를 setTimeout을 이용해 1초 후에 실행해주고, state === 'now' ? ... : state === 'finish' ? ... : ... 이런식으로 코드를 바꾸는 것이다. 그런데 이 방법에도 문제가 발생했다. 분명 setTimeout으로 1초 후에 state를 'finish'로 바꾸라고 했음에도 불구하고 콘솔에 console.log(state)로 상태를 찍어보면 'now'가 출력된다. 이유를 정확히는 모르겠지만 setTimeout 안에서 state를 변경했을 때 반영이 되질 않았다.
따라서 매우 단순해보이는 문제임에도 불구하고 아직까지 명확한 해결법을 찾지 못했다. React와 콜백함수들의 기본적인 성질들에 대해 많이 까먹은게 문제인 것 같아 이후에 추가적으로 복습을 한 뒤에 코드를 수정해볼 예정이다.
...
} else if (state === 'now') { // 반응속도 체크
if(result.length !== 4) {
endTime.current = new Date();
setState('waiting');
// eslint-disable-next-line react/jsx-key
setMessage(['기회는 5번! 다음 화면에서 배경이 초록색이 되는 순간 클릭하세요.', <br/>, <br/>, '시작하려면 클릭해주세요.']);
setResult((prevResult) => {
return [...prevResult, endTime.current - startTime.current];
});
} else {
endTime.current = new Date();
setResult((prevResult) => {
return [...prevResult, endTime.current - startTime.current];
});
setState('finish');
setMessage('게임이 종료됐습니다. 게임을 다시하려면 아래 다시 버튼을 눌러주세요!');
e.preventDefault();
}
}
...
return result.length === 0
?
<Link href="/record">
<a><div style={{ textAlign: 'center'}}><MainButton type="primary">다른 사람 점수 보러가기</MainButton></div></a>
</Link>
: result.length === 5
?
<>
<FirstUl style={{ position: 'relative' }}>
<Li>
평균 : {score}ms
</Li>
<Li>
{score > 250
?
'연습을 더 하셔야겠네요!'
:
score > 220
?
'이 정도면 전체 평균 정도 수준이네요!'
:
'정말 빠른 반응 속도를 가지고 있네요!'}
</Li>
</FirstUl>
<Div>
<ButtonWrapper type="primary" onClick={onReset}>다시!</ButtonWrapper>
<Link href="/record">
<a><ButtonWrapper type="primary">다른 사람 점수 보러가기</ButtonWrapper></a>
</Link>
</Div>
<UnderButton setMessage={setMessage} onReset={onReset} score={score} />
</>
:
<>
<SecondUl>
<Li key="first">1회차 : {result[0] ? `${result[0]}ms` : null}</Li>
<Li key="second">2회차 : {result[1] ? `${result[1]}ms` : null}</Li>
<Li key="third">3회차 : {result[2] ? `${result[2]}ms` : null}</Li>
<Li key="fourth">4회차 : {result[3] ? `${result[3]}ms` : null}</Li>
<Li key="fifth">5회차 : {result[4] ? `${result[4]}ms` : null}</Li>
<Li key="sixth"><UnderResetButton onClick={onReset} type="primary">다시!</UnderResetButton></Li>
</SecondUl>
</>
}, [result]);
JavaScript
7-2. n번 실수를 범하면 실격 처리되는 기능
현재까지 진행한 반응속도체크 사이트의 1등부터 10등까지를 보면 반응속도가 100ms를 넘질 않는다. 이게 얼마나 빠른거냐면 프로게이머의 평균 반응속도가 150을 넘어선다. 1등 상품으로 베스킨라빈스 파인트 아이스크림을 걸었더니 다들 죽어라 달려들었다. 아무튼 1등인 24ms는 인간의 반응속도가 아니라 랜덤으로 설정돼있는 타이밍을 찍어 맞춰서 나온 수치라는 것이다.
물론 24ms는 성공하려면 굉장한 집착을 가져야하기 때문에 플레이해준 친구에게 매우 고맙다. 하지만 공정하게 반응속도를 체크하려면 일정 횟수 이상 실패했을 때 점수를 초기화해버리는 기능을 넣어야겠다는 생각이 들었다.
현재는 만약 초록색 화면이 나오기 전에 화면을 누를 경우, 위와 같은 스크린이 출력되고 다시 클릭하면 기존의 점수가 지워지는 것이 아니라 이전 점수에 이어서 계속 게임을 진행할 수 있다. 즉, 집착을 가지고 좋은 점수가 나올 때까지 무한정 시도할 수 있다는 것이다. 당장은 취준에 집중해야하기 때문에 업데이트할 생각은 없지만 이후에 시간적 여유가 생긴다면 수정할 예정이다.
7-3. 글씨체가 바로 반영이 되지 않는 문제
현재 사이트에 접속해보면 0.3초 정도 후에 전체 글씨체가 적용되는 것을 볼 수 있다. 아마 styled-components를 이용해 글씨체를 적용했기 때문에 발생하는 문제같다. 아직까지 정확한 해결방법은 모르겠다. 만약 styled-components에서 즉각적으로 글씨체를 반영하는 것이 불가능하다면, 글씨체 반영과 관련된 action을 만들고 이 action이 완료되기 전까지는 loading 화면을 보여주는 방법 정도가 현재까지는 생각나는 방법이다.