문자와 시간
한 글자가 번호·바이트·모양으로 갈라지는 과정과 한 시각이 UTC·시간대·오프셋으로 갈라지는 과정을 따라간다. 한글의 KS·EUC-KR·CP949·조합형, 유니코드 자모와 이모지, 게임·웹의 IME 입력 오류도 입력·저장·표시 단계로 구분한다.
목차 (22)
- 문자, 코드 포인트, 글리프, 바이트
- ASCII에서 유니코드로
- UTF-8 인코딩 규칙
- 다중 바이트: 바이트로 자르면 글자가 깨진다
- UTF-16과 서로게이트
- 글자 수 세기: 코드 유닛, 코드 포인트, 자소 클러스터
- 정규화: NFC와 NFD
- 한글 조합 공식
- 옛 인코딩: EUC-KR·CP949와 깨진 글자
- 게임과 웹의 한글 입력: IME 조합과 확정
- 증상으로 구분하는 한글 입력 오류
- 대소문자와 정렬은 언어마다 다르다
- 시간: UTC, 시간대, 오프셋
- IANA tz 데이터베이스
- 서머타임 함정: 없는 시각, 두 번 있는 시각
- 유닉스 시간과 2038 문제
- 윤초와 스미어링
- ISO 8601과 RFC 3339 형식
- 저장과 표시 원칙
- 시계: 벽시계와 단조 시계
- 흔한 오해
- 핵심 자료
문자, 코드 포인트, 글리프, 바이트
"가"라는 글자에는 네 겹이 있다. 맨 위는 문자, 곧 "한글 음절 가"라는 추상적인 뜻이다. 유니코드는 문자마다 번호를 붙이는데, 이것이 코드 포인트이고 U+AC00처럼 적는다. 번호를 파일이나 네트워크에 실으려면 바이트로 바꿔야 하고, 그 규칙이 인코딩(UTF-8, UTF-16 등)이다. 같은 번호도 인코딩마다 바이트가 다르다. 마지막으로 화면에 그려지는 모양이 글리프이고, 이것은 글꼴이 정한다. 명조와 고딕의 "가"는 모양이 달라도 같은 문자다.
텍스트 버그는 대개 이 겹을 섞을 때 생긴다. 바이트 수를 글자 수로 알거나, 다른 인코딩으로 쓴 바이트를 읽거나, 모양이 같으니 같은 문자열이라고 믿는 경우다. 비트·바이트·16진수 자체는 데이터 노트에 있다.
ASCII에서 유니코드로
초기의 컴퓨터는 영어 글자·숫자·기호와 제어 문자를 7비트 128칸에 담은 ASCII를 썼다. 다른 언어는 남은 8번째 비트나 2바이트 조합으로 각자 표를 만들었고(한국어의 EUC-KR 등), 그래서 같은 바이트가 나라마다 다른 글자가 되었다. 유니코드는 세상의 모든 문자에 번호 하나씩을 주는 하나의 표다. 코드 공간은 U+0000부터 U+10FFFF까지, 65,536칸짜리 평면 17개다. 첫 평면이 기본 다국어 평면(BMP)이고 한글 음절도 여기 있다. 2026년 9월의 유니코드 18.0은 13,007자를 더해 172,808자를 담았다.
유니코드는 ASCII의 128칸을 같은 번호로 맨 앞에 두었다. 그래서 U+0000~007F는 ASCII와 번호가 같고, UTF-8로 쓰면 바이트까지 같다. 영어만 쓴 옛 파일이 그대로 올바른 UTF-8인 이유다.
UTF-8 인코딩 규칙
UTF-8(RFC 3629)은 코드 포인트 크기에 따라 1~4바이트를 쓴다. 첫 바이트의 머리 비트가 길이를 알려 준다. 0으로 시작하면 1바이트(ASCII), 110이면 2바이트, 1110이면 3바이트, 11110이면 4바이트이고, 이어지는 바이트는 모두 10으로 시작한다. 나머지 x 자리에 코드 포인트의 비트를 앞에서부터 채운다. 한글 음절은 U+0800~FFFF 구간이라 한 글자 3바이트, U+FFFF를 넘는 😀 같은 이모지는 4바이트다.
"가"는 U+AC00다. 이진수로 16자리라 U+0800–FFFF 범위, 즉 3바이트 패턴 1110xxxx 10xxxxxx 10xxxxxx를 쓴다.
이 설계에는 장점이 많다. ASCII와 겹치고, 바이트 순서(엔디언) 문제가 없고, 중간 바이트(10…)와 첫 바이트가 구별되어 아무 곳에서 읽기 시작해도 다음 글자 경계를 찾을 수 있다. 대신 규칙이 엄격하다. RFC 3629는 U+10FFFF를 넘는 값, 서로게이트(U+D800~DFFF), 더 짧게 쓸 수 있는 값을 일부러 길게 쓴 너무 긴 형식(예: C0 80)을 금지하고, 디코더가 이런 바이트를 거부해야 한다고 정한다. 같은 글자를 여러 바이트열로 쓸 수 있으면 바이트로 검사하는 필터를 피해 갈 수 있기 때문이다. WHATWG 인코딩 표준은 새 프로토콜과 형식이 UTF-8만 쓰도록 정했다.
다중 바이트: 바이트로 자르면 글자가 깨진다
글자 하나를 1바이트로 쓰는 인코딩(ASCII, ISO-8859-1)을 단일 바이트, 여러 바이트로 쓰는 인코딩을 다중 바이트(multibyte)라 한다. 1바이트로 나타낼 수 있는 값은 256가지뿐이라, 글자가 수천에서 수만 개인 한글·한자·일본어는 처음부터 다중 바이트를 써 왔다. EUC-KR·CP949는 ASCII를 1바이트로, 한글 한 자를 2바이트로 쓰고, UTF-8은 위 표처럼 1~4바이트를 섞어 쓰는 가변 길이다. 그래서 바이트 수와 글자 수가 다르다. "한글"은 EUC-KR로 4바이트, UTF-8로 6바이트다.
문제는 길이 제한을 바이트로 세면서 그 자리에서 잘라 낼 때 생긴다. 칼럼 길이, 로그·메시지 한도, 고정 크기 버퍼나 패킷이 그런 자리다. 글자 하나의 바이트가 갈라지면 앞 조각은 혼자서는 글자가 되지 못해 대체 문자 �(U+FFFD)로 바뀌고, 남은 바이트가 다음 바이트와 엉뚱하게 이어 읽히면 처음 보는 한자나 기호가 나온다. 아래 "증상으로 구분하는 한글 입력 오류" 표의 첫 줄이 이 경우다.
- UTF-8은 경계를 되찾을 수 있다: 이어지는 바이트는 모두
10으로 시작하므로, 자를 자리 바로 다음 바이트가10…이면 첫 바이트가 나올 때까지 앞으로 물러난 곳에서 자르면 된다. 아무 데서나 읽기 시작해도 다음 글자의 처음을 찾을 수 있다는 앞 절의 성질 덕분이다. - EUC-KR·CP949는 어렵다: 둘째 바이트도 첫 바이트와 같은 범위(A1~FE)를 쓰므로, 바이트 하나만 보고는 그것이 글자의 앞쪽인지 뒤쪽인지 알 수 없다. 경계를 알려면 앞에서부터 다시 읽어야 한다.
- 무엇을 세는지 먼저 정한다: 같은 문자열도 UTF-8 바이트 수, UTF-16 코드 유닛 수, 코드 포인트 수, 눈에 보이는 글자 수가 모두 다르다(글자 수 세기). 데이터베이스 칼럼이나 API 한도가 바이트 단위인지 글자 단위인지 문서에서 확인하고, 바이트로 잘라야 한다면 위처럼 경계까지 물러난다. 이모지 가족처럼 여러 코드 포인트가 한 글자로 보이는 것까지 지키려면 자소 클러스터 단위로 자른다.
- 깨진 바이트를 그냥 넘기지 않는다: 디코더는 기본으로 잘못된 바이트를 �로 바꾸고 계속 읽는다. 데이터가 깨졌는지 알아야 하는 곳이라면 오류를 내게 설정한다(TextDecoder의
fatal옵션).
UTF-16과 서로게이트
UTF-16은 16비트 코드 유닛을 단위로 쓴다. BMP 안의 문자(U+FFFF까지)는 유닛 하나, 그 너머는 두 유닛이다. 두 유닛 짝을 서로게이트 쌍이라 한다. 코드 포인트에서 0x10000을 빼면 20비트가 남고, 위 10비트를 0xD800에, 아래 10비트를 0xDC00에 더한다. 유니코드가 BMP 안에 서로게이트 구간을 문자 없이 비워 둔 이유가 이것이다. 그 번호들은 혼자서는 아무 글자도 아니다.
MDN에 따르면 JS의 length는 "UTF-16 코드 유닛의 수"다. 그래서 "😀".length는 2이고, slice로 자르다가 쌍의 한가운데를 자르면 짝 잃은 서로게이트가 남아 깨진 글자가 된다. 한글은 BMP 안이라 이 문제를 잘 겪지 않아서 오히려 늦게 발견된다.
글자 수 세기: 코드 유닛, 코드 포인트, 자소 클러스터
"몇 글자인가"에는 답이 여럿이다. 저장 크기는 바이트, JS의 length는 코드 유닛, [...s].length처럼 문자열을 순회하면 코드 포인트다. 그런데 사람이 한 글자로 보는 것은 셋 다 아닐 수 있다. 가족 이모지 👨👩👧👦는 사람 이모지 넷을 폭 없는 이음표(ZWJ, U+200D) 셋으로 이은 것이라 코드 포인트가 7개다. UTF-8로 25바이트, UTF-16으로 11유닛이지만 지원하는 글꼴·렌더러에서는 가족 그림 하나로 보인다.
사람이 느끼는 한 글자를 유니코드는 자소 클러스터(grapheme cluster)라 하고, 경계 규칙을 UAX #29에 정해 두었다. ZWJ로 이은 이모지 시퀀스 안에서는 끊지 않는다는 규칙(GB11)도 여기 있다. 브라우저와 Node에서는 Intl.Segmenter에 granularity: "grapheme"을 주면 이 규칙대로 나눠 준다. 닉네임 글자 수 제한, 커서 이동, 말줄임은 자소 클러스터로, DB 열 크기는 바이트로 세야 한다. "한글"은 UTF-8 6바이트에 두 글자지만, 같은 단어를 NFD로 풀면 18바이트가 된다(다음 절).
ZWJ를 사이에 넣는다고 모든 이모지 조합이 그림 하나가 되는 것은 아니다. 해당 시퀀스를 지원하지 않으면 사람 그림이 따로 보일 수 있다. 자소 클러스터 하나라는 문자열 경계와 글리프 하나라는 표시 결과도 구별해야 한다. Unicode Emoji의 ZWJ 설명이 이 대체 표시를 다룬다. 유니코드 탐색기에서 가족 이모지의 실제 코드 포인트를 확인할 수 있다.
정규화: NFC와 NFD
유니코드에는 같은 글자를 쓰는 방법이 둘 이상인 경우가 있다. "é"는 미리 합친 U+00E9 하나로도, e(U+0065)와 결합 악센트(U+0301) 둘로도 쓸 수 있고, 둘은 정준 동치(canonical equivalence)라 같게 보이고 같게 다뤄야 한다. 한글도 그렇다. 완성형 음절 "한"(U+D55C) 하나로도, 조합형 자모 ㅎ(U+1112)·ㅏ(U+1161)·ㄴ(U+11AB) 셋으로도 쓸 수 있다. UAX #15는 합칠 수 있는 만큼 합친 꼴을 NFC, 끝까지 푼 꼴을 NFD로 정한다. 호환 문자(합자 "fi" 등)까지 펴는 NFKC·NFKD도 있지만, 의미 있는 구별을 지울 수 있으니 아무 텍스트에나 쓰지 말라고 경고한다.
JS의 ===는 코드 유닛 열을 그대로 비교하므로 NFC "한"과 NFD "한"은 다르다. 정규화하지 않은 단순 일치 검색·해시·파일명 비교에서도 다르게 취급될 수 있다. macOS의 APFS는 파일 이름을 받은 꼴 그대로 보존하면서 정규화한 꼴로 비교하고, 옛 HFS+는 분해된 정규화 형태를 사용했다. 따라서 모든 맥 파일명이 무조건 NFD라는 뜻은 아니다. 압축·업로드·다른 운영체제 사이에서 문제가 나면 실제 코드 포인트를 먼저 확인하고, 비교 경계에서 합의한 형식(보통 NFC)으로 맞춘다. NFD도 정상 유니코드이며, 자모가 보이게 분리되는 현상에는 글꼴·렌더러의 조합 지원도 관여한다. 기초 설명은 데이터 노트의 텍스트 인코딩 절에 짧게 있다.
한글 조합 공식
유니코드의 한글 음절 11,172자는 아무렇게나 놓인 것이 아니다. 초성 19개, 중성 21개, 종성 28개(받침 없음 포함)의 모든 조합을 정해진 순서로 늘어놓았고, 유니코드 표준 3.12절이 그 상수(SBase = AC00, VCount = 21, TCount = 28 등)를 정한다. 그래서 자모 번호만 알면 표를 찾지 않고 계산으로 음절을 만들 수 있다. 음절 번호는 (초 × 21 + 중) × 28 + 종이고, 여기에 U+AC00을 더한 것이 코드 포인트다. 거꾸로 나눗셈과 나머지로 자모 번호를 되찾는다.
"한"을 자모로 나누면 ㅎ·ㅏ·ㄴ이다. 표에서의 순서(0부터)가 번호다. 종성 0번은 받침 없음이다.
NFD 정규화도 같은 번호 체계로 음절을 조합형 자모로 푼다(자모 블록의 시작 U+1100·U+1161·U+11A7에 번호를 더한 값이다). 글꼴도 같은 생각을 쓸 수 있다. 음절 1만여 자를 다 그리는 대신 자모만 그려 두고 조합하는 방식이다. 이 사이트의 폰트 만들기가 그렇게 픽셀 자모로 한글 음절을 만든다.
완성 음절, 결합 자모, 호환 자모는 다르다
유니코드의 현대 한글 완성 음절은 가(U+AC00)~힣(U+D7A3)이다. NFD의 결합 자모는 초성·중성·종성의 역할이 정해져 있지만, 채팅에서 단독으로 쓰는 ㅎ·ㅏ·ㄴ 같은 호환 자모는 다른 코드다. 아래의 세 줄을 같은 문자열로 취급해서는 안 된다. 옛한글은 추가 자모를 조합해 나타내므로 현대 음절 11,172자만으로 모든 옛한글까지 표현하는 것은 아니다.
| 표현 | 문자열 | 코드 포인트 | UTF-8 |
|---|---|---|---|
| 완성 음절 | 한 | U+D55C | 3바이트 |
| 결합 자모 · NFD | 한 | U+1112 + U+1161 + U+11AB | 9바이트 |
| 호환 자모 | ㅎㅏㄴ | U+314E + U+314F + U+3134 | 9바이트 |
"한".normalize("NFC")는 한 음절로 합쳐지지만 호환 자모 ㅎㅏㄴ은 NFC로 "한"이 되지 않는다. NFKC도 받침의 의도를 추측하는 한글 입력기가 아니다. 조합 중에 강제로 정규화한다고 깨진 IME 처리가 고쳐지지는 않는다. 구분의 근거는 Unicode 한국어 FAQ에 있다.
옛 인코딩: EUC-KR·CP949와 깨진 글자
KS는 한국산업표준이라는 규격 체계이지 인코딩 이름 하나가 아니다. 특히 KS C 5601-1987, 뒤의 KS X 1001 완성형 문자 집합과, 이를 실제 바이트로 싣는 EUC-KR을 구별해야 한다. 같은 "KS"라는 말 아래 문자 목록·조합형 부속 규격·제품별 인코딩 이름이 섞여 쓰여 혼란이 생겼다.
| 이름 | 무엇을 정하는가 | 범위와 읽을 때의 함정 |
|---|---|---|
| KS C 5601-1987 → KS X 1001 | 한글·한자·기호 등의 문자 집합과 코드 위치 | 현대 완성 음절 2,350자에 직접 코드를 배정했다. KS X 1002(옛 KS C 5657) 같은 보충 문자 집합도 별개로 있었다. |
| EUC-KR | ASCII와 KS X 1001 문자를 함께 싣는 바이트 표현 | 통상 말하는 완성형 음절은 2바이트다. 엄밀한 원래 범위와 브라우저가 같은 이름으로 처리하는 확장 범위가 다르다. |
| 조합형 · Johab | 초성·중성·종성 정보를 비트 필드로 조합하는 레거시 인코딩 | 현대 음절 11,172자를 나타낸다. Windows 코드 페이지 1361로도 구분하며, Unicode NFD와는 다른 바이트 규칙이다. |
| CP949 · UHC | 기존 EUC-KR 영역을 유지하고 음절 코드를 확장 | 추가 8,822자까지 합쳐 현대 음절 11,172자를 2바이트로 나타낸다. 옛 EUC-KR만 기대하는 프로그램에서는 확장 음절이 깨질 수 있다. |
| ISO-2022-KR | 옛 인터넷 메일의 7비트 전송을 위한 상태 전환 방식 | 이스케이프와 SO·SI로 ASCII·한국어 구간을 전환한다. 브라우저 API가 그대로 지원한다고 가정하면 안 된다. |
| Unicode + UTF-8·UTF-16 | 공통 문자 번호와 그 번호를 바이트·코드 유닛으로 표현하는 방식 | 한글뿐 아니라 여러 언어와 이모지를 함께 다룬다. 현대 한글 음절은 UTF-8 3바이트, UTF-16 1유닛이다. |
| 음절 | KS X 1001 음절의 EUC-KR 바이트 | CP949 | Johab | UTF-8 |
|---|---|---|---|---|
| 가 | B0 A1 | B0 A1 | 88 61 | EA B0 80 |
| 똠 | 직접 코드 없음 | 8C 63 | 99 B1 | EB 98 A0 |
| 힣 | 직접 코드 없음 | C6 52 | D3 BD | ED 9E A3 |
"똠"과 "힣"에는 KS X 1001의 직접 배정 2바이트 음절 코드가 없다. 일부 규격·변환기는 여러 자모 코드를 잇는 조합 시퀀스를 지원하기도 하므로 "어떤 EUC-KR 구현에서도 절대 표현할 수 없다"고 단정하는 것과는 다르다. 실무에서는 어느 문자 집합·바이트 규칙·변환기인가까지 확인해야 한다. UTN #60의 매핑, Windows 코드 페이지 목록, RFC 1557을 구분의 근거로 삼을 수 있다.
웹에서는 이름보다 실제 디코더의 범위가 중요하다. WHATWG Encoding Standard는 euc-kr, ks_c_5601-1987, windows-949를 같은 인코딩 이름표로 묶고, CP949 범위의 표를 쓴다. 따라서 브라우저의 TextDecoder("euc-kr")는 확장 음절도 읽는다. 이 사이트의 인코딩 도구도 그 브라우저 표를 사용하므로, 엄밀한 옛 EUC-KR 파일만 검사하는 도구로 오해하면 안 된다.
바이트에는 "나는 어느 인코딩이다"라는 표시가 없다. 그래서 EUC-KR 파일을 UTF-8로 읽으면 UTF-8 규칙에 맞지 않는 바이트가 나와 대체 문자 �(U+FFFD)가 찍히고, 거꾸로 UTF-8 파일을 EUC-KR로 읽으면 엉뚱한 한자·기호가 나온다. 이렇게 깨진 글자를 mojibake라 한다. 오래된 CSV·자막·게임 세이브를 열 때 흔하다. 고치는 길은 바이트를 원래 인코딩으로 다시 읽는 것이고, 이미 �로 바꿔 저장했다면 정보가 사라져 되돌릴 수 없다. HTTP의 Content-Type: text/html; charset=utf-8, HTML의 <meta charset="utf-8">처럼 인코딩을 늘 밝혀 두는 이유다. 메일의 MIME도 charset을 적는다(이메일 노트).
게임과 웹의 한글 입력: IME 조합과 확정
한글 입력은 키를 누를 때마다 완성 글자를 하나 붙이는 과정이 아니다. IME(Input Method Editor, 입력기)는 "ㅎ → 하 → 한"처럼 아직 확정하지 않은 조합 문자열(preedit)을 같은 영역에서 바꾸다가 최종 결과를 확정한다. 조합형 인코딩이나 NFC·NFD와는 다른, 입력 과정의 상태다. 중간 문자열을 매번 덧붙이면 "ㅎ하한"이 되고, 조합 끝과 최종 입력을 모두 추가하면 마지막 음절이 중복된다.
입력기가 조합 영역을 연다. 확정된 본문과 아직 바뀔 수 있는 조합 문자열을 구별한다.
Windows·macOS·Linux의 IME는 같은가
조합 중인 글자를 갱신하다가 확정한다는 원리는 같지만, 구현과 앱에 전달하는 방식은 다르다. IME는 특정 운영체제의 제품 이름이 아니라 여러 입력기를 통틀어 부르는 말이다. 앞의 단계 그림은 세 운영체제에 공통인 개념 모델이며, 동일한 API나 이벤트 순서를 뜻하지 않는다.
| 운영체제 | 대표적인 앱 연결 방식 | 조합 상태를 전달하는 방식 |
|---|---|---|
| Windows | TSF(Text Services Framework), 기존 IMM32 | TSF의 조합 세션이나 기존 IME 메시지로 조합 상태·결과 문자열을 주고받는다. TSF 조합 문서 |
| macOS | AppKit의 NSTextInputClient | 조합 중인 영역을 marked text로 관리하고 텍스트 삽입·선택 영역과 연결한다. Apple 입력 클라이언트 문서 |
| Linux | IBus·Fcitx 같은 입력 프레임워크와 GTK·Qt 등의 연동 | 입력 프레임워크·툴킷·X11/Wayland 조합에 따라 앱과 연결하는 경로가 달라진다. IBus, Fcitx 앱 연동 문서 |
웹에서는 브라우저가 운영체제의 입력 경로를 공통 DOM 이벤트로 연결한다. 공통 이벤트가 있다고 키 입력·조합 확정·포커스 이동의 세부 순서까지 모든 환경에서 같다고 가정해서는 안 된다. 특정 환경에서만 한글이 중복되거나 입력되지 않는다면 OS·입력기·브라우저 또는 게임 엔진의 조합을 함께 확인한다.
웹: 키 이벤트와 텍스트 변경을 구별한다
compositionstart·compositionupdate·compositionend는 조합 세션을, beforeinput·input은 편집 의도와 실제 값 변경을 다룬다. keydown의 Enter가 음절 확정에 쓰였는데 채팅 전송까지 실행하면 마지막 글자 누락·중복·빈 메시지가 생길 수 있다. isComposing과 조합 상태를 보고 전송·단축키 같은 앱 동작을 미루되, IME 확정을 위한 기본 키 동작까지 무조건 막지는 않는다.
조합 중 정규식 필터·대소문자 변환·정규화를 적용한 값을 다시 넣거나 입력 DOM을 새로 만들면 조합 영역과 커서가 깨질 수 있다. React 같은 제어 입력은 화면의 현재 값은 유지하면서, 검색·검증·저장 같은 부수 효과를 조합 뒤로 미루는 식으로 설계한다. "조합 중 onChange를 모두 무시한다"는 한 줄 처방으로 일반화하면 안 된다. 표준의 상태와 편집 이벤트는 W3C UI Events와 Input Events Level 2에 정의되어 있다.
웹 입력 예시: 조합 중 전송을 피하고 DOM의 전체 값을 읽는다
다음은 독립된 HTML 예시다. 조합 이벤트의 문자열을 본문에 덧붙이지 않고 입력 칸이 가진 전체 값을 읽는다. 정규화는 확정한 메시지를 보낼 때 한다.
<form id="message-form">
<label for="message">메시지</label>
<textarea id="message" name="message"></textarea>
<button type="submit">보내기</button>
</form>
<p id="preview"></p>
<p id="sent" role="status"></p>
<script type="module">
const form = document.querySelector('#message-form');
const field = document.querySelector('#message');
const preview = document.querySelector('#preview');
const sent = document.querySelector('#sent');
let composing = false;
/** 입력 칸의 전체 값을 미리보기에 반영한다. */
function showPreview() {
preview.textContent = field.value;
}
field.addEventListener('compositionstart', () => {
composing = true;
});
field.addEventListener('compositionend', () => {
composing = false;
showPreview();
});
field.addEventListener('input', (event) => {
if (composing || event.isComposing) return;
showPreview();
});
field.addEventListener('keydown', (event) => {
if (composing || event.isComposing) return;
if (event.key !== 'Enter' || event.shiftKey) return;
event.preventDefault();
form.requestSubmit();
});
form.addEventListener('submit', (event) => {
event.preventDefault();
if (composing) return;
sent.textContent = '보낸 문자열: ' + field.value.normalize('NFC');
field.value = '';
showPreview();
});
</script>브라우저·OS·IME마다 세부 이벤트 순서와 Enter 처리가 다를 수 있으므로 실제 한국어 입력기로 확인해야 한다. 예전 코드의 keyCode === 229는 호환성 진단 단서일 뿐, 모든 환경에 통하는 표준 판정법은 아니다. 위 예시는 복잡한 편집기·모바일 키보드까지 보장하는 범용 입력기가 아니다.
게임: 이동 키와 채팅 문자열은 서로 다른 입력 경로다
WASD 이동은 키 위치로 처리해도 되지만 채팅·닉네임은 OS·엔진의 텍스트 입력 경로를 써야 한다. 예를 들어 SDL 3는 SDL_StartTextInput으로 텍스트 입력을 켜고, SDL_EVENT_TEXT_EDITING에서 조합 중 문자열을, SDL_EVENT_TEXT_INPUT에서 입력 결과를 받는다. 조합 표시는 별도로 바꾸고 확정 결과만 본문에 넣는다. 키 코드에서 문자를 직접 만들거나 조합·확정 콜백을 같은 추가 동작에 연결하면 한글이 누락·분리·중복될 수 있다.
게임 UI가 운영체제 입력창이 아닌 직접 그린 화면이면 조합 영역·커서·초점도 연결해야 한다. SDL_SetTextInputArea처럼 실제 커서의 윈도 좌표를 알려 주지 않으면 후보 창 위치가 어긋날 수 있다. Windows의 전통적인 IME 경로도 WM_IME_COMPOSITION에서 조합과 결과 문자열을 구별한다. SDL 조합 이벤트, SDL 텍스트 입력 이벤트, Windows 결과 문자열 처리가 구체적인 API 예다.
증상으로 구분하는 한글 입력 오류
한글이 이상하다고 모두 인코딩 문제는 아니다. 다음은 API의 역할에서 따라 나오는 대표적인 실패 유형이다. 특정 게임이나 브라우저의 버그를 단정하는 표가 아니며, 같은 증상에도 원인이 여러 개일 수 있다.
| 흔한 증상 | 발생하는 이유 | 확인할 지점 |
|---|---|---|
| 한글 대신 엉뚱한 한자·기호 또는 � | 바이트를 다른 인코딩으로 읽거나, 다중 바이트 한가운데를 잘랐다. | 원본 바이트와 HTTP·파일·DB 연결의 charset을 맞춘다. 이미 대체 문자로 저장했다면 원본이 필요하다. |
| 일부 드문 음절·이모지만 ?로 바뀜 | 저장 인코딩이 문자를 표현하지 못해 대체했을 수 있다. | CP949 확장·Unicode 지원과 변환기의 대체 정책을 본다. ASCII ?로 손실된 정보를 글꼴로 되살릴 수는 없다. |
| 마지막 글자 누락·중복, Enter를 눌러야 반영 | 조합 확정과 전송의 순서를 섞거나, 중간값·확정값을 두 번 추가한다. | 조합 상태와 실제 입력 값·전송 횟수를 기록한다. 본문에 반영할 경로를 하나로 정한다. |
| 영어는 되는데 한글은 안 됨, 붙여넣기만 됨 | 키 코드만 받아 문자를 만들거나 IME·텍스트 입력·초점 연결을 빠뜨렸을 수 있다. | 게임의 텍스트 입력 활성화, 웹의 입력 DOM 재생성, 전역 단축키 가로채기를 본다. |
| 입력 중 자모가 끊기거나 커서가 튐 | 조합 중 값을 강제로 바꾸거나 커서·선택 영역을 덮어썼다. | 정규식 필터·정규화·자동완성·원격 편집 반영 시점을 조합 뒤로 미룬다. |
| 파일명·검색어가 같아 보이는데 일치하지 않음 | NFC·NFD나 호환 자모 등 실제 코드 포인트가 다르다. | 유니코드 탐색기로 비교한 뒤 검색·비교 경계에서 정규화한다. 호환 자모와 IME 오류는 별개로 다룬다. |
| Backspace가 이모지·자모 일부만 지움 | UTF-16 유닛·바이트·코드 포인트를 사용자가 보는 글자 경계로 착각했다. | 확정 텍스트는 grapheme 경계로 편집한다. 조합 중 삭제는 입력기의 조합 동작과 충돌하지 않게 한다. |
| 일부 글자만 □로 보이거나 NFD 자모가 벌어짐 | 코드 포인트는 맞아도 글꼴·fallback·글리프 아틀라스·자모 조합 기능이 부족할 수 있다. | 폰트의 글리프 범위와 셰이핑을 확인한다. 게임용 폰트에 2,350자만 넣으면 나머지 현대 음절이 빠질 수 있다. |
| 한자·IME 후보 창이 화면 밖이나 엉뚱한 곳에 뜸 | 앱의 커서 위치와 OS가 아는 텍스트 입력 영역의 좌표가 다르다. | 윈도 크기·DPI·화면 배율 변화 뒤 입력 영역을 갱신하고 API가 요구하는 좌표계를 따른다. |
작게 재현할 때는 가·한·똠·힣, NFD 한, 호환 자모 ㅎㅏㄴ, 가족 이모지를 각각 시험한다. 직접 입력·붙여넣기·저장 후 다시 읽기를 나눠 보면 단계가 좁혀진다. 유니코드 탐색기는 문자열 구조를, 인코딩 도구는 바이트 변환을, 폰트 테스트는 글리프 지원을 확인하는 데 쓴다. 자모 조합 렌더링은 OpenType 한글 처리 설명을 참고할 수 있다.
대소문자와 정렬은 언어마다 다르다
문자열을 코드 포인트 순서로 정렬하면 대문자가 소문자보다, 영어가 한글보다 먼저 온다. 사람이 기대하는 순서가 아니다. 유니코드 정렬 알고리즘(UTS #10)은 문자열마다 정렬 키를 만들어 비교하는 방법을 정하고, 언어별 규칙은 CLDR이 덧붙인다. 같은 글자도 언어마다 자리가 다르다. UTS #10의 예로, 스웨덴어에서 ö는 z 뒤에 오는 별도 글자이고 독일어에서는 o 곁에 온다. 정렬은 Intl.Collator(또는 localeCompare)에 언어를 주고 한다.
대소문자 변환도 언어를 탄다. 독일어 ß를 대문자로 바꾸면 두 글자 SS가 되어 길이가 바뀌고, 튀르키예어에서 I의 소문자는 점 없는 ı, i의 대문자는 점 있는 İ다. 그래서 "대소문자 무시 비교"를 toLowerCase() 두 번으로 하면 언어에 따라 틀린다. 사용자 이름처럼 언어와 무관하게 같아야 하는 식별자는 정해진 규칙(로캘 없는 변환 + NFC)으로 맞추고, 화면에 보여 줄 정렬만 로캘을 따른다.
시간: UTC, 시간대, 오프셋
시간 쪽의 겹은 셋이다. UTC는 세계가 함께 쓰는 기준 시각으로, 어느 한 순간을 하나의 값으로 가리킨다. 오프셋은 어느 지역의 벽시계가 그 순간 UTC와 몇 시간 차이 나는지를 나타내는 숫자다(+09:00). 시간대는 Asia/Seoul, Europe/London 같은 이름이고, "이 지역은 언제 어떤 오프셋을 썼고 쓸 것인가"라는 규칙과 역사 전체다.
오프셋과 시간대를 같은 것으로 다루는 것이 흔한 실수다. 런던은 10월 말에 +00:00이지만 여름에는 +01:00이다. "+00:00"만 저장해 두면 그 사람에게 다음 여름 회의 시각을 계산할 수 없다. 반대로 "Asia/Seoul"이라는 이름을 알면 과거와 미래의 오프셋을 모두 찾을 수 있다.
IANA tz 데이터베이스
시간대 규칙은 나라가 법으로 바꾼다. 서머타임을 새로 하거나 그만두고, 표준시를 옮긴다. 이 변경을 모아 둔 것이 IANA가 배포하는 tz 데이터베이스(zoneinfo)다. IANA 페이지는 이를 "세계 여러 대표 지점의 현지 시각 역사를 나타내는 코드와 데이터"라고 소개한다. 주로 소프트웨어를 만드는 쪽을 위한 데이터이고, 운영체제와 라이브러리가 이것을 받아 시간대를 계산한다. 2026년 10월 27일에 2026f가 나왔을 만큼 자주 갱신된다. 규칙이 바뀌었는데 기기의 데이터가 낡았으면 시계가 한 시간 틀린다.
한국도 기록이 있다. tz 데이터에 따르면 Asia/Seoul은 1987년과 1988년 여름에 +10:00(서머타임)을 썼다. 그 시절 날짜를 UTC로 바꾸는 프로그램은 이 기록 덕분에 맞는 답을 낸다.
서머타임 함정: 없는 시각, 두 번 있는 시각
서머타임이 시작하는 날 벽시계는 한 시간을 건너뛰고, 끝나는 날 한 시간을 되풀이한다. 2026년 뉴욕(America/New_York)은 3월 8일 UTC 07:00에 02:00 → 03:00으로 넘어가고, 11월 1일 UTC 06:00에 02:00 → 01:00으로 돌아간다. 그래서 3월 8일의 02:30은 존재하지 않는 시각이고, 11월 1일의 01:30은 두 번 있는 시각이다.
3월 8일 새벽, 뉴욕은 UTC−5에서 UTC−4로 바뀐다. UTC 07:00에 벽시계가 02:00에서 03:00으로 건너뛴다. 위 축의 한 시간이 아래 축에서는 두 칸을 넘는다.
여기서 생기는 버그는 정해져 있다. "매일 02:30" 예약 작업이 1년에 한 번 빠지거나 두 번 돈다. 벽시계로 "하루 뒤 = 24시간 뒤"라고 계산하면 그날은 23시간이나 25시간이 된다. 로그에 오프셋 없이 현지 시각만 남기면 가을의 01:30 기록 둘의 순서를 알 수 없다. 처방은 순간은 UTC로 다루고, 벽시계 계산이 필요할 때는 시간대 이름을 아는 라이브러리에 맡기는 것이다. JS의 새 날짜 API Temporal은 시간대를 아는 Temporal.ZonedDateTime을 따로 두었다(MDN 기준 아직 모든 주요 브라우저에서 쓰이지는 않는다).
유닉스 시간과 2038 문제
컴퓨터 내부에서 순간을 다루는 가장 흔한 방법은 유닉스 시간, 즉 1970-01-01T00:00:00Z부터 센 초다. 숫자 하나라 비교·저장이 쉽고 시간대와 무관하다. 문제는 그 숫자를 담는 칸의 크기다. 부호 있는 32비트 정수의 최댓값 2³¹ − 1 = 2,147,483,647초는 2038-01-19T03:14:07Z이고, 1초 뒤에는 넘쳐서 -2,147,483,648, 곧 1901-12-13T20:45:52Z로 돌아간다. 32비트로는 0에서 양쪽으로 약 68년밖에 못 센다. 이것이 2038년 문제다.
같은 초를 부호 있는 64비트로 담으면 약 2923억 년을 센다. 남은 위험은 32비트 시간을 그대로 담은 파일 형식, DB 열, 프로토콜 필드, 오래 쓰는 임베디드 기기다. 2038년 이후의 만료일(30년 대출, 인증서, 예약)을 다루는 코드는 이미 그 날짜를 만난다.
윤초와 스미어링
원자시계로 정한 1초와 지구 자전은 조금씩 어긋난다. UTC는 그 차이(UT1 − UTC)를 작게 유지하려고 가끔 하루 끝에 1초를 끼워 넣었다. 이것이 윤초이고, 그 순간의 시각은 23:59:60이다. 지금 UTC는 원자시(TAI)보다 37초 늦다. 이 차이는 2017년 1월 1일부터 그대로이고, IERS 회보 C 72호(2026년 7월)는 2026년 12월 말에도 윤초가 없다고 발표했다.
소프트웨어에는 윤초가 골칫거리다. 1분이 61초인 날, 같은 초가 두 번 찍히는 시계, 60초를 받아들이지 못하는 파서가 생긴다. 그래서 큰 서비스들은 윤초를 한순간에 넣는 대신 시계를 조금 늦춰 펴 바르는 스미어링을 쓴다. Google은 정오부터 다음 날 정오까지 24시간 동안 고르게 늦추는 선형 스미어를 권하는데, 속도 변화는 약 11.6 ppm이고 윤초 순간의 UTC와의 차이는 0.5초에 조금 못 미친다. 다만 스미어 시계와 진짜 UTC는 그 하루 동안 서로 다르므로, 한 시스템 안에서는 모든 서버가 같은 방식을 써야 한다.
이 골칫거리는 끝나 간다. 2022년 제27차 국제도량형총회(CGPM)는 결의 4에서 UT1 − UTC의 최대 허용 차이를 2035년이나 그 전에 늘리기로 결정했다. 새 값은 국제도량형위원회(CIPM)가 ITU 등과 협의해, UTC가 적어도 100년 동안 연속되도록 정해 제시하도록 했다. 허용 차이가 커지면 윤초를 넣을 일이 사실상 없어진다.
ISO 8601과 RFC 3339 형식
시각을 글자로 주고받을 때는 2026-10-30T14:05:09.250+09:00처럼 쓴다. 큰 단위부터 적어서 글자 순 정렬이 시간 순 정렬과 같고, 오프셋이 붙어 있어 어느 순간인지 하나로 정해진다. 인터넷 프로토콜이 쓰는 꼴은 RFC 3339가 정한다. 연도는 네 자리, 날짜와 시각 사이는 T(읽기 좋게 공백을 쓰는 응용도 있다), 오프셋은 Z나 ±hh:mm이고, 윤초가 있는 달 끝에는 초 자리에 60을 쓸 수 있다.
오프셋의 뜻은 미묘하다. RFC 3339에서 -00:00은 "UTC 시각은 알지만 현지 오프셋은 모른다"는 뜻이다. 2024년의 RFC 9557은 Z도 같은 뜻으로 바꾸고, +00:00은 "UTC를 기준으로 삼은 시각"으로 남겼다. ISO 8601은 -00:00을 허용하지 않으므로 Z를 권한다. RFC 9557은 또 [Asia/Seoul]처럼 대괄호 꼬리표로 시간대 이름을 덧붙이는 형식을 만들어, "어느 규칙의 +09:00인지"까지 문자열 하나에 담을 수 있게 했다.
저장과 표시 원칙
원칙은 짧다. 이미 일어난 순간은 UTC로 저장하고, 표시할 때 보는 사람의 시간대로 바꾼다. 주문 시각, 로그, 글쓴 시각이 여기 든다. 원래 어느 시간대에서 일어났는지가 의미 있으면 시간대 이름을 따로 함께 저장한다.
예외가 둘 있다. 하나는 미래의 현지 약속이다. "2027년 3월 14일 서울 09:00 회의"를 지금 UTC로 바꿔 저장하면, 그 사이에 규칙이 바뀌었을 때 회의가 한 시간 밀린다. 벽시계 시각과 시간대 이름을 저장하고 그때 계산한다. 다른 하나는 날짜만 있는 값이다. 생일이나 공휴일은 순간이 아니라 달력의 칸이다. 이것을 "그날 자정 UTC"로 저장하면 UTC보다 서쪽에 있는 사람에게는 하루 전 날짜로 보인다. 날짜로만 저장한다. Temporal은 이 셋을 Instant·ZonedDateTime·PlainDate로 나눠 두었다.
시계: 벽시계와 단조 시계
운영체제에는 시계가 여럿이다. Linux의 CLOCK_REALTIME은 벽시계다. 유닉스 시간을 주고, 관리자가 바꾸거나 NTP가 맞추면 갑자기 앞뒤로 뛸 수 있다. CLOCK_MONOTONIC은 단조 시계다. 설정할 수 없고 부팅 무렵부터 세며, 시스템 시각의 불연속한 점프에 영향을 받지 않는다(NTP의 속도 조정은 받는다). 기기가 잠든 시간은 세지 않고, 그것까지 세려면 CLOCK_BOOTTIME을 쓴다.
그래서 "지금 몇 시인가"는 벽시계로, "얼마나 걸렸나"는 단조 시계로 잰다. 측정 도중 NTP가 시계를 2초 뒤로 맞추면, 벽시계로 잰 4초짜리 작업은 2초로 나온다. 타임아웃·재시도 간격·성능 측정에 벽시계를 쓰면 이런 일이 생긴다. 여러 서버 사이의 시계 어긋남과, 실제 시각 대신 순서만 세는 논리 시계는 분산 시스템 노트의 시간과 순서 절에 있다.
흔한 오해
- "length가 글자 수다." JS의 length는 UTF-16 코드 유닛 수다. 사람이 보는 글자 수는 Intl.Segmenter로 센다.
- "한글 한 글자는 2바이트다." 직접 배정된 EUC-KR·CP949 음절 코드는 2바이트지만 UTF-8 완성 음절은 3바이트, NFD 자모로 풀면 6~9바이트다.
- "UTF-8로 바꾸면 한글 입력 오류가 모두 고쳐진다." IME 조합·확정 처리, 문자열 경계, 글꼴 문제는 인코딩과 별개다.
- "화면에 똑같이 보이면 같은 문자열이다." NFC와 NFD처럼 코드 포인트가 다를 수 있다. 비교하기 전에 정규화한다.
- "UTF-8이면 BOM이 필요하다." UTF-8은 바이트 순서가 없다. BOM(EF BB BF)은 표식일 뿐이고 RFC 3629는 프로토콜이 쓰임을 제한할 수 있다고 적었다.
- "+09:00은 서울 시간대다." 오프셋은 숫자 하나다. 서울의 오프셋은 1987·1988년 여름에 +10:00이었다.
- "하루는 늘 86,400초다." 서머타임 전환일의 현지 하루는 23·25시간이고, 윤초가 있던 날의 UTC 하루는 86,401초였다.
- "경과 시간은 현재 시각의 차로 잰다." 벽시계는 뒤로 갈 수 있다. 단조 시계를 쓴다.
핵심 자료
바이트와 인코딩의 기초는 데이터 노트, 서버 사이의 시계 동기화는 분산 시스템 노트에 있다.
| 자료 | 종류 | 요지 | 링크 |
|---|---|---|---|
| The Unicode Standard, Version 18.0 | 표준 | 코드 공간, 문자 속성, 한글 음절 조합(3.12절)까지 유니코드의 본문. 18.0은 172,808자를 담는다. | Unicode |
| RFC 3629 UTF-8 | 표준 | UTF-8의 1~4바이트 패턴, U+10FFFF 상한, 서로게이트와 너무 긴 형식 금지, 잘못된 바이트를 거부해야 하는 이유. | RFC Editor |
| UAX #29 Text Segmentation | 표준 부속서 | 사람이 한 글자로 느끼는 자소 클러스터의 경계 규칙. 이모지 ZWJ 시퀀스를 한 덩어리로 묶는 GB11 규칙이 있다. | Unicode |
| UAX #15 Unicode Normalization Forms | 표준 부속서 | NFC·NFD·NFKC·NFKD의 정의와 정준 동치. K가 붙은 형식은 의미 있는 구별을 지울 수 있다는 경고. | Unicode |
| Encoding Standard | 웹 표준 | 브라우저가 쓰는 인코딩 목록과 이름. 새 형식은 UTF-8만 쓰라고 정하고, EUC-KR 이름 아래 Windows 949 표를 둔다. | WHATWG |
| Time Zone Database | 데이터 | 세계 여러 곳의 현지 시각 역사를 담은 tz(zoneinfo) 데이터와 코드. 운영체제·언어 런타임이 이것을 쓴다. | IANA |
| RFC 3339 Date and Time on the Internet: Timestamps | 표준 | 인터넷 프로토콜이 쓰는 타임스탬프 꼴. Z, 오프셋, -00:00, 윤초 60초 표기. | RFC Editor |
| CGPM 2022 결의 4 (UTC의 사용과 미래) | 결의 | UT1−UTC의 최대 허용 차이를 2035년이나 그 전에 늘리기로 한 결정. 사실상 윤초를 멈추는 길이다. | BIPM |
- The Unicode Standard, Version 18.0 — Unicode
- RFC 3629 UTF-8 — RFC Editor
- UAX #29 Text Segmentation — Unicode
- UAX #15 Unicode Normalization Forms — Unicode
- Encoding Standard — WHATWG
- Time Zone Database — IANA
- RFC 3339 Date and Time on the Internet: Timestamps — RFC Editor
- CGPM 2022 결의 4 (UTC의 사용과 미래) — BIPM
- Chapter 3 Conformance (3.12 Conjoining Jamo Behavior) — Unicode
- UTS #10 Unicode Collation Algorithm — Unicode
- String: length — MDN
- TextDecoder — MDN
- Temporal — MDN
- RFC 9557 Date and Time on the Internet: Timestamps with Additional Information — RFC Editor
- Bulletin C — IERS
- Leap Smear — Google Public NTP
- clock_gettime(2) — man7.org
- APFS FAQ (Unicode normalization) — Apple Developer
- Korean FAQ — Unicode
- UTN #60 Legacy Hangul Syllables Mappings — Unicode
- RFC 1557 Korean Character Encoding for Internet Messages — RFC Editor
- Code Page Identifiers — Microsoft Learn
- UI Events: Composition Events — W3C
- Input Events Level 2 — W3C
- Compositions (Text Services Framework) — Microsoft Learn
- NSTextInputClient — Apple Developer
- IBus — 공식 프로젝트
- How does an application talk to Fcitx — Fcitx
- SDL_TextEditingEvent — SDL
- SDL_TextInputEvent — SDL
- SDL_StartTextInput — SDL
- SDL_SetTextInputArea — SDL
- Processing the WM_IME_COMPOSITION Message — Microsoft Learn
- Developing OpenType Fonts for Korean Hangul Script — Microsoft Learn
- UTS #51 Unicode Emoji — Unicode