[VanilaJS] Web Speech API를 활용한 음성인식 게임 개발 회고

@itsmo · July 03, 2023 · 32 min read

2023년 5월 24일부터 5월 30일까지 약 일주일 간 진행한 바닐라 자바스크립트 기반 개인 프로젝트를 회고해보고자 한다. 어째서 이 프로젝트가 시작되었는가 하는 이야기부터, 프로젝트를 진행하면서 겪었던 여러 가지 장애들을 되짚어보며 무엇을 습득했는지 톺아보자. 결과물 빠르게 구경하기

사건의 발단

자바스크립트 교육이 끝나고, HTML/CSS/JS를 이용한 간단한 자기소개 페이지(추후 포트폴리오로 쓸 용도로) 혹은 원하는 페이지를 만들라는 과제가 주어졌다. 간단히 자기소개 페이지를 제작하면 금방 끝날 일이었지만 바로 이전 과제에서 팀 단위로 짧게나마 소개하는 페이지를 만들기도 했고, 포트폴리오는 추후 Next.js 학습 이후에 블로그 개선과 함께 작성할 예정이어서 만들고 싶지 않았다. 극 내향인이라 자기소개가 부끄럽기도 했고.

무엇을 만들어 볼까

하지만 그렇다고 다른 번뜩이는 아이디어가 있었는가 하면 그건 또 아니었다. 거진 한 시간 가까이 각종 깃허브, 유튜브를 전전하며 아이디어를 물색하다가 문득 ‘간단한 게임이나 만들어 볼까?’하는 생각이 들었다. 페이지 내용을 억지로 채우지 않아도 되고, CSS와 JS로 그럴듯한 애니메이션과 화면 전환만 만들면 간단하지만 뭔가 있어 보이는 프로젝트가 완성될 것 같았다. 그렇게 당일 만든 간단한 게임은 바로…

간단한 키보드 조작 게임 a.k.a. 오디션

현재도 서비스중인, 과거를 주름잡았던 한빛소프트의 리듬게임 ‘오디션’
현재도 서비스중인, 과거를 주름잡았던 한빛소프트의 리듬게임 ‘오디션’

오디션이라는 게임을 아는가? 말도 많고 탈도 많은 게임이지만 나름 많은 팬층을 보유했던, 현재까지 서비스 중인 장수 리듬게임이다. 화면 상에 떠 있는 방향 아이콘을 키보드 방향키에 맞게 입력하여 없애는 방식의 게임인데, 위 게임 로직을 바닐라 자바스크립트로 구현하고, CSS로 다채롭게 꾸미면 나름 괜찮은 스낵 게임이 될 것 같았다. 당시 고안했던 아이디어는 최대한 빠른 시간 안에 일정 레벨까지 도달하는 타임 어택 모드와, 무한한 시간이 주어지고 레벨을 클리어할 때마다 점수를 얻을 수 있지만 한 번이라도 틀리면 그대로 종료되는 무한 모드, 총 두 가지 모드를 구현하는 것이었다. 그러나 이 게임은 프로토타입 제작을 끝으로 취소되었는데, 이유는 단순히 ‘내가 재미가 없어서’였다.

프로젝트의 목적이 무엇이냐

재미가 없는 것이 제작을 포기한 첫 번째 이유였고, 또 다른 이유는 ‘왜 만들었는가’, 그리고 ‘이 프로젝트를 하면서 무엇을 배웠는가’라는 물음에 적절한 답을 찾지 못해서였다. 내가 지금 이 프로젝트를 하는 이유는 결국 무언가를 배워가기 위함인데, 위의 프로젝트 로직에서 내가 알고 있는 지식 이외에 무언가를 배웠는가? 하면 그렇지 않았다. 결국 흥미는 크게 떨어졌고 나는 다른 아이디어를 찾아 인터넷을 다시 떠돌기 시작했다.

Web API와의 만남

구독해두었던 각종 IT 관련 채널들의 영상을 뒤적이다가 바로 채용되는 웹개발자 포트폴리오 만들기라는 코딩애플님의 영상을 보았다. 약 1분 10초쯤부터 Web API에 대한 소개가 나오는데, 해당 부분을 보자마자 속으로 ‘이거다’싶었다. 한 번도 써 보지 않았던 기능이기도 하거니와 서버를 거쳐야만 가능할 것 같았던 기능들이 브라우저 내장 API 만으로 해결되는 모습을 보고, 이걸 이용한 무언가를 바닐라 자바스크립트로 구현한다면 괜찮은 반응이 나올 것 같았다.

그중에서도 특히 관심 있었던 API는 Web Audio APIWeb Speech API였는데, 사용하기엔 둘 다 살짝 문제가 있었다. Audio API는 제일 사용해 보고 싶었으나 제시간 안에 괜찮은 프로덕트가 나오지 못할 것 같았고(특히 구글에서 이 API를 소개하기 위해 샘플 페이지를 만들어 두었는데, 여기에 내가 생각했었던 대부분의 아이디어들이 이미 구현되어 있어서 포기했다.), Speech API는 TTSText-To-Speech, Speech Recognition(음성 인식) 두 기능을 제공하지만 두 기능 모두 이미 널리 사용하고 있는 기술이어서 브라우저로 간단히 구현 가능하다는 사실 말고는 새롭다는 이미지를 주기가 힘들 것 같았다. 그러나 더 찾아보기엔 시간이 촉박했기 때문에, 내가 지금까지 수집했던 정보들을 조합해서 아이디어를 빨리 내야만 했다. 처음 기획했던 게임이라는 주제와 엮어 볼만한 Web API가 무엇이 있을까. 숱한 고민 끝에 나온 답은 바로…

음성 인식을 활용한 게임 만들기

많은 Web API 후보 중 선택된 것은 바로 Web Speech API의 음성 인식 기능이었다. 짧은 시간 안에 게임을 만들려면 그래픽적인 요소를 최소화해야 했는데, Web Speech API를 사용해서 나온 부산물들이 텍스트인 만큼 이를 그대로 게임 요소로 활용한다면 그래픽은 크게 신경 쓰지 않아도 되겠거니 싶었다. 다음 과제는 도대체 이 텍스트를 어떻게 게임 요소로 활용할 것이냐였는데, 그 문제는 내 무의식의 속삭임이 쉽게 해결해 주었다.

???: 답은 산성비야

90년대생이라면 모르는 사람이 없는 명작, 학교 컴퓨터실을 주름잡았던 최고의 게임 ‘한컴타자연습 산성비’
90년대생이라면 모르는 사람이 없는 명작, 학교 컴퓨터실을 주름잡았던 최고의 게임 ‘한컴타자연습 산성비’

산성비는 화면 위에서 랜덤한 단어가 떨어지고, 떨어지는 단어가 바닥에 닿기 전에 입력 창에 올바르게 입력하여 없애야 하는 간단한 게임이다. 여기서 떨어지는 단어만 음성 인식된 단어들로 바꿔준다면? 무려 세상에 없던 새로운 형식의 음성 인식 기반 게임이 탄생하는 것이었다. 신경 써야 할 그래픽 리소스도 없고, 로직도 생각보다 간단하지 않으며 배워보지 않았던 새로운 기술을 익힐 수 있는 데다가 별다른 서버 없이 음성 인식을 구현했다는 뭔가 있어 보이는 타이틀까지. 그야말로 완벽에 가까운 프로젝트 아이디어가 아닐 수 없었다. 앞으로 내가 할 일은 핵심 로직을 간단하게 구현하고, 그럴듯하게 CSS로 꾸며주고, 약간의 자바스크립트 제어만 해 주는 것이 전부였다. …전부여야 했다.

프로젝트 셋업

아이디어가 정해졌으니 이제 본격적으로 프로젝트를 시작할 차례다. 개인 프로젝트이고 볼륨이 크지 않았기에 그냥 손 가는 대로 작업해도 그만이었지만, 배워 가야 하는 입장에서 단순히 아이디어의 구현 만으로는 얻는 것이 크게 없다고 생각했다. 따라서 나는 이번 프로젝트를 Github를 최대한 활용하면서 협업처럼 진행하기로 했다.

main branch에 direct push 하지 않기

main branch에 protection rule을 걸어 직접 푸쉬를 막고, pull request를 진행해야만 적용될 수 있도록 하였다.
main branch에 protection rule을 걸어 직접 푸쉬를 막고, pull request를 진행해야만 적용될 수 있도록 하였다.

github repository를 혼자 사용할 일이 많다 보니, 브랜치를 만들어 관리한다거나 할 일이 없었다. 그러다 보니 소스코드를 바로 main branch에 직접 푸쉬하는게 기본이었는데, 협업을 하거나 실제 프로젝트를 진행하게 되면 main 브랜치는 라이브 서버에 deploy되는 브랜치로 활용될 확률이 크다. 따라서 main 브랜치는 코드 리뷰가 끝난 코드만이 올라가야 한다. 따라서 이 프로젝트의 레포지토리에는 main branch에 프로텍트를 걸어 다이렉트 푸쉬를 막아 두고 merge 전에 pull request를 요청하도록 하고, 코드 소유자가 해당 pull request를 리뷰해야만 merge될 수 있도록 설정했다.

Feature 단위로 커밋하기

평소에는 정말 아무렇게나 커밋하곤 했다. 마치 한글 문서 저장하듯, 이쯤이면 됐겠지 싶은 부분이나 하루 동안 작업한 분량을 기록하듯 커밋을 사용했다. 그러니 나중에 커밋 메시지를 확인했을 때 해당 커밋이 어떤 내용인지, 어떤 부분이 수정되었는지 알기가 어려웠다. 결국 수정했던 부분을 확인하려면 모든 커밋을 확인해야 하는 사태가 일어나는 것이다. 이번 프로젝트에서는 이런 참사가 일어나지 않도록 커밋을 기능 단위로 남기기로 하고, 커밋 메시지 또한 규칙을 지켜 작성하기로 했다.

Github Milestones / Issue 활용하기

Github Issues 탭의 Milestones를 통해 개발 단계를 나누고, 개발에 필요한 요소들을 Issue로 발행하여 발행된 Issue를 해결하는 방식으로 프로세스를 진행했다.

Milestones 탭을 활용하여 개발 단계를 나누고, 각 단계마다 Issue를 발행하며 기능별로 개발을 진행했다.
Milestones 탭을 활용하여 개발 단계를 나누고, 각 단계마다 Issue를 발행하며 기능별로 개발을 진행했다.

그저 의식의 흐름대로 개발하다 보면 중간에 샛길로 빠져버려 무엇을 하려 했는지 잊어버리곤 해 헤매는 시간이 많았다. 마일스톤과 이슈를 활용했더니 이런 뻘짓이 많이 줄었다. 해야 할 일들을 기능 단위로 쪼개 이슈를 발행했고, 발행된 티켓들을 하나씩 해결하면서 마치 탑을 쌓아 나가듯이 개발해 나갔다. 중간중간 새롭게 떠오르는 아이디어들도 바로 적용하지 않고 일단 이슈를 발행한 뒤, 현재 개발하고 있는 기능이 끝난 뒤에 적용 여부를 결정했다. 마치 서류더미를 쌓아 두고 하나씩 일을 처리하는 느낌이었는데, 일일이 이슈를 적어야 한다는 점 빼고는 생산성도 올라가고 작업 흐름도 원활해져서 굉장히 큰 도움이 되었다.

커밋 메시지 컨벤션 지키기

커밋 메시지 만으로도 무엇이 변경되었는지 알 수 있도록 작성했다. 눈에 잘 띄라고 이모지도 넣었다.
커밋 메시지 만으로도 무엇이 변경되었는지 알 수 있도록 작성했다. 눈에 잘 띄라고 이모지도 넣었다.

커밋만큼이나 커밋 메시지도 중요하다. 해당 커밋에서 어떤 내용이 추가/수정되었는지 쉽게 파악이 되어야 팀원들 간에 혼란이 없을 것이다. 따라서 Udacity Git Commit Message Style Guide를 참고하여 커밋 메시지를 작성했다. 제목은 태그: 제목의 형태로 어떤 변경 사항이 적용되었는지를 표기하고, 본문에는 추가 설명이 필요한 경우 그 내용을 적어 메시지만으로도 무엇이 변경되었는지 쉽게 파악할 수 있도록 작성했다.

프로젝트가 다 끝난 지금에 와서야 하는 말이지만, 자잘하게 지켜지지 않은 부분이 더 많았다. 한 커밋에 하나 이상의 기능이 추가되기도 하고, 수정된 사항을 메시지에 반영하지 않은 경우도 있었다(readme를 수정하였으나 너무 작은 내용이라 넣지 않았다던가). 협업이었다면 응당 거절당했을 커밋들이지만 이슈 발행도 내가, 처리도 내가 하다 보니 크게 신경 쓰지 않고 넘어갔던 부분이다. 귀찮은 작업이지만 실보다 득이 많은 작업들이므로, 반성하고 다음엔 더 엄격하게 관리하도록 하자.

구현 중 마주한 문제들

버그가 우수수… 분명 내가 테스트할 때는 멀쩡했는데!!!
버그가 우수수… 분명 내가 테스트할 때는 멀쩡했는데!!!

위와 같이 엄격한 프로세스를 적용해도 프로그래머의 숙명이 그러하듯, 여러 가지 문제가 발생했다. 음성 인식을 하고, 인식된 문장을 단어로 쪼개는 본 프로젝트의 핵심 로직 자체는 Web API로 간단하게 구현이 끝났지만, 자바스크립트의 CSS 및 DOM 제어에서 자꾸 생각지도 못한 버그가 터져 나왔다. 분명 내가 테스트할 때는 정상적으로 동작했는데, 친구들에게 테스트를 부탁하자 기상천외한 버그가 튀어나왔다. 결국 게임 자체는 사나흘 만에 완성되었지만 UI 버그를 잡기 위해 게임 개발 기간만큼의 시간을 소요하는 놀라운 결과가 나왔다. 개발하면서 마주한 다양한 버그들과, 그 버그를 해결하기 위해 어떠한 수정을 했는지 간단하게 회고해 보자.

1. 스크린 토글이 정상적으로 되지 않았던 문제

게임을 계속 재시작하다 보면 다음과 같이 화면이 이상해져버리는 문제가 발생했다.
게임을 계속 재시작하다 보면 다음과 같이 화면이 이상해져버리는 문제가 발생했다.

이 게임은 시작 화면, 게임 화면, 게임 오버 화면 총 세 개의 화면으로 이루어져 있다. 이 게임은 게임 오버 화면에서 키를 조작해 페이지 새로 고침 없이 게임을 재시작하거나 타이틀로 돌아갈 수 있다. 물론 SPASingle Page Application이기 때문에 실제 페이지 전환은 해당 화면들의 CSS display 속성을 none으로 토글링하게 구현하였다. 문제는, 그 행동을 반복하다 보면 어느 순간 위 사진과 같이 스크린이 이상해진다는 거였다.

1번 문제의 원인

이 버그는 내가 스크린 토글 기능을 한 메서드로 구현하지 않고 게임이 끝나거나 시작될 때 필요한 스크린들을 토글하도록 구현해놓아서 생긴 일이었다. 한 화면에서 다른 화면으로 이동할 때 어떤 요소들이 토글될지 눈에 보이게끔 메서드를 작성해야 하는데, 그러지 않고 그때그때 잡히는 대로 토글을 해버렸더니 디버깅도 어려운 지경에 이르렀다.

해결

화면별로 스위치 하는 메서드와 모든 화면을 숨기는 메서드를 만들고, 화면 스위치 메서드를 호출하면 일단 모든 화면을 숨기는 메서드를 호출한 뒤, 필요한 요소들을 다시 보이게 하는 식으로 코드를 변경했다. 화면을 제어하는 부분을 메서드로 빼니 눈에 보기도 좋아졌고, 해당 버그도 해결되었다.

2. 게임 재시작 시 이전 게임에서 인식되었던 단어들이 출력되는 문제

게임을 하다가 단어를 입력하지 못해 게임이 종료된 뒤, 재시작 버튼을 누르면 종료 전에 인식되었던 문장의 단어들이 출력되는 문제가 있었다. 해당 버그는 단어를 화면에서 떨어뜨리는 로직을 변경한 커밋 이후에 발생했다. 기존 로직에서는 음성 인식이 끝났다는 이벤트recognition.onresult가 발생하면 단순히 문장을 잘라 배열에 넣어주고, setInterval을 통해 1초마다 해당 배열을 감시하면서 배열에 단어가 있을 때마다 랜덤한 시간마다 단어가 출력되도록 딜레이를 설정해 timeout을 걸어 주는 방식이었다. 이 로직은 배열이 비어있어도 1초마다 배열을 확인하기 때문에 비효율적이라고 생각했고, 리팩터링 작업을 하면서 배열과 Interval을 사용하지 않고 문장 인식이 끝났을 때 곧바로 모든 단어들에 타임아웃을 설정하는 방식으로 변경했던 것이었다.

2번 문제의 원인

원인은 음성 인식 종료 이벤트가 호출되는 시점이 게임이 끝난 시점보다 뒤에 위치할 수 있다는데에 있었다. 게임이 끝나기 직전까지 음성 인식이 진행 중일 경우, 게임이 끝난 뒤에 음성 인식 종료 이벤트가 호출되어 단어들의 타임아웃이 설정되는 것이었다. 그래서 게임이 끝나자마자 재시작을 누르면, 설정된 딜레이 이후 단어들이 출력되어버린다. 기존에는 단어들을 배열에 집어넣는 방식이었기에 재시작을 할 때 배열을 비워주기만 하면 간단하게 해결되었지만, 바뀐 로직에서는 배열 없이 곧바로 타임아웃을 호출하므로 버그가 발생했던 것이다.

해결

단어들에 타임아웃을 설정하기 전에, 게임이 끝났는지의 여부를 판단하는 변수인 isGameOver를 확인하여 해당 값이 참이라면 타임아웃을 설정하지 않고 메서드가 종료되도록 로직을 변경하였다. 해결 자체는 간단했지만, 원인을 파악하는 데 가장 많은 시간을 쓴 버그였다. 변경한 로직에 문제가 없다고 자만했던 내 잘못이었다😥.

3. 게임 도중 화면 크기를 줄일 때 떨어지는 단어 위치가 이상해지는 문제

이 케이스는 위 트윗을 현실로 마주했던 케이스였다.
이 케이스는 위 트윗을 현실로 마주했던 케이스였다.

이 문제는 정말 생각해 본 적도 없는 문제였다. 어느 누가 게임 도중에 화면 크기를 변경한단 말인가! 주변 사람들에게 테스트를 맡겼기에 찾을 수 있었던 버그 아닌 버그였다. 문제는 이걸 해결하기 위해 창 높이가 변경될 때마다 애니메이션을 바꾸도록 로직을 변경해야 했다는 데 있었다.

3번 문제의 원인

이 게임은 단어가 화면 하단까지 떨어지게 되면 끝난다. 단어가 화면에 떨어지도록 하기 위해 CSS의 애니메이션을 이용했다. 애니메이션은 간단히 일정 시간 동안 게임 화면의 맨 위에서 맨 아래로 Y값을 변경하도록 transform을 이용했다. 애니메이션 도중에 해당 단어가 입력되면 해당 단어를 DOM에서 삭제하는데, 이때 애니메이션에선 animationcancel 이벤트를 발생시킨다. 반대로 단어가 지워지지 않고 화면 끝까지 이동하면 애니메이션이 정상적으로 종료되므로 animationend 이벤트를 발생시키는데, 이 이벤트를 이용해서 animationend 이벤트가 발생할 때, 즉 단어가 중간에 제거되지 않고 끝까지 내려왔을 때 게임 종료가 되도록 구현한 것이다.

따라서 이 애니메이션을 정상적으로 동작시키기 위해서는 게임 화면의 높이 값을 알아야 했는데, 이화면 크기 계산을 화면을 구성할 때, 즉 사용자가 페이지에 진입할 때를 기준으로 잡았다. 즉 사용자가 사이트에 접속해서 말 그대로 ‘게임만’ 한다면 문제가 있을 수가 없었고, 사이트에 접속한 이후에 화면의 높이를 줄이거나 늘였다면 게임이 정상적으로 동작하지 않게 보이는 것이었다.

해결

기존 로직은 페이지가 로딩될 때 자바스크립트 상에서 style 태그를 만들고, 그 안에 높이를 계산한 키프레임을 담아 head 태그 안에 집어넣는 방식으로 구현되어 있었다. 이 문제를 해결하려면 애니메이션의 키프레임이 동적으로 변경되어야 했으므로 HTML에 id 값을 가진 style 태그를 미리 선언해 두고, 창 크기가 조절될 때 발생하는 resize 이벤트가 호출되면 해당 애니메이션의 키프레임을 새롭게 정의한 후 미리 선언한 style 태그에 적용하는 방향으로 로직을 바꾸어서 해결했다.

버그가 왜 계속 터졌을까

물론 버그와 싸우는 것이 프로그래머의 숙명이라지만, 개발 기간의 절반을 버그를 수정하는 데 사용했다는 것은 개발 방식에 큰 문제가 있었다는 뜻이라고 생각한다. 물론 혼자의 힘으로는 절대 찾아낼 수 없었던 버그도 있었지만, 사전에 체크할 수 있었던 문제들도 여럿 있었다. 왜 이렇게 많은 버그가 발생했을까?

OOP의 부재

일반 사이트였다면 모르겠지만, 내가 만들고자 했던 것은 간단하긴 해도 게임이었다. 게임 제작이야말로 OOP적 설계가 중요한데, 이러한 부분을 배제한 채 개발을 하다 보니 데이터가 중구난방해지고, 결국 여러 곳에서 버그가 터져 나왔다. 위에서 서술한 첫 번째 문제가 이런 객체지향적 설계의 부재 때문에 발생한 버그의 대표적인 예였다. 스크린에 필요한 요소들을 객체로 묶어 관리했다면 조금 더 효율적으로 개발할 수 있었을 텐데, 아쉬운 부분이 크다.

유닛 테스트의 부재

현대 개발에서 테스트는 빠져서는 안 되는 항목이다. 테스트만을 위한 다양한 방법론도 존재하고, 테스팅 라이브러리도 다양하다. 특히 유닛 테스트는 기존 기능이 변경될 때 변경된 기능이 제대로 동작하는지 확인하기에도 용이한데, 이 프로젝트에서는 그러한 유닛 테스트가 전무하다. 그러니 버그가 터져서 버그를 고치면 다른 데서 버그가 터지고, 그걸 고치면 또 다른 데서 버그가 터지는 미치고 팔짝 뛰는 일이 발생하는 것이었다. TDDTest-Driven Development까진 아니더라도 유닛 테스트를 작성했다면 두 번째 문제와 같은 일은 생기지 않았을 것이다. 다음 프로젝트에서는 꼭 테스트를 도입해야겠다고 다짐하는 계기가 되었다.

마치며

프로젝트는 5월에 끝났으나, 회고 글은 6월에 시작해 7월이 되어서야 모두 작성했다. 이런저런 핑계만 대다가 계속 늦어졌고, 이대로라면 영영 쓰지 않을 것 같아 새 출발 전이 되어서야 급하게 끝마쳤다. 글을 못 쓰는 것은 당연하고, 써야 는다는 사실을 알면서도 글이 마음에 들지 않다는 이유, 쓸 말이 정리되지 않았다는 이유로 미루는 나를 반성한다. 앞으로는 무언가를 공부하고 회고를 쓰는 게 아니라, 회고를 써야 공부를 끝마친다는 각오로 글을 작성해야겠다.

@itsmo
배운 것을 잊지 않기 위해 틈틈히 기록합니다.