파서를 구현할 때 가장 먼저 마주하게 되는 설계 결정 중 하나는 식별자와 예약어를 어떻게 분리하고 처리할 것인지에 대한 부분이에요. 프로그래밍 언어에서 `if`, `while`, `for`와 같은 단어들은 문법 구조를 결정하는 중요한 역할을 수행하기 때문에, 이를 일반적인 변수나 함수 이름으로 사용할 수 있는 식별자(Identifier)와 엄격하게 구분해야 하거든요. 만약 파싱 단계에서 이 둘을 명확히 구분하지 못하면 문법 분석기가 모호한 상태에 빠지게 되어 정확한 구문 분석이 불가능해지기도 해요.

실무에서는 보통 어휘 분석(Lexing) 단계나 그 직후의 파싱 초기 단계에서 예약어 목록을 정의하여 처리해요. Lexer가 문자열을 읽어 들였을 때 그것이 식별자의 규칙에 부합한다면, 다시 한번 미리 정의된 키워드 리스트와 대조하는 과정을 거치는 방식이에요. 예를 들어 `let`이라는 단어가 입력되었을 때, 이것이 단순히 변수 선언을 위한 이름인지 아니면 변수 선언 키워드인지를 확인하여 각각 다른 토큰 타입으로 할당하게 돼요. 이렇게 타입을 분리해두면 파서가 구문 규칙을 적용할 때 훨씬 명확하게 동작할 수 있거든요.

public enum TokenType {
    KEYWORD_IF, KEYWORD_WHILE,
    IDENTIFIER,
    EOF
}

public class Lexer {
    private static final Set<String> RESERVED_WORDS = Set.of("if", "while");

    public TokenType identify(String input) {
        if (RESERVED_WORDS.contains(input)) {
            if (input.equals("if")) return TokenType.KEYWORD_IF;
            if (input.equals("while")) return TokenType.KEYWORD_WHILE;
        }
        return TokenType.IDENTIFIER;
    }
}

또한 식별자 내에 포함될 수 있는 특수 문자의 처리도 중요하게 다뤄져야 해요. 많은 언어에서 식별자에 공백이나 특정 기호를 직접 넣는 것을 금지하지만, 이스케이프 시퀀스를 통해 이를 허용하는 경우도 있거든요. 이때 파서는 단순히 문자열을 그대로 받아들이는 것이 아니라, 백슬래시와 같은 이스케이프 문자를 해석하여 실제 식별자 이름을 추출하는 과정을 거쳐야 해요. 이러한 규칙이 정확하게 구현되어야만 코드 내에서 복잡한 이름이나 특수 기호가 포함된 식별자가 등장해도 파서가 중단 없이 안정적으로 동작할 수 있어요.

결과적으로 견고한 파서를 만들기 위해서는 어휘 분석 단계에서부터 식별자의 정의를 명확히 하고, 예약어와의 충돌을 원천 차단하는 구조를 설계하는 것이 중요해요. 초기 설계 단계에서 키워드 매핑 테이블이나 해시셋을 활용해 타입을 분리해두면 이후의 구문 분석 로직이 훨씬 단순해지고 유지보수도 쉬워지기 때문이에요.

파서를 직접 구현하다 보면 연산자 우선순위 처리가 가장 먼저 막히는 지점이에요. 단순히 토큰을 순서대로 읽는 구조라면 중위 표기법 표현식을 제대로 해석할 수 없거든요. 실제로 4칙 연산을 지원하는 문법을 설계할 때, 곱셈과 나눗셈이 덧셈이나 뺄셈보다 먼저 계산되어야 한다는 규칙을 파서에 맞게 전달해야 해요. 이 문제를 해결하는 가장 일반적인 방법은 호출 계층을 연산자 우선순위에 따라 단계적으로 나누는 거예요. 최상위 함수에서 덧셈이나 뺄셈을 처리하고, 그 안에서 곱셈 나눗셈을 담당하는 하위 함수를 호출하는 구조로 만들면 자연스럽게 우선순위가 반영돼요. 결합법칙도 왼쪽에서 오른쪽으로 평가되도록 재귀 호출 순서를 조정하면 간단하게 처리할 수 있더라고요.

fun parseExpression(): Expr {
    var node = parseTerm()
    while (peek().type == PLUS || peek().type == MINUS) {
        val op = advance()
        val right = parseTerm()
        node = BinaryExpr(op, node, right)
    }
    return node
}

fun parseTerm(): Expr {
    var node = parseFactor()
    while (peek().type == STAR || peek().type == SLASH) {
        val op = advance()
        val right = parseFactor()
        node = BinaryExpr(op, node, right)
    }
    return node
}

fun parseFactor(): Expr {
    if (peek().type == LPAREN) {
        advance()
        val expr = parseExpression()
        expect(RPAREN)
        return ParenExpr(expr)
    }
    return parseNumber()
}

while 루프 안에서 현재 토큰이 원하는 연산자 타입과 일치할 때까지 advance 함수를 호출하는 방식이 핵심이에요. 이때 피연산자를 바로 읽기보다는 하위 파서 함수를 호출해서 트리 노드를 받아오는 구조로 설계해야 해요. 이렇게 하면 곱셈이나 나눗셈 연산자가 나타나면 즉시 parseTerm을 재귀 호출해 더 높은 우선순위의 피연산자 트리를 완성할 수 있네요. 토큰 스트림의 포인터를 잘못 관리하면 무한 루프에 빠지기 쉬워요. advance 호출 전 peek로 미리 확인하고, 조건이 충족되면 반드시 한 번 더 advance하는 패턴을 유지해야 해요. 파서 작성 시 문법 규칙이 왼쪽 재귀를 포함하면 무한 루프가 발생해요. 우선순위 처리 구조는 이를 자연스럽게 피할 수 있는 대안이 되네요.

이렇게 계층을 나누면 괄호를 사용한 중위 표기법도 자연스럽게 재귀 호출로 풀리게 되어요. 우선순위를 함수 호출 순서로 매핑하는 방식은 확장할 때 문법 규칙이 꼬이는 것을 방지해주네요. 파싱 로직이 복잡해지지 않도록 피연산자 추출과 연산자 소비를 명확히 분리해서 작성하면 유지보수가 훨씬 수월해져요. 재귀 호출의 깊이보다는 연산자의 종류와 결합 방향을 먼저 정의하고, 토큰 스트림을 한 번에 읽으면서 상태를 관리하는 구조로 잡으면 파서 완성도가 크게 올라가더라고요. precedence climbing 기법을 활용하면 함수 계층을 줄일 수 있으니 문법 표기 방식에 따라 선택하면 되네요.

텍스트 기반 설정 파일이나 API 응답에서 숫자를 추출할 때 생각보다 여러 가지 변수가 맞닥뜨려집니다. 소수점이 포함되기도 하고, 지수 표기법(e 또는 E)이 섞여 나오며, 때로는 천 단위 구분자가 포함되어 있어 단순 변환 함수만으로는 기대한 값이 안 나올 수 있어요. 특히 과학 계산이나 금융 관련 데이터를 다룰 때는 부동소수점 연산의 한계가 그대로 드러나곤 하거든요. 파서 설계 단계에서 이 부분을 미리 고려하지 않으면 후속 처리 로직이 복잡해지기 마련이에요.

숫자 리터럴을 파서 규칙으로 정의할 때는 부호와 지수 표기법을 우선 토큰으로 분리하는 게 중요해요. 정규식만 믿고 일괄 치환하면 음수나 소수점 위치를 잘못 인식할 수 있거든요. 문자열을 왼쪽부터 스캔하며 정수부, 소수부, 지수부를 상태 기계처럼 처리하는 로직이 가장 예측 가능하더라고요. 내장 함수는 이미 완성된 파싱 규칙이지만, 커스텀 구문 분석기에서 직접 숫자 규칙을 추가할 때는 Number.MAX_SAFE_INTEGER 같은 한계값을 미리 체크해야 합니다. 부동소수점 오차는 절대 발생하지 않는 환경이라면 고정밀 아키텍처를 적용하는 게 맞고, 일반적인 데이터 처리라면 허용 오차 기준을 명확히 정의해두는 게 좋아요. 파싱 중 발견된 잘못된 문자열은 즉시 토큰화 과정을 중단하고 에러 위치를 반환하는 구조로 설계해야 합니다.

function parseNumberStrict(str) {
  const cleaned = str.trim().replace(/,/g, '');
  const num = parseFloat(cleaned);
  if (isNaN(num)) throw new Error('Invalid number format');
  return Number.isFinite(num) ? num : null;
}

파서에서 숫자 규칙은 단순한 변환기가 아니라 데이터 유효성 검증의 핵심이에요. 입력값이 기대한 범위나 정밀도 요구사항을 벗어나면 즉시 실패 처리를 하고, 로그에 원본 텍스트와 함께 위치 정보를 남기는 게 실무에서는 필수적입니다. 이 과정을 구조화해두면 나중에 새로운 숫자 포맷이 추가되더라도 파서 확장성이 크게 달라지거든요. 구현 초기에 규칙을 명확히 해두면 유지보수가 훨씬 수월해집니다. 특히 대용량 스트림을 처리할 때는 메모리 할당 없이 순차적으로 토큰을 생성하는 방식이 성능에 큰 영향을 미치네요.

파서를 직접 만들다 보면 가장 먼저 부딪히는 게 바로 공백과 주석 처리였어요. 문법 규칙을 설계할 때 입력값에 빈칸이나 주석이 섞이면 토큰라이저나 파싱 로직이 엉키기 쉬워요. 파일 확장자나 문법 사양에 따라 처리 방식이 조금씩 달라지지만, 원리는 동일하니까 기본기를 잘 잡아두면 도움이 돼요. 저도 처음엔 정규식으로 일일이 치워버렸는데, 유지보수가 점점 힘들어지더라고요. 그래서 현재는 파서 진입점이나 연산자 앞뒤에서 명시적으로 건너뛰는 방식을 꾸준히 쓰고 있어요.

핵심은 파싱 함수가 실행되기 전후에 항상 공백과 주석을 정리해주는 거예요. 특히 재귀 하강 방식으로 구현할 때는 각 규칙이 끝날 때마다 다음 토큰을 읽어오기 전에 정화 과정을 반드시 거쳐야 해요. 아래처럼 현재 위치를 가리키는 인덱스를 관리하면서, 빈칸이나 한 줄 주석, 블록 주석을 만나면 해당 구간에 맞춰 위치를 이동시키면 돼요. 파싱 함수 호출 직전과 재귀 호출 이후에 이 메서드를 배치하면 입력값이 매끄럽게 정리돼요. 특히 연산자 우선순위나 괄호 짝 맞추기 로직에서 공백을 무시하지 않으면 예외가 자주 터지니 주의해야 해요.

function skipWhitespaceAndComments(): void {
  while (currentChar) {
    if (isWhitespace(currentChar)) {
      advance();
    } else if (currentInput.startsWith('//', currentPos)) {
      while (currentChar && currentChar !== '\n') {
        advance();
      }
      // 한 줄 주석 뒤의 개행 문자도 함께 소비합니다.
      if (currentChar === '\n') {
        advance();
      }
    } else if (currentInput.startsWith('/*', currentPos)) {
      let foundClose = false;
      while (!foundClose && currentChar) {
        if (currentInput.startsWith('*/', currentPos)) {
          advance();
          advance();
          foundClose = true;
        } else {
          advance();
        }
      }
    } else {
      break;
    }
  }
}

function isWhitespace(char: string): boolean {
  return char === ' ' || char === '\t' || char === '\n' || char === '\r';
}

파싱 로직을 작성하다 보면 의도치 않게 공백이 토큰 경계로 인식되는 경우가 많아요. 이럴 때는 입력 스트림을 한 글자씩 훑으면서 조건문을 걸거나, 재귀 호출 전에 반드시 정화 함수를 실행하는 습관을 들이는 게 좋아요. 저도 여러 차례 디버깅을 거쳐서야 이 패턴이 가장 안정적이라는 걸 깨달았거든요. 이렇게 하면 파싱 로직 자체는 순수한 문법 구조에만 집중할 수 있어서 훨씬 깔끔해지더라고요. 다만 개행 문자가 문장 종결자로 쓰이는 언어라면 이 방식 그대로 적용하면 안 된다는 점을 꼭 기억해야 해요. C 계열처럼 개행이 구문과 무관한 경우에만 판정 함수에 줄바꿈과 캐리지 리턴을 포함시켜야 매끄럽게 작동하거든요. 실제 프로젝트에서 파서를 다룰 때 이 패턴을 기본 템플릿처럼 넣어두면 토큰 오버랩이나 무한 루프 같은 귀찮은 버그를 크게 줄일 수 있을 거예요.

- 참조

- http://www.colm.net/open-source/ragel/

- http://www.colm.net/wp-content/uploads/2014/10/ragel-guide-6.9.pdf


- Ragel 사용이 더 좋은 작업

- 문자열 파싱

- 프로그래밍 언어 분석(컴파일러 제작)

- 사용자 입력 검증


- 지원 언어

- C, C++, Obj-C, C#, D, Java, Go, Ruby


- 코드

- Ragel은 유한상태머신(Finite State Machine, FSM)을 구현

- 여러줄 인 경우 "%%{"와 "}%%"로 감싸고, 한줄 인 경우 라인의 앞에 "%%"를 입력한다.

- C언어처럼 한 줄의 마지막에 ";"을 입력한다.

예)

%%{

machine atoi;

...

%%}

%%write init;


- 코드 삽입

- FSM의 이름 삽입

%% machine {FSM 이름};


- Ragel 코드 시작은 main이다.

- ":=" 연산자는 R-Value의 표현식을 나타내는 상태 셋을 생성한다.

%% main := {수식};


- 수식

- 사용자 정의 된 이름 혹은 미리 정의된 이름, 사용자 정의 Action이 올 수 있다.

- Action은 사용자 입력이 수식과 매칭되는 경우 실행할 코드를 넣는 일종의 함수로 각 언어별 실행 코드를 넣는다.

%% action on_word {printf("%n\n", n);}

- 사용자 정의 수식을 정의하려면 "=" 을 사용한다.

%% word = ^space+;


- FSM 초기화 및 시작

- FSM 에서 필요한 상수 선언

%% write data;

- FSM 데이터 초기화

%% write init;

- FSM 실행

- 실제 FSM이 실행 될 위치에 다음과 같이 삽입한다.

%% write exec;


- Action 종류

- Action은 연산자에 따라서 실행하는 방법이 달라진다.

- {수식} > {Action}: Entering Action(FSM state: from the start state)

- 수식에 진입하는 시점에서 Action이 실행된다.

- 예를들어, 문자열을 파싱할 때, 처음 한번만 실행하는 경우

%% action onStart {puts("Start Parsing");}

%% main := alpha+ > onStart;

- {수식} @ {Action}: Finishing Action(FSM state: into a final state)

- 수식의 마지막에 Action이 실행된다.

- 다음은 a-z까지의 소문자가 하나 있을때마다 lower Action이 실행된다.

%% word = ([a-z] @lower)+;

- 다음은 a-z까지의 소문자가 아닌 경우 notLower Action이 실행된다.

%% word = [a-z]+ @notLower;

- {수식} $ {Action}: All Transition Action(FSM state: all state for matched)

- 수식과 매칭될 때마다 Action이 실행된다.

- 다음은 a-z까지의 소문자가 하나 있을때마다 lower Action이 실행된다. (Finishing Action 예제의 변형)

%% word = [a-z]+ $lower;

- {수식} % {Action}: Leaving Action(FSM state: go out of a machine via a final state)

- 수식을 벗어날 때 실행된다.

- 다음은 a-z까지의 소문자가 아닌 경우 lastLower Action이 실행 된 후 notLower Action 실행된다.

%% word = [a-z]+ %lastLower @notLower;

- Action의 순서

>(Entering) $(All) %(Leaving) @(Finishing)


- 주요 연산자

- {수식}+: 1개 이상

- {수식}*: 0개 이상

- {수식}?: 0개 혹은 1개

- ^{수식}: 수식의 반대, ^space 는 공백문자가 아닌 모든 문자를 뜻함.

- ({수식}): 그룹핑, 여러 수식을 하나의 수식으로 처리

- {수식1}|{수식2}: or 연산, 수식1 혹은 수식2와 매칭


- cs(Current State): FSM 상태를 나타내는 변수, 에러, 성공등의 값이 저장.


- 스캐너
- 상태 셋을 생성하는 ":=" 연산자를 사용하여 정의한다.
- 여러가지 패턴 목록을 정의한 후 각 패턴마다 action을 실행할 수 있다.
- main을 정의할 때 스캐너로 정의할 수 있으며, 별도로 정의한 경우 "@{fcall" {스캐너이름}"}" 형태로 실행한다.
- 기본 구조는 다음처럼 스캐너 이름을 정의하고 "|*" 와 "*|" 사이에 패턴과, 패턴 매칭시 실행 될 Action을 정의한다.
- Action을 정의할 때 "=>" 연산자는 생략가능하다.
{스캐너 이름} := |*
{패턴} [{[=>] Action}];
...
*|
- 다음은 영문자와 숫자가 합쳐져 있는 문자열에서 숫자를 검색하는 스캐너이다.
digit_scan := |*
alpha+;
digit+ {puts("digit!");};
*|
- 스캐너는 매칭된 문자열의 시작과 종료를 ts(token start), te(token end)에 저장한다.
- 매칭되기 전의 ts와 te는 같은 값을 가지며, 하나의 패턴이 매칭되면 그 다음 위치를 저장한다.
- 예를들어 90번지에서 100번지까지 패턴이 매칭되었으면 그 다음 패턴 검색시 ts, te의 값은 101이다.
digit_scan := |* ...
digit+ => {char buf[12]; memcpy(buf, ts, te - ts); puts(buf);};
*|


+ Recent posts