본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Algorithm

파싱 알고리즘과 프랫 파서

작성 2026. 10. 14.추가
목차7개 항목
출처와 참고

팔만코딩경(42Seoul 커뮤니티 위키)의 「미니쉘 파싱 쉽게 해보기」, 「나만의 JSON 파서로 재귀 파싱 연습하기」를 바탕으로 재구성한 노트다.

파싱이 하는 일

인터프리터는 코드 문자열을 다음 순서로 실행한다. 셸도 셸 스크립트라는 언어의 인터프리터다.

flowchart LR
  n1["입력 문자열<br/>#quot;a + b * c#quot;"] -->|"렉싱"| n2["토큰 배열<br/>[a, +, b, *, c]"]
  n2 -->|"파싱"| n3["추상 구문 트리(AST)<br/>(+ a (* b c))"]
  n3 -->|"평가"| n4["실행"]
  • 렉싱(lexing, 토큰화): 연산자·키워드·식별자처럼 의미를 갖는 최소 단위(token)로 쪼갠다.
  • 파싱(parsing): 토큰을 문법에 맞게 묶어 추상 구문 트리(AST) 로 만든다. 같은 토큰열도 묶는 순서에 따라 뜻이 달라진다. a + b * c를 (a + b) * c로 묶으면 틀린 해석이다.
  • 평가(evaluating): 트리를 순회하며 실행한다.

파서 생성기(yacc/bison, ANTLR)가 문법 파일에서 파서를 만들어 주지만, 연산자 표현식은 손으로 짜는 경우가 많다. 그 대표가 프랫 파서다.

파싱 알고리즘의 계보

방식핵심장점단점
재귀 하향(백트래킹)문법 규칙마다 함수를 두고 위 규칙부터 차례로 시도문법을 그대로 코드로 옮김실패하면 만든 트리를 버리고 다른 규칙 시도 → 최악 지수 시간, 오류 위치 파악 어려움
연산자 우선순위(플로이드)토큰 종류에 맞는 처리를 하고, 우선순위표로 묶을지 미룰지 결정백트래킹 없이 O(n), 오류를 그 자리에서 발견상향식 shift-reduce라 구현이 직관적이지 않음
프랫(하향식 연산자 우선순위)재귀 하향 구조 + 연산자마다 바인딩 파워O(n), 함수 몇 개로 구현, 전위·중위·후위 연산자를 쉽게 추가피연산자끼리 바로 붙는 비정형 문법에 약함

플로이드(Robert W. Floyd)의 아이디어는 완전 탐색을 그리디로 바꾼 것이다. 우선순위를 미리 알면 시도해 보고 되돌릴 필요 없이 처음부터 맞게 묶을 수 있다. shift-reduce 파서는 토큰을 스택에 쌓다가(shift), 스택 위 연산자의 우선순위가 새 연산자보다 높으면 스택의 노드를 묶는다(reduce).

프랫(Vaughan Pratt)은 1973년 이 우선순위 개념을 하향식 재귀 구조에 합쳤다. 재귀 하향 파서의 간결함과 연산자 우선순위 파서의 효율을 함께 얻는다.

재귀 하향 파서: 문법을 함수로 옮기기

백트래킹이 없는 재귀 하향 파서는 지금도 가장 흔한 손 파서다. 다음 토큰 하나만 보고 어느 규칙인지 결정할 수 있는 문법(LL(1))이면 되돌아갈 일이 없다. JSON이 대표적이다. 문법을 BNF(Backus-Naur Form)로 적으면 규칙 하나가 함수 하나가 된다.

<json>   ::= <object> | <array>
<object> ::= '{' [ <member> *( ',' <member> ) ] '}'
<member> ::= <string> ':' <value>
<array>  ::= '[' [ <value> *( ',' <value> ) ] ']'
<value>  ::= <string> | <number> | <object> | <array> | true | false | null
BNF 표기코드
A | B (선택)다음 글자를 보고 분기: {면 객체, [면 배열, "면 문자열
[ X ] (생략 가능)닫는 괄호가 바로 오면 건너뛴다
*( ',' X ) (0회 이상 반복)쉼표가 나오는 동안 반복
규칙 안의 다른 규칙그 함수를 호출. value ↔ object·array가 서로를 부르는 상호 재귀
def parse_value(s, i):
    i = skip_ws(s, i)
    if s[i] == '{': return parse_object(s, i)
    if s[i] == '[': return parse_array(s, i)
    if s[i] == '"': return parse_string(s, i)
    return parse_literal(s, i)                 # 숫자, true, false, null
 
def parse_array(s, i):                         # 각 함수는 (값, 다음 위치)를 돌려준다
    i, items = skip_ws(s, i + 1), []
    if s[i] == ']':
        return items, i + 1                    # 빈 배열 [ ]
    while True:
        v, i = parse_value(s, i)
        items.append(v)
        i = skip_ws(s, i)
        if s[i] == ']': return items, i + 1
        if s[i] != ',': raise SyntaxError(f"',' 또는 ']'가 필요함: {i}")
        i += 1                                 # 쉼표 뒤에는 반드시 값 → 끝 쉼표는 오류

반복 구조 X *( ',' X ) 덕분에 "마지막 원소 뒤에는 쉼표가 없다"는 규칙이 저절로 지켜진다. 파싱 결과는 타입 태그와 값(문자열·배열·키-값 목록)을 담는 트리 노드로 표현한다. 연산자 우선순위가 여러 단계인 수식에서는 단계마다 함수를 하나씩 두어야 해 함수가 늘어나는데, 이 부분을 바인딩 파워 표 하나로 압축한 것이 프랫 파서다.

바인딩 파워

바인딩 파워(binding power)는 연산자가 양옆의 피연산자를 끌어당기는 힘이다. 우선순위가 높을수록 세다.

  • 왼쪽 바인딩 파워(lbp): 연산자가 왼쪽 피연산자를 당기는 힘
  • 오른쪽 바인딩 파워(rbp): 연산자가 오른쪽 피연산자를 당기는 힘
  • 피연산자 하나를 사이에 둔 두 연산자는 왼쪽 연산자의 rbp와 오른쪽 연산자의 lbp가 경쟁한다. 이긴 쪽이 피연산자를 가져간다.
flowchart TB
  A["a + b * c"] --> B["+ 의 rbp < * 의 lbp"] --> C["b는 *의 왼쪽 자식으로 끌려간다"] --> D["(+ a (* b c))"]

결합 방향도 바인딩 파워로 정한다. 좌결합(a - b - c = (a - b) - c)이면 rbp를 lbp보다 살짝 크게, 우결합(a ^ b ^ c = a ^ (b ^ c))이면 작게 둔다.

구현

프랫 파서의 중심은 함수 하나다. "현재 최소 바인딩 파워보다 센 연산자가 오는 동안 계속 왼쪽 트리를 키운다."

BP = {'+': (1, 2), '-': (1, 2), '*': (3, 4), '/': (3, 4), '^': (6, 5)}  # (lbp, rbp)
 
def parse_expr(tokens, min_bp=0):
    lhs = tokens.pop(0)                        # 전위 위치: 피연산자(또는 괄호·전위 연산자)
    if lhs == '(':
        lhs = parse_expr(tokens, 0)
        tokens.pop(0)                          # ')'
    while tokens and tokens[0] in BP:
        op = tokens[0]
        lbp, rbp = BP[op]
        if lbp < min_bp:                       # 왼쪽 연산자가 더 세다 → 여기서 멈춤
            break
        tokens.pop(0)
        rhs = parse_expr(tokens, rbp)          # 오른쪽은 rbp 이상인 연산자만 가져간다
        lhs = (op, lhs, rhs)
    return lhs
 
print(parse_expr(list("a+b*c-d")))   # ('-', ('+', 'a', ('*', 'b', 'c')), 'd')
print(parse_expr(list("2^3^2")))     # ('^', '2', ('^', '3', '2'))

토큰마다 한 번씩만 소비하므로 O(n)이다. 전위 연산자(-x), 후위 연산자(x!), 괄호, 함수 호출은 "토큰이 맨 앞에 올 때(nud)"와 "중간에 올 때(led)"의 처리를 토큰별로 등록하는 식으로 늘려 간다.

예: 셸 문법에 적용하기

셸의 연산자도 우선순위로 정리할 수 있다. 낮은 것부터

시작(0) < 논리 &&, || < 파이프 | < 리디렉션 <, <<, >, >> < 괄호(서브셸)
  • cmd1 && cmd2 | cmd3 || cmd4 → 파이프가 더 세게 당기므로 (cmd1 && (cmd2 | cmd3)) || cmd4. bash에서 &&와 ||는 같은 우선순위의 좌결합이다.
  • 괄호는 안쪽 연산자의 바인딩 파워를 바깥보다 높이는 역할을 한다. cmd1 | (cmd2 && cmd3)에서는 &&가 파이프보다 먼저 묶인다.
  • 입력 리디렉션 < file은 전위 연산자처럼, 출력 리디렉션 cmd > file은 중위 연산자처럼 다룰 수 있다.

셸 문법은 일반 수식과 달리 연산자와 피연산자가 번갈아 나오지 않는 경우가 있다. < infile cat -에서는 infile과 cat -이 연산자 없이 붙어 있다. 그래서 전위 리디렉션 노드를 "연산자, 파일 이름, 뒤따르는 명령"의 세 부분으로 만드는 등 별도 처리가 필요하다. bash는 < infile | cat 처럼 파이프 왼쪽에 명령이 없는 구문도 허용하므로, 이런 경계 사례는 실제 bash 동작과 비교해 정한다.

정리

  • 파싱은 토큰열을 문법에 맞는 트리로 묶는 일이고, 묶는 순서가 곧 의미다.
  • 백트래킹 재귀 하향은 단순하지만 느리고, 플로이드의 연산자 우선순위 파서는 그리디로 O(n)을 얻었다.
  • 프랫 파서는 연산자마다 (lbp, rbp)를 두고 "더 센 연산자가 오는 동안 왼쪽을 키우는" 재귀 함수 하나로 O(n) 파싱을 한다.
  • 우선순위와 결합 방향을 숫자 두 개로 표현하므로 새 연산자를 추가하기 쉽다.

관련 노트: 트리와 이진 탐색 트리, 재귀 함수, 스택, 그리디 알고리즘

원본 기록3

댓글

GitHub 계정으로 댓글을 남길 수 있어요.