한글 타이핑 애니메이션을 만들기까지

커버 이미지

한글 타이핑 애니메이션을 구현하고 싶었습니다. 타이머 함수를 이용하면 구현 자체는 쉬웠지만, 단순히 한 글자씩 출력하는 것은 다소 밋밋했고, 실제로 타자를 치는 듯한 애니메이션을 구현해보고 싶었습니다.

구현 과정

한글 타이핑 애니메이션을 자연스럽게 구현하려면 입력받은 문자가 초성 → 초성+중성 → 초성+중성+종성 순으로 출력되어야 합니다. 예를 들어 "곰"이라는 글자는 화면에 ㄱ → 고 → 곰 형태로 출력될 때 더 자연스럽습니다. 이를 위해서는 입력받은 "곰" 문자를 초성/중성/종성으로 분리하는 과정이 필요합니다.

한글 문자를 분리할 때 가장 많이 사용되는 방법 중 하나가 유니코드를 이용하는 방식인데, 이를 위해서는 유니코드에서 한글이 어떤 규칙으로 배치되어 있는지 먼저 알아야 합니다.

한글 유니코드의 규칙

다음 함수는 '가'부터 '힣'까지 한글 유니코드를 출력하는 함수입니다.

export function showkoreanAllUnicode() {
  // 문자를 유니코드로 변환
  const ga = "가".charCodeAt(0); // 가 (맨 처음 한글 문자)
  const hih = "힣".charCodeAt(0); // 힣 (맨 마지막 한글 문자)
 
  let uni = ga;
  while (uni) {
    const kor = String.fromCharCode(uni); // 유니코드를 문자로 변환
    console.log("uniCode :", uni, "// kor : " + kor);
 
    if (uni === hih) break;
 
    uni++;
  }
}
 
showkoreanAllUnicode(); // '가' ~ '힣'까지 모든 한글 unicode 출력

해당 함수를 호출해보면 아래처럼 콘솔이 출력됩니다. 살펴보면 종성은 1마다, 중성은 28마다, 초성은 588마다 변하는 것을 확인할 수 있습니다.

이미지1

이 규칙을 이용하면 아래처럼 문자를 분리하는 함수를 만들 수 있습니다.

// 초성 배열
const f = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ',
           'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ',
           'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
// 중성 배열
const s = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ',
           'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ',
           'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
// 종성 배열(공백 포함)
const t = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ',
           'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ',
           'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ',
           'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
 
 
// 문자를 분해하여 초성, 중성, 종성 순으로 출력하는 함수
function disassembleKoreanChar(char: string): string[] {
  const ga = "가".charCodeAt(0); // 가 (맨 처음 한글 문자)
  const giyeok = "ㄱ".charCodeAt(0); // 'ㄱ' (맨 처음 한글 자음)
  const uniCode = char.charCodeAt(0) - ga; // 입력받은 문자의 유니코드와 '가' 유니코드의 차
 
  // 한글이 아닐 경우 예외처리
  if (uniCode < 0 || uniCode > hih - giyeok) {
    return [char, "", ""];
  }
 
  // 종성은 1마다, 중성은 28마다, 초성은 588마다 값이 변함
 
  // 초성 배열의 인덱스
  const fIdx = Math.floor(uniCode / 588);
  // 중성 배열의 인덱스
  const sIdx = Math.floor((uniCode - fIdx * 588) / 28);
  // 종성 배열의 인덱스
  const tIdx = Math.floor(uniCode % 28);
 
  return [f[fIdx], s[sIdx], t[tIdx]];
}
 
const result = disassembleKoreanChar('곰');
console.log(result);	// ['ㄱ', 'ㅗ', 'ㅁ']

우선 uniCode 변수에 입력받은 문자의 유니코드 값에서 '가'의 유니코드를 뺀 값을 저장합니다. 이 값을 기반으로 초성/중성/종성 값을 구합니다.

fIdx는 초성 배열의 인덱스입니다. 초성은 588마다 값이 바뀌고 배열의 시작 인덱스는 0이므로 uniCode를 588로 나눈 뒤 Math.floor()로 내림 처리합니다.

sIdx는 중성 배열의 인덱스입니다. 중성은 28마다 값이 바뀌므로 28로 나누며, 초성이 바뀔 때마다 중성이 다시 처음부터 반복되기 때문에 uniCode에서 fIdx * 588을 빼서 보정한 뒤 계산합니다.

tIdx는 종성 배열의 인덱스입니다. 종성은 1마다 값이 바뀌고 총 28개의 값을 가지므로 uniCode % 28이 종성 배열의 인덱스가 됩니다.

위 코드를 응용하면 아래처럼 [초성, 초성+중성, 초성+중성+종성] 순으로 출력하는 함수를 만들 수 있습니다.

// 초성 배열
const f = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ',
           'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ',
           'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
// 중성 배열
const s = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ',
           'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ',
           'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
// 종성 배열(공백 포함)
const t = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ',
           'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ',
           'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ',
           'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
 
export function disassembleKoreanString(char: string): string[] {
  const ga = "가".charCodeAt(0); // 가 (맨 처음 한글 문자)
  const giyeok = "ㄱ".charCodeAt(0); // 'ㄱ' (맨 처음 한글 자음)
  const uniCode = char.charCodeAt(0) - ga;
 
  // 한글이 아닐 경우 예외처리
  if (uniCode < 0 || uniCode > hih - giyeok) {
    return [char, "", ""];
  }
 
  // 종성은 1마다, 중성은 28마다, 초성은 588마다 값이 변함
 
  // 초성 인덱스
  const fIdx = Math.floor(uniCode / 588);
  // 중성 인덱스
  const sIdx = Math.floor((uniCode - fIdx * 588) / 28);
  // 종성 인덱스
  const tIdx = Math.floor(uniCode % 28);
 
  // [초성, 초성 + 중성, 초성 + 중성 + 종성] 배열을 출력
  return [
    f[fIdx],
    String.fromCharCode(ga + fIdx * 588 + sIdx * 28),
    t[tIdx] ? String.fromCharCode(ga + fIdx * 588 + sIdx * 28 + tIdx) : "",
  ];
}
 
const result = disassembleKoreanString('곰');
console.log(result);	// ['ㄱ','고','곰']

위 함수 안에 사용된 String.fromCharCode는 유니코드 값에 해당하는 문자를 출력하는 함수입니다. 여기에 인자로 '가'의 유니코드 + (초성 인덱스 x 초성 간의 간격) + (중성의 인덱스 x 중성 간의 간격) + 종성 인덱스를 넣으면 초성 + 중성 + 종성을 다 더한 문자를 구할 수 있다. 이를 응용해서 마지막에 종성 인덱스를 생략하면 초성 + 중성을 더한 문자 또한 구할 수 있다.

따라서 위 함수는 문자를 입력받으면 해당 문자의 [초성, 초성+중성, 초성+중성+종성] 순으로 이루어진 배열을 출력합니다. 예를 들어 함수에 "곰"이라고 입력하면 ["ㄱ", "고", "곰"] 형태로 출력됩니다. 또한 함수 외부에서 사용하기 쉽도록, 입력받은 글자가 한글이 아니더라도 배열 길이를 3으로 맞춰 반환하도록 만들었습니다.

하지만 위 함수는 단일 문자만 처리할 수 있으므로, 문자열을 입력받았을 때도 처리할 수 있도록 별도의 함수를 하나 더 만들었습니다.

// 입력받은 문자열을 쪼갠 결과값을 출력하는 함수
function disassembleString(line: string): string[] {
  let titleArr: { a: string; b: string; c: string }[] = [];
 
  [...line].forEach((char) => {
    titleArr = titleArr.concat(disassembleKoreanString(char));
  });
 
  return titleArr;
}
 
const result = disassembleString('곰돌이');
console.log(result); // ['ㄱ', '고', '곰', 'ㄷ', '도', '돌', 'ㅇ', '이', '']

disassembleString 함수는 입력받은 문자열의 길이만큼 disassembleKoreanString 함수를 호출하고, 그 결과 배열들을 하나로 합쳐 반환하는 함수입니다.

예를 들어 "곰돌이"라고 입력한 경우 disassembleKoreanString 함수를 3번 호출해야 하는데 각각 ['ㄱ', '고', '곰'], ['ㄷ', '도', '돌'], ['ㅇ', '이', ''] 3개의 배열이 출력됩니다. 반면 disassembleString 함수를 사용하면 ['ㄱ', '고', '곰', 'ㄷ', '도', '돌', 'ㅇ', '이', '']처럼 하나의 배열로 받을 수 있습니다.

최종적으로 disassembleString 함수로 출력된 배열을 화면에 출력하는 작업도 필요합니다. 이때 한글이 아닌 문자가 입력되거나 종성이 없는 문자가 입력되었을 경우도 고려해 예외 처리를 해주는 것이 중요합니다.

let timerId = 0;
 
// 문자 배열을 입력받아 화면에 출력하는 함수
function typing(element: Element, txt: string) {
  return new Promise((resolve) => {
    let idx = 0;
    timerId = setInterval(function () {
      if (idx % 3 === 0) {
        // 다음 글자로 넘어갈 때 글자 추가
        element.innerHTML += txt[idx];
      } else {
        // 현재 글자일 때 초성, 초성 + 중성, 초성 + 중성 + 종성 순으로 출력
        if (txt[idx] !== "") {
          // 중성/종성이 공백일 때 무시
          element.innerHTML = element.innerHTML.slice(0, -1) + txt[idx];
        }
      }
      idx++;
 
      if (txt.length <= idx) {
        clearInterval(timerId);
 
        idx = 0;
        resolve(true);
      }
    }, 100);
  });
}
 
typing(titleElement, disassembleString('곰돌이')); // 화면에 곰돌이 출력

typing 함수는 100ms마다 disassembleString을 통해 만들어진 문자 배열의 요소들을 화면에 출력하는 함수입니다. 반복문을 한 번 돌 때마다 idx 값이 1씩 증가하며, idx 값이 3의 배수가 아닌 경우 현재까지 출력된 문자열의 마지막 문자를 현재 idx의 문자로 치환하고, idx 값이 3의 배수인 경우에는 출력된 문자열 끝에 새 문자를 추가합니다.

3을 기준으로 정한 이유는 disassembleString에서 반환되는 문자 배열의 길이가 항상 3의 배수이기 때문입니다. 실제로 함수 내부에서 console.log를 찍어보면 문자 배열 길이가 9인 것을 확인할 수 있습니다.

이미지2

또한 Promise를 사용해 typing 함수를 여러 번 호출하더라도 순서대로 한 줄씩 화면에 출력되도록 구성했습니다.

출력 결과

typing 함수를 출력한 결과는 다음과 같습니다. 사진처럼 자음/모음이 잘 분리되어 출력되며, 한글이 아닌 다른 문자를 넣어도 자연스럽게 출력되는 것을 확인할 수 있습니다.

이미지3

쌍자음과 복합 종성 테스트

쌍자음이나 복합 종성이 포함된 문자에서도 정상 동작하는지 확인한 결과입니다.

입력분해 결과타이핑 시퀀스정상 여부
ㄱ, ㅗ, ㅁㄱ → 고 → 곰O
ㅆ, ㅗ, ㄷㅆ → 쏘 → 쏟O
ㄷ, ㅏ, ㄺㄷ → 다 → 닭O
ㅇ, ㅓ, ㅄㅇ → 어 → 없O
ㅇ, ㅣ, ㄺㅇ → 이 → 읽O
ㅂ, ㅞ, ㄺㅂ → 붸 → 뷁O

쌍자음과 복합 종성 모두 배열에 포함되어 있어 분해 자체는 문제없이 동작합니다. 다만 복합 종성은 하나의 단위로 처리되므로, "닭"은 ㄷ → 다 → 닭으로 출력됩니다. 실제 키보드처럼 ㄷ → 다 → 달 → 닭으로 보여주려면 복합 종성을 개별 자음으로 추가 분해하는 로직이 필요합니다.

// 복합 종성 분해 매핑
const complexFinal = {
  'ㄳ': ['ㄱ', 'ㅅ'], 'ㄵ': ['ㄴ', 'ㅈ'], 'ㄶ': ['ㄴ', 'ㅎ'],
  'ㄺ': ['ㄹ', 'ㄱ'], 'ㄻ': ['ㄹ', 'ㅁ'], 'ㄼ': ['ㄹ', 'ㅂ'],
  'ㄽ': ['ㄹ', 'ㅅ'], 'ㄾ': ['ㄹ', 'ㅌ'], 'ㄿ': ['ㄹ', 'ㅍ'],
  'ㅀ': ['ㄹ', 'ㅎ'], 'ㅄ': ['ㅂ', 'ㅅ'],
};

이 매핑을 활용하면 "닭"을 ㄷ → 다 → 달 → 닭 4단계로 출력할 수 있어, 체감 타이핑 자연스러움이 한층 올라갑니다.

샘플 코드

github 저장소 이동

참고 자료