- Published on
ES2024 RegExp v 플래그로 유니코드 버그 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그, 사용자 닉네임, 검색어, 이모지, 합성 문자(결합 문자)까지 다루다 보면 정규식이 “분명 맞는 것 같은데” 틀리는 순간이 옵니다. 특히 유니코드에서는 문자 하나가 코드 포인트 하나가 아닐 수 있고, 같은 글자가 여러 방식으로 표현될 수 있으며, 언어별 대소문자 규칙도 단순하지 않습니다.
ES2024는 RegExp에 v 플래그(Unicode Sets)를 도입해 이런 문제를 줄이는 방향으로 진화했습니다. 이 글에서는 v가 왜 필요한지, u와 무엇이 다른지, 그리고 실제로 어떤 버그를 잡을 수 있는지 코드로 확인합니다.
참고로 Node.js 환경에서 ESM 전환이나 런타임 설정을 정리해야 한다면 Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드도 함께 보면 좋습니다.
u 플래그로도 남는 유니코드 버그의 정체
정규식 유니코드 이슈는 크게 3가지로 자주 터집니다.
- 문자 경계 문제: 사람이 보는 “글자” 단위(그래핌 클러스터)와 코드 포인트 단위가 다름
- 문자군/범위 문제: 대괄호 문자 클래스
[]가 유니코드에서 기대와 다르게 동작하거나 표현력이 부족함 - 케이스 폴딩/정규화 문제: 같은 글자처럼 보이는데 매칭이 안 됨(예: 조합형/완성형, 호환 자모, 악센트 결합 등)
u 플래그는 코드 포인트 단위 해석을 개선하고 일부 유니코드 이스케이프를 제공하지만, 문자 집합(set) 연산이나 더 엄밀한 유니코드 문자 클래스 표현에는 한계가 있었습니다.
ES2024 v 플래그란 무엇인가
v 플래그는 흔히 Unicode Sets라고 부르며, 정규식의 문자 클래스 []를 “유니코드 집합”으로 확장합니다.
핵심은 다음입니다.
- 문자 클래스를 집합으로 보고 연산을 지원(교집합, 차집합 등)
- 유니코드 속성 기반 클래스(예:
\p{…})를 더 강력하게 조합 - 다중 코드 포인트로 이루어진 요소(문자열처럼 보이는 것)까지 더 안전하게 다루는 방향
주의할 점도 있습니다.
v는u와 동시 사용이 아니라, 유니코드 모드 자체를v가 대체하는 개념에 가깝습니다.- 엔진 지원이 필요합니다(브라우저/Node 버전에 따라 다름). 프로덕션 적용 전 런타임 매트릭스를 확인하세요.
런타임에서 v 지원 여부 체크
서비스 코드에서는 기능 감지로 폴백을 두는 편이 안전합니다.
export function supportsUnicodeSetsV() {
try {
// 빈 패턴이라도 플래그 파싱 단계에서 지원 여부가 갈립니다.
new RegExp("", "v");
return true;
} catch {
return false;
}
}
console.log("RegExp v supported:", supportsUnicodeSetsV());
지원하지 않는 환경에서는 u 기반의 보수적 패턴으로 폴백하거나, 빌드 타깃을 조정해야 합니다.
버그 1: “영문+숫자+밑줄” 검증이 유니코드에서 깨지는 경우
아이디 정책을 “영문, 숫자, 밑줄만 허용”으로 뒀다고 합시다. 흔히 이런 정규식을 씁니다.
const re = /^[A-Za-z0-9_]+$/;
문제는 이 패턴이 유니코드 문맥에서 “영문”의 정의가 너무 좁고, 반대로 특정 문자를 예상치 못하게 허용/차단하는 정책 혼선을 만들 수 있다는 점입니다.
- 라틴 확장(예:
é,ø)은 영문권 사용자에게는 “영문”인데 차단됨 - 반대로 유사 문자(동형 이의어) 문제를 고려하면 무작정 허용도 위험
v 플래그는 이런 정책을 더 명확히 만들 수 있습니다. 예를 들어 “ASCII만 허용”이라면 명시적으로 ASCII 집합을 쓰는 게 낫습니다.
// ASCII만 허용: 정책을 더 명확하게 표현
const asciiId = /^[\p{ASCII}&&[\p{Letter}\p{Number}_]]+$/v;
console.log(asciiId.test("user_01")); // true
console.log(asciiId.test("usér")); // false
위 패턴은 다음 의미에 가깝습니다.
\p{ASCII}집합과\p{Letter}또는\p{Number}또는_집합의- 교집합만 허용
즉 “ASCII 범위의 문자 중에서 글자/숫자/밑줄만”이라는 정책을 정규식 자체에 담습니다.
버그 2: 유니코드 공백 처리에서 생기는 파싱 오류
로그 파서나 토크나이저에서 공백을 나누는 작업은 흔합니다. 그런데 유니코드에는 공백처럼 보이지만 다른 코드 포인트인 것들이 많습니다(예: NBSP).
기존에는 \s를 쓰거나, 하나만 처리하다가 데이터가 깨집니다.
v에서는 유니코드 속성 기반으로 “공백”을 더 의도적으로 다룰 수 있고, 동시에 제외 조건도 집합 연산으로 명확히 할 수 있습니다.
// 유니코드 공백 전부를 구분자로 보되,
// 줄바꿈은 토큰 경계가 아니라 레코드 경계로 남기고 싶다면
const splitWsExceptNewline = /[\p{White_Space}--[\n\r]]+/v;
const s = "a\u00A0b\t c\nnext"; // NBSP, tab, space, newline
console.log(s.split(splitWsExceptNewline));
// 기대: ["a", "b", "c\nnext"]
여기서 --는 집합의 차집합(빼기) 개념으로 이해하면 됩니다. \p{White_Space}에서 개행 문자를 제외한 집합을 만들었습니다.
버그 3: 이모지/결합문자 때문에 “한 글자” 검증이 실패
“닉네임은 2자 이상 10자 이하” 같은 요구사항은 흔하지만, 유니코드에서는 ‘자’의 정의가 애매합니다.
👍같은 이모지는 코드 포인트 1개처럼 보이지만, 변형 선택자나 스킨톤이 붙으면 여러 코드 포인트가 됩니다.가는 1코드 포인트지만, 어떤 문자는 결합 문자로 2개 이상이 합쳐져 보입니다.
정규식만으로 “사람이 인지하는 글자 수”를 완벽하게 세는 건 어렵습니다. 다만 v는 문자 클래스 표현을 더 안전하게 만들어, 최소한 “이모지 범주” 같은 검증을 더 정확히 할 수 있습니다.
// 이모지로만 구성된 문자열인지 검사(대략적인 정책 예시)
const emojiOnly = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic})+$/v;
console.log(emojiOnly.test("👍")); // true
console.log(emojiOnly.test("👍🏽")); // 케이스에 따라 엔진/속성 지원에 영향
console.log(emojiOnly.test("A👍")); // false
중요한 실무 포인트는 이겁니다.
- “길이 제한”은 정규식보다
Intl.Segmenter같은 그래핌 단위 세그먼테이션을 고려 - “허용 문자 정책”은
v로 유니코드 속성 집합을 조합해 명확히 기술
길이 검증 예시는 다음처럼 분리하는 편이 안전합니다.
export function graphemeLength(str, locale = "en") {
const seg = new Intl.Segmenter(locale, { granularity: "grapheme" });
let count = 0;
for (const _ of seg.segment(str)) count++;
return count;
}
console.log(graphemeLength("👍🏽"));
v 플래그로 “유니코드 버그”를 줄이는 설계 패턴
1) 정책을 문자 범위가 아니라 속성으로 표현하기
[A-Za-z] 같은 범위 기반 정책은 국제화에서 바로 한계가 옵니다. 다음처럼 속성으로 바꾸면 의도가 선명해집니다.
// 모든 유니코드 문자 중 Letter만 허용
const lettersOnly = /^\p{Letter}+$/v;
console.log(lettersOnly.test("Hello")); // true
console.log(lettersOnly.test("한국어")); // true
console.log(lettersOnly.test("123")); // false
2) 예외 규칙은 차집합으로 명시하기
금지 문자를 나열하기보다, “허용 집합에서 제외”로 작성하면 유지보수가 쉽습니다.
// Letter와 Number는 허용하되, 특정 스크립트(예: Cyrillic)를 제외하고 싶다는 정책 예시
const allowButNoCyrillic = /^[\p{Letter}\p{Number}--\p{Script=Cyrillic}]+$/v;
console.log(allowButNoCyrillic.test("user01")); // true
console.log(allowButNoCyrillic.test("Иван")); // false
3) 입력 정규화는 정규식 이전에 수행하기
유니코드는 같은 글자를 여러 방식으로 표현할 수 있습니다. 정규식만으로 해결하려 하면 끝이 없습니다. 보통은 정규식 적용 전에 정규화부터 합니다.
export function normalizeForCompare(str) {
// NFC가 일반적인 사용자 입력 처리에 많이 쓰입니다.
return str.normalize("NFC");
}
const a = "e\u0301"; // e + 결합 악센트
const b = "\u00E9"; // é
console.log(a === b); // false
console.log(normalizeForCompare(a) === normalizeForCompare(b)); // true
정규화 후 v 기반 패턴을 적용하면 “같아 보이는데 매칭이 안 됨” 류의 버그를 크게 줄일 수 있습니다.
테스트 전략: 유니코드 회귀 테스트를 꼭 넣기
정규식은 한 번 배포하면 엣지 케이스가 계속 들어옵니다. 아래 같은 입력을 픽스처로 만들어 회귀 테스트에 넣는 걸 권합니다.
- NBSP
\u00A0, ZWJ, 변형 선택자 - 조합형/완성형이 섞인 문자열
- 서로 다른 스크립트 혼합(라틴+키릴 등)
간단한 Node 테스트 예시는 다음처럼 구성할 수 있습니다.
import assert from "node:assert/strict";
const re = /^[\p{White_Space}--[\n\r]]+$/v;
assert.equal(re.test("\t\u00A0 "), true);
assert.equal(re.test("\n"), false);
이런 “사소한 입력”이 실제로는 인증/파싱/검색 품질을 좌우합니다. 비슷한 맥락에서, 작은 언어 스펙 차이가 운영 장애로 이어지는 사례는 많습니다. 예를 들어 Go의 문법/동작 변화로 발생하는 버그를 다룬 글인 Go 1.22 range 변수 캡처 버그? 고치는 법도 같은 결의 교훈을 줍니다.
마이그레이션 가이드: u에서 v로 옮길 때 체크리스트
- 실행 환경 지원 여부: 브라우저 타깃/Node 버전 확인, 기능 감지로 폴백
[]안의 표현 재검토:v에서는 문자 클래스가 “집합”으로 해석되며, 의도치 않은 차이가 없는지 테스트- 정규화 선적용:
normalize("NFC")같은 정규화 단계 도입 - 속성 기반으로 정책 재정의:
\p{Letter}/\p{Number}/\p{Script=...}등으로 명시 - 회귀 테스트 추가: 공백, 결합 문자, 이모지, 혼합 스크립트
정리
ES2024의 RegExp v 플래그는 “유니코드 문자열을 정규식으로 안전하게 다루기”를 한 단계 올려줍니다. 특히 문자 클래스를 집합 연산으로 다룰 수 있게 되면서,
- 허용/금지 규칙을 더 명확히 작성하고
- 국제화 입력에서 발생하는 엣지 케이스를 테스트로 고정하며
u로는 애매했던 문자군 표현을 더 정확히 다룰 수 있습니다.
정규식은 결국 정책을 코드로 박아 넣는 도구입니다. v는 그 정책을 “ASCII 중심의 감”이 아니라 “유니코드 속성 기반의 명세”로 끌어올리는 옵션이라고 보는 게 실무적으로 가장 유용합니다.