Interpreter 패턴: 도메인 규칙을 작은 언어로 만들어 해석하기
Interpreter 패턴으로 도메인 규칙을 소형 언어로 모델링하는 방법과 AST 캐싱, 파이썬 예제, 파서 제너레이터와의 트레이드오프를 정리한다.
2026-08-13 · 최초 발행 2025-10-14
타겟팅 조건이나 정책 규칙이 바뀔 때마다 배포를 새로 해야 한다면, 그 규칙을 코드 밖으로 꺼내 작은 언어로 표현하는 방법을 고려할 만하다. Interpreter 패턴은 그 언어의 문법을 클래스 계층으로 모델링하고, 구문 트리(AST)를 만들어 해석하는 구조를 제공한다. 문법이 단순하고 입력 크기가 작을 때 맞는 접근이고, 문법이 복잡해지거나 입력이 커지면 파서 제너레이터나 JIT/바이트코드 생성 쪽이 낫다.
언어를 객체로 모델링한다는 것
문장을 언어 문법에 따라 해석하는 클래스를 정의하고, 구문 트리를 구성해 실행하는 것이 Interpreter 패턴의 핵심이다. 각 규칙을 표현식 클래스(터미널/비터미널)로 분해하고, Context가 실행 환경을 제공한다. 구문을 코드에 직접 매핑하는 대신 언어의 문법 자체를 객체로 모델링하는 셈이다 — Parser가 AST를 만들고, 각 노드가 interpret(context)를 통해 의미론을 평가한다.
문법과 AST 구조
터미널 표현식은 더 이상 분해되지 않는 노드다. 리터럴이나 식별자가 여기 해당하고, NumberExpression·VariableExpression 같은 형태로 구현한다. 비터미널 표현식은 규칙의 조합을 나타낸다 — AddExpression, MultiplyExpression, BooleanAndExpression 같은 노드가 여기 속한다.
Context와 환경
변수 바인딩, 함수 테이블, 상수 풀을 Context가 들고 있고, 해석 시점에 동적으로 조회한다. 스코프와 기본값, 미정의 변수를 어떻게 처리할지도 Context의 정책이고, 성능을 위해 캐시를 걸 수도 있다.
확장과 변경
새 연산자를 추가할 때는 클래스 1~2개와 파서 규칙만 더하면 되고 기존 코드에는 영향이 없다. Visitor를 적용하면 분석·최적화·코드 생성 같은 부가 기능을 비침투적으로 확장할 수 있다.
성능과 운영에서 고려할 점
단순한 문법이면 파싱과 해석 모두 O(n)에 가깝다. 반복 평가가 많다면 AST를 캐싱해 지연시간을 30~70% 줄일 수 있다. 다만 AST 객체 수만큼 메모리 오버헤드가 생기고 재귀 호출 스택도 늘어나므로, 심볼 테이블 조회 비용을 최소화하는 게 좋다.
컴파일과 실행이 나뉘는 구조
입력은 expression 문자열과 변수 맵, 안전 정책이다. 처리는 세 단계로 진행된다 — 토큰화(숫자·식별자·연산자·괄호를 분리하고 위치 정보를 붙인다), 파싱(재귀 하강 파서로 우선순위를 처리해 AST를 구성한다), 해석(Context로 변수를 조회하고 연산을 수행하며 단락 평가를 적용한다). 출력은 값이거나, 오류가 나면 메시지·위치·오류 유형이다.
오류는 발생 지점에 따라 처리 정책이 다르다. 어휘 오류(미지원 문자)는 즉시 중단하고 위치를 보고한다. 구문 오류(괄호 불일치, 연산자 누락)는 기대 토큰 힌트를 제공한다. 실행 오류(0으로 나눔, 미정의 변수)는 기본값을 쓸지 실패로 반환할지 정책으로 정한다.
산술 DSL 하나로 보는 구현
전제조건은 Python 3.10 이상이며 외부 라이브러리는 필요 없다. 변수, 정수/실수, 사칙연산, 괄호를 지원하고 AST 캐싱 예시도 포함한다.
# file: mini_interpreter.py
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
# ---------- Lexer ----------
@dataclass
class Token:
type: str
text: str
pos: int
def lex(src: str) -> List[Token]:
tokens: List[Token] = []
i = 0
while i < len(src):
c = src[i]
if c.isspace():
i += 1
continue
if c.isdigit() or (c == '.' and i + 1 < len(src) and src[i+1].isdigit()):
start = i
dot = c == '.'
i += 1
while i < len(src) and (src[i].isdigit() or (src[i] == '.' and not dot)):
if src[i] == '.':
dot = True
i += 1
tokens.append(Token("NUMBER", src[start:i], start))
continue
if c.isalpha() or c == '_':
start = i
i += 1
while i < len(src) and (src[i].isalnum() or src[i] == '_'):
i += 1
tokens.append(Token("IDENT", src[start:i], start))
continue
if c in "+-*/()":
type_map = {"+":"PLUS","-":"MINUS","*":"STAR","/":"SLASH","(":"LPAREN",")":"RPAREN"}
tokens.append(Token(type_map[c], c, i))
i += 1
continue
raise SyntaxError(f"어휘 오류: 지원하지 않는 문자 '{c}' at {i}")
tokens.append(Token("EOF", "", len(src)))
return tokens
# ---------- AST ----------
class Expr:
def interpret(self, ctx: Dict[str, float]) -> float:
raise NotImplementedError
@dataclass
class Number(Expr):
value: float
def interpret(self, ctx: Dict[str, float]) -> float:
return self.value
@dataclass
class Variable(Expr):
name: str
def interpret(self, ctx: Dict[str, float]) -> float:
if self.name not in ctx:
raise NameError(f"미정의 변수: {self.name}")
return float(ctx[self.name])
@dataclass
class Binary(Expr):
op: str
left: Expr
right: Expr
def interpret(self, ctx: Dict[str, float]) -> float:
l = self.left.interpret(ctx)
# 간단한 단락 평가 예시는 산술에는 적용하지 않음
r = self.right.interpret(ctx)
if self.op == '+': return l + r
if self.op == '-': return l - r
if self.op == '*': return l * r
if self.op == '/':
if r == 0:
raise ZeroDivisionError("0으로 나눔")
return l / r
raise ValueError(f"지원하지 않는 연산자: {self.op}")
# ---------- Parser (Recursive Descent) ----------
class Parser:
def __init__(self, tokens: List[Token]):
self.toks = tokens
self.i = 0
def peek(self) -> Token:
return self.toks[self.i]
def consume(self, typ: str) -> Token:
t = self.peek()
if t.type != typ:
raise SyntaxError(f"구문 오류: {typ} 기대, 실제 {t.type} at {t.pos}")
self.i += 1
return t
def parse(self) -> Expr:
expr = self.expr()
self.consume("EOF")
return expr
# expr -> term (('+'|'-') term)*
def expr(self) -> Expr:
node = self.term()
while self.peek().type in ("PLUS", "MINUS"):
op_tok = self.peek(); self.i += 1
right = self.term()
node = Binary('+' if op_tok.type=="PLUS" else '-', node, right)
return node
# term -> factor (('*'|'/') factor)*
def term(self) -> Expr:
node = self.factor()
while self.peek().type in ("STAR", "SLASH"):
op_tok = self.peek(); self.i += 1
right = self.factor()
node = Binary('*' if op_tok.type=="STAR" else '/', node, right)
return node
# factor -> NUMBER | IDENT | '(' expr ')'
def factor(self) -> Expr:
t = self.peek()
if t.type == "NUMBER":
self.i += 1
return Number(float(t.text))
if t.type == "IDENT":
self.i += 1
return Variable(t.text)
if t.type == "LPAREN":
self.i += 1
node = self.expr()
self.consume("RPAREN")
return node
raise SyntaxError(f"구문 오류: 값 기대 at {t.pos}")
# ---------- Compile & Evaluate with Cache ----------
class Engine:
def __init__(self):
self.cache: Dict[str, Expr] = {}
def compile(self, source: str) -> Expr:
if source in self.cache:
return self.cache[source]
tokens = lex(source)
ast = Parser(tokens).parse()
self.cache[source] = ast
return ast
def eval(self, source: str, context: Dict[str, float]) -> float:
ast = self.compile(source)
return ast.interpret(context)
# ---------- Demo ----------
if __name__ == "__main__":
eng = Engine()
expr = "(a + b*2) / (c - 1)"
ctx = {"a": 10, "b": 3, "c": 5}
val = eng.eval(expr, ctx)
print(f"결과: {val}") # 결과: 4.0
# 캐시 히트 예시
val2 = eng.eval(expr, {"a": 12, "b": 1, "c": 3})
print(f"결과2: {val2}") # 결과2: 6.5
운영 팁을 정리하면 이렇다. 보안 측면에서는 외부 입력을 평가할 때 허용 연산 화이트리스트, 시간·스텝 제한, 입력 길이 제한을 적용한다. 성능 측면에서는 AST 캐시 키에 버전·테넌트 식별자를 포함시키고, 변수 조회를 dict 대신 배열 인덱싱으로 최적화할 수 있다. 관찰성 측면에서는 파싱 실패율, 캐시 히트율, 평가 지연(P50/P95)을 메트릭으로 수집한다.
피처 플래그 규칙과 로그 필터 DSL
피처 플래그나 타겟팅 규칙(country == 'KR' and app_version >= 120 같은 비교·논리 연산 중심)을 규칙 엔진으로 구성할 때 쓴다. 서버·SDK 쪽에 AST 캐싱을 적용하면 요청당 규칙 평가 지연을 최소화할 수 있다. 로그·이벤트 필터 DSL(level in ('WARN','ERROR') and service =~ /auth.*/ 같은 실시간 스트림 필터링)에도 쓰고, 이때는 샌드박스 평가와 리소스 제한으로 안전하게 운영한다. 주문 금액·재고·회원 등급에 따른 승인 규칙 같은 정책·검증 규칙도 배포 없이 바꿀 수 있어 운영 민첩성이 올라간다. 이메일·문서 템플릿의 조건부 렌더링이나 간단한 계산식도 대상이다.
규칙을 바꿀 때 재배포가 필요 없고 운영 콘솔에서 수정할 수 있어, 평균 변경 리드타임을 50% 이상 단축할 수 있다는 관찰도 있다. 복잡한 외부 엔진을 도입하는 것과 비교하면 총소유비용(TCO)도 아낄 수 있어 소규모 팀도 유지보수하기 어렵지 않다.
트레이드오프와 대안
문법은 단순화하고 정규화하며, 연산자 우선순위를 명확히 하는 게 기본이다. AST를 캐싱하고 불변 객체를 쓰면 스레드 안전성도 챙길 수 있다. 에러 메시지에 위치와 기대 토큰을 넣으면 디버깅이 수월해진다.
다만 문법이 복잡해질수록 확장 비용이 늘고 클래스가 폭증하는 문제가 생긴다. 대규모 입력이나 복잡한 분석에는 JIT/바이트코드나 파서 제너레이터가 성능에서 우위다. 정적 타입 검증이 부족하면 런타임 오류 가능성도 운영 리스크로 남는다.
| 접근 | 성능 | 확장성 | 일관성 | 안정성 | 운영 편의 |
|---|---|---|---|---|---|
| Interpreter 패턴 | 단순 문법에서 양호, 캐시로 개선 | 연산자 추가 용이, 대규모 문법에선 불리 | 구현에 따라 편차 | 코드 기반이므로 예측 가능 | 경량, 내장 배포 용이 |
| 파서 제너레이터(ANTLR 등) | 대형 문법·최적화에 유리 | 문법 파일 중심 확장 용이 | 공식 문법으로 일관성 높음 | 검증된 런타임 사용 | 도구 학습 필요 |
| 표현식 라이브러리(SpEL/JEXL 등) | 엔진 최적화로 우수 | 플러그인·함수 확장 지원 | 표준 문법 제공 | 널리 사용, 안정성 높음 | 의존성·샌드박스 설정 필요 |