Use when handling Korean identity numbers or personal data — 주민등록번호 resident registration number, 사업자등록번호 business number, 법인등록번호, 개인정보 마스킹·주소 마스킹 PII masking (including address masking), 개인정보 보호법 PIPA compliance, 본인인증 identity verification, CI/DI. Covers why resident number checksum validation is broken since 2020 and what to store instead.
Installs into .claude/skills of the current project.
Are you the author of Korean Identifiers?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/sungjun1217-korean-identifiers)
---
name: korean-identifiers
description: Use when handling Korean identity numbers or personal data — 주민등록번호 resident registration number, 사업자등록번호 business number, 법인등록번호, 개인정보 마스킹·주소 마스킹 PII masking (including address masking), 개인정보 보호법 PIPA compliance, 본인인증 identity verification, CI/DI. Covers why resident number checksum validation is broken since 2020 and what to store instead.
---
<!-- kimchi-ignore-file 법령 용어와 나쁜 예를 그대로 인용한다 -->
# 한국의 식별번호와 개인정보
예제 파일을 열 수 있으면 Read 로 열고, 권한 때문에 막히면 아래 코드를 그대로 쓰십시오.
## 먼저 알아야 할 것 두 가지
**주민등록번호는 검증할 수 없습니다.** 2020년 10월부터 뒷자리 중 지역 표시와 검증 번호가
없어지고 임의로 발급됩니다. 오래 알려진 체크섬 알고리즘은 그 이후 발급된 번호에 통하지
않으므로, 검증하는 코드는 **정상 번호를 거부합니다.** 한국 코드베이스에 아직 많이 남아
있는 버그이고 새로 짤 때 가장 하기 쉬운 실수입니다.
**그리고 애초에 저장하지 않는 것이 맞습니다.** 개인정보 보호법은 법령에 구체적 근거가 있는
경우로 주민등록번호 처리를 제한합니다. 근거 없이 수집하면 위법입니다. "혹시 필요할까 봐"
받아 두는 설계를 제안하지 마십시오.
## 주민등록번호 대신 무엇을 쓰나
| 하려는 일 | 쓸 것 |
|---|---|
| 본인 확인 | 본인인증 기관의 CI(연계정보). 번호 자체를 보관하지 않습니다 |
| 중복 가입 차단 | CI 로 판단합니다. 서비스가 달라도 같은 사람은 같은 CI 입니다 |
| 서비스 내 식별 | DI(중복가입확인정보). 서비스별로 다른 값입니다 |
| 나이·성인 확인 | 생년월일이나 성인 여부만 받습니다 |
| 실명 확인 | 본인인증 **결과**만 보관합니다 |
법령 근거가 있어 정말로 보관해야 하면 암호화가 의무이고, 화면·로그·오류 보고에는 마스킹한
값만 나가야 합니다.
## 검증할 수 있는 번호들
사업자등록번호(10자리)와 법인등록번호(13자리)는 체크섬이 살아 있어 검증할 수 있습니다.
**두 번호의 알고리즘이 다릅니다.** 가중치도 계산 방식도 달라서 한 함수로 처리하면 틀립니다.
동작하는 구현: `examples/business-number.mjs`
```js
import { isValidBusinessNumber, isValidCorporateNumber } from "./examples/business-number.mjs";
isValidBusinessNumber("123-45-67891"); // true, 하이픈이 있어도 된다
isValidCorporateNumber("1234567890120"); // 가중치가 1·2 번갈이로 다르다
```
**체크섬이 맞는다고 영업 중인 사업자는 아닙니다.** 폐업하거나 휴업한 사업자의 번호도 체크섬은
그대로 맞습니다. 세금계산서 발행, 정산, 거래처 등록처럼 돈이 걸린 자리에서는 국세청의
사업자등록 상태 조회(공공데이터포털 "국세청_사업자등록정보 진위확인 및 상태조회 서비스")로
계속사업자인지와 과세 유형을 확인하십시오. 체크섬은 입력란의 오타를 바로 알려 주는 데까지만 씁니다.
관공서 자료에서 복사한 번호에는 전각 숫자가 섞여 옵니다. 예시 구현은 NFKC로 되돌린 뒤 검증합니다.
## 마스킹
되돌릴 수 없어야 합니다. 앞뒤를 조금씩 남기면 다른 정보와 합쳐 복원됩니다. 특히 생년월일과
이름을 함께 남기면 사실상 식별됩니다.
| 대상 | 남기는 것 | 주의 |
|---|---|---|
| 이름 | 첫 글자와 끝 글자 | 성이 두 글자인 경우(남궁, 황보)를 자동으로 알 수 없어 성을 분리하지 않습니다 |
| 주민등록번호 | 생년월일만 | 성별 자리도 가립니다. 생년월일과 성별이 함께 남으면 식별될 수 있습니다 |
| 전화번호 | 앞자리와 뒤 네 자리 | 서울은 지역번호가 두 자리입니다 |
| 전자우편 | 계정 앞 두 글자와 도메인 | 도메인은 남겨야 문제를 진단할 수 있습니다 |
| 계좌·카드 | 뒤 네 자리만 | 카드 앞 여섯 자리(BIN)도 남기지 않습니다. 카드사와 상품이 드러납니다 |
| 주소 | 도로명과 건물번호 | 동·호수를 남기면 특정됩니다 |
동작하는 구현: `examples/masking.mjs`
`maskRecord` 는 필드 이름을 정규화해서(소문자로 바꾸고 `_`·`-`·공백을 지웁니다)
`resident_number`·`residentNumber`·`RESIDENT-NUMBER`·`RRN` 을 같은 필드로 알아봅니다.
이름이 알려지지 않은 필드(`memo`, `note` 등)라도 값(문자열·숫자·bigint) 안에
주민등록번호나 전화번호가 섞여 있으면 찾아서 가리고 경고를 답니다. 전각 숫자, 대시류
(en-dash 포함), 보이지 않는 서식 문자가 끼어든 형태, 구분자 없이 붙은 형태
(`"9001011234567"`, `"주민번호9001011234567"`)까지 `resident-number.mjs` 의 `foldForScan()` <!-- kimchi-allow-rrn -->
으로 원문 위치를 되짚어 잡습니다. **찾은 구간만 가리고 나머지 표기는 원문 그대로
둡니다** — 리가처·단위 기호·원문자처럼 민감정보가 아닌 표기까지 바뀌어 나가지 않습니다.
탐지기(`findResidentNumbers`)는 `created_at`처럼 키 이름 끝이 시간을 가리키면 오탐을
피하려고 건너뛰지만, 마스킹은 그 판단을 일부러 들여오지 않고 그대로 가립니다 — 관리자
화면은 값을 놓치는 쪽이 더 위험합니다.
중첩된 객체와 배열(`{ }` 리터럴, `Object.create(null)`)은 재귀적으로 마스킹합니다.
`Map`·`Set`·`Buffer`·직접 만든 클래스 인스턴스처럼 필드처럼 순회할 수 없는 객체는
통째로 가리고 경고합니다 — 순회하면 형태가 뭉개지고, 그렇다고 원본을 그대로 돌려주면
그 안의 민감정보가 새어 나갈 수 있습니다. **함수 값(특히 `toJSON`)은 마스킹된 결과에서
빼고 경고합니다** — 원본 함수를 그대로 옮기면 다른 필드는 다 가려졌어도
`JSON.stringify(masked)` 한 번에 그 클로저가 쥐고 있던 원본 민감정보가 그대로 나옵니다.
`Date` 는 필드 이름을 모르면 값 하나로 보고 손대지 않지만, **알려진 필드가 `Date`
(그 밖에 문자열·숫자가 아닌 값)를 담고 있으면 통째로 가리고 경고합니다** — `rrn: new
Date(...)` 처럼 잘못 채워진 값을 마스커에 그대로 넘기면 `String(Date)` 결과를 흘려보낼
수 있습니다.
**알려진 필드인데 값이 문자열이 아니라 객체·배열(원소가 객체인 배열 포함)이면 통째로
가리고 경고합니다** — 하위 필드가 무엇을 뜻하는지 알 수 없어 잘못 짐작하느니
통째로 가립니다. 원소가 전부 문자열/숫자인 배열(`"phones": [...]`)은 예외로 각
원소에 그 필드의 마스커를 적용합니다. **알려진 필드의 값 자체를 알아볼 수 없으면
통째로 가리고 경고합니다** — 아래 주소 마스킹과 같은 실패 방향입니다.
**같은 객체나 배열을 여러 필드가 공유하거나 순환 참조가 있어도 원본을 그대로 돌려주지
않습니다.** 공유된 값은 (그 값, 적용된 마스커) 조합으로 캐싱해 재사용합니다 — 값만으로
캐싱하면 같은 배열을 한쪽은 `"tags"`(마스커 없음), 다른 쪽은 `"name"`(이름 마스커)으로
쓸 때 먼저 계산된 쪽의 결과를 정책이 다른 자리에 그대로 돌려주게 됩니다.
순환 참조는 `"[순환참조]"` 자리표시자와 경고로 대신합니다 — 원본을 그대로 돌려주면
`util.inspect`·구조적 복제·로거가 원본까지 따라가 민감정보를 보여줄 수 있습니다.
중첩이 6단계를 넘으면 그 지점부터 통째로 가리고 경고합니다.
**한계.** 전화번호나 주민등록번호를 문자열이 아니라 숫자로 저장하면 맨 앞 0 이 이미
파싱 단계에서 사라져 있습니다. 전화번호는 접두사 모양으로 짐작해 잡지만, 주민등록번호는
자릿수가 12개로 줄어 다른 12자리 숫자와 구별할 수 없어 놓칩니다. 애초에 문자열로
저장하십시오.
**주소 마스킹은 실패를 닫는 쪽으로 둡니다.** 도로명·지번을 알아볼 수 없다고 원본이나 첫
낱말을 그대로 돌려주면 동·호수가 새어 나갑니다. 규칙은 이렇습니다.
1. 도로명(`…로 123`, `…로123길 45`, `…길 45-6`, `…로 지하 12`)이나 지번(`…동 123-4`,
`…동2가 12-3`, `산 101`)을 알아보면 그 번호까지만 남기고 뒤는 가립니다.
2. 숫자 바로 뒤에 **호·동·층**이 붙으면 그 숫자는 건물번호가 아니라 상세주소의 단위입니다
("1203호"의 1203은 건물번호가 아닙니다) — 건물번호로 착각해 남기면 안 됩니다.
3. 위 둘 다 아니면 첫 낱말이 **한국의 시/도 이름**(서울, 경기도 등, 별칭 포함)으로
시작하는지 봅니다. 아니면 무엇도 믿지 말고 통째로 가립니다(`"****"`).
4. 시/도까지는 확인됐지만 그 뒤가 공백 없이 붙어 있으면(`"서울시강남구…"`) 시/도까지만
남기고, 공백으로 나뉘어 있으면 이어지는 시/군/구까지만 남깁니다. 숫자나 시/군/구가
아닌 낱말이 나오면 거기서 멈춥니다.
`examples/masking.mjs` 원본과 어긋나지 않는 전체 구현입니다.
```js
// inline:masking.mjs
const ROAD_DETAIL = /^(.*?[가-힣]+(?:로\d+길|[로길])\s*(?:지하\s*)?\d+(?:-\d+)?)(?![\d-가호동층길])/;
const LOT_DETAIL = /^(.*?[가-힣]+(?:\d+가|[동리가])\s*(?:산\s*)?\d+(?:-\d+)?)(?![\d-호동층])/;
const PROVINCE_NAMES = [
"서울특별시", "서울시", "서울",
"부산광역시", "부산시", "부산",
"대구광역시", "대구시", "대구",
"인천광역시", "인천시", "인천",
"광주광역시", "광주",
"대전광역시", "대전시", "대전",
"울산광역시", "울산시", "울산",
"세종특별자치시", "세종시", "세종",
"경기도", "경기",
"강원특별자치도", "강원도", "강원",
"충청북도", "충북",
"충청남도", "충남",
"전북특별자치도", "전라북도", "전북",
"전라남도", "전남",
"경상북도", "경북",
"경상남도", "경남",
"제주특별자치도", "제주도", "제주",
].sort((a, b) => b.length - a.length);
function safePrefix(text) {
const tokens = text.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return "";
const province = PROVINCE_NAMES.find((name) => tokens[0].startsWith(name));
if (province === undefined) return "";
if (tokens[0] !== province) return province;
let lastAdminIndex = 0;
for (let i = 1; i < tokens.length; i += 1) {
if (/\d/.test(tokens[i])) break;
if (!/[시군구]$/.test(tokens[i])) break;
lastAdminIndex = i;
}
return tokens.slice(0, lastAdminIndex + 1).join(" ");
}
function withMaskedRemainder(prefix, text) {
const remainder = text.slice(prefix.length).trim();
return remainder.length === 0 ? prefix : `${prefix} ****`;
}
export function maskAddress(value) {
const text = String(value).trim();
const road = ROAD_DETAIL.exec(text);
if (road !== null) return withMaskedRemainder(road[1], text);
const lot = LOT_DETAIL.exec(text);
if (lot !== null) return withMaskedRemainder(lot[1], text);
const prefix = safePrefix(text);
if (prefix.length === 0) return "****";
return prefix.length === text.length ? prefix : `${prefix} ****`;
}
```
## 관리자 화면
회원 목록을 보여 주는 화면이 유출 통로가 됩니다.
- **마스킹은 서버에서 하십시오.** 원본을 내려보내고 화면에서 가리면 개발자 도구와 네트워크
기록, 브라우저 캐시에 원본이 그대로 남습니다.
- **접속기록을 남기십시오.** 누가 언제 누구의 정보를 조회·수정·내려받았는지입니다.
개인정보 처리 시스템의 접속기록 보관은 안전성 확보조치 기준상 **의무**이고 최소 보관
기간이 정해져 있습니다. 정확한 기간과 조건은 고시 원문을 확인하십시오 — 처리 규모와
정보 종류에 따라 다릅니다. 유출이 생겼을 때 추적할 유일한 수단이기도 합니다.
- **전체 내려받기를 통제하십시오.** 건수 상한, 기간 제한, 별도 권한. 목록 조회 권한과
내려받기 권한을 같은 것으로 두지 마십시오.
- **권한을 쪼개십시오.** 목록 조회와 상세 조회가 다른 권한입니다. 상세는 대개 필요 없습니다.
- **조합으로 판단하십시오.** 각각은 안전해 보여도 한 줄에 모이면 특정됩니다.
이름 첫 글자 + 생년월일 + 지역이면 사실상 한 사람입니다.
```js
import { maskRecord } from "./examples/masking.mjs";
const { masked, warnings } = maskRecord(row);
if (warnings.length > 0) log.warn(warnings); // 조합 위험을 알려 준다
```
## 로그와 오류 보고에 새는 것
주민등록번호가 코드에 들어가는 경로는 대개 다음 셋입니다.
1. 시험 자료에 실제 번호를 씀 — 형식만 맞는 가짜 번호를 쓰십시오
2. 요청 본문을 그대로 로그에 남김 — 필드 단위로 골라 남기십시오
3. 오류 추적 도구에 요청 전체를 보냄 — 전송 전에 마스킹 훅을 두십시오
`examples/resident-number.mjs` 의 `findResidentNumbers()` 로 글에 섞인 것을 찾을 수 있습니다.
커밋 전 검사나 로그 전송 훅에 걸어 쓰십시오.
## 자주 틀리는 것
- 주민등록번호를 체크섬으로 검증하기 — 2020년 10월 이후 번호를 거부합니다
- 주민등록번호를 기본키나 회원 식별자로 쓰기 — 법적 근거가 없고 바꿀 수도 없습니다
- 성별 자리를 1·2 만 다루기 — 외국인은 5~8 입니다
- 출생 연도를 두 자리로 저장하기 — 성별 자리가 세기를 결정합니다
- 사업자등록번호를 법인등록번호와 같은 함수로 검증하기
- 체크섬이 맞으면 거래 가능한 사업자로 보기 — 폐업자의 번호도 체크섬은 맞습니다. 상태 조회를 하십시오
- 숫자를 `\D` 로만 걸러내기 — 전각 숫자가 지워져 정상 번호를 거부하고, 탐지에서는 빠져나갑니다
- 마스킹을 화면에서 하기 — 원본이 네트워크 기록에 남습니다
- 접속기록을 남기지 않기 — 의무이고, 유출 시 추적할 수단이 없어집니다
- 필드마다 따로 판단하기 — 각각 안전해 보여도 모이면 특정됩니다