Published on

ES2024 RegExp v 플래그로 유니코드 버그 잡기

Authors

서버 로그, 사용자 닉네임, 검색어, 이모지, 합성 문자(결합 문자)까지 다루다 보면 정규식이 “분명 맞는 것 같은데” 틀리는 순간이 옵니다. 특히 유니코드에서는 문자 하나가 코드 포인트 하나가 아닐 수 있고, 같은 글자가 여러 방식으로 표현될 수 있으며, 언어별 대소문자 규칙도 단순하지 않습니다.

ES2024는 RegExp에 v 플래그(Unicode Sets)를 도입해 이런 문제를 줄이는 방향으로 진화했습니다. 이 글에서는 v가 왜 필요한지, u와 무엇이 다른지, 그리고 실제로 어떤 버그를 잡을 수 있는지 코드로 확인합니다.

참고로 Node.js 환경에서 ESM 전환이나 런타임 설정을 정리해야 한다면 Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드도 함께 보면 좋습니다.

u 플래그로도 남는 유니코드 버그의 정체

정규식 유니코드 이슈는 크게 3가지로 자주 터집니다.

  1. 문자 경계 문제: 사람이 보는 “글자” 단위(그래핌 클러스터)와 코드 포인트 단위가 다름
  2. 문자군/범위 문제: 대괄호 문자 클래스 []가 유니코드에서 기대와 다르게 동작하거나 표현력이 부족함
  3. 케이스 폴딩/정규화 문제: 같은 글자처럼 보이는데 매칭이 안 됨(예: 조합형/완성형, 호환 자모, 악센트 결합 등)

u 플래그는 코드 포인트 단위 해석을 개선하고 일부 유니코드 이스케이프를 제공하지만, 문자 집합(set) 연산이나 더 엄밀한 유니코드 문자 클래스 표현에는 한계가 있었습니다.

ES2024 v 플래그란 무엇인가

v 플래그는 흔히 Unicode Sets라고 부르며, 정규식의 문자 클래스 []를 “유니코드 집합”으로 확장합니다.

핵심은 다음입니다.

  • 문자 클래스를 집합으로 보고 연산을 지원(교집합, 차집합 등)
  • 유니코드 속성 기반 클래스(예: \p{…})를 더 강력하게 조합
  • 다중 코드 포인트로 이루어진 요소(문자열처럼 보이는 것)까지 더 안전하게 다루는 방향

주의할 점도 있습니다.

  • vu와 동시 사용이 아니라, 유니코드 모드 자체를 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로 옮길 때 체크리스트

  1. 실행 환경 지원 여부: 브라우저 타깃/Node 버전 확인, 기능 감지로 폴백
  2. [] 안의 표현 재검토: v에서는 문자 클래스가 “집합”으로 해석되며, 의도치 않은 차이가 없는지 테스트
  3. 정규화 선적용: normalize("NFC") 같은 정규화 단계 도입
  4. 속성 기반으로 정책 재정의: \p{Letter}/\p{Number}/\p{Script=...} 등으로 명시
  5. 회귀 테스트 추가: 공백, 결합 문자, 이모지, 혼합 스크립트

정리

ES2024의 RegExp v 플래그는 “유니코드 문자열을 정규식으로 안전하게 다루기”를 한 단계 올려줍니다. 특히 문자 클래스를 집합 연산으로 다룰 수 있게 되면서,

  • 허용/금지 규칙을 더 명확히 작성하고
  • 국제화 입력에서 발생하는 엣지 케이스를 테스트로 고정하며
  • u로는 애매했던 문자군 표현을 더 정확히 다룰 수 있습니다.

정규식은 결국 정책을 코드로 박아 넣는 도구입니다. v는 그 정책을 “ASCII 중심의 감”이 아니라 “유니코드 속성 기반의 명세”로 끌어올리는 옵션이라고 보는 게 실무적으로 가장 유용합니다.