블록을 조립해서 로봇 동작을 만드는 티칭 도구를 개발하다 보면 결국 한 가지 문제에 도달하게 됩니다.
블록의 속성값을 언제까지 상수로만 둘 것인가 하는 문제 입니다.
‘이동’ 블록의 Z축 값에 그냥 100을 넣는 것으로 끝나면 좋겠지만, 실제 현장에서는 아래와 같은 요구가 계속 들어옵니다.
- 직전 비전 검사 블록이 돌려준 보정값을 더한 위치로 이동
- 적재 카운트에 따라 달라지는 높이 계산
- 센서 값이 임계치를 넘으면 A, 아니면 B로 분기
이런 요구를 처리하는 가장 손쉬운 방법은 문자열 치환 입니다.
${block1.result} 같은 토큰을 정규식으로 찾아 값으로 바꿔치기 하고, 남은 문자열을 간단한 계산기에 던지는 방식 입니다.
초반에는 잘 동작하지만 요구사항이 조금만 늘어나면 금방 무너집니다.
1 + 2 * 3 // 연산자 우선순위
(a + b) * (c - d) // 괄호 중첩
sensor != null && sensor.value > 10 // 단축 평가
MAX(offsetX, MIN(limit, base + 5)) // 중첩 함수 호출
count > 5 ? 100 : 50 // 삼항 연산
정규식으로 이 조합을 감당하려는 순간부터는 버그를 잡는 시간이 기능을 만드는 시간보다 길어집니다.
결론부터 말하면 표현식은 문자열이 아니라 트리로 다뤄야 합니다.
개념을 먼저 정리한 뒤, 실제 스튜디오 백앤드에 들어간 Studio.Expressions 모듈을 기준으로 구현을 보겠습니다.
컴파일러는 언어를 어떻게 읽는가
사람이 쓴 코드는 컴퓨터 입장에서는 그냥 문자의 나열 입니다.
a + b * 2는 아홉 글자짜리 문자열일 뿐이고, 여기에 “곱셈이 덧셈보다 먼저”라는 정보는 어디에도 적혀 있지 않습니다.
컴파일러와 인터프리터가 하는 일의 앞부분은 이 평평한 문자열에서 구조를 복원하는 과정 입니다.
교과서적으로 이 앞단(프론트엔드)은 세 단계로 나뉩니다.
- 어휘 분석(Lexical Analysis): 문자 나열을 의미 있는 최소 단위인 토큰으로 자릅니다.
a,+,b,*,2. 이 일을 하는 것이 Lexer(또는 Scanner) 입니다. - 구문 분석(Syntax Analysis): 토큰 나열이 문법에 맞는지 검사하면서 계층 구조, 즉 트리를 만듭니다. 이 일을 하는 것이 Parser 입니다.
- 의미 분석(Semantic Analysis): 만들어진 트리를 보고 타입이 맞는지, 선언되지 않은 이름을 쓰지는 않았는지 확인합니다. 정적 타입 언어라면 이 단계가 두꺼워지고, 동적 타입이면 상당 부분이 실행 시점으로 밀립니다.
일반적인 컴파일러는 여기서 더 나아가 중간 표현(IR)을 만들고 최적화한 뒤 기계어를 뽑습니다.
반면 우리처럼 표현식만 다루는 경우에는 트리를 만든 다음 그 트리를 걷어 다니며 값을 계산하면 끝입니다. 이 방식을 트리 워킹 인터프리터(Tree-walking Interpreter) 라고 부릅니다.
문법(Grammar)이라는 것
파서가 “문법에 맞는지”를 판단하려면 문법이 형식적으로 정의되어 있어야 합니다.
보통 BNF, EBNF 표기법으로 씁니다. 사칙연산만 있는 아주 작은 문법을 예로 들면 이렇습니다.
expression -> term (( "+" | "-" ) term)*
term -> factor (( "*" | "/" ) factor)*
factor -> NUMBER | "(" expression ")"
expression, term, factor처럼 다른 규칙으로 펌쳐지는 이름을 논터미널(nonterminal), NUMBER나 "+"처럼 더 이상 펌쳐지지 않는 실제 토큰을 터미널(terminal) 이라고 합니다.
이 세 줄에는 이미 중요한 정보가 들어 있습니다.
- 우선순위:
expression이term을 부르고term이factor를 부릅니다. 나중에 불리는 쪽, 즉 트리에서 더 깊은 쪽이 먼저 계산됩니다. 그래서*가+보다 우선순위가 높아집니다. - 결합성:
term (("+"|"-") term)*처럼 반복으로 쓰면 왼쪽부터 묶이는 좌결합이 됩니다.
즉, 문법 규칙을 어떻게 배치하느냐가 곳 우선순위와 결합성 규칙이 됩니다.
뒤에서 나올 파서 코드가 이 문법을 거의 그대로 옮겨 놓은 형태라는 점을 눈여겨보면 좋습니다.
파스 트리와 AST의 차이
파싱 결과로 나오는 트리는 두 종류로 이야기합니다.
파스 트리(Parse Tree), 다른 말로 구체 구문 트리(CST, Concrete Syntax Tree) 는 문법 규칙을 그대로 반영한 트리 입니다.
(1 + 2) * 3을 위 문법으로 파싱하면 expression 아래 term 아래 factor 아래 NUMBER 식으로 중간 노드가 잔뜩 생기고, 괄호나 쉼표 같은 기호도 노드로 남습니다.
AST(Abstract Syntax Tree, 추상 구문 트리) 는 여기서 계산에 필요 없는 것을 걷어낸 트리 입니다. 이름에 ‘추상’이 붙은 이유가 그것 입니다.
"(1 + 2) * 3"
[파스 트리] [AST]
expression Mul
term / \
factor Add 3
"(" expression ")" / \
term 1 2
...
"*"
factor
NUMBER(3)
괄호는 AST에 남지 않습니다. 괄호의 역할은 “어느 것을 먼저 묶을 것인가”를 알려주는 것이고, 그 정보는 이미 트리 모양에 반영되어 있기 때문 입니다.
우선순위도 마찬가지입니다. 트리가 만들어진 시점에 이미 Mul 아래에 Add가 들어가 있으므로, 평가할 때는 그냥 자식부터 계산하면 됩니다. 우선순위 표를 다시 볼 일이 없습니다.
정리하면 문법의 복잡함을 파서가 한 번 흔수해서 평가기에게 단순한 트리를 넘긴다, 이것이 AST의 핵심 입니다.
이 글에서 만들 것도 파스 트리가 아니라 AST 입니다. 실무에서 파스 트리를 그대로 유지하는 경우는 코드 포매터나 린터처럼 공백과 괄호까지 원본 그대로 복원해야 하는 도구 정도 입니다.
파싱 방식: 하향식과 상향식
트리를 만드는 방식은 크게 두 갈래 입니다.
하향식(Top-down) 은 시작 규칙에서 출발해서 아래로 내려가며 토큰과 맞춰봅니다.
LL 파서가 여기에 속하고, 재귀 하강(Recursive Descent) 이 대표적인 구현 방식 입니다. 문법 규칙 하나가 메서드 하나에 그대로 대응되기 때문에 사람이 읽기 쉽고 손으로 짜기 좋습니다. 대신 좌재귀 문법을 그대로 쓸 수 없고, 문법이 커지면 코드도 그만큼 늘어납니다.
상향식(Bottom-up) 은 토큰에서 출발해서 규칙과 맞는 부분을 찾아 위로 접어 올라갑니다.
LR, LALR 계열이고 yacc, bison, ANTLR 같은 파서 생성기가 이 방식을 씁니다. 다룰 수 있는 문법의 범위가 넣지만, 문법 파일과 생성된 코드 사이에 한 겹이 끼기 때문에 충돌이 났을 때 원인을 찾기가 까다롭습니다.
참고로 손으로 짠 재귀 하강 파서는 장난감 수준의 선택이 아닙니다.
GCC, Clang, Roslyn(C# 컴파일러), V8의 자바스크립트 파서 모두 손으로 작성한 재귀 하강 파서를 씁니다. 오류 메시지와 오류 복구 전략을 세밀하게 통제할 수 있다는 점이 생성기의 이점보다 크기 때문 입니다.
표현식 하나 파싱하는 데 파서 생성기를 붙일 이유는 더더욱 없습니다. 그래서 여기서도 재귀 하강으로 갑니다.
전체 파이프라인
개념을 정리했으니 이제 구현입니다. 앞에서 본 세 단계를 그대로 옮기면 이렇게 됩니다.
"a.b + MAX(1, 2) * 3"
|
| Lexer (어휘 분석)
v
[Identifier(a)][Dot][Identifier(b)][Plus][Identifier(MAX)][LParen] ...
|
| Parser (구문 분석, 재귀 하강)
v
BinaryOp(Add)
/ \
MemberAccess(a.b) BinaryOp(Mul)
/ \
FunctionCall(MAX) Literal(3)
|
| Evaluator (평가)
v
결과 값
모듈 구성도 이 단계를 그대로 따라갑니다.
Studio.Expressions/
Lexer/ Token, TokenType, Lexer
Parsing/ Parser, Ast/ (노드 정의)
Operators/ BinaryOperator, UnaryOperator, OperatorSemantics
Evaluation/ ExpressionEvaluator, FunctionRegistry, IExpressionContext
Exceptions/ ExpressionSyntaxException, ExpressionRuntimeException
AST를 스쳐 지나가는 중간 산출물이 아니라 별도로 보관하는 자산으로 두면 얻는 것이 명확합니다.
- 파싱 1회, 평가 N회: 티칭 프로그램은 블록 하나가 루프 안에서 수천 번 실행됩니다. 매 실행마다 문자열을 다시 토큰화할 이유가 없습니다. AST를 캐싱해 두고 평가만 반복하면 됩니다.
- 검증 시점 분리: 사용자가 속성값을 저장하는 시점에 파싱만 돌려서 문법 오류를 잡아낼 수 있습니다. 로봇이 움직이는 도중에 문법 오류를 만나는 상황을 없앨 수 있습니다.
- 정적 분석 가능: 트리를 순회하면 이 표현식이 어떤 블록을 참조하는지, 어떤 함수를 쓰는지 목록을 뽑을 수 있습니다. 블록 간 의존성 그래프를 그리거나 삭제된 블록 참조를 경고하는 기능이 여기서 나옵니다.
1단계. Lexer - 문자열을 토큰으로
Lexer는 문자 스트림을 의미 단위인 토큰으로 자릅니다.
토큰 자체는 아주 단순한 레코드 입니다.
public sealed record Token(
TokenType Type,
string Lexeme,
object? Literal,
int Line,
int Column);
여기서 놓치기 쉬운 부분이 Line, Column 입니다. 이 정보를 토큰에 담아두지 않으면 “문법 오류”라는 말만 하고 어디가 문제인지 알려줄 수 없습니다. 블록 속성 입력창에 오류 위치를 표시하려면 토큰 생성 시점에 위치를 기록해 두어야 합니다.
스캔 로직에서 신경 쓸 지점은 최장 일치(maximal munch) 입니다.
>를 만났을 때 바로 Gt 토큰을 만들면 >=와 >>를 영원히 만들 수 없습니다. 한 글자 더 보고 판단해야 합니다.
case '>':
if (Match('='))
{
AddToken(TokenType.GtEq);
break;
}
AddToken(Match('>') ? TokenType.ShiftRight : TokenType.Gt);
break;
case '&':
if (Match('&'))
{
AddToken(TokenType.And);
break;
}
AddToken(TokenType.BitAnd);
break;
숫자 처리에서는 정수와 실수를 구분할지 결정해야 합니다.
전부 double로 통일하면 구현은 단순해지지만 10 / 3이 3이 아니라 3.333...이 됩니다. 로봇 제어 쪽은 비트 마스크나 정수 나눗셈을 그대로 쓰는 경우가 많아서 소수점 유무로 long과 double을 구분해 두었습니다.
var isDouble = false;
if (Peek() == '.' && IsDigit(PeekNext()))
{
isDouble = true;
Advance();
while (IsDigit(Peek())) Advance();
}
var text = _source[_start.._current];
object value = isDouble
? double.Parse(text, CultureInfo.InvariantCulture)
: long.Parse(text, CultureInfo.InvariantCulture);
AddToken(TokenType.Number, value);
CultureInfo.InvariantCulture를 명시한 것도 의도가 있습니다. 지역 설정에 따라 소수점 구분자가 ,인 환경이 있고, 그런 환경에서 파싱이 깨지면 재현하기 까다로운 버그가 됩니다.
2단계. Parser - 토큰을 AST로
앞에서 본 재귀 하강(Recursive Descent) 방식을 그대로 적용합니다.
핵심 아이디어는 문법의 우선순위 규칙을 메서드 호출 사다리로 옮기는 것 입니다. 앞서 문법 예제에서 expression이 term을, term이 factor를 불렀던 것과 정확히 같은 구조로, 우선순위가 낮은 연산자를 처리하는 메서드가 바깥쪽에 있고 그 안에서 한 단계 높은 우선순위 메서드를 호출 합니다.
ParseAssignment = (가장 낮음, 우결합)
ParseTernary ? :
ParseOr ||
ParseAnd &&
ParseBitwiseOr |
ParseBitwiseAnd &
ParseEquality == !=
ParseComparison < <= > >=
ParseShift << >>
ParseAdditive + -
ParseMultiplicative * / %
ParseUnary ! -
ParsePrimary 리터럴, 식별자, 함수 호출, 괄호, 멤버 접근
좌결합 연산자는 while 루프로 처리 합니다.
루프를 돌면서 지금까지 만든 노드를 왼쪽 자식으로 밀어 넣으면 자연스럽게 왼쪽으로 기우는 트리가 만들어집니다.
private ExpressionNode ParseAdditive()
{
var expression = ParseMultiplicative();
while (true)
{
if (Match(TokenType.Plus))
{
expression = new BinaryOpNode(BinaryOperator.Add, expression, ParseMultiplicative());
}
else if (Match(TokenType.Minus))
{
expression = new BinaryOpNode(BinaryOperator.Sub, expression, ParseMultiplicative());
}
else
{
return expression;
}
}
}
반대로 우결합 연산자는 자기 자신을 재귀 호출 합니다. 대입이 대표적인데 a = b = 1은 a = (b = 1)로 묶여야 합니다. 대입 대상은 아무 표현식이나 올 수 없기 때문에 여기서 한 번 걸러 줍니다.
private ExpressionNode ParseAssignment()
{
var target = ParseTernary();
if (!Match(TokenType.Eq))
{
return target;
}
if (target is not IdentifierNode and not MemberAccessNode)
{
throw Error(Previous(), "invalid assignment target");
}
var value = ParseAssignment(); // 우결합
return new AssignmentNode(target, value);
}
1 + 2 = 3 같은 입력이 들어와도 런타임까지 가지 않고 파싱 단계에서 잘립니다. 위치 정보와 함께 오류를 던지기 때문에 사용자에게 몇 번째 글자가 문제인지 그대로 보여줄 수 있습니다.
AST 노드는 전부 record로 정의했습니다. 불변이고, 값 기반 비교가 공짜로 따라오고, 무엇보다 평가기에서 패턴 매칭이 깔끔해집니다.
public abstract record ExpressionNode;
public sealed record LiteralNode(object? Value) : ExpressionNode;
public sealed record IdentifierNode(string Name) : ExpressionNode;
public sealed record MemberAccessNode(ExpressionNode Target, string Member) : ExpressionNode;
public sealed record UnaryOpNode(UnaryOperator Op, ExpressionNode Right) : ExpressionNode;
public sealed record BinaryOpNode(BinaryOperator Op, ExpressionNode Left, ExpressionNode Right) : ExpressionNode;
public sealed record TernaryNode(ExpressionNode Cond, ExpressionNode Then, ExpressionNode Else) : ExpressionNode;
public sealed record AssignmentNode(ExpressionNode Target, ExpressionNode Value) : ExpressionNode;
public sealed record FunctionCallNode(string Name, IReadOnlyList<ExpressionNode> Args) : ExpressionNode;
여기서 한 가지 중요한 설계 결정이 있습니다. 파서는 토큰 타입을 그대로 트리에 넣지 않습니다. TokenType.Plus가 아니라 BinaryOperator.Add로 변환해서 담습니다.
어휘 수준의 개념과 의미 수준의 개념을 분리해 두면, 나중에 표기법을 바꾸거나 별칭을 추가해도 평가기는 손대지 않아도 됩니다.
ParsePrimary에서는 식별자를 읽은 다음 (가 붙어 있으면 함수 호출, 아니면 변수 참조로 갈라집니다.
else if (Match(TokenType.Identifier))
{
var identifier = Previous();
expression = Match(TokenType.LParen)
? ParseFunctionCall(identifier)
: new IdentifierNode(identifier.Lexeme);
}
...
// 후위 처리: a.b.c 처럼 연속된 멤버 접근을 왼쪽부터 감싼다
while (Match(TokenType.Dot))
{
var member = Consume(TokenType.Identifier, "expected member name after '.'");
expression = new MemberAccessNode(expression, member.Lexeme);
}
3단계. Evaluator - AST를 값으로
평가기는 트리를 후위 순회하면서 값을 접습니다. switch 식과 패턴 매칭 덕분에 노드 종류별 분기가 그대로 코드가 됩니다.
private object? Eval(ExpressionNode node, IExpressionContext context)
=> node switch
{
LiteralNode literal => literal.Value,
IdentifierNode identifier => context.Get(identifier.Name),
MemberAccessNode member => context.Get(FlattenPath(member)),
UnaryOpNode unary => OperatorSemantics.Apply(unary.Op, Eval(unary.Right, context)),
BinaryOpNode binary => EvalBinary(binary, context),
TernaryNode ternary => OperatorSemantics.IsTruthy(Eval(ternary.Cond, context))
? Eval(ternary.Then, context)
: Eval(ternary.Else, context),
AssignmentNode assignment => EvalAssignment(assignment, context),
FunctionCallNode call => EvalFunction(call, context),
_ => throw new ExpressionRuntimeException($"알 수 없는 표현식 문법 입니다. node: {node.GetType().Name}"),
};
삼항 연산을 보면 조건이 참일 때 Then만 평가하고 Else는 아예 건드리지 않습니다. 자식 노드를 미리 다 평가해 놓고 고르는 방식으로 만들면 선택되지 않은 쪽의 부수 효과까지 실행되어 버리기 때문 입니다.
같은 이유로 &&, ||도 다른 이항 연산자와 분리해서 다룹니다.
private object? EvalBinary(BinaryOpNode binary, IExpressionContext context)
{
if (binary.Op == BinaryOperator.And)
{
var left = Eval(binary.Left, context);
return OperatorSemantics.IsTruthy(left) ? Eval(binary.Right, context) : left;
}
if (binary.Op == BinaryOperator.Or)
{
var left = Eval(binary.Left, context);
return OperatorSemantics.IsTruthy(left) ? left : Eval(binary.Right, context);
}
// 나머지는 양쪽을 모두 평가한 뒤 연산자 의미를 적용
return OperatorSemantics.Apply(binary.Op, Eval(binary.Left, context), Eval(binary.Right, context));
}
OperatorSemantics.Apply는 인자를 이미 평가된 값으로 받기 때문에, 여기에 맡기는 순간 단축 평가는 불가능해집니다.
“평가 여부를 결정하는 연산자는 평가기의 책임” 이라고 정리해 두면 헷갈리지 않습니다.
바깥 세계와의 접점, IExpressionContext
표현식 모듈이 로봇 런타임을 직접 알아서는 안 되기 때문에, 변수 조회와 저장은 메서드 두 개로만 뚫어 두었습니다.
public interface IExpressionContext
{
object? Get(string path);
void Set(string path, object? value);
}
a.b.c처럼 점으로 이어진 접근은 평가 시점에 하나의 경로 문자열로 평탄화해서 컨텍스트에 넘깁니다.
private static string FlattenPath(ExpressionNode node)
=> node switch
{
IdentifierNode identifier => identifier.Name,
MemberAccessNode member => $"{FlattenPath(member.Target)}.{member.Member}",
_ => throw new ExpressionRuntimeException("잘못된 변수 참조 입니다. 변수 경로를 확인해주세요."),
};
리플렉션으로 실제 객체 그래프를 타고 들어가지 않는 이유는, 블록 실행 컨텍스트가 CLR 객체가 아니라 블록 ID와 파라미터 ID로 구성된 논리적 저장소이기 때문 입니다.
경로 해석을 컨텍스트 구현체에 위임해 두면 표현식 모듈은 로봇 도메인을 전혀 몰라도 되고, 단위 테스트에서는 Dictionary 하나로 컨텍스트를 대신할 수 있습니다.
그리고 티칭 도구에서 가장 많이 쓰이는 “직전 블록의 결과 참조”는 별도 함수로 노출했습니다.
// RESULTPARAM("block-3", "detectedCount") 형태의 호출을
// "block-3.Result.Parameters.detectedCount" 경로 조회로 변환
result = context.Get($"{blockId}.Result.Parameters.{parameterId}");
사용자가 내부 경로 규칙을 외울 필요가 없고, 저장소 구조가 바뀌어도 이 변환 지점 한 곳만 고치면 됩니다.
일반 함수는 FunctionRegistry에 등록해서 씁니다. 기본으로 MIN, MAX, ABS, AVG, POW가 들어 있고, 도메인 함수가 필요하면 Register()로 추가하면 됩니다.
동적 타입 연산의 규칙 정하기
object?를 값으로 다루는 순간, 연산자 의미를 직접 정의해야 합니다. 애매하게 두면 반드시 문제가 되기 때문에 OperatorSemantics에 규칙을 못 박아 두었습니다.
- 정수 보존: 양쪽이 모두 정수 타입이면 정수 연산, 아니면 실수 연산으로 처리
- truthy 규칙: null은 거짓, bool은 그대로, 숫자는 0이면 거짓
- 비트 연산은 정수만:
&,|,<<,>>에 실수가 오면 조용히 잘라내지 않고 예외. 시프트 횟수도 0~63으로 검증 - 0으로 나누기:
double나눗셈은 예외 없이Infinity를 반환하므로, 잘못된 값이 로봇 좌표까지 흘러가지 않도록 명시적으로 검사해서 예외
public static bool IsTruthy(object? value)
{
if (value is null) return false;
if (value is bool b) return b;
if (IsNumeric(value)) return Math.Abs(ToDouble(value)) > double.Epsilon;
return true;
}
private static object Numeric(object? left, object? right,
Func<long, long, long> integerOp, Func<double, double, double> doubleOp)
=> BothIntegers(left, right)
? integerOp(ToLong(left), ToLong(right))
: doubleOp(ToDouble(left), ToDouble(right));
암묵적 변환으로 넘어가는 것보다 명시적으로 실패하는 편이 디버깅 비용이 훨씬 적습니다.
예외 계층도 두 갈래로 나눠 두었습니다.
ExpressionException (abstract)
├─ ExpressionSyntaxException 파싱 단계, 위치 정보(line:column) 포함
└─ ExpressionRuntimeException 평가 단계
└─ UnknownIdentifierException
구문 예외는 속성을 저장하는 시점에 잡아서 입력창에 바로 표시하고, 런타임 예외는 프로그램 실행 중 로그와 정지 처리로 넘깁니다. 예외 타입만 보고 처리 경로를 나눌 수 있습니다.
실제 사용 모습
외부에 노출되는 진입점은 인터페이스 하나 입니다. Parse와 Evaluate가 분리되어 있는 점이 중요 합니다.
public interface IExpressionEvaluator
{
ExpressionNode Parse(string source);
object? Evaluate(string source, IExpressionContext context);
object? Evaluate(ExpressionNode ast, IExpressionContext context);
}
블록 속성을 저장할 때는 Parse만 호출해서 문법 검증과 AST 캐싱을 하고, 실행 시점에는 캐싱된 AST로 Evaluate만 반복 호출하면 됩니다.
// 1. 티칭 시점: 문법 검증 + AST 캐싱
var evaluator = new ExpressionEvaluator();
var ast = evaluator.Parse("PARAM(\"targetZ\") + RESULTPARAM(\"vision-1\", \"offsetZ\")");
block.CachedAst = ast;
// 2. 실행 시점: 캐싱된 AST로 평가만 반복
var z = evaluator.Evaluate(block.CachedAst, runtimeContext);
// 3. 조건 블록도 동일한 파이프라인
var repeat = evaluator.Evaluate("loopCount < MAX(3, targetCount)", runtimeContext);
if (OperatorSemantics.IsTruthy(repeat)) { /* 반복 */ }
정리
- 표현식은 문자열 치환으로 버티지 말고 어휘 분석, 구문 분석, 평가의 세 단계로 나눈다
- 파스 트리가 아니라 AST를 만든다. 괄호와 우선순위 같은 문법상의 장치는 트리 모양으로 흔수되어 사라진다
- 중간에 AST를 두면 파싱과 평가를 분리할 수 있고, 검증 시점 이동과 캐싱, 정적 분석이 전부 여기서 나온다
- 재귀 하강 파서의 우선순위는 메서드 호출 사다리로 표현하고, 좌결합은 while, 우결합은 재귀로 처리한다
- 단축 평가와 삼항 연산처럼 평가 여부를 결정하는 연산은 반드시 평가기 책임으로 둔다
- 동적 타입 연산의 규칙(정수 보존, truthy, 0 나누기, 비트 연산 타입 제약)은 애매하게 두지 말고 명시적으로 정의한다
- 바깥 세계와의 접점은
IExpressionContext하나로 좁혀서 도메인 의존성을 차단한다
코드 양으로 보면 파일 스무 개 남짓, 1,000줄이 조금 넘는 규모 입니다.
스크립트 엔진을 통째로 붙이는 것보다 훨씬 가볍고, 문법과 함수 셋을 완전히 통제할 수 있다는 점이 가장 큰 장점 입니다.
워크플로우 엔진의 조건 분기, 알림 규칙, 리포트 계산 필드처럼 표현식이 필요한 곳이라면 같은 구조를 그대로 쓸 수 있습니다.