C/C++ 디자인 패턴 (23) — 인터프리터(Interpreter)

로그 뷰어에 “레벨 3 이상이면서 커널에서 왔거나 메시지에 oom이 들어간 줄” 같은 조건을 넣고 싶다. 조건 조합마다 체크박스와 코드를 추가할 수는 없으니, 사용자가 level >= 3 and (src == kernel or msg ~ oom)처럼 작은 언어로 조건을 쓰게 하는 편이 낫다. 인터프리터(Interpreter)는 간단한 언어의 문법 규칙 하나하나를 클래스로 표현하고, 문장을 그 클래스들의 트리(추상 구문 트리)로 만든 뒤 트리를 따라 해석한다. 이 글에서는 로그 필터 언어를 만들어 해석하고, 문법 오류 보고, 트리를 클로저로 “컴파일”하는 최적화, C의 후위 표기 스택 인터프리터를 다룬다. GoF 23개 패턴 시리즈의 마지막 편이다.

이 시리즈의 다른 글

구조

참여자역할예제
AbstractExpressioninterpret(context) 선언Expr
TerminalExpression더 쪼개지지 않는 문법 기호 해석Compare (level >= 3)
NonterminalExpression다른 식을 조합하는 규칙, 자식을 재귀 해석And, Or, Not
Context해석에 필요한 전역 정보로그 한 줄의 필드 맵
Client문장으로 트리를 만들고(파서) 해석을 요청Parser, main()

GoF 인터프리터 패턴은 트리를 해석하는 부분만 다루고, 문장을 트리로 바꾸는 파싱은 범위 밖으로 둔다. 예제에서는 문법을 그대로 옮긴 재귀 하강 파서를 함께 만들었다.

로그 필터 언어

// filter.hpp — 로그 필터 언어: 문법 규칙 하나 = 클래스 하나
//   expr   := term ('or' term)*
//   term   := factor ('and' factor)*
//   factor := 'not' factor | '(' expr ')' | field op value
//   op     := '==' | '!=' | '>=' | '<=' | '~'
#pragma once
#include <map>
#include <memory>
#include <stdexcept>
#include <string>

using Context = std::map<std::string, std::string>;      // 해석 문맥: 로그 한 줄의 필드

struct Expr {                                             // AbstractExpression
	virtual ~Expr() = default;
	virtual bool interpret(const Context &c) const = 0;
	virtual std::string str() const = 0;
};
using ExprP = std::unique_ptr<Expr>;

struct Compare : Expr {                                   // TerminalExpression
	std::string field, op, value;
	Compare(std::string f, std::string o, std::string v) : field(std::move(f)), op(std::move(o)), value(std::move(v)) {}
	bool interpret(const Context &c) const override
	{
		auto it = c.find(field);
		if (it == c.end()) return false;
		const std::string &x = it->second;
		if (op == "==") return x == value;
		if (op == "!=") return x != value;
		if (op == "~")  return x.find(value) != std::string::npos;
		long a = std::stol(x), b = std::stol(value);      // >=, <=는 숫자 비교
		return op == ">=" ? a >= b : a <= b;
	}
	std::string str() const override { return field + op + value; }
};

struct And : Expr {                                       // NonterminalExpression
	ExprP l, r;
	And(ExprP a, ExprP b) : l(std::move(a)), r(std::move(b)) {}
	bool interpret(const Context &c) const override { return l->interpret(c) && r->interpret(c); }
	std::string str() const override { return "(" + l->str() + " AND " + r->str() + ")"; }
};
struct Or : Expr {
	ExprP l, r;
	Or(ExprP a, ExprP b) : l(std::move(a)), r(std::move(b)) {}
	bool interpret(const Context &c) const override { return l->interpret(c) || r->interpret(c); }
	std::string str() const override { return "(" + l->str() + " OR " + r->str() + ")"; }
};
struct Not : Expr {
	ExprP e;
	explicit Not(ExprP x) : e(std::move(x)) {}
	bool interpret(const Context &c) const override { return !e->interpret(c); }
	std::string str() const override { return "NOT " + e->str(); }
};

class Parser {                                            // 문장 → 추상 구문 트리
public:
	explicit Parser(std::string s) : s_(std::move(s)) {}
	ExprP parse()
	{
		auto e = expr();
		skip();
		if (pos_ != s_.size())
			fail("unexpected '" + s_.substr(pos_, 1) + "'");
		return e;
	}
private:
	ExprP expr()   { auto e = term();   while (word("or"))  e = std::make_unique<Or>(std::move(e), term());   return e; }
	ExprP term()   { auto e = factor(); while (word("and")) e = std::make_unique<And>(std::move(e), factor()); return e; }
	ExprP factor()
	{
		if (word("not")) return std::make_unique<Not>(factor());
		if (sym("(")) { auto e = expr(); if (!sym(")")) fail("expected ')'"); return e; }
		std::string f = ident();
		for (const char *op : {"==", "!=", ">=", "<=", "~"})
			if (sym(op)) return std::make_unique<Compare>(f, op, ident());
		fail("expected operator after '" + f + "'");
	}
	void skip() { while (pos_ < s_.size() && s_[pos_] == ' ') ++pos_; }
	bool sym(const std::string &t) { skip(); if (s_.compare(pos_, t.size(), t) == 0) { pos_ += t.size(); return true; } return false; }
	bool word(const std::string &w)
	{
		skip();
		std::size_t e = pos_ + w.size();
		if (s_.compare(pos_, w.size(), w) == 0 && (e == s_.size() || s_[e] == ' ' || s_[e] == '(')) { pos_ = e; return true; }
		return false;
	}
	std::string ident()
	{
		skip();
		std::size_t b = pos_;
		while (pos_ < s_.size() && (isalnum((unsigned char)s_[pos_]) || s_[pos_] == '_')) ++pos_;
		if (b == pos_) fail("expected name or value");
		return s_.substr(b, pos_ - b);
	}
	[[noreturn]] void fail(const std::string &m)
	{ throw std::runtime_error("at col " + std::to_string(pos_ + 1) + ": " + m + "\n    " + s_ + "\n    " + std::string(pos_, ' ') + "^"); }
	std::string s_;
	std::size_t pos_ = 0;
};
// logfilter.cpp — 필터 문장을 해석해 로그를 거른다
#include <iostream>
#include <vector>
#include "filter.hpp"

int main()
{
	std::vector<Context> logs = {
		{{"level", "2"}, {"src", "net"},    {"msg", "link up"}},
		{{"level", "4"}, {"src", "kernel"}, {"msg", "oom-killer invoked"}},
		{{"level", "3"}, {"src", "disk"},   {"msg", "smart warning"}},
		{{"level", "5"}, {"src", "app"},    {"msg", "oom in worker"}},
		{{"level", "1"}, {"src", "kernel"}, {"msg", "boot ok"}},
	};
	for (std::string q : {"level >= 3 and (src == kernel or msg ~ oom)",
			      "not src == kernel and level <= 2",
			      "level >= 3 and (src == kernel",
			      "level => 3"}) {
		std::cout << "query: " << q << '\n';
		try {
			ExprP e = Parser(q).parse();
			std::cout << "  tree : " << e->str() << '\n';
			for (const auto &l : logs)
				if (e->interpret(l))
					std::cout << "  match: [" << l.at("level") << "] " << l.at("src") << ": " << l.at("msg") << '\n';
		} catch (const std::exception &ex) {
			std::cout << "  error " << ex.what() << '\n';
		}
	}
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o logfilter logfilter.cpp && ./logfilter
query: level >= 3 and (src == kernel or msg ~ oom)
  tree : (level>=3 AND (src==kernel OR msg~oom))
  match: [4] kernel: oom-killer invoked
  match: [5] app: oom in worker
query: not src == kernel and level <= 2
  tree : (NOT src==kernel AND level<=2)
  match: [2] net: link up
query: level >= 3 and (src == kernel
  error at col 30: expected ')'
    level >= 3 and (src == kernel
                                 ^
query: level => 3
  error at col 7: expected operator after 'level'
    level => 3
          ^

트리 출력을 보면 and가 or보다 먼저 묶였다. 문법 규칙의 층(expr → term → factor)이 그대로 연산자 우선순위가 된 것이다. 잘못된 문장은 해석 전에 파서가 위치와 함께 거부했다.

트리 해석 vs 클로저 컴파일

같은 트리를 매번 해석하면 노드마다 가상 호출과 연산자 문자열 비교, 숫자 변환이 반복된다. 트리를 한 번 람다 조합으로 바꿔 두면 연산자 선택과 상수 변환을 미리 끝낼 수 있다. 20만 줄을 5번 걸러 비교했다.

// compile.cpp — 트리를 매번 해석 vs 한 번 클로저로 "컴파일"한 뒤 실행
#include <chrono>
#include <functional>
#include <iostream>
#include <vector>
#include "filter.hpp"

using Pred = std::function<bool(const Context &)>;

Pred compile(const Expr &e)                         // 트리를 람다 조합으로 변환
{
	if (auto *a = dynamic_cast<const And *>(&e)) { auto l = compile(*a->l), r = compile(*a->r); return [l, r](const Context &c) { return l(c) && r(c); }; }
	if (auto *o = dynamic_cast<const Or *>(&e))  { auto l = compile(*o->l), r = compile(*o->r); return [l, r](const Context &c) { return l(c) || r(c); }; }
	if (auto *n = dynamic_cast<const Not *>(&e)) { auto x = compile(*n->e); return [x](const Context &c) { return !x(c); }; }
	auto *cmp = dynamic_cast<const Compare *>(&e);
	std::string f = cmp->field, v = cmp->value;
	if (cmp->op == "==") return [f, v](const Context &c) { auto it = c.find(f); return it != c.end() && it->second == v; };
	if (cmp->op == "~")  return [f, v](const Context &c) { auto it = c.find(f); return it != c.end() && it->second.find(v) != std::string::npos; };
	long b = std::stol(v);                            // 숫자 변환을 컴파일 시점에 한 번만
	bool ge = cmp->op == ">=";
	return [f, b, ge](const Context &c) { auto it = c.find(f); if (it == c.end()) return false; long a = std::stol(it->second); return ge ? a >= b : a <= b; };
}

int main()
{
	std::vector<Context> logs;
	for (int i = 0; i < 200000; ++i)
		logs.push_back({{"level", std::to_string(i % 6)}, {"src", i % 3 ? "app" : "kernel"}, {"msg", i % 7 ? "ok" : "oom"}});

	ExprP tree = Parser("level >= 3 and (src == kernel or msg ~ oom)").parse();
	Pred fast = compile(*tree);

	auto time = [&](const char *name, auto &&pred) {
		auto t0 = std::chrono::steady_clock::now();
		int n = 0;
		for (int rep = 0; rep < 5; ++rep)
			for (const auto &l : logs) n += pred(l);
		auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
		std::cout << name << ms << " ms, matches=" << n / 5 << '\n';
	};
	time("interpret tree : ", [&](const Context &c) { return tree->interpret(c); });
	time("compiled lambda: ", fast);
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o compile compile.cpp && ./compile
interpret tree : 37.9818 ms, matches=42857
compiled lambda: 29.0971 ms, matches=42857

약 20% 빨라졌지만 극적이지는 않다. 줄마다 std::map에서 필드를 찾는 비용이 대부분이라, 성능이 중요하면 필드를 정수 인덱스로 미리 바꾸는 것처럼 문맥의 표현부터 손봐야 한다. 정규식 엔진이나 SQL 엔진이 해석기 대신 바이트코드나 기계어로 컴파일하는 것도 같은 이유다.

C 구현: 스택 기계

후위 표기(RPN)는 괄호와 우선순위가 없어 트리 없이 스택만으로 해석된다. 숫자는 쌓고, 연산자는 두 개를 꺼내 계산한 결과를 다시 쌓는다.

/* rpn.c — C 인터프리터: 후위 표기(RPN) 산술식을 스택 기계로 해석한다 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define STACK_MAX 64

/* 성공 0, 실패 -1. 오류 위치(토큰 번호)를 *err_at에 남긴다 */
static int rpn_eval(const char *src, double *out, int *err_at)
{
	char buf[256], *save, *tok;
	double st[STACK_MAX];
	int sp = 0, n = 0;

	snprintf(buf, sizeof(buf), "%s", src);
	for (tok = strtok_r(buf, " ", &save); tok; tok = strtok_r(NULL, " ", &save), n++) {
		char *end;
		double v = strtod(tok, &end);

		if (*end == '\0') {                          /* 숫자: 스택에 넣기 */
			if (sp == STACK_MAX) goto error;
			st[sp++] = v;
			continue;
		}
		if (strlen(tok) != 1 || sp < 2)              /* 연산자: 피연산자 2개 필요 */
			goto error;
		double b = st[--sp], a = st[--sp];
		switch (tok[0]) {
		case '+': st[sp++] = a + b; break;
		case '-': st[sp++] = a - b; break;
		case '*': st[sp++] = a * b; break;
		case '/': if (b == 0) goto error; st[sp++] = a / b; break;
		default:  goto error;
		}
	}
	if (sp != 1) goto error;                             /* 값이 정확히 하나 남아야 한다 */
	*out = st[0];
	return 0;
error:
	*err_at = n;
	return -1;
}

int main(void)
{
	const char *progs[] = { "3 4 + 2 *", "5 1 2 + 4 * + 3 -", "2 3 4", "1 0 /", "7 +" };

	for (size_t i = 0; i < sizeof(progs) / sizeof(progs[0]); i++) {
		double v;
		int at;
		if (rpn_eval(progs[i], &v, &at) == 0)
			printf("%-18s = %g\n", progs[i], v);
		else
			printf("%-18s : error at token %d\n", progs[i], at);
	}
	return 0;
}
$ gcc -std=c11 -Wall -Wextra -O2 -D_POSIX_C_SOURCE=200809L -o rpn rpn.c && ./rpn
3 4 + 2 *          = 14
5 1 2 + 4 * + 3 -  = 14
2 3 4              : error at token 3
1 0 /              : error at token 2
7 +                : error at token 1

피연산자가 남는 식, 0으로 나누기, 피연산자가 모자란 연산자를 모두 오류로 처리했다. JVM, Python, eBPF 같은 가상 머신도 규모만 다를 뿐 이렇게 스택이나 레지스터 위에서 명령을 해석하는 구조다.

컴포지트·방문자와의 관계

패턴인터프리터에서의 역할
컴포지트구문 트리 자체의 구조(And가 Expr 두 개를 가짐)
방문자해석 외의 연산(출력, 최적화, 타입 검사)을 노드 밖으로 뺄 때
플라이웨이트같은 리터럴·식별자 노드를 공유할 때
반복자트리를 순회할 때

주의사항

  • 문법이 작을 때만: 규칙 하나가 클래스 하나라서 문법이 커지면 클래스가 폭발한다. 규모가 있는 언어는 파서 생성기(bison, ANTLR)나 기존 언어(Lua 등)를 내장하는 편이 낫다.
  • 사용자 입력은 신뢰하지 않는다: 깊게 중첩된 괄호는 재귀 파서와 재귀 해석의 스택을 넘치게 할 수 있다. 중첩 깊이와 입력 길이를 제한한다.
  • 예외 경로 점검: 예제의 Compare는 숫자가 아닌 값에 >=를 쓰면 std::stol이 예외를 던진다. 실제 도구라면 파싱 단계에서 필드 타입을 검사해 미리 거부한다.
  • 에러 메시지가 사용성이다: 위치 표시(^)처럼 어디가 틀렸는지 보여 주지 않으면 작은 언어도 쓰기 어렵다.
  • 성능은 문맥 표현에서: 위 측정처럼 해석 방식보다 데이터 접근 비용이 더 클 때가 많다. 최적화 전에 어디서 시간이 드는지 측정한다.

마무리

인터프리터는 “문법을 코드 구조로 옮긴다”는 점에서 가장 직접적인 패턴이다. 문법 규칙 → 클래스, 문장 → 객체 트리, 의미 → interpret()가 일대일로 대응한다. 이것으로 생성 패턴 5개, 구조 패턴 7개, 행위 패턴 11개, GoF 23개 패턴을 C++과 C 예제로 모두 살펴봤다. 패턴은 외울 목록이 아니라, 변하는 부분과 변하지 않는 부분을 나누는 방법의 이름이라는 점이 시리즈 전체에 걸친 공통점이다.

참고

답글 남기기