개발일지/Python

[Regex] 주민등록번호, 외국인등록번호 유효성 검사

은기조아 2024. 3. 29. 01:33

배경


최근 AI 업계에서 LLM이 크게 회자되고 있는 만큼, AI Engineer로서 하고 있는 업무의 패러다임도 마찬가지로 조금씩 바뀌어 가고 있다. 기존에는 BERT 기반의 작은 LLM을 Fine-tuning하여, AGI (Artificial General Intelligence) 지향이 아닌 특정 태스크(ex. 분류)만을 수행하는 AI 모델을 만드는 일이 다반사였다. 그러나 최근에는 LLM의 성능을 높이는 것이 큰 이슈거리가 되었고, LLM의 성능이 곧 AI를 다루는 기업의 경쟁력을 나타내는 지경까지 왔다.

 

한편으로, LLM의 성능뿐만 아니라, 개인 정보, 기업 기밀 등이 포함된 데이터가 Training Data로서 쓰이지 않도록 하는 보안 강화 이슈 역시 큰 화제이기도 하다. 필자 또한 최근에 기업에서 RAG (Retrieval Augmented Generation) 모듈을 개발하는 중에, 개인 정보가 포함된 문서는 LLM의 Input에서 제외될 수 있도록 해야 한다는 사내 규정이 있다는 것을 알게 되었다. 특히 주민등록번호, 외국인등록번호, 법인등록번호가 포함된 문서는 반드시 제외해야 했기에, 이를 반영하고자 Regex를 활용하여 보안 규정에 위배되는 문서를 추출하였다.

 

서론이 길었다. 요약하자면, 본 글에서는 Regex를 활용하여 주민등록번호, 외국인등록번호, 법인등록번호를 인식하고, 인식된 부분에 대해 유효성 검사를 할 수 있는 방법에 대해 소개하고자 한다.

 

 

 

서론


먼저, Regex를 통해 패턴을 인식하기 이전에, 이러한 개인정보가 어떻게 구성되어 있는지 알아야한다.

(급하신 분들은 본론으로 넘어가셔서 유효성 검사를 하는 부분만을 따로 보시길 바란다.)

 

리처치를 통해 이 번호들은 모두 규칙성이 존재하고, 심지어 유효성 검사를 하는 구체적인 방법도 정해져 있다는 것을 알게 되었다.

주민등록번호, 외국인등록번호는 공통적으로 아래의 형식을 따른다.

주민등록번호, 외국인등록번호 양식

 

 

1. 주민등록번호, 외국인등록번호

▶ 1-6번째 자리생년월일은 모두 공통양식이고, YYMMDD 형태이다.

 

1981년 1월 1일이면, 810101

2002년 1월 1일이면, 020101

 

아래와 같이, 뒤 7자리 숫자만 그 규칙이 다르다.

 

▶ 7번째 자리는 성별을 나타낸다.

  • 9 : 1800 ~ 1899년에 태어난 남성
  • 0 : 1800 ~ 1899년에 태어난 여성
  • 1 : 1900 ~ 1999년에 태어난 남성
  • 2 : 1900 ~ 1999년에 태어난 여성
  • 3 : 2000 ~ 2099년에 태어난 남성
  • 4 : 2000 ~ 2099년에 태어난 여성
  • 5 : 1900 ~ 1999년에 태어난 외국인 남성
  • 6 : 1900 ~ 1999년에 태어난 외국인 여성
  • 7 : 2000 ~ 2099년에 태어난 외국인 남성
  • 8 : 2000 ~ 2099년에 태어난 외국인 여성

7번째 자리가 0, 1, 2, 3, 4, 9인 경우는 주민등록번호 (한국인)이고, 5, 6, 7, 8인 경우는 외국인등록번호 (외국인)이다.

 

 8-9번째 자리는 출생등록지에 해당하는 지방자치단체의 고유번호이다.

  • 서울특별시 : 00~08
  • 부산광역시 : 09~12
  • 인천광역시 : 13~15
  • 경기도 : 16~25
  • 강원도 : 26~34
  • 충청북도 : 35~39
  • 대전광역시 : 40
  • 충청남도 : 41~47
  • 세종특별자치시 : 44, 96
  • 전라북도 : 48~54
  • 전라남도 : 55~66
  • 광주광역시 : 55, 56
  • 대구광역시 : 67~69, 76
  • 경상북도 : 70~75, 77~81
  • 경상남도 : 82~84, 86~92
  • 울산광역시 : 85
  • 제주특별자치도 : 93~95

10-11번째 자리는 출생등록을 한 읍면동 주민센터 고유번호이다.

 

 12번째 자리는 해당 날에 주민센터에서 출생신고를 한 순서이다. (일련번호)

 

▶ 13번째 자리는 주민등록번호에 오류가 없는지 확인하는 검증번호로, 아래와 같은 특수한 규칙으로 만든다.

11-{(2×①+3×②+4×③+5×④+6×⑤+7×⑥+8×⑦+9×⑧+2×⑨+3×⑩+4×⑪+5×⑫) mod 11}

 

즉, 소괄호 안에 있는 것을 계산한 값을 11로 나눠서 나온 나머지를 11에서 뺀 값이다.

 

2. 법인등록번호

일반적으로 주민등록번호와 법인등록번호를 구분해야할 상황은 크게 없을 것 같지만, 정보가 필요한 분들을 위해 아래에 남긴다.

 

더보기

 1-4번째 자리 : 등기관서번호(4자리)

  • 서울중앙지방법원 등기국: 1101 
  • 서울동부지방법원 강동등기소: 2441
  • 인천지방밥원 김포등기소: 1244
  • 수원지방법원 용인등기소: 1345
  • 대전지방법원 등기과: 1601   
  • 대전지방법원 세종등기소: 1647
  • 대구지방법원 예천등기소: 1755
  • 부산지방법원 부산진등기소: 1841
  • 광주지방법원 여수등기소: 2047
  • 제주지방법원 서귀포등기소: 2241

 

5-6번째 자리 : 법인종류번호(2자리)

  • 1)상법 법인:11~15
  • 2)민법법인:21~22
  • 3)특수법인:31~51
  • 4)외국법인:81~86
  • 5)기타분류할수없는법인:71

 

7-12번째 자리 : 일련번호(6자리)

 

13번째 자리 : 오류가 없는지 확인하는 검증번호(1자리)

10-{(1×①+2×②+1×③+2×④+1×⑤+2×⑥+1×⑦+2×⑧+1×⑨+2×⑩+1×⑪+2×⑫) mod 10}

 

즉, 소괄호 안에 있는 것을 계산한 값을 10으로 나눠서 나온 나머지를 10에서 뺀 값

 

 

본론


아래 validate 함수는 "XXXXXX-XXXXXXX"의 형식으로 되어있는 문자열의 리스트를 Input으로 받으며, 만약 주민등록번호, 외국인등록번호에 해당하는 문자열이 있으면 해당 문자열을 모두 반환하고, 그렇지 않다면 빈 문자열을 반환한다.

def validate(numbers: List[str]):
    valid = False
    
    if len(numbers) != 0:
        valid_numbers = []
        for number in numbers:
            # validation first step: check if valid six-digit birth date
            six_birth = number.split("-")[0]
            matched = re.search(r"^([0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[1,2][0-9]|3[0,1])$)", six_birth)
            if not matched:
                continue
            else:
                # validation second step: check if back number starts with 0 or 9
                back_num = number.split("-")[1]
                matched = re.search(r"^(?!0|9).*", back_num)
                if not matched:
                    continue
                else:
                    # validation third step: resident numbers or foreigner register numbers validation logic
                    _number = number.replace("-", "")
                    j, total = 2, 0
                    for i in range(0, len(_number)-1):
                        total += int(_number[i]) * j
                        j += 1
                        if j == 10:
                            j = 2
                    
                    total = 11 - (total%11)
                    if int(_number[-1]) == total:
                        valid = True
                        valid_numbers += [number]
    
    return valid_numbers if valid else ""

 

 

아래 Regex는 앞의 6자리 숫자가 생년월일이 맞는지 인식하는 부분에 해당한다.

 

r"^([0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[1,2][0-9]|3[0,1])$)"

 

또한, 해당 코드에서는 아래 Regex도 삽입되어 있는데, 이는 7번째 자리의 숫자가 0 혹은 9로 되어 있는지를 확인하기 위한 부분이다.

r"^(?!0|9).*"

 

서론 부분을 참고하면 알 수 있듯이, 7번째 자리가 0 혹은 9인 경우는 1800년대에 태어난 사람들의 주민등록번호인 것을 알 수 있고, 필자가 처한 상황에서는 이러한 주민등록번호가 존재할 확률이 없으므로 포함하였다. 각자의 상황에 맞추어 위 validate 함수를 수정하여 사용하길 바란다.

 

또한, 필자는 Fine-tuning하기 위한 데이터가 PostgreSQL database에 저장되어 있고, 이 데이터 중에서 주민등록번호, 외국인등록번호가 포함된 데이터를 필터링해야하는 상황에 놓였다. Regex에 매칭된 문자열이 무엇인지까지 추출해야했기에, re.findall() 함수를 통해 일차적으로 [6자리-7자리]으로 되어 있는 문자열을 모두 추출한 후, 추출된 문자열에 대해서 validate 함수를 적용하였다.

 

[6자리-7자리]로 되어 있는 rows를 pandas dataframe에서 추출하기 위한 부분은 아래와 같다.

data["catched"] = data["content"] .str.findall(r"(\d{6}[-]\d{7})")

 

전체 코드는 다음과 같다.

 

import re
import pandas as pd
from typing import List
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# get database Session
SQLALCHEMY_POSTGRES_URL = ("postgresql://{username}:{password}@{host}/{dbname}")
engine = create_engine(SQLALCHEMY_POSTGRES_URL, echo=False)
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)

# define query to fetch data from database
GET_DATA_QUERY = """
SELECT
    *
FROM my_table
OFFSET {start_idx}
LIMIT {batch_size}
"""

# generator to fetch data from database by range iteratively
# here not to fetch all the data from database at once to alleviate the overload
# instead, retrieve only the number of rows (batch_size) at one time.
def db_fetcher(batch_size:int):
    
    DB = Session()
    n_rows = DB.execute("""SELECT COUNT(*) FROM my_table""").fetchall()[0][0]
    
    i = 0
    while i <= n_rows:
        response = DB.execute(GET_DATA_QUERY.format(batch_size=batch_size, start_idx=i))
        data = pd.DataFrame(data=response.fetchall(), columns=response.keys())
        
        if len(documents) == 0:
            break
        
        yield data
        
        i += batch_size
        

def validate(numbers: List[str]):
    valid = False
    
    if len(numbers) != 0:
        valid_numbers = []
        for number in numbers:
            
            # validation first step: check if valid six-digit birth date
            six_birth = number.split("-")[0]
            matched = re.search(r"^([0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[1,2][0-9]|3[0,1])$)", six_birth)
            if not matched:
                continue
            else:
                # validation second step: check if back number starts with 0 or 9
                back_num = number.split("-")[1]
                matched = re.search(r"^(?!0|9).*", back_num)
                if not matched:
                    continue
                else:
                    # validation third step: resident numbers or foreigner register numbers validation logic
                    _number = number.replace("-", "")
                    j, total = 2, 0
                    for i in range(0, len(_number)-1):
                        total += int(_number[i]) * j
                        j += 1
                        if j == 10:
                            j = 2
                    
                    total = 11 - (total%11)
                    if int(_number[-1]) == total:
                        valid = True
                        valid_numbers += [number]
    
    return valid_numbers if valid else ""


if __name__ == "__main__":
    result = []        
    data_loader = db_fetcher(batch_size=1000)
	
    for data in data_loader:
        data["catched"] = (
            data["content"]
            .str.findall(r"(\d{6}[-]\d{7})") # regex to catch register numbers
        )
        data["validity"] = data["catched"].apply(lambda x: validate(x))
        records = data[data["validity"] != ""][["data_id", "validity"]].to_dict("records")
        result.extend(records)
        
    result = pd.DataFrame(result).drop_duplicates(subset=["data_id"])
    
    extended_result = []
    for idx, row in result.iterrows():
        n_validities = len(row.validity)
        extended_result += [
            pd.DataFrame(
                {
                    "data_id": [row.data_id] * n_validities,
                    "validity": row.validity
                }
            )
        ]
    
    extended_result = pd.concat(extended_result).reset_index(drop=True)
    extended_result.to_csv("./detected_data_ids.csv", index=None)

 

긴 글 읽어주셔서 감사합니다! (꾸벅)

궁금한 사항이 있다면 댓글로 남겨주세요 :)