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

Ruby의 숨겨진 보석, StringScanner 완벽 가이드

Ruby는 재미있는 언어일 뿐만 아니라 훌륭한 표준 라이브러리도 함께 제공합니다. 그중 일부는 잘 알려지지 않아 마치 숨겨진 보석과 같습니다. 오늘은 게스트 작가 Michael Kohl이 자신이 특히 좋아하는 기능 중 하나인 StringScanner를 소개합니다.

Ruby의 숨겨진 보석: StringScanner

OpenStruct나 Set 같은 자료구조부터 CSV 파싱, 벤치마킹까지, 서드파티 젬(gem)을 설치하지 않고도 꽤 많은 작업을 처리할 수 있습니다. 하지만 Ruby의 기본 설치에 포함되어 있음에도 불구하고 잘 알려지지 않아 매우 유용하게 활용할 수 있는 라이브러리들이 있습니다. 그중 하나가 바로 공식 문서에서 '문자열에 대한 어휘 스캐닝 작업을 제공한다'고 설명된 StringScanner입니다.

스캐닝과 파싱이란?

그렇다면 '어휘 스캐닝'은 정확히 무엇을 의미할까요? 본질적으로 이는 입력 문자열에서 특정 규칙에 따라 의미 있는 정보 조각들을 추출하는 과정을 말합니다. 예를 들어 컴파일러의 첫 번째 단계에서는 2 + 1 같은 표현식을 입력받아 다음과 같은 토큰 시퀀스로 변환합니다:

[{ number: "2" }, {operator: "+"}, { number: "1"}]

어휘 스캐너는 보통 유한 상태 오토마타(finite-state automata)로 구현되며, ANTLR이나 Ragel처럼 이를 자동으로 생성해 주는 잘 알려진 도구들도 여럿 존재합니다.

하지만 때로는 파싱 요구 사항이 그렇게까지 복잡하지 않은 경우도 많습니다. 이런 상황에서는 정규 표현식 기반의 간단한 라이브러리인 StringScanner가 매우 유용합니다. 이 라이브러리는 문자열 내 인덱스에 불과한 스캔 포인터(scan pointer)의 위치를 기억하는 방식으로 동작합니다. 스캔 과정에서는 스캔 포인터 바로 뒤의 내용이 주어진 표현식과 일치하는지 검사합니다. 매칭 연산 외에도 StringScanner는 스캔 포인터를 앞뒤로 이동시키는 메서드, 포인터를 움직이지 않고 다음 내용을 미리 확인하는 미리보기(look-ahead) 메서드, 그리고 현재 위치가 줄의 시작인지 끝인지, 전체 문자열의 끝인지 등을 확인하는 메서드들도 제공합니다.

Rails 로그 파싱하기

이론은 여기까지 하고, StringScanner가 실제로 어떻게 동작하는지 살펴보겠습니다. 다음 예제는 아래와 같은 Rails 로그 엔트리를

log_entry = <<EOS
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
Processing by HomeController#index as HTML
  Rendered text template within layouts/application (0.0ms)
  Rendered layouts/_assets.html.erb (2.0ms)
  Rendered layouts/_top.html.erb (2.6ms)
  Rendered layouts/_about.html.erb (0.3ms)
  Rendered layouts/_google_analytics.html.erb (0.4ms)
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
EOS

다음과 같은 해시로 파싱합니다:

{
  method: "GET",
  path: "/",
  ip: "127.0.0.1",
  timestamp: "2017-08-20 20:53:10 +0900",
  success: true,
  response_code: "200",
  duration: "79ms",
}

참고: 이 예제는 StringScanner의 활용법을 보여주기에는 좋지만, 실제 애플리케이션에서는 Lograge와 JSON 로그 포매터를 사용하는 편이 더 좋습니다.

StringScanner를 사용하려면 먼저 require 구문이 필요합니다:

require 'strscan'

이후 생성자에 로그 엔트리를 인자로 전달하여 새 인스턴스를 초기화할 수 있습니다. 동시에 파싱 결과를 담을 빈 해시도 정의합니다:

scanner = StringScanner.new(log_entry)
log = {}

이제 스캐너의 pos 메서드로 스캔 포인터의 현재 위치를 확인할 수 있습니다. 예상대로 결과는 문자열의 첫 번째 문자를 가리키는 0입니다:

scanner.pos #=> 0

과정을 쉽게 따라갈 수 있도록 시각화해 보겠습니다:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)

스캐너의 상태를 더 자세히 살펴보기 위해 beginning_of_line?eos? 메서드를 사용하면, 스캔 포인터가 현재 줄의 시작에 있으며 아직 입력을 모두 소비하지 않았다는 것을 확인할 수 있습니다:

scanner.beginning_of_line? #=> true
scanner.eos? #=> false

먼저 추출할 정보는 HTTP 요청 메서드입니다. 이는 'Started'라는 단어와 공백 바로 뒤에 위치합니다. 스캐너의 skip 메서드를 사용해 스캔 포인터를 앞으로 이동시키면, 무시된 문자 수가 반환되는데 이 경우 8입니다. 또한 matched? 메서드로 모든 것이 예상대로 동작했는지 확인할 수 있습니다:

scanner.skip(/Started /) #=> 8
scanner.matched? #=> true

이제 스캔 포인터는 요청 메서드 바로 앞에 위치합니다:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
       ^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)

이제 scan_until을 사용해 실제 값을 추출할 수 있습니다. 이 메서드는 정규 표현식에 일치하는 전체 문자열을 반환합니다. 요청 메서드는 모두 대문자이므로, 하나 이상의 문자에 일치하는 간단한 문자 클래스와 + 연산자를 활용할 수 있습니다:

log[:method] = scanner.scan_until(/[A-Z]+/) #=> "GET"

이 연산 후 스캔 포인터는 'GET'이라는 단어의 마지막 'T'에 위치합니다.

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
          ^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)

다음으로 요청된 경로를 추출하려면 공백 하나를 건너뛴 뒤, 큰따옴표로 둘러싸인 모든 내용을 추출해야 합니다. 이를 수행하는 방법은 여러 가지가 있는데, 그중 하나는 임의의 문자 하나 이상에 일치하는 캡처 그룹(괄호로 묶인 정규 표현식 부분, 즉 (.+))을 사용하는 것입니다:

scanner.scan(/\s"(.+)"/) #=> " \"/\""

하지만 이 scan 연산의 반환값을 직접 사용하지 않고, 대신 captures를 사용해 첫 번째 캡처 그룹의 값을 가져옵니다:

log[:path] = scanner.captures.first #=> "/"

경로를 성공적으로 추출했고, 스캔 포인터는 이제 닫는 큰따옴표에 위치합니다:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
              ^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)

로그에서 IP 주소를 파싱하려면 다시 한번 skip을 사용해 공백으로 둘러싸인 'for' 문자열을 건너뛴 후, scan_until로 공백이 아닌 문자 하나 이상에 일치시킵니다(\s는 공백을 나타내는 문자 클래스이며, [^\s]는 그 부정입니다):

scanner.skip(/ for /) #=> 5
log[:ip] = scanner.scan_until(/[^\s]+/) #=> "127.0.0.1"

스캔 포인터가 지금 어디에 있을지 짐작할 수 있나요? 잠시 생각해 본 뒤 정답과 비교해 보세요:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
                            ^
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)

타임스탬프 파싱은 이쯤 되면 익숙하게 느껴질 것입니다. 먼저 믿음직한 skip으로 리터럴 문자열 ' at '을 건너뛴 후, scan_until을 사용해 현재 줄의 끝까지 읽어냅니다. 정규 표현식에서 줄의 끝은 $로 표현합니다:

scanner.skip(/ at /) #=> 4
log[:timestamp] = scanner.scan_until(/$/) #=> "2017-08-20 20:53:10 +0900"

다음으로 관심 있는 정보는 마지막 줄의 HTTP 상태 코드입니다. 따라서 skip_until을 사용해 'Completed'라는 단어 뒤의 공백까지 한 번에 이동합니다.

scanner.skip_until(/Completed /) #=> 296

이름에서 알 수 있듯이 이 메서드는 scan_until과 비슷하게 동작하지만, 일치한 문자열 대신 건너뛴 문자 수를 반환합니다. 이로써 스캔 포인터는 우리가 원하는 HTTP 상태 코드 바로 앞에 위치하게 됩니다.

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
         ^

이제 실제 HTTP 응답 코드를 스캔하기 전에, 해당 코드가 성공(이 예제에서는 2xx 범위의 코드)을 의미하는지 실패(그 외 모든 범위)를 의미하는지 판단할 수 있다면 좋지 않을까요? 이를 위해 peek 메서드를 사용해 스캔 포인터를 실제로 이동시키지 않고 다음 문자를 확인할 수 있습니다.

log[:success] = scanner.peek(1) == "2" #=> true

이제 scan을 사용해 정규 표현식 /\d{3}/으로 표현되는 세 개의 문자를 읽을 수 있습니다:

log[:response_code] = scanner.scan(/\d{3}/) #=> "200"

스캔 포인터는 다시 한번 방금 일치한 정규 표현식의 끝에 위치합니다:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900
...
Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms)
            ^

로그 엔트리에서 마지막으로 추출할 정보는 밀리초 단위의 실행 시간입니다. 이는 ' OK in ' 문자열을 skip으로 건너뛴 후, 리터럴 문자열 'ms'까지 포함하여 모두 읽어내면 됩니다.

scanner.skip(/ OK in /) #=> 7
log[:duration] = scanner.scan_until(/ms/) #=> "79ms"

마지막 정보까지 채워졌으니, 우리가 원하던 해시를 완성했습니다.

{
  method: "GET",
  path: "/",
  ip: "127.0.0.1",
  timestamp: "2017-08-20 20:53:10 +0900",
  success: true,
  response_code: "200",
  duration: "79ms",
}

마무리

Ruby의 StringScanner는 단순한 정규 표현식과 완전한 형태의 렉서(lexer) 사이에서 적절한 중간 지점을 차지합니다. 복잡한 스캐닝 및 파싱 요구 사항에는 최선의 선택이 아닐 수 있습니다. 하지만 그 단순한 특성 덕분에 기본적인 정규 표현식 지식만 있다면 누구나 입력 문자열에서 손쉽게 정보를 추출할 수 있으며, 필자 역시 과거 프로덕션 코드에서 성공적으로 활용한 경험이 있습니다. 여러분도 이 숨겨진 보석을 발견하게 되기를 바랍니다.

PS: 다음에 소개했으면 하는 숨겨진 보석이 있다면 의견을 남겨주세요!