정규식 매칭 개수는 뜨는데 하이라이트가 안 보이는 이유
정규식 테스터에 패턴을 넣고 실행했더니 "3개 매칭"이라고 숫자는 뜨는데, 정작 결과창을 아무리 들여다봐도 색칠된 부분이 하나도 없는 경우가 있습니다. 버그처럼 보이지만 사실은 정규식 엔진과 하이라이트 렌더링 방식이 만나면 필연적으로 생기는 현상입니다. 이 가이드는 그 원인을 코드 레벨에서 짚어봅니다.
1. 증상: 숫자와 화면이 따로 논다
예를 들어 정규식 치트시트의 실시간 테스터에 패턴 a*를 넣고 텍스트에 "bcd"만 입력하면, 매칭 개수는 여러 개로 표시되는데 텍스트 자체는 하나도 색칠되지 않은 채 그대로 보입니다. 사용자 입장에서는 "매칭이 없는데 왜 숫자가 나오지?"라고 오해하기 쉽지만, 실제로는 매칭이 존재하되 화면에 표시할 수 없는 형태로 존재하는 것입니다.
2. 폭 0(zero-width) 매칭이란 무엇인가
정규식 매칭은 보통 문자열의 일부 구간을 "소비(consume)"합니다. abc라는 패턴이 "abc"라는 텍스트에 매칭되면 3글자를 소비한 것이죠. 그런데 a*처럼 "0회 이상"을 허용하는 수량자나, 애초에 문자를 소비하지 않도록 설계된 \b(단어 경계), ^·$(앵커), (?=...)(lookahead), (?<=...)(lookbehind) 같은 어서션(assertion)은 매칭에 성공해도 시작 위치와 끝 위치가 같은, 즉 길이가 0인 매칭을 만들어냅니다. 엔진 입장에서는 이것도 엄연히 "매칭 성공"이라 카운트에 들어가지만, 실제로 하이라이트로 감쌀 문자가 하나도 없는 것이 문제의 핵심입니다.
3. 하이라이트 렌더링은 왜 이걸 못 그리나
대부분의 정규식 테스터는 매칭 결과를 <span class="match-hl">매칭된 문자열</span> 형태로 감싸서 그립니다. 매칭된 문자열이 빈 문자열("")이면 결과적으로 <span class="match-hl"></span>, 즉 내용이 없는 빈 태그가 만들어집니다. 빈 span은 배경색을 칠할 대상 자체가 없으므로 화면에는 아무 변화도 나타나지 않습니다 — 렌더링이 실패한 게 아니라, 애초에 칠할 픽셀이 존재하지 않는 것입니다. 반면 매칭 개수를 세는 로직은 matchAll()이 반환한 배열의 길이를 그대로 세기 때문에, 길이가 0인 매칭도 똑같이 하나로 카운트됩니다. 숫자 집계 로직과 시각화 로직이 서로 다른 기준(존재 여부 vs 픽셀 여부)으로 동작하기 때문에 둘 사이에 괴리가 생기는 것입니다.
a*(g 플래그)를 적용하면, 정규식 엔진은 각 문자 위치(0,1,2)와 문자열 끝(3) 총 4곳에서 "a가 0개 있음"을 매칭으로 인정해 총 4개의 빈 매칭을 반환합니다. 매칭 개수 표시는 "4개 매칭"이 뜨지만, 4개 모두 길이 0이라 하이라이트는 하나도 보이지 않습니다.
| 패턴 | 대상 텍스트 | 매칭 개수 | 화면 하이라이트 |
|---|---|---|---|
a* | bcd | 4개 | 없음 (전부 길이 0) |
\bcat\b | cat category | 1개 | "cat" 3글자 표시됨 |
(?=cat) | cat category | 2개 | 없음 (lookahead는 소비 안 함) |
4. 단어 경계·lookahead가 특히 헷갈리는 이유
\b나 (?=...) 같은 어서션은 "이 위치의 앞뒤 문맥이 조건을 만족하는가"만 검사할 뿐 어떤 문자도 결과에 포함시키지 않도록 명세 자체가 그렇게 설계되어 있습니다. 그래서 (?=cat) 하나만 단독으로 쓰면 "cat이라는 글자 앞의 모든 위치"를 찾아내지만, 매칭 결과 문자열은 항상 빈 문자열입니다. 초보자가 자주 겪는 혼란은 "lookahead로 뒤에 오는 문자열까지 하이라이트되겠지"라고 기대하는 것인데, 실제로는 lookahead 자체는 아무것도 소비하지 않으므로 뒤에 cat처럼 실제로 소비하는 패턴을 이어 붙이지 않는 한 화면에는 아무것도 나타나지 않습니다.
5. 그래서 어떻게 확인하나
"매칭 개수는 나오는데 하이라이트가 없다"는 현상을 마주쳤다면, 버그를 의심하기 전에 패턴에 폭 0 요소(수량자의 0회 허용, 앵커, lookahead/lookbehind)가 들어있는지부터 확인하는 게 순서입니다. 정규식 치트시트의 실시간 테스터에서 g·i·m·s·u·y 플래그를 하나씩 켜고 끄며 패턴을 실제로 넣어보면 어느 지점에서 폭 0 매칭이 발생하는지 눈으로 확인할 수 있고, 패턴을 다른 형태로 다시 만들고 싶다면 정규식 생성기로 이메일·URL 등 표준 패턴부터 다시 짜보는 것도 방법입니다.
자주 묻는 질문
Q. 폭 0 매칭도 진짜 "매칭"으로 쳐야 하나요?
네. 정규식 엔진 표준 명세상 길이 0인 매칭도 정상적인 매칭 성공입니다. JavaScript의 String.matchAll()이나 RegExp.exec() 모두 이를 그대로 반환합니다. 다만 대체(replace) 작업에서는 무한 루프를 막기 위해 엔진이 다음 탐색 위치를 강제로 1칸 앞으로 이동시키는 별도 처리를 합니다.
Q. 하이라이트가 안 보이면 그냥 매칭이 없는 셈 치면 되나요?
용도에 따라 다릅니다. "이 위치가 조건을 만족하는가"만 확인하려는 용도(예: 치환 위치 찾기)라면 매칭은 실제로 유효합니다. 반대로 "실제로 어떤 텍스트가 걸렸는지" 눈으로 보고 싶은 용도라면, 패턴에 실제로 문자를 소비하는 부분을 추가해야 확인할 수 있습니다.
Q. lookahead를 하이라이트로 보이게 만들 방법이 있나요?
lookahead 자체는 소비하지 않으므로 순수하게 그것만으로는 불가능합니다. cat(?=egory)처럼 실제로 소비할 부분(cat)을 패턴 앞쪽에 두고 lookahead는 조건 검사용으로만 뒤에 붙이면, 소비되는 "cat" 부분만 하이라이트로 표시됩니다.
Q. 다른 언어(Python, Java)에서도 똑같이 동작하나요?
네, 폭 0 매칭이라는 개념 자체는 정규식 표준(PCRE 계열)에 공통되는 동작이라 Python의 re, Java의 Pattern 등도 동일하게 길이 0인 매칭을 정상적으로 반환합니다. 다만 언어별로 이를 시각화하는 테스터 UI가 어떻게 처리하는지는 도구마다 다를 수 있습니다.