Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 정규 표현식에서 조건문 활용하기: 그룹 캡처부터 룩어라운드 트릭까지

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+ 하나 이상의 숫자를 매칭

꽤 깔끔하죠?