수식을 계산하는 프로그램은 문자열을 왼쪽부터 순서대로 계산해서는 안 됩니다.
예를 들어 2*(3+4)-5와 같은 식을 처리할 때, 괄호 안을 먼저 계산하고 다음으로 곱셈을 수행한 후 마지막에 뺄셈을 해야 합니다.
이번 글에서는 Perl로 작성된 Ast.pm 모듈을 다루며, 수식으로부터 AST(추상 구문 트리)를 구축하고 이 트리를 따라 계산하는 메커니즘을 추적해 보겠습니다.
Tool::mmt::Controller::Ast는 문자열 형태의 수식을 이진 트리(AST)로 변환하고, 그 트리를 순회하며 계산을 수행하는 Mojolicious 컨트롤러입니다. 대상 파일은 Ast.pm (커밋 59dd332) 입니다.
- 입력: 요청 파라미터
calc (예: 2*(3+4)-5)
- 출력: stash의
anser (계산 결과)와 tree (Data::Dumper를 이용한 트리 덤프)
- 방식: 식을 '가장 우선순위가 낮은 연산자'를 기준으로 좌우로 분할하는 과정을 재귀적으로 반복하여 트리를 만들고, 나뭇잎(leaf)부터 순차적으로 계산합니다.
eval을 사용하여 문자열을 그대로 실행하지 않기 때문에, 입력이 코드로 실행될 위험은 없습니다.
연산자는 %$op에 [처리 코드 리퍼런스, 우선순위, 결합성]의 형태로 정의되어 있습니다. 숫자가 작을수록 결합성이 약하며, 트리의 위쪽(루트 근처)에 위치하게 됩니다.
| 연산자 | 우선순위 | 결합성 | 처리 |
|---|
+ - | 1 | 좌 | 덧셈・뺄셈 |
* / % | 2 | 좌 | 곱셈・나눗셈・나머지 |
** | 4 | 우 | 거듭제곱 |
NGE | 4 | 우 | 단항 마이너스 (파싱 시 -를 대체하는 내부용 기호) |
( ) | 9 / 10 | 좌 | 아무것도 안 함. 정규 표현식으로 분할하기 위해 등록 |
setReOps는 이 키들을 긴 순서대로 배열하여 정규 표현식 $s->{ops}를 만듭니다. 긴 순서로 만드는 이유는 **를 * 두 개와 오인하지 않기 위함입니다.
요청부터 답까지의 과정은 다음 순서로 일직선으로 진행됩니다.
- (액션):
ast
calc 파라미터를 가져와 formula로 Astnew에 전달합니다. 반환값인 anser를 stash에 저장합니다.
- (컨트롤러): 인수를 해시로 만들고,
Astnew의 setReOps로 연산자의 정규 표현식을 준비합니다. formula가 있으면 _ast를 호출합니다.
- (메서드): 다음 4~7을 순차적으로 실행하여, 트리의 덤프를 stash의
tree에, 결과를 $s->{anser}에 저장합니다.
- (정규식 처리): 연산자 앞뒤에 공백을 넣습니다. 숫자나
adjust 바로 뒤에 (가 있으면 *를 보강합니다 ((1+2)(2-1) $
ightarrow$ (1+2)*(2-1)).
- (토큰화): 공백으로 분리하여 토큰 배열인
item_split을 $s->{item}으로 만듭니다.
- (트리 구축): 토큰 열에서 이진 트리를 구성합니다 (다음 절에서 자세히 설명).
makeTree
- (계산): 트리를 나뭇잎부터 루트 방향으로 순회하며 값을 계산합니다.
readTree
노드는 newNode가 생성하는 {data => 연산자, left => 왼쪽, right => 오른쪽} 형태의 해시입니다. 나뭇잎은 실제 숫자 값(스칼라)이며, readTree는 '해시 참조 여부'를 통해 나뭇잎과 연산자를 구분합니다.
readTree의 동작:
- 나뭇잎이면 그 값을 반환합니다.
NGE라면 오른쪽 자식을 계산하여 부호를 반전합니다 (왼쪽은 사용하지 않음).
- 그 외는 좌우를 재귀적으로 계산하고, 그 결과를 노드에 다시 기록한 후 연산자의 코드를 적용합니다.
makeTree:
'괄호 밖에 있는, 우선순위가 가장 낮은 연산자'를 루트로 삼고, 그 왼쪽과 오른쪽 토큰 열로 자신을 재귀적으로 호출합니다.
- 외부 괄호 제거: 시작이
(이고 끝이 )이며, 이 괄호가 식 전체를 감싸는 경우에만 제거합니다. ((1))와 같은 중첩 괄호는 반복해서 제거합니다. (1+2)*(3+4)처럼 여러 개의 괄호가 있는 경우는 제거하지 않습니다.
- 나뭇잎 판별: 토큰이 하나뿐이면 그것을 나뭇잎으로 반환합니다.
- 분할점 찾기: 괄호 깊이가 0인 토큰만 보고, 우선순위가 가장 낮은 연산자의 위치를 기록합니다. 비교는
juge_priority가 수행합니다.
- 분할 및 재귀: 분할점보다 앞을 왼쪽 자식으로, 뒤를 오른쪽 자식으로 하여
makeTree를 호출합니다.
를 호출하고, newNode로 묶습니다.
동일한 우선순위가 나열되었을 때 (결합성)
- 좌측 결합 (
+ - * / %):
같은 순위라면 뒤의 연산자로 덮어쓰기 때문에 가장 오른쪽에서 분할합니다. 8-3-2는 (8-3)-2 = 3이 됩니다.
- 우측 결합 (
**, NGE):
더 낮은 경우에만 덮어쓰기 때문에 가장 왼쪽에서 분할합니다. 2**3**2는 2**(3**2) = 512가 됩니다.
단항 마이너스
-가 식의 시작 부분에 있거나, ) 이외의 연산자 직후에 있을 때, NGE로 대체합니다. NGE 노드는 left가 비어 있고, right에 해당 식을 가집니다. 우선순위는 **와 같은 4이므로, -2**2는 -(2**2) = -4가 됩니다 (수학의 일반적인 해석과 동일).
calc=2*(3+4)-5를 전달하면 답은 9가 됩니다.
adjust와 item_split을 통해 토큰열 2 * ( 3 + 4 ) - 5가 됩니다. - 괄호 밖에 있는 연산자는 * (순위 2)와 - (순위 1)입니다. 낮은 -이 뿌리(root)가 되고, 왼쪽은 2 * ( 3 + 4 ), 오른쪽은 5가 됩니다. - 좌측에서는 *로 분할하고, 왼쪽이 2, 오른쪽이 ( 3 + 4 )가 됩니다. - ( 3 + 4 )는 바깥쪽 괄호를 제거하고 +로 분할하며, 왼쪽이 3, 오른쪽이 4가 됩니다. - readTree가 잎사귀부터 계산합니다: 3+4 = 7 → 2*7 = 14 → 14-5 = 9.
[embedded content: 2*(3+4)-5의 AST · 연산자 3개, 잎 4개]
나무는 위에서 만들어지고 계산은 아래(잎)부터 진행됩니다. 우선순위가 낮은 연산자일수록 뿌리에 가깝고 마지막에 계산됩니다.
입력 검사가 없기 때문에, 잘못된 식이라도 에러 없이 어떤 결과를 반환한다는 점에 주의해야 합니다.
| 입력/위치 | 현재 동작 | 개선 방안 |
|---|
1/0 , 1%0 | Perl이 | |
- 오류 처리 부재: 0으로 나누면 die하고, 잘못된 수식이라도 undefined나 잘못된 값을 조용히 반환함
- 효율성: 분할할 때마다 배열을 잘라내어 재탐색하므로 최악의 경우 O(n²). 계산기 식의 길이라면 실질적으로는 문제가 없음
- 오타(
anser, juge_priority), _text에 대한 버그, 입력에 NGE라고 쓸 수 있는 점 - 이 파일에서는 테스트 코드를 확인할 수 없음
개선 우선순위
_ast를 eval { }로 감싸서 오류 발생 시 anser에 메시지를 넣도록 수정함 - 토큰화 단계에서 괄호 대응과 '숫자/연산자가 번갈아 나타나는지'를 검사하도록 함
- 계산 예시나 경계 케이스를
Test::More의 테스트로 작성함
본격적으로 진행한다면 Shunting-yard 법이나 재귀 하강 파서(Recursive Descent Parser)로 만들 경우, 검사와 속도 모두 좋아짐. 다만 'AST를 만든 후 계산하는' 메커니즘을 보여주는 것이 목적이라면 현재 구조가 이해하기 더 쉬움.