정규식 \b는 한글 뒤에서 항상 실패한다 — 의도와 정반대로 동작하는 이유
블로그 초안을 검사하는 스크립트에 규칙을 하나 넣었다. 본문에 서로 다른 아파트 단지명이 몇 개나 나오는지 세는 것이었다. 그런데 잡아야 할 글이 그대로 통과했다. 원인은 정규식 끝에 습관처럼 붙인 \b 하나였다.
증상 — 매칭 결과가 빈 배열
문제의 정규식과 대조군을 같은 문자열에 돌려 봤다.
const withB = /[가-힣]{2,}\s?\d{0,3}\s?(?:단지|블록|BL)\b/g;
const noB = /[가-힣]{2,}\s?\d{0,3}\s?(?:단지|블록|BL)/g;
const t = '대상은 인천검단 AA19블록, 인천서창1단지, 검단13단지, 검단26단지다.';
[...t.matchAll(withB)].map(m => m[0]);
// []
[...t.matchAll(noB)].map(m => m[0]);
// [ '인천서창1단지', '검단13단지', '검단26단지' ]
\b 하나 차이로 세 건이 0건이 된다. 부분 실패가 아니라 전멸이다. 정규식만 떼어내 이 대조를 재현하는 것이 원인을 좁힌 첫 단계였다.
어떤 경우에 true가 되는지 특성화하기
가설을 세우기 전에 경계 문자만 바꿔 가며 전부 직접 돌려 봤다. 아래는 node에서 그대로 실행해 얻은 실측 결과다.
/단지\b/.test('단지') // false ← 문자열 끝
/단지\b/.test('단지 ') // false ← 공백
/단지\b/.test('단지,') // false ← 쉼표
/단지\b/.test('단지A') // true ← 영문
/단지\b/.test('단지1') // true ← 숫자
여기서 결과가 뒤집혀 있다는 게 보인다. 단어가 끝나는 자리에서는 전부 false이고, 뒤에 다른 글자가 붙었을 때만 true다. \b를 붙인 목적이 “여기서 단어가 끝난다”를 표현하는 것이었다면, 실제 동작은 정확히 그 반대다.
영문으로 끝나는 패턴은 정상이다.
/BL\b/.test('BL') // true
/BL\b/.test('BL ') // true
/BL\b/.test('BL,') // true
/BL\b/.test('BLK') // false
앞쪽에 붙여도 마찬가지로 뒤집힌다.
/\b단지/.test('서창1단지') // true ← 숫자 다음이라 경계가 생김
원인 — \b는 ASCII \w 기준이다
\b의 정의는 “\w 문자와 \w가 아닌 문자가 맞닿은 지점”이다. 그리고 자바스크립트에서 \w는 [A-Za-z0-9_]와 정확히 같다. 한글은 여기에 들어가지 않는다.
그래서 이렇게 계산된다.
| 문자열 | 지 앞뒤 | 경계? |
|---|---|---|
…단지 (끝) | non-word / 문자열 끝 | ❌ |
…단지 | non-word / non-word(공백) | ❌ |
…단지, | non-word / non-word(쉼표) | ❌ |
…단지A | non-word / word | ✅ |
…단지1 | non-word / word | ✅ |
한글로 끝나는 패턴 뒤의 \b는 어떤 정상적인 문장에서도 매치되지 않는다. 뒤에 영문이나 숫자가 곧바로 붙는 경우에만 우연히 통과한다.
u 플래그로는 해결되지 않는다
유니코드 관련 문제이니 u 플래그를 떠올리기 쉽다. 직접 확인해 보면 되지 않는다.
/단지\b/u.test('서창1단지') // false
u 플래그는 \p{Script=Hangul} 같은 유니코드 속성 이스케이프를 쓸 수 있게 해 주고 서로게이트 페어 처리를 바꾸지만, \b와 \w의 정의 자체는 ASCII 기준으로 남는다. v 플래그도 마찬가지다.
이 버그가 위험한 이유
문법 오류가 아니다. 정규식은 정상적으로 컴파일되고, 코드는 예외 없이 돌아가고, 결과만 조용히 비어 있다.
내 경우 검사 스크립트가 “규칙 통과”를 출력했다. 규칙이 동작하지 않는다는 신호가 어디에도 없었다. 발견한 계기는 잡혀야 할 글이 안 잡힌다는 것을 눈으로 본 것이었지, 어떤 도구가 알려준 것이 아니다.
한글 데이터에서 \b를 쓴 정규식은 거짓 음성(false negative)을 조용히 만든다. 검색이 안 되는 게 아니라 “검색 결과 없음”으로 보인다.
대안 세 가지
1. 그냥 빼는 것이 대부분 정답이다.
한국어는 조사가 붙어서 단어 경계 자체가 모호하다. 서창1단지가, 서창1단지는, 서창1단지다 모두 같은 단지를 가리킨다. \b로 끊으려는 시도 자체가 언어 구조와 맞지 않는다.
const re = /[가-힣]{2,}\s?\d{0,3}\s?(?:단지|블록|BL)/g;
2. 실제로 올 수 있는 구분자를 명시한다.
경계가 정말 필요하다면 뒤에 올 수 있는 것을 직접 적는다.
const re = /[가-힣]{2,}\s?\d{0,3}\s?(?:단지|블록)(?=[\s,、·)\]]|$)/g;
전방탐색이라 구분자는 매치 결과에 포함되지 않는다. 다만 조사(가, 는, 를)를 경계로 볼지는 별도 판단이 필요하다.
3. 유니코드 속성으로 직접 정의한다.
문자 종류가 바뀌는 지점을 경계로 삼고 싶다면 u 플래그와 속성 이스케이프를 쓴다.
// 한글이 아닌 것이 뒤따르거나 문자열이 끝나는 지점
const re = /[가-힣]{2,}\s?\d{0,3}\s?(?:단지|블록)(?=\P{Script=Hangul}|$)/gu;
이 방식은 단지1처럼 숫자가 붙은 경우도 경계로 인정하므로, 원하는 동작이 무엇인지 먼저 정해야 한다.
회귀 테스트로 고정하기
이런 버그는 조용하기 때문에 테스트가 없으면 다시 들어온다. 나는 실제 실패 사례를 그대로 케이스로 박았다.
test("숫자 단지명은 뒤에 한글이 붙어도 잡힌다 (\\b 버그 회귀)", () => {
// "인천서창1단지다"처럼 조사가 붙으면 ASCII \b가 성립하지 않아 예전엔 놓쳤다.
assert.ok(collect("인천서창1단지 영구임대 신청자격").length > 0);
});
테스트 이름에 버그 이름을 넣어 두면, 나중에 누군가 “이 \b 왜 없지” 하고 다시 붙였을 때 이유가 즉시 드러난다.
정리
- 자바스크립트
\b는\w = [A-Za-z0-9_]기준이고 한글은 non-word다. - 한글로 끝나는 패턴 뒤의
\b는 문자열 끝·공백·쉼표에서 전부 false다. - 뒤에 영문·숫자가 붙었을 때만 true가 되어 의도와 정반대로 동작한다.
u플래그로 해결되지 않는다.- 컴파일 오류도 예외도 없이 결과만 조용히 비므로, 회귀 테스트로 고정하는 것이 유일한 방어다.
자주 묻는 질문
왜 '단지A'는 true이고 '단지'는 false인가요?
\b는 word 문자와 non-word 문자가 맞닿은 지점을 뜻합니다. 한글은 \w에 포함되지 않으므로 '지'는 non-word입니다. '단지A'에서는 non-word('지') 다음에 word('A')가 와서 경계가 성립하지만, '단지'로 끝나거나 뒤에 공백·쉼표가 오면 양쪽 모두 non-word라 경계가 없습니다. 그래서 단어를 끊으려던 의도와 정확히 반대 결과가 나옵니다.
u 플래그를 쓰면 해결되나요?
되지 않습니다. u 플래그는 \p{...} 같은 유니코드 속성 이스케이프를 쓸 수 있게 하고 서로게이트 페어 처리를 바꾸지만, \b와 \w의 정의는 그대로 ASCII 기준입니다. /단지\b/u.test('서창1단지')도 false입니다.
그럼 한글에서 단어 경계는 어떻게 잡나요?
대부분의 경우 \b가 애초에 필요 없습니다. 한국어는 조사가 붙어 경계 자체가 모호하므로, 패턴을 그냥 두거나 뒤따를 수 있는 문자를 명시적으로 쓰는 편이 낫습니다. 예를 들어 목록에서 잘라내야 한다면 (?=[\s,、·)\]]|$) 같은 전방탐색으로 실제로 올 수 있는 구분자를 적어 주는 방식입니다.
어떻게 발견했나요?
블로그 초안 검사 스크립트에 '본문에 서로 다른 단지명이 몇 개인지' 세는 규칙을 넣었는데, 잡아야 할 글이 그대로 통과했습니다. 정규식만 떼어내 matchAll로 돌려 보니 \b가 있을 때는 결과가 빈 배열이고 뺐을 때는 세 건이 잡혔습니다. 이 대조가 원인을 확정하는 근거였습니다.