정규식 문자 클래스의 함정, 왜 이모지가 절반만 지워지는가
"특정 문자를 지우는" 도구는 내부적으로 거의 항상 정규식 문자 클래스 [...]를 씁니다. 그런데 이 문자 클래스가 u(유니코드) 플래그 없이 만들어지면, 이모지처럼 UTF-16에서 두 코드 유닛으로 표현되는 문자를 다룰 때 예상과 다른 결과가 나올 수 있습니다. 특수 문자 제거기의 실제 코드를 그대로 Node.js에서 실행해 이 문제가 정확히 어떤 형태로 나타나는지 검증했습니다.
1. 서로게이트 쌍이란 무엇인가
유니코드는 U+0000부터 U+10FFFF까지 백만 개가 넘는 문자를 정의하지만, JavaScript 문자열의 내부 인코딩인 UTF-16은 하나의 코드 유닛(16비트)으로 U+0000~U+FFFF까지만 직접 표현할 수 있습니다. 이 범위를 기본 다국어 평면(BMP)이라 부르며, 대부분의 이모지(U+1F300 이상)는 이 범위 밖에 있습니다. BMP를 벗어나는 문자는 "서로게이트 쌍"이라는 방식으로, 상위 서로게이트와 하위 서로게이트 두 개의 코드 유닛을 이어붙여 하나의 문자를 표현합니다. 예를 들어 😀(U+1F600)은 내부적으로 \ud83d와 \ude00 두 코드 유닛이 순서대로 붙어 있는 형태입니다.
2. u 플래그 없는 정규식이 서로게이트를 다루는 방식
문제는 JS 정규식 엔진이 u 플래그 없이 동작할 때, 문자열을 유니코드 코드포인트가 아니라 UTF-16 코드 유닛 단위로 읽는다는 데 있습니다. 즉 문자 클래스 [😀]를 u 플래그 없이 만들면, 이 클래스는 "😀라는 한 글자"가 아니라 "\ud83d 또는 \ude00 코드 유닛 중 하나"를 의미하는 두 개의 개별 원소로 해석됩니다. 특수 문자 제거기의 실제 소스(remove-special-characters.html)를 보면 문자 클래스를 이렇게 만듭니다.
if(re)txt=txt.replace(new RegExp('['+re+']','g'),'');RegExp 생성자에 u 플래그가 전달되지 않습니다. 사용자가 추가 제거 필드에 이모지를 입력하면, 그 이모지의 두 서로게이트 코드 유닛이 그대로 문자 클래스 문자열 안에 들어갑니다.
3. 실제로 실행해서 검증한 결과
이 도구가 만드는 정규식을 Node.js에서 그대로 재현해 두 가지 케이스를 테스트했습니다. 먼저 사용자가 지우고 싶은 이모지 😀(U+1F600)를 추가 제거 필드에 입력하고, 본문에도 같은 😀가 있는 경우입니다.
| 테스트 | 정규식 | 대상 텍스트 | 실행 결과 |
|---|---|---|---|
| 같은 이모지 제거 | /[😀]/g (u 플래그 없음) | "hello 😀 world" | "hello world" — 😀가 온전히 지워짐 |
| 서로게이트가 겹치는 다른 이모지 | /[😀]/g (u 플래그 없음) | "hello 😁 world" (😁=U+1F601, 상위 서로게이트가 😀와 동일) | "hello \ude01 world" — 상위 서로게이트만 지워지고 하위 서로게이트가 고아로 남아 깨진 문자로 표시됨 |
흥미로운 점은, 지우려던 이모지 자체는 우연히 "완전히" 지워진다는 것입니다. 상위·하위 서로게이트 코드 유닛이 둘 다 클래스 안에 개별 원소로 들어 있으므로 replace()가 두 번 매칭되어 결과적으로 통째로 사라지기 때문입니다. 진짜 문제는 다른 곳에서 터집니다. 같은 상위 서로게이트(\ud83d)를 공유하는 U+1F300~U+1F5FF, U+1F600~U+1F64F 대역의 다른 이모지(예: 😁, 😂, 🙂 등 매우 많은 이모지)가 본문에 있으면, 그 이모지의 상위 서로게이트만 개별적으로 매칭되어 지워지고 하위 서로게이트만 외로이 남습니다. 그 결과 브라우저는 유효하지 않은 단독 서로게이트를 렌더링하지 못해 대체 문자(□, U+FFFD 등)로 표시하게 됩니다. 즉 "지운 이모지는 사라지지만, 지우려 하지 않은 다른 이모지가 깨진다"는 부작용입니다.
4. u 플래그를 붙이면 왜 해결되는가
u 플래그가 있는 정규식은 문자열을 코드 유닛이 아니라 유니코드 코드포인트 단위로 해석합니다. 같은 [😀] 문자 클래스라도 u 플래그를 붙이면 "서로게이트 쌍 전체가 하나의 코드포인트 😀"로만 매칭되고, 상위·하위 서로게이트를 개별 원소로 쪼개 취급하지 않습니다. 그래서 다른 이모지가 우연히 서로게이트 절반을 공유하더라도 잘못 매칭될 일이 없습니다. 유니코드 인식 문자 처리가 필요한 경우라면 유니코드 변환기처럼 codePointAt()이나 스프레드 연산자([...text])로 코드포인트 단위 순회를 쓰는 방식도 대안입니다.
5. 실무에서 안전하게 쓰는 방법
- 추가 제거 필드에 특정 이모지 하나만 지정하는 대신, 체크박스로 제공되는 사전 정의 카테고리(구두점, 기호 등)를 우선 활용하세요. 이 경로는 서로게이트 쌍 이슈가 있는 문자를 다루지 않습니다.
- 이모지 자체를 통째로 지우고 싶다면 특수문자 제거기보다 이모지 제거기처럼 이모지 전용으로 만들어진 도구를 쓰는 편이 안전합니다.
- 직접 정규식을 짤 일이 있다면 문자 클래스에 비ASCII 문자가 들어가는 순간 습관적으로
u플래그를 붙이는 것이 가장 확실한 예방책입니다.
자주 묻는 질문
Q. 특수 문자 제거기의 정규식에는 실제로 u 플래그가 없나요?
네. remove-special-characters.html 소스의 new RegExp('['+re+']','g') 호출을 확인하면 플래그 인자가 'g' 하나뿐이고 u는 전달되지 않습니다.
Q. 그럼 이모지를 추가 제거 필드에 넣으면 아예 못 지우나요?
지정한 이모지 자체는 대부분 지워집니다(상위·하위 서로게이트가 둘 다 개별 매칭되어 사라지므로). 문제는 그 이모지와 상위 서로게이트를 공유하는 다른 이모지가 본문에 있을 때, 그쪽이 반쪽만 지워져 깨진 문자로 남는다는 점입니다.
Q. 사전 정의된 체크박스(구두점, 특수기호 등)를 쓸 때도 이 문제가 생기나요?
아니요. 체크박스가 제공하는 문자들은 모두 BMP 안의 단일 코드 유닛 문자라 서로게이트 쌍 문제와 무관합니다. 이슈는 사용자가 추가 제거 필드에 직접 이모지 같은 BMP 밖 문자를 입력했을 때만 발생합니다.