모든 여권의 사진이 있는 면 아래쪽에는 알아보기 힘든 텍스트 두 줄이 있습니다. 대문자, 숫자, 그리고 수많은 < 기호입니다. 이것이 기계 판독 영역, 즉 MRZ(machine-readable zone)이며, 여행 문서의 표준인 ICAO(국제민간항공기구) Doc 9303이 정의합니다. 인쇄된 면과 같은 핵심 정보(이름, 문서 번호, 국적, 생년월일, 성별, 만료일)를 담고 있지만, 스캐너가 글꼴이나 레이아웃을 추측하지 않고도 읽을 수 있는 형태로 되어 있습니다.
MRZ에는 자체 오류 검출 기능도 있습니다. 문자 몇 개는 체크 디지트(check digit)입니다. 각 체크 디지트는 특정 필드로부터 계산되고, 마지막 숫자 하나는 여러 필드를 한꺼번에 검사합니다. 문자 하나를 잘못 읽으면 그 문자를 보호하는 숫자가 대개 더 이상 일치하지 않습니다. 그래서 MRZ는 신분증 처리에서 직접 검증할 수 있는 몇 안 되는 요소 중 하나입니다. 코드 스무 줄이면 되고, 누구의 OCR도 믿을 필요가 없습니다.
이 글에서는 알고리즘, 세 가지 MRZ 형식에서 각 숫자가 놓이는 위치, 그리고 프로젝트에 그대로 붙여 넣을 수 있는 Python과 JavaScript 검증기를 차례로 다룹니다. 모든 예시는 ICAO가 직접 만든 가상의 견본, "Utopia"의 Anna Maria Eriksson(UTO는 견본에만 존재하는 국가 코드)을 사용합니다. 실제 문서는 어디에도 등장하지 않습니다.
이곳은 MRZ를 읽고 서버에서 이 숫자들을 다시 검사하는 문서 인식 API, doc.cheap의 블로그입니다. 아래 내용은 doc.cheap 없이도 모두 동작하며, 코드는 오프라인에서 실행됩니다.
사용하는 문자
MRZ는 정확히 37개의 문자, 즉 0-9, A-Z, 그리고 필러 <만 사용합니다. 소문자, 공백, 구두점은 없습니다. 악센트가 있는 이름이나 라틴 문자가 아닌 이름은 음역되고, 필드 안의 공백은 <가 됩니다. 필러는 모든 필드를 고정 폭까지 채우는 데도 쓰이므로 ERIKSSON<<ANNA<MARIA<<<<<<<는 "성 ERIKSSON, 이름 ANNA MARIA"를 뜻하며, 두 개 연속된 <<가 성과 이름을 구분합니다.
알고리즘: 가중치 7, 3, 1
체크 디지트는 모든 형식의 모든 필드에서 같은 방식으로 계산합니다.
- 각 문자를 숫자로 바꿉니다. 숫자는 그 값 그대로입니다. 문자는 알파벳 순서에 9를 더한 값이므로
A= 10,B= 11, …Z= 35입니다. 필러<는 0입니다. - 반복되는 가중치를 곱합니다. 필드의 첫 문자부터 7, 3, 1, 7, 3, 1, … 순서입니다.
- 곱을 모두 더하고 10으로 나눈 나머지를 구합니다. 그 한 자리 숫자가 체크 디지트입니다.
견본 여권 번호 L898902C3으로 직접 계산해 보겠습니다. 인쇄된 체크 디지트는 6입니다.
character L 8 9 8 9 0 2 C 3
value 21 8 9 8 9 0 2 12 3
weight 7 3 1 7 3 1 7 3 1
product 147 24 9 56 27 0 14 36 3
sum = 316 316 mod 10 = 6 the zone prints 6
왜 7-3-1일까요? 가장 흔한 판독 오류, 즉 문자 하나를 잘못 읽는 경우와 인접한 두 문자가 뒤바뀌는 경우의 상당수에서 합계가 달라지도록 고른 가중치입니다. 암호학적 체크섬은 아닙니다. 누구나 계산할 수 있으므로 숫자가 일치한다는 것은 영역이 내부적으로 일관된다는 것만 증명할 뿐, 문서가 진짜라는 뜻은 아닙니다.
세 가지 형식
ICAO 9303은 세 가지 MRZ 레이아웃을 정의합니다. 줄 수와 줄당 문자 수로 구별합니다.
| 형식 | 줄 × 문자 | 주로 쓰이는 곳 |
|---|---|---|
| TD1 | 3 × 30 | 신분증, 체류 허가증 |
| TD2 | 2 × 36 | 구형 신분증과 일부 여행 문서 |
| TD3 | 2 × 44 | 책자형 여권 |
아래에서 사용하는 견본입니다.
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
TD3의 두 번째 줄을 왼쪽부터 읽어 보면 L898902C3은 문서 번호, 6은 그 체크 디지트, UTO는 국적, 740812는 생년월일(YYMMDD), 2는 그 체크 디지트, F는 성별, 120415는 만료일, 9는 그 체크 디지트, ZE184226B<<<<<는 선택 데이터(대개 개인 번호), 1은 그 체크 디지트, 마지막 0은 종합(composite) 체크 디지트입니다.
MRZ 파서에는 세 형식 모두의 필드와 체크 디지트 위치를 정리한 참조 표가 있고, MRZ 형식 페이지에서는 각 레이아웃을 하나씩 설명합니다.
각 체크 디지트의 위치
위치는 0부터 시작하므로 slice에 그대로 넣을 수 있습니다. 각 필드의 체크 디지트는 필드 바로 뒤에 있습니다.
| 필드 | TD3 (2번째 줄) | TD2 (2번째 줄) | TD1 |
|---|---|---|---|
| 문서 번호 | 0–8, 숫자는 9 | 0–8, 숫자는 9 | 1번째 줄: 5–13, 숫자는 14 |
| 생년월일 | 13–18, 숫자는 19 | 13–18, 숫자는 19 | 2번째 줄: 0–5, 숫자는 6 |
| 만료일 | 21–26, 숫자는 27 | 21–26, 숫자는 27 | 2번째 줄: 8–13, 숫자는 14 |
| 선택 데이터 | 28–41, 숫자는 42 | 없음 | 없음 |
| 종합 | 숫자는 43 | 숫자는 35 | 2번째 줄: 숫자는 29 |
직접 만든 검증기가 가장 많이 틀리는 곳이 종합 체크 디지트입니다. 줄 전체를 대상으로 하지 않기 때문입니다.
- TD3: 2번째 줄의 0–9, 13–19, 21–42번 위치. 국적(10–12)과 성별(20)은 건너뜁니다.
- TD2: 2번째 줄의 0–9, 13–19, 21–34번 위치. 건너뛰는 부분은 같습니다.
- TD1: 두 줄에 걸칩니다. 1번째 줄의 5–29번 위치, 이어서 2번째 줄의 0–6, 8–14, 18–28번 위치입니다.
각 범위에는 그 안에 있는 필드별 체크 디지트도 포함됩니다. 그래서 종합 체크 디지트는 체크 디지트 자체의 오류도 잡아냅니다.
Python 검증기
의존성은 없습니다. 모양으로 형식을 판별하고, 모든 필드의 체크 디지트와 종합 체크 디지트를 검사한 뒤 결과를 dict로 돌려줍니다.
WEIGHTS = (7, 3, 1)
def char_value(c):
if c.isdigit():
return int(c)
if "A" <= c <= "Z":
return ord(c) - ord("A") + 10
if c == "<":
return 0
raise ValueError(f"not an MRZ character: {c!r}")
def check_digit(data):
return sum(char_value(c) * WEIGHTS[i % 3] for i, c in enumerate(data)) % 10
def digit_ok(data, printed):
# 필러로만 이루어진 필드는 체크 디지트 자리에 "<"를 인쇄할 수 있습니다.
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# 형식별 (이름, 줄 인덱스, 시작, 끝, 체크 디지트 위치)
LAYOUTS = {
"TD3": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27), ("personal number", 1, 28, 42, 42)],
"TD2": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27)],
"TD1": [("document number", 0, 5, 14, 14), ("birth date", 1, 0, 6, 6),
("expiry date", 1, 8, 14, 14)],
}
def composite(fmt, lines):
if fmt == "TD3":
l = lines[1]
return l[0:10] + l[13:20] + l[21:43], l[43]
if fmt == "TD2":
l = lines[1]
return l[0:10] + l[13:20] + l[21:35], l[35]
a, b = lines[0], lines[1]
return a[5:30] + b[0:7] + b[8:15] + b[18:29], b[29]
def detect(lines):
shape = (len(lines), len(lines[0]))
fmt = {(2, 44): "TD3", (2, 36): "TD2", (3, 30): "TD1"}.get(shape)
if fmt is None or any(len(l) != shape[1] for l in lines):
raise ValueError(f"unknown MRZ shape: {[len(l) for l in lines]}")
return fmt
def validate(lines):
fmt = detect(lines)
results = {}
for name, li, start, end, pos in LAYOUTS[fmt]:
results[name] = digit_ok(lines[li][start:end], lines[li][pos])
data, printed = composite(fmt, lines)
results["composite"] = digit_ok(data, printed)
return fmt, results
if __name__ == "__main__":
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10"]))
print(*validate(["I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<",
"D231458907UTO7408122F1204159<<<<<<<6"]))
print(*validate(["I<UTOD231458907<<<<<<<<<<<<<<<",
"7408122F1204159UTO<<<<<<<<<<<6",
"ERIKSSON<<ANNA<MARIA<<<<<<<<<<"]))
# 한 글자 오독: 문서 번호의 3을 4로 읽은 경우
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
출력:
TD3 {'document number': True, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': True}
TD2 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD1 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD3 {'document number': False, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': False}
이 연습의 핵심은 마지막 줄입니다. 문자 하나를 이웃한 문자로 잘못 읽었을 뿐인데, 필드 체크 디지트와 종합 체크 디지트가 둘 다 이를 잡아냅니다.
같은 검증기를 JavaScript로
순수 ES 모듈이며 Node와 브라우저에서 모두 실행됩니다.
const WEIGHTS = [7, 3, 1];
function charValue(c) {
if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48;
if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 55; // A = 10
if (c === "<") return 0;
throw new Error(`not an MRZ character: ${JSON.stringify(c)}`);
}
export function checkDigit(data) {
let sum = 0;
for (let i = 0; i < data.length; i++) sum += charValue(data[i]) * WEIGHTS[i % 3];
return sum % 10;
}
const digitOk = (data, printed) => checkDigit(data) === (printed === "<" ? 0 : Number(printed));
const LAYOUTS = {
TD3: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27], ["personal number", 1, 28, 42]],
TD2: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27]],
TD1: [["document number", 0, 5, 14], ["birth date", 1, 0, 6], ["expiry date", 1, 8, 14]],
};
function composite(fmt, [a, b]) {
if (fmt === "TD3") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 43), b[43]];
if (fmt === "TD2") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 35), b[35]];
return [a.slice(5, 30) + b.slice(0, 7) + b.slice(8, 15) + b.slice(18, 29), b[29]];
}
export function validate(lines) {
const fmt = { "2x44": "TD3", "2x36": "TD2", "3x30": "TD1" }[`${lines.length}x${lines[0].length}`];
if (!fmt || lines.some((l) => l.length !== lines[0].length)) throw new Error("unknown MRZ shape");
const results = {};
// 체크 디지트는 보호하는 필드 바로 뒤에 있습니다.
for (const [name, li, start, end] of LAYOUTS[fmt]) {
results[name] = digitOk(lines[li].slice(start, end), lines[li][end]);
}
const [data, printed] = composite(fmt, lines);
results.composite = digitOk(data, printed);
return { format: fmt, results };
}
console.log(validate([
"P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10",
]));
node mrz.mjs를 실행하면 format: 'TD3'와 함께 다섯 가지 검사 모두 true가 출력됩니다.
흔한 함정
필러를 잘라 내지 마십시오. < 문자도 체크 디지트 계산 대상인 데이터의 일부입니다. 줄 끝의 <를 제거하면 멀쩡한 문서에서도 종합 체크 디지트가 실패합니다.
파싱한 필드로 영역을 다시 조립하지 마십시오. MRZ를 필드로 파싱하고 정규화(날짜는 ISO로, 이름은 공백으로)한 뒤 다시 직렬화해서 체크 디지트를 검사하면, 검사하는 것은 여러분의 직렬화 코드일 뿐입니다. 읽어 들인 그대로의 원본 줄을 검사하십시오.
검증 전에 OCR 결과를 정규화하되, 신중하게 하십시오. OCR 엔진은 소문자나 공백을 돌려주거나 < 대신 «를 돌려주기도 합니다. 대문자로 바꾸고 공백을 제거하는 것은 안전합니다. "문서 번호는 숫자니까"라며 O를 0으로 바꾸는 것은 안전하지 않습니다. 문서 번호에는 문자가 들어갈 수 있으며, L898902C3이 바로 그 예입니다.
체크 디지트가 맞는다고 실제 날짜라는 보장은 없습니다. 740812는 1974년 8월 12일이 그럴듯한지와 상관없이 체크 디지트를 통과하며, YYMMDD에는 세기가 없습니다. 세기는 맥락으로 판단하십시오. 생년월일은 과거이고, 만료일은 대개 미래입니다.
TD1의 긴 문서 번호. ICAO는 9자를 넘는 TD1 문서 번호가 선택 데이터 필드로 넘어가는 것을 허용합니다. 이때 원래 체크 디지트 자리에는 <가 들어가고, 체크 디지트는 번호의 마지막 문자 뒤에 옵니다. 위 검증기는 이 경우를 처리하지 않습니다. 이 방식을 쓰는 발급국의 신분증을 처리한다면 분기를 추가하십시오. 비교 대상이 필요하다면 MRZ 파서가 이 경우를 처리합니다.
체크 디지트는 진위 확인이 아닙니다. 이미지를 편집할 수 있는 사람이라면 누구나 올바른 체크 디지트를 계산할 수 있습니다. MRZ가 알려 주는 것은 영역이 올바르게 읽혔고 내부적으로 일관된다는 사실뿐, 문서가 진짜라는 사실이 아닙니다. MRZ를 인쇄된 육안 판독 영역과 대조하는 것이 더 강한 신호이지만, 그것도 위조 검사는 아닙니다.
실제 여권 없이 테스트 데이터 만들기
이 코드를 테스트하는 데 실제 사람의 여권이 필요한 일은 없어야 합니다. 방법은 두 가지입니다.
- 위의 ICAO 견본. 바로 이런 용도로 공개된 것입니다.
- 직접 생성하기: MRZ 생성기는 입력한 값으로 올바른 체크 디지트를 갖춘 합성 TD3 영역을 브라우저에서 만들어 줍니다. 만든 뒤 문자 하나를 바꾸면 실패 사례가 됩니다.
반대 방향으로는, 어떤 영역(TD1, TD2 또는 TD3)이든 MRZ 파서에 붙여 넣으면 됩니다. 형식을 판별하고, 모든 필드를 읽고, 계산한 체크 디지트를 인쇄된 값 옆에 보여 주며, 모두 브라우저 안에서 처리됩니다. 여러분의 구현과 다른 사람의 구현이 서로 다른 결과를 낼 때 유용합니다.
실제 파이프라인에서의 역할
자체 OCR로 MRZ를 읽는다면, 매번 읽을 때마다 이 검사를 실행하고 실패는 "사람을 거절"이 아니라 "다시 촬영"으로 처리하십시오. 문자 하나에 걸친 빛 반사, 닳은 코팅, 구겨진 페이지가 위조보다 훨씬 흔합니다.
호스팅되는 인식 API를 쓰더라도, 결과가 돈이나 접근 권한을 결정한다면 체크 디지트를 직접 다시 계산하십시오. 응답 중에서 공급자를 믿지 않고도 확인할 수 있는 유일한 부분입니다. 저희도 마찬가지입니다. doc.cheap 응답은 자체 판정인 mrz.status 옆에 영역을 원문 그대로 mrz.lines와 mrz.text(줄 사이에 아무것도 넣지 않고 이어 붙인 것)로 제공합니다. 바로 위와 같은 함수에 넣어 볼 수 있도록 하기 위해서입니다. 문서의 MRZ 확인하기에서 이 흐름을 다룹니다.
검증기가 잘못 판단하는 사례를 발견하면 admin@doc.cheap으로 알려 주십시오.
두 코드 블록은 실제로 실행했으며 출력은 인쇄된 그대로 붙여 넣었습니다. doc.cheap에 관한 모든 설명은 코드와 대조해 확인했습니다.