목차
수배 전단에는 이름이 아니라 인상착의가 적힌다. 누구인지 몰라도 찾을 수 있게 하려는 것이고, 정규식이 하는 일이 정확히 그것이다.
글자 그대로 찾으면 되는 줄 알았다
로그에 남은 전화번호를 찾아 가려야 한다고 하자. 문자열 함수부터 꺼내게 된다.
line.contains("010-1234-5678")이건 그 번호 하나만 찾는다. 우리는 어떤 번호가 들어 있는지 모른다. 그래서 다음으로 손이 가는 게 이것이다.
line.startsWith("010")이제 02-555-0100을 놓친다. 그리고 0101호 창고도 걸린다. 조건을 덧붙이기 시작한다. 길이를 재고, -가 몇 개인지 세고, 잘라서 숫자인지 확인한다. 그렇게 스무 줄쯤 쌓이면 이 코드는 아무도 못 고치는 덩어리가 된다.
무엇이 문제였나. 우리가 찾던 것은 정해진 글자가 아니라 정해진 꼴이었다. 그런데 문자열 함수는 정해진 글자를 찾는 도구다. 도구가 문제를 안 닮았다.
이름 대신 인상착의를 적는다
이름을 알면 이름을 적으면 된다. contains가 그것이다. 모를 때는 어떻게 하나. 수배 전단은 그 자리에서 이렇게 적는다.
키 180 안팎, 검은 모자, 왼쪽 뺨에 흉터.
이 전단은 한 사람을 가리키지 않는다. 조건에 맞는 사람 전부를 가리킨다. 전단을 든 사람은 얼굴을 몰라도 골목에서 사람을 붙잡을 수 있다.
정규식은 글자로 적은 인상착의다. 찾을 것을 그대로 적는 대신 “이런 꼴인 것”이라고 적는다. 그러면 그 꼴인 것 전부가 걸린다. 시리즈 내내 이 전단 하나로 이야기를 끌고 가겠다.
인상착의는 두 가지로 적는다 - 무엇이, 몇 개
인상착의를 적으려면 두 가지가 필요하다. 무엇이 있는지와 그게 몇 개인지다. 정규식도 딱 그 둘로 되어 있다.
무엇이 있는지는 글자의 갈래로 적는다. [0-9]는 숫자 한 자, [가-힣]은 한글 한 자, [abc]는 셋 중 하나, .은 아무 글자 한 자다. 자주 쓰는 것에는 줄임말이 있어서 [0-9] 대신 \d라고 쓴다.
몇 개인지는 뒤에 붙인다. +는 한 번 이상, *는 없어도 되고 여러 번도 되고, ?는 있거나 없거나, {2,3}은 두 개나 세 개다.
이 둘을 붙이면 인상착의가 된다. \d{2,3}은 “숫자가 두세 자”. 그러면 전화번호 꼴은 이렇게 적힌다.
\d{2,3}-\d{3,4}-\d{4}숫자 두세 자, 하이픈, 숫자 서너 자, 하이픈, 숫자 네 자. 앞의 스무 줄이 한 줄이 됐다. 그리고 정규식 문법의 뼈대는 사실상 여기까지다. 나머지는 이 둘의 변형이거나 자주 쓰는 것에 붙인 줄임말이다.
하나가 아니라 한 무리를 가리킨다
여기서 성질 하나가 갈린다. 글자를 적으면 그 글자 하나가 걸리지만, 규칙을 적으면 그 규칙에 맞는 것 전부가 걸린다.
contains("010-1234-5678")
1줄적어 둔 그 글자 하나
\d{2,3}-\d{3,4}-\d{4}
3줄그 꼴인 것 전부
같은 목록에 같은 일을 시켰다. 글자를 적으면 그 글자 하나가, 규칙을 적으면 그 규칙에 맞는 것 전부가 걸린다.
같은 목록에 같은 일을 시켰는데 한쪽은 한 줄, 다른 쪽은 세 줄이 걸렸다. 이게 정규식을 쓰는 이유 전부이자, 앞으로 볼 골칫거리 전부의 출발점이기도 하다. 한 무리를 가리킨다는 건 그 무리에 무엇이 들어 있는지 내가 다 안다는 뜻이 아니다.
인상착의를 넓게 적을수록 걸리는 사람이 늘어난다. 그중에는 찾던 사람이 아닌 이도 섞인다.
전단을 어디에 붙일지도 정해야 한다
전단을 골목 어귀에 붙이느냐 온 동네에 붙이느냐는 다른 문제다. 정규식에도 같은 구분이 있다.
기본 동작은 문자열 어딘가에 그 꼴이 있으면 걸린다는 것이다. 그래서 \d{3}으로 검사한 입력 칸에 abc123def가 통과한다. 123이 안에 들어 있으니까.
이걸 못 박는 것이 앵커다. ^는 문자열의 시작, $는 끝을 뜻한다. ^\d{3}$이라고 적으면 “처음부터 끝까지 숫자 세 자”가 되어 abc123def는 떨어진다.
규칙 하나로 정리된다. 찾을 때는 부분 일치가 맞고, 검증할 때는 전체 일치가 맞다. 검증하러 온 정규식에 앵커가 없으면 대개 버그다.
부분 일치가 기본인지 전체 일치가 기본인지는 언어마다 다르다. 자바의 String.matches()는 문자열 전체가 맞아야 참을 주고, 자바스크립트의 RegExp.test()는 어딘가에 있기만 하면 참을 준다. 같은 패턴을 옮겨 붙였는데 동작이 달라지는 자리가 여기다. 앵커를 명시해 두면 어느 쪽에서도 뜻이 하나로 읽힌다.
잡은 것을 도로 꺼낸다
정규식은 찾기만 하는 도구가 아니다. 찾은 자리에서 조각을 뽑아 오는 일까지 한다. 그 장치가 괄호다.
(\d{2,3})-(\d{3,4})-(\d{4})괄호로 묶어 두면 걸린 뒤에 각 조각을 따로 꺼낼 수 있다. 첫째 묶음은 지역번호, 둘째는 국번, 셋째는 뒷자리다. 치환할 때는 $1, $2처럼 부른다. 전화번호를 010-****-5678로 가리는 일이 한 줄이 되는 게 이 덕분이다.
괄호에는 역할이 하나 더 있다. 묶어서 한 덩어리로 만드는 것이다. ab+는 b만 반복되지만 (ab)+는 ab 전체가 반복된다. 이 “묶어서 반복”이 다음 글부터 이야기의 중심이 된다. 편해 보이는 이 괄호 하나가 서버를 세우는 일이 실제로 벌어진다.
정규식은 뜻을 모른다
여기서 전단 비유가 깨진다. 밝혀 두고 가야 한다.
전단을 든 순경은 판단을 한다. 인상착의가 좀 어긋나도 “수상하다” 싶으면 붙잡고, 딱 맞아도 아니다 싶으면 보낸다. 정규식은 그런 걸 전혀 못 한다. 글자의 생김새만 본다.
그래서 \d{2,3}-\d{3,4}-\d{4}는 999-9999-9999도 통과시킨다. 세상에 없는 번호지만 꼴은 맞다. 형식과 사실은 다른 것이고, 정규식이 답할 수 있는 건 형식뿐이다.
한 가지 더 있다. “글자 하나”가 무엇인지조차 정규식이 정하지 않는다. .이 한 자를 뜻한다고 했지만 이모지 하나가 . 두 개로 세지는 일이 흔하다. 사람이 세는 글자와 컴퓨터가 세는 단위가 층마다 다르기 때문이고, 정규식은 그 아래층의 규칙을 그대로 물려받는다.
실무에서: 이미 곳곳에 들어 있다
정규식은 배워서 쓰는 도구라기보다 이미 들어가 있는데 모르고 지나치는 도구에 가깝다.
- 로그를 뒤질 때.
grep에 넘기는 것이 정규식이다. 문자열로만 남긴 로그에서 무언가를 찾아내야 할 때 유일한 손이 된다. - 라우팅. 웹 프레임워크가
/users/{id}같은 경로를 실제로 맞춰 볼 때 그 밑에서 도는 것이 정규식이다. - 입력 검증. 아이디 규칙, 우편번호, 사업자번호 같은 형식 검사가 전부 여기다.
- 에디터의 일괄 치환. 찾기 창의
.*체크박스가 그것이다. 코드 수백 곳을 한 번에 고치는 일이 여기서 난다.
읽는 쪽도 챙겨야 한다. 돌아가는 정규식과 읽히는 정규식은 다르다. 한 줄이 길어지면 두 달 뒤의 내가 못 읽는다. 대부분의 언어가 괄호에 이름을 붙이는 문법((?<areaCode>\d{2,3}))과 패턴 안에 주석·줄바꿈을 허용하는 옵션을 갖고 있다. 길어질 것 같으면 처음부터 그쪽으로 쓴다.
마지막으로 값싼 습관 하나. 같은 패턴을 반복해서 쓸 거면 미리 컴파일해 재사용한다. 정규식은 쓸 때마다 문자열을 해석해 내부 구조로 바꾸는데, 루프 안에서 매번 새로 만들면 그 비용을 계속 낸다. 자바라면 Pattern.compile()의 결과를 상수로 들고 있는 식이다.
정리
- 문자열 함수는 정해진 글자를 찾는다. 우리가 자주 찾는 것은 정해진 꼴이라 도구가 문제를 안 닮았다.
- 정규식은 이름 대신 적는 인상착의다. 한 대상이 아니라 그 꼴인 것 한 무리를 가리킨다.
- 인상착의는 둘로 적는다. 무엇이 있는지(문자 갈래)와 몇 개인지(반복). 뼈대는 사실상 여기까지다.
- 기본은 부분 일치다. 검증에 쓸 거면
^와$로 못 박는다. 앵커 없는 검증 패턴은 대개 버그다. - 괄호는 조각을 뽑아 오고, 여러 글자를 묶어 반복시킨다. 이 묶기가 다음 이야기의 재료다.
- 정규식은 뜻을 모른다. 꼴이 맞는지만 답한다. 꼴이 맞는 것과 실제로 존재하는 것은 다르다.
다음 글은 전단을 든 쪽으로 넘어간다. 엔진은 이 인상착의를 들고 골목을 어떻게 뒤지는가. 거기서 탐욕과 게으름이 왜 다른 답을 내는지가 풀린다.