모든 여권의 사진이 있는 면 아래쪽에는 알아보기 힘든 텍스트 두 줄이 있습니다. 대문자, 숫자, 그리고 수많은 < 기호입니다. 이것이 기계 판독 영역, 즉 MRZ(machine-readable zone)이며, 여행 문서의 표준인 ICAO(국제민간항공기구) Doc 9303이 정의합니다. 인쇄된 면과 같은 핵심 정보(이름, 문서 번호, 국적, 생년월일, 성별, 만료일)를 담고 있지만, 스캐너가 글꼴이나 레이아웃을 추측하지 않고도 읽을 수 있는 형태로 되어 있습니다.

MRZ에는 자체 오류 검출 기능도 있습니다. 문자 몇 개는 체크 디지트(check digit)입니다. 각 체크 디지트는 특정 필드로부터 계산되고, 마지막 숫자 하나는 여러 필드를 한꺼번에 검사합니다. 문자 하나를 잘못 읽으면 그 문자를 보호하는 숫자가 대개 더 이상 일치하지 않습니다. 그래서 MRZ는 신분증 처리에서 직접 검증할 수 있는 몇 안 되는 요소 중 하나입니다. 코드 스무 줄이면 되고, 누구의 OCR도 믿을 필요가 없습니다.

이 글에서는 알고리즘, 세 가지 MRZ 형식에서 각 숫자가 놓이는 위치, 그리고 프로젝트에 그대로 붙여 넣을 수 있는 Python과 JavaScript 검증기를 차례로 다룹니다. 모든 예시는 ICAO가 직접 만든 가상의 견본, "Utopia"의 Anna Maria Eriksson(UTO는 견본에만 존재하는 국가 코드)을 사용합니다. 실제 문서는 어디에도 등장하지 않습니다.

이곳은 MRZ를 읽고 서버에서 이 숫자들을 다시 검사하는 여권·신분증 OCR API, doc.cheap의 블로그입니다. 아래 내용은 doc.cheap 없이도 모두 동작하며, 코드는 오프라인에서 실행됩니다.

사용하는 문자

MRZ는 정확히 37개의 문자, 즉 0-9, A-Z, 그리고 필러 <만 사용합니다. 소문자, 공백, 구두점은 없습니다. 악센트가 있는 이름이나 라틴 문자가 아닌 이름은 음역되고, 필드 안의 공백은 <가 됩니다. 필러는 모든 필드를 고정 폭까지 채우는 데도 쓰이므로 ERIKSSON<<ANNA<MARIA<<<<<<<는 "성 ERIKSSON, 이름 ANNA MARIA"를 뜻하며, 두 개 연속된 <<가 성과 이름을 구분합니다.

알고리즘: 가중치 7, 3, 1

체크 디지트는 모든 형식의 모든 필드에서 같은 방식으로 계산합니다.

  1. 각 문자를 숫자로 바꿉니다. 숫자는 그 값 그대로입니다. 문자는 알파벳 순서에 9를 더한 값이므로 A = 10, B = 11, … Z = 35입니다. 필러 <는 0입니다.
  2. 반복되는 가중치를 곱합니다. 필드의 첫 문자부터 7, 3, 1, 7, 3, 1, … 순서입니다.
  3. 곱을 모두 더하고 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)이든 같은 파서에 붙여 넣으면 됩니다. 형식을 판별하고, 모든 필드를 읽고, 계산한 체크 디지트를 인쇄된 값 옆에 보여 주며, 모두 브라우저 안에서 처리됩니다. 여러분의 구현과 다른 사람의 구현이 서로 다른 결과를 낼 때 유용합니다.

실제 파이프라인에서의 역할

자체 OCR로 MRZ를 읽는다면, 매번 읽을 때마다 이 검사를 실행하고 실패는 "사람을 거절"이 아니라 "다시 촬영"으로 처리하십시오. 문자 하나에 걸친 빛 반사, 닳은 코팅, 구겨진 페이지가 위조보다 훨씬 흔합니다.

호스팅되는 인식 API를 쓰더라도, 결과가 돈이나 접근 권한을 결정한다면 체크 디지트를 직접 다시 계산하십시오. 응답 중에서 공급자를 믿지 않고도 확인할 수 있는 유일한 부분입니다. 저희도 마찬가지입니다. doc.cheap 응답은 자체 판정인 mrz.status 옆에 영역을 원문 그대로 mrz.lines와 mrz.text(줄 사이에 아무것도 넣지 않고 이어 붙인 것)로 제공합니다. 바로 위와 같은 함수에 넣어 볼 수 있도록 하기 위해서입니다. 문서의 MRZ 확인하기에서 이 흐름을 다룹니다. 아직 호스팅 API를 고르는 중이라면 여권 OCR API 비교에서 각 서비스가 공개한 가격과 실패한 스캔에도 과금하는지를 나란히 볼 수 있습니다.

검증기가 잘못 판단하는 사례를 발견하면 admin@doc.cheap으로 알려 주십시오.

두 코드 블록은 실제로 실행했으며 출력은 인쇄된 그대로 붙여 넣었습니다. doc.cheap에 관한 모든 설명은 코드와 대조해 확인했습니다.

마지막으로 질문 하나 드립니다. 체크 디지트가 맞지 않는 문서를 만나 보신 적이 있습니까? 원인은 무엇이었습니까? 아래 댓글로 알려 주십시오.