Spring에서 antlr 사용하기 (gradle)

임원재·2025년 9월 24일
0

SpringBoot

목록 보기
19/19
post-thumbnail

antlr란?

  • antlr는 ANother Tool for Language Recognition 의 줄임말로, 일정한 규칙을 지닌 문자열을 런타임에 파싱할 수 있도록 도와주는 파서 도구(라이브러리)이다.
  • 자바 ORM인 Hibernate는 SQL 쿼리문을 의미있는 단위로 파싱 및 처리하기 위해 antlr를 사용하며 , 검색 엔진인 ElasticSearch또한 해당 툴을 사용한다.
  • 이러한 파싱 툴로 로그·쿼리를 분석 및 파싱하여 의미있는 데이터만 추출할 수 있다.

사용 방법

  • antlr 라이브러리는 .g4파일을 통해 파싱 규칙을 정의한다.
    미리 정의한 .g4파일을 통해 gradle에서 자바코드로 문법클래스를 자동으로 생성한다.
    이렇게 문법파일을 자바 클래스로 변환하여 자바 코드 레벨에서 String형태의 자료형에 문법을 적용하여 파싱을 진행할 수 있다.

  • 먼저, gradle에서 antlr플러그인 + runtime을 추가한다.

plugins {
	id 'antlr'
}

dependencies {
	  antlr "org.antlr:antlr4:4.13.1" // g4 파일을 java 코드로 변환
    implementation "org.antlr:antlr4-runtime:4.13.1" // 실행할 때 사용
}

antlr {
    arguments += ['-visitor', '-long-messages'] // Visitor & Listener 자동 생성
}
  • .g4파일 위치는 src/main/antlr/Arithmetic.g4로 두면 된다.
  • 간단한 계산(더하기 곱하기)문법은 아래와 같이 표현할 수 있다.
grammar Arithmetic;

// Parser rules
expr : addExpr ;
addExpr : mulExpr ('+' mulExpr)* ;
mulExpr : atom ('*' atom)* ;
atom : INT  | '(' expr ')' ;
    
// Lexer rules
INT     : [0-9]+ ;
WS      : [ \t\r\n]+ -> skip ;
  • 위 문법은 BNF와 비슷한 antlr 도메인 특화 언어 (DSL)이다. 필자도 처음에는 생소했지만, 금방 적응할 수 있었다.

문법구조

ANTLR문법은 크게 3가지 파트로 나눌 수 잇다.

  1. Grammar 선언

    • grammar {규칙이름};으로 작성한다.
    • 여기서 지정한 규칙이름이 곧 파일명 {규칙이름}.g4가 되어야 한다.
    • 위 예제에서는 해당 파일의 이름은 Arithmetic.g4이 될 것이다.
  2. Parser 규칙

    • 문자열을 의미단위로 어떻게 쪼갤지를 정의한다.
    • 위 예제에서는 expr라는 의미 단위를 곱셈, 덧셈, 괄호처리를 작은 단위로 분해할 수 있도록 정의하였다. expraddExprmulExpratom으로 진행되는 흐름을 확인할 수 있다.

  1. Lexer 규칙
    • 기본단위(토큰)을 어떻게 인식할지를 결정한다.
    • 위 예제에서, INT라는 단위는 [0-9]+로 정의되어 하나 이상의 숫자를 의미한다.
    • WS같은 경우에는, 공백처리를 skip을 붙여 무시하도록 설정하였다.
  • 마치 spring을 사용할 때 빌드 설정을 위해 groovy라는 도메인 특화 언어로 build.gradle를 작성하는 것과 비슷하다고 생각했다.
  • 즉, 특정 목적(빌드 or 파싱 등..)을 위해 제공되는 도메인 특화 언어(DSL)를 사용해 규칙을 정의하는 것이다.
./gradlew generateGrammarSource
  • 위 명령어를 통해 .g4파일을 자바 코드로 변환한다.
  • Lexer, Parser, BaseVisitor, Visitor, Listener 클래스가 build/generated-src/antlr/main/경로에 생성된다.
public class Main {
    public static void main(String[] args) {
        String input = "2+3*4";

        // 1. 문자열을 CharStream으로 변환
        CodePointCharStream stream = CharStreams.fromString(input);

        // 2. Lexer 생성
        ArithmeticLexer lexer = new ArithmeticLexer(stream);

        // 3. TokenStream 생성
        CommonTokenStream tokens = new CommonTokenStream(lexer);

        // 4. Parser 생성
        ArithmeticParser parser = new ArithmeticParser(tokens);

        // 5. 진입점(expr) 파싱 → ParseTree 얻기
        ParseTree tree = parser.expr();
        System.out.println(tree.toStringTree(parser));
    }
}
  • 위 코드에서 parser.expr();를 통해 ParseTree라는 것을 생성한다.
    ParseTree는 각 노드로 이루어지며, 각 파서를 담당하는 노드 클래스는 ParserTree.{파서이름Context}로 구현되어있다.
    해당 노드 클래스를 통해 토큰 값 조회, 하위 노드 접근, 상위 노드 접근, 트리 탐색 등을 수행할 수 있다.
  • 위 코드를 실행하면 대략 아래와 같은 파싱 결과를 확인할 수 있다. (계산기 결과 x)
(regex (expr (element (atom (charClass [ (classAtom a) (classAtom b) (classAtom c) ])))))
  • 우리는 각각의 lexer들에 접근해서 특정 로직을 실행시켜야 의미있는 파싱이 이루어지지만, 위와같은 형태는 의미는 구분되었지만 결국 문자열을 다시 파싱해야하는 곤란한 상황이 발생한다.

BaseVisitor

  • 위와같은 문제를 마주쳤을 때, gradle의 antlr플러그인으로 자동 생성한 자바 클래스 중 BaseVisitor.class로 문제를 해결할 수 있다.
public class ArithmeticBaseVisitor<T> extends AbstractParseTreeVisitor<T>
                                      implements ArithmeticVisitor<T> {
    @Override public T visitMulExpr(ArithmeticParser.MulExprContext ctx) { 
        return visitChildren(ctx); 
    }
    @Override public T visitAddExpr(ArithmeticParser.AddExprContext ctx) { 
        return visitChildren(ctx); 
    }
    ...
}
  • 코드를 뜯어보면, 메서드 이름이 visit + parser이름으로 되어있다. 그리고 각각의 메서드들은 visitChildren(ctx);를 반환한다.
  • 이때 ArithmeticParser.Context같은 클래스는 자동 생성된 파서규칙 노드타입이다. MulExpr 라는 파서를 .g4에서 선언했기에 자동생성되었다.
  • 클래스 이름이 Context로 끝나는 노드가 parser이름을 방문했을 때, 다음 하위의 노드(children)으로 내려간다는 의미가 되겠다.
  • 즉, 특정 parser를 방문하면, 호출되는 메서드임을 알 수 있다.
  • 이를 extends하고 필요한 메서드만 오버라이드하여 사용자 전용 파싱 로직을 작성할 수 있다.
public class ArithmeticVisitorImpl extends ArithmeticBaseVisitor<Integer> {

    @Override
    public Integer visitIntExpr(ArithmeticParser.IntExprContext ctx) {
        return Integer.valueOf(ctx.INT().getText());
    }

    @Override
    public Integer visitAddExpr(ArithmeticParser.AddExprContext ctx) {
        int left = visit(ctx.expr(0));
        int right = visit(ctx.expr(1));
        return left + right;
    }

    @Override
    public Integer visitMulExpr(ArithmeticParser.MulExprContext ctx) {
        int left = visit(ctx.expr(0));
        int right = visit(ctx.expr(1));
        return left * right;
    }

    @Override
    public Integer visitParenExpr(ArithmeticParser.ParenExprContext ctx) {
        return visit(ctx.expr());
    }
}
  • 위와같이 특정 parser에 접근했을 때 어떤 로직을 실행할지 정의할 수 있다.
  • visitAddExpr 메서드에서, AddxprContext 라는 노드의 첫 번째 expr에 방문하면, 다시 visitIntExpr메서드가 invoke되어, 해당 expr노드의 값을 리턴한다.
  • 각각의 expr의 값을 더해 덧셈이 구현된다.
  • 이렇게 각 노드별 메서드가 재귀적으로 호출되면서, 트리 전체를 탐색하고 최종 값을 계산하게 된다.

BaseListener

  • 위에서 언급한 BaseVisitor는, 사용자가 트리를 탐색하는 시점(visit(ctx.expr()))을 직접 제어한다.
  • Listener클래스는 파서가 ParseTree를 생성할 때, 자동으로 콜백메서드를 호출한다.
public class RegexBaseListener implements RegexListener {
	/**
	 * {@inheritDoc}
	 *
	 * <p>The default implementation does nothing.</p>
	 */
	@Override public void enterRegex(RegexParser.RegexContext ctx) { }
	/**
	 * {@inheritDoc}
	 *
	 * <p>The default implementation does nothing.</p>
	 */
	@Override public void exitRegex(RegexParser.RegexContext ctx) { }
	/**
	 * {@inheritDoc}
	 *
	 * <p>The default implementation does nothing.</p>
	 */
	@Override public void enterExpr(RegexParser.ExprContext ctx) { }
	/**
	 * {@inheritDoc}
	 *
	 * <p>The default implementation does nothing.</p>
	 */
	@Override public void exitExpr(RegexParser.ExprContext ctx) { }
	/**
	 * {@inheritDoc}
	 *
	 * <p>The default implementation does nothing.</p>
	 */
	@Override public void enterElement(RegexParser.ElementContext ctx) { }
	/**
	 * {@inheritDoc}
	 *
	 * <p>The default implementation does nothing.</p>
	 */
	@Override public void exitElement(RegexParser.ElementContext ctx) { }
  • 위 코드와 같이, 특정 노드에 진입하거나(enter), 나갈 때(exit) 호출되는 콜백 메서드를 확인할 수 있다.
  • 사용자가 직접 탐색하지 않고 파싱시에 호출된다는 점에서 실시간 이벤트 처리나 로그 처리에 사용될 수 있겠다.

0개의 댓글