23. Interpreter — no-cheese, extra-sauce, well-done을 피자 객체로 바꾼다

작성일:2026.05.21|수정일:2026.08.04|조회수:0

23. Interpreter — no-cheese, extra-sauce, well-done을 피자 객체로 바꾼다

에이든 피자의 시범 운영 마지막 날이다. 가게는 이제 완벽한 시스템을 갖춘 듯 보였지만, 배달 앱 연동을 시작하자마자 예상치 못한 복병을 만났다. 바로 '특수 요청 사항'이다.

손님들은 메모란에 저마다의 언어로 요구사항을 남긴다. "no-cheese, extra-sauce, well-done" "no-olive, no-onion, light" "extra-pepperoni, well-done"

처음에는 이 문자열을 쉼표로 자르고 if-else 문을 돌려 처리했다. "no-"로 시작하면 토핑을 빼고, "extra-"로 시작하면 토핑을 더하는 식이었다. 하지만 요청의 종류가 늘어날수록 파싱 로직은 점점 지저분해졌다. "double no-cheese" 같은 중첩된 표현이나 "no-cheese and extra-sauce" 같은 복잡한 문법이 등장하기라도 하면, 우리의 파싱 함수는 순식간에 읽을 수 없는 암호문이 될 게 뻔했다.

무엇이 불편한가

지금의 가장 큰 문제는 '언어'를 다루는 방식이 파편화되어 있다는 점이다. 특수 요청은 일종의 '미니 언어(DSL, Domain Specific Language)'다. 이 언어에는 나름의 문법이 있고, 그 문법에 따라 해석되는 규칙이 있다. 그런데 우리는 이 규칙을 하나의 거대한 함수 안에 절차적으로 구겨 넣으려 하고 있었다.

새로운 명령어(예: "light", "half-half")가 추가될 때마다 명령어를 표현식에 연결하는 파서의 매핑은 바꿔야 했고, 문법 규칙이 복잡해질수록 실수할 확률도 높아졌다. 표현식 클래스를 추가하는 것만으로 새 문자열 문법이 자동 인식되지는 않는다. 우리는 이 '특수 요청'이라는 언어의 문법 자체를 코드로 표현하고 싶었다. 문법 규칙 하나를 클래스 하나로 만들고, 문장이 들어오면 이 클래스들이 서로 협력해 의미를 해석하는 구조 말이다.

Interpreter 패턴

Interpreter 패턴은 어떤 언어에 대해 문법을 정의하고, 그 언어의 문장을 해석하는 인터프리터를 제공하는 패턴이다. GoF의 원래 의도는 다음과 같다.

언어의 문법을 클래스 계층으로 나타내어, 그 언어로 된 문장을 해석한다.

이 패턴에는 다음과 같은 역할들이 등장한다.

  1. Abstract Expression (추상 표현식): 모든 문법 규칙이 따라야 할 인터페이스다. 보통 interpret(context) 메서드를 가진다.
  2. Terminal Expression (종단 표현식): 문법의 최소 단위다. 실제 연산을 수행한다. (예: NoTopping, ExtraTopping)
  3. Non-Terminal Expression (비종단 표현식): 다른 표현식들을 조합하여 더 큰 의미를 만드는 규칙이다. (예: 명령어들의 목록)
  4. Context (문맥): 해석에 필요한 정보나 해석 결과가 반영될 대상이다. 여기서는 Pizza 객체가 그 역할을 한다.

우리만의 주문 해석기 만들기

에이든 피자의 특수 요청 DSL을 처리할 인터프리터를 만들어보자. 먼저 피자 객체에 요청을 적용할 인터페이스다.

TS
interface SpecialRequestExpression {  interpret(pizza: Pizza): void;}

이제 실제 동작을 수행할 종단 표현식들을 만든다. "토핑 빼주세요"와 "잘 구워주세요" 같은 기본 명령들이다.

TS
class NoToppingExpression implements SpecialRequestExpression {  constructor(private ingredient: string) {}  interpret(pizza: Pizza) {    pizza.removeTopping(this.ingredient);  }}class WellDoneExpression implements SpecialRequestExpression {  interpret(pizza: Pizza) {    pizza.setDoneness("well-done");  }}class LightExpression implements SpecialRequestExpression {  interpret(pizza: Pizza) {    pizza.setDoneness("light");  }}

그리고 여러 명령을 순서대로 실행해줄 비종단 표현식도 추가한다.

TS
class RequestListExpression implements SpecialRequestExpression {  constructor(private expressions: SpecialRequestExpression[]) {}  interpret(pizza: Pizza) {    this.expressions.forEach(e => e.interpret(pizza));  }}

마지막으로 문자열을 읽어 이 표현식 트리로 만들어줄 Parser가 필요하다. 파서는 문자열을 분석해 "이건 NoToppingExpression이야", "이건 WellDoneExpression이야"라고 판단하고 조립해준다. 이제 "no-cheese, well-done"이라는 문자열이 들어오면, 파서는 두 개의 표현식을 품은 RequestListExpression을 돌려주고, 우리는 그저 그 객체의 interpret(pizza)를 호출하면 된다.

TypeScript 포인트

Interpreter 패턴을 구현할 때 TypeScript의 정적 타입 시스템은 큰 도움이 된다. 특히 표현식의 interpret 메서드에 제네릭을 적용하면, 다양한 도메인 객체에 대해 재사용 가능한 인터프리터를 설계할 수 있다.

하지만 현실적으로 생각해보자. GoF의 23개 패턴 중 인터프리터 패턴은 실무에서 가장 드물게 쓰이는 패턴 중 하나다. 왜일까? 문법이 조금만 복잡해져도 클래스 수가 기하급수적으로 늘어나기 때문이다. 만약 에이든 피자의 특수 요청이 프로그래밍 언어 수준으로 복잡해진다면, 우리는 이 패턴을 직접 구현하는 대신 ANTLR나 PEG.js 같은 전문 파서 라이브러리를 사용했을 것이다.

그럼에도 이 패턴을 배우는 이유는 "문제를 언어로 바라보고 해결하는 관점"을 갖기 위해서다. 복잡한 로직을 하나의 언어 체계로 정리하고 클래스로 대응시키는 사고방식은, 파서뿐만 아니라 복잡한 비즈니스 룰을 설계할 때도 꽤 쓸모가 있다.

트레이드오프

Interpreter 패턴의 장점은 문법 규칙의 책임을 표현식 클래스로 분리한다는 점이다. 새 규칙에는 새 Expression 클래스를 추가할 수 있지만, 이 단순한 문자열 파서에서는 그 규칙을 인식하도록 파서의 매핑도 함께 추가해야 한다. 문법이 클래스 단위로 캡슐화되어 있어 유지보수의 경계가 더 명확해진다.

단점은 명확하다. 문법이 복잡해지면 클래스 구조가 너무 커져서 관리하기 힘들고, 해석 속도가 느려질 수 있다. 또한 파서 자체가 여전히 복잡한 로직을 품게 된다는 한계도 있다. 그래서 이 패턴은 "문법이 간단하고 성능이 최우선이 아니며, 자주 확장될 가능성이 있는" 경우에만 도입하는 것이 현명하다. 다행히 에이든 피자의 특수 요청은 이 조건에 딱 들어맞았다.

전체 코드

TS
/** * Context: 해석 결과가 반영될 피자 객체 */class Pizza {  private toppings = new Map<string, number>();  private doneness: "regular" | "well-done" | "light" = "regular";  addTopping(name: string, quantity: number) {    const current = this.toppings.get(name) || 0;    this.toppings.set(name, current + quantity);  }  removeTopping(name: string) {    this.toppings.delete(name);  }  setDoneness(level: "regular" | "well-done" | "light") {    this.doneness = level;  }  showState() {    console.log(`[피자 상태] 굽기: ${this.doneness}, 토핑: ${JSON.stringify(Object.fromEntries(this.toppings))}`);  }}/** * Abstract Expression */interface SpecialRequestExpression {  interpret(pizza: Pizza): void;}/** * Terminal Expressions */class NoToppingExpression implements SpecialRequestExpression {  constructor(private ingredient: string) {}  interpret(pizza: Pizza) {    pizza.removeTopping(this.ingredient);    console.log(`- ${this.ingredient} 제거 해석 완료`);  }}class ExtraToppingExpression implements SpecialRequestExpression {  constructor(private ingredient: string) {}  interpret(pizza: Pizza) {    pizza.addTopping(this.ingredient, 2);    console.log(`- ${this.ingredient} 2배 추가 해석 완료`);  }}class WellDoneExpression implements SpecialRequestExpression {  interpret(pizza: Pizza) {    pizza.setDoneness("well-done");    console.log(`- 바짝 굽기(well-done) 해석 완료`);  }}class LightExpression implements SpecialRequestExpression {  interpret(pizza: Pizza) {    pizza.setDoneness("light");    console.log(`- 살짝 굽기(light) 해석 완료`);  }}/** * Non-Terminal Expression */class RequestListExpression implements SpecialRequestExpression {  constructor(private expressions: SpecialRequestExpression[]) {}  interpret(pizza: Pizza) {    this.expressions.forEach(e => e.interpret(pizza));  }}/** * Parser: 문자열을 Expression 트리로 변환 */class SpecialRequestParser {  parse(input: string): SpecialRequestExpression {    const commands = input.split(",").map(s => s.trim().toLowerCase());    const expressions = commands.map(cmd => this.parseCommand(cmd));    return new RequestListExpression(expressions);  }  private parseCommand(command: string): SpecialRequestExpression {    if (command.startsWith("no-")) return new NoToppingExpression(command.slice(3));    if (command.startsWith("extra-")) return new ExtraToppingExpression(command.slice(6));    if (command === "well-done") return new WellDoneExpression();    if (command === "light") return new LightExpression();    // 알 수 없는 요청은 적용하지 않은 채 주문 확인 단계로 넘긴다.    throw new Error(`[주문 확인 필요] 지원하지 않는 특수 요청: ${command}`);  }}/** * 시뮬레이션 */function runInterpreterDemo() {  const pizza = new Pizza();  const parser = new SpecialRequestParser();  const requestString = "no-cheese, extra-sauce, well-done";  console.log(`주문 요청 수신: "${requestString}"`);  try {    const expression = parser.parse(requestString);    expression.interpret(pizza);    pizza.showState();  } catch (error) {    // 운영 화면에서는 이 오류를 직원/고객 확인 흐름으로 연결한다.    console.warn(error instanceof Error ? error.message : "요청을 확인할 수 없습니다.");  }}runInterpreterDemo();

인터프리터 패턴을 끝으로 에이든 피자의 23가지 디자인 패턴 여정이 일단 마무리되었다. 처음엔 if-else가 가득했던 작은 가게가, 이제는 꽤 복잡한 요구사항도 받아낼 수 있는 시스템으로 성장했다.

이제 준비는 끝났다. 각 편에서 고립해 살펴본 패턴들을 하나의 도메인 모델 위에 다시 배치하고, 드디어 에이든 피자의 정식 오픈을 선언할 시간이다. 내일 아침, 우리는 await new AydenPizzaApplication().main() 한 줄로 이 거대한 오케스트라의 지휘봉을 휘두르게 될 것이다.

댓글

댓글을 불러오는 중...