[IT-방법] Go 연산자 직접 구현하기 – 원리 이해와 실전 예제까지

연산자 개념을 시각화한 Go 프로그래밍 일러스트

연산자의 동작 원리, 블랙박스로 남겨두실 건가요?

코드를 작성하다 보면 a + bx > y 같은 연산자를 아주 당연하게 사용하곤 해요. 하지만 이 간단한 기호들이 컴퓨터 내부에서 어떤 과정을 거쳐 결과값을 만들어내는지 고민해 본 적이 있나요? 대부분의 개발자는 연산자를 마치 마법처럼 작동하는 블랙박스로 취급하며 그저 사용법만 익히고 넘어가곤 해요.

단순히 연산자를 사용하는 것과, 그 연산자가 처리되는 로직을 직접 설계하고 구현하는 것은 완전히 다른 차원의 이야기예요. 연산자의 우선순위가 왜 중요한지, 문자열로 된 수식이 어떻게 숫자로 변환되는지, 그리고 복잡한 논리 구조가 어떻게 트리 형태로 구성되는지를 이해하면 프로그래밍을 바라보는 시야가 완전히 달라져요. 이는 단순한 문법 공부를 넘어 컴파일러와 인터프리터의 기초를 배우는 과정이기도 해요.

이 글을 끝까지 읽고 나면 여러분은 단순히 Go 문법을 사용하는 수준을 넘어, 자신만의 계산 엔진이나 간단한 프로그래밍 언어의 핵심 로직을 설계할 수 있는 기초 체력을 갖게 될 거예요. 복잡한 조건문과 연산 로직을 직접 제어하면서 발생하는 다양한 예외 상황을 다루는 법도 배우게 될 거고요.

💡 이 글에서 다루는 내용

  • 문자열을 의미 있는 단위로 쪼개는 렉서(Lexer) 구현 방법
  • 연산 우선순위를 반영한 구문 분석(Parsing) 원리
  • 추상 구문 트리(AST)를 통한 데이터 구조화
  • 구현된 트리를 바탕으로 실제 값을 계산하는 평가기(Evaluator) 제작

본격적인 구현 전, 반드시 챙겨야 할 준비물

연산자 구현을 시작하기 전에 먼저 머릿속에 그려두어야 할 지도가 있어요. 단순히 코드 몇 줄을 적는 것이 아니라, 데이터가 흘러가는 통로를 설계해야 하기 때문이에요. 무작정 코딩을 시작하기보다는 우리가 어떤 도구를 사용하고, 어떤 개념을 기반으로 움직일지 명확히 정하는 것이 시행착오를 줄이는 지름길이에요.

가장 먼저 필요한 것은 Go 언어의 기본적인 문법에 대한 숙련도예요. 특히 구조체(struct)와 메서드(method), 그리고 인터페이스(interface)를 자유롭게 다룰 수 있어야 해요. 연산자라는 추상적인 개념을 코드로 구현할 때, 우리는 다양한 타입을 하나의 인터페이스로 묶어서 처리해야 하거든요. 또한, 재귀(recursion) 함수에 대한 이해도 필수적이에요. 연산의 우선순위는 트리 구조의 계층적 특성을 가지기 때문에, 함수가 자기 자신을 호출하며 깊이 들어가는 과정이 반드시 포함돼요.

구현 방식에 따른 비교

우리가 선택할 수 있는 구현 방식은 크게 두 가지로 나눌 수 있어요. 단순히 함수를 만들어 사용하는 방식과, 전체적인 구조를 설계하는 엔진 방식이에요.

구분 항목 단순 함수 구현 연산 엔진 구현 (추천)
적용 범위 특정 연산 하나에 국한됨 다양한 수식과 연산자 확장 가능
학습 난이도 매우 낮음 중상 (컴파일러 원리 포함)
확장성 매우 낮음 매우 높음 (새 연산자 추가 용이)
주요 활용처 단순 계산 로직 스크립트 언어, SQL 파서 등

우리는 이번 과정에서 확장성이 뛰어난 엔진 방식을 선택할 거예요. 처음에는 조금 어렵게 느껴질 수 있지만, 이 방식을 통해 연산자의 우선순위, 결합 법칙 등을 완벽하게 제어하는 경험을 할 수 있어요.

⚠️ 주의
단순히 연산자 기호를 비교하는 로직만 짜면, 나중에 곱셈과 덧셈의 우선순위가 뒤섞이는 대참사가 발생할 수 있어요. 반드시 구조적인 설계를 먼저 고민해야 해요.

연산자 엔진 구축을 위한 4단계 실전 가이드

이제 본격적으로 엔진을 조립해 볼 시간이에요. 전체 과정은 크게 문자열 분해 → 구조 파악 → 트리 구성 → 결과 계산의 흐름으로 진행돼요. 각 단계는 서로 긴밀하게 연결되어 있으며, 앞 단계의 결과물이 다음 단계의 입력값이 되는 구조를 가지고 있어요.

STEP 1. 렉서(Lexer)로 문자열 조각내기

가장 먼저 해야 할 일은 사용자가 입력한 문자열을 컴퓨터가 이해할 수 있는 최소 단위인 토큰(Token)으로 쪼개는 작업이에요. 예를 들어 “12 + 5 * 3”이라는 문자열이 들어오면, 렉서는 이를 [숫자(12), 연산자(+), 숫자(5), 연산자(*), 숫자(3)]라는 조각으로 나누어야 해요.

렉서를 만들 때 주의할 점은 숫자를 단순히 한 글자씩 읽는 것이 아니라, 연속된 숫자를 하나의 숫자로 인식해야 한다는 점이에요. ‘1’ 다음에 ‘2’가 오면 이를 ’12’라는 하나의 값으로 합치는 로직이 필요해요. 또한 공백 문자는 의미가 없으므로 건너뛰는 처리를 해주어야 해요. 이 과정이 완벽해야 이후의 파싱 과정에서 데이터가 꼬이지 않아요.

STEP 2. 파서(Parser)로 연산 순서 정하기

렉서가 조각을 잘 나누어 놓았다면, 이제 이 조각들이 어떤 순서로 계산되어야 하는지 결정해야 해요. 이것이 바로 파싱 과정이에요. 여기서 가장 어려운 부분은 연산자 우선순위를 어떻게 적용할 것인가 하는 문제예요.

우리는 보통 ‘재귀 하강 파싱(Recursive Descent Parsing)’이라는 기법을 사용해요. 예를 들어, 덧셈을 처리하는 함수를 만들고, 그 안에서 다시 곱셈 함수를 호출하게 만드는 방식이죠. 이렇게 하면 곱셈이 덧셈보다 먼저 처리되어야 한다는 규칙을 자연스럽게 코드 구조에 녹여낼 수 있어요. 곱셈 함수가 호출되면 먼저 곱셈을 수행한 뒤 그 결과를 덧셈 함수로 돌려주는 식이죠. 이 단계를 통해 단순한 나열이었던 토큰들이 위계질서를 가진 구조로 변모하게 돼요.

STEP 3. 추상 구문 트리(AST) 설계하기

파싱이 끝나면 데이터는 이제 추상 구문 트리(AST, Abstract Syntax Tree)라는 형태로 존재하게 돼요. 트리는 나무를 거꾸로 뒤집어 놓은 것 같은 모양인데, 루트(Root) 노드에서 시작해 가지를 치며 내려가는 구조예요. 연산자는 노드(Node)가 되고, 숫자(Operand)는 트리의 끝단인 리프(Leaf) 노드가 돼요.

예를 들어 “12 + 5 * 3”의 트리는 다음과 같은 구조를 가져요:

  • 루트 노드: +
  • 왼쪽 자식: 12
  • 오른쪽 자식: *
  • 그 아래 자식들: 53

이런 식으로 트리를 구성하면, 복잡한 수식이라도 결국 하나의 뿌리에서 시작하는 계층 구조로 단순화할 수 있어요. 트리를 잘 설계해 두면 나중에 새로운 연산자를 추가하거나, 수식을 최적화하는 작업이 훨씬 수월해져요.

STEP 4. 평가기(Evaluator)로 최종 값 산출하기

마지막 단계는 완성된 트리를 타고 내려가며 실제 계산을 수행하는 평가기를 만드는 거예요. 이 과정은 보통 트리의 가장 밑바닥(리프 노드)부터 시작해서 위로 올라오는 방식으로 진행돼요. 이를 후위 순회(Post-order Traversal)라고 불러요.

평가기는 노드를 만날 때마다 다음과 같이 행동해요:

  • 만약 노드가 숫자라면, 그 값을 그대로 반환해요.
  • 만약 노드가 연산자라면, 해당 연산자의 왼쪽 자식과 오른쪽 자식의 계산 결과값을 가져와서 계산을 수행한 뒤, 그 결과를 부모 노드에게 전달해요.

이 과정을 거치면 트리의 최상단인 루트 노드에 도달했을 때, 우리가 원하는 최종적인 계산 결과값이 딱 나타나게 돼요. 이 모든 과정을 하나의 흐름으로 묶으면 비로소 완벽한 연산 엔진이 탄생하는 것이죠.

💡 구현 시나리오 예시
1. 입력: “10 + 2 * 5”
2. 렉서 결과: [10, +, 2, *, 5]
3. 파서 결과: ‘+’ 노드 아래 ’10’과 ‘*’ 노드가 있음
4. 트리 구조: (+) -> [10, (* -> [2, 5]]
5. 평가 결과: 10 + (2 * 5) = 20

자주 하는 실수와 해결법

연산자 엔진을 직접 만들다 보면 논리적인 함정에 빠지기 쉬워요. 많은 개발자가 공통적으로 겪는 문제들을 정리했으니, 여러분의 코드와 비교해 보세요.

  • 연산 우선순위 무시 → 모든 연산자를 순차적으로 처리함 → ✅ 재귀 하강 파싱을 사용하여 연산자 간의 계층 구조를 만드세요.
  • 정수 나눗셈 오류 → 5 / 2를 했을 때 2가 나옴 → ✅ 계산 결과를 담는 타입을 float64로 설정하여 소수점까지 처리하세요.
  • 무한 재귀 호출 → 파서가 자기 자신을 끝없이 호출함 → ✅ 종료 조건(Base Case)이 명확한지, 토큰을 제대로 소모하고 있는지 확인하세요.
  • 입력값 예외 처리 미흡 → “10 ++ 5” 같은 잘못된 수식에 프로그램이 멈춤 → ✅ 렉서 단계에서 유효하지 않은 토큰을 발견하면 즉시 에러를 반환하도록 설계하세요.
  • 토큰 소모 실패 → 같은 위치의 토큰을 계속 읽음 → ✅ 토큰을 읽은 후에는 반드시 다음 토큰으로 포인터를 이동시켜야 해요.

자주 묻는 질문

Q. Go 언어에서는 연산자 오버로딩이 안 되는데 어떻게 구현하나요?

Go는 C++처럼 기존 연산자의 동작을 바꾸는 오버로딩을 지원하지 않아요. 그래서 우리는 연산자 자체를 구현하는 것이 아니라, 연산자의 로직을 수행하는 ‘엔진’이나 ‘함수’를 만드는 방식으로 접근해야 해요.

Q. 파서(Parser)를 만들 때 가장 중요한 원칙이 무엇인가요?
가장 중요한 것은 결정론적(Deterministic)인 구조를 만드는 거예요. 동일한 입력에 대해서는 항상 동일한 트리 구조가 나와야 하며, 연산자 우선순위 규칙이 코드에 명확히 반영되어야 해요.

Q. 왜 굳이 트리를 만들어야 하나요? 그냥 바로 계산하면 안 되나요?
단순한 계산은 바로 해도 되지만, 수식이 복잡해지거나 괄호가 포함되면 순서 제어가 불가능해져요. 트리는 복잡한 관계를 시각화하고 논리적으로 정리해 주는 가장 강력한 도구예요.

Q. 성능이 걱정되는데, 트리를 사용하면 너무 느리지 않을까요?
대규모 데이터 처리라면 성능을 고려해야 하겠지만, 일반적인 비즈니스 로직이나 스크립트 엔진에서는 트리의 오버헤드가 미미해요. 오히려 정확한 로직 구현이 훨씬 중요해요.

핵심 요약과 다음 단계

Go 연산자 직접 구현은 단순히 코드를 짜는 작업을 넘어, 컴퓨터가 언어를 이해하는 방식을 배우는 아주 가치 있는 여정이에요. 오늘 배운 내용을 잊지 않도록 아래 체크리스트를 확인해 보세요.

✅ 핵심 요약

  • 렉서는 문자열을 의미 있는 토큰 단위로 분해해요.
  • 파서는 토큰의 위계질서를 파악하여 구조를 잡아요.
  • AST는 연산의 관계를 트리 형태로 저장하는 핵심 자료구조예요.
  • 평가기는 트리를 순회하며 최종 계산을 수행해요.
  • 연산 우선순위는 재귀적인 구조로 해결하는 것이 가장 깔끔해요.

이제 이론은 충분해요. 이제 직접 손을 움직여 코드를 작성해 볼 차례예요.

실행 가이드

  • 오늘 할 일: Go 환경을 설정하고, 아주 간단한 덧셈만 수행하는 렉서를 먼저 만들어 보세요.
  • 이번 주 할 일: 렉서에 곱셈과 나눗셈을 추가하고, 재귀 파싱을 통해 우선순위를 적용해 보세요.
  • 실행 직전 할 할: 괄호(Parentheses) 처리를 위한 로직을 어떻게 추가할지 미리 구상해 보세요.

직접 만든 엔진이 복잡한 수식을 정확하게 계산해 낼 때의 쾌감은 정말 엄청나요! 실무에서도 이러한 로직 설계 능력은 복잡한 규칙 엔진이나 도메인 특화 언어(DSL)를 만드는 데 큰 밑거름이 될 거예요. 만약 이 과정이 즐거웠다면, 실무 체크리스트를 저장해 배포 전에 활용해 보세요.

함께 읽으면 좋은 글: /programming/golang/go-operators-complete-guide/ 글과 함께 읽어보시면 더 깊은 이해를 도와드려요.

댓글 남기기