2013년 Ruby 2.0이 출시하며 선보인 수많은 새로운 기능 중에서, 제가 가장 주목하지 않았던 것은 바로 새로운 정규 표현식 엔진인 Onigmo였습니다. 어차피 정규 표현식은 정규 표현식이니까요 — 루비가 이를 어떻게 구현했는지까지 신경 쓸 필요가 있을까요?
그런데 알고 보니 Onigmo 정규식 엔진에는 몇 가지 멋진 기술이 숨겨져 있었고, 그중 하나가 바로 정규 표현식 안에서 조건문을 사용할 수 있는 기능입니다.
이 글에서는 정규식 조건문에 대해 자세히 알아보고, 루비 구현체만의 특수한 동작 방식을 살펴본 뒤, 루비의 한계를 우회하는 몇 가지 트릭까지 함께 다뤄보겠습니다. 시작해 볼까요!
그룹과 캡처
정규 표현식의 조건문을 이해하려면 먼저 그룹화(grouping)와 캡처(capture) 개념을 알아야 합니다.
다음과 같은 미국 도시 목록이 있다고 상상해 보세요:
Fayetteville, AR
Seattle, WA
여기서 도시 이름과 주(州) 약자를 분리하고 싶습니다. 한 가지 방법은 여러 번 매칭을 수행하는 것입니다:
PLACE = "Fayetteville, AR"
# City: 쉼표가 아닌 문자를 매칭
PLACE.match(/[^]+/)
# => #<MatchData "Fayetteville">
# Separator: 쉼표와 선택적 공백을 매칭
PLACE.match(/, */)
# => #<MatchData ", ">
# State: 문자열 끝의 두 자리 코드를 매칭
PLACE.match(/[A-Z]{2}$/)
# => #<MatchData "AR">
동작은 하지만 너무 장황합니다. 그룹을 사용하면 단 하나의 정규 표현식으로 도시와 주를 모두 캡처할 수 있습니다.
위의 정규식들을 하나로 합치고 각 부분을 괄호로 감싸 보겠습니다. 괄호는 정규 표현식에서 요소를 묶는 방법입니다.
PLACE = "Fayetteville, AR"
m = PLACE.match(/([^]+)(, *)([A-Z]{2})/)
# => #<MatchData "Fayetteville, AR" 1:"Fayetteville" 2:", " 3:"AR">
위 표현식은 도시와 주를 모두 캡처합니다. MatchData를 배열처럼 다루어 결과에 접근할 수 있습니다:
m[1]
# => "Fayetteville"
m[3]
# => "AR"
하지만 위처럼 그룹화를 하면 캡처된 데이터가 배열에 담긴다는 문제가 있습니다. 배열에서 위치가 바뀌면 코드를 수정해야 하고, 그렇지 않으면 곧바로 버그가 발생합니다.
예를 들어 ", " 문자까지 캡처하는 건 불필요하다고 판단했다고 해봅시다. 그래서 해당 부분의 괄호를 제거합니다:
m = PLACE.match(/([^]+), *([A-Z]{2})/)
# => #<MatchData "Fayetteville, AR" 1:"Fayetteville" 2:"AR">
m[3]
# => nil
이제 m[3]에는 더 이상 주(state) 정보가 들어 있지 않습니다 — 버그 발생!
이름 있는 그룹(Named Groups)
정규 표현식 그룹에 이름을 붙이면 훨씬 더 의미론적으로 만들 수 있습니다. 문법은 방금 사용한 것과 비슷합니다. 정규식을 괄호로 감싸고 아래처럼 이름을 지정하면 됩니다:
/(?<groupname>regex)/
도시/주 정규식에 적용하면 다음과 같습니다:
m = PLACE.match(/(?<city>[^]+), *(?<state>[A-Z]{2})/)
# => #<MatchData "Fayetteville, AR" city:"Fayetteville" state:"AR">
캡처된 데이터는 MatchData를 해시처럼 다루어 접근할 수 있습니다:
m[:city]
# => "Fayetteville"
조건문
정규 표현식의 조건문은 /(?(A)X|Y)/ 형태를 가집니다. 몇 가지 유효한 사용법은 다음과 같습니다:
# A가 참이면 X를 평가하고, 아니면 Y를 평가
/(?(A)X|Y)/
# A가 참이면 X
/(?(A)X)/
# A가 거짓이면 Y
/(?(A)|Y)/
조건 A로 가장 많이 쓰이는 두 가지 옵션은 다음과 같습니다:
- 특정 이름 또는 번호의 그룹이 캡처되었는가?
- 룩어라운드(look-around)가 참으로 평가되는가?
실제 사용 예를 살펴보겠습니다.
그룹이 캡처되었는가?
그룹의 존재 여부를 확인하려면 ?(n) 문법을 사용합니다. 여기서 n은 정수이거나 <> 또는 ''로 감싼 그룹 이름입니다.
# 1번 그룹이 캡처되었는가?
/(?(1)foo|bar)/
# "mygroup"이라는 이름의 그룹이 캡처되었는가?
/(?(<mygroup>)foo|bar)/
예제
미국 전화번호를 파싱한다고 상상해 보세요. 이 번호들은 세 자리 지역번호(area code)를 가질 수 있는데, 번호가 1로 시작하는 경우에만 지역번호가 필수입니다.
1-800-555-1212 # Valid
800-555-1212 # Valid
555-1212 # Valid
1-555-1212 # INVALID!!
조건문을 활용하면 번호가 1로 시작할 때만 지역번호를 필수로 요구하도록 만들 수 있습니다.
# 이 정규식은 복잡해 보이지만 사실 단순한 조각들의 조합입니다
# `^(1-)?` 문자열이 "1-"로 시작하는가? 그렇다면 그룹 1로 캡처
# `(?(1)` 그룹 1에 무언가 캡처되었는가?
# `\d{3}-` 그렇다면 세 자리 숫자와 대시(지역번호)를 필수로 매칭
# `|(\d{3}-)?` 아니라면 세 자리 숫자와 대시(지역번호)를 선택적으로 매칭
# `\d{3}-\d{4}` 나머지 전화번호 매칭 (항상 필수)
re = /^(1-)?(?(1)\d{3}-|(\d{3}-)?)\d{3}-\d{4}/
"1-800-555-1212".match(re)
#=> #<MatchData "1-800-555-1212" 1:"1-" 2:nil>
"800-555-1212".match(re)
#=> #<MatchData "800-555-1212" 1:nil 2:"800-">
"555-1212".match(re)
#=> #<MatchData "555-1212" 1:nil 2:nil>
"1-555-1212".match(re)
=> nil
한계
그룹 기반 조건문의 한 가지 문제는 그룹 매칭 과정에서 해당 문자들이 문자열에서 "소비(consume)"된다는 점입니다. 즉, 그 문자들은 이후 조건문에서 사용할 수 없게 됩니다.
예를 들어, 다음 코드는 "USD" 텍스트가 있을 때 100을 매칭하려다 실패합니다:
"100USD".match(/(USD)(?(1)\d+)/) # nil
Perl 등 일부 언어에서는 조건문에 룩어헤드(look-ahead)를 추가할 수 있습니다. 이를 통해 문자열 어디에 있든 특정 텍스트를 기준으로 조건을 트리거할 수 있습니다. 하지만 루비에는 이런 기능이 없어서 약간 창의적인 접근이 필요합니다.
룩어라운드(Look-around)
다행히도 룩어라운드 표현식을 활용하면 루비 정규식 조건문의 한계를 우회할 수 있습니다.
룩어라운드란?
일반적으로 정규 표현식 파서는 문자열의 처음부터 끝까지 차례대로 진행하며 매칭을 찾습니다. 워드 프로세서에서 커서를 왼쪽에서 오른쪽으로 옮기는 것과 비슷합니다.
반면 룩어헤드(look-ahead)와 룩비하인드(look-behind) 표현식은 조금 다르게 작동합니다. 어떤 문자도 소비하지 않으면서 문자열을 검사할 수 있습니다. 검사가 끝나면 커서는 처음 위치 그대로 남아 있습니다.
룩어라운드에 대한 훌륭한 소개는 Rexegg의 look ahead / look behind 마스터 가이드를 참고하세요.
문법은 다음과 같습니다:
| 종류 | 문법 | 예제 |
|---|---|---|
| 룩어헤드 | (?=query) |
\d+(?= dollars)는 "100 dollars"에서 100을 매칭 |
| 부정 룩어헤드 | (?!query) |
\d+(?! dollars)는 뒤에 "dollars"가 오지 않는 경우에만 100을 매칭 |
| 룩비하인드 | (?<=query) |
(?<=lucky )\d는 "lucky 7"에서 7을 매칭 |
| 부정 룩비하인드 | (?<!query) |
(?<!furious )\d는 "lucky 7"에서 7을 매칭 |
룩어라운드를 활용해 조건문 강화하기
조건문에서는 이미 설정된 그룹의 존재 여부만 확인할 수 있습니다. 일반적으로 그룹의 내용은 이미 소비되어 조건문에서 사용할 수 없게 되죠.
하지만 룩어헤드를 사용하면 어떠한 문자도 소비하지 않으면서 그룹을 설정할 수 있습니다! 놀랍지 않나요?
아까 동작하지 않았던 코드를 기억하시나요?
"100USD".match(/(USD)(?(1)\d+)/) # nil
룩어헤드 안에서 그룹을 캡처하도록 수정하면 갑자기 잘 동작합니다:
"100USD".match(/(?=.*(USD))(?(1)\d+)/)
=> #<MatchData "100" 1:"USD">
쿼리를 분해해서 무슨 일이 일어나는지 살펴보겠습니다:
(?=.*(USD))룩어헤드로 텍스트를 스캔하여 "USD"를 찾고 그룹 1에 캡처(?(1)그룹 1이 존재한다면\d+하나 이상의 숫자를 매칭
꽤 깔끔하죠?