로그를 걸러 보는 작은 필터를 짠다고 하자. 처음엔 “레벨이 ERROR인 줄만” 정도면 충분해서 line.contains("ERROR") 한 줄로 끝난다. 그런데 요구가 붙는다 — “ERROR면서 결제 모듈인 것”, “ERROR거나 WARN인데 결제는 빼고”, “(결제 또는 주문) 그리고 타임아웃”. 사용자가 조건을 문자열로 입력하고 싶어 한다. level = ERROR AND (module = payment OR module = order) AND NOT timeout. 이제 이건 단순한 contains가 아니라 작은 언어다. AND·OR·NOT·괄호가 있고, 우선순위가 있고, 조합이 무한하다.
가장 급한 대로 짜면 거대한 파싱 함수 하나가 태어난다. 토큰을 훑으며 if (token.equals("AND")) … else if (token.equals("OR")) … 분기를 늘리고, 괄호 깊이를 세는 카운터를 두고, 우선순위를 처리하는 특수 케이스를 여기저기 박는다. 문법이 조금만 자라도 이 함수는 아무도 손대기 싫은 수백 줄짜리 괴물이 된다. 인터프리터(Interpreter) 패턴은 이 괴물을 뒤집는다 — 문법의 규칙 하나(AND, OR, NOT, 변수 비교)를 클래스 하나로 만들고, 문장을 그 클래스들의 트리로 세운 뒤, 트리에게 “너 자신을 해석해라”라고 시킨다.
무엇이 문제인가 — 동기
거대 파싱 함수의 아픔은 세 겹이다.
첫째, 문법과 코드가 따로 논다. 종이에 적힌 문법은 “식 = 식 AND 식 | 식 OR 식 | NOT 식 | 변수 = 값 | ( 식 )“처럼 재귀적이고 깔끔하다. 그런데 이걸 하나의 함수 안 switch로 옮기면, 그 재귀 구조가 카운터와 인덱스 조작과 분기 사이로 녹아 사라진다. 문법의 규칙 하나가 코드의 어디에 대응하는지 짚을 수가 없다. “OR 규칙만 고치고 싶다”가 함수 전체를 이해해야 가능한 일이 된다.
둘째, 평가와 파싱이 한 덩어리로 엉킨다. 문자열을 읽어 구조를 알아내는 일(파싱)과, 그 구조가 뜻하는 값을 계산하는 일(평가)은 원래 별개다. 그런데 급조한 함수는 토큰을 읽으면서 그 자리에서 참·거짓을 계산해 버린다. 그래서 같은 식을 “여러 로그 줄에 반복 적용”하려면 매번 문자열을 처음부터 다시 파싱해야 한다. 한 번 해석한 구조를 재사용할 방법이 없다.
셋째, 새 규칙을 넣으려면 잘 돌던 함수를 다시 연다. XOR를 추가하고 싶다, 정규식 매칭 ~ 연산자를 넣고 싶다 — 그럴 때마다 그 거대한 파싱 함수를 열어 분기를 하나 더 끼워 넣어야 한다. 무관한 두 변경이 같은 함수에서 충돌하고, 괄호 처리 같은 잘 돌던 부분을 실수로 깨뜨릴 위험이 매번 따라온다.
판단 기준: 다뤄야 할 문제가 “간단하지만 문법이 있는 언어”로 반복해서 나타나고 — 불린식·검색 필터·계산식·정규식처럼 — 그 문장들을 여러 번 해석해야 한다면 인터프리터를 의심한다. 함정: 문제가 언어가 아닌데 억지로 언어로 본다면 시작부터 잘못이다. 조건이 고정된 몇 가지뿐이라면 그냥 if가 정답이지, 문법을 발명할 일이 아니다.
패턴의 구조 — 누가 무엇을 하나
인터프리터의 핵심은 문법 규칙 하나 = 클래스 하나라는 대응이다. 배역은 넷이다.
AbstractExpression(추상 표현식). 모든 규칙이 공유하는 얼굴. interpret(Context) 하나만 선언한다. 트리의 어떤 노드든 이 얼굴을 하고 있어서, 부모는 자식이 단말인지 연산인지 따지지 않고 그저 interpret을 호출한다.
TerminalExpression(단말 표현식). 문법에서 더 쪼갤 수 없는 잎이다. 불린식이라면 변수 = 값 같은 비교, 상수 true/false가 여기 해당한다. 자기 자신을 보고 곧장 값을 낸다 — 남에게 물을 것이 없다.
NonterminalExpression(비단말 표현식). 다른 표현식을 품는 규칙이다. AND·OR·NOT이 여기다. AndExpression은 왼쪽·오른쪽 표현식 둘을 자식으로 들고 있다가, 자기 interpret이 불리면 자식들의 interpret을 먼저 부른 뒤 그 결과를 AND로 합친다. 규칙이 재귀적이면 트리도 재귀적이고, 해석도 재귀적으로 내려간다.
Context(문맥). 해석에 필요한 바깥 정보를 담는다. “지금 이 로그 줄에서 module의 값은 payment다” 같은 변수 바인딩이 여기 들어 있고, 모든 노드가 같은 Context를 넘겨받아 자기에게 필요한 값을 꺼내 쓴다.
협력의 결정적 지점은 트리를 세우는 일과 해석하는 일이 분리된다는 것이다. 문자열 A AND (B OR C)를 파서가 한 번 읽어 And(A, Or(B, C))라는 트리를 세운다. 그 다음부터 해석은 이 트리의 루트에게 interpret을 부르는 한 줄이다. 루트 And가 자식 A와 Or에게 묻고, Or가 다시 B와 C에게 묻는다. 요청이 트리를 타고 잎까지 흘러 내려갔다가, 값이 뿌리로 거슬러 올라온다. 여기서 트리 구조를 개별·복합 노드의 구분 없이 다루는 방식은 컴포지트와 정확히 같다 — 인터프리터의 표현식 트리는 컴포지트의 한 특수한 쓰임이다.
판단 기준: 문법 규칙을 종이에 BNF로 적었을 때 규칙 하나가 클래스 하나로 자연스럽게 떨어지면 인터프리터가 앉을 자리다. 함정: 파서(문자열 → 트리)까지 이 패턴이 준다고 오해하지 말 것. 인터프리터는 트리가 세워진 다음의 해석만 책임진다. 문자열을 트리로 바꾸는 파싱은 별도의 일이고, 대개 이쪽이 더 어렵다.
코드로 — 패턴이 자라나는 과정
거대 파싱 분기에서 출발해, 규칙을 표현식 클래스로 뽑고, interpret 재귀로 해석이 트리를 타고 흐르게 만드는 과정을 단계로 따라간다.
public class FilterEvaluator { // "ERROR AND payment" 같은 식을 토큰 배열로 받아 그 자리에서 계산한다. public boolean eval(String[] tokens, Map<String, String> row) { boolean acc = true; String op = "AND"; for (int i = 0; i < tokens.length; i++) { String t = tokens[i]; if (t.equals("AND")) { op = "AND"; continue; } if (t.equals("OR")) { op = "OR"; continue; } if (t.equals("NOT")) { // NOT 다음 토큰을 미리 당겨 부정한다 — 특수 케이스가 여기서 샌다 boolean v = row.getOrDefault(splitKey(tokens[++i]), "") .equals(splitVal(tokens[i])); acc = combine(acc, !v, op); continue; } // 괄호? 우선순위? 여기에 또 카운터와 분기를 박아야 한다... boolean v = row.getOrDefault(splitKey(t), "").equals(splitVal(t)); acc = combine(acc, v, op); } return acc; } // combine, splitKey, splitVal... 규칙이 늘 때마다 이 함수가 부푼다.}// 파싱(토큰 훑기)과 평가(참·거짓 계산)가 한 몸이라, 같은 식을 여러 row에// 적용하려면 매번 처음부터 다시 훑는다. 괄호·XOR를 넣으려면 이 함수를 또 연다.
첫 스텝의 거대한 eval 함수가 사라진 게 아니라 여러 표현식 클래스로 흩어졌다. 파싱과 평가가 엉켜 있던 한 덩어리에서, 평가만 떼어내 트리 노드 각자의 interpret으로 나눠 준 것이다. 트리를 세우는 일(마지막 스텝의 new And(...))은 이제 해석과 명확히 갈라졌고, 한 번 세운 트리는 여러 Context에 재사용된다. 무엇보다 문법 규칙과 클래스가 일대일로 맞아떨어져서 — And가 곧 AND 규칙의 문서다 — “OR만 고치기”가 Or 클래스 하나를 여는 일이 됐다.
판단 기준: 리팩터링 후 “새 연산자 추가 = 새 표현식 클래스 하나, 기존 클래스 0줄 수정”이 성립하면 인터프리터가 제대로 앉은 것이다. 함정: 마지막 스텝에서 트리를 손으로 new And(...) 조립했다는 점을 놓치지 말 것 — 실전에선 문자열을 이 트리로 바꾸는 파서가 필요하고, 그 파서의 복잡도는 이 패턴이 줄여 주지 않는다.
결과 — 무엇을 얻고 무엇을 내주나
인터프리터가 사는 값은 문법을 클래스로 표현해 확장을 쉽게 만드는 것이다. 규칙 하나가 클래스 하나이므로, 문법이 어떻게 생겼는지가 클래스 계층에 그대로 드러난다. 새 규칙을 더하는 일은 Expression을 구현한 클래스를 하나 추가하는 일이고, 기존 규칙을 고치는 일은 그 규칙의 클래스 하나를 여는 일이다 — 개방-폐쇄 원칙이 문법 수준에서 성립한다. 파싱과 평가가 갈라지므로 한 번 세운 트리를 여러 Context에 반복 적용할 수 있고, 트리를 순회하는 다른 연산(예쁘게 출력하기, 최적화하기)을 얹기도 쉽다.
대가는 만만치 않다. 첫째, 규칙이 많아지면 클래스가 폭발한다. 문법 규칙이 스무 개면 클래스도 스물 안팎이다. 산술식처럼 연산자·함수·우선순위가 풍부한 문법을 이 방식으로 다루면, 자잘한 표현식 클래스가 수십 개 쏟아져 전체를 조망하기 어려워진다. 둘째, 복잡한 문법에는 애초에 맞지 않는다. 문법이 커지면 클래스 계층을 관리하는 비용이 급격히 커지고, 이쯤 되면 손으로 짠 표현식 트리보다 파서 생성기(ANTLR 같은)나 별도의 파싱 기법이 훨씬 낫다. 인터프리터는 어디까지나 단순한 문법을 위한 패턴이다. 셋째, 앞서 짚었듯 파싱은 여전히 남는 숙제다. 이 패턴은 트리가 세워진 다음의 해석만 우아하게 만들 뿐, 문자열을 트리로 바꾸는 일은 별개의 노력으로 남는다.
운영비용 렌즈로 보면 순이득의 조건이 분명하다. 문법이 작고 안정적이며, 그 문장을 여러 번 해석해야 한다면 — 설정 필터, 규칙 엔진의 조건식, 계산기 — 늘어난 클래스의 대가보다 “문법이 곧 코드 구조”가 되는 명료함과 재사용의 이득이 크다. 반대로 문법이 크거나 계속 자라날 것 같으면, 클래스 폭발이 이득을 삼켜 버린다.
판단 기준: 얻는 것(문법 확장 쉬움·트리 재사용·규칙별 격리)과 내주는 것(클래스 폭발·파싱 숙제·복잡 문법 부적합)을 세어 순이득이 양수일 때만 앉힌다. 함정: “이것도 언어로 볼 수 있다”는 유혹에 지면 문법이 복잡한 문제까지 표현식 트리로 억지로 눌러 담게 된다 — 규칙이 십수 개를 넘어가면 그건 인터프리터가 아니라 파서 생성기의 영역이다.
언제 쓰고, 언제 과용인가
인터프리터가 정확히 맞는 자리는 이렇다. 되풀이되는 문제를 간단한 언어의 문장으로 표현할 수 있고, 그 문법이 작고 안정적일 때. 불린 조건식, 검색·필터 표현식, 간단한 계산식, 그리고 좁은 범위의 정규식이 교과서적 사례다. 효율이 최우선 관심사가 아닐 때도 잘 맞는다 — 트리를 재귀로 걷는 해석은 최적화된 파서보다 느리지만, 규칙 엔진이나 설정 해석처럼 초당 수백만 번 돌 일이 아닌 자리에선 그 느림이 문제가 되지 않는다.
과용의 냄새는 두 방향에서 난다. 하나는 문법이 없는데 언어를 발명하는 것. 조건이 서너 개로 고정돼 있고 늘어날 일이 없다면, 그건 if 몇 줄이면 될 일을 표현식 트리와 파서까지 끌어와 감싸는 과잉이다. 다른 하나는 반대로 문법이 너무 커진 뒤에도 이 패턴을 고집하는 것. 규칙이 수십 개로 불어나 클래스가 폭발하기 시작하면, 인터프리터는 이미 자기 적정 규모를 넘어섰다는 신호다. 이럴 땐 파서 생성기로 갈아타는 편이 정직하다.
이웃 패턴과의 경계를 그어 둘 필요가 있다. 컴포지트는 인터프리터의 뼈대다 — 표현식 트리는 개별 노드(단말)와 복합 노드(비단말)를 같은 얼굴로 다루는 컴포지트 그 자체이고, interpret이 그 트리를 재귀로 걷는다. 방문자는 해석 로직을 표현식 클래스 바깥으로 빼내고 싶을 때 짝이 된다 — interpret을 각 표현식에 두는 대신, 트리를 순회하는 Visitor 하나에 모든 해석 로직을 모으면 표현식 클래스는 구조만 남기고 연산은 방문자로 분리된다(단, 이러면 새 규칙 추가가 어려워지는 방문자의 대가를 함께 진다). 플라이웨이트는 트리에 같은 단말이 잔뜩 반복될 때 쓴다 — 불린식에서 true/false 상수나 같은 변수 참조처럼, 상태 없는 단말 표현식은 인스턴스 하나를 공유해 트리 전체가 그것을 가리키게 하면 메모리를 아낀다.
판단 기준: 문제를 BNF 몇 줄로 적을 수 있고 그 줄 수가 앞으로도 십수 개를 넘지 않으면 인터프리터, 문법이 그보다 크거나 성능이 병목이면 파서 생성기·전용 파싱으로 간다. 함정: 트리 노드의 상태·순회·확장이 필요해지면 컴포지트·방문자·플라이웨이트를 각각 끌어와야 하는데, 이 셋을 다 얹다 보면 “간단한 언어” 하나가 네 패턴이 얽힌 덩어리가 된다 — 그 무게가 정당한지를 문법의 크기로 되물어야 한다.
다음으로 반복자