주의: 이 글은 Python 3 입장에서 작성되었습니다. 2에서의 동작을 보장하지 않습니다.


예전에 IRC Bot을 개발하던 시절의 이야기 입니다. 당시 저는 간편하게 사용 가능한 계산 기능이 필요했는데, 마땅한 방법을 알지 못해서 exec를 할 때 local 영역에 들어가는 __builtins__를 뜯어고쳐서 썼었습니다. 하지만 그건 너무나도 비정상적인 방법이란 생각이 들어서 방법을 바꾸기로 했습니다.

처음 썼던 방식은 Google 검색에 수식을 넣으면 결과로 계산된 값이 나오는 것을 이용했었습니다. 당연히 구글이 제공하는거니 매우 잘 동작했지만 구글의 마크업이 변할때마다 대응해줘야해서 귀찮았습니다. Python sandbox를 만들라는 제안을 받았었지만 당시로썬 그런건 할 줄 몰랐습니다. 그래서 취한 방법은 직접 계산하는 것이었습니다. 한번 읽어봤던 자료구조 책에서 Stack으로 수식 계산을 하는 방법이 있었어서1 그 방법을 차용해서 제가 원하는 수준의 계산기를 구현해버렸습니다.2 문제는 1.사용자 입력이 깔끔하다는 보장이 없다는 것(띄어쓰기 유무, 올바르지 않은 수식) 2.제가 만든 구린 처리기가 인식할 수 없는 경우들이었습니다. 저는 그 문제를 무식하게 re.sub을 이용해서 적당히 고쳐서 동작하게 만들었는데, 지금 생각해보면 __builtins__를 고치는것 만큼이나 이상한 방법인 것 같습니다.

그러던 와중에 다른 분이 만든 봇에도 계산 기능이 있어서 보니까 작동 중 실패하면 ASTError라는 것이 나왔습니다. 당시에 나는 Python에 ast라는 모듈이 있다는 건 docs를 읽다가 우연히 보긴 했었지만 정작 AST가 뭔지 몰랐었습니다. 나는 AST라는걸 쓰면 되나보다 하고 넘어갔었습니다. 그리고 시간이 흘러 PHP 7에 AST가 생긴다는 소식을 듣는다던가 하는 식으로 AST라는 단어를 몇번 더 마주하였지만 “문법을 저장하는 트리” 정도로만 이해하고 넘어갔습니다.

그리고 지금 보고 있는 책, 실전 파이썬 프로그래밍3에서 AST를 또 마주하게 되었습니다. 저는 AST에 대해서 관심이 많은 상태에서 읽게 됬는데4 불행히도 이 책은 AST라는것이 있다는 정도만 소개해주고 있었습니다. 결국 목 마른 자가 우물을 판다고, 직접 몸통박치기를 시작했습니다.

일단 내가 목표로 잡은 것은 이것입니다.

0.1을 10번 더했을 때 1.0이 나오게 해보자.

당연히 1.0이 나오는 것 아니냐고 생각하시는 분들은 지금 빨리 가서 직접 더해보고 오시길 권해드립니다. 실험해보면 알 수 있지만 1.0이 나오지 않습니다. 왜냐하면 Python도 부동소숫점을 쓰기 때문입니다. 이 문제를 해결하는 Pythonic한 방법은 decimal모듈을 사용하는 것입니다.

>>> 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1
0.9999999999999999
>>> import decimal
>>> decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1')
Decimal('1.0')

당연히 계산기를 쓰는 사람들에게 *“숫자를 쓸때 decimal.Decimal이라고 쓰세요”*라고 할 순 없으므로 숫자를 자동으로 치환해주는 작업이 필요합니다.

문제를 좀 더 구체화해보기로 했습니다. 다음과 같은 변수를 가지고 있다고 가정합니다.

expr = 'print(0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1)'

그러면 이제 실행을 시키면 내부적으론 이렇게 실행되어야 합니다.

print(decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1') + decimal.Decimal('0.1'))

그러려면 ast모듈을 활용해야 합니다. AST(Abstract Syntax Tree)는 이름 그대로 Tree형의 자료 구조입니다. 각각의 노드를 갖게 되는데 실제로 1.2 + 3.4의 AST를 뜯어보면 다음과 같습니다.

>>> import ast
>>> ast.dump(ast.parse('1.2 + 3.4'))
'Module(body=[Expr(value=BinOp(left=Num(n=1.2), op=Add(), right=Num(n=3.4)))])'

ast.parse로 AST 구조를 뽑아낼 수 있고, ast.dump로 결과물을 볼 수 있습니다. 결과물에 보이는 Module이라던가 Expr등등은 모두 ast 모듈에서 제공하는 클래스들입니다. 우리가 주목할 것은 Num입니다. 이것은 숫자 표현을 나타내는데, 내 목표는 이걸 (어떻게 하는지는 몰라도) decimal.Decimal로 바꿔버리는 것입니다.

ast 모듈은 이러한 작업을 간편하게 할 수 있도록 ast.NodeTransformer라는 클래스를 제공합니다. 이것을 상속받아서 원하는 node 종류를 고칠 수 있습니다. 내가 바꿀 종류는 Num이므로 visit_Num(self, node)와 같은 형태의 메소드를 만들어두고, Num이 아닌 다른 값을 return해주면 변경이 될 것입니다.

그러면 decimal.Decimal은 어떻게 사용할까요? 역시 아까와 같은 방법으로 decimal.Decimal('12.34') + decimal.Decimal('56.78')을 대상으로 ast.dump를 사용해보기로 했습니다.

>>> ast.dump(ast.parse("decimal.Decimal('12.34') + decimal.Decimal('56.78')"))
"Module(body=[Expr(value=BinOp(left=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='12.34')], keywords=[], starargs=None, kwargs=None), op=Add(), right=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='56.78')], keywords=[], starargs=None, kwargs=None)))])"

아까랑 다른 점은 Call, Attribute, Str같은 새로운 것들이 출현한 점 입니다. 추측해보건데 Call__call__을, Attribute__getattr__을 의미할 것입니다. Strstr형이라고 생각했습니다.

일단 아주 단순무식하게 바꿔치기해보기로 했습니다.

>>> import ast
>>> expr = '1.2 + 3.4'
>>> node = ast.parse(expr)
>>> class NumTransformer(ast.NodeTransformer):                                                                 ...     def visit_Num(self, node):                                                                               ...         return ast.Call(func=ast.Attribute(value=ast.Name(id='decimal', ctx=ast.Load()), attr='Decimal', ctx=ast.Load()), args=[ast.Str(s=str(node.n))], keywords=[])
...
>>> NumTransformer().visit(node)
<_ast.Module object at 0x10077b898>
>>> ast.dump(node)
"Module(body=[Expr(value=BinOp(left=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='1.2')], keywords=[]), op=Add(), right=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='3.4')], keywords=[])))])"

일단 AST 자체는 의도한대로는 나온 것 같습니다. 하지만 이 소스는 동작하지 않습니다.

>>> compile(node, '', 'exec')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: required field "lineno" missing from expr

왜냐하면 ast.AST는 모두 lineno라는 속성과 col_offset이란 속성을 필요로 하기 때문입니다. 이 속성은 강제로 지정해줘도 되지만, 혹시라도 만져야할 AST의 원본 소스 코드가 여러 줄일 경우에는 곤란할 수 있습니다. 이럴 경우 ast.copy_location을 사용하면 됩니다.

아래는 실행이 가능하도록 고친 소스입니다.

import ast


class NumTransformer(ast.NodeTransformer):

    def visit_Num(self, node):
        decimal = ast.copy_location(
            ast.Name(id='decimal', ctx=ast.Load()), node
        )
        Decimal = ast.copy_location(
            ast.Attribute(value=decimal, attr='Decimal', ctx=ast.Load()),
            node
        )
        num = ast.copy_location(ast.Str(s=str(node.n)), node)
        return ast.copy_location(
            ast.Call(
                func=Decimal,
                args=[num],
                keywords=[]
            ),
            node
        )

이제 이 클래스를 불러다가 쓰면 됩니다.

>>> from test import *
>>> expr = '1.2 + 3.4'
>>> node = ast.parse(expr)
>>> NumTransformer().visit(node)
<_ast.Module object at 0x10077b8d0>
>>> exec(compile(node, '', 'exec'), {})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "", line 1, in <module>
NameError: name 'decimal' is not defined
>>> exec(compile(node, '', 'exec'), {'decimal': __import__('decimal')})

하지만 아무것도 나오지 않는데, 원인이 세가지 있습니다. 첫째로 decimal이 내부적으로 정의가 안되있을 것입니다. 하지만 마지막 줄에서 정의해줘도 변화가 없습니다. 또 하나는 실행 모드가 exec라는 점이고, 마지막은 node의 상태입니다. 다시 node를 열어보았습니다.

>>> ast.dump(node)
"Module(body=[Expr(value=BinOp(left=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='1.2')], keywords=[]), op=Add(), right=Call(func=Attribute(value=Name(id='decimal', ctx=Load()), attr='Decimal', ctx=Load()), args=[Str(s='3.4')], keywords=[])))])"

이 상태는 말 그대로 node가 품고 있는 것이 Module로 처리되고 있다는 뜻인데, Module은 exec로밖에 감당이 안됩니다. 이것은 ast.parse를 실행할때 인자를 달리 주면 해결됩니다.

>>> import ast
>>> import decimal
>>> from test import *
>>> expr = '1.2 + 3.4'
>>> node = ast.parse(expr, '', 'eval')
>>> NumTransformer().visit(node)
<_ast.Expression object at 0x10186ac88>
>>> eval((compile(node, '', 'eval')))
Decimal('4.6')

eval을 사용하므로 decimal을 import 해준 부분에 유의해주세요. 바꾸고 나니 잘 되는 것 같습니다. 근데 정말로 의도한 것 처럼 되는것인지 0.1을 10번 더해보았습니다.

>>> expr = '0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1'
>>> node = ast.parse(expr, '', 'eval')
>>> NumTransformer().visit(node)
<_ast.Expression object at 0x10186acc0>
>>> eval((compile(node, '', 'eval')))
Decimal('1.0')

매우 잘 되고 있음이 확인 가능합니다.


계산기 만들기는 여기서 살짝만 응용하면 되는 영역이므로 어렵지 않을것으로 예상됩니다. 그보다, math.sin 같은 함수를 지원한다거나 할 것이 아니라면 ast.literal_eval로도 이미 충분한걸지도 모르겠습니다. 물론 그 경우엔 ast.literal_eval('{1, 2, 3}')같은 것도 유효한 식으로 처리되긴 합니다. 저는 그게 싫어서라도 따로 만들것입니다. 어떻게 할 것인지는 다음 기회에 글로 쓸 생각입니다.

각주.

  1. 정확히는 in-order 표기법으로 된 것을 post-order방식으로 바꾸는 것과, 별도로 있는 post-order방식으로 표기된 것을 해석하는 아주 간략한 예제였습니다. 원래 위치로

  2. 당시에 난 내가 뭘 만들었는지 몰랐었는데 깨닫고보니 아주 간단한 lexer였습니다. 지금 보면 소스도 무지 지저분하다고 생각하는데 당시로썬 아주 열심히 만든 것이었습니다. 리팩토링을 하게 된다면 더 깔끔하게 만들 수도 있을 것 같지만, 일단 저 DogBot이란 프로젝트 자체는 더 이상 개선할 생각이 없습니다. 당장에 저 소스는 PEP-8 준수만 시켜도 diff가 심각하게 나올 것인지라… 원래 위치로

  3. 책 베타 리더로 소마 멘토님이 참여하셨었는데, “집에 꼽아놓고 먼지만 쌓이게 할 것 아니면 읽어볼만 하다.”라고 하셔서 지른 책입니다. 절반쯤 읽었는데 정말 추천할만 합니다. 원래 위치로

  4. GraphQL의 Python구현에 관심이 있는데, 저걸 알려면 Lexer를 다룰 줄 알아야하고, Lexer는 AST를 반환해주므로 관심이 가고 있었습니다. 원래 위치로