Skip to content
Back to skills

Korean Identifiers

ASecurity

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.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 15, 2026
development

Security analysis

A100/100

Pro scans all 4 files and shows the line behind each finding

Scanned October 15, 2026

npx -y skills add SungJun1217/kimchi-claude --skill korean-identifiers --agent claude-code

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.

Security grade badge for Korean Identifiers
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/sungjun1217-korean-identifiers/badge)](https://www.skillsdirectory.com/skills/sungjun1217-korean-identifiers)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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` 로만 걸러내기 — 전각 숫자가 지워져 정상 번호를 거부하고, 탐지에서는 빠져나갑니다
- 마스킹을 화면에서 하기 — 원본이 네트워크 기록에 남습니다
- 접속기록을 남기지 않기 — 의무이고, 유출 시 추적할 수단이 없어집니다
- 필드마다 따로 판단하기 — 각각 안전해 보여도 모이면 특정됩니다

Files in this skill

  • SKILL.md14.5 KB
  • examples/business-number.mjs2.4 KB
  • examples/masking.mjs26 KB
  • examples/resident-number.mjs13.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.