'한'은 한 글자일까요?
중복 가입을 막는 코드를 리뷰하다 멈춘 적이 있습니다. 닉네임이 이미 있는지 확인하는 조건문이었는데, 로직에는 아무 문제가 없었습니다. 문제는 그 위였습니다. 두 문자열이 같은지 묻는 그 한 줄이, 제가 생각한 "같다"와 같은 뜻인지 저는 확신할 수 없었습니다.
그동안 문자열은 숫자와 비슷한 것이라고 여겨왔습니다. 값이 들어 있고, 비교하면 되고, 길이를 재면 되는 것. 그런데 화면에 똑같이 보이는 두 글자가 다른 값일 수 있고, 같은 글자의 길이가 1이기도 3이기도 하다면 그 전제부터 다시 봐야 합니다.
우리가 코드에서
string이라고 부르는 값은 정확히 무엇이고, 그 타입은 무엇을 말해주지 않고 있을까요?
이 글에서 다루는 내용
이 글은 특정 API 사용법이 아니라 문자열이라는 원시값에 숨어 있는 계약을 다룹니다. 같아 보이는 두 글자가 왜 다른 값인지, 글자 수를 센다는 말이 왜 그 자체로 모호한지, 그리고 사용자가 타이핑하는 동안 그 값이 아직 존재하지 않을 수도 있다는 것까지 봅니다.
앞의 네 장은 문자열이 생각보다 복잡하다는 지식이고, 뒤의 두 장은 그래서 시스템을 어떻게 설계해야 하는가에 대한 이야기입니다. 실무에서 값을 치르는 쪽은 뒤의 두 장입니다.
- 1. 문자열은 한 종류가 아닙니다
- 2. 같아 보인다고 같은 값은 아닙니다
- 3. '한 글자'는 세는 방법마다 달라집니다
- 4. 입력된 값이 아직 완성된 값이 아닐 수도 있습니다
- 5. 문자열의 계약은 경계에서 결정합니다
- 6. 정규화는 데이터 정책입니다
- References
1. 문자열은 한 종류가 아닙니다
먼저 코드 한 조각으로 시작하겠습니다. 두 변수 모두 화면에서는 '한'이라는 한 글자로 보입니다.
// 코드 포인트를 고정하기 위해 양쪽 모두 escape로 적는다
const nfc = '\uD55C' // 한, 완성된 음절 하나
const nfd = '\u1112\u1161\u11AB' // 초성·중성·종성을 조합한 것
nfc === nfd // false
nfc.length // 1
nfd.length // 3
같은 글자를 두고 프로그램은 다르다고 판단하고, 길이조차 다르게 셉니다. 여기서 두 가지가 한꺼번에 무너집니다. 이 값이 무엇과 같은지에 대한 가정과, 이 값이 몇 글자인지에 대한 가정입니다.
그리고 하나가 더 있습니다. 사용자가 키보드로 이 글자를 입력하는 동안에는 '한'라는 값 자체가 아직 만들어지지 않았을 수 있습니다.
문자열에 걸려 있는 세 가지 계약
| 계약 | 흔히 하는 가정 | 실제로 답해야 하는 질문 |
|---|---|---|
| 동일성 | 똑같이 보이면 같은 문자열이다 | 이 값은 무엇을 기준으로 같은가 |
| 계량 | length는 글자 수다 | 이 값에서 한 글자는 무엇인가 |
| 확정성 | 입력창에 들어온 값은 이미 완성됐다 | 이 값은 언제 최종값이 되는가 |
string이라는 타입은 이 셋 중 어느 것도 말해주지 않습니다. 함수 시그니처를 하나 보겠습니다.
function saveName(name: string) {}
이 name이 조합 중인 값인지 확정된 값인지, 어떤 정규형으로 들어왔는지, 사용자가 입력한 원본인지 검색용으로 가공된 값인지, 길이 제한은 어떤 단위로 걸려 있는지. 전부 타입 바깥에 있습니다. 컴파일러는 이 중 무엇도 검사하지 않고, 문서에 적혀 있지 않으면 호출하는 쪽과 받는 쪽이 서로 다른 가정을 해도 아무도 모릅니다.
Recap
문자열에는 동일성, 계량, 확정성이라는 세 가지 암묵적 계약이 걸려 있습니다. 같아 보인다고 같은 값이 아니고, length가 글자 수가 아니며, 입력창의 값이 아직 완성되지 않았을 수 있습니다. string 타입은 이 셋 중 어느 것도 표현하지 않으므로, 계약은 코드 바깥의 합의로 남습니다.
2. 같아 보인다고 같은 값은 아닙니다
'한'을 표현하는 방법이 둘인 것은 유니코드의 버그가 아니라 설계입니다. 기존 문자 집합과 왕복 변환을 보장해야 했기 때문에, 이미 완성형 음절을 쓰던 인코딩과 자모를 조합하던 인코딩 양쪽을 모두 수용해야 했습니다. 그래서 유니코드는 두 표현을 모두 인정하는 대신, 이 둘이 표준적으로 동등(canonical equivalence)하다고 규정했습니다.
여기서 중요한 것은 이 규정의 성격입니다. 유니코드는 "두 값이 같은 글자를 나타낸다"고 선언했을 뿐, 프로그래밍 언어의 ===가 그것을 알아야 한다고 강제하지는 않습니다.
언어 하나 안에서도 판정이 셋입니다
DB나 검색엔진을 꺼내기 전에, JavaScript 표준 라이브러리만으로도 답이 갈립니다.
const nfc = '\uD55C' // 완성형 '한'
const nfd = '\u1112\u1161\u11AB' // 조합형 '한'
nfc === nfd // false
nfc.normalize('NFC') === nfd.normalize('NFC') // true
new Intl.Collator('ko').compare(nfc, nfd) // 0 (같다고 판정)
===는 코드 단위 배열을 그대로 비교하므로 다르다고 답하고, Intl.Collator는 표준적 동등성을 아는 비교이므로 같다고 답합니다. 둘 다 맞습니다. 서로 다른 질문에 답하고 있을 뿐입니다.
그래서 "이 두 문자열은 같은가"라는 질문에는 시스템이 답을 정해주지 않습니다. 동일성은 주어지는 성질이 아니라 우리가 고르는 정책입니다. 고르지 않으면 각 레이어가 알아서 다른 답을 고릅니다.
네 가지 정규형
정규화(normalization)는 여러 표현 중 하나를 대표로 정하는 작업이고, 유니코드는 네 가지를 정의합니다.
| 정규형 | 하는 일 | 호환 문자 구별을 보존하는가 |
|---|---|---|
| NFC | 표준적 분해 후 최대한 합성 | 예 |
| NFD | 표준적으로 분해 | 예 |
| NFKC | 호환 분해 후 합성 | 아니오 |
| NFKD | 호환 분해 | 아니오 |
NFC와 NFD는 같은 글자를 다른 모양으로 담습니다. 다만 어느 쪽도 역함수가 있는 변환은 아닙니다. 정규화는 동등한 여러 시퀀스를 하나의 정규형으로 모으는 작업이므로, 되돌리면 원래의 코드 포인트 배열이 나온다는 뜻이 아닙니다. K가 붙은 둘은 여기서 한 걸음 더 나가 호환 문자의 구별까지 지웁니다.
'①'.normalize('NFKC') // '1'
'ア'.normalize('NFKC') // 'ア'
①은 1이 되고 되돌아오지 않습니다. NFKC는 검색 색인처럼 "웬만하면 같은 것으로 쳐야 하는" 자리에서는 쓸모가 있지만, 사용자가 입력한 값을 저장하는 자리에 쓰면 원본을 파괴합니다. 그래서 "정규화하세요"는 절반만 맞는 조언입니다. 넷 중 어느 것인지, 어느 자리에서인지를 함께 말해야 실행할 수 있는 조언이 됩니다.
자판의 ㅎ은 호환용 자모 U+314E이고, NFD가 '한'을 분해해서 내놓는 ㅎ은 조합용 초성 U+1112입니다. 모양이 같아 보여도 코드 포인트가 다르고, 서로 다른 목적으로 존재합니다. 자모를 직접 비교하는 코드를 짤 일이 있다면 이 둘부터 구분해야 합니다.
레이어마다 답이 다릅니다
애플리케이션 하나를 지나가는 동안 문자열은 여러 시스템을 통과하고, 각 시스템에는 이미 자기만의 동일성 규칙이 있습니다.
- DB: 콜레이션(collation)이 비교 규칙입니다.
utf8mb4_general_ci는 대소문자를 같게 보고, 유니크 제약도 이 규칙 위에서 동작합니다. 애플리케이션이 다르다고 판단한 두 값을 DB가 중복이라며 거절할 수 있고, 그 반대도 됩니다. - 검색엔진: 애널라이저가 소문자화, 정규화, 형태소 분석을 거친 뒤 색인합니다. 저장된 값과 색인된 값은 처음부터 다른 문자열입니다.
- 파일시스템: HFS+는 파일명을 분해된 형태로 저장했습니다. 그래서 macOS에서 만든 한글 파일명이 다른 환경으로 넘어가면 자모가 흩어져 보이는 일이 오래 있었습니다. 현재의 APFS는 다릅니다. 주어진 표현을 그대로 보존하면서 비교할 때만 정규화 차이를 무시합니다.
APFS의 처리 방식은 이 장의 요지를 그대로 보여줍니다. 표현을 보존하는 것과 동일성을 판정하는 것은 별개의 결정이고, 둘을 따로 정할 수 있습니다.
다만 그 관대함은 파일시스템 안에서만 유효합니다. 같은 이름이 zip 엔트리로, S3 키로, HTTP 경로로, DB 컬럼으로 나가는 순간 다시 평범한 바이트 열이 되고 정규화를 무시해주는 비교는 어디에도 없습니다. 한 레이어가 문제를 흡수해줬다고 해서 그 문제가 사라진 것은 아닙니다.
Recap
유니코드는 두 표현이 표준적으로 동등하다고 규정하지만 ===는 그 규정을 모릅니다. JavaScript 안에서만도 ===, normalize 후 비교, Intl.Collator가 서로 다른 답을 내놓고, DB 콜레이션과 검색 애널라이저와 파일시스템은 각자 또 다른 규칙을 이미 갖고 있습니다. 동일성은 고르는 것이고, 고르지 않으면 레이어마다 제각기 정해집니다.
3. '한 글자'는 세는 방법마다 달라집니다
length가 무엇을 세는지 물으면 대개 글자 수라고 답합니다. 실제로는 UTF-16 코드 단위의 개수입니다. 평소에 이 둘이 일치하기 때문에 구분할 일이 없었을 뿐입니다.
같은 값을 네 가지 단위로 세어보면 이렇게 갈립니다.
| 값 | 코드 단위 | 코드 포인트 | 자소 클러스터 | UTF-8 바이트 |
|---|---|---|---|---|
'\uD55C' (완성형 '한') | 1 | 1 | 1 | 3 |
'\u1112\u1161\u11AB' (조합형 '한') | 3 | 3 | 1 | 9 |
| 👨👩👧 (가족 이모지) | 8 | 5 | 1 | 18 |
사람이 한 글자로 인식하는 단위는 맨 오른쪽에서 두 번째, 자소 클러스터(grapheme cluster)입니다. 세 값 모두 여기서는 1입니다. 나머지 세 열은 전부 저장 방식의 사정이고, 사용자와는 상관이 없습니다.
// 자소 클러스터로 세려면 별도의 도구가 필요하다
const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' })
;[...seg.segment('\u1112\u1161\u11AB')].length // 1
잘못 세면 자를 때 드러납니다
세는 단위를 정하지 않은 채 숫자만 정해두면, 그 숫자로 문자열을 자르는 순간 결과가 나옵니다.
'\u1112\u1161\u11AB'.slice(0, 1) // '\u1112' 초성 하나만 남는다
'\u{1F468}\u200D\u{1F469}\u200D\u{1F467}'.slice(0, 2) // 아버지만 남는다
말줄임 처리가 자소 클러스터 중간을 자르면 깨진 글자가 남거나 다른 뜻의 이모지가 됩니다. 가족 이모지에서 아버지만 남기는 쪽이 특히 곤란합니다. 깨진 문자는 버그처럼 보이지만 이건 멀쩡한 문자로 보이기 때문에 아무도 신고하지 않습니다.
같은 VARCHAR(n)이 다른 상한입니다
바이트 열이 맨 오른쪽 열에 있는 이유는 저장소가 그것으로 세는 경우가 있기 때문입니다.
- MySQL과 PostgreSQL의
VARCHAR(n)은 문자 수로 셉니다. - Oracle의
VARCHAR2(n)은 기본이 바이트입니다. 문자 수로 세려면VARCHAR2(n CHAR)처럼 단위를 명시해야 합니다. - MySQL의 예전
utf8은 한 문자를 최대 3바이트까지만 저장해서 이모지가 들어가지 않았습니다. 지금의utf8mb4가 그 자리를 대신합니다.
여기에 프론트엔드의 maxLength가 얹힙니다. HTML의 maxlength 속성은 코드 단위를 셉니다. 그래서 입력창이 20자까지 허용한 값을 DB가 거절하는 상황이 생깁니다. 두 쪽 다 20이라고 적혀 있는데 세는 대상이 다릅니다.
Recap
length는 코드 단위를 세고, 사람이 한 글자로 보는 단위는 자소 클러스터입니다. 그 사이에 코드 포인트와 바이트가 더 있어 같은 값을 네 가지로 셀 수 있습니다. 세는 단위를 정하지 않고 상한만 정하면 입력창과 저장소가 서로 다른 20자를 뜻하게 되고, 자르는 순간 깨진 글자가 남습니다.
4. 입력된 값이 아직 완성된 값이 아닐 수도 있습니다
여기까지는 값이 이미 만들어졌다고 전제했습니다. 한글 입력에서는 그 전제부터 흔들립니다.
'한'을 치려면 ㅎ, ㅏ, ㄴ을 순서대로 누릅니다. 사용자에게는 한 글자를 입력하는 하나의 행위지만, 애플리케이션이 관찰하는 것은 여러 개의 중간 상태입니다.
사용자가 누른 것 ㅎ → ㅏ → ㄴ
화면에 보이는 것 ㅎ → 하 → 한
애플리케이션이 본 것 값이 세 번 바뀌었다
이 과정을 담당하는 것이 입력기(IME, Input Method Editor)입니다. 한글, 일본어, 중국어처럼 키 입력과 글자가 일대일로 대응하지 않는 언어에서 조합을 맡습니다.
애플리케이션은 구조적으로 늦게 압니다
키 입력은 운영체제의 입력기를 먼저 지납니다. 입력기가 조합 상태를 갱신한 뒤에야 브라우저가, 그다음에 애플리케이션이 결과를 받습니다. 조합의 규칙을 정하는 쪽은 애플리케이션이 아닙니다.
그래서 IME 입력은 일반 키 입력처럼 keydown에서 preventDefault를 부르는 것만으로 제어되지 않습니다. 조합 중에 일어나는 DOM 갱신은 취소할 수 없는 입력 이벤트로 전달될 수 있습니다. 입력을 막으려 드는 쪽보다 조합 상태를 인식해서 처리하는 쪽이 안전합니다.
브라우저는 조합 과정을 세 개의 이벤트로 알려줍니다. 조합이 시작될 때 compositionstart, 조합 중 값이 바뀔 때마다 compositionupdate, 조합이 확정될 때 compositionend입니다. 그리고 조합 중에도 input 이벤트는 계속 발생합니다. 이때 이벤트의 isComposing이 true입니다.
input.addEventListener('input', (e) => {
if (e.isComposing) return // 아직 조합 중인 값이다
handle(e.target.value)
})
조합 중인 값이 새어 나가는 곳
isComposing을 보지 않으면 미완성 상태가 그대로 시스템으로 흘러갑니다.
- 실시간 검증: ㅎ, 하, 한을 각각 검증하고 조합 도중에 오류 메시지를 띄웁니다.
- 자동 저장: 조합 중인 자모가 그대로 저장되고, 나중에 목록에서 깨진 글자로 발견됩니다.
- 디바운스 검색: 조합이 끝나기 전에 타이머가 만료되어 자모로 질의합니다. 결과가 없거나 엉뚱합니다.
- 키보드 단축키: 조합 중 눌린 Enter는 확정을 뜻하는데, 이걸 전송으로 처리하면 글자가 잘린 채 메시지가 나갑니다.
마지막 항목은 채팅 입력창에서 특히 자주 겪습니다. 한글을 다 치고 Enter를 눌렀는데 마지막 글자만 남아 두 번 전송되는 그 현상입니다.
controlled input은 값을 되써 넣을 때 조심해야 합니다
React처럼 상태가 값을 되돌려 쓰는 구조에서는 주의할 지점이 하나 더 있습니다. 매 입력마다 e.target.value를 그대로 다시 쓰는 평범한 controlled input 자체는 조합을 깨뜨리지 않습니다. 문제가 되는 것은 조합 중에 그 값과 다른 값을 주입할 때입니다. 값을 가공하거나 형식을 맞추거나 걸러내는 경우, 그리고 상태 갱신이 비동기로 늦게 도착해 DOM이 들고 있던 값을 덮는 경우입니다. 커서가 튀거나 글자가 중복되는 증상이 여기서 나옵니다. 조합 중에는 값을 손대지 않고 그대로 두는 편이 안전합니다.
역사적으로는 isComposing이 없던 시절에 keyCode === 229를 확인하는 관용구가 쓰였습니다. 조합 중이라는 사실을 알려주는 표준적인 방법이 없어서, 입력기가 처리 중일 때 브라우저가 넣어주던 값을 보고 짐작한 것입니다. 지금은 isComposing과 beforeinput으로 같은 판단을 훨씬 분명하게 할 수 있습니다.
compositionend와 마지막 input 이벤트의 발생 순서는 브라우저마다 달랐던 역사가 있습니다. 순서에 의존하는 코드를 짜야 한다면 대상 브라우저에서 실제로 찍어보고 확인하는 편이 안전합니다.
Recap
한글 입력은 키 하나에 글자 하나가 대응하지 않아 여러 중간 상태를 거칩니다. 애플리케이션은 입력기가 결정을 내린 뒤에 결과를 받으므로 그 과정에 개입하기 어렵고, 조합 중에도 input 이벤트는 계속 발생합니다. isComposing을 보지 않으면 미완성 값이 검증, 저장, 검색, 전송으로 그대로 새어 나갑니다.
5. 문자열의 계약은 경계에서 결정합니다
앞의 네 장은 문자열이 생각보다 복잡하다는 이야기였습니다. 여기서부터는 그래서 무엇을 해야 하는가입니다.
이 계열의 버그가 계속 재발하는 이유는 한 지점씩 고치기 때문입니다. 검색이 안 되면 검색 쪽에 normalize를 넣고, 중복 체크가 뚫리면 그 조건문을 고칩니다. 각각은 맞는 수정이지만 다음 레이어에서 같은 문제가 다시 나옵니다. 레이어마다 합의가 다른 것이 원인인데 레이어 하나씩 손대고 있기 때문입니다.
그래서 결정은 문자열이 시스템에 들어오는 경계에서 한 번 내려야 합니다. 답해야 할 질문은 넷입니다.
- 언제 확정된 입력으로 볼 것인가. 조합이 끝난 값만 받을 것인지, 조합 중인 값도 처리할 것인지.
- 무엇을 같은 것으로 볼 것인가. 정규화 차이를 무시할 것인지, 대소문자는 어떻게 할 것인지.
- 어떤 정규형을 어디서 적용할 것인가. 저장, 비교, 색인이 각각 다른 답을 가질 수 있습니다.
- 무엇을 한 글자로 셀 것인가. 입력창, 검증, 저장소가 같은 단위를 써야 합니다.
용도별로 답이 갈리는 것이 자연스럽습니다. 예를 들어 원본은 표시용으로 보존하고, 비교에는 NFC를, 검색 색인에는 NFKC나 소문자화 같은 별도 규칙을 적용할 수 있습니다. 어느 조합이 맞는지는 요구사항이 정합니다. 다만 하나의 정규형으로 전부 통일하려 들면 어딘가에서 원본이 파괴되거나 검색이 덜 걸립니다.
그리고 이 결정들을 코드 여러 곳에 흩어두지 않는 편이 낫습니다. 비교 함수를 한 곳에 두고 모두가 그것을 쓰면, 정책이 바뀔 때 고칠 자리가 하나입니다. ===를 직접 쓰는 코드가 여기저기 있으면 정책이라는 것이 사실상 없는 셈입니다.
Recap
한 지점씩 고치면 다음 레이어에서 같은 버그가 다시 나옵니다. 문자열이 들어오는 경계에서 확정 시점, 동일성 기준, 정규형, 계량 단위를 한 번에 정하고, 그 판단을 담은 비교 함수를 한 곳에 두어야 정책이 정책으로 남습니다.
6. 정규화는 데이터 정책입니다
정규화를 도입하기로 했다면, 그것은 코드를 고치는 일이 아니라 데이터를 다루는 일입니다. 실제로 값을 치르는 지점이 셋 있습니다.
원본을 덮어쓰지 않습니다
정규화한 값으로 원본을 대체하면 되돌릴 수 없습니다. NFC와 NFD 사이라면 글자 자체는 보존되지만, 사용자가 보낸 코드 포인트 배열 그대로는 아닙니다. NFKC를 적용하면 손실이 글자 수준으로 올라갑니다. ①은 1이 되어 영영 돌아오지 않습니다.
그래서 비교와 색인에 쓸 값을 따로 두고, 사용자에게 보여줄 원본은 그대로 보관하는 편이 안전합니다. 컬럼 하나를 더 쓰는 대신 표시와 검색이 각자 옳은 값을 갖습니다. 2장에서 본 APFS가 하는 일이 정확히 이것입니다. 표현은 보존하고 비교만 관대하게 합니다.
이미 저장된 데이터가 있습니다
비교 규칙만 바꾸고 기존 행을 그대로 두면, 옛 행과 새 행이 서로 다른 규칙 아래 놓입니다. 오늘 가입한 사용자와 작년에 가입한 사용자의 중복 판정이 달라지고, 이런 종류의 불일치는 재현 조건이 가입 시점이라 디버깅이 오래 걸립니다.
정규화 도입은 정책 결정처럼 보이지만 실행 단계에서는 데이터 마이그레이션입니다. 기존 데이터를 어떻게 옮길지, 옮기는 동안 두 규칙이 공존하는 기간을 어떻게 다룰지가 함께 계획되어야 합니다.
딸려 오는 것들이 있습니다
문자열의 동일성 기준을 바꾸면 그 기준 위에 세워진 것들이 함께 움직입니다.
- 유니크 제약과 인덱스는 비교 규칙 위에서 동작하므로 재생성이 필요할 수 있습니다.
- 캐시 키가 문자열이라면 정규화 전후로 키가 달라져 캐시가 통째로 미스가 됩니다.
- 검색 색인은 다시 만들어야 합니다.
- 외부 시스템과 주고받는 값이라면 상대방의 규칙과 맞는지 확인해야 합니다.
이 목록이 길어 보인다면, 그것이 바로 문자열의 동일성이 시스템 전체에 걸린 계약이라는 증거입니다.
Recap
정규화는 비가역일 수 있으므로 표시용 원본과 비교용 값을 분리해서 보관합니다. 규칙만 바꾸고 기존 데이터를 두면 저장 시점에 따라 판정이 갈리므로, 도입은 사실상 마이그레이션 과제입니다. 유니크 제약, 인덱스, 캐시 키, 검색 색인이 그 기준 위에 서 있어 함께 움직입니다.
정리
string이라는 타입에는 사실상 아무 정보도 들어 있지 않습니다. 조합 중인지 확정됐는지, 어떤 정규형인지, 원본인지 가공된 값인지, 어떤 단위로 길이가 제한됐는지. 우리가 문자열에 걸어둔 계약은 전부 타입 바깥에 있습니다.
그중 일부는 타입에 새길 수 있습니다. 브랜디드 타입으로 "정규화를 거친 값"과 "사용자 원본"을 다른 타입으로 만들면 컴파일러가 뒤섞임을 잡아줍니다. 이 방향의 이야기는 타입스크립트로 잘못된 상태 막기에서 다뤘습니다.
다만 타입은 결정을 강제해줄 뿐 결정을 대신해주지는 않습니다. 문자열을 다루는 시스템은 결국 세 가지 질문에 답해야 합니다.
무엇을 같은 것으로 볼 것인가. 무엇을 한 글자로 셀 것인가. 그리고 언제 그 값을 완성된 것으로 볼 것인가.
'한'은 한 글자입니다. 사람에게는 그렇습니다. 그 사실을 프로그램도 알게 하려면 세 번의 결정이 필요합니다.
References
유니코드 표준
- The Unicode Standard, Chapter 3: Conformance
- UAX #15: Unicode Normalization Forms
- UAX #29: Unicode Text Segmentation
- Unicode FAQ: Normalization
언어와 브라우저
- ECMAScript: Source Text
- MDN: String.prototype.normalize()
- MDN: Intl.Segmenter
- MDN: KeyboardEvent.isComposing
- MDN: Element compositionstart event
- W3C UI Events: Composition Events
- W3C: Input Events Level 2
- React:
<input> - MDN: maxlength 속성
저장소와 파일시스템
- Apple: Technical Note TN1150, HFS Plus Volume Format
- Apple File System Guide: FAQ
- Git: core.precomposeUnicode
- MySQL: The utf8mb4 Character Set
