regexes-best-practices — 정규식·그래머를 견고하게 쓰는 요령

regexes-best-practices — 정규식·그래머를 견고하게 쓰는 요령

정규식(regex)과 그래머(grammar)를 견고하게 만들려면, 코드 배치와 가독성, 무엇을 실제로 매칭할지, 그리고 흔한 함정들을 피하는 데 신경 써야 해요. 이 페이지는 그런 모범 사례들을 정리한 거예요.

출처: Regexes: Best practices and gotchas - Raku Documentation

코드 배치

:sigspace 어드버브를 쓰지 않는 한 Raku 정규식에서는 공백이 의미를 갖지 않아요. 이 점을 활용해 가독성이 좋아지는 곳에 공백을 넣고, 필요하면 주석도 넣어요.

매우 압축된 버전을 비교해 볼게요:

my regex float { <[+-]>?\d*'.'\d+[e<[+-]>?\d+]? }

이렇게 가독성 좋게 쓴 버전이랑요:

my regex float {
     <[+-]>?        # optional sign
     \d*            # leading digits, optional
     '.'
     \d+
     [              # optional exponent
        e <[+-]>?  \d+
     ]?
}

일반적인 규칙으로는 원자(atom) 주변과 그룹 안쪽에 공백을 쓰고, 수량자(quantifier)는 원자 바로 뒤에 붙이며, 여는/닫는 대괄호와 괄호를 세로로 정렬해요.

괄호나 대괄호 안에 대안(alternation) 목록을 쓸 때는 세로 막대를 정렬해요:

my regex example {
    <preamble>
    [
    || <choice_1>
    || <choice_2>
    || <choice_3>
    ]+
    <postamble>
}

작게 유지하기

정규식은 보통 일반 코드보다 압축적이에요. 적은 코드로 많은 걸 하기 때문에, 정규식을 짧게 유지하는 게 좋아요.

정규식의 일부에 이름을 붙일 수 있다면, 그 부분을 별도의 이름 있는 정규식으로 빼는 게 보통 좋아요.

예를 들어 앞의 float 정규식을 이렇게 분해할 수 있어요:

my token sign { <[+-]> }
my token decimal { \d+ }
my token exponent { 'e' <sign>? <decimal> }
my regex float {
    <sign>?
    <decimal>?
    '.'
    <decimal>
    <exponent>?
}

이렇게 하면 정규식이 더 복잡해질 때 특히 도움이 돼요. 예를 들어 지수가 있으면 소수점을 선택 사항으로 만들고 싶을 때도 있고요.

my regex float {
    <sign>?
    [
    || <decimal>?  '.' <decimal> <exponent>?
    || <decimal> <exponent>
    ]
}

무엇을 매칭할 것인가

입력 데이터 형식에 명확한 명세가 없거나, 프로그래머가 명세를 모를 때가 많아요. 그럴 땐 기대하는 바를 너그럽게(liberal) 두는 게 좋은데, 단 모호함이 생길 가능성이 없을 때만 그래요.

예를 들어 ini 파일을 봐요:

[section]
key=value

섹션 헤더 안에는 뭐가 들어갈 수 있을까요? 단어 하나만 허용하는 건 너무 제한적일 수 있어요. 누군가는 [two words]처럼 쓰거나 대시를 쓸 수도 있으니까요. "안에 뭐가 허용되나"를 묻는 대신, **"뭐가 허용되지 않나?"**를 물어보는 편이 나을 때가 있어요.

분명히 닫는 대괄호는 허용되면 안 돼요. [a]b]가 모호해지거든요. 같은 논리로 여는 대괄호도 금지해야 해요. 그러면 이렇게 남아요:

token header { '[' <-[ \[\]] >+ ']' }

이건 한 줄만 처리한다면 괜찮아요. 하지만 파일 전체를 처리한다면 이 정규식이 이런 것까지 파싱해 버려요:

[with a
newline in between]

바람직하지 않을 수 있죠. 절충안은 이렇게 하는 거예요:

token header { '[' <-[ \[\]] \n >+ ']' }

그리고 후처리에서 섹션 헤더의 앞뒤 공백·탭을 떼어내면 돼요.

공백 매칭

:sigspace 어드버브(또는 token/regex 대신 rule 선언자)는 여러 곳에 나타날 수 있는 공백을 암시적으로 파싱할 때 아주 유용해요.

ini 파일 파싱 예제로 돌아가 볼게요:

my regex kvpair { \s* <key=identifier> '=' <value=identifier> \n+ }

사용자가 등호 주변에 공백을 넣을 수 있으니 이건 우리가 원하는 만큼 너그럽지 않아요. 그래서 이렇게 해볼 수 있는데:

my regex kvpair { \s* <key=identifier> \s* '=' \s* <value=identifier> \n+ }

이건 다루기 어려워 보이죠. 그래서 다른 방법을 써 볼게요:

my rule kvpair { <key=identifier> '=' <value=identifier> \n+ }

잠깐! 값 뒤의 암시적 공백 매칭이 줄바꿈 문자를 포함한 모든 공백을 다 써버려서, \n+가 매칭할 게 남지 않아요 (rule은 백트래킹도 비활성화하니까 방법도 없고요).

따라서 암시적 공백의 정의를 "입력 형식에서 의미 없는 공백"으로 다시 정의하는 게 중요해요.

이건 ws 토큰을 재정의해서 하며, 그래머 안에서만 동작해요:

grammar IniFormat {
    token ws { <!ww> \h* }
    rule header { \s* '[' (\w+) ']' \n+ }
    token identifier  { \w+ }
    rule kvpair { \s* <key=identifier> '=' <value=identifier> \n+ }
    token section {
        <header>
        <kvpair>*
    }

    token TOP {
        <section>*
    }
}

my $contents = q:to/EOI/;
    [passwords]
        jack = password1
        joy = muchmoresecure123
    [quotas]
        jack = 123
        joy = 42
EOI
say so IniFormat.parse($contents);

모든 정규식을 그래머로 모으고 토큰으로 바꾼 것(어차피 백트래킹할 필요가 없으니) 외에, 흥미로운 새 부분은 이거예요:

token ws { <!ww> \h* }

이건 암시적 공백 파싱을 위해 호출돼요. 두 단어 문자 사이가 아닐 때(<!ww>, 부정된 "단어 안" 단언) 매칭되고, 가로 공백 문자 0개 이상에서도 매칭돼요. 가로 공백으로 제한하는 게 중요한데, 줄바꿈(세로 공백)은 레코드를 구분하는 문자라 암시적으로 매칭되면 안 되기 때문이에요.

그래도 공백 관련 함정이 하나 더 있어요. 정규식 \n+"\n \n" 같은 문자열과 매칭되지 않아요. 두 줄바꿈 사이에 빈칸이 있으니까요. 그런 입력을 허용하려면 \n+\n\s*로 바꾸면 돼요.