CS · Regex · Backend

쓸 자리와 안 쓸 자리 - 정규식이 답이 아닐 때

HTML을 정규식으로 파싱하면 왜 반드시 깨지는지, 이메일 검증 패턴이 왜 길수록 나빠지는지를 본다. 정규식이 필요해 보이는 자리 중 상당수는 위층 설계가 실패한 자리다.

·패턴 하나로 찾기 4편
목차
  1. 정규식은 중첩을 셀 수 없다
  2. 그래서 HTML은 정규식으로 못 읽는다
  3. 이메일을 정규식으로 검증하려 들면
  4. 좁게 막으면 진짜 사용자가 막힌다
  5. 이미 파서가 있는 것은 파서를 쓴다
  6. 문자열 함수로 되는 일에 쓰지 않는다
  7. 그럼 언제 쓰나
  8. 실무에서: 무엇을 고를지
  9. 정리

전단으로 못 찾는 사람이 있다. 그때는 그 동네를 아는 사람에게 묻는 게 빠르다.

정규식은 중첩을 셀 수 없다

먼저 원리부터 못 박고 가자. 정규식이 HTML을 못 다루는 이유는 문법이 모자라서가 아니라 셈을 못 해서다.

정규식이 짝지은 것  <div>.*?</div>

짝 없음
<div> <div> A </div> </div>
? ? ? ? ?

몇 개가 열려 있나 - 들고 있지 못한다

여는 것을 세는 파서  열면 +1, 닫으면 -1

<div> <div> A </div> </div>
1 2 2 1 0

몇 개가 열려 있나 - 0으로 돌아온 자리가 바깥 짝이다

갈림길은 하나다. 몇 개가 열려 있는지를 셀 수 있느냐. 못 세면 가장 먼저 만난 닫는 것과 짝지어 버리고 하나가 남는다.

정규식은 지금까지 몇 개가 열려 있는지를 들고 있지 못한다. 그래서 <div><div>A</div></div>에 <div>.*?</div>를 대면 가장 먼저 만난 닫는 태그와 짝지어 버리고 하나가 남는다.

파서는 그 수를 들고 다닌다. 열 때 하나 올리고 닫을 때 하나 내린다. 그러려면 쌓았다가 꺼내는 그릇이 필요한데, 정규식에는 그런 자리가 없다. 정규식이 들고 있을 수 있는 것은 미리 정해진 개수의 상태뿐이고, “지금 몇 겹인가”는 끝이 없는 수라 거기 안 들어간다.

깊이를 3까지만 받겠다고 정하면 정규식으로도 쓸 수 있다. 다만 그건 셈을 한 게 아니라 경우를 손으로 다 적은 것이고, 4겹이 들어오는 날 조용히 틀린다.

참고

일부 엔진에는 자기 자신을 부르는 문법이 있다(PCRE의 (?R), .NET의 균형 그룹). 그걸 쓰면 괄호 짝을 맞추는 일이 되긴 한다. 다만 그건 정규식이라는 도구를 넘어선 확장이고, 결과물은 두 달 뒤의 내가 못 읽는다. 읽히지 않는 한 줄로 파서를 흉내 내는 것보다 파서를 부르는 게 언제나 싸다.

그래서 HTML은 정규식으로 못 읽는다

중첩은 시작일 뿐이다. HTML을 정규식으로 훑으려 들면 아래가 전부 지뢰가 된다.

  • 속성 값 안의 꺾쇠. <a title="a>b">에서 태그가 어디서 끝나는지를 정규식은 못 가른다
  • 주석과 스크립트 안의 태그. <!-- <div> -->는 태그가 아닌데 생김새는 똑같다
  • 닫지 않는 태그. <br>, <img>는 짝이 아예 없다
  • 관대한 브라우저. 문법이 깨진 마크업을 브라우저가 알아서 고쳐 읽는다. 그 고치는 규칙까지가 HTML이고, 그건 정규식으로 옮길 수 있는 종류의 규칙이 아니다

여기서 실제로 사고가 나는 자리가 하나 있다. 정규식으로 위험한 태그를 걸러 XSS를 막으려는 시도다. <script를 지우면 <SCRIPT , <scr<script>ipt, 이벤트 속성, javascript: URL이 남는다. 목록을 아무리 늘려도 빠뜨린 것이 남는다는 게 이 접근의 성질이다. 그래서 막는 자리는 입력을 걸러내는 쪽이 아니라 꽂아 넣는 쪽이다.

JSON도 같은 종류다. 중첩이 있고, 문자열 안에 이스케이프된 따옴표가 들어간다. 파서로 읽어야 한다.

이메일을 정규식으로 검증하려 들면

“올바른 이메일”을 정규식으로 적으려는 시도는 누구나 한 번 한다. 그 끝이 정해져 있다.

규격(RFC 5322)을 그대로 옮긴 정규식이 인터넷에 돌아다니는데, 한 줄이 수천 자다. 아무도 못 읽고 아무도 못 고친다. 그리고 그 괴물을 통과해도 얻은 것은 하나뿐이다 - 꼴이 맞다. 그 주소가 실제로 존재하는지, 메일이 도착하는지는 여전히 모른다.

거꾸로 짧은 패턴은 멀쩡한 주소를 떨어뜨린다. +가 붙은 별칭 주소, 한글 도메인, 새로 생긴 최상위 도메인이 그렇게 막힌다.

그래서 실무의 답은 정규식을 잘 쓰는 쪽이 아니라 역할을 나누는 쪽이다.

  • 정규식은 오타만 잡는다. @가 하나 있고 앞뒤가 비어 있지 않은지 정도. 여기에 길이 제한을 더한다
  • 진짜 검증은 확인 메일이 한다. 그 주소로 메일이 도착해야 존재하는 주소다

브라우저의 type="email"이 하는 검사도 딱 이 정도 수준이다. 더 하지 않는 게 실수가 아니라 더 할 수 없다는 걸 아는 것이다.

좁게 막으면 진짜 사용자가 막힌다

이메일만의 이야기가 아니다. 형식 검증에는 방향이 있다.

^[a-zA-Z]+$로 이름을 받으면 하이픈이 든 성, 아포스트로피가 든 성, 한글, 결합 문자로 된 이름이 전부 거부된다. 전화번호는 나라마다 자릿수가 다르고, 우편번호는 실제로 자릿수가 바뀐 적이 있다.

여기서 방향을 정해 주는 문장 하나가 있다. 형식 검증의 목적은 오타를 알려 주는 것이지 보안이 아니다.

  • 너무 좁게 막으면 진짜 사용자를 잃는다. 그 사람은 다시 오지 않는다
  • 좀 느슨해도 뒤에서 대개 걸린다. 없는 주소면 메일이 반송되고, 없는 번호면 문자가 안 간다
  • 보안은 형식 검사로 얻는 게 아니다. 쓰는 자리마다 알맞게 처리하는 것이 보안이다

그래서 검증 정규식은 거부가 확실한 것만 거부하게 짠다. 애매하면 통과시키고 뒤에서 확인한다.

이미 파서가 있는 것은 파서를 쓴다

문법이 정의된 형식에는 예외 없이 파서가 있다. 그리고 그 파서는 내가 모르는 예외를 이미 알고 있다.

  • 날짜와 시각. 시각 문자열을 정규식으로 자르면 안 되는 이유는 규격이 넓기 때문이다. 소수 초 자릿수가 제각각이고 구분 기호 없는 축약형도 정당한 표기다. 자릿수를 못 박은 정규식은 그 자리에서 깨진다
  • URL. 퍼센트 인코딩, 포트, 대괄호로 감싼 IPv6 주소, 사용자 정보 부분. 언어가 주는 URL 파서를 쓴다
  • CSV. 따옴표 안에 쉼표가 들어가고 줄바꿈까지 들어간다. 한 줄씩 잘라 쉼표로 나누는 코드는 언젠가 반드시 틀린다
  • JSON·XML·YAML. 중첩이 있으니 앞에서 본 이유로 끝이다

문자열 함수로 되는 일에 쓰지 않는다

반대쪽 실수도 흔하다. 정규식을 배우고 나면 안 써도 되는 자리에 쓰게 된다.

plaintext
url.matches("^https://.*")     →   url.startsWith("https://")
text.matches(".*error.*")      →   text.contains("error")
str.replaceAll("\\.", "_")     →   str.replace(".", "_")

정규식이 느려서만은 아니다. 읽는 사람이 한 번 더 해석해야 한다는 게 더 큰 비용이다. startsWith는 이름이 곧 설명이고, ^https://.*는 눈으로 파싱해야 한다.

마지막 줄은 성격이 좀 다르다. 자바의 replaceAll은 이름과 달리 정규식을 받는다. 점 하나를 밑줄로 바꾸려고 replaceAll(".", "_")이라고 적으면 .이 “아무 글자”로 해석되어 문자열 전체가 밑줄이 된다. 정규식을 쓰려던 게 아닌데 정규식이 끼어드는 자리가 실제로 있다는 뜻이다.

여기서 더 깊은 신호가 하나 나온다. 로그에서 값을 뽑느라 정규식을 짜고 있다면, 그건 로그 형식이 잘못됐다는 신호다. 필드로 남기는 구조화 로깅으로 바꾸면 정규식이 통째로 사라진다. 이 패턴은 로그 밖에서도 되풀이된다 - 정규식으로 힘들게 캐내고 있는 값 중 상당수는 위층에서 구조를 지운 탓에 다시 파내는 중이다.

그럼 언제 쓰나

여기까지 읽으면 정규식을 아예 쓰지 말라는 말처럼 들릴 수 있는데, 그렇지 않다. 잘 맞는 자리가 분명히 있다.

  • 형식이 정해져 있지 않은 텍스트에서 무언가를 건질 때. 남이 만든 로그, 붙여 넣은 덤프, 일회성 조사. 파서를 만들 수 없는 대상이라 정규식이 유일한 손이다
  • 짧은 토큰 하나의 꼴을 볼 때. 아이디 허용 문자, 우편번호 자릿수, 인증번호 여섯 자리
  • 에디터나 스크립트의 일괄 치환. 한 번 쓰고 버리는 일이고, 결과를 눈으로 확인할 수 있다
  • 프레임워크가 이미 정규식으로 만들어 둔 자리. 라우팅 규칙, 리버스 프록시 설정 같은 것

공통점이 보인다. 대상이 짧고, 구조가 평평하고, 틀려도 되돌릴 수 있다. 셋 중 하나라도 어긋나면 다른 도구를 찾을 때다.

여기서 전단 비유의 마지막 한계도 드러난다. 전단의 값은 아무 데나 붙일 수 있다는 것이다. 동네 지리를 몰라도 되고 준비도 필요 없다. 반대로 그 동네를 아는 사람을 데려오는 일(파서)은 준비가 든다. 그러니 지도가 필요한 일에 전단을 붙이는 것도, 사람 하나 찾는 일에 지도를 그리는 것도 둘 다 낭비다.

실무에서: 무엇을 고를지

판단이 필요한 자리를 한 표로 모아 둔다.

하려는 일정규식무엇을 쓰나
HTML·XML에서 값 뽑기✗DOM 파서·셀렉터
JSON·YAML 읽기✗그 형식의 파서
CSV 읽기✗CSV 라이브러리
날짜·시각 문자열 읽기✗언어의 날짜 파서
URL 분해✗URL 파서
이메일이 존재하는지✗확인 메일
XSS 막기✗꽂는 자리에 맞는 이스케이프
이메일 오타 잡기△아주 느슨한 패턴 + 길이 제한
아이디·우편번호 꼴 검사○앵커를 박은 짧은 패턴
접두사·포함 여부 확인✗startsWith·contains
비정형 로그에서 값 건지기○정규식. 다만 로그 형식부터 의심한다
일회성 일괄 치환○정규식

쓰기로 정했다면 세 가지를 함께 챙긴다.

  • 검증이면 ^와 $를 박는다. 앵커 없는 검증 패턴은 대개 버그다
  • 정규식에 넣기 전에 길이를 자른다. 가장 싸게 최악을 막는 방법이다
  • 테스트에 실패하는 입력을 넣는다. 통과하는 값만 넣은 테스트는 느린 패턴을 절대 못 잡는다

정리

  • 정규식은 몇 겹이 열려 있는지를 셀 수 없다. 중첩이 있는 형식은 원리상 다룰 수 없다는 뜻이다.
  • HTML은 중첩에 더해 속성 안의 꺾쇠·주석·관대한 브라우저까지 있어서 반드시 깨진다. XSS도 걸러내기로는 못 막는다.
  • 이메일은 정규식을 길게 쓸수록 나빠진다. 오타는 느슨한 패턴이 잡고, 존재 여부는 확인 메일이 잡는다.
  • 형식 검증의 목적은 오타 알림이지 보안이 아니다. 좁게 막으면 진짜 사용자가 막힌다.
  • 문법이 정의된 형식(날짜·URL·CSV·JSON)에는 파서가 있고, 그 파서는 내가 모르는 예외를 알고 있다.
  • startsWith 하나면 되는 자리에 쓰지 않는다. 정규식으로 값을 캐내고 있다면 위층에서 구조를 지운 것은 아닌지 먼저 본다.
  • 잘 맞는 자리는 분명하다. 짧고, 평평하고, 틀려도 되돌릴 수 있는 일이다.