메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

IT/모바일

Lexing Your Data

한빛미디어

|

2007-12-13

|

by HANBIT

9,724

제공 : 한빛 네트워크
저자 : Curtis Poe
역자 : 김도형
원문 : Lexing Your Data

s/(?

우리들 중 대부분은 HTML 을 파싱한다거나, 코드를 난처하게(obfuscating) 한다거나, 접시를 닦는다거나 기타 등등 처럼 사용하지 말아야할 곳에 정규표현식을 사용한 적이 한 번 이상 있을 것입니다. 이것은 기술적인 용어로 뽐내기를 말합니다. 나 역시 그랬었습니다:
$html =~ s{
            (](?!href))*hrefs*)
            (&(&[^;]+;)?(?:.(?!3))+(?:3)?)
            ([^>]+>)
        }
        {$1 . decode_entities($2) .  $4}gsexi;
나는 코드를 쓸때면 공작처럼 으스대다가, 실행을 시킬때면 곧 잘못을 인정해야 했습니다. 나는 한번도 제대로 동작하는 것을 볼 수 없었습니다. 아직까지도 내가 무엇을 하려고 했던 것인지 확신할 수 없습니다. 저 정규 표현식으로 인해 나는 HTML::TokeParser 모듈을 사용하는 법을 익히게 되었습니다. 더욱 중요한 것은, 저 정규표현식으로 인해 정규표현식이 얼마나 어려운 것인지 알게되었다는 것입니다.

정규 표현식을 쓸 때의 문제

이 정규 표현식을 다시 한번 보세요:
/(](?!href))*hrefs*)(&(&[^;]+;)?(?:.(?!3))+(?:3)?)([^>]+>)/
여러분은 저것이 매치하는 것을 알 수 있습니까? 정확하게? 정말로요? 심지어 저것이 동작한다고 하더라도, 여러분은 쉽게 고칠 수 있을까요? 만약 여러분이 저것이 동작하는 것을 모른다면(그리고 공정하게 말하면, 그것은 깨어진 것임을 잊지 마세요), 그것을 이해하는데 얼마나 오랜 시간을 소요해야 할까요? 단 한줄의 코드로 여러분의 요구에 딱 맞아 떨어졌던 적이 언제가 마지막이었나요?

물론, 문제는 이런 정규표현식은 한 줄의 코드가 동작하는 것에 비해 많은 것을 처리하려 한다는 것입니다. 이런 정규표현식을 맞닥뜨리게되면, 나는 다음 몇 가지 방식을 사용합니다.
  • 신중하게 문서화 하기
  • /x 스위치를 사용해서 여러줄에 걸쳐서 표현하기
  • 가능하다면 함수안에서 처리할 수 있도록 감싸기
하지만, 때로는 네 번째 옵션도 있습니다: 렉싱(lexing) 입니다.

렉싱(Lexing)

코드를 개발하다보면, 우리는 전형적으로 문제에 부딪히고, 그것을 쉽게 풀기위해 일련의 작은 문제로 나누곤 합니다. 정규 표현식은 코드입니다. 그리고 여러분은 쉽게 해결하기 위해서 그것을 일련의 작은 문제들로 나눌 수 있습니다. 이것을 쉽게 하기 위한 하나의 기법은 렉싱을 사용하는 것입니다.

렉싱은 토큰을 분리하고, 각각의 토큰에 의미를 부여하는 작업입니다. 좀 애매한 설명이긴 하지만, 기본적인 것을 다루기엔 충분할 것입니다.

보통 렉싱이 끝나면 토큰들을 좀 더 유용하게 바꾸기 위해서 파싱을 합니다. 파싱은 보통 렉싱이 끝난 토큰에 잘 정의된 문법을 적용하는 몇 가지 도구들의 영역입니다.

때로는 정보를 추출하거나 보고(reporting)하기엔 잘 정의된 문법이 실용적이지 않습니다. 회사의 임시 변통적인(ad-hoc) 로그 파일 포맷에는 문법이 없을 수도 있습니다. 어떤 때는 여러분은 문법을 쓰기 위해서 시간을 들이는 것보다 토큰을 직접 수동으로 처리하는 것이 쉽다는 것을 깨달을 것입니다. 여전히 어떤 때는 렉싱한 모든 데이터가 아니라 일부에만 관심을 기울일 때도 있을 것입니다. 어떤 문제는 이러한 이유 세 개 모두가 적용 됩니다.

SQL 파싱하기

펄 몽크스(Perlmonks) (parse a query string) 에서는, 다음과 같은 SQL 구문을 파싱하는 방법에 대한 질문이 올라온 적이 있었습니다:
select the_date as "date",
round(months_between(first_date,second_date),0) months_old
,product,extract(year from the_date) year
,case
    when a=b then "c"
    else "d"
    end tough_one
from ...
where ...
글을 올린 사람은 위의 SQL 에서 각각의 칼럼의 별칭(alias)을 추려내고 싶어했습니다. 이 경우 date, months_old, product, year, tough_one가 별칭(alias) 입니다. 물론 이것은 단지 하나의 예제에 불과했습니다. 실제로 많은 양의 생성된 SQL의 컬럼 별칭(alias)이 조금씩 미묘하게 변형되어 있었기 때문에, 이것을 처리하는 것은 간단한 일이 아닙니다. 그렇지만 여기서 흥미로운 점은 우리는 컬럼 별칭 말고는 무엇도 필요하지 않다는 것입니다. 이 글의 나머지 부분에서는 별칭을 찾기 위한 내용을 설명합니다.

여러분은 첫 번째로 이것을 파싱하기 위해 SQL::Statement 모듈을 이용하는 것을 떠올릴 것입니다. 밝혀지겠지만 이 모듈은 CASE 문을 처리할 수 없습니다. 그러므로 여러분은 어떻게 SQL::Statement 모듈을 패치할지 알아야하고, 패치를 제출하고 그것이 승인되서 늦지 않게 릴리즈 되기를 바래야 합니다. (SQL::Statement 모듈은 SQL::Parser 모듈을 사용하기 때문에 후자 역시 옵션이 아닙니다.)

두 번째로, 우리 대부분은 제작하는 중에 문제를 해결해야 하는 환경에서 일해왔습니다. 그러나 만약 그것들이 완전히 인가되야 하는 상황에 도달한다면, 앞으로도 필요한 모듈 설치를 위해 3 주를 기다려야 합니다.

비록 SQL::Statement 모듈이 이 문제를 다룰 수 있다할지라도 만약 여러분이 렉서(lexer) 대신 SQL::Statement 모듈을 사용했다면 이 문서는 너무 짧아진다는 것이 가장 큰 이유입니다. :)

렉싱의 기본

앞서 언급한 것 처럼, 렉싱은 본질적으로 데이터를 분석하고, 다루기 쉬운 토큰의 연속으로 분리하는 작업입니다. 데이터가 다른 형태일 수도 있지만, 보통 이것은 문자열을 분석하는 것을 의미합니다. 명백한 예제를 위해 다음 표현을 생각해보겠습니다:
x = (3 + 2) / y
렉싱이 끝나면 여러분은 다음과 같은 일련의 토큰을 얻을 것입니다:
my @tokens = (
    [ OP  => "x" ],
    [ OP  => "=" ],
    [ OP  => "(" ],
    [ INT => "3" ],
    [ VAR => "+" ],
    [ INT => "2" ],
    [ OP  => ")" ],
    [ OP  => "/" ],
    [ VAR => "y" ],
);
여러분에게 적절한 문법이 있다면, 어쩌면 간단한 언어의 인터프리터를 만든다거나, 이 코드를 다른 프로그래밍 언어로 변환하기 위해 이 일련의 토큰을 읽을 수 있고, 토큰의 값을 기초로 동작 시킬 수도 있을 것입니다.

문법이 없다고 할지라도, 여러분은 이 토큰이 유용하다는 것을 알 것입니다.

토큰 식별하기

렉서를 만들 때 첫 번째 작업은 파싱을 하고 싶은 토큰을 식별하는 일입니다. SQL 을 한번 더 보겠습니다:
select the_date as "date",
round(months_between(first_date,second_date),0) months_old
,product,extract(year from the_date) year
,case
    when a=b then "c"
        else "d"
    end tough_one
from ...
where ...
from 키워드 다음은 어떤 것도 신경 쓸 필요가 없습니다. 이렇게 보면, 쉼표나 from 키워드 바로 앞의 것에만 집중하면 됩니다. 그러나 함수의 괄호안에도 쉼표가 있기 때문에 쉼표를 기준으로 분리하는 것으로는 충분하지 않습니다.

첫 번째로 할 일은 간단한 정규 표현식을 이용해서 일치(match)를 시킬 수 있는 여러가지 것들을 식별하는 것입니다.

이러한 것들은 괄호와, 쉼표, 연산자, 키워드, 무작위의 텍스트 입니다. 첫 번째 처리를 표현하면 이럴 것입니다:
my $lparen  = qr/(/;
my $rparen  = qr/)/;
my $keyword = qr/(?i:select|from|as)/; # 이 문제에서 필요로 하는 키워드 전부
my $comma   = qr/,/;
my $text    = qr/(?:w+|"w+"|"w+")/;
my $op      = qr{[-=+*/<>]};
텍스트 매치는 무언가 부족해 보일 것입니다. 아마 여러분은 정규표현식 처리를 위해 Regexp::Common 모듈을 사용하고 싶겠지만 지금은 단순하게 그대로 두겠습니다.

연산자(operator)는 약간 복잡해 보입니다. 어떤 SQL 은 수학 명령문을 포함하고 있다고 가정하겠습니다.

이제 실제로 렉서를 만들어 보겠습니다. 렉서를 만드는 방법중의 하나는 여러분이 직접 만드는 것입니다. 그것은 이런 형태를 가질 것입니다:
sub lexer {
    my $sql = shift;
    return sub {
        LEXER: {
            return ["KEYWORD", $1] if $sql =~ /G ($keyword) /gcx;
            return ["COMMA",   ""] if $sql =~ /G ($comma)   /gcx;
            return ["OP",      $1] if $sql =~ /G ($op)      /gcx;
            return ["PAREN",    1] if $sql =~ /G $lparen    /gcx;
            return ["PAREN",   -1] if $sql =~ /G $rparen    /gcx;
            return ["TEXT",    $1] if $sql =~ /G ($text)    /gcx;
            redo LEXER             if $sql =~ /G s+        /gcx;
        }
    };
}

my $lexer = lexer($sql);

while (defined (my $token = $lexer->())) {
    # 토큰을 이용해 작업하기
}
이것이 어떻게 동작하는지 자세히 설명하지 않아도, 이것이 최고의 해답은 아니라는 것을 말하는 것은 당연합니다. 원래의 펄 몽크스 글타래를 통해, 여러분은 원하는 것을 추출하기 위해서 데이터를 두 번 처리(two pass) 해야한다는 것을 알것입니다. 독자들의 연습을 위해 이 부분의 설명은 하지 않겠습니다.

이것을 간단하게 만들기 위해서 CPAN의 HOP::Lexer 모듈을 사용합니다. Mark Jason Dominus 의 책 Higher Order Perl 에서 설명하는 이 모듈을 이용하면 렉서를 만들때 마다 필요한 반복적인 작업들을 생략할 수 있고, 위의 예제보다 조금 더 강력한 렉서를 만들수 있습니다. 새로운 코드는 다음과 같습니다:
use HOP::Lexer "make_lexer";
my @sql   = $sql;
my $lexer = make_lexer(
    sub { shift @sql },
    [ "KEYWORD", qr/(?i:select|from|as)/          ],
    [ "COMMA",   qr/,/                            ],
    [ "OP",      qr{[-=+*/]}                      ],
    [ "PAREN",   qr/(/,      sub { [shift,  1] } ],
    [ "PAREN",   qr/)/,      sub { [shift, -1] } ],
    [ "TEXT",    qr/(?:w+|"w+"|"w+")/, &text  ],
    [ "SPACE",   qr/s*/,     sub {}              ],
);

sub text {
    my ($label, $value) = @_;
    $value =~ s/^[""]//;
    $value =~ s/[""]$//;
    return [ $label, $value ];
}
이것은 확실히 읽기에 전혀 쉬워 보이지 않지만, 일단 조금만 기다려 보세요.

make_lexer 함수의 첫 번째 인자는 매 호출시 마다 일치 작업을 하기위한 텍스트를 반환하는 반복자(iterator) 입니다. 지금의 경우, 여러분은 일치 작업을 하기 위한 하나의 텍스트 조각을 가지고 있을 뿐이므로 단지 배열에서 하나를 빼내면(shift) 됩니다. 만약에 여러분이 로그 파일에서 줄을 읽어야 한다면, 반복자는 제법 편리할 것입니다.

첫 번째 인자 다음의 인자는 배열 레퍼런스의 리스트 입니다. 각각의 레퍼런스 두 개의 필수 인자와 하나의 선택 가능한 인자를 가집니다:
[ $label, $pattern, $optional_subroutine ]
$label 은 토큰의 이름입니다. $pattern 은 레이블이 구분하는 것을 일치시킵니다. 세 번째 인자인 $optional_subroutine 은 함수 레퍼런스 입니다. 함수 레퍼런스는 레이블과 레이블이 매치시킨 텍스트를 인자로 받은 후, 여러분이 토큰으로써 원하는 것을 토큰 이름과 실제 토큰의 값 쌍의 형태로 반환합니다.

어떻게 make_lexer 함수를 사용할 수 있을지 생각해보겠습니다.
[ "KEYWORD", qr/(?i:select|from|as)/ ],
토큰을 만들기 전에 데이터를 변화시키는 예제는 다음과 같습니다:
[ "TEXT", qr/(?:w+|"w+"|"w+")/, &text ],
앞서 언급한 것 처럼, 정규표현식이 좀 부족해보이지만, 일단은 그대로 두고 &text 함수에 집중하겠습니다:
sub text {
    my ($label, $value) = @_;
    $value =~ s/^[""]//;
    $value =~ s/[""]$//;
    return [ $label, $value ];
}
이것은, 레이블과 그 값을 취한 다음, 값에서 처음과 끝에 나오는 인용 부호를 제거하고, 레이블과 값을 배열 레퍼런스로 반환 하는 것을 의미합니다.

단순히 아무것도 반환하지 않으면 신경쓰지 않는 공백 문자를 제거할 수 있습니다:
[ "SPACE", qr/s*/, sub {} ],
이제 여러분은 여러분의 렉서를 가지게 되었습니다. 동작하도록 해보겠습니다. 컬럼 별칭(alias)은 괄호 안에 있는 TEXT 가 아니라 쉼표나 from 키워드 바로 앞의 TEXT 임을 기억해야 합니다. 어떻게 괄호 안에 있음을 알 수 있을까요? 약간의 꼼수를 사용해보겠습니다:
[ "PAREN", qr/(/, sub { [shift,  1] } ],
[ "PAREN", qr/)/, sub { [shift, -1] } ],
이렇게 하면, 여는 괄호를 만나면 1을 더하고, 닫는 괄호를 만나면 1을 뺍니다. 결과 값이 0일 때면, 여러분은 괄호 밖에 있음을 알 수 있습니다.

토큰을 얻어오기 위해서 $lexer 반복자를 계속해서 호출합니다.
while ( defined (my $token = $lexer->() ) { ... }
토큰은 이렇게 보일 것입니다:
[ "KEYWORD",      "select" ]
[ "TEXT",       "the_date" ]
[ "KEYWORD",          "as" ]
[ "TEXT",           "date" ]
[ "COMMA",             "," ]
[ "TEXT",          "round" ]
[ "PAREN",               1 ]
[ "TEXT", "months_between" ]
[ "PAREN",               1 ]
그 이후도 마찬가지 입니다.

토큰을 처리하는 방법은 다음과 같습니다:
01:  my $inside_parens = 0;
02:  while ( defined (my $token = $lexer->()) ) {
03:      my ($label, $value) = @$token;
04:      $inside_parens += $value if "PAREN" eq $label;
05:      next if $inside_parens || "TEXT" ne $label;
06:      if (defined (my $next = $lexer->("peek"))) {
07:          my ($next_label, $next_value) = @$next;
08:          if ("COMMA" eq $next_label) {
09:              print "$valuen";
10:          }
11:          elsif ("KEYWORD" eq $next_label && "from" eq $next_value) {
12:              print "$valuen";
13:              last; # 끝났군요!
14:          }
15:      }
16:  }
이것은 매우 직관적이지만 몇가지 기교적인 부분이 있습니다. 각각의 토큰은 배열 레퍼런스이며 그 레퍼런스 안에는 두 개의 요소 가 있습니다. 그러므로 3 번째 줄에서 그 두 요소인 토큰 이름과 토큰 값을 명시적으로 분리합니다. 4, 5 번째 줄에서는 괄호를 처리하기 위해 앞서 말한 기교를 부립니다. 5 번째 줄에서는 또한 토큰 이름이 TEXT 가 아니면 역시 무시합니다.

6번째 줄은 좀 특이합니다. HOP::Lexer 모듈은 peek 문자열을 인자로 $lexer 렉서 반복자를 호출하면 반복자가 제자리에 있으면서 다음 토큰값을 반환합니다. 그 점에서 값이 기준에 맞는 컬럼 별칭 인지를 찾는 논리가 직관적입니다.

모든 것을 묶어보면 다음과 같습니다. (정규표현식을 가독성있게 보이게 하기 위해 qr// 구문을 qr{}x 로, s/// 구문을 s{}{}x 로 변경했음을 유의 하세요. 정규표현식 옵션으로 x 를 이용할 경우 정규 표현식 내의 공백 문자는 무시됩니다.):
#!/usr/bin/perl

use strict;
use warnings;
use HOP::Lexer "make_lexer";

#
# SQL 명령문을 HEREDOC을 이용해서 저장
#
my $sql = <<"END_SQL";
select the_date as "date",
round(months_between(first_date,second_date),0) months_old
,product,extract(year from the_date) year
,case
  when a=b then "c"
    else "d"
      end tough_one
      from XXX
END_SQL

#
# 렉서 생성
#
my @sql   = $sql;           # 지금은 하나의 sql을 처리하지만 그 이상도 가능
my $lexer = make_lexer(
    sub { shift @sql },     # 반복자(iterator)
    [ "KEYWORD", qr{ (?i:select|from|as) }x                      ],
    [ "COMMA",   qr{ ,                   }x                      ],
    [ "OP",      qr{ [-=+*/]             }x                      ],
    [ "PAREN",   qr{ (                  }x, sub { [shift,  1] } ],
    [ "PAREN",   qr{ )                  }x, sub { [shift, -1] } ],
    [ "TEXT",    qr{ (?:w+|"w+"|"w+") }x, &text              ],
    [ "SPACE",   qr{ s*                 }x, sub {}              ],
);

#
# TEXT 토큰 값의 앞 뒤 인용부호 제거
#
sub text {
    my ( $label, $value ) = @_;
    $value =~ s{ ^ [""]   }{}x;
    $value =~ s{   [""] $ }{}x;
    return [ $label, $value ];
}

#
# 렉서가 반환하는 토큰을 이용해서 별칭(alias)를 찾는다.
#
my $inside_parens = 0;
while ( defined ( my $token = $lexer->() ) ) {
    my ( $label, $value ) = @$token;
    $inside_parens += $value if "PAREN" eq $label;
    next if $inside_parens || "TEXT" ne $label;
    if ( defined ( my $next = $lexer->("peek") ) ) {
        my ( $next_label, $next_value ) = @$next;
        if ( "COMMA" eq $next_label ) {
            print "$valuen";
        }
        elsif ( "KEYWORD" eq $next_label && "from" eq $next_value ) {
            print "$valuen";
            last; # 끝났군요!
        }
    }
}
컬럼 별칭의 출력 결과는 다음과 같습니다:
date
months_old
product
year
tough_one
이제 여러분의 작업이 끝난 것 같나요? 아뇨. 아마도 아닐 것입니다. 이제 여러분은 첫 번째 문제에서 언급했던 많은 다른 SQL 예제가 필요할 것입니다. 아마도 &text 함수가 부족할 수도 있습니다. 아마도 여러분이 빠뜨린 다른 연산자가 있을 수도 있습니다. 아마도 SQL에 소수점이 들어간 숫자가 있을 수도 있습니다. 여러분이 직접 데이터를 렉싱해야 할 때는 실제의 데이타를 일치시키기 위해 렉서를 미세조정하는데 몇 번의 시도를 해야할 것입니다.

그리고 순서가 매우 중요함을 기억해야 합니다. &make_lexer 함수는 인자로 받는 배열 레퍼런스의 순서대로 일치 작업을 처리합니다. 만약 여러분이 TEXT 배열 레퍼런스를 KEYWORD 배열 레퍼런스보다 앞에 위치시켰다면, TEXT 정규 표현식이 keyword 까지 일치시켜서 KEYWORD 까지 일치 연산을 수행하지 않아 엉터리의 결과를 얻을 것입니다. 렉싱을 하게 된 것을 축하합니다! :-)
TAG :
댓글 입력
자료실

최근 본 책0