‘하드코어 타이핑’에서 제시문을 입력하는 데 실패한 스크린샷
[메인 포스트]

‘하드코어 타이핑’ 개발 과정

‘하드코어 타이핑’을 만들게 된 동기, 기본 개념, 구체화한 내용, 구현 과정, 처리하기 어려웠던 문제, 간단한 소감

최초 게시
2022년 11월 9일 00시
키워드
후기,
Frontend, 한글 입력

왜 만들게 되었나

키보드로 한글을 타이핑하면서 늘 느끼지만, 오타가 너무 자주 난다. 생각을 글로 옮기는 데 방해가 된다는 생각이 자주 들 정도이다. 아무래도 10여 년 전 세벌식 자판에 익숙해지고 어느 정도 지난 뒤에 ‘모아주기’라는 (입력 순서를 바로잡아 주는 놀라운) 기능을 켰다가, 입력 순서를 뭉개서 치는 나쁜 습관이 든 것 같다.

오타 빈도를 줄일 방법을 고민했다. 무작정 타자 연습을 한다고 하더라도, 오타가 나면 지우고 다시 쓰면 그만이다. 오타가 생기지 않도록 신경을 쓰면서 천천히 타이핑을 하는 방법도 생각해 봤지만, 속도를 어떻게 제어해야 효율적으로 오타를 줄일 수 있을지 감이 오지 않았다.

어느 날 Celeste(셀레스트)라는 플랫폼 게임을 했다. 각 챕터를 완료하기 위해서는 챕터를 구성하는 각 방마다 통과할 방법, 수집 요소를 획득할 방법 등을 고민해야 했다. 이 게임의 어려운 점은, 머리로는 그 방법을 깨달았다 하더라도 손이 따라 줘야 한다는 것이다. (다행히 수집 요소에 집착하지 않는다면 메인 스토리 챕터는 함부로 추천하지 못할 정도로 어렵지는 않다.)

중간에 재미있는 생각이 떠올랐다. 타이핑을 하다가 틀리면 그 문장을 처음부터 다시 쓰는 것이다. “그냥 쓰면 되는 거 아냐?” 하고 방심하는 사이에 오타를 내고 처음부터 다시 쓰는 그림이 떠올랐다.

기본 개념

다음과 같이 기본 개념을 구상했다.

  • ‘하드코어 타이핑’은 오타 교정을 위해 강력한 제약사항을 건 타자 연습이다.
  • 문장 하나를 완성하는 데 단 한 번의 실수도 용납되지 않는다. 오타를 내면 그 문장을 처음부터 다시 입력해야 한다.

구체화

짧은 호흡 vs 긴 호흡

Celeste에서 새로운 요소가 처음 나타나는 방은 그 작동 방식을 이해할 수 있도록 디자인되어 있다. 쉬운 방은 대체로 피해야 하는 장애물이 적은 편이고 통과하는 데 걸리는 시간도 짧다. 반면에 어려운 방은 장애물이 조밀하게 배치되어 있고 방 자체의 호흡도 길어서, 통과하려면 더 정밀한 컨트롤이 요구된다.

타자 연습 방법으로는 자리 연습, 낱말 연습, 짧은 글 연습(개별 문장), 긴 글 연습(여러 문장), 타자검정(시간 기준) 등이 있다. 자리 연습, 낱말 연습은 튜토리얼에 가깝고, 짧은 글 연습은 짧은 호흡으로, 긴 글 연습과 타자검정은 긴 호흡으로 진행된다.

원래는 짧은 글 연습과 긴 글 연습을 모두 생각하고 있었는데, 일단 기본 기능 구현에 집중하고자 짧은 글 연습만을 고려하기로 했다. 긴 글 연습과 타자검정을 나중에 추가한다면, 문장 단위로 세이브 포인트를 만들거나(연습 모드), 원 코인 클리어를 강제할 수 있을 것 같다. 샷건을 유발하는 타자연습

기본 동작 흐름

  • 정타: 계속 진행
  • 오타
    1. 입력을 멈출 때까지 추가 입력을 차단한다.
    2. 현재 문장을 다시 입력할 수 있도록 입력란을 비우고 활성화한다.
    3. 입력을 시작할 때, 타수, 입력 시간 등 현재 문장 입력 정보를 초기화한다.
  • 정확도 100%로 입력 성공
    1. 이전 결과, 누적 결과를 갱신한다.
    2. 다음 문장을 보여주고, 입력란을 비운다.

오타 발생 감지 방법

오타가 발생하면 처음부터 다시 입력하게 한다는 기본 개념을 바탕으로 오타 발생 감지 기준을 고민했다.

가장 쉽게는 글자 단위로 감지하는 방법을 생각해 볼 수 있다. 하지만 다음과 같이 한글을 조합하는 중에 오타가 발생했다고 잘못 감지하게 되므로, 여기서는 가장 나쁜 방법이다. (특수한 문장을 제외하고는 완성할 수가 없다!)

  • 두벌식: → 고 → → 고 → 고양 → 고양 → 고양이
  • 세벌식: → 고 → 고 → 고 → 고양 → 고양 → 고양이

그렇다면 한글 입력에 한해서는 뒤에서 두 번째 글자를 감지하게 하면 되지 않느냐는 질문을 할 수 있다. 하지만 이 방법도 좋지 않다. 첫째 이유는 입력기에 따라 조합 종료 상태를 확신할 수 없다는 점이다. 천지인, 나랏글 등, 여러 타로 자모를 정하는 입력 방법에서는 조합 중인 글자가 여러 글자로 나타날 수 있다(천지인의 경우, ㅇ → ㅇ·ㅇ: → 여 → 연 → 열 → 여ㄹ· → 여러). 둘째 이유는 마지막 글자의 오타를 수정할 수 있으므로 기본 개념에 어긋난다는 점이다. 후자는 삭제 키와 이동 키를 금지하여 해결할 수 있다.

그래서 오타 감지는 뒤에서 두 번째 어절 검사를 통해서 하되, 글자 수정을 막기 위해서 삭제·이동 키를 금지하기로 했다.


구현

개별 기능을 구현하는 데 집중하기 위해서, 구현 내용을 다음과 같이 나누어 진행했다.

  1. 타이핑 테스트 기본 기능
  2. 통계
  3. 문장 데이터 가져오기
  4. 기능 추가

1. 타이핑 테스트 기본 기능

첫째로, 제시문과 입력란으로 구성된 타이핑 테스트 최소 기능을 구현했다. 입력란에 제시문을 입력하면 입력란을 초기화하고 다음 제시문을 보여주도록 했다. 초기에는 Space로만 입력을 완료하게 했다. Enter로도 입력을 완료할 수 있게 하려 했으나, Enter를 누르기 직전까지 조합 중이었던 글자가 입력란 초기화 후에도 입력란에 남는 문제가 있었다. (이 문제는 나중에 해결했다.)

둘째로, 오타 감지를 구현했다. 일단 입력 완료 시에만 검사하도록 하고, 오타 발생 이벤트가 잘 통지되는지 확인했다. 그 다음에는 어절 단위 감지를 추가했다. 입력 중에는 뒤에서 두 번째 어절을 검사하도록 했다. 글자, 자소, 스트로크 단위의 오타 감지는 일부 자판에서 오작동할 수 있거나, 자판별로 오토마타가 필요해서 제외했다.

셋째로, 오타 수정 방지를 구현했다. Backspace, Delete, 커서 이동 등, 오타를 수정할 수 있는 동작을 차단했다.

넷째로, 오타 발생 시 실패 처리를 구현했다. 뒤에서 두 번째 어절에 오타가 있으면 입력란을 비우고 현재 제시문을 처음부터 다시 입력하게 했다. 틀린 것을 인지하기까지 연달아 틀리는 것을 막기 위해서, 실패 처리가 되면 일시적으로 입력을 차단했다.

2. 통계

타이핑하는 사람이 진행 상태를 알 수 있도록 통계 기능을 붙였다. 제시문에는 몇 번째 문장인지를 표시했고, 누적 실패 횟수와 현재 타자 속도를 표시했다. 나중에는 직전 제시문과 현재 제시문의 실패 횟수, 직전 제시문과 누적 타자 속도를 추가로 표시했다.

타자 속도 단위는 WPM(Words Per Minute)과 SPM(Strokes Per Minute)을 선택했다. 자판 배열이나 오토마타에 따라서 같은 글자를 입력하는 데 요구되는 타수가 항상 같지 않기 때문에 WPM을 가장 우선으로 생각했다. 추가로, keydown 이벤트로 타수를 셀 수 있고, 입력한 내용의 수정도 막았기 때문에, SPM도 함께 표시하기로 했다. (다만 이런 타수 산정 방식은 완벽하지 않다. 키를 눌렀는데 변화가 없는 경우에도 1타로 인정되기 때문이다.)

3. 문장 데이터 가져오기

문장 데이터는 표준국어대사전에 등재된 속담 표제어 일부와 국가교육과정정보센터(NCIC)에 공개된 2015 개정 교육과정 원문에서 가져왔다. 출처를 표시하기 위해 문장을 출처별로 나누어 저장했다. 전체 문장 데이터를 JSON으로 가공하는 스크립트도 작성했다.

4. 기능 추가

기초 기능에 이어서 몇 가지 기능을 더 추가했다. 제시문 순서를 섞었고, 실패 피드백 관련 기능과 현재 문장 진행도 표시를 추가했다. 올바르게 입력한 어절을 흐리게 처리하는 기능도 적용했다.

문장 입력 실패 시 입력 차단에 디바운싱을 적용하여, 입력을 멈추지 않으면 입력란이 활성화되지 않게 했다. 실패 시 박스 테두리가 빨갛게 바뀌게 했고, 고개를 가로젓듯이 박스가 좌우로 흔들리는 트랜지션도 적용했다. 틀린 어절 강조 표시도 추가했다. ESC로 중도 포기할 때에도 실패 피드백을 적용했다. 나중에는 실패 후 안내에 따라 ESC, Space, Enter 중 하나를 눌러서 입력란을 직접 재활성화하는 단계도 넣었다.


처리하기 어려웠던 문제

예상했던 대로, 한글 입력과 관련된 부분에서 어려움이 있었다. 한글 입력 문제는 어느 플랫폼에서든 잊을 만하면 튀어나오는 것 같다.

한글 조합 중 Enter가 2타로 입력되는 문제

한글 조합 중 Enter를 누르면 1타가 아닌 2타가 가산되었다. 한글 조합 중 Space나 문장부호 등, 다른 키를 눌렀을 때는 1타만 가산되었다. Google에 검색하면 해결 방법이 많이 나오지만, 브라우저별로 상황이 어떤지 확인하기 위해 직접 원인을 파악하고 문제를 해결하기로 했다.

타수 계산은 keydown 이벤트 핸들러에서 했다. React 없이 바닐라 JS에서도 동일한 문제가 발생하는 것도 확인했다. 아래의 코드로 각 브라우저에서 발생하는 로그를 추적해 보았다.

const textarea = document.createElement('textarea')

textarea.addEventListener('keydown', (event) => {
  const { code, key, target: { value } } = event
  console.log(`[keydown] ${code} ${key}  "${value}"`)
})

textarea.addEventListener('input', (event) => {
  console.log(`[input]  "${event.target.value}"`)
})

document.body.appendChild(textarea)

한글을 조합하고 있는 상태에서 Enter를 눌렀을 때, Safari에서는 Enter가 한 번만 기록되었고, Chrome, Firefox에서는 Enter가 두 번 기록되었다. Firefox와 Windows용 Chrome에서는, 연달아 발생한 Enter 중 첫 번째 Enterevent.key"Process"로, 두 번째 Enterevent.key"Enter"로 기록되었다. macOS용 Chrome에서는 두 Enter 모두 event.key"Enter"로 기록되었다.

이것을 보고 혹시나 해서 (deprecated된) KeyboardEvent.keyCode를 로깅해 보았는데, 한글 조합 중에는 229로 기록되어서 한글 조합 상태를 구분할 수 있었다. 다만, deprecated되었기 때문에 쓰고 싶지 않았다. 웬만해서는 대체할 만한 게 있을 것이라고도 생각했고, 그렇지 않다면 사용 시에 문제가 발생할 여지가 있을 것이라고도 생각했다.

그래서 KeyboardEvent에 쓸 만한 것이 있는지 MDN의 Web API 레퍼런스를 살펴보았다. 여기서 문자 조합 상태를 알려주는 KeyboardEvent.isComposing을 찾았다. 예제만으로는 정말로 나에게 필요한 것인지를 알 수 없어서, 앞의 코드에서 keydown 이벤트 핸들링을 아래와 같이 변경했다.

textarea.addEventListener('keydown', (event) => {
  const { code, key, isComposing, target: { value } } = event
  console.log(`[keydown] ${code} ${key} ${isComposing}  "${value}"`)
})

한글을 조합하고 있는 상태에서 Enter를 눌렀을 때, Safari에서는 isComposingfalseEnter 하나만 기록되었고, Chrome과 Firefox에서는 true인 것과 false인 것이 순서대로 기록되었다.

로그에 따르면 Chrome과 Firefox에서는 한글 조합 중 Enter를 누르면 isComposingtrueEnter가 하나 더 기록되는 꼴이므로, isComposingtrueEnter는 타수 계산에서 제외하는 방식으로 문제를 해결했다.

Safari에서 입력란을 비운 뒤 시간, 타수가 초기화되지 않는 문제

이 문제도 한글 입력 문제와 관련이 있었다. 앞의 코드를 가지고 Safari가 다른 브라우저와 어떻게 다르게 처리하는지를 확인해 보았다. Chrome이나 Firefox에서는 한글과 영문 입력 모두 keydowninput 순서로 이벤트가 발생했지만, Safari에서는 한글 조합 중 inputkeydown 순서로 이벤트가 발생하여, 순서가 뒤집힌 것을 확인했다. 입력란의 value도 Safari에서는 Chrome, Firefox와 다르게 기록되었다.

현재 문장의 시간, 타수 초기화는 (1) 제시문을 틀리지 않고 입력하는 데 성공하여 다음 제시문으로 넘어간 경우와 (2) keydown 이벤트가 발생했을 때 입력란이 비어 있는 경우에만 진행하게 했었다. Safari에서는 입력란 초기화 이후 첫 keydown 이벤트를 처리하는 시점에는, 이미 input 이벤트 처리 결과로 인해 입력란에 value가 들어가 있는 상황이므로 시간·타수 초기화가 되지 않았다. 따라서 입력란을 비운 뒤 keydown 이벤트가 처음 발생할 때 입력란의 value와 상관없이 시간과 타수를 초기화하도록 변경하여 문제를 해결했다.

keydown, input 이벤트를 둘 다 활용하는 경우, 되도록 이벤트 발생 순서에 종속되지 않도록 프로그래밍하는 게 좋겠다.


간단한 소감

만드는 과정에서 내가 집중할 수 있을 만한 수준으로 작업 범위를 쪼갰다. 그게 집중도와 흥미도 유지에 도움이 많이 된 것 같다. 쪼갠 대로 작업을 하는데 막상 예상했던 것보다 조각이 크다면, 잠시 멈추고 한 번 더 쪼갰다. 입보다 큰 음식을 먹을 때, 자르거나 조각을 떼는 등 부피를 줄이는 행동을 하지 않으면, 입부터 시작하여 소화 기관을 다칠 수 있는 것과 비슷한 것 같다.

이제 와서 돌아보니 ‘하다 보니까 해결했네.’ 하는 생각이 들지만, 한글 입력과 관련된 문제 해결은 쉽지 않았다. macOS와 Safari를 업데이트한 뒤 이 글을 쓰는 시점에도 뭔가가 이전과 달라졌는데(다행히 ‘하드코어 타이핑’에 문제가 되지는 않았다.), 한글 입력 관련 문제는 끊임없이 생겨나는 것 같다.

아쉬웠던 점이라면, 타자 속도를 계산할 때 짧은 주기로 상태를 변경하도록 구현했는데 다른 방법을 생각해 보지 않았다는 점이다. 컴포넌트의 상태가 변경되면서 useEffect 안에 작성한 타이머가 해제·생성을 반복하는 것 같은데, 성능 상의 문제는 없는지, 문제가 있다면 그걸 해소할 방법으로는 무엇이 있는지 고민해 볼 필요가 있겠다.

yejunian / blog

© 2022-2024 yejunian

라이선스를 따로 명시하지 않았다면, 각 콘텐츠에는 아래와 같은 라이선스가 적용됩니다.

  • yejunian/blog-post에 업로드한 게시물(코드 블록 제외): CC-BY 4.0
  • 게시물에 삽입된 코드 블록의 내용: 퍼블릭 도메인
  • 댓글 등, 웹 사이트 방문자가 작성한 게시물의 저작권은 그 콘텐츠의 작성자에게 있으며, 이를 활용하려면 저작권자의 이용허락이 필요합니다.

게시물에 포함된 이미지 등을 외부 서비스에서 사용하려면, 해당 콘텐츠를 다운로드한 뒤 공유하려는 서비스의 콘텐츠 첨부 기능을 활용하기 바랍니다. 이 사이트에 첨부한 파일의 URL은 언제든지 변경될 수 있습니다.

Powered by Gatsby. Hosted on GitHub.