it-box

정규식 테스트 입력과 ReDoS 방지 방법

정규식을 검증할 때 넣어야 할 입력의 종류, 탐욕과 게으른 수량자의 차이, 파국적 백트래킹이 생기는 패턴, 한글 범위와 이메일 전화번호 검증의 현실적 기준을 정리합니다.

맞는 입력보다 틀린 입력으로 테스트합니다

정규식을 처음 작성하면 기대한 입력 몇 개가 맞는지부터 확인하게 됩니다. 그러나 실제 사고는 대부분 기대하지 않은 입력에서 납니다. 테스트 입력은 최소한 아래 네 종류를 섞어서 준비합니다.

  • 기대하는 형태의 입력: 정상적인 예 두세 개
  • 명백히 틀린 입력: 글자 수가 부족하거나, 구분 기호가 빠졌거나, 숫자 자리에 문자가 들어간 경우
  • 경계 입력: 빈 문자열, 앞뒤 공백이 붙은 값, 최소 길이와 최대 길이 근처의 값
  • 환경이 만드는 입력: 줄바꿈이 포함된 값, 조합형 한글, 이모지, 아주 긴 문자열

정규식 테스터에서 이 네 종류를 한 번에 넣어 보면 빠진 부분이 금방 드러납니다. 이 도구는 정규식과 테스트 문자열을 입력받아 매치 위치, 매치된 값, 번호형 캡처와 이름 있는 그룹을 표로 보여 줍니다. 기본 플래그는 g, s, u가 켜져 있고, 나머지 플래그(i, m, v)는 체크박스로 바꿀 수 있습니다. 다만 v는 u가 꺼져 있을 때만 적용됩니다. 정규식 입력란 아래에는 정규식 치트시트로 연결되는 링크가 있습니다.

탐욕 수량자와 게으른 수량자

*, +, ?, {n,m}은 기본적으로 가능한 한 길게 먹습니다. 이것을 탐욕(greedy) 수량자라고 합니다. 뒤에 ?를 붙이면 가능한 한 짧게 먹는 게으른(lazy) 수량자가 됩니다.

'<b>a</b> and <b>c</b>'.match(/<b>.*<\/b>/)[0]
// '<b>a</b> and <b>c</b>'  (두 태그 사이 전체가 하나로 잡힘)

'<b>a</b> and <b>c</b>'.match(/<b>.*?<\/b>/g)
// ['<b>a</b>', '<b>c</b>']

두 번째 결과가 의도한 값입니다. 그런데 이 문제는 HTML 조각을 뽑을 때만 생기지 않습니다. 로그 한 줄에서 "..." 사이 값을 뽑거나, key=value; 목록에서 값을 뽑을 때도 같은 실수가 나옵니다. 구분 기호가 정해져 있다면 [^"]*처럼 "구분 기호가 아닌 문자"로 표현하는 편이 게으른 수량자보다 예측하기 쉽습니다.

HTML이나 JSON 구조를 정규식으로 파싱하는 것은 권장하지 않습니다. 중첩 구조는 정규식으로 맞추기 어렵습니다. 이런 입력은 전용 파서를 쓰고, 정규식은 값의 형식 확인에만 씁니다.

유니코드 플래그 없이 이모지를 다루면 생기는 일

JavaScript 정규식의 .은 기본적으로 UTF-16 코드 유닛 하나와 맞습니다. 이모지처럼 두 개의 코드 유닛으로 된 문자는 두 번에 나뉩니다.

/^.$/.test('😀')    // false
/^.$/u.test('😀')   // true

u 플래그를 켜면 코드 포인트 단위로 맞춥니다. 정규식 테스터는 기본으로 u가 켜져 있으므로 이 차이를 놓치기 쉽습니다. 서버의 정규식 엔진이나 다른 언어의 기본값과 다르면 같은 패턴이 다르게 동작할 수 있습니다.

파국적 백트래킹(ReDoS)

정규식 엔진은 매치가 실패하면 이전 선택지로 되돌아가 다른 경우를 다시 시도합니다. 중첩된 수량자가 있으면 가능한 나누기 조합이 기하급수적으로 늘어납니다. 입력이 조금만 길어져도 매치 한 번에 몇 초가 걸리고, 서버가 그 시간 동안 다른 요청을 처리하지 못하게 됩니다. 이것을 ReDoS(Regular Expression Denial of Service)라고 부릅니다.

가장 흔한 예는 (a+)+처럼 같은 문자를 두 번 수량자로 감싼 패턴입니다. 끝에 매치되지 않는 문자를 붙여 측정했습니다. 측정 환경에 따라 수치는 달라집니다.

const re = /^(a+)+$/;
// 'a'가 20개 + '!'  : 약 6 ms
// 'a'가 22개 + '!'  : 약 24 ms
// 'a'가 24개 + '!'  : 약 95 ms
// 같은 입력을 /^a+$/로 검사하면 24개에서 0.02 ms 안팎입니다.

입력 길이가 2 늘 때마다 시간이 약 4배씩 늘었습니다. 길이가 몇십 자만 넘어도 서버에서 문제가 됩니다.

같은 모양을 가진 패턴은 다음과 같습니다.

  • (a|a)*처럼 같은 문자를 가진 선택지를 수량자로 반복하는 패턴
  • (\w+\s?)*$처럼 공백이 선택 사항인 반복 안에 또 다른 반복이 있는 패턴

피하는 방법은 다음과 같습니다.

  1. 중첩 수량자를 없앱니다. (a+)+는 a+로 충분합니다.
  2. 구분 기호를 명시합니다. 반복 단위가 서로 겹치지 않게 만듭니다.
  3. 매치 전에 입력 길이를 제한합니다. 예를 들어 if (text.length > 1000) return false;처럼 먼저 자르면 최악의 경우도 제한됩니다.
  4. 사용자 입력을 그대로 패턴으로 만들지 않습니다.

JavaScript 정규식은 원자 그룹(atomic group)이나 소유 수량자(possessive quantifier)를 지원하지 않습니다. 그래서 다른 엔진에서 쓰던 해법을 그대로 옮길 수 없습니다. 패턴 자체를 단순하게 바꾸는 것이 가장 확실합니다.

한글 범위 [가-힣]의 한계

한글을 매칭할 때 [가-힣]을 가장 많이 씁니다. 이 범위는 U+AC00부터 U+D7A3까지이며, 완성형 음절 11,172자입니다. 확인해 보면 다음과 같습니다.

/[가-힣]/.test('힣')        // true  (U+D7A3)
/[가-힣]/.test('ㄱ')        // false (U+3131, 호환 자모)
/[가-힣]/.test('ᄒ')   // false (초성 자모, 분해형)
'한글 ABC ㄱㅏ 가'.match(/[가-힣]+/g)
// ['한글', '가']

이 범위는 자모를 포함하지 않습니다. 따라서 자모가 분리된 입력(맥에서 온 파일명 등)은 매치되지 않습니다. 이런 입력을 다룬다면 먼저 NFC로 정규화해야 합니다. 정규화 방법은 한글 정규화 글에서 다뤘습니다. 자모만 입력하는 한글 입력기 상태, 한자, 기타 확장 블록 문자도 이 범위에 없습니다. 요구사항에 따라 \p{Script=Hangul}을 u 플래그와 함께 쓰는 방법도 있습니다.

이메일과 전화번호 검증의 현실적 기준

이메일 주소의 문법 규격은 매우 복잡합니다. 따옴표로 감싼 로컬 파트나 주석 같은 형식까지 허용합니다. 일반 서비스에서 이 규격을 정규식으로 완벽히 구현할 이유는 거의 없습니다. 현실적인 기준은 다음과 같습니다.

const email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
email.test('user+tag@example.com')   // true
email.test('한글@도메인.한국')        // true
email.test('a@b')                    // false (도메인에 점이 없음)
email.test('a b@x.com')              // false (공백 포함)

이 정도 패턴으로 입력 실수를 걸러 내고, 실제 주소인지는 인증 메일로 확인합니다. 형식 검사만으로 "존재하는 주소"라고 판단하면 안 됩니다.

휴대전화 번호는 다음 정도면 대부분의 입력 실수를 걸러 냅니다.

const mobile = /^01[016789]-?\d{3,4}-?\d{4}$/;
mobile.test('010-1234-5678')  // true
mobile.test('01012345678')    // true
mobile.test('010-123-4567')   // true
mobile.test('02-123-4567')    // false (지역번호는 휴대전화가 아님)

이 패턴은 휴대전화 형식만 확인합니다. 유선 번호, 국제 번호(+82), 하이픈 위치 차이는 따로 처리해야 합니다. 입력 단계에서 숫자만 남기고 하이픈을 제거한 뒤 검사하면 패턴이 훨씬 단순해집니다.

테스트 데이터에는 실제 고객 정보를 넣지 마세요. 전화번호와 이메일은 가짜 값을 쓰고, 온라인 테스터에 개인정보를 붙여 넣지 않는 습관이 필요합니다.

흔한 실수

  • 기대한 입력 두세 개로만 확인하고 끝냅니다.
  • g 플래그를 켠 정규식을 test()에 반복해서 씁니다. lastIndex 상태가 남아 두 번째 호출이 false를 돌려줍니다.
const r = /a/g;
r.test('a')  // true,  lastIndex = 1
r.test('a')  // false, lastIndex = 0 (처음부터 다시 검사)
  • 정규식으로 HTML이나 JSON을 파싱합니다.
  • 중첩 수량자가 있는 패턴을 사용자 입력 검증에 씁니다.
  • 검증 정규식만 있고 서버 쪽 길이 제한이 없습니다.
  • 줄바꿈이 포함된 입력에서 ^와 $가 줄 단위로 동작하는지 확인하지 않습니다. m 플래그 여부를 확인하세요.

점검 목록

  • 기대 입력, 틀린 입력, 경계 입력, 긴 입력을 모두 넣어 테스트했는지 확인합니다.
  • 중첩 수량자가 있는지 찾아보고, 있다면 패턴을 단순하게 바꿉니다.
  • 매치 전에 입력 길이를 제한합니다.
  • u 플래그를 쓰는지, 그 결정이 이모지 처리에 맞는지 확인합니다.
  • 한글 입력이 NFC인지 확인하고, 필요하면 정규화한 뒤 매칭합니다.
  • 테스트 데이터에 실제 개인정보를 쓰지 않았는지 확인합니다.
  • 정규식 옆에 어떤 형식을 허용하는지 한 줄 주석으로 남깁니다.

관련 도구