테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

문자와 시간

한 글자가 번호·바이트·모양으로 갈라지는 과정과 한 시각이 UTC·시간대·오프셋으로 갈라지는 과정을 따라간다. 한글의 KS·EUC-KR·CP949·조합형, 유니코드 자모와 이모지, 게임·웹의 IME 입력 오류도 입력·저장·표시 단계로 구분한다.

목차 (22)
  1. 문자, 코드 포인트, 글리프, 바이트
  2. ASCII에서 유니코드로
  3. UTF-8 인코딩 규칙
  4. 다중 바이트: 바이트로 자르면 글자가 깨진다
  5. UTF-16과 서로게이트
  6. 글자 수 세기: 코드 유닛, 코드 포인트, 자소 클러스터
  7. 정규화: NFC와 NFD
  8. 한글 조합 공식
  9. 옛 인코딩: EUC-KR·CP949와 깨진 글자
  10. 게임과 웹의 한글 입력: IME 조합과 확정
  11. 증상으로 구분하는 한글 입력 오류
  12. 대소문자와 정렬은 언어마다 다르다
  13. 시간: UTC, 시간대, 오프셋
  14. IANA tz 데이터베이스
  15. 서머타임 함정: 없는 시각, 두 번 있는 시각
  16. 유닉스 시간과 2038 문제
  17. 윤초와 스미어링
  18. ISO 8601과 RFC 3339 형식
  19. 저장과 표시 원칙
  20. 시계: 벽시계와 단조 시계
  21. 흔한 오해
  22. 핵심 자료

문자, 코드 포인트, 글리프, 바이트

"가"라는 글자에는 네 겹이 있다. 맨 위는 문자, 곧 "한글 음절 가"라는 추상적인 뜻이다. 유니코드는 문자마다 번호를 붙이는데, 이것이 코드 포인트이고 U+AC00처럼 적는다. 번호를 파일이나 네트워크에 실으려면 바이트로 바꿔야 하고, 그 규칙이 인코딩(UTF-8, UTF-16 등)이다. 같은 번호도 인코딩마다 바이트가 다르다. 마지막으로 화면에 그려지는 모양이 글리프이고, 이것은 글꼴이 정한다. 명조와 고딕의 "가"는 모양이 달라도 같은 문자다.

텍스트 버그는 대개 이 겹을 섞을 때 생긴다. 바이트 수를 글자 수로 알거나, 다른 인코딩으로 쓴 바이트를 읽거나, 모양이 같으니 같은 문자열이라고 믿는 경우다. 비트·바이트·16진수 자체는 데이터 노트에 있다.

문자 가가 코드 포인트 U+AC00이 되고, UTF-8과 UTF-16 바이트로 저장되며, 글꼴마다 다른 글리프로 그려지는 관계문자(추상)한글 음절"가"코드 포인트(번호)U+AC00바이트(인코딩)UTF-8EA B0 80UTF-16AC00글리프(모양)가명조 계열가고딕 계열가고정폭번호는 하나, 저장 바이트는 인코딩마다, 모양은 글꼴마다 다르다
한 글자의 네 겹. 번호는 하나지만 저장되는 바이트는 인코딩마다, 그려지는 모양은 글꼴마다 다르다. 바이트는 인코딩 함수로 계산했다.

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인 이유다.

유니코드 코드 공간 17개 평면과, 첫 평면 안의 ASCII, 한글 음절, 서로게이트 영역의 위치코드 공간: 평면 17개 × 65,536 = 1,114,112칸0123456789101112131415160 = BMP(기본 다국어 평면)1 = 😀(U+1F600) 같은 이모지가 있는 평면서로게이트U+D800–DFFF한글 음절U+AC00–D7A3ASCII 128자U+0000–007FU+FFFFASCII는 7비트 128칸, 유니코드는 그 128칸을 그대로 앞에 두고 1,114,112칸으로 넓혔다
유니코드 코드 공간. 위는 평면 17개, 아래는 첫 평면(BMP)을 늘린 것이다. 한글 음절 11,172자가 한 구간에 모여 있고, 서로게이트 구간은 문자가 아니라 UTF-16이 쓰려고 비워 둔 번호다.

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바이트다.

UTF-8의 1바이트부터 4바이트까지 범위, 비트 패턴, 예시 문자 A, é, 가, 이모지의 바이트코드 포인트 범위바이트 패턴(x = 값 비트)예시U+0000–007F0xxxxxxxA41U+0080–07FF110xxxxx 10xxxxxxéC3 A9U+0800–FFFF1110xxxx 10xxxxxx 10xxxxxx가EA B0 80U+10000–10FFFF11110xxx 10xxxxxx ×3😀F0 9F 98 80값 비트 7 · 11 · 16 · 21개. 예시 U+1F600는 4바이트, ASCII는 그대로 1바이트다
UTF-8의 네 가지 길이. 예시 바이트는 UTF-8 인코딩 함수로 계산했다.
가와 이모지를 UTF-8 바이트로 바꾸는 과정을 비트 단위로 보이는 단계 그림가U+AC001010 1100 0000 00001110xxxx1010EA10xxxxxx110000B010xxxxxx000000803바이트 · 값 비트 4+6+6😀U+1F6000 0001 1111 0110 0000 000011110xxx000F010xxxxxx0111119F10xxxxxx0110009810xxxxxx000000804바이트 · 값 비트 3+6+6+6
1 / 5

"가"는 U+AC00다. 이진수로 16자리라 U+0800–FFFF 범위, 즉 3바이트 패턴 1110xxxx 10xxxxxx 10xxxxxx를 쓴다.

UTF-8 인코딩을 한 비트씩. 비트와 바이트는 UTF-8 인코딩 함수로 계산했고, 결과는 TextEncoder와 같은지 테스트로 확인했다.

이 설계에는 장점이 많다. 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바이트다.

UTF-8로 "가나다"는 9바이트다. 4바이트에서 자르면 "가" 다음에 깨진 글자가 남고, 이어지는 바이트를 피해 3바이트에서 자르면 "가"만 깨끗이 남는다. EUC-KR로 "한글"은 4바이트이고 3바이트에서 자르면 "한" 다음에 깨진 글자가 남는다UTF-8로 "가나다" — 9바이트, 글자마다 3바이트EAB080EB8298EB8BA4가나다4바이트에서 그냥 자르면 → "가�" (둘째 글자의 첫 바이트만 남음)이어지는 바이트를 피해 3바이트로 물러나면 → "가"EUC-KR로 "한글" — 4바이트, 글자마다 2바이트C7D1B1DB한글3바이트에서 자르면 → "한�"둘째 바이트도 A1~FE라 첫 바이트와구별이 안 돼, 중간에서 경계를 못 찾는다
같은 글자를 바이트 수로 자르기. 색칠한 칸은 글자의 첫 바이트, 빈 칸은 이어지는 바이트다. 빨간 점선은 바이트 수만 보고 자른 자리, 파란 선은 글자 경계까지 물러나 자른 자리다. 결과 글자는 실제 디코더(TextDecoder)로 읽은 값이다.

문제는 길이 제한을 바이트로 세면서 그 자리에서 잘라 낼 때 생긴다. 칼럼 길이, 로그·메시지 한도, 고정 크기 버퍼나 패킷이 그런 자리다. 글자 하나의 바이트가 갈라지면 앞 조각은 혼자서는 글자가 되지 못해 대체 문자 �(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 안이라 이 문제를 잘 겪지 않아서 오히려 늦게 발견된다.

U+1F600이 서로게이트 쌍 D83D DE00으로 바뀌는 계산과 자바스크립트 length 비교코드 포인트U+1F600− 0x100000x0F600위 10비트 + D8000x03D → D83D아래 10비트 + DC000x200 → DE00😀 = D83D DE00 (높은 서로게이트 + 낮은 서로게이트)"가".length1"😀".length2
U+1F600을 UTF-16으로. 계산은 UTF-16 인코딩 함수로 했고, 결과는 JS 문자열의 코드 유닛과 같은지 테스트로 확인했다.

글자 수 세기: 코드 유닛, 코드 포인트, 자소 클러스터

"몇 글자인가"에는 답이 여럿이다. 저장 크기는 바이트, 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바이트가 된다(다음 절).

가족 이모지가 코드 포인트 일곱 개로 이루어져 바이트, 코드 유닛, 코드 포인트, 자소 클러스터 수가 각각 다르다는 그림눈에는 한 글자: 👨‍👩‍👧‍👦👨U+1F468ZWJU+200D👩U+1F469ZWJU+200D👧U+1F467ZWJU+200D👦U+1F466ZWJ(폭 없는 이음표, U+200D)가 사람 넷을 한 그림으로 묶는다UTF-8 바이트저장·전송 크기25UTF-16 코드 유닛JS length11코드 포인트[...s].length7자소 클러스터Intl.Segmenter1
가족 이모지 하나를 네 가지로 센 값. 값은 길이 함수로 계산했다(자소 클러스터는 Intl.Segmenter).

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도 정상 유니코드이며, 자모가 보이게 분리되는 현상에는 글꼴·렌더러의 조합 지원도 관여한다. 기초 설명은 데이터 노트의 텍스트 인코딩 절에 짧게 있다.

한이라는 글자의 NFC 형태는 코드 포인트 하나, NFD 형태는 자모 코드 포인트 셋이고 그대로 비교하면 다르다는 그림NFC(조합)U+D55C코드 포인트 1 · UTF-8 3바이트 · 화면에는 같은 "한"NFD(분해)U+1112U+1161U+11AB코드 포인트 3 · UTF-8 9바이트 · 화면에는 같은 "한"그대로 비교: 다르다둘 다 NFC로 바꾼 뒤 비교: 같다초성 U+1100대, 중성 U+1161대,종성 U+11A8대의 조합형 자모
같은 '한'의 두 꼴. 보이는 것은 같지만 코드 포인트 수, 바이트 수, 비교 결과가 다르다. 값은 길이 함수와 normalize로 계산했다.

한글 조합 공식

유니코드의 한글 음절 11,172자는 아무렇게나 놓인 것이 아니다. 초성 19개, 중성 21개, 종성 28개(받침 없음 포함)의 모든 조합을 정해진 순서로 늘어놓았고, 유니코드 표준 3.12절이 그 상수(SBase = AC00, VCount = 21, TCount = 28 등)를 정한다. 그래서 자모 번호만 알면 표를 찾지 않고 계산으로 음절을 만들 수 있다. 음절 번호는 (초 × 21 + 중) × 28 + 종이고, 여기에 U+AC00을 더한 것이 코드 포인트다. 거꾸로 나눗셈과 나머지로 자모 번호를 되찾는다.

한의 초성 ㅎ, 중성 ㅏ, 종성 ㄴ 번호로 U+D55C를 계산하는 단계 그림초성 19ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ중성 21ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ종성 28∅ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎㅎ = 18, ㅏ = 0, ㄴ = 4 (0부터 센 번호)초 × 21 + 중 = 18 × 21 + 0 = 378(378) × 28 + 종 = 10584 + 4 = 10588U+AC00 + 10588 = 0xAC00 + 0x295C = U+D55C → 한19 × 21 × 28 = 11,172자: U+AC00(가)부터 U+D7A3(힣)까지U+AC00 + (초 × 21 + 중) × 28 + 종
1 / 5

"한"을 자모로 나누면 ㅎ·ㅏ·ㄴ이다. 표에서의 순서(0부터)가 번호다. 종성 0번은 받침 없음이다.

한글 음절의 코드 포인트 계산. 번호와 결과는 한글 조합 함수로 계산했다(상수는 유니코드 표준 3.12절).

NFD 정규화도 같은 번호 체계로 음절을 조합형 자모로 푼다(자모 블록의 시작 U+1100·U+1161·U+11A7에 번호를 더한 값이다). 글꼴도 같은 생각을 쓸 수 있다. 음절 1만여 자를 다 그리는 대신 자모만 그려 두고 조합하는 방식이다. 이 사이트의 폰트 만들기가 그렇게 픽셀 자모로 한글 음절을 만든다.

완성 음절, 결합 자모, 호환 자모는 다르다

유니코드의 현대 한글 완성 음절은 가(U+AC00)~힣(U+D7A3)이다. NFD의 결합 자모는 초성·중성·종성의 역할이 정해져 있지만, 채팅에서 단독으로 쓰는 ㅎ·ㅏ·ㄴ 같은 호환 자모는 다른 코드다. 아래의 세 줄을 같은 문자열로 취급해서는 안 된다. 옛한글은 추가 자모를 조합해 나타내므로 현대 음절 11,172자만으로 모든 옛한글까지 표현하는 것은 아니다.

표현문자열코드 포인트UTF-8
완성 음절한U+D55C3바이트
결합 자모 · NFD한U+1112 + U+1161 + U+11AB9바이트
호환 자모ㅎㅏㄴU+314E + U+314F + U+31349바이트

"한".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-KRASCII와 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의 직접 배정 음절 2350자와 CP949·조합형·유니코드의 현대 음절 11172자 비교현대 한글 음절의 직접 배정 범위11,172자 기준KS X 1001완성형 2바이트 음절 코드2,350CP949 · UHC기존 음절 + 8,822자 확장11,172조합형 · Johab초성·중성·종성 비트 조합11,172Unicode가 U+AC00 ~ 힣 U+D7A311,172KS X 1001은 문자 집합, EUC-KR은 이를 바이트로 싣는 방식이다.브라우저의 euc-kr 이름은 CP949 범위까지 해석한다.
현대 한글 음절 코드의 범위 비교. 2,350은 KS X 1001에 직접 배정된 완성 음절 수이며 여러 자모 코드로 표현하는 조합 시퀀스와는 구별한다. 수와 예시 매핑은 Unicode UTN #60과 대조했다.
음절KS X 1001 음절의 EUC-KR 바이트CP949JohabUTF-8
가B0 A1B0 A188 61EA B0 80
똠직접 코드 없음8C 6399 B1EB 98 A0
힣직접 코드 없음C6 52D3 BDED 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을 적는다(이메일 노트).

EUC-KR과 UTF-8로 저장한 한글 바이트를 맞는 인코딩과 틀린 인코딩으로 읽었을 때의 결과 비교저장한 바이트맞게 읽음틀리게 읽음EUC-KR·CP949 공통 바이트C7 D1 B1 DBeuc-kr로한글utf-8로�ѱ�UTF-8로 저장ED 95 9C EA B8 80utf-8로한글euc-kr로�•œ湲€이 예시의 음절은 EUC-KR·CP949 2바이트, UTF-8 3바이트. 해석 규칙이 서로 다르다�(U+FFFD)는 UTF-8 해석기가 규칙에 맞지 않는 바이트를 만났다는 표시다
같은 '한글'을 두 인코딩으로 저장하고 맞게·틀리게 읽은 결과. 결과는 빌드 때 TextDecoder로 디코딩한 그대로다.

게임과 웹의 한글 입력: IME 조합과 확정

한글 입력은 키를 누를 때마다 완성 글자를 하나 붙이는 과정이 아니다. IME(Input Method Editor, 입력기)는 "ㅎ → 하 → 한"처럼 아직 확정하지 않은 조합 문자열(preedit)을 같은 영역에서 바꾸다가 최종 결과를 확정한다. 조합형 인코딩이나 NFC·NFD와는 다른, 입력 과정의 상태다. 중간 문자열을 매번 덧붙이면 "ㅎ하한"이 되고, 조합 끝과 최종 입력을 모두 추가하면 마지막 음절이 중복된다.

ㅎ에서 하와 한으로 같은 조합 영역을 교체한 뒤 한 번 확정하는 단계조합 시작확정한 본문조합 중인 영역∅∅화면: ∅갱신마다 잘못 덧붙임: ∅조합 영역 갱신확정한 본문조합 중인 영역∅ㅎ화면: ㅎ갱신마다 잘못 덧붙임: ㅎ조합 영역 갱신확정한 본문조합 중인 영역∅하화면: 하갱신마다 잘못 덧붙임: ㅎ하조합 영역 갱신확정한 본문조합 중인 영역∅한화면: 한갱신마다 잘못 덧붙임: ㅎ하한최종 문자열 확정확정한 본문조합 중인 영역한∅화면: 한갱신마다 잘못 덧붙임: ㅎ하한조합 중 Enter는 음절 확정에 쓰일 수 있다. 채팅 전송과 구별한다.
1 / 5

입력기가 조합 영역을 연다. 확정된 본문과 아직 바뀔 수 있는 조합 문자열을 구별한다.

한 음절을 조합하는 개념 모델. 실제 DOM·엔진 이벤트의 횟수와 순서를 그대로 재현한 것은 아니다.

Windows·macOS·Linux의 IME는 같은가

조합 중인 글자를 갱신하다가 확정한다는 원리는 같지만, 구현과 앱에 전달하는 방식은 다르다. IME는 특정 운영체제의 제품 이름이 아니라 여러 입력기를 통틀어 부르는 말이다. 앞의 단계 그림은 세 운영체제에 공통인 개념 모델이며, 동일한 API나 이벤트 순서를 뜻하지 않는다.

운영체제대표적인 앱 연결 방식조합 상태를 전달하는 방식
WindowsTSF(Text Services Framework), 기존 IMM32TSF의 조합 세션이나 기존 IME 메시지로 조합 상태·결과 문자열을 주고받는다. TSF 조합 문서
macOSAppKit의 NSTextInputClient조합 중인 영역을 marked text로 관리하고 텍스트 삽입·선택 영역과 연결한다. Apple 입력 클라이언트 문서
LinuxIBus·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의 역할에서 따라 나오는 대표적인 실패 유형이다. 특정 게임이나 브라우저의 버그를 단정하는 표가 아니며, 같은 증상에도 원인이 여러 개일 수 있다.

입력기·문자열·인코딩·글꼴 단계별 한글 오류 진단 흐름입력IME · 초점누락 · 중복문자열정규화 · 경계검색 · 삭제 오류바이트인코딩 · 저장깨짐 · 정보 손실화면글꼴 · 조합□ · 자모 분리붙여넣기는 되는데 직접 타이핑만 실패한다면 입력 경로부터 본다.코드 포인트는 맞는데 □로 보인다면 글꼴과 렌더링부터 본다.
입력·문자열·바이트·화면을 구별하는 진단 순서. 화면의 모양만 보고 인코딩을 바꾸기 전에 어느 단계에서 값이 달라졌는지 찾는다.
흔한 증상발생하는 이유확인할 지점
한글 대신 엉뚱한 한자·기호 또는 �바이트를 다른 인코딩으로 읽거나, 다중 바이트 한가운데를 잘랐다.원본 바이트와 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)으로 맞추고, 화면에 보여 줄 정렬만 로캘을 따른다.

같은 글자들을 기본 정렬, 한국어, 스웨덴어, 독일어 규칙으로 정렬한 결과와 언어에 따라 다른 대소문자 변환기본 sort()BZa가나코드 유닛 순: 대문자 → 소문자 → 한글Intl.Collator("ko")가나aBZ한국어 규칙Intl.Collator("sv")aozäö스웨덴어: ä·ö는 z 뒤의 별도 글자Intl.Collator("de")aäoöz독일어: ä는 a, ö는 o 곁에"ß".toUpperCase()→ SS한 글자가 두 글자로"I".toLocaleLowerCase("tr")→ ı튀르키예어: 점 없는 ı"i".toLocaleUpperCase("tr")→ İ점 있는 대문자 İ
정렬과 대소문자 변환. 결과는 빌드 때 Intl.Collator와 toLocaleUpperCase로 계산한 그대로다.

시간: UTC, 시간대, 오프셋

시간 쪽의 겹은 셋이다. UTC는 세계가 함께 쓰는 기준 시각으로, 어느 한 순간을 하나의 값으로 가리킨다. 오프셋은 어느 지역의 벽시계가 그 순간 UTC와 몇 시간 차이 나는지를 나타내는 숫자다(+09:00). 시간대는 Asia/Seoul, Europe/London 같은 이름이고, "이 지역은 언제 어떤 오프셋을 썼고 쓸 것인가"라는 규칙과 역사 전체다.

오프셋과 시간대를 같은 것으로 다루는 것이 흔한 실수다. 런던은 10월 말에 +00:00이지만 여름에는 +01:00이다. "+00:00"만 저장해 두면 그 사람에게 다음 여름 회의 시각을 계산할 수 없다. 반대로 "Asia/Seoul"이라는 이름을 알면 과거와 미래의 오프셋을 모두 찾을 수 있다.

같은 순간 2026-10-30T03:00Z를 UTC, 서울, 런던, 뉴욕의 현지 시각과 오프셋으로 나타낸 표한 순간: 2026-10-30T03:00Z시간대(IANA 이름)그곳의 벽시계그 순간의 오프셋UTC2026-10-30 03:00+00:00Asia/Seoul2026-10-30 12:00+09:00Europe/London2026-10-30 03:00+00:00America/New_York2026-10-29 23:00-04:00오프셋은 숫자 하나, 시간대는 "언제 어떤 오프셋을 쓰는가"의 규칙과 역사다.런던은 지금 +00:00이지만 여름엔 +01:00이다. 오프셋만 저장하면 미래 시각을 계산할 수 없다
한 순간을 네 곳의 벽시계로 본 것. 시각과 오프셋은 빌드 때 Intl.DateTimeFormat(tz 데이터)으로 계산했다.

IANA tz 데이터베이스

시간대 규칙은 나라가 법으로 바꾼다. 서머타임을 새로 하거나 그만두고, 표준시를 옮긴다. 이 변경을 모아 둔 것이 IANA가 배포하는 tz 데이터베이스(zoneinfo)다. IANA 페이지는 이를 "세계 여러 대표 지점의 현지 시각 역사를 나타내는 코드와 데이터"라고 소개한다. 주로 소프트웨어를 만드는 쪽을 위한 데이터이고, 운영체제와 라이브러리가 이것을 받아 시간대를 계산한다. 2026년 10월 27일에 2026f가 나왔을 만큼 자주 갱신된다. 규칙이 바뀌었는데 기기의 데이터가 낡았으면 시계가 한 시간 틀린다.

한국도 기록이 있다. tz 데이터에 따르면 Asia/Seoul은 1987년과 1988년 여름에 +10:00(서머타임)을 썼다. 그 시절 날짜를 UTC로 바꾸는 프로그램은 이 기록 덕분에 맞는 답을 낸다.

tz 데이터베이스의 Asia/Seoul 오프셋. 1987년과 1988년 7월만 UTC+10이고 나머지는 UTC+9Asia/Seoul의 오프셋(매달 15일 기준)+91월+97월+91월+97월+91월+107월+91월+107월+91월+97월+91월+97월198519861987198819891990"서울 시각"이라는 규칙에도 역사가 있다. 옛 날짜를 계산할 때 이 기록이 쓰인다
tz 데이터의 Asia/Seoul. 1985~1990년 1월과 7월 오프셋을 빌드 때 Intl로 읽었다. 색칠한 막대가 서머타임이다.

서머타임 함정: 없는 시각, 두 번 있는 시각

서머타임이 시작하는 날 벽시계는 한 시간을 건너뛰고, 끝나는 날 한 시간을 되풀이한다. 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은 두 번 있는 시각이다.

뉴욕의 2026년 봄과 가을 서머타임 전환에서 UTC 시각이 벽시계 시각으로 떨어지는 위치와, 없는 시각과 두 번 있는 시각UTC뉴욕봄: 2026-03-08 (EST → EDT)05:0006:0007:0008:0009:0000:0001:0002:0003:0004:0005:00가을: 2026-11-01 (EDT → EST)04:0005:0006:0007:0008:0000:0001:0002:0003:0004:0005:0002:30: 없음01:30: 2번전환 시각과 오프셋은 tz 데이터베이스의 America/New_York 기록
1 / 4

3월 8일 새벽, 뉴욕은 UTC−5에서 UTC−4로 바뀐다. UTC 07:00에 벽시계가 02:00에서 03:00으로 건너뛴다. 위 축의 한 시간이 아래 축에서는 두 칸을 넘는다.

서머타임 전환과 벽시계. 위는 UTC, 아래는 America/New_York의 벽시계다. 전환 데이터는 tz 데이터와 같은지 테스트로 확인했고, 후보 순간은 벽시계 해석 함수로 계산했다.

여기서 생기는 버그는 정해져 있다. "매일 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년 대출, 인증서, 예약)을 다루는 코드는 이미 그 날짜를 만난다.

부호 있는 32비트 유닉스 시간의 범위가 1901년부터 2038년까지이고, 최댓값 다음에는 최솟값으로 넘어간다는 그림유닉스 시간 = 1970-01-01T00:00:00Z부터 센 초−2³¹02³¹ − 1 = 2,147,483,6471901-12-13T20:45:52Z1970-01-01T00:00:00Z2038-01-19T03:14:07Z+1초 → 넘쳐서 -2,147,483,648(1901년)32비트: 0에서 양쪽으로 약 68.1년 · 64비트: 약 2923억 년파일 형식·DB 열·임베디드 펌웨어에 남은 32비트 time_t가 문제다. 시간 계산은 64비트로 한다
부호 있는 32비트 유닉스 시간의 범위. 경계 시각은 32비트 한계 함수로 계산했다.

윤초와 스미어링

원자시계로 정한 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년 동안 연속되도록 정해 제시하도록 했다. 허용 차이가 커지면 윤초를 넣을 일이 사실상 없어진다.

24시간 선형 스미어 동안 스미어 시계가 UTC보다 최대 0.5초 늦었다가 윤초 뒤 0.5초 빨라지고 다시 0으로 돌아오는 그래프스미어 시계 − UTC 표기(초)+0.50−0.50.49999-0.4999923:59:60 (윤초)정오다음 날 정오그동안 시계 속도는 약 11.6 ppm 느리다. 한 번도 뒤로 가거나 같은 초를 두 번 세지 않는다
24시간 선형 스미어 동안 스미어 시계와 UTC 표기의 차이. 값은 스미어 함수로 계산했다. 윤초 앞뒤로 부호만 바뀌고 크기는 0.5초에 조금 못 미친다.

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인지"까지 문자열 하나에 담을 수 있게 했다.

RFC 3339 타임스탬프의 날짜, T, 시각, 소수 초, 오프셋 부분과 Z, +00:00, -00:00의 뜻, 시간대 이름 꼬리표 예2026-10-30T14:05:09.250+09:00날짜(연-월-일)T시각소수 초UTC와의 차ZUTC 시각. RFC 9557부터는 "현지 오프셋은 모름"과 같은 뜻+00:00UTC를 기준으로 삼은 시각-00:00UTC 시각은 알지만 현지 오프셋은 모름(RFC 3339)2026-10-30T14:05:09+09:00[Asia/Seoul]RFC 9557: 대괄호 안에 IANA 시간대 이름을 덧붙여 "어느 규칙의 +09:00인지"까지 남긴다
RFC 3339 타임스탬프의 구조와 오프셋 표기의 뜻.

저장과 표시 원칙

원칙은 짧다. 이미 일어난 순간은 UTC로 저장하고, 표시할 때 보는 사람의 시간대로 바꾼다. 주문 시각, 로그, 글쓴 시각이 여기 든다. 원래 어느 시간대에서 일어났는지가 의미 있으면 시간대 이름을 따로 함께 저장한다.

예외가 둘 있다. 하나는 미래의 현지 약속이다. "2027년 3월 14일 서울 09:00 회의"를 지금 UTC로 바꿔 저장하면, 그 사이에 규칙이 바뀌었을 때 회의가 한 시간 밀린다. 벽시계 시각과 시간대 이름을 저장하고 그때 계산한다. 다른 하나는 날짜만 있는 값이다. 생일이나 공휴일은 순간이 아니라 달력의 칸이다. 이것을 "그날 자정 UTC"로 저장하면 UTC보다 서쪽에 있는 사람에게는 하루 전 날짜로 보인다. 날짜로만 저장한다. Temporal은 이 셋을 Instant·ZonedDateTime·PlainDate로 나눠 두었다.

일어난 순간, 미래의 현지 약속, 날짜만 있는 값을 각각 어떤 꼴로 저장하는지와 표시할 때 변환하는 원칙값의 종류저장하는 꼴JS Temporal 타입일어난 순간주문 시각, 로그2026-10-30T03:00:00ZTemporal.Instant미래의 현지 약속서울 회의 09:002027-03-14T09:00 + Asia/SeoulZonedDateTime날짜만 있는 값생일, 공휴일1990-05-01Temporal.PlainDate표시할 때만 보는 사람의 시간대로 바꾼다(Intl.DateTimeFormat의 timeZone)날짜만 있는 값을 자정 UTC 순간으로 저장하면, 서쪽 시간대에서 하루 전 날짜로 보인다
값의 종류에 따라 저장하는 꼴. 표시 변환은 화면 쪽에서 한 번만 한다.

시계: 벽시계와 단조 시계

운영체제에는 시계가 여럿이다. Linux의 CLOCK_REALTIME은 벽시계다. 유닉스 시간을 주고, 관리자가 바꾸거나 NTP가 맞추면 갑자기 앞뒤로 뛸 수 있다. CLOCK_MONOTONIC은 단조 시계다. 설정할 수 없고 부팅 무렵부터 세며, 시스템 시각의 불연속한 점프에 영향을 받지 않는다(NTP의 속도 조정은 받는다). 기기가 잠든 시간은 세지 않고, 그것까지 세려면 CLOCK_BOOTTIME을 쓴다.

그래서 "지금 몇 시인가"는 벽시계로, "얼마나 걸렸나"는 단조 시계로 잰다. 측정 도중 NTP가 시계를 2초 뒤로 맞추면, 벽시계로 잰 4초짜리 작업은 2초로 나온다. 타임아웃·재시도 간격·성능 측정에 벽시계를 쓰면 이런 일이 생긴다. 여러 서버 사이의 시계 어긋남과, 실제 시각 대신 순서만 세는 논리 시계는 분산 시스템 노트의 시간과 순서 절에 있다.

실제 시간 동안 단조 시계는 곧게 오르고, 벽시계는 NTP 보정으로 2초 뒤로 뛰어 경과 시간 측정이 틀어지는 그래프실제로 흐른 시간 →시계가 읽은 값NTP가 -2초 보정단조벽시계시작끝단조 시계로 잰 시간: 4초벽시계로 잰 시간: 2초
측정 도중 벽시계가 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