본문으로 건너뛰기
김도현

타이핑 중인 한글을 정규식으로 찾기

영어를 한 글자씩 입력받아 거르는 건 조건 하나로 끝난다. sea까지 쳤을 때 search가 걸리는 이유는 중간 상태가 결과의 접두사이기 때문이다. 입력창에 찍힌 문자열을 그대로 startsWith에 넣으면 된다.

한글은 이 전제가 깨진다. 입력기가 자모를 음절로 조합하기 때문에 타이핑 도중 입력창에 실제로 찍히는 문자열은 목표의 접두사가 아니다. "검색"을 치는 동안 입력창을 거쳐 가는 상태는 이렇다.

누른 키입력창'검색'.startsWith(입력창)
false
false
true
검ㅅfalse
검새false
검색true

여섯 단계 중 넷이 탈락한다. includes로 바꿔도 결과는 같다. 사용자 입장에서는 멀쩡히 "검색"을 치고 있는데 결과 목록이 깜빡거린다.

결과 없음

자모 분해가 잃는 것

가장 먼저 떠오르는 해법은 양쪽을 자모로 분해해서 비교하는 것이다. "검새"는 ㄱㅓㅁㅅㅐ, "검색"은 ㄱㅓㅁㅅㅐㄱ이 되니 접두사 관계가 복원된다.

문제는 결과를 어디에 쓰느냐다. 검색 결과에서 매칭된 부분을 강조하려면 원문 기준 인덱스가 필요하다. 분해된 문자열에서 4번째 글자가 원문에서 몇 번째인지 알려면, 음절마다 자모 개수가 달라서 따로 매핑 테이블을 들고 다녀야 한다. 문서 본문 전체를 분해해 두는 비용도 같이 붙는다.

정규식은 이 두 문제를 동시에 피한다. 원문 위에서 그대로 돌고 execindex와 매칭 길이를 원문 좌표로 돌려준다. 남은 일은 "조합 중일 수 있다"는 조건을 정규식으로 번역하는 것뿐인데, 유니코드가 한글 음절을 배열해 둔 방식 덕분에 이게 문자 범위 하나로 떨어진다.

같은 모양 다른 글자

정규식으로 "이 자리에 올 수 있는 글자들"을 적으려면 그 글자가 코드포인트 하나여야 한다. 그런데 유니코드는 이걸 보장해주지 않는다.

여러 요소가 합쳐진 글자를 유니코드는 두 가지 방식으로 표현한다. 합쳐진 모양 자체에 코드포인트를 하나 배정하거나, 구성 요소를 순서대로 나열한다. é는 U+00E9 하나로도 적을 수 있고 e(U+0065) 뒤에 액센트 기호(U+0301)를 붙여 둘로도 적을 수 있다. 화면에는 똑같이 보이지만 length=== 결과도 다르다.

이 둘을 한쪽으로 몰아주는 게 정규화(normalization)다1. 합치는 쪽이 NFC(Composition), 풀어헤치는 쪽이 NFD(Decomposition)다. 한글 음절도 정확히 이 대상이다.

'é'.normalize('NFC'); // U+00E9 (길이 1)
'é'.normalize('NFD'); // U+0065 U+0301 (길이 2)
'한'.normalize('NFC'); // U+D55C (길이 1)
'한'.normalize('NFD'); // U+1112 U+1161 U+11AB (길이 3)

NFC는 음절 하나가 코드포인트 하나다. NFD는 초성·중성·종성이 따로 놓인 길이 3짜리 문자열이고, 이 자모들은 뒤에서 다룰 음절 영역이 아니라 U+1100 블록에 있다. 그래서 한쪽만 NFD로 바꾸면 같은 글자끼리도 어긋난다. '검색'.normalize('NFD').includes('검')false가 된다.

macOS 파일 시스템에서 읽어온 파일명이나 일부 API 응답이 이 형태로 들어온다. 그래서 색인을 만들 때 본문을 한 번 NFC로 맞춰 두고, 입력도 같은 형태로 받는다.

match.ts
export const tokenize = (query: string): string[] =>
query.toLowerCase().normalize('NFC').split(/\s+/).filter(Boolean);

이제 한 음절이 코드포인트 하나라고 놓고 시작할 수 있다.

빈칸 없이 줄지어 선 11,172자

유니코드의 한글 음절 영역은 U+AC00부터 U+D7A3까지 11,172자다2. 19(초성) × 21(중성) × 28(종성)과 정확히 같은 수이고 실제로 그 순서대로 빈칸 없이 채워져 있다.

세 자리에 올 수 있는 자모는 고정된 목록이다. 유니코드가 음절을 배열한 순서와 똑같은 순서로 적어 두면 인덱스와 자모 사이를 배열 접근만으로 오갈 수 있다.

배열길이내용
CHOSUNG19ㄱ ㄲ ㄴ ㄷ ㄸ ㄹ ㅁ ㅂ ㅃ ㅅ ㅆ ㅇ ㅈ ㅉ ㅊ ㅋ ㅌ ㅍ ㅎ
JUNGSUNG21부터 까지, 복합 모음 포함
JONGSUNG28빈 문자열 + 받침 27종

종성 배열의 첫 칸은 빈 문자열이다. 받침 없음도 한 자리를 차지하기 때문에 27종에 하나를 더해 28이 된다. 중성과 종성 배열이 어떤 표기로 적혀 있는지는 규칙 2에서 본다.

hangul.ts
const SYLLABLE_START = 0xac00;
const SYLLABLE_END = 0xd7a3;
const SYLLABLES_PER_CHOSUNG = JUNGSUNG.length * JONGSUNG.length;
export const isSyllable = (code: number) => code >= SYLLABLE_START && code <= SYLLABLE_END;
export const toChosungIndex = (character: string) => CHOSUNG.indexOf(character);
const toJamoIndexes = (code: number) => {
const offset = code - SYLLABLE_START;
return {
chosung: Math.floor(offset / SYLLABLES_PER_CHOSUNG),
jungsung: Math.floor(offset / JONGSUNG.length) % JUNGSUNG.length,
jongsung: offset % JONGSUNG.length,
};
};
const toFirstSyllable = (chosungIndex: number) =>
SYLLABLE_START + chosungIndex * SYLLABLES_PER_CHOSUNG;

종성이 가장 안쪽 자리라 28자마다 한 바퀴를 돌고, 중성이 바뀌면 28자씩, 초성이 바뀌면 588자(21 × 28)씩 건너뛴다. toFirstSyllable이 초성 인덱스에 588을 곱하는 것도 그래서다. 중요한 건 나눗셈이 아니라 그 결과다. 같은 조건을 만족하는 음절들이 코드포인트 상에서 붙어 있다. 초성이 인 음절 588자는 부터 까지 끊김 없이 이어진다. 조건을 열거할 필요 없이 범위 하나로 적을 수 있다는 뜻이다.

규칙 1 - 초성만 찍힌 상태

하나만 찍힌 상태는 "초성이 인 아무 음절"과 맞아야 한다. 초성 인덱스로 블록의 시작과 끝을 계산하면 끝난다.

hangul.ts
export const toChosungPattern = (chosungIndex: number) => {
const first = toFirstSyllable(chosungIndex);
const last = first + SYLLABLES_PER_CHOSUNG - 1;
return `[${CHOSUNG[chosungIndex]}${String.fromCharCode(first)}-${String.fromCharCode(last)}]`;
};

[ㄱ가-깋]이 된다. 범위 앞에 자모 을 하나 더 넣어둔 건, 본문에 ㄱㄴㄷ처럼 자모가 그대로 적혀 있는 경우까지 걸리게 하기 위해서다.

규칙 2 - 아직 조합 중인 글자

까지 친 상태에서 사용자가 멈춘 게 아니라면, 다음 키는 이 글자에 붙을 수도 있고 새 글자를 시작할 수도 있다. 자신은 물론 , , … 전부를 후보로 둔다. 종성이 없는 음절이므로 뒤따르는 28자가 통째로 후보다.

다음에 를 누르면 새 글자가 아니라 가 된다. 중성 자체가 자라는 것이다. 그래서 중성 테이블은 복합 모음을 합쳐진 글자가 아니라 누른 순서 그대로 적어 둔다.

hangul.ts (일부)
const JUNGSUNG = [
'ㅏ',
'ㅐ',
'ㅑ',
'ㅒ',
'ㅓ',
'ㅔ',
'ㅕ',
'ㅖ',
'ㅗ',
'ㅗㅏ',
'ㅗㅐ',
'ㅗㅣ',
'ㅛ',
'ㅜ',
'ㅜㅓ',
'ㅜㅔ',
'ㅜㅣ',
'ㅠ',
'ㅡ',
'ㅡㅣ',
'ㅣ',
];

ㅗㅏ로, ㅗㅐ로 적어 두면 음절을 자모 문자열로 펼쳤을 때 접두사 비교가 그대로 성립한다. ㄱㅗ, ㄱㅗㅏ이므로 후자가 전자로 시작한다. 종성 배열도 겹받침을 ㄱㅅ, ㄴㅈ처럼 누른 순서로 적어 두므로 에서 으로 자라는 것까지 한 조건으로 처리된다.

펼치는 일 자체는 인덱스 셋을 뽑아 이어 붙이는 게 전부다.

hangul.ts
const toJamoSequence = (code: number) => {
const { chosung, jungsung, jongsung } = toJamoIndexes(code);
return CHOSUNG[chosung] + JUNGSUNG[jungsung] + JONGSUNG[jongsung];
};

ㄱㅗㅏ, ㅇㅏㄴㅈ, 받침 없는 는 종성 칸이 빈 문자열이라 ㄱㅗ가 된다.

hangul.ts
export const toComposingPattern = (code: number) => {
const jamo = toJamoSequence(code);
const first = toFirstSyllable(toJamoIndexes(code).chosung);
const candidates: number[] = [];
for (let candidate = first; candidate < first + SYLLABLES_PER_CHOSUNG; candidate++) {
if (toJamoSequence(candidate).startsWith(jamo)) candidates.push(candidate);
}
if (candidates.length === 1) return String.fromCharCode(candidates[0]);
return `[${toCharacterClassBody(candidates)}]`;
};

초성 블록 588자를 훑으면서 후보의 자모 문자열이 입력의 자모 문자열로 시작하는 것만 모은다. 모인 코드포인트는 대부분 연속이므로 toCharacterClassBody가 이어진 구간을 시작-끝으로 압축한다.

hangul.ts
const toCharacterClassBody = (codes: number[]) => {
const ranges: [start: number, end: number][] = [];
for (const code of codes) {
const last = ranges.at(-1);
if (last && code === last[1] + 1) last[1] = code;
else ranges.push([code, code]);
}
return ranges
.map(([start, end]) => {
const from = String.fromCharCode(start);
return start === end ? from : `${from}-${String.fromCharCode(end)}`;
})
.join('');
};
입력패턴무엇이 걸리나
[거-겋]받침이 붙을 자리가 비어 있다
[고-굏], , 까지 함께 걸린다
[안-않], 으로 겹받침이 자란다
[색샋]에서 으로만 자랄 수 있다

처럼 후보가 떨어져 있으면 압축이 안 되고 그냥 나열된다. 다음에 올 수 있는 겹받침은 하나뿐이라 두 글자만 남는다.

규칙 3 - 다음 글자로 넘어갈 받침

"미나리"를 칠 때 실제로 거쳐 가는 상태를 보면 이상한 게 하나 섞여 있다.

누른 키입력창
미나
미나ㄹ
미나리

이다. 입력기는 이 다음 글자의 초성인지 이번 글자의 받침인지 알 방법이 없어서 일단 받침으로 붙인다. 다음 모음이 들어오면 그때 아래로 내려보낸다. 즉 입력의 마지막 받침은 임시 상태일 수 있다.

그래서 마지막 글자에 받침이 있으면 그 받침을 떼어 다음 글자의 초성으로 옮긴 갈래를 하나 더 만들어 둔다.

hangul.ts
export const splitJongsung = (code: number) => {
if (!isSyllable(code)) return null;
const { jongsung } = toJamoIndexes(code);
if (jongsung === 0) return null;
const jamo = JONGSUNG[jongsung];
const moved = jamo[jamo.length - 1];
const remaining = JONGSUNG.indexOf(jamo.slice(0, -1));
return {
base: String.fromCharCode(code - jongsung + remaining),
chosungIndex: toChosungIndex(moved),
};
};

겹받침도 마지막 한 자만 넘어가므로 jamo.slice(0, -1)로 남는 받침을 되찾는다. 이면 + 이 된다.

조립

마지막 글자만 규칙 2·3의 대상이고 앞쪽 글자들은 이미 확정된 리터럴이다. 낱자가 끼어 있으면 규칙 1을 쓰고 그 외 문자는 정규식 메타문자만 이스케이프해서 그대로 둔다.

match.ts
const REGEXP_SYNTAX = /[.*+?^${}()|[\]\\]/;
const toCharacterPattern = (token: string, offset: number): string => {
const character = token[offset];
const code = token.charCodeAt(offset);
const isLast = offset === token.length - 1;
if (isLast && isSyllable(code)) return toComposingPattern(code);
const chosungIndex = toChosungIndex(character);
if (chosungIndex !== -1) return toChosungPattern(chosungIndex);
return character.replace(REGEXP_SYNTAX, '\\$&');
};
export const compile = (token: string) => {
const characterPatterns = Array.from({ length: token.length }, (_, offset) =>
toCharacterPattern(token, offset)
);
const whole = characterPatterns.join('');
const carriedJongsung = splitJongsung(token.charCodeAt(token.length - 1));
if (!carriedJongsung) return new RegExp(whole, 'g');
const head = characterPatterns.slice(0, -1).join('');
const carried = head + carriedJongsung.base + toChosungPattern(carriedJongsung.chosungIndex);
return new RegExp(`(?:${whole}|${carried})`, 'g');
};

처음의 여섯 단계를 다시 통과시키면 이렇게 나온다.

입력창컴파일된 패턴
[ㄱ가-깋]
[거-겋]
(?:검|거[ㅁ마-밓])
검ㅅ검[ㅅ사-싷]
검새검[새-샣]
검색(?:검[색샋]|검새[ㄱ가-깋])

여섯 단계 전부가 "검색"에 걸린다. (?:[민-믾]|미[ㄴ나-닣])가 되어 "미나리"를 놓치지 않는다.

원문 좌표 꺼내기

필요한 건 매칭 여부가 아니라 원문 몇 번째 글자부터 몇 글자인지다.

match.ts
type Range = [start: number, end: number];
export const findRanges = (text: string, tokens: string[]): Range[] => {
const lowerText = text.toLowerCase();
const ranges: Range[] = [];
for (const token of tokens) {
const regex = compile(token);
let found: RegExpExecArray | null;
while ((found = regex.exec(lowerText)) !== null)
ranges.push([found.index, found.index + found[0].length]);
}
return mergeRanges(ranges);
};

tokenize가 공백으로 쪼개 둔 토큰마다 정규식을 하나씩 돌린다. compileg 플래그를 달아 두므로 exec를 반복 호출하면 한 토큰이 여러 번 등장하는 경우까지 걸린다. 영문 대소문자를 무시하려고 소문자 사본 위에서 찾지만 index는 원문 위의 위치 그대로다. İ처럼 소문자로 바꾸면 길이가 늘어나는 문자도 있는데, 한글과 ASCII는 해당하지 않아 좌표가 어긋나지 않는다.

토큰이 여럿이면 구간이 겹치거나 순서가 뒤섞일 수 있어서 mergeRanges가 시작 위치로 정렬한 뒤 맞닿은 구간을 하나로 합친다. 남는 건 [시작, 끝] 목록이고, 화면에 칠하는 쪽은 text.slice로 잘라 <mark>를 끼우기만 하면 된다.

  • 미나리 재배 일지
  • 검색어 강조 알고리즘 개발기
  • 한글 입력기와 조합 중인 글자
  • 정규식으로 문서 찾기
  • 유니코드 한글 음절 배열
  • 고양이와 과일 가게

마무리

한글 점진 검색이 어려운 건 자모가 복잡해서가 아니라 입력기가 만들어내는 중간 상태가 결과의 접두사가 아니기 때문이다. 이 중간 상태는 제멋대로가 아니다. 초성만 찍힌 상태, 아직 자랄 수 있는 마지막 글자, 다음으로 넘어갈 수 있는 받침 세 가지로 정리된다.

그리고 유니코드 음절 영역이 초성·중성·종성 순서로 정렬돼 있어서 세 조건이 전부 문자 범위 표현으로 떨어진다. 자모 분해가 필요할 것 같던 문제가 문자 클래스 몇 개로 끝난다.

각주

  1. UAX #15: Unicode Normalization Forms

  2. Unicode - Hangul Syllables (U+AC00–U+D7AF)

관련 글