본문 바로가기
Homo Coding

Antlr 을 활용한 복잡한 문자열 파싱

by javauser 2011. 4. 16.
일반적으로 문장(sentence)은 여러 개의 단어(word)들로 구성된다. 또한, 단어의 순서를 조합함으로써 서로 다른 의미의 문장을 표현하게 된다. 이러한 문장을 읽어들여서 해당 의미를 인식하려면 최소한 문장을 구성하는 구조에 대해서 이해하고, 그 구조 안에 있는 단어들이 어떠한 의미로 사용되는지를 파악해야 한다.

단순한 문장의 경우, 일반적으로 문자열로 읽어들여서 하나의 구조에 맞는지를 검사하여 해당 단위 요소로 분해하여 이를 의미를 부여하면 된다. 예를 들어, SQL 구문의 경우 "SELECT column1, column2 FROM table_name"과 같은 문장은 SELECT, FROM과 같은 키워드를 중심으로 그 안에 어떤 정보가 들어가는지를 정해서 해당 문장을 읽어들여 테이블명과 컬럼명을 인식할 수 있다.

하지만, 문장은 또 다른 문장을 포함할 수 있으며, 이는 반복적인 형태로 나타날 수 있다. 마치 무한대의 반복 순환되는 문장을 읽는 recursive 함수를 호출하는 것과 같다. 이를 코딩하려고 한다면, 상당히 고려해야 할 사항들이 많아져서 이 자체가 더 많은 노력을 요할 수도 있다.

이러한 문제를 손쉽게 해결해줄 수 있는 것이 parsing 기술인데, 보통 컴파일러에서부터 발전한 형태다. 이러한 대표적인 예가 Lex와 Yacc가 있다. Lex는 Lexical Analyser의 의미로, 보통 의미가 있는 단어가 된다. SQL 구문에서는 SELECT와 FROM 등이 될 수 있으며, 만일 SQL 구문 끝에 세미콜론(';')이 붙는다면 이 역시 Lex가 된다. Yacc은 parser라고도 하며, 'Yet Another Compiler Compiler'의 약어이다. 좀 더 자세하게 말하면, Yacc은 Lex를 사용해서 parser를 생성하는 컴파일러의 컴파일러인 셈이다. UNIX나 Linux에는 Lex와 Yacc이 이미 프로그램으로 지원되며 다양한 오픈 소스들도 나와 있다.

자바에서 오픈 소스인 Antlr이 있으며, 이 Antlr이 Yacc과 같은 역할을 한다. 이 Antlr은 자체 문법을 통해서 Yacc과 Lex를 자동으로 만들어주고(자바 코드), 이를 통해 복잡한 문자열을 파싱할 수 있다. 즉, 그 문법은 복잡한 문자열이 의미하는 내용을 정의하면 된다.

다음 예제는 메이븐 프로젝트를 통해서 Antlr을 사용해 일반 자바 문법의 일부를 파싱하는 예제이다.

우선, Maven 프로젝트를 만들고, Antlr에 대한 의존관계를 다음과 같이 추가한다.

<dependencies>

   <dependency>
   <groupId>org.antlr</groupId>
   <artifactId>antlr</artifactId>
   <version>3.3</version>
   </dependency>
   <dependency>
   <groupId>junit</groupId>
   <artifactId>junit</artifactId>
   <version>4.8</version>
   <scope>test</scope>
   </dependency>
  </dependencies>


또한, Antlr에서 자동으로 만들어지는 자바코드가 위치하는 별도의 폴더를 다음과 같이 지정한다.

<build>
   <resources>
   <resource>
   <directory>src/main/java</directory>
   </resource>
   <resource>
   <directory>src/main/resources</directory>
   </resource>
   <resource>
   <directory>src/main/gen</directory>
   </resource>
   </resources>
   <testResources>
   <testResource>
   <directory>src/test/java</directory>
   </testResource>
   <testResource>
   <directory>src/test/resources</directory>
   </testResource>
   </testResources>
  </build>


위의 설정에서 Antlr에서 만들어진 자바 코드는 src/main/gen 폴더 하위에 만들어진다.

이제 Antlr을 Maven에서 실행하기 위해 메이븐 플러그인을 다음과 같이 설정한다.

<plugins>
   <plugin>
   <groupId>org.antlr</groupId>
   <artifactId>antlr3-maven-plugin</artifactId>
   <version>3.3</version>
   <executions>
   <execution>
   <goals>
   <goal>antlr</goal>
   </goals>
   <configuration>
   <conversionTimeout>10000</conversionTimeout>
   <debug>false</debug>
   <dfa>false</dfa>
   <nfa>false</nfa>
   <libDirectory>src/main/antlr3/imports</libDirectory>
   <messageFormat>antlr</messageFormat>
   <outputDirectory>src/main/gen</outputDirectory>
   <printGrammar>false</printGrammar>
   <profile>false</profile>
   <report>false</report>
   <sourceDirectory>src/main/antlr3</sourceDirectory>
   <trace>false</trace>
   <verbose>true</verbose>
   </configuration>
   </execution>
   </executions>
   </plugin>
   <plugin>
   <artifactId>maven-clean-plugin</artifactId>
   <configuration>
   <filesets>
   <fileset>
   <directory>src/main/gen</directory>
   <includes>
   <include>**/*</include>
   </includes>
   <followSymlinks>false</followSymlinks>
   </fileset>
   </filesets>
   </configuration>
   </plugin>
   </plugins>


위에서 Antlr의 문법 파일(.g 파일)이 놓여질 위치는 src/main/gen(sourceDirectory)이 되며, 자동 생성되는 자바 파일 위치는 src/main/gen(outputDirectory)가 된다.

이제 Antlr을 사용할 준비가 되었다. 본격적으로 Antlr 문법(*.g 파일)을 만들어보자. 우선 문법을 만들기 전에 무엇을 파싱할 것인지를 정해야 하는데, 간단하지만 다소 복잡한 부분인 자바 타입을 파싱해보자.

Eclipse에는 Antlr 플러그인을 다운로드받아 설치할 수 있다. 이를 활용하면 문법적인 오류나 파싱 테스티를 수행해볼 수 있다.
Helios의 경우 Eclipse Marketplace(Help > Eclipse MarketPlace)에 들어가서 Find에서 Antlr을 검색하면 ANTLR IDE가 나타난다.


이를 Eclipse 플러그인으로 설치하고 나서, Window > Preferences 창에서 ANTLR > Builder에서 Antlr 디렉토리를 설정해야 한다. (ANTLR 은 사이트에서 다운로드 받는다.)





먼저 src/main/antlr3 밑에 kr/nextree/dsm/parser/JavaType.g 파일을 만들어보자.


위의 그림에서 grammar JavaType; 은 생성될 파서 (자바 코드)의 이름을 지정하는 부분이며, @option은 생성할 언어를 지정한다. @header를 통해서 생성되는 위치(패키지)를 지정한다. 또한, Lexer와 같이 생성되기 때문에 Lexer의 패키지도 @lexer::header를 통해서 패키지 선언을 한다.

compilationUnit 이 최초에 문자열을 받아들여서 시작되는 부분이며, 이는 다시 declType으로 연결되어 있다. 또한, declType은 Identifier라는 것으로 연결되어 있다. Antlr의 문법은 이와 같이 트리와 같은 형태로 문장들을 구성하며 이를 AST (Abstract Syntax Tree)라고 한다.


위의 그림은 Lex 부분인데, 최종적으로 파싱되는 단어라고 보면 된다. 이를 Recognizer라고도 한다. Letter와 JavaIDDigit가 정의되어 있으며, .. 은 연속된 표시라고 생각하면 된다. 예를 들어, 알파벳 표현시 'a'..'z' | 'A'..'Z' 라고 표현하면 된다. 즉, 현재 Identifier라는 것은 Letter 로 시작되면 이후에 Letter 나 JavaIDDigit가 n개 붙을 수도 있다. +와 *표시를 사용해서 다수성을 표현할 수 있다.

Eclipse Antlr 플러그인에서는 Railroad View를 제공하는데 그 화면에서 Identifier를 선택하면 아래와 같은 그림이 나타난다.


위의 그림에서 좌측의 그래프를 보면 Idetifier라는 것이 어떻게 구성되는지를 한눈에 볼 수 있다.
일단 이와 같이 작성된 문법을 통해서 테스트를 하려면 Interpreter라는 화면에서 수행해 볼 수 있다.


위의 그림에서 우측 화면에 파싱할 단어를 입력하고 그 옆에 있는 녹색 실행버튼을 클릭하면 하단에 파싱 결과를 보여준다.
그럼, 이를 소스를 생성하여, 단위테스트를 통해서 확인해보자.

메이븐 실행에서 antlr3:antlr을 실행하면 소스가 자동생성된다.


위의 그림에서 src/main/gen 하위에 JavaTypeParser와 JavaTypeLexer의 두개의 자바 파일이 생성된 것을 볼 수 있다. 이를 컴파일하기 위해서 Eclipse에서 src/main/gen 디렉토리 역시 소스 디렉토리로 잡는다.

kr.nextree.dsm.parser.JavaTypeParser가 실제 문자열을 파싱하는 클래스이며, 이를 대상으로 단위테스트를 다음과 같이 만든다.


위의 테스트 코드에서 보듯이 JavaTypeParser에서는 JavaType.g 파일에서 선언된 대로 메소드가 만들어진다. 위의 테스트를 수행하게 되면 사실 결과는 아무 것도 나타나지 않는다. 물론, 테스트 실패시나 에러시에는 에러/실패 메시지가 나타나지만, 파싱 결과를 검증하는 것이 필요하다. 따라서, 문자열을 파싱하는 결과를 반환해주는 변수와 메소드 선언이 필요하다.

Antlr 은 문법에 해당 언어를 같이 표기가 가능하다. 따라서, JavaType.g 파일을 다음과 같이 수정한다.


위의 그림에서 @members에 파싱 결과를 받는 parsedJavaType을 선언해 주었으며, declType에서 파싱 결과를 이 변수에 세팅하는 로직을 추가하였다. Antlr의 문법에서 식별되는 변수는 $표시를 사용하며 .text를 사용해서 파싱된 문자열을 가지고 올 수 있다. 수정된 내용을 다시 maven을 통해서 파서를 생성하면, public String getParsedJavaType 오퍼레이션이 생성된 것을 확인해 볼 수 있으며, 단위테스테는 다음가 같이 검증을 할 수 있다.



지금까지는 단순한 내용을 파싱을 해보았지만, 좀 더 복잡한 자바 타입을 분석해보자. 예를 들어, 패키지 명이 들어간 자바 타입의 경우 (java.util.Date) 는 파싱 문법이 다소 달라진다. 현재 Identifier를 통해서 읽은 문자열은 단어 하나만을 인식한다. 즉, 패키지 형태와 같이 . 이 들어가는 문자는 이를 위해서 . 을 인식할 수 있도록 문법을 바꾸어야 한다.

 
위의 그림에서 qualifiedName이 추가되었으며 이는 Identifier 하나만 있거나 그 뒤에 '.' 이 붙는 Identifier가 나타날 수 있는 형태다. 파싱 자체도 패키지 단어별로도 수행할 수도 있으며, 아니면 전체 fullname인 형태로 파싱이 가능하다. 만일 패키지 요소들을 분해하려면 qualifiedName 선언에서 Identiifer를 읽어들이면 된다. 단위테스트에서 final String javaType = "java.util.Date"; 로 바꾸어서 테스트를 수행해보면 된다. (문법을 위와 같이 수정하지 않고도 먼저 테스트해보면 파싱 에러가 나타날 것이다.)

이제 단순한 자바 타입이 아닌 generic을 포함하는 Collection 유형의 자바 타입을 파싱해보자. JDK5 이상에서는 java.util.List<String>이나 java.util.List<java.util.Date>와 같은 표현이 가능하다. 이를 잘 분석해보면, '<'와 '>' 사이에 다시 한번 자바 타입 선언이 나타나는 것을 볼 수 있다. 즉, 위의 그림에서 declType : qualifiedName 다음에 이를 표현해주면 된다.

declType
   : qualifiedName ('<' declType '>')?
   ;



위의 문법을 보면 generic 부분은 발생하지 않을 수도 있고, 발생할 수도 있는 형태이다. 자바 타입을 세팅하는 부분 역시 단순히 하나의 자바 타입이 존재하지 않음을 알 수 있다. java.util.List<java.util.Date>와 같은 형태는 java.util.List와 java.util.Date의 두개의 자바 타입이 존재하기 때문에 기존 parsedJavaType은 아마도 여러개의 자바타입을 받도록 처리해야 할 것이다.

 배열 역시 마찬가지로 볼 수 있다. 즉, java.util.Date[] 형태나 java.util.Date[][]의 형태도 자바 타입 선언 뒤에 '['와 ']'가 연속으로 나타날 수 있다. 여기에서 '['와 ']'를 구분해서 표기하는 방식은 '['와 ']' 사이에 공백 문자가 들어갈 수도 있기 때문이다. 기존 Lex에서 WS 는 무시하도록 처리를 했기 때문에 '['와 ']' 사이에 아무리 많은 공백이 들어가도 Antlr은 이를 무시하고 '['와 ']' 만을 인식한다. 이를 수용하도록 다시 declType을 바꾸면 다음과 같다.

declType
   : qualifiedName ('<' declType '>')? ('[' ']')*
   ;



이는 이전의 generic 유형이나 배열 유형 모두를 한꺼번에 처리를 가능하게 해준다. 위의 경우에서 Map<String, Integer>와 같은 자바 타입은 처리에 문제가 발생된다. 즉, 이 부분 역시 같이 처리하게 해주면 다음과 같이 표현할 수 있다.

declType
   : qualifiedName ('<' declType (',' declType)? '>')? ('[' ']')*
   ;


그럼 Map<String, Map<String, List<Integer>>> 과 같은 자바 타입은 어떻게 될까? 위의 표현에서 generic 안에 declType이 선언되어 있기 때문에 마찬가지로 위의 문법에서 파싱이 가능하다. 이러한 예제를 Intepreter를 통해서 테스트해보면 다음과 같은 화면이 나타난다.

 
다소 복잡하긴 하지만, 모든 요소(recognizer)들을 파싱했다는 것을 알 수 있다. 배열이 중간에 들어가는 것 역시 파싱이 잘 되는 것을 볼 수 있다.

마지막으로 한가지를 더 고려하면, java.util.List<? extends java.lang.Serializable>과 같은 형태이다. 여기에서 '?'와 'extends'라는 새로운 단어들이 등장했기 때문에 다음과 같이 declType을 바꾸면 된다.

declType
   : qualifiedName ('<' ('?' 'extends')? declType (',' declType)? '>')? ('[' ']')*
   ;


이상과 같이 복잡한 문자열을 파싱하는 경우, Antlr을 사용하면 단 몇줄의 코딩으로 해결이 가능하다. 이외에도 다양한 코드 파싱에 대한 내용들이 http://www.antlr.org/grammar/list 에 소개되어 있다.

Antlr은 이외에도 일정 규칙을 가지는 복잡한 문장을 파싱하는데 상당한 장점을 가진다. 또한, 문법 중간에 자바 코드를 삽입할 수 있기 때문에 DSL(Domain Specific Language)로의 활용도 가능하다. 그러한 목적을 위해서 antlr 은 StringTemplate이라는 것을 제공한다. Antlr을 활용하여 많은 수고를 줄일 수 있다.
 

반응형