파서 구현에서 마주치는 연산자 우선순위 충돌
표현식 파서를 직접 구현하다 보면 "어떤 연산자가 먼저 적용되어야 하는가"라는 질문에 맞닥뜨리는 순간이 있다. 팩토리얼(!)처럼 수학에서는 익숙하지만 일반적인 프로그래밍 언어에서는 보기 드문 연산자를 추가할 때 특히 그렇다. -3!이라는 표현식 하나가 -(3!) = -6인지, (-3)! = 에러인지를 결정하는 것은 단순한 버그 수정이 아니라 문법 설계 결정이다.
전위 단항 연산자와 후위 연산자의 우선순위 충돌
대부분의 파서 구현은 연산자 우선순위 테이블 또는 재귀 하강 파서의 호출 계층으로 우선순위를 표현한다. 후위 연산자(postfix)는 일반적으로 전위 단항 연산자(prefix unary)보다 피연산자에 더 밀착되어 있다고 간주한다. 수학적 관례 역시 동일한데, n!에서 !는 바로 앞의 값 전체에 적용된다.
// -(2+3)! 파싱 시 우선순위 적용 예
// 후위 > 전위 단항 일 경우
-(2+3)! → -((2+3)!) → -(120) → -120
// 전위 단항 > 후위 일 경우
-(2+3)! → (-(2+3))! → (-5)! → 에러(음수 팩토리얼)
수학적 직관과 실무 관례 모두 후위 연산자가 전위 단항 연산자보다 높은 우선순위를 가져야 한다는 쪽을 지지한다. Python의 ** 연산자와 단항 -의 관계(-2**2 == -4)도 같은 맥락이다.
재귀 하강 파서에서의 실제 처리 방법
재귀 하강 파서에서는 호출 계층의 깊이가 우선순위를 결정한다. 후위 연산자를 더 안쪽(높은 우선순위) 레벨에서 처리하면 전위 단항보다 먼저 바인딩된다.
// 재귀 하강 파서 예시 (후위 > 전위 단항)
private Expr parseUnary() {
if (match(MINUS)) {
Expr operand = parseUnary();
return new UnaryExpr(MINUS, operand);
}
return parsePostfix(); // 후위 연산자를 더 안쪽에서 처리
}
private Expr parsePostfix() {
Expr expr = parsePrimary();
while (match(FACTORIAL)) {
expr = new FactorialExpr(expr); // (2+3)! → Factorial(Group(2+3))
}
return expr;
}
이 구조에서는 -3!이 자연스럽게 -(3!)로 파싱된다. 반대로 parsePostfix를 parseUnary 안쪽에서 처리하지 않고 바깥에 두면 (-3)!로 해석되어 음수 팩토리얼로 인한 에러가 발생한다. 어느 쪽이 맞는지는 기술적으로 강제되지 않지만, 사용자가 기대하는 의미론과의 일치가 핵심 기준이 되어야 한다.
실무에서 커스텀 DSL이나 수식 평가기를 만들 때도 동일한 원칙이 적용된다. 연산자를 추가할 때마다 기존 문법과의 충돌 가능성을 사전에 검토하고, 우선순위 결정의 근거를 문서화해두는 것이 유지보수 측면에서 매우 중요하다.
정리
-3!처럼 전위 단항과 후위 연산자가 충돌할 때, 수학적 관례와 구현 편의 모두 후위 연산자의 우선순위를 더 높게 설정하는 방향을 지지한다.- 재귀 하강 파서에서는
parsePostfix를parseUnary보다 안쪽 계층에서 호출하는 것으로 우선순위를 자연스럽게 표현할 수 있다. - 연산자 추가 시 기존 문법과의 우선순위 충돌을 사전에 검토하고, 결정의 근거를 문서화하는 것이 장기적인 유지보수성을 높인다.