솔직히 고백하자면, 저는 정규식 작업이 그다지 좋지 않습니다. 실무에서 자주 사용하긴 하지만, /^foo.*$/ 수준을 넘어서는 복잡한 표현식을 마주치면 매번 멈춰서 곰곰이 생각해야 합니다. \A(?=\w{6,10}\z)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3}) 같은 표현식을 한눈에 해독할 수 있는 사람도 분명 있겠지만, 저는 몇 분 동안 구글링을 해야 하고 기분까지 가라앉습니다. 익숙한 Ruby 코드를 읽는 것과는 상당히 다른 경험입니다.
참고로 위 예제는 정규식 lookahead(전방탐색)를 다룬 한 글에서 가져온 것입니다.
문제의 상황
Honeybadger에서 저는 현재 검색 UI 개선 작업을 진행하고 있습니다. 많은 검색 시스템과 마찬가지로 우리 시스템 역시 간단한 쿼리 언어를 사용합니다. 제가 변경하기 전에는 특정 날짜 범위로 검색하려면 다음과 같은 쿼리를 직접 타이핑해야 했습니다:
occurred:[2017-06-12T16:10:00Z TO 2017-06-12T17:10:00Z]
꽤나 번거롭죠!
새로운 검색 UI에서는 사용자가 날짜 관련 쿼리를 입력하기 시작하는 순간을 감지해 유용한 데이트 피커(datepicker)를 띄우려 합니다. 물론 데이트 피커는 시작에 불과합니다. 앞으로 문맥에 맞는 힌트 기능을 더 다양한 종류의 검색어로 확장할 계획입니다. 몇 가지 예시를 보겠습니다:
assigned:jane@email.com context.user.id=100
resolved:false ignored:false occurred:[
params.article.title:"Starr's parser post" foo:'ba
저는 이런 문자열들을 다음 조건에 맞게 토큰화해야 했습니다:
- 공백이 토큰을 구분합니다. 단, '', "" 또는 []로 감싸인 경우는 예외입니다
- 따옴표로 묶이지 않은 공백 자체도 하나의 토큰입니다
tokens.join("")을 실행하면 원본 문자열을 정확히 재현할 수 있습니다
예를 들면 다음과 같습니다:
tokenize(%[params.article.title:"Starr's parser post" foo:'ba])
=> ["params.article.title:\"Starr's parser post\"", " ", "foo:'ba"]
정규식으로 접근하기
처음 든 생각은 캡처 그룹이 있는 정규식으로 유효한 토큰의 형태를 정의한 뒤, String#split으로 문자열을 토큰 단위로 나누는 것이었습니다. 사실 꽤 멋진 트릭입니다:
# 정규식의 괄호 덕분에 구분자도 배열에 포함됩니다
"foo bar baz".split(/(foo|bar|baz)/)
=> ["", "foo", " ", "bar", " ", "baz"]
이상한 빈 문자열들이 섞여 있긴 했지만, 처음에는 유망해 보였습니다. 그러나 실제 환경에서 필요한 정규식은 훨씬 더 복잡했습니다. 제 첫 번째 초안은 다음과 같았습니다:
/
( # split이 매칭/비매칭 문자열을 모두 포함하도록 캡처 그룹 사용
(?: # 키의 첫 문자로,
(?!\s)[^:\s"'\[]{1} # ..앞에 공백이 없는 유효한 "키" 문자
|^[^:\s"'\[]{0,1} # ..또는 줄 시작 위치의 유효한 "키" 문자
)
[^:\s"'\[]* # 나머지 "키" 문자들
: # 콜론
(?: # 값 문자들로,
'[^']+' # ..작은따옴표로 감싸인 임의의 문자열
| "[^"]+" # ..또는 큰따옴표로 감싸인 임의의 문자열
| \[\S+\sTO\s\S+\] # ..또는 [x TO y] 형태
| [^\s"'\[]+ # ..또는 공백·특수문자를 포함하지 않는 문자열
)
)
/xi
이것을 다루면서 점점 불길한 예감이 들었습니다. 엣지 케이스를 발견할 때마다 정규식을 수정해야 했고, 수정할수록 식은 더욱 복잡해졌습니다. 게다가 Ruby뿐 아니라 JavaScript에서도 동작해야 했기 때문에 부정형 후방탐색(negative lookbehind) 같은 일부 기능은 아예 사용할 수 없었습니다.
...바로 그 무렵, 이 모든 것이 얼마나 우스운 일인지 깨달았습니다. 제가 사용하던 정규식 접근 방식은 처음부터 간단한 파서를 직접 작성하는 것보다 훨씬 복잡했던 것입니다.
파서의 구조
저는 전문가가 아니지만, 간단한 파서는 정말 간단합니다. 하는 일이라곤 다음이 전부입니다:
- 문자열을 한 글자씩 순회합니다
- 각 문자를 버퍼에 추가합니다
- 토큰 구분 조건을 만나면 버퍼 내용을 배열에 저장하고 비웁니다
이 원리를 알면 공백으로 문자열을 분리하는 간단한 파서를 만들 수 있습니다. 대략 "foo bar".split(/(\s+)/)와 동일한 동작입니다.
class Parser
WHITESPACE = /\s/
NON_WHITESPACE = /\S/
def initialize
@buffer = []
@output = []
end
def parse(text)
text.each_char do |c|
case c
when WHITESPACE
flush if previous.match(NON_WHITESPACE)
@buffer << c
else
flush if previous.match(WHITESPACE)
@buffer << c
end
end
flush
@output
end
protected
def flush
if @buffer.any?
@output << @buffer.join("")
@buffer = []
end
end
def previous
@buffer.last || ""
end
end
puts Parser.new().parse("foo bar baz").inspect
# 출력 결과: ["foo", " ", "bar", " ", "baz"]
원하는 방향으로 한 걸음 나아갔지만, 아직 따옴표와 대괄호 지원이 빠져 있습니다. 다행히 여기에 추가하는 데는 몇 줄이면 충분합니다:
def parse(text)
surround = nil
text.each_char do |c|
case c
when WHITESPACE
flush if previous.match(NON_WHITESPACE) && !surround
@buffer << c
when '"', "'"
@buffer << c
if !surround
surround = c
elsif surround == c
flush
surround = nil
end
when "["
@buffer << c
surround = c if !surround
when "]"
@buffer << c
if surround == "["
flush
surround = nil
end
else
flush() if previous().match(WHITESPACE) && !surround
@buffer << c
end
end
flush
@output
end
이 코드는 정규식 기반 접근 방식보다 약간 길지만, 훨씬 더 명확하고 직관적입니다.
마치며
제 사용 사례에 딱 맞는 정규식이 어딘가에 이미 존재할 가능성이 높습니다. 과거의 경험이 말해주듯, 아마 제가 바보 같아 보일 만큼 간단한 정규식일 겁니다. :)
그래도 이 작은 파서를 직접 작성할 기회가 정말 즐거웠습니다. 정규식 접근 방식에 빠져 있던 고착 상태에서 벗어날 수 있었고, 보너스로 복잡한 정규식 기반 코드를 다룰 때보다 결과물에 대한 확신이 훨씬 크다는 점도 얻었습니다.