어느 자모에서 틀리는지 세는 한글 타자 연습기
한국어 자판을 몇 손가락으로만 치는 습관이 있어서 고치는 중이다. 그런데 타자 연습 사이트들은 타수와 정확도만 알려준다. 몇 퍼센트 틀렸는지는 나오는데 어느 키에서 틀렸는지는 안 나온다.
손가락을 고치려면 그게 필요하다. 어느 자모를 반복해서 못 누르는지 알아야 그 손가락을 집중해서 연습한다. 찾아봐도 없어서 만들었다.
집계 단위를 자모로 잡는다
핵심은 무엇을 세느냐다. 음절 단위로 세면 "값 에서 틀렸다"까지만 남는다. 초성에서 틀렸는지 받침에서 틀렸는지 알 수 없으니 손가락으로 연결이 안 된다.
자판에서 키 하나는 자모 하나다. 그래서 자모 단위로 세야 한다.
완성형 한글 분해하기
한글 음절은 유니코드에서 U+AC00부터 U+D7A3까지 초성 19 × 중성 21 × 종성 28 순서로 빈틈없이 배열되어 있다. 그래서 나눗셈과 나머지만으로 뽑아낼 수 있다.
const offset = code - 0xAC00;
const cho = CHO[Math.floor(offset / (21 * 28))];
const jung = JUNG[Math.floor((offset % (21 * 28)) / 28)];
const jong = JONG[offset % 28];
종성 배열의 0번이 빈 문자열이라 받침 없는 글자도 같은 식으로 처리된다.
NFD로는 부족하다
유니코드에 정규화 분해(NFD)가 이미 있는데 쓰지 않았다. NFD는 겹받침과 이중모음을 하나로 둔다. 값 을 NFD로 분해하면 종성이 ㅄ 하나로 남고, 과 의 중성은 ㅘ 하나로 남는다.
키보드에서는 다르다. ㅄ 은 ㅂ 과 ㅅ 두 키고, ㅘ 는 ㅗ 와 ㅏ 두 키다. 두 키를 하나로 세면 어느 쪽에서 틀렸는지 다시 알 수 없어진다.
그래서 한 단계 더 쪼갰다.
const JONG_MAP = {
'ㄱ': 'ㄱ', 'ㄲ': 'ㄲ', 'ㄳ': 'ㄱㅅ', 'ㄴ': 'ㄴ',
'ㄵ': 'ㄴㅈ', 'ㄶ': 'ㄴㅎ', 'ㄷ': 'ㄷ', 'ㄹ': 'ㄹ',
'ㄺ': 'ㄹㄱ', 'ㄻ': 'ㄹㅁ', 'ㄼ': 'ㄹㅂ', 'ㄽ': 'ㄹㅅ',
'ㄾ': 'ㄹㅌ', 'ㄿ': 'ㄹㅍ', 'ㅀ': 'ㄹㅎ', ...
};
const COMPOUND_JUNG = {
'ㅘ': 'ㅗㅏ', 'ㅙ': 'ㅗㅐ', 'ㅚ': 'ㅗㅣ',
'ㅝ': 'ㅜㅓ', 'ㅞ': 'ㅜㅔ', 'ㅟ': 'ㅜㅣ',
'ㅢ': 'ㅡㅣ'
}
기준은 두벌식에서 키를 몇 번 누르는가다.
ㄳ,ㄵ,ㄺ같은 겹받침은 서로 다른 두 키다. 쪼갠다.ㅘ,ㅚ,ㅢ같은 이중모음도 두 키다. 쪼갠다.ㄲ,ㅆ같은 쌍자음은 shift를 누른 채 한 키다. 쪼개지 않는다.JONG_MAP에서'ㄲ': 'ㄲ'로 자기 자신에 대응시킨 게 이것 때문이다.ㅐ,ㅔ도 두벌식에서 각각 한 키다.COMPOUND_JUNG에 넣지 않았다.
모양이 겹쳐 보이는지가 아니라 손가락이 몇 번 움직이는지로 나눈 셈이다.
function decompose(str) {
return [...str].map(ch => {
const code = ch.charCodeAt(0);
if (code >= 0xAC00 && code <= 0xD7A3) {
const offset = code - 0xAC00;
const cho = CHO[Math.floor(offset / (21 * 28))];
const jung = JUNG[Math.floor((offset % (21 * 28)) / 28)];
const jong = JONG[offset % 28];
const jung_expanded = COMPOUND_JUNG[jung] || jung
return cho + jung_expanded + (jong ? JONG_MAP[jong] : '');
}
return ch;
}).join('');
}
한글이 아닌 문자는 그대로 통과시킨다. 공백과 문장부호가 섞여 있어도 같은 함수로 처리된다.
조합 중인 글자를 어떻게 비교할까
한글 입력은 IME가 글자를 조합한다. 감 을 치면 input 이벤트가 ㄱ, 가, 감 세 번 발생한다. 완성된 글자끼리 비교하려고 하면 조합 중간 상태를 전부 오타로 세게 된다.
여기서 자모 분해가 통계용을 넘어 비교 자체에도 쓰인다. 목표 문장과 입력값을 둘 다 자모로 펼쳐놓고 앞에서부터 맞춰본다.
- 목표:
감사→ㄱㅏㅁㅅㅏ - 조합 중 입력:
가→ㄱㅏ
앞에서부터 비교하면 ㄱㅏ 까지 일치하고 거기서 끝난다. 조합이 덜 끝난 상태가 자연스럽게 접두사가 되므로 따로 처리할 게 없다.
let i = 0
let found_mistake = false
while (i < Math.min(sentence_jamo.length, cur_jamo.length)) {
if (sentence_jamo[i] !== cur_jamo[i]) {
found_mistake = true
if (!recording_mistake) {
recording_mistake = true
dict[sentence_jamo[i]] = (dict[sentence_jamo[i]] || 0) + 1
updateDict()
}
break
}
i += 1
}
if (!found_mistake) recording_mistake = false
세는 건 입력한 자모가 아니라 처음 어긋난 자리의 목표 자모다. "이 키를 제대로 못 눌렀다"를 세는 것이므로 이쪽이 맞다.
같은 오타를 여러 번 세지 않기
recording_mistake 플래그가 여기서 필요하다.
틀린 상태로 계속 타이핑하면 input 이벤트가 계속 발생하고, 매번 같은 자리에서 같은 자모가 어긋난다. 플래그가 없으면 한 번 틀린 걸 글자를 칠 때마다 계속 센다. 오타 하나가 수십 번으로 부풀어 통계가 무의미해진다.
한 번 기록하면 플래그를 세우고, 어긋난 곳이 없어지면(즉 지우고 제대로 고쳤으면) 내린다.
진행 판정은 음절 단위로
오타는 자모로 세지만, 어디까지 쳤는지와 화면 색칠은 완성된 글자 단위로 한다. 자모 단위로 색칠하면 조합 중인 글자가 계속 깜빡인다.
const sentence_sliced = sentence.slice(progress, cur.length)
const cur_sliced = cur.slice(progress)
let j = 0
while (j < Math.min(sentence_sliced.length, cur_sliced.length)) {
if (sentence_sliced[j] !== cur_sliced[j]) break
j += 1
}
progress += j
progress 는 되돌아가지 않는다. 지우기를 눌러 입력이 progress 보다 짧아지면 맞은 데까지의 문장으로 되돌려놓는다.
if (cur.length < progress) {
e.target.value = sentence.slice(0, progress)
updateColors(false)
}
이미 통과한 부분을 다시 고칠 수 없게 막는 것이라 연습기로서는 이 편이 낫다고 봤다.
나머지
문장은 korean-advice-open-api에서 받아온다. 연습 문장을 직접 채워넣을 이유가 없었다. 한 문장을 끝내면 render() 를 다시 불러 다음 문장을 가져온다.
화면은 Web Components로 만들었다. <typing-practice> 하나를 정의하고 index.html 에는 그 태그만 있다. 빌드 도구 없이 파일 세 개로 끝난다.
오타 집계는 개수 기준으로 정렬해 화면에 계속 띄워둔다. 많이 틀린 자모가 앞에 온다.
아직 안 된 것
- 무엇을 눌렀는지는 기록하지 않는다. 목표 자모만 센다.
ㅁ을 칠 자리에서ㄴ을 눌렀다는 것까지 알면 인접 키 혼동인지 다른 문제인지 구분할 수 있는데 지금은 안 된다. - 자모까지만 세고 손가락으로 묶지는 않았다. 원래 목적이 손가락 교정이므로 자모를 두벌식 손가락 배치에 매핑해서 손가락별로 합치는 게 맞다. 자모 단위로도 눈으로 보면 대충 보여서 일단 여기까지 두고 있다.
- 통계가 새로고침하면 사라진다. 값이 메모리에만 있다. 며칠치를 쌓아 추이를 보려면 저장이 필요하다.