thenullpage.com

아이디가 영문 소문자와 숫자로만 되어 있는지 검사했는데 느낌표가 섞인 값이 멀쩡히 통과해버린 적 있으신가요. 문서에서 cat이라는 단어를 찾았더니 category랑 concatenate까지 줄줄이 딸려 나오는 것도 흔한 일이에요. 패턴은 멀쩡한데 결과가 이상한 이런 사고는 대부분 위치를 지정하지 않아서 생겨요. 오늘은 글자를 잡는 대신 자리를 잡는 기호인 앵커단어 경계를 정리해볼게요.


05.1 앵커가 글자를 안 잡는다는 게 무슨 말인가요?

지금까지 봐온 기호들은 전부 글자를 잡아먹었어요. \d는 숫자 한 글자를, [a-z]는 소문자 한 글자를 가져가죠. 그런데 앵커(anchor)는 글자를 하나도 안 가져가요. 대신 여기가 어느 자리인지만 확인하고 넘어가요. 이런 성질을 폭이 0이다라고 표현해요.


가장 기본이 되는 앵커는 두 개예요. ^는 문자열의 시작 자리, $는 문자열의 끝 자리를 뜻해요. 눈으로 확인해볼게요.


import re
re.findall(r"\d+", "12 사이 34")
결과: ['12', '34']
re.findall(r"^\d+", "12 사이 34")
결과: ['12']
re.findall(r"\d+$", "12 사이 34")
결과: ['34']


같은 \d+인데 앞에 ^를 붙이니 맨 앞에 붙어있는 숫자만, 뒤에 $를 붙이니 맨 끝에 있는 숫자만 나왔어요. 결과에 ^$ 자체는 안 들어있죠. 자리만 확인하고 아무것도 안 가져갔기 때문이에요.


헷갈리기 쉬운 게 하나 있어요. ^는 대괄호 안에 들어가면 뜻이 달라져요. [^abc]^는 시작 자리가 아니라 이것들을 뺀 나머지라는 부정의 뜻이에요. 대괄호 밖이면 앵커, 대괄호 안 맨 앞이면 부정이라고 구분해두시면 돼요.


05.2 아이디 검사가 왜 이상한 값을 통과시키나요?

앵커를 제일 자주 쓰는 곳이 형식 검사예요. 아까 말한 아이디 검사 사고를 그대로 재현해볼게요.


패턴 = r"[a-z0-9]+"
bool(re.search(패턴, "hello!!!"))
결과: True


느낌표가 세 개나 붙어있는데 통과했어요. 이유는 search문자열 어딘가에 그 패턴이 있기만 하면 성공으로 치기 때문이에요. hello 부분이 조건에 맞으니까 나머지는 쳐다***도 않은 거죠.


여기에 앵커를 양쪽으로 채우면 이야기가 달라져요.


패턴 = r"^[a-z0-9]+$"
bool(re.search(패턴, "hello!!!"))
결과: False
bool(re.search(패턴, "hello99"))
결과: True


이제 시작부터 끝까지 전부 소문자와 숫자여야만 통과해요. 형식 검사를 하는 정규식은 거의 항상 ^와 $를 양쪽에 붙여야 한다고 외워두셔도 될 정도예요. 이걸 안 붙여서 뚫리는 검증 코드가 정말 많거든요.


05.3 단어 하나만 정확히 찾으려면 어떻게 하나요?

문자열 양 끝이 아니라 단어의 양 끝을 잡고 싶을 때가 있어요. 이때 쓰는 앵커가 단어 경계\b예요. \b는 한쪽은 단어 문자이고 반대쪽은 단어 문자가 아닌 자리를 가리켜요. 여기서 단어 문자란 \w가 잡는 것들, 그러니까 영문자와 숫자와 밑줄이에요.


문장 = "cat category concatenate"
re.findall(r"cat", 문장)
결과: ['cat', 'cat', 'cat']
re.findall(r"\bcat\b", 문장)
결과: ['cat']


앞뒤로 \b를 감싸주니 단어로 쓰인 cat 하나만 남았어요. category는 cat 뒤에 e가 붙어 그 자리가 경계가 아니고, concatenate는 앞뒤가 전부 글자라 양쪽 다 경계가 아니에요. 치환할 때 이게 없으면 사고가 나요. 변수 이름 iduser_id로 바꾸겠다고 \b 없이 치환하면 videovuser_ideo로 망가져요.


한글에서도 \b가 작동해요. 파이썬3에서는 한글도 \w에 포함되거든요. 다만 조사가 붙으면 경계가 안 생긴다는 점은 알아두세요.


re.findall(r"\b고양이\b", "고양이 고양이가 귀엽다")
결과: ['고양이']


고양이가는 뒤에 가가 붙어 경계가 성립하지 않아 빠졌어요. 한국어 검색에서는 \b가 만능이 아니라는 뜻이에요.


참고로 \b의 반대인 \B도 있어요. 경계가 아닌 자리라서 단어 안쪽에 파묻힌 것만 골라내요.


re.findall(r"\Bcat\B", "cat category concatenate")
결과: ['cat']


이번엔 concatenate 안쪽에 파묻힌 cat만 남았어요.


05.4 여러 줄짜리 텍스트에서는 ^와 $가 어떻게 되나요?

여기서 초보분들이 제일 많이 당황해요. ^$는 기본적으로 문자열 전체의 시작과 끝이지 줄의 시작과 끝이 아니거든요. 줄이 여러 개여도 마찬가지예요.


로그 = "INFO 시작\nERROR 실패\nINFO 종료"
re.findall(r"^\w+", 로그)
결과: ['INFO']


줄이 세 개인데 첫 줄 것만 나왔죠. 각 줄마다 앞부분을 잡고 싶다면 MULTILINE 모드를 켜야 해요. re.M이라고 짧게 써도 되고, 패턴 맨 앞에 (?m)을 적어도 같은 뜻이에요.


re.findall(r"^\w+", 로그, re.M)
결과: ['INFO', 'ERROR', 'INFO']


이제 ^가 줄바꿈 바로 다음 자리에도 붙어서 세 줄을 전부 잡았어요. $도 똑같이 각 줄의 끝을 뜻하게 바뀌고요. 로그 파일에서 특정 단어로 시작하는 줄만 추리는 작업은 이 모드 없이는 안 되니까 꼭 챙겨두세요.


05.5 $에 숨어있는 함정이 있다던데요?

있어요. 파이썬에서 $는 문자열의 진짜 끝뿐 아니라 맨 끝에 붙은 줄바꿈 바로 앞에서도 매칭돼요. 파일을 읽으면 끝에 줄바꿈이 딸려오는 경우가 많은데, 그걸 눈치 못 채고 검사를 통과시켜버리는 거죠.


bool(re.search(r"^\d+$", "12345"))
결과: True
bool(re.search(r"^\d+$", "12345\n"))
결과: True


숫자만 있어야 하는데 줄바꿈이 붙은 값도 통과했어요. 이걸 막으려면 \Z를 쓰면 돼요. \Z는 예외 없이 문자열의 절대적인 끝만 뜻해요. 시작 쪽에도 같은 짝인 \A가 있는데, 이건 MULTILINE 모드를 켜도 절대 흔들리지 않고 문자열 맨 앞만 가리켜요.


bool(re.search(r"\A\d+\Z", "12345\n"))
결과: False


깔끔하게 걸러냈어요. 다만 이 기호는 환경마다 이름이 조금씩 달라요. 자바스크립트에는 아예 없고, 자바나 PCRE 계열에서는 \z가 절대적인 끝을 뜻해요.


05.6 그래서 전체 검사에는 뭘 쓰는 게 제일 좋나요?

파이썬에는 앵커를 안 붙이고도 전체 일치를 검사하는 방법이 따로 있어요. re.fullmatch예요. 이름 그대로 문자열 전체가 패턴과 맞아떨어져야만 성공해요.


bool(re.fullmatch(r"[a-z0-9]+", "hello99"))
결과: True
bool(re.fullmatch(r"[a-z0-9]+", "hello!!!"))
결과: False
bool(re.fullmatch(r"[a-z0-9]+", "hello99\n"))
결과: False


앵커를 하나도 안 썼는데 끝 줄바꿈까지 알아서 걸러줬어요. 그래서 전체 형식 검사는 fullmatch, 긴 텍스트 안에서 뭔가를 찾는 건 searchfindall, 이렇게 역할을 나눠 쓰면 실수가 확 줄어요. 다른 언어에는 fullmatch가 없는 경우가 많은데, 그럴 땐 ^$를 양쪽에 붙이면 돼요.


정리하면 앵커는 글자가 아니라 자리를 지정하는 기호예요. 문자열 양 끝은 ^$, 줄 단위는 MULTILINE, 흔들림 없는 절대 끝은 \A\Z, 단어 단위는 \b\B. regex101에 오늘 패턴을 붙여넣고 MULTILINE 스위치를 껐다 켜보면 매칭 범위가 달라지는 게 눈에 보이니까 한번 만져보세요.