thenullpage.com
게시판 검색 기능을 만들면서 이런 일을 겪었어요. 분명 목록에 보이는 파일 이름인데 검색창에 똑같이 쳐 넣으면 하나도 안 나와요. 화면에 뜬 글자랑 제가 친 글자가 눈으로는 완전히 같은데도요. 알고 보니 그 둘은 컴퓨터 입장에서 다른 데이터였어요. 정규식 문법은 영어권에서 만들어져서, 한글이나 이모지를 다루면 예상 밖의 일이 종종 생겨요. 오늘은 그 지점들을 하나씩 짚어볼게요.
12.1 한글만 골라내려면 어떻게 하나요?
제일 많이 쓰는 건 [가-힣]이에요. 유니코드에서 완성된 한글 글자 11172개가 가부터 힣까지 번호 순서대로 붙어 있어서, 범위 하나로 전부 잡혀요.
import re
re.findall(r"[가-힣]+", "제 이름은 홍길동 입니다 abc 123")
결과: ['제', '이름은', '홍길동', '입니다']
영어와 숫자는 빠지고 한글 덩어리만 남았죠. 그런데 댓글이나 채팅을 다루다 보면 금방 구멍이 보여요.
re.findall(r"[가-힣]+", "안녕하세요 ㅋㅋㅋ ㅠㅠ 반가워요")
결과: ['안녕하세요', '반가워요']
ㅋ와 ㅠ가 통째로 사라졌어요. 이것들은 자음이나 모음 하나짜리라서 완성형 범위 밖에 따로 살거든요. 자음은 ㄱ-ㅎ, 모음은 ㅏ-ㅣ로 따로 챙겨야 해요.
re.findall(r"[가-힣ㄱ-ㅎㅏ-ㅣ]+", "안녕하세요 ㅋㅋㅋ ㅠㅠ 반가워요")
결과: ['안녕하세요', 'ㅋㅋㅋ', 'ㅠㅠ', '반가워요']
웃음 표현이나 초성체를 걸러야 하는 자리라면 이 세 범위를 세트로 기억해두세요.
12.2 그럼 \w는 한글도 잡나요?
파이썬 3에서는 잡아요. 놀라는 분들이 꽤 많은데 실제로 이래요.
re.findall(r"\w+", "홍길동 hong 123 안녕")
결과: ['홍길동', 'hong', '123', '안녕']
파이썬 3의 \w는 기본이 유니코드 모드라서 한글, 한자, 프랑스어 악센트 글자까지 다 포함해요. 영어와 숫자만 원하면 re.ASCII를 주면 돼요.
re.findall(r"\w+", "홍길동 hong 123 안녕", re.ASCII)
결과: ['hong', '123']
여기서 진짜 조심할 게 하나 있어요. 자바스크립트의 \w는 언제나 영어와 숫자, 밑줄만이에요. 그래서 같은 닉네임 검증 패턴을 서버(파이썬)와 브라우저(자바스크립트)에 똑같이 붙여 넣으면, 브라우저에서는 막히는데 서버에서는 통과하는 상황이 생겨요. 한글을 허용할 생각이라면 양쪽 다 [가-힣a-zA-Z0-9]처럼 직접 적어주는 게 안전해요.
12.3 숫자를 세는 \d도 조심할 게 있나요?
있어요. 이것도 유니코드 모드의 영향을 받아요.
re.findall(r"\d+", "123 456")
결과: ['123', '456']
앞의 것은 한글 입력 상태에서 나오는 전각 숫자예요. 폭이 넓어서 눈으로는 구분되지만 \d는 이것도 숫자로 쳐요. 아라비아 숫자만 받고 싶으면 범위를 직접 적으세요.
re.findall(r"[0-9]+", "123 456")
결과: ['456']
참고로 int("123")은 123으로 잘 변환돼요. 그래서 파이썬 안에서는 조용히 넘어가다가, 그 값을 데이터베이스나 다른 시스템에 넘기는 순간 터지는 경우가 있어요.
12.4 \p{L} 같은 문법은 파이썬에서 왜 안 되나요?
다른 언어 예제에서 \p{L}(모든 문자), \p{Han}(한자) 같은 걸 보고 파이썬에 그대로 썼다가 안 돼서 당황하신 적 있으실 거예요. 파이썬 표준 re는 이 유니코드 속성 문법을 지원하지 않아요. 쓰려면 regex라는 별도 라이브러리를 pip install regex로 설치해야 해요.
import regex
regex.findall(r"\p{Hangul}+", "안녕하세요 hello 123 漢字")
결과: ['안녕하세요']
regex.findall(r"\p{L}+", "안녕 hello 漢字 123")
결과: ['안녕', 'hello', '漢字']
자바스크립트에서는 u 플래그만 붙이면 표준으로 돼요. "안녕hello123".match(/\p{Script=Hangul}+/gu)는 ['안녕']을 돌려줘요.
[가-힣]과 뭐가 다르냐면, 범위를 직접 관리하지 않아도 된다는 점이에요. 나중에 일본어나 한자까지 받아야 하면 [가-힣]은 손으로 범위를 붙여야 하지만 \p{L}은 그대로 두면 돼요.
12.5 이모지는 왜 두 글자로 세나요?
자바스크립트에서 이모지 길이를 재보면 이래요.
"👍".length
결과: 2
[..."👍"].length
결과: 1
이모지는 흔한 글자보다 번호가 훨씬 커서, 자바스크립트가 내부적으로 두 조각으로 나눠 저장하거든요. 그래서 점 하나로 자르면 이렇게 깨져요.
"abc👍".match(/./g)
결과: ['a', 'b', 'c', '\ud83d', '\udc4d']
"abc👍".match(/./gu)
결과: ['a', 'b', 'c', '👍']
깨진 반쪽이 화면에 나가면 네모난 물음표가 돼요. 이모지가 섞일 수 있는 문자열에는 u 플래그를 습관처럼 붙이세요.
파이썬은 기본이 글자 단위라 len("👍")이 1이에요. 대신 다른 함정이 있어요.
re.findall(r".", "👨👩👧")
결과: ['👨', '', '👩', '', '👧']
가족 이모지 하나가 다섯 개로 쪼개졌죠. 사람 셋 사이에 보이지 않는 연결자가 끼어 있어서 그래요. 사람 눈에 보이는 대로 한 덩어리씩 자르려면 regex의 \X를 쓰면 돼요.
regex.findall(r"\X", "👨👩👧 가")
결과: ['👨👩👧', ' ', '가']
12.6 눈으로 같은 글자가 왜 검색이 안 되죠?
맨 처음 말씀드린 그 사건의 범인이에요. 한글 한을 저장하는 방법이 두 가지거든요. 완성된 글자 하나로 두는 방식과, ㅎ과 ㅏ와 ㄴ 세 조각으로 쪼개 두는 방식이에요. 화면에 그려지는 모양은 완전히 똑같아요.
import unicodedata
글자 = unicodedata.normalize("NFD", "한")
len(글자)
결과: 3
re.findall(r"[가-힣]+", 글자)
결과: []
화면에는 한이라고 멀쩡히 찍히는데 [가-힣]에 하나도 안 걸렸어요. 쪼개진 조각들은 완성형 범위에 없으니까요. 이게 실제로 어디서 오냐면 맥에서 만든 파일 이름이 대표적이에요. 맥은 파일 이름을 쪼개진 형태로 저장하거든요.
해결은 검사하기 전에 한 형태로 통일하는 거예요. 이걸 정규화라고 하고, 합쳐진 쪽이 NFC예요.
이름 = unicodedata.normalize("NFD", "사진")
re.search(r"사진", 이름)
결과: None
re.search(r"사진", unicodedata.normalize("NFC", 이름))
결과: <re.Match object; span=(0, 2), match='사진'>
같은 두 글자인데 정규화 한 줄에 결과가 갈렸죠. 그러니 사용자가 입력한 값, 업로드된 파일 이름, 외부에서 받아온 데이터는 정규식에 넣기 전에 unicodedata.normalize("NFC", 값)을 한 번 거치게 해두세요. 자바스크립트에도 값.normalize("NFC")가 똑같이 있어요. 검색이 안 된다는 신고를 받았는데 도무지 재현이 안 된다면 이걸 제일 먼저 의심해보세요.
한국어를 다루는 이상 오늘 내용은 언젠가 반드시 마주치게 돼요. 지금 쓰고 계신 코드에 \w나 \d가 들어간 검증 패턴이 있다면, 거기에 한글 한 글자와 전각 숫자를 한 번씩 넣어보세요. 통과되면 안 될 게 통과되고 있는지 몇 초면 확인돼요. 사용자 입력을 받는 자리에는 정규화 한 줄을 미리 깔아두시고요.