Use when 엑셀 CSV 한글 깨짐, BOM, CP949/EUC-KR 인코딩 문제, 초성 검색, 한글 정렬, 파일명 NFC/NFD 정규화, 자모 분해, 을/를·이/가 조사 선택을 다룰 때 — Korean text encoding and search, legacy CP949/EUC-KR files, Excel CSV BOM mojibake, NFC/NFD normalization for macOS-uploaded filenames, initial-consonant (초성) search, Korean collation, particle selection in generated messages.
Installs into .claude/skills of the current project.
Are you the author of Korean Encoding?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/sungjun1217-korean-encoding)
---
name: korean-encoding
description: Use when 엑셀 CSV 한글 깨짐, BOM, CP949/EUC-KR 인코딩 문제, 초성 검색, 한글 정렬, 파일명 NFC/NFD 정규화, 자모 분해, 을/를·이/가 조사 선택을 다룰 때 — Korean text encoding and search, legacy CP949/EUC-KR files, Excel CSV BOM mojibake, NFC/NFD normalization for macOS-uploaded filenames, initial-consonant (초성) search, Korean collation, particle selection in generated messages.
---
<!-- kimchi-ignore-file 나쁜 예를 그대로 인용한다 -->
# 한글 인코딩과 문자 처리
예제 파일을 열 수 있으면 Read 로 열고, 권한 때문에 막히면 아래 코드를 그대로 쓰십시오.
## 인코딩이 깨지는 세 자리
**엑셀에서 CSV 가 깨진다.** 엑셀은 UTF-8 파일에 BOM 이 없으면 시스템 기본 인코딩으로
읽습니다. 한국어 윈도에서는 CP949 라서 한글이 전부 깨집니다. 해결은 BOM 을 앞에 붙이는
것입니다. 파일 내용을 CP949 로 바꾸지 마십시오 — 엑셀은 열리지만 다른 도구가 깨집니다.
**BOM 을 `"\uFEFF"` 이스케이프로 쓰지 마십시오.** 도구 호출 인자는 JSON 이라 `\uFEFF`가
파싱 단계에서 바로 진짜 BOM 문자로 풀립니다. 그러면 에디터 화면에 아무것도 안 보이는
글자가 소스에 그대로 박혀서, 나중에 지우거나 옮기다가 없어져도 아무도 알아채지 못합니다.
코드 포인트로 만드십시오.
```js
const csv = String.fromCharCode(0xfeff) + rows.map((row) => row.join(",")).join("\r\n");
```
줄바꿈도 `\r\n` 이어야 엑셀이 제대로 나눕니다. `toExcelCsv()` 가 둘을 함께 처리합니다.
`toExcelCsv()` 도 **앞자리 0 은 지키지 못합니다.** CSV 에는 셀 서식이 없어서 엑셀이 열 때
숫자로만 보이는 값을 다시 숫자로 해석합니다. 앞자리 0 을 지켜야 하면 CSV 가 아니라 xlsx 를
만들고 해당 열을 문자열 서식으로 지정하십시오.
**그리고 수식 주입을 막아야 합니다.** `=`, `+`, `-`, `@` 로 시작하는 값을 엑셀과 구글
스프레드시트가 **수식으로 실행합니다.** 상호나 비고란에 실제로 들어오고, 받는 사람의
기기에서 실행되므로 보안 문제입니다. 앞에 홑따옴표를 붙이면 문자열로 다룹니다.
`escapeForSpreadsheet()` 를 쓰십시오. 아래는 `examples/tabular.mjs` 원본과 어긋나지
않는 전체 구현입니다.
```js
// inline:tabular.mjs
const FORMULA_STARTERS = /^[=+\-@\t\r]/;
export function escapeForSpreadsheet(value) {
const text = String(value ?? "");
return FORMULA_STARTERS.test(text) ? `'${text}` : text;
}
export function toExcelCsv(rows) {
const BOM = String.fromCharCode(0xfeff);
const body = rows
.map((row) =>
row
.map((cell) => {
const safe = escapeForSpreadsheet(cell);
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
})
.join(",")
)
.join("\r\n");
return BOM + body;
}
```
**전각 문자와 보이지 않는 글자가 섞인다.** 관공서 자료와 웹에서 복사한 값에
`123`(전각 숫자), 전각 하이픈, U+00A0(줄바꿈 없는 공백), 폭 없는 글자가 들어옵니다.
눈으로는 구별할 수 없고 정규식 `\d` 에도 걸리지 않아서 **검증이 조용히 실패합니다.**
```js
import { cleanField, normalizeWidth } from "./examples/tabular.mjs";
cleanField(row["사업자등록번호"]); // 폭·공백·정규화를 한 번에
```
**옛 파일이 CP949 다.** 관공서 자료, 은행 거래 내역, 레거시 시스템 내보내기가 아직
CP949/EUC-KR 입니다. Node 는 CP949 를 기본으로 지원하지 않아 `iconv-lite` 같은 것이 필요합니다.
바이트를 UTF-8 로 가정해 읽으면 조용히 깨진 문자열이 됩니다.
**macOS 에서 올린 파일 이름이 다르다.** 이게 가장 찾기 어렵습니다. macOS 는 파일 이름을
NFD(자모 분리)로 저장하고 리눅스·윈도는 NFC(음절 결합)로 저장합니다. 눈으로는 똑같은
`한글.txt` 인데 문자열이 달라서 조회가 실패합니다.
규칙은 하나입니다. **경계를 넘는 모든 문자열은 NFC 로 정규화합니다.** 업로드를 받을 때,
데이터베이스에 넣을 때, 비교할 때.
```js
import { normalizeForStorage, differsOnlyByNormalization } from "./examples/hangul-jamo.mjs";
normalizeForStorage(uploadedName); // 저장 전에 반드시
differsOnlyByNormalization(a, b); // 버그를 진단할 때
```
## 앞자리 0 이 날아간다
한국 자료의 식별자는 대개 0 으로 시작합니다. 사업자번호, 우편번호, 전화번호, 계좌번호.
숫자로 읽으면 앞자리 0 이 사라집니다.
더 나쁜 경우가 있습니다. **받은 파일이 이미 엑셀을 거쳤으면 원본이 이미 망가져 있습니다.**
담당자가 열어 저장한 순간 0 이 없어지거나 지수 표기(`1.23457E+09`)로 바뀝니다.
**0 을 채워 복구하려 들지 마십시오.** 잘못 채우면 다른 사업자의 번호가 됩니다.
이상 자료로 표시해 담당자에게 되돌리는 것이 유일한 해결입니다.
```js
import { checkDigitCount } from "./examples/tabular.mjs";
const check = checkDigitCount(row["사업자등록번호"], 10);
if (!check.ok) reject(row, check.reason); // 채우지 않고 되돌린다
```
내보낼 때는 xlsx 셀 서식을 문자열로 지정하십시오. 아니면 받는 쪽에서 다시 날아갑니다.
## 초성 검색
한국 서비스의 검색창에서는 사실상 필수입니다. "김치"를 "ㄱㅊ"로 찾는 기능입니다.
한글 음절은 `(초성 × 21 × 28) + (중성 × 28) + 종성 + 0xAC00` 으로 만들어지므로 초성 색인은
`(코드 - 0xAC00) / 588` 입니다. 직접 짜면 이 산술을 틀리기 쉽습니다.
동작하는 구현: `examples/chosung-search.mjs`
**놓치기 쉬운 것**: 질의가 초성만일 때에만 초성으로 찾아야 합니다. 사용자가 "김"을
입력했는데 초성으로 처리하면 "ㄱ"으로 시작하는 모든 것이 걸려 오히려 방해가 됩니다.
그리고 한글이 아닌 글자는 그대로 통과시켜야 "iOS앱"을 "iOSㅇ"로 찾을 수 있습니다.
**입력하는 도중의 질의도 받아야 합니다.** 한글 입력기는 "김치"를 치는 동안
`기 → 김 → 김ㅊ → 김치`를 차례로 보냅니다. 글자를 칠 때마다 결과를 보여 주는 검색창에서
완성된 질의만 받으면 결과가 사라졌다가 마지막 글자에서 다시 나타납니다. 그래서
- 음절과 초성이 섞인 질의(`김ㅊ`)는 자리마다 따로 비교합니다
- 마지막 음절은 앞부분만 맞아도 통과시킵니다. `기`는 `김`의, `갑`은 `가방`의, `달`은 `닭`의 앞부분입니다
그리고 대상과 질의를 모두 NFC로 정규화하십시오. 맥에서 올린 이름은 NFD라서 초성이 하나도 뽑히지 않습니다.
## 한글 정렬
**현대 한글 음절끼리는 코드포인트 순서가 가나다순과 같습니다.** 유니코드가 11,172자를 사전순으로
배열했고, 겹받침도 맞습니다(`달 < 달걀 < 닭`). 한글만 있는 목록에 정렬 규칙을 직접 짜지 마십시오.
어긋나는 곳은 섞였을 때입니다.
| 섞인 것 | 코드포인트 정렬 | 한국어 화면에서 기대하는 것 |
|---|---|---|
| 영문 | `Apple` 이 한글보다 앞 | 한글이 앞 |
| 숫자 | `파일10` 이 `파일2` 보다 앞 | `파일2` 가 앞 |
| NFD 이름 | 맥에서 올린 이름이 맨 앞으로 몰림 | 제자리 |
동작하는 구현: `examples/hangul-jamo.mjs` 의 `sortKorean` — NFC로 모은 뒤 `Intl.Collator("ko", { numeric: true })`
하나를 재사용합니다. 데이터베이스에서 정렬한다면 한국어 콜레이션을 지정하십시오.
```js
names.sort((a, b) => a.localeCompare(b, "ko"));
```
직접 자모를 분해해 비교하지 마십시오. 표준 콜레이션이 이미 옳게 합니다.
## 생성 문구의 조사
`"${name}을 삭제했습니다"` 처럼 문자열을 이어붙이면 이름에 따라 틀립니다.
"파일을"은 맞고 "캐시을"은 틀립니다. 받침으로 골라야 합니다.
```js
import { particleFor } from "./examples/hangul-jamo.mjs";
`${name}${particleFor(name, "을/를")} 삭제했습니다`;
```
`으로/로` 는 예외가 있습니다. ㄹ 받침 뒤에는 "로"를 씁니다. "서울로", "제주로".
**영어나 숫자로 끝나면 자동으로 알 수 없습니다.** 발음으로 판단해야 하는데 철자로는
알 수 없습니다. `particleFor` 가 `null` 을 돌려주면 조사를 쓰지 않는 문장으로 바꾸십시오.
"commit을 확인하세요" 대신 "다음 커밋을 확인하세요".
## 자주 틀리는 것
- CSV 에 BOM 없이 UTF-8 로 쓰기 — 엑셀에서 깨집니다
- 업로드 파일 이름을 정규화하지 않기 — macOS 에서 올린 것이 조회되지 않습니다
- 음절 질의를 초성 검색으로 처리하기 — 결과가 쏟아집니다
- 한글만 있는 목록에 정렬 규칙을 직접 짜기 — 코드포인트 순서가 이미 가나다순입니다. 영문·숫자·NFD가 섞일 때만 콜레이션이 필요합니다
- 완성된 질의만 검색하기 — 입력 도중인 `김ㅊ`에서 결과가 사라집니다
- 문자열 이어붙이기로 조사 만들기 — 절반은 틀립니다
- `slice` 로 한글 자르기 — 자모 분리(NFD)된 문자열에서는 `[...text]` 도 깨집니다. 음절 하나가
여러 코드포인트로 흩어져 있어서 스프레드도 코드포인트 단위로 자르기 때문입니다. NFC 로
정규화하고 나서 자르거나, 자소 단위가 그대로 필요하면 `Intl.Segmenter` 를 쓰십시오
- 전각 숫자를 정제하지 않기 — `\d` 에 걸리지 않아 검증이 조용히 실패합니다
- 식별자를 숫자로 읽기 — 앞자리 0 이 날아갑니다. 문자열로 다루십시오
- 날아간 0 을 채워 복구하기 — 다른 사업자의 번호가 됩니다. 되돌려야 합니다
- 스프레드시트로 내보낼 때 수식 주입을 막지 않기