본문으로 건너뛰기
2026. 8. 15·약 12분

'한'은 한 글자일까요?

같은 글자 '한' 아래에 파란 칸 하나와 빨간 칸 셋이 나란히 놓인 그림

화면에는 똑같이 '한'으로 보이는 두 문자열이 있습니다. 그런데 프로그램은 둘을 다른 값이라고 판단하고, 길이도 하나는 1이고 다른 하나는 3이라고 셉니다.

이 둘이 닉네임이나 파일명이라면 같은 값으로 봐야 할까요? 최대 20자라는 조건에서는 둘 다 한 글자로 세야 할까요? 사용자가 한글을 조합하는 중이라면 입력창에 보이는 값을 이미 완성된 값으로 받아도 될까요?

'같다', '한 글자다', '입력이 끝났다'는 판단을 string이라는 타입만으로 내릴 수 있을까요?


이 글에서 다루는 내용​

같아 보이는 문자열의 동일성, 길이를 세는 단위, IME 입력이 확정되는 시점을 살펴본 뒤 이 규칙을 시스템 경계와 데이터 정책에 반영하는 방법을 정리합니다.





문자열은 한 종류가 아닙니다​

먼저 코드 한 조각으로 시작하겠습니다. 두 변수 모두 화면에서는 '한'이라는 한 글자로 보입니다.

// 코드 포인트를 고정하기 위해 양쪽 모두 escape로 적는다
const nfc = '\uD55C' // 한, 완성된 음절 하나
const nfd = '\u1112\u1161\u11AB' // 초성·중성·종성을 조합한 것

nfc === nfd // false
nfc.length // 1
nfd.length // 3

JavaScript 콘솔에서 NFD 문자열은 길이 3, NFC 문자열은 길이 1로 확인한 결과

같은 글자를 두고 프로그램은 다르다고 판단하고, 길이조차 다르게 셉니다. 이 값이 무엇과 같은지, 몇 글자인지에 대해 흔히 하던 가정이 한꺼번에 깨집니다.

그리고 하나가 더 있습니다. 사용자가 키보드로 이 글자를 입력하는 동안에는 '한'이라는 값 자체가 아직 만들어지지 않았을 수 있습니다.


문자열에 필요한 계약​

계약흔히 하는 가정실제로 답해야 하는 질문
동일성똑같이 보이면 같은 문자열이다이 값은 무엇을 기준으로 같은가
계량length는 글자 수다이 값에서 한 글자는 무엇인가
확정성입력창에 들어온 값은 이미 완성됐다이 값은 언제 최종값이 되는가

string이라는 타입은 동일성의 기준, 길이의 단위, 입력이 확정된 시점을 말해주지 않습니다. 함수 시그니처를 하나 보겠습니다.

function saveName(name: string) {}

이 name이 조합 중인 값인지 확정된 값인지, 어떤 정규형으로 들어왔는지, 사용자가 입력한 원본인지 검색용으로 가공된 값인지, 길이 제한은 어떤 단위로 걸려 있는지는 전부 타입 바깥의 정보입니다. 컴파일러는 이 중 무엇도 검사하지 않고, 문서에 적혀 있지 않으면 호출하는 쪽과 받는 쪽이 서로 다른 가정을 해도 아무도 모릅니다.




같아 보인다고 같은 값은 아닙니다​

'한'을 표현하는 방법이 둘인 것은 유니코드의 버그가 아니라 설계입니다. 유니코드는 현대 한글의 완성형 음절과 음절을 구성하는 조합용 자모를 모두 인코딩하고, 둘 사이의 분해와 합성을 알고리즘으로 정의합니다. 그래서 완성형 '한'과 조합용 자모로 만든 '한'은 서로 다른 코드 포인트 열이면서 표준적으로 동등(canonical equivalence)합니다.

유니코드는 두 표현을 표준적으로 동등하다고 정의하지만, 프로그래밍 언어의 === 비교가 이를 따르지는 않습니다.


JavaScript의 문자열 비교 방식​

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는 검색 색인처럼 "웬만하면 같은 것으로 쳐야 하는" 곳에서는 쓸모가 있지만, 사용자가 입력한 값을 저장할 때 쓰면 원본을 파괴합니다. 그래서 "정규화하세요"는 절반만 맞는 조언입니다. 넷 중 어느 것을 어디에 적용하는지 함께 말해야 실행할 수 있는 조언이 됩니다.

키보드로 치는 자모와 NFD가 만드는 자모는 다릅니다

자판의 ㅎ은 호환용 자모 U+314E이고, NFD가 '한'을 분해해서 내놓는 ㅎ은 조합용 초성 U+1112입니다. 모양이 같아 보여도 코드 포인트가 다르고, 서로 다른 목적으로 존재합니다. 자모를 직접 비교하는 코드를 짤 일이 있다면 이 둘부터 구분해야 합니다.


레이어마다 답이 다릅니다​

애플리케이션 하나를 지나가는 동안 문자열은 여러 시스템을 통과하고, 각 시스템에는 이미 자기만의 동일성 규칙이 있습니다.

  • DB: 콜레이션(collation)이 비교 규칙입니다. utf8mb4_general_ci는 대소문자를 같게 보고, 유니크 제약도 이 규칙을 따라 동작합니다. 애플리케이션이 다르다고 판단한 두 값을 DB가 중복이라며 거절할 수 있고, 그 반대도 됩니다.
  • 검색엔진: 애널라이저 설정에 따라 소문자화, 정규화, 형태소 분석을 거친 뒤 색인할 수 있습니다. 이 경우 저장된 값과 색인된 값은 서로 다른 문자열입니다.
  • 파일시스템: HFS+는 파일명을 분해된 형태로 저장했습니다. 그래서 macOS에서 만든 한글 파일명이 다른 환경으로 넘어가면 자모가 흩어져 보이는 일이 오래 있었습니다. macOS High Sierra 이후의 APFS는 주어진 표현을 그대로 보존하면서 비교할 때 정규화 차이를 무시합니다.

APFS는 저장 형식과 비교 규칙을 분리한 사례입니다.

다만 그 관대함은 파일시스템 안에서만 유효합니다. 같은 이름이 zip 엔트리, S3 키, HTTP 경로, DB 컬럼으로 넘어가면 APFS의 비교 규칙은 더는 적용되지 않습니다. 대상 시스템이 값을 어떻게 보존하고 비교하는지 별도로 확인해야 합니다. 한 레이어가 문제를 흡수해줬다고 해서 그 문제가 사라진 것은 아닙니다.


Recap​

유니코드는 두 표현이 표준적으로 동등하다고 규정하지만 ===는 그 규정을 모릅니다. JavaScript 안에서만도 ===, normalize 후 비교, Intl.Collator가 서로 다른 답을 내놓고, DB 콜레이션과 검색 애널라이저와 파일시스템은 각자 또 다른 규칙을 이미 갖고 있습니다. 동일성은 고르는 것이고, 고르지 않으면 레이어마다 제각기 정해집니다.




'한 글자'는 세는 방법마다 달라집니다​

length가 무엇을 세는지 물으면 대개 글자 수라고 답합니다. 실제로는 UTF-16 코드 단위의 개수입니다. 평소에 이 둘이 일치하기 때문에 구분할 일이 없었을 뿐입니다.

같은 값을 네 가지 단위로 세어보면 이렇게 갈립니다.

값코드 단위코드 포인트자소 클러스터UTF-8 바이트
'\uD55C' (완성형 '한')1113
'\u1112\u1161\u11AB' (조합형 '한')3319
👨‍👩‍👧 (가족 이모지)85118

사람이 인식하는 한 글자에 가장 가까운 단위는 맨 오른쪽에서 두 번째, 자소 클러스터(grapheme cluster)입니다. 세 값 모두 여기서는 1입니다. 코드 포인트는 유니코드 텍스트 처리에, 코드 단위는 JavaScript 문자열 API에, 바이트는 저장과 전송에 필요합니다. 어느 단위도 틀린 것은 아니지만 사용자에게 보여주는 글자 수와 같은 답을 주지는 않습니다.

// 자소 클러스터로 세려면 별도의 도구가 필요하다
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)이 다른 상한입니다​

표에 UTF-8 바이트 열을 넣은 이유는 저장소가 바이트로 길이를 세는 경우가 있기 때문입니다.

  • MySQL과 PostgreSQL의 VARCHAR(n)은 문자 수로 셉니다. 여기서 문자 수가 자소 클러스터 수와 같다는 뜻은 아닙니다.
  • Oracle의 VARCHAR2(n)은 단위를 생략하면 NLS_LENGTH_SEMANTICS 설정을 따르고, 이 설정의 기본값은 BYTE입니다. 문자 단위를 고정하려면 VARCHAR2(n CHAR)처럼 명시합니다.
  • MySQL의 예전 utf8은 한 문자를 최대 3바이트까지만 저장해서 😀처럼 보충 평면에 있는 문자를 저장할 수 없습니다. 지금은 utf8mb4가 그 역할을 합니다.

프론트엔드의 maxLength까지 더하면 세는 방식이 하나 더 늘어납니다. HTML의 maxlength 속성은 코드 단위를 셉니다. DB가 바이트 수로 길이를 제한한다면, 입력창을 통과한 한글 20자가 DB에서는 거절될 수 있습니다. 반면 PostgreSQL·MySQL의 문자 수 제한과 비교하면 UTF-16 코드 단위를 세는 입력창이 이모지에 대해 더 엄격할 수도 있습니다. 두 쪽 다 20이라고 적혀 있어도 세는 대상부터 맞춰야 합니다.




입력된 값이 아직 완성된 값이 아닐 수도 있습니다​

여기까지는 값이 이미 만들어졌다고 전제했습니다. 한글 입력에서는 그 전제부터 흔들립니다.

'한'을 치려면 ㅎ, ㅏ, ㄴ을 순서대로 누릅니다. 사용자에게는 한 글자를 입력하는 하나의 행위지만, 애플리케이션은 여러 개의 중간 상태를 관찰할 수 있습니다. 아래는 두벌식 한글 입력에서 흔히 볼 수 있는 예이고, 실제 중간값과 이벤트 개수는 운영체제, 입력기, 브라우저에 따라 달라질 수 있습니다.

사용자가 누른 것 ㅎ → ㅏ → ㄴ
화면에 보이는 것 ㅎ → 하 → 한
애플리케이션이 본 것 값이 여러 번 바뀌었다

이 과정을 담당하는 것이 입력기(IME, Input Method Editor)입니다. 한글, 일본어, 중국어처럼 키 입력과 글자가 일대일로 대응하지 않는 언어에서 조합을 맡습니다.


애플리케이션은 구조적으로 늦게 압니다​

키 입력은 운영체제의 입력기를 먼저 지납니다. 입력기가 조합 상태를 갱신한 뒤에야 브라우저가, 그다음에 애플리케이션이 결과를 받습니다. 조합의 규칙을 정하는 쪽은 애플리케이션이 아닙니다.

그래서 IME 입력은 일반 키 입력처럼 keydown에서 preventDefault를 부르는 것만으로 제어되지 않습니다. 조합 중에 일어나는 DOM 갱신은 취소할 수 없는 입력 이벤트로 전달될 수 있습니다. 입력을 막으려 드는 쪽보다 조합 상태를 인식해서 처리하는 쪽이 안전합니다.

브라우저는 조합 과정을 세 개의 이벤트로 알려줍니다. 조합이 시작될 때 compositionstart, 입력기가 조합 중인 문자열을 갱신할 때 compositionupdate, 조합 세션이 끝날 때 compositionend입니다. 그리고 조합 중에도 input 이벤트가 발생하며 이때 이벤트의 isComposing은 true입니다.

조합 중인 input만 무시해서는 충분하지 않습니다. 명세는 compositionend 뒤에 isComposing이 false인 input이 한 번 더 온다고 보장하지 않습니다. 반대로 브라우저에 따라 마지막 input이 뒤따를 수도 있으므로, 조합이 끝난 값은 compositionend에서 처리하되 같은 값을 두 번 처리하지 않도록 해야 합니다.

let composing = false
let lastHandledValue = input.value

function handleIfChanged() {
if (input.value === lastHandledValue) return
lastHandledValue = input.value
handle(input.value)
}

input.addEventListener('compositionstart', () => {
composing = true
})

input.addEventListener('compositionend', () => {
composing = false
handleIfChanged() // 마지막 input이 따로 오지 않아도 확정값을 처리한다
})

input.addEventListener('input', (e) => {
if (composing || e.isComposing) return
handleIfChanged() // compositionend 뒤에 같은 input이 오면 중복 처리하지 않는다
})

조합 중인 값이 새어 나가는 곳​

isComposing을 보지 않으면 미완성 상태가 그대로 시스템으로 흘러갑니다.

  • 실시간 검증: ㅎ, 하, 한을 각각 검증하고 조합 도중에 오류 메시지를 띄웁니다.
  • 자동 저장: 조합 중인 자모가 그대로 저장되고, 나중에 목록에서 깨진 글자로 발견됩니다.
  • 디바운스 검색: 조합이 끝나기 전에 타이머가 만료되어 자모로 질의합니다. 결과가 없거나 엉뚱합니다.
  • 키보드 단축키: 조합 중 눌린 Enter는 후보 선택이나 조합 확정에 쓰일 수 있는데, 이걸 전송으로 처리하면 글자가 잘린 채 메시지가 나갑니다.

마지막 항목은 채팅 입력창에서 마지막 글자만 남거나 메시지가 두 번 전송되는 현상으로 나타날 수 있습니다.


controlled input은 값을 다시 써 넣을 때 조심해야 합니다​

React처럼 상태가 값을 되돌려 쓰는 구조에서는 하나 더 주의할 것이 있습니다. 매 입력마다 e.target.value를 그대로 다시 쓰는 평범한 controlled input 자체는 조합을 깨뜨리지 않습니다. 문제가 되는 것은 조합 중에 그 값과 다른 값을 주입할 때입니다. 값을 가공하거나 형식을 맞추거나 걸러내는 경우, 그리고 상태 갱신이 비동기로 늦게 도착해 DOM이 들고 있던 값을 덮는 경우입니다. 커서가 튀거나 글자가 중복되는 증상이 이때 나타납니다. 조합 중에는 값을 손대지 않고 그대로 두는 편이 안전합니다.

역사적으로는 isComposing이 없던 시절에 keyCode === 229를 확인하는 관용구가 쓰였습니다. 조합 중이라는 사실을 알려주는 표준적인 방법이 없어서, 입력기가 처리 중일 때 브라우저가 넣어주던 값을 보고 짐작한 것입니다. 지금은 표준 isComposing과 beforeinput의 inputType으로 더 분명하게 판단할 수 있습니다. 다만 이벤트 순서와 입력기 동작에는 구현 차이가 남아 있으므로 대상 환경에서 확인해야 합니다.

이벤트 순서는 직접 확인하세요

compositionend와 마지막 input 이벤트의 발생 순서는 브라우저마다 달랐던 역사가 있습니다. 순서에 의존하는 코드를 짜야 한다면 대상 브라우저에서 실제로 찍어보고 확인하는 편이 안전합니다.


Recap​

한글 입력은 키 하나에 글자 하나가 대응하지 않아 여러 중간 상태를 거칩니다. 애플리케이션은 입력기가 조합을 처리한 뒤에 결과를 받으므로 그 과정에 개입하기 어렵고, 조합 중에도 input 이벤트가 발생합니다. isComposing으로 중간값을 구분하고 compositionend에서 확정값을 처리하지 않으면 값이 누락되거나 미완성 값이 검증, 저장, 검색, 전송으로 새어 나갈 수 있습니다.




문자열의 계약은 경계에서 결정합니다​

동일성·길이·입력 확정 문제를 호출부마다 처리하면 계층마다 규칙이 달라집니다. 검색 쪽에만 normalize를 넣거나 중복 조건문 하나만 고치면 다음 계층에서 같은 문제가 다시 생깁니다. 문자열이 시스템에 들어오는 경계에서 다음 정책을 정합니다.

  1. 언제 확정된 입력으로 볼 것인가. 조합이 끝난 값만 받을 것인지, 조합 중인 값도 처리할 것인지.
  2. 무엇을 같은 것으로 볼 것인가. 정규화 차이를 무시할 것인지, 대소문자는 어떻게 할 것인지.
  3. 어떤 정규형을 어디서 적용할 것인가. 저장, 비교, 색인이 각각 다른 답을 가질 수 있습니다.
  4. 무엇을 한 글자로 셀 것인가. 입력창, 검증, 저장소가 같은 단위를 써야 합니다.

용도별로 답이 갈리는 것이 자연스럽습니다. 예를 들어 원본은 표시용으로 보존하고, 비교에는 NFC를, 검색 색인에는 NFKC나 소문자화 같은 별도 규칙을 적용할 수 있습니다. 어느 조합이 맞는지는 요구사항이 정합니다. 다만 하나의 정규형으로 전부 통일하려 들면 어딘가에서 원본이 파괴되거나 검색이 덜 걸립니다.

그리고 이 결정들을 코드 여러 곳에 흩어두지 않는 편이 낫습니다. 비교 함수를 한 곳에 두고 모두가 그것을 쓰면, 정책이 바뀔 때 한 곳만 고치면 됩니다. ===를 직접 쓰는 코드가 여기저기 있으면 정책이라는 것이 사실상 없는 셈입니다.




정규화는 데이터 정책입니다​

정규화를 도입하면 코드뿐 아니라 이미 저장된 데이터도 바꿔야 합니다. 비용은 아래와 같은 곳에서 생깁니다.


원본을 덮어쓰지 않습니다​

정규화한 값으로 원본을 대체하면 되돌릴 수 없습니다. NFC와 NFD 사이라면 글자 자체는 보존되지만, 사용자가 보낸 코드 포인트 배열 그대로는 아닙니다. NFKC를 적용하면 글자 자체가 바뀝니다. ①은 1이 되어 영영 돌아오지 않습니다.

그래서 비교와 색인에 쓸 값을 따로 두고, 사용자에게 보여줄 원본은 그대로 보관하는 편이 안전합니다. 컬럼 하나를 더 쓰는 대신 표시와 검색이 각자 옳은 값을 갖습니다. APFS가 표현 보존과 비교 규칙을 분리한 것도 같은 방향입니다.


이미 저장된 데이터가 있습니다​

비교 규칙만 바꾸고 기존 행을 그대로 두면, 옛 행과 새 행이 서로 다른 규칙으로 비교됩니다. 오늘 가입한 사용자와 작년에 가입한 사용자의 중복 판정이 달라지고, 이런 종류의 불일치는 재현 조건이 가입 시점이라 디버깅이 오래 걸립니다.

정규화 도입은 정책 결정처럼 보이지만 실행 단계에서는 데이터 마이그레이션입니다. 기존 데이터를 어떻게 옮길지, 옮기는 동안 두 규칙이 공존하는 기간을 어떻게 다룰지가 함께 계획되어야 합니다.


영향받는 인덱스와 외부 시스템​

문자열의 동일성 기준을 바꾸면 그 기준에 기대는 것들도 함께 바뀝니다.

  • 유니크 제약과 인덱스는 비교 규칙을 따르므로 재생성이 필요할 수 있습니다.
  • 캐시 키가 문자열이라면 정규화 전후로 키가 달라져 캐시가 통째로 미스가 됩니다.
  • 검색 색인은 다시 만들어야 합니다.
  • 외부 시스템과 주고받는 값이라면 상대방의 규칙과 맞는지 확인해야 합니다.

따라서 동일성 정책을 바꿀 때는 이 항목들의 갱신 계획도 함께 세워야 합니다.




정리​

string이라는 타입에는 사실상 아무 정보도 들어 있지 않습니다. 조합 중인지 확정됐는지, 어떤 정규형인지, 원본인지 가공된 값인지, 어떤 단위로 길이가 제한됐는지처럼 우리가 문자열에 걸어둔 계약은 전부 타입 바깥에 있습니다.

그중 일부는 타입에 새길 수 있습니다. 브랜디드 타입으로 "정규화를 거친 값"과 "사용자 원본"을 다른 타입으로 만들면 컴파일러가 뒤섞임을 잡아줍니다. 이 방향의 이야기는 타입스크립트로 잘못된 상태 막기에서 다뤘습니다.

다만 타입은 결정을 강제해줄 뿐 결정을 대신해주지는 않습니다. 문자열을 다루는 시스템은 결국 다음 질문에 답해야 합니다.

무엇을 같은 것으로 볼 것인가. 무엇을 한 글자로 셀 것인가. 그리고 언제 그 값을 완성된 것으로 볼 것인가.

'한'은 한 글자입니다. 사람에게는 그렇습니다. 그 사실을 프로그램도 알게 하려면 세 번의 결정이 필요합니다.




References​

유니코드 표준

  1. The Unicode Standard, Chapter 3: Conformance
  2. UAX #15: Unicode Normalization Forms
  3. UAX #29: Unicode Text Segmentation
  4. Unicode FAQ: Normalization

언어와 브라우저

  1. ECMAScript: The String Type
  2. ECMAScript: String.prototype.normalize()
  3. ECMAScript Internationalization API: CompareStrings
  4. ECMAScript Internationalization API: Intl.Segmenter
  5. WHATWG Infra: Strings
  6. WHATWG HTML: maxlength
  7. W3C UI Events: Composition Events
  8. W3C: Input Events Level 2
  9. React: <input>

저장소와 파일시스템

  1. Apple: Technical Note TN1150, HFS Plus Volume Format
  2. Apple File System Guide: FAQ
  3. Git: core.precomposeUnicode
  4. MySQL: The utf8mb4 Character Set
  5. MySQL: CREATE TABLE Character Lengths
  6. PostgreSQL: Character Types
  7. Oracle: NLS_LENGTH_SEMANTICS


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자