이미지 로딩 중...
AI Generated
2025. 11. 7. · 3 Views
후위 표기법 계산기 완벽 가이드
스택을 활용한 후위 표기법 계산기 구현을 단계별로 배웁니다. 중위 표기법을 후위 표기법으로 변환하고, 효율적으로 계산하는 방법을 실전 코드와 함께 익힙니다.
목차
- 후위_표기법_기초
- 중위_표기법에서_후위_표기법_변환
- 완전한_후위_표기법_계산기_구현
- 스택_자료구조_깊이_이해하기
- 다양한_연산자_지원_확장하기
- 괄호_매칭_검증하기
- 에러_처리와_디버깅_전략
- 성능_최적화_전략
1. 후위_표기법_기초
시작하며
여러분이 계산기 프로그램을 만들 때 "3 + 5 * 2"와 같은 수식을 어떻게 처리해야 할지 고민해본 적 있나요? 괄호와 연산자 우선순위를 고려해야 하고, 복잡한 조건문을 작성하다 보면 코드가 점점 복잡해집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 수식 파서, 계산기 앱, 수학 라이브러리를 개발할 때 연산자 우선순위와 괄호 처리가 까다롭습니다.
잘못 처리하면 계산 결과가 틀리거나 예상치 못한 에러가 발생하죠. 바로 이럴 때 필요한 것이 후위 표기법(Postfix Notation)입니다.
후위 표기법을 사용하면 괄호 없이도 연산 순서가 명확해지고, 스택 하나만으로 간단하게 계산할 수 있습니다.
개요
간단히 말해서, 후위 표기법은 연산자를 피연산자 뒤에 배치하는 표기 방식입니다. 우리가 일반적으로 사용하는 "3 + 5"는 중위 표기법(Infix)이고, 후위 표기법으로는 "3 5 +"가 됩니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, 컴퓨터가 수식을 계산할 때 중위 표기법은 연산자 우선순위와 괄호를 처리하기 위해 복잡한 로직이 필요합니다. 예를 들어, 계산기 앱이나 스프레드시트 프로그램에서 사용자가 입력한 수식을 정확하게 계산해야 하는 경우에 매우 유용합니다.
기존에는 중위 표기법을 재귀적으로 파싱하거나 복잡한 파서를 작성해야 했다면, 이제는 후위 표기법으로 변환한 후 스택을 사용해 순차적으로 처리할 수 있습니다. 후위 표기법의 핵심 특징은 첫째, 괄호가 필요 없다는 점입니다.
연산 순서가 이미 명확하기 때문이죠. 둘째, 왼쪽에서 오른쪽으로 한 번만 읽으면 됩니다.
셋째, 스택 자료구조만으로 간단하게 계산할 수 있습니다. 이러한 특징들이 알고리즘을 단순하고 효율적으로 만들어줍니다.
코드 예제
// 후위 표기법 계산기 - 기본 구조
function evaluatePostfix(expression) {
const stack = [];
const tokens = expression.split(' ');
for (let token of tokens) {
// 숫자인 경우 스택에 푸시
if (!isNaN(token)) {
stack.push(Number(token));
} else {
// 연산자인 경우 두 개 pop하여 계산
const b = stack.pop();
const a = stack.pop();
const result = calculate(a, b, token);
stack.push(result);
}
}
return stack[0]; // 최종 결과
}
설명
이것이 하는 일: 후위 표기법으로 작성된 수식(예: "3 5 + 2 *")을 스택을 사용해 계산합니다. 전체적인 동작은 토큰을 하나씩 읽으면서 숫자는 스택에 저장하고, 연산자를 만나면 스택에서 두 개를 꺼내 계산한 후 결과를 다시 스택에 넣는 방식입니다.
첫 번째로, 표현식을 공백으로 분리하여 토큰 배열을 만듭니다. 이렇게 하는 이유는 각 숫자와 연산자를 개별적으로 처리하기 위함입니다.
배열을 순회하면서 각 토큰을 확인합니다. 그 다음으로, 토큰이 숫자인지 연산자인지 판단합니다.
isNaN() 함수를 사용해 숫자 여부를 확인하고, 숫자라면 Number로 변환하여 스택에 푸시합니다. 내부에서는 스택의 맨 위에 값이 추가되어 나중에 꺼낼 수 있게 준비됩니다.
연산자를 만나면 스택에서 두 개의 값을 pop합니다. 주의할 점은 나중에 pop한 값이 첫 번째 피연산자(a)가 되고, 먼저 pop한 값이 두 번째 피연산자(b)가 된다는 것입니다.
계산 결과를 다시 스택에 푸시하여 다음 연산에서 사용할 수 있게 합니다. 마지막으로, 모든 토큰을 처리하고 나면 스택에는 최종 계산 결과 하나만 남게 됩니다.
이 값을 반환하면 후위 표기법 수식의 계산이 완료됩니다. 여러분이 이 코드를 사용하면 복잡한 조건문 없이도 수식을 정확하게 계산할 수 있습니다.
연산자 우선순위를 신경 쓸 필요가 없고, 괄호 처리도 불필요하며, 알고리즘이 선형 시간 O(n)으로 매우 효율적입니다.
실전 팁
💡 토큰을 분리할 때는 공백을 일관성 있게 사용하세요. "35+"처럼 붙여쓰면 파싱이 어렵습니다.
💡 스택이 비어있을 때 pop을 시도하면 에러가 발생하므로, 항상 스택 길이를 체크하는 validation을 추가하세요.
💡 부동소수점 연산을 다룰 때는 parseFloat을 사용하고, 정밀도 문제를 고려해 toFixed()로 반올림하세요.
💡 나눗셈 연산자를 처리할 때는 0으로 나누는 경우를 예외 처리해야 합니다.
💡 디버깅할 때는 각 단계마다 스택의 상태를 console.log로 출력하면 계산 과정을 쉽게 추적할 수 있습니다.
2. 중위_표기법에서_후위_표기법_변환
시작하며
여러분이 사용자로부터 "3 + 5 * 2"와 같은 일반적인 수식을 입력받았을 때, 이를 어떻게 처리해야 할까요? 사용자는 우리가 평소 쓰는 중위 표기법으로 입력하지만, 계산은 후위 표기법이 훨씬 쉽습니다.
이런 문제는 실제 계산기 앱, 컴파일러, 수식 파서 등을 개발할 때 반드시 해결해야 합니다. 중위 표기법을 직접 계산하려면 연산자 우선순위, 결합 방향, 괄호 처리 등 고려할 것이 너무 많습니다.
코드가 복잡해지고 버그가 발생하기 쉽죠. 바로 이럴 때 필요한 것이 중위 표기법을 후위 표기법으로 변환하는 알고리즘입니다.
한 번만 변환하면 이후 계산이 매우 간단해집니다.
개요
간단히 말해서, Shunting Yard 알고리즘을 사용해 중위 표기법을 후위 표기법으로 변환합니다. 이 알고리즘은 기차역에서 기차를 정렬하는 것에 비유되어 이런 이름이 붙었습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, 사용자 인터페이스는 직관적이어야 하므로 중위 표기법으로 입력을 받아야 하지만, 내부 계산은 후위 표기법이 효율적입니다. 예를 들어, Excel 같은 스프레드시트에서 "=A1+B1*C1" 형태의 수식을 처리할 때 이 변환이 필수적입니다.
기존에는 복잡한 재귀 파서나 Abstract Syntax Tree를 만들어야 했다면, 이제는 스택 하나로 간단하게 변환할 수 있습니다. 이 알고리즘의 핵심 특징은 첫째, 연산자 우선순위를 자동으로 처리한다는 점입니다.
둘째, 괄호를 올바르게 처리하여 연산 순서를 보장합니다. 셋째, 왼쪽 결합과 오른쪽 결합을 모두 지원합니다.
이러한 특징들이 복잡한 수식도 정확하게 변환할 수 있게 해줍니다.
코드 예제
// 중위 표기법을 후위 표기법으로 변환
function infixToPostfix(infix) {
const precedence = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3};
const stack = [];
const output = [];
const tokens = infix.split(' ');
for (let token of tokens) {
if (!isNaN(token)) {
// 숫자는 바로 출력
output.push(token);
} else if (token === '(') {
// 여는 괄호는 스택에 푸시
stack.push(token);
} else if (token === ')') {
// 닫는 괄호: 여는 괄호까지 pop
while (stack.length && stack[stack.length - 1] !== '(') {
output.push(stack.pop());
}
stack.pop(); // '(' 제거
} else {
// 연산자: 우선순위가 높거나 같은 것들 pop
while (stack.length && precedence[stack[stack.length - 1]] >= precedence[token]) {
output.push(stack.pop());
}
stack.push(token);
}
}
// 남은 연산자 모두 출력
while (stack.length) {
output.push(stack.pop());
}
return output.join(' ');
}
설명
이것이 하는 일: 중위 표기법 수식(예: "3 + 5 * 2")을 후위 표기법(예: "3 5 2 * +")으로 변환합니다. 전체적인 동작은 토큰을 하나씩 읽으면서 숫자는 즉시 출력하고, 연산자는 우선순위에 따라 스택에 저장했다가 적절한 시점에 출력하는 방식입니다.
첫 번째로, 연산자 우선순위 테이블을 정의합니다. 곱셈과 나눗셈은 2, 덧셈과 뺄셈은 1, 거듭제곱은 3으로 설정합니다.
이렇게 하는 이유는 나중에 연산자를 비교할 때 숫자로 간단히 판단하기 위함입니다. 그 다음으로, 각 토큰 타입에 따라 다르게 처리합니다.
숫자를 만나면 즉시 output 배열에 추가합니다. 후위 표기법에서는 피연산자가 먼저 오기 때문이죠.
여는 괄호 '('를 만나면 무조건 스택에 푸시하여 대응하는 닫는 괄호를 만날 때까지 보관합니다. 닫는 괄호 ')'를 만나면 여는 괄호가 나올 때까지 스택의 모든 연산자를 pop하여 출력합니다.
내부에서는 괄호 안의 표현식이 먼저 계산되어야 하므로, 해당 연산자들이 먼저 출력되는 것입니다. 마지막으로 여는 괄호 자체는 제거합니다.
연산자를 만나면 현재 스택 top에 있는 연산자와 우선순위를 비교합니다. 스택 top의 연산자가 현재 연산자보다 우선순위가 높거나 같으면 pop하여 출력합니다.
이 과정을 반복한 후 현재 연산자를 스택에 푸시합니다. 이렇게 하면 높은 우선순위의 연산자가 먼저 계산되도록 순서가 정해집니다.
모든 토큰을 처리한 후에는 스택에 남아있는 연산자들을 모두 pop하여 출력합니다. 이제 output 배열에는 완전한 후위 표기법 수식이 담겨있습니다.
여러분이 이 코드를 사용하면 어떤 복잡한 중위 표기법 수식도 자동으로 후위 표기법으로 변환할 수 있습니다. 연산자 우선순위가 자동으로 처리되고, 괄호 중첩도 정확하게 다루며, 이후 계산이 매우 간단해집니다.
실전 팁
💡 연산자 우선순위 테이블에 새로운 연산자를 추가할 때는 수학적 규칙을 정확히 따르세요. 예를 들어 모듈로(%)는 곱셈과 같은 우선순위입니다.
💡 오른쪽 결합 연산자(예: 거듭제곱 ^)를 처리할 때는 우선순위 비교 조건을 '>='에서 '>'로 변경해야 합니다.
💡 괄호가 매칭되지 않는 경우를 대비해 validation 로직을 추가하세요. 여는 괄호와 닫는 괄호의 개수가 일치하는지 미리 확인하면 좋습니다.
💡 공백이 없는 입력(예: "3+5*2")을 처리하려면 정규식으로 토큰화하는 로직을 추가하세요.
💡 변환 과정을 시각화하려면 각 단계마다 스택과 output의 상태를 기록하면 디버깅과 학습에 도움이 됩니다.
3. 완전한_후위_표기법_계산기_구현
시작하며
여러분이 실제로 동작하는 계산기를 만들려면 변환과 계산을 모두 구현해야 합니다. 사용자는 "( 3 + 5 ) * 2"처럼 익숙한 형태로 입력하고, 내부적으로는 이를 후위 표기법으로 변환한 후 계산해야 하죠.
이런 문제는 실무에서 매우 흔합니다. 단순히 알고리즘을 아는 것과 실제로 동작하는 프로그램을 만드는 것은 다릅니다.
에러 처리, 입력 검증, 다양한 연산자 지원 등 고려할 것이 많습니다. 바로 이럴 때 필요한 것이 완전한 계산기 구현입니다.
지금까지 배운 변환과 계산을 결합하여 실전에서 사용할 수 있는 수준의 코드를 만들어봅시다.
개요
간단히 말해서, 중위 표기법 입력을 받아 후위 표기법으로 변환하고 계산하는 완전한 계산기를 만듭니다. 모든 기본 연산자(+, -, *, /, ^)와 괄호를 지원합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, 이론만 아는 것과 실제로 사용할 수 있는 코드를 작성하는 것은 완전히 다릅니다. 예를 들어, 웹 계산기, 수식 평가 라이브러리, 게임의 데미지 계산 시스템 등에서 이런 완전한 구현이 필요합니다.
기존에는 각 부분을 따로 구현하고 연결하는 과정에서 버그가 발생하기 쉬웠다면, 이제는 체계적으로 설계된 하나의 시스템으로 안정적으로 동작할 수 있습니다. 핵심 특징은 첫째, 모든 기본 연산자를 지원한다는 점입니다.
둘째, 에러 처리가 포함되어 잘못된 입력에도 적절히 대응합니다. 셋째, 확장 가능한 구조로 새로운 연산자를 쉽게 추가할 수 있습니다.
이러한 특징들이 실무에서 바로 사용할 수 있는 품질을 보장합니다.
코드 예제
// 완전한 후위 표기법 계산기
class PostfixCalculator {
constructor() {
this.precedence = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3};
this.operators = {
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
'/': (a, b) => {
if (b === 0) throw new Error('Division by zero');
return a / b;
},
'^': (a, b) => Math.pow(a, b)
};
}
// 중위 → 후위 변환
infixToPostfix(infix) {
const stack = [];
const output = [];
const tokens = infix.match(/\d+\.?\d*|[+\-*/^()]/g);
if (!tokens) throw new Error('Invalid expression');
for (let token of tokens) {
if (!isNaN(token)) {
output.push(token);
} else if (token === '(') {
stack.push(token);
} else if (token === ')') {
while (stack.length && stack[stack.length - 1] !== '(') {
output.push(stack.pop());
}
if (!stack.length) throw new Error('Mismatched parentheses');
stack.pop();
} else if (this.operators[token]) {
while (stack.length && this.precedence[stack[stack.length - 1]] >= this.precedence[token]) {
output.push(stack.pop());
}
stack.push(token);
}
}
while (stack.length) {
if (stack[stack.length - 1] === '(') throw new Error('Mismatched parentheses');
output.push(stack.pop());
}
return output;
}
// 후위 표기법 계산
evaluatePostfix(postfix) {
const stack = [];
for (let token of postfix) {
if (!isNaN(token)) {
stack.push(Number(token));
} else if (this.operators[token]) {
if (stack.length < 2) throw new Error('Invalid expression');
const b = stack.pop();
const a = stack.pop();
stack.push(this.operators[token](a, b));
}
}
if (stack.length !== 1) throw new Error('Invalid expression');
return stack[0];
}
// 전체 계산 프로세스
calculate(expression) {
const postfix = this.infixToPostfix(expression);
return this.evaluatePostfix(postfix);
}
}
// 사용 예시
const calc = new PostfixCalculator();
console.log(calc.calculate("( 3 + 5 ) * 2")); // 16
console.log(calc.calculate("3 + 5 * 2")); // 13
console.log(calc.calculate("2 ^ 3 + 1")); // 9
설명
이것이 하는 일: 사용자가 입력한 중위 표기법 수식을 받아서 자동으로 후위 표기법으로 변환하고 최종 결과를 계산하여 반환합니다. 전체적인 동작은 클래스 기반으로 설계되어 재사용성이 높고, 모든 기본 연산과 에러 상황을 처리합니다.
첫 번째로, 생성자에서 연산자 우선순위와 연산 함수를 정의합니다. operators 객체는 각 연산자를 실제 계산 함수와 매핑합니다.
이렇게 하는 이유는 나중에 새로운 연산자를 추가할 때 이 객체만 수정하면 되기 때문입니다. 나눗셈 함수에는 0으로 나누는 경우의 에러 처리가 포함되어 있습니다.
그 다음으로, infixToPostfix 메서드에서 정규식을 사용해 토큰을 추출합니다. 정규식 /\d+\.?\d*|[+\-*/^()]/g는 정수, 소수, 연산자, 괄호를 모두 인식합니다.
내부에서는 공백이 있든 없든 상관없이 토큰을 정확히 분리합니다. 이전에 배운 Shunting Yard 알고리즘을 적용하되, 괄호 매칭 에러를 체크하는 로직이 추가되었습니다.
세 번째로, evaluatePostfix 메서드에서 후위 표기법 배열을 받아 계산합니다. 스택에 값이 부족한 경우(연산자에 비해 피연산자가 적은 경우)를 체크하여 에러를 발생시킵니다.
각 연산자에 대해 operators 객체에서 해당 함수를 찾아 실행합니다. 계산이 끝난 후 스택에 정확히 하나의 값만 남아있는지 확인하여 수식의 유효성을 보장합니다.
마지막으로, calculate 메서드는 전체 프로세스를 하나로 묶습니다. 사용자는 이 메서드만 호출하면 되고, 내부적으로 변환과 계산이 자동으로 수행됩니다.
각 단계에서 발생할 수 있는 에러가 모두 처리되어 안정적입니다. 여러분이 이 코드를 사용하면 완전히 동작하는 계산기를 즉시 사용할 수 있습니다.
클래스 기반이므로 여러 인스턴스를 만들어 독립적으로 사용할 수 있고, 새로운 연산자를 추가하기도 쉬우며, 에러 처리가 완벽하여 프로덕션 환경에서도 안정적으로 동작합니다.
실전 팁
💡 클래스 인스턴스를 싱글톤 패턴으로 만들면 메모리를 절약할 수 있습니다. 상태가 없으므로 하나의 인스턴스를 공유해도 안전합니다.
💡 정규식을 수정하여 과학적 표기법(1e10), 음수(-5), 공백 처리 등을 지원할 수 있습니다.
💡 계산 과정을 기록하려면 각 메서드에서 중간 결과를 배열에 저장하고 반환하세요. 디버깅과 사용자에게 단계별 설명을 보여줄 때 유용합니다.
💡 성능이 중요한 경우 자주 사용되는 수식의 결과를 캐싱하는 메모이제이션을 적용하세요.
💡 TypeScript로 포팅하면 타입 안정성이 높아지고, 연산자 타입을 enum으로 정의하여 더 명확한 코드를 작성할 수 있습니다.
4. 스택_자료구조_깊이_이해하기
시작하며
여러분이 후위 표기법 계산기를 구현하면서 계속해서 스택을 사용했는데, 스택이 정확히 어떻게 동작하는지 궁금하지 않으셨나요? 배열의 push와 pop만 사용했지만, 실제로 스택의 원리를 깊이 이해하면 더 효율적인 코드를 작성할 수 있습니다.
이런 문제는 알고리즘을 구현할 때 자주 발생합니다. 자료구조의 표면만 이해하고 사용하면 성능 문제나 예상치 못한 버그가 발생할 수 있습니다.
특히 대용량 데이터를 처리하거나 메모리가 제한된 환경에서는 자료구조의 내부 동작을 이해하는 것이 필수적입니다. 바로 이럴 때 필요한 것이 스택 자료구조의 깊이 있는 이해입니다.
LIFO(Last In First Out) 원칙을 완벽히 이해하고, 직접 스택을 구현해보면 후위 표기법 계산기가 왜 효율적인지 명확히 알 수 있습니다.
개요
간단히 말해서, 스택은 마지막에 들어간 데이터가 가장 먼저 나오는 LIFO 구조의 자료구조입니다. 책을 쌓는 것처럼 맨 위에만 추가하고 맨 위에서만 제거할 수 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, 스택은 함수 호출 스택, 브라우저 히스토리, 실행 취소 기능, 괄호 검사 등 수많은 곳에서 사용됩니다. 예를 들어, 브라우저의 뒤로 가기 버튼은 방문 기록을 스택에 저장했다가 하나씩 꺼내는 방식으로 구현됩니다.
기존에는 배열의 모든 기능을 사용할 수 있었지만 불필요한 연산(중간 삽입, 검색 등)이 가능했다면, 이제는 스택 인터페이스로 제한하여 의도를 명확히 하고 실수를 방지할 수 있습니다. 스택의 핵심 특징은 첫째, O(1) 시간에 push와 pop이 가능하다는 점입니다.
둘째, 메모리를 순차적으로 사용하여 캐시 효율성이 높습니다. 셋째, 구조가 단순하여 구현과 사용이 쉽습니다.
이러한 특징들이 후위 표기법 계산에 완벽하게 맞아떨어집니다.
코드 예제
// 스택 클래스 직접 구현
class Stack {
constructor() {
this.items = [];
this.top = -1; // 스택 최상단 인덱스
}
// 요소 추가 - O(1)
push(element) {
this.top++;
this.items[this.top] = element;
return this.top + 1; // 현재 크기 반환
}
// 요소 제거 및 반환 - O(1)
pop() {
if (this.isEmpty()) {
throw new Error('Stack underflow');
}
const element = this.items[this.top];
this.top--;
return element;
}
// 최상단 요소 확인 (제거하지 않음)
peek() {
if (this.isEmpty()) {
return null;
}
return this.items[this.top];
}
// 스택이 비어있는지 확인
isEmpty() {
return this.top === -1;
}
// 스택 크기 반환
size() {
return this.top + 1;
}
// 스택 초기화
clear() {
this.items = [];
this.top = -1;
}
// 스택 내용 출력 (디버깅용)
print() {
return this.items.slice(0, this.top + 1).join(' <- ');
}
}
// 사용 예시
const stack = new Stack();
stack.push(10);
stack.push(20);
stack.push(30);
console.log(stack.print()); // "10 <- 20 <- 30"
console.log(stack.pop()); // 30
console.log(stack.peek()); // 20
설명
이것이 하는 일: 스택 자료구조를 처음부터 구현하여 내부 동작 원리를 완벽히 이해합니다. 전체적인 동작은 top 인덱스를 관리하면서 배열의 끝에서만 추가와 제거가 일어나도록 제한하는 방식입니다.
첫 번째로, 생성자에서 items 배열과 top 인덱스를 초기화합니다. top을 -1로 시작하는 이유는 스택이 비어있음을 나타내기 위함입니다.
첫 번째 요소를 추가하면 top이 0이 되어 items[0]에 저장됩니다. 이 방식은 스택의 크기를 top + 1로 간단히 계산할 수 있게 해줍니다.
그 다음으로, push 메서드에서 top을 먼저 증가시킨 후 해당 위치에 요소를 저장합니다. 내부에서는 배열의 길이가 자동으로 늘어나므로 별도의 크기 확인이 필요 없습니다.
현재 스택 크기를 반환하여 호출자가 스택 상태를 파악할 수 있게 합니다. pop 메서드에서는 먼저 스택이 비어있는지 확인합니다.
빈 스택에서 pop을 시도하면 'Stack underflow' 에러를 발생시켜 잘못된 사용을 방지합니다. 최상단 요소를 임시 변수에 저장한 후 top을 감소시키고 요소를 반환합니다.
실제로 배열에서 제거하지 않아도 top보다 큰 인덱스는 접근할 수 없으므로 논리적으로 제거된 것과 같습니다. peek 메서드는 pop과 유사하지만 top을 감소시키지 않습니다.
이는 다음에 나올 요소를 미리 확인하고 싶을 때 사용하며, 후위 표기법 변환에서 연산자 우선순위를 비교할 때 유용합니다. isEmpty, size, clear 메서드들은 스택의 상태를 관리하는 유틸리티 함수들입니다.
print 메서드는 디버깅할 때 스택의 현재 상태를 시각화하여 보여줍니다. 여러분이 이 코드를 사용하면 스택의 동작 원리를 완벽히 이해할 수 있습니다.
모든 연산이 O(1) 시간 복잡도로 동작하므로 매우 효율적이고, 에러 처리가 포함되어 안전하며, 디버깅 기능이 있어 학습과 개발에 유용합니다.
실전 팁
💡 실무에서는 JavaScript 배열을 직접 사용해도 되지만, 스택 클래스로 감싸면 인터페이스가 명확해지고 실수를 방지할 수 있습니다.
💡 메모리 효율을 높이려면 pop할 때 items[this.top] = null을 추가하여 참조를 제거하고 가비지 컬렉션을 돕습니다.
💡 고정 크기 스택이 필요하면 생성자에서 maxSize를 받고, push 전에 크기를 체크하여 'Stack overflow' 에러를 발생시키세요.
💡 제네릭 타입을 지원하려면 TypeScript의 Stack<T> 형태로 구현하면 타입 안정성이 크게 향상됩니다.
💡 성능 측정을 위해 push/pop 횟수를 카운터로 추적하면 알고리즘의 효율성을 분석하는 데 도움이 됩니다.
5. 다양한_연산자_지원_확장하기
시작하며
여러분이 기본 계산기를 만들었는데, 사용자가 모듈로(%) 연산이나 팩토리얼(!) 같은 다른 연산자를 요청한다면 어떻게 하시겠어요? 기존 코드를 전부 수정해야 할까요?
이런 문제는 소프트웨어가 성장하면서 반드시 직면하게 됩니다. 처음에는 간단했던 요구사항이 점점 복잡해지고, 새로운 기능을 추가할 때마다 코드 전체를 고치는 것은 비효율적입니다.
확장 가능한 구조가 필요한 이유죠. 바로 이럴 때 필요한 것이 확장 가능한 연산자 시스템입니다.
객체 지향 설계 원칙을 적용하면 새로운 연산자를 쉽게 추가할 수 있습니다.
개요
간단히 말해서, 연산자를 객체로 정의하여 쉽게 추가, 수정, 제거할 수 있는 시스템을 만듭니다. 단항 연산자(!, √), 이항 연산자(+, -, *, /), 삼항 연산자까지 지원할 수 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, 프로덕트는 계속 진화합니다. 처음에는 사칙연산만 필요했지만, 나중에 삼각함수, 로그, 비트 연산 등이 추가될 수 있습니다.
예를 들어, 과학 계산기나 금융 계산기로 발전시킬 때 확장 가능한 구조가 필수적입니다. 기존에는 새로운 연산자마다 if-else 문을 추가하고 여러 곳의 코드를 수정해야 했다면, 이제는 연산자 정의만 추가하면 자동으로 모든 기능이 동작합니다.
핵심 특징은 첫째, Open-Closed Principle(확장에는 열려있고 수정에는 닫혀있음)을 따른다는 점입니다. 둘째, 연산자별로 독립적인 설정(우선순위, 결합 방향, 피연산자 개수)이 가능합니다.
셋째, 커스텀 함수를 연산자로 등록할 수 있습니다. 이러한 특징들이 계산기를 유연하고 강력하게 만듭니다.
코드 예제
// 확장 가능한 연산자 시스템
class AdvancedCalculator {
constructor() {
this.operators = new Map();
this.registerDefaultOperators();
}
// 연산자 등록 메서드
registerOperator(symbol, precedence, associativity, operandCount, execute) {
this.operators.set(symbol, {
precedence,
associativity, // 'left' 또는 'right'
operandCount, // 1: 단항, 2: 이항
execute
});
}
// 기본 연산자 등록
registerDefaultOperators() {
// 이항 연산자
this.registerOperator('+', 1, 'left', 2, (a, b) => a + b);
this.registerOperator('-', 1, 'left', 2, (a, b) => a - b);
this.registerOperator('*', 2, 'left', 2, (a, b) => a * b);
this.registerOperator('/', 2, 'left', 2, (a, b) => {
if (b === 0) throw new Error('Division by zero');
return a / b;
});
this.registerOperator('^', 3, 'right', 2, (a, b) => Math.pow(a, b));
this.registerOperator('%', 2, 'left', 2, (a, b) => a % b);
// 단항 연산자 (후위)
this.registerOperator('!', 4, 'left', 1, (a) => {
if (a < 0 || !Number.isInteger(a)) throw new Error('Factorial requires non-negative integer');
let result = 1;
for (let i = 2; i <= a; i++) result *= i;
return result;
});
}
// 우선순위 비교 (결합 방향 고려)
shouldPopOperator(stackOp, currentOp) {
const stack = this.operators.get(stackOp);
const current = this.operators.get(currentOp);
if (!stack || !current) return false;
if (current.associativity === 'left') {
return stack.precedence >= current.precedence;
} else {
return stack.precedence > current.precedence;
}
}
// 중위 → 후위 변환 (개선된 버전)
infixToPostfix(infix) {
const stack = [];
const output = [];
const tokens = infix.match(/\d+\.?\d*|[+\-*/%^!()]/g);
if (!tokens) throw new Error('Invalid expression');
for (let token of tokens) {
if (!isNaN(token)) {
output.push(token);
} else if (token === '(') {
stack.push(token);
} else if (token === ')') {
while (stack.length && stack[stack.length - 1] !== '(') {
output.push(stack.pop());
}
if (!stack.length) throw new Error('Mismatched parentheses');
stack.pop();
} else if (this.operators.has(token)) {
while (stack.length && stack[stack.length - 1] !== '(' &&
this.shouldPopOperator(stack[stack.length - 1], token)) {
output.push(stack.pop());
}
stack.push(token);
}
}
while (stack.length) {
if (stack[stack.length - 1] === '(') throw new Error('Mismatched parentheses');
output.push(stack.pop());
}
return output;
}
// 후위 표기법 계산 (개선된 버전)
evaluatePostfix(postfix) {
const stack = [];
for (let token of postfix) {
if (!isNaN(token)) {
stack.push(Number(token));
} else if (this.operators.has(token)) {
const op = this.operators.get(token);
if (stack.length < op.operandCount) {
throw new Error('Invalid expression');
}
if (op.operandCount === 1) {
const a = stack.pop();
stack.push(op.execute(a));
} else if (op.operandCount === 2) {
const b = stack.pop();
const a = stack.pop();
stack.push(op.execute(a, b));
}
}
}
if (stack.length !== 1) throw new Error('Invalid expression');
return stack[0];
}
// 전체 계산
calculate(expression) {
const postfix = this.infixToPostfix(expression);
return this.evaluatePostfix(postfix);
}
}
// 사용 예시
const calc = new AdvancedCalculator();
// 커스텀 연산자 추가
calc.registerOperator('√', 4, 'right', 1, (a) => Math.sqrt(a));
console.log(calc.calculate("5!")); // 120
console.log(calc.calculate("10 % 3")); // 1
console.log(calc.calculate("2 ^ 3 * 4")); // 32
설명
이것이 하는 일: 연산자를 데이터로 관리하여 프로그램 실행 중에도 새로운 연산자를 추가할 수 있는 유연한 계산기를 만듭니다. 전체적인 동작은 연산자 메타데이터(우선순위, 결합성, 피연산자 개수, 실행 함수)를 Map에 저장하고, 이를 참조하여 변환과 계산을 수행하는 방식입니다.
첫 번째로, registerOperator 메서드로 연산자를 등록합니다. 각 연산자는 심볼, 우선순위, 결합 방향, 피연산자 개수, 실행 함수를 속성으로 가집니다.
이렇게 하는 이유는 연산자의 모든 정보를 한 곳에 모아 관리하기 위함입니다. Map을 사용하면 O(1) 시간에 연산자를 조회할 수 있어 효율적입니다.
그 다음으로, registerDefaultOperators에서 기본 연산자들을 등록합니다. 내부에서는 사칙연산, 거듭제곱, 모듈로 등을 설정하고, 팩토리얼 같은 단항 연산자도 추가합니다.
팩토리얼 함수는 음수나 소수 입력에 대해 에러를 발생시켜 수학적으로 올바른 동작을 보장합니다. shouldPopOperator 메서드에서는 연산자의 결합 방향을 고려하여 우선순위를 비교합니다.
왼쪽 결합 연산자는 같은 우선순위에서도 먼저 계산되어야 하므로 >= 조건을 사용하고, 오른쪽 결합 연산자(예: 거듭제곱)는 나중에 계산되도록 > 조건을 사용합니다. 이렇게 하면 "2 ^ 3 ^ 2"가 올바르게 "2 ^ (3 ^ 2)"로 계산됩니다.
infixToPostfix와 evaluatePostfix 메서드는 이전 버전과 유사하지만, 하드코딩된 연산자 대신 operators Map을 참조합니다. 이제 새로운 연산자를 추가해도 이 메서드들을 수정할 필요가 없습니다.
evaluatePostfix에서는 operandCount를 확인하여 단항과 이항 연산자를 다르게 처리합니다. 여러분이 이 코드를 사용하면 무한히 확장 가능한 계산기를 만들 수 있습니다.
제곱근, 삼각함수, 로그 등 어떤 연산자든 registerOperator 한 번만 호출하면 즉시 사용할 수 있고, 기존 코드를 전혀 수정하지 않아도 되며, 연산자별로 독립적인 설정이 가능합니다.
실전 팁
💡 단항 연산자를 전위(prefix)로 사용하려면 토큰 파싱 단계에서 위치를 추적하고, 전위/후위를 구분하는 로직을 추가하세요.
💡 삼각함수(sin, cos)나 로그(log) 같은 함수형 연산자를 지원하려면 함수 이름 인식 로직과 괄호 처리를 확장하세요.
💡 사용자 정의 함수를 런타임에 추가할 수 있게 하려면 registerOperator를 외부 API로 노출하고, 보안을 위해 execute 함수를 샌드박스에서 실행하세요.
💡 연산자 별칭을 지원하려면 Map에 여러 심볼을 같은 정의에 매핑하세요. 예를 들어 '**'와 '^'를 모두 거듭제곱으로 등록할 수 있습니다.
💡 디버그 모드를 추가하여 각 연산자 적용 시점과 스택 상태를 로깅하면 복잡한 수식의 계산 과정을 추적하기 쉽습니다.
6. 괄호_매칭_검증하기
시작하며
여러분이 "((3 + 5) * 2"처럼 괄호가 맞지 않는 수식을 입력받으면 어떻게 되나요? 계산 중간에 에러가 발생하고, 사용자는 어디가 잘못되었는지 알기 어렵습니다.
이런 문제는 사용자 경험을 크게 해칩니다. 특히 복잡한 수식에서 괄호가 하나만 빠져도 전혀 다른 결과가 나오거나 에러가 발생합니다.
계산을 시작하기 전에 입력을 검증하는 것이 중요합니다. 바로 이럴 때 필요한 것이 괄호 매칭 검증입니다.
스택을 활용하면 중첩된 괄호도 정확하게 검사할 수 있고, 사용자에게 명확한 에러 메시지를 제공할 수 있습니다.
개요
간단히 말해서, 여는 괄호와 닫는 괄호가 올바르게 짝을 이루는지 스택으로 검증합니다. 여는 괄호를 만나면 스택에 넣고, 닫는 괄호를 만나면 스택에서 꺼내 매칭되는지 확인합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, 잘못된 입력을 조기에 발견하면 디버깅 시간이 줄어들고 사용자 경험이 개선됩니다. 예를 들어, 코드 에디터의 괄호 하이라이팅, JSON 검증기, 수식 입력 폼 등에서 실시간으로 괄호 매칭을 검사합니다.
기존에는 단순히 여는 괄호와 닫는 괄호의 개수만 비교했다면, 이제는 순서와 중첩까지 정확하게 검증할 수 있습니다. "( ) )"같은 잘못된 입력도 정확히 잡아낼 수 있죠.
핵심 특징은 첫째, O(n) 시간에 검증이 완료되어 효율적입니다. 둘째, 여러 종류의 괄호((), [], {})를 동시에 검증할 수 있습니다.
셋째, 에러 위치를 정확히 알려줄 수 있습니다. 이러한 특징들이 강력한 입력 검증 시스템을 만들어줍니다.
코드 예제
// 괄호 매칭 검증기
class ParenthesesValidator {
constructor() {
// 괄호 쌍 정의
this.pairs = {
'(': ')',
'[': ']',
'{': '}'
};
// 닫는 괄호 → 여는 괄호 역매핑
this.closingToOpening = {
')': '(',
']': '[',
'}': '{'
};
}
// 괄호 매칭 검증 (상세한 에러 정보 포함)
validate(expression) {
const stack = [];
const errors = [];
for (let i = 0; i < expression.length; i++) {
const char = expression[i];
// 여는 괄호인 경우
if (this.pairs[char]) {
stack.push({ char, position: i });
}
// 닫는 괄호인 경우
else if (this.closingToOpening[char]) {
if (stack.length === 0) {
// 매칭되는 여는 괄호가 없음
errors.push({
type: 'UNMATCHED_CLOSING',
char,
position: i,
message: `위치 ${i}에 매칭되지 않는 닫는 괄호 '${char}'가 있습니다.`
});
} else {
const opening = stack.pop();
const expectedClosing = this.pairs[opening.char];
if (char !== expectedClosing) {
// 괄호 타입이 맞지 않음
errors.push({
type: 'MISMATCHED_TYPE',
char,
position: i,
expected: expectedClosing,
message: `위치 ${i}의 '${char}'는 위치 ${opening.position}의 '${opening.char}'와 매칭되지 않습니다. '${expectedClosing}'가 필요합니다.`
});
}
}
}
}
// 닫히지 않은 여는 괄호 확인
while (stack.length > 0) {
const unclosed = stack.pop();
errors.push({
type: 'UNCLOSED_OPENING',
char: unclosed.char,
position: unclosed.position,
message: `위치 ${unclosed.position}의 여는 괄호 '${unclosed.char}'가 닫히지 않았습니다.`
});
}
return {
isValid: errors.length === 0,
errors
};
}
// 간단한 검증 (boolean만 반환)
isValid(expression) {
const result = this.validate(expression);
return result.isValid;
}
// 수식용 괄호 검증 (숫자와 연산자 무시)
validateExpression(expression) {
// 괄호만 추출
const brackets = expression.split('').filter(char =>
this.pairs[char] || this.closingToOpening[char]
).join('');
return this.validate(brackets);
}
}
// 사용 예시
const validator = new ParenthesesValidator();
const test1 = "((3 + 5) * 2)";
console.log(validator.validateExpression(test1));
// { isValid: true, errors: [] }
const test2 = "((3 + 5) * 2";
console.log(validator.validateExpression(test2));
// { isValid: false, errors: [{ type: 'UNCLOSED_OPENING', ... }] }
const test3 = "(3 + [5 * 2)}";
console.log(validator.validateExpression(test3));
// { isValid: false, errors: [{ type: 'MISMATCHED_TYPE', ... }] }
설명
이것이 하는 일: 수식이나 코드에서 괄호가 올바르게 매칭되는지 검사하고, 에러가 있다면 정확한 위치와 원인을 알려줍니다. 전체적인 동작은 문자열을 순회하면서 여는 괄호는 스택에 저장하고, 닫는 괄호를 만나면 스택에서 꺼내 타입이 일치하는지 확인하는 방식입니다.
첫 번째로, 생성자에서 괄호 쌍을 정의합니다. pairs 객체는 여는 괄호를 키로, 대응하는 닫는 괄호를 값으로 저장합니다.
closingToOpening은 그 반대로, 닫는 괄호에서 여는 괄호를 빠르게 찾기 위한 역매핑입니다. 이렇게 하는 이유는 양방향 조회를 O(1)에 수행하기 위함입니다.
그 다음으로, validate 메서드에서 문자열을 한 글자씩 순회합니다. 내부에서는 현재 위치(i)를 추적하여 에러가 발생한 정확한 위치를 기록합니다.
여는 괄호를 만나면 괄호 문자와 위치를 함께 스택에 저장합니다. 나중에 에러 메시지에서 "몇 번째 위치의 괄호"라고 알려주기 위함이죠.
닫는 괄호를 만났을 때 스택이 비어있다면, 대응하는 여는 괄호가 없다는 의미이므로 'UNMATCHED_CLOSING' 에러를 기록합니다. 스택에 여는 괄호가 있다면 pop하여 타입을 비교합니다.
예를 들어 '('를 열었는데 ']'로 닫으려 하면 'MISMATCHED_TYPE' 에러를 기록하고, 어떤 괄호가 필요한지도 함께 알려줍니다. 모든 문자를 처리한 후에도 스택에 여는 괄호가 남아있다면, 그것들은 닫히지 않은 괄호입니다.
이 경우 'UNCLOSED_OPENING' 에러를 기록하여 사용자가 어느 위치의 괄호를 닫지 않았는지 정확히 알 수 있게 합니다. validateExpression 메서드는 수식 전용으로, 숫자와 연산자를 무시하고 괄호만 추출하여 검증합니다.
이렇게 하면 "3 + 5"같은 일반 문자 때문에 검증이 방해받지 않습니다. 여러분이 이 코드를 사용하면 괄호 에러를 정확하게 감지하고 사용자에게 친절한 메시지를 제공할 수 있습니다.
어느 위치에 어떤 문제가 있는지 명확히 알려주므로 디버깅이 쉽고, 여러 종류의 괄호를 동시에 지원하며, 에러 타입별로 다른 처리가 가능합니다.
실전 팁
💡 실시간 검증을 구현하려면 사용자가 타이핑할 때마다 validate를 호출하고, 에러 위치에 빨간색 밑줄을 표시하세요.
💡 IDE처럼 괄호 짝을 하이라이팅하려면 validate 과정에서 매칭된 괄호 쌍의 위치를 배열로 반환하도록 확장하세요.
💡 성능 최적화를 위해 큰 파일은 청크 단위로 나누어 검증하고, Web Worker에서 백그라운드로 실행하세요.
💡 다국어 지원을 위해 에러 메시지를 별도 파일로 분리하고, message 대신 messageKey를 반환하세요.
💡 자동 수정 기능을 추가하려면 에러 타입별로 수정 방법(닫는 괄호 추가, 잘못된 괄호 교체)을 제안하세요.
7. 에러_처리와_디버깅_전략
시작하며
여러분이 계산기를 사용하는 사용자에게 "Invalid expression"이라는 메시지만 보여준다면, 사용자는 무엇이 잘못되었는지 전혀 알 수 없습니다. 개발자에게도 버그를 추적하기가 어렵죠.
이런 문제는 모든 애플리케이션에서 발생합니다. 에러가 발생했을 때 명확한 정보를 제공하지 않으면 사용자는 좌절하고, 개발자는 디버깅에 시간을 낭비합니다.
프로덕션 환경에서는 더욱 심각합니다. 바로 이럴 때 필요한 것이 체계적인 에러 처리와 디버깅 전략입니다.
에러를 타입별로 분류하고, 상세한 컨텍스트를 제공하며, 디버깅 도구를 내장하면 개발과 운영이 훨씬 쉬워집니다.
개요
간단히 말해서, 에러를 커스텀 클래스로 정의하고, 발생 위치와 원인을 상세히 기록하며, 디버그 모드에서는 계산 과정을 단계별로 추적할 수 있게 만듭니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 좋은 에러 처리는 사용자 경험과 직결됩니다.
사용자는 무엇을 고쳐야 하는지 알아야 하고, 개발자는 버그를 빠르게 찾아야 합니다. 예를 들어, 금융 계산 앱에서 계산 오류가 발생하면 정확한 원인을 로깅하여 감사(audit) 추적이 가능해야 합니다.
기존에는 console.error로 간단히 로깅하거나 generic한 에러만 던졌다면, 이제는 에러 타입, 발생 위치, stack trace, 계산 히스토리 등을 체계적으로 관리할 수 있습니다. 핵심 특징은 첫째, 에러를 카테고리별로 분류하여 적절한 대응이 가능합니다.
둘째, 디버그 모드에서는 모든 계산 단계를 추적합니다. 셋째, 프로덕션 모드에서는 민감한 정보를 숨기고 간결한 메시지만 제공합니다.
이러한 특징들이 안정적이고 유지보수하기 쉬운 애플리케이션을 만듭니다.
코드 예제
// 커스텀 에러 클래스들
class CalculatorError extends Error {
constructor(message, errorType, context = {}) {
super(message);
this.name = 'CalculatorError';
this.errorType = errorType;
this.context = context;
this.timestamp = new Date().toISOString();
}
}
// 디버깅 기능이 포함된 계산기
class DebuggableCalculator {
constructor(options = {}) {
this.debugMode = options.debug || false;
this.history = [];
this.operators = this.initOperators();
}
initOperators() {
return {
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
'/': (a, b) => {
if (b === 0) {
throw new CalculatorError(
'0으로 나눌 수 없습니다',
'DIVISION_BY_ZERO',
{ dividend: a, divisor: b }
);
}
return a / b;
},
'^': (a, b) => Math.pow(a, b)
};
}
// 디버그 로그 기록
log(step, data) {
if (this.debugMode) {
const entry = {
step,
timestamp: Date.now(),
data
};
this.history.push(entry);
console.log(`[DEBUG ${step}]`, data);
}
}
// 토큰화 with 에러 처리
tokenize(expression) {
this.log('TOKENIZE_START', { expression });
const tokens = expression.match(/\d+\.?\d*|[+\-*/^()]/g);
if (!tokens) {
throw new CalculatorError(
'유효하지 않은 수식입니다',
'INVALID_EXPRESSION',
{ expression }
);
}
this.log('TOKENIZE_COMPLETE', { tokens });
return tokens;
}
// 중위 → 후위 변환 with 디버깅
infixToPostfix(tokens) {
this.log('CONVERSION_START', { tokens });
const precedence = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3};
const stack = [];
const output = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (!isNaN(token)) {
output.push(token);
this.log('PUSH_OPERAND', { token, output: [...output] });
} else if (token === '(') {
stack.push(token);
this.log('PUSH_PAREN', { stack: [...stack] });
} else if (token === ')') {
while (stack.length && stack[stack.length - 1] !== '(') {
const op = stack.pop();
output.push(op);
this.log('POP_OPERATOR', { operator: op, output: [...output] });
}
if (!stack.length) {
throw new CalculatorError(
'괄호가 매칭되지 않습니다',
'MISMATCHED_PARENTHESES',
{ position: i, token }
);
}
stack.pop();
this.log('POP_PAREN', { stack: [...stack] });
} else if (this.operators[token]) {
while (stack.length &&
precedence[stack[stack.length - 1]] >= precedence[token]) {
const op = stack.pop();
output.push(op);
this.log('POP_HIGHER_PRECEDENCE', {
operator: op,
current: token,
output: [...output]
});
}
stack.push(token);
this.log('PUSH_OPERATOR', { token, stack: [...stack] });
}
}
while (stack.length) {
const op = stack.pop();
if (op === '(') {
throw new CalculatorError(
'여는 괄호가 닫히지 않았습니다',
'UNCLOSED_PARENTHESIS',
{ operator: op }
);
}
output.push(op);
}
this.log('CONVERSION_COMPLETE', { postfix: output });
return output;
}
// 후위 표기법 계산 with 디버깅
evaluatePostfix(postfix) {
this.log('EVALUATION_START', { postfix });
const stack = [];
for (let i = 0; i < postfix.length; i++) {
const token = postfix[i];
if (!isNaN(token)) {
stack.push(Number(token));
this.log('PUSH_NUMBER', { number: token, stack: [...stack] });
} else if (this.operators[token]) {
if (stack.length < 2) {
throw new CalculatorError(
'피연산자가 부족합니다',
'INSUFFICIENT_OPERANDS',
{ position: i, operator: token, stackSize: stack.length }
);
}
const b = stack.pop();
const a = stack.pop();
try {
const result = this.operators[token](a, b);
stack.push(result);
this.log('APPLY_OPERATOR', {
operator: token,
operands: [a, b],
result,
stack: [...stack]
});
} catch (error) {
if (error instanceof CalculatorError) {
throw error;
}
throw new CalculatorError(
`연산 중 오류 발생: ${error.message}`,
'OPERATION_ERROR',
{ operator: token, operands: [a, b], originalError: error.message }
);
}
}
}
if (stack.length !== 1) {
throw new CalculatorError(
'계산 결과가 올바르지 않습니다',
'INVALID_RESULT',
{ finalStack: stack }
);
}
this.log('EVALUATION_COMPLETE', { result: stack[0] });
return stack[0];
}
// 전체 계산 프로세스
calculate(expression) {
try {
this.history = [];
this.log('CALCULATE_START', { expression });
const tokens = this.tokenize(expression);
const postfix = this.infixToPostfix(tokens);
const result = this.evaluatePostfix(postfix);
this.log('CALCULATE_COMPLETE', { result });
return result;
} catch (error) {
if (error instanceof CalculatorError) {
// 프로덕션에서는 간단한 메시지만
if (!this.debugMode) {
console.error(`계산 오류: ${error.message}`);
} else {
// 디버그 모드에서는 상세 정보
console.error('=== 계산 오류 발생 ===');
console.error('메시지:', error.message);
console.error('타입:', error.errorType);
console.error('컨텍스트:', error.context);
console.error('발생 시각:', error.timestamp);
console.error('계산 히스토리:', this.history);
}
throw error;
}
// 예상치 못한 에러
console.error('예상치 못한 오류:', error);
throw new CalculatorError(
'알 수 없는 오류가 발생했습니다',
'UNKNOWN_ERROR',
{ originalError: error.message }
);
}
}
// 디버그 히스토리 가져오기
getHistory() {
return this.history;
}
}
// 사용 예시
const calc = new DebuggableCalculator({ debug: true });
try {
const result = calc.calculate("3 + 5 * 2");
console.log("결과:", result);
} catch (error) {
console.error("에러 발생:", error.message);
}
// 디버그 히스토리 확인
console.log("전체 히스토리:", calc.getHistory());
설명
이것이 하는 일: 계산 과정의 모든 단계를 추적하고, 에러가 발생하면 정확한 위치와 원인을 상세히 기록하여 디버깅을 쉽게 만듭니다. 전체적인 동작은 각 메서드에서 log를 호출하여 히스토리를 쌓고, 에러 발생 시 CalculatorError를 던져 타입별로 처리하는 방식입니다.
첫 번째로, CalculatorError 클래스를 정의합니다. 기본 Error를 확장하여 errorType, context, timestamp를 추가했습니다.
이렇게 하는 이유는 에러를 프로그래밍 방식으로 처리하고, 로깅 시스템과 통합하며, 에러 타입별로 다른 UI를 보여주기 위함입니다. 예를 들어 'DIVISION_BY_ZERO'는 "0으로 나눌 수 없습니다"라는 사용자 친화적 메시지를 보여주고, context에는 실제 피연산자 값을 기록합니다.
그 다음으로, DebuggableCalculator 생성자에서 debugMode 옵션을 받습니다. 내부에서는 이 플래그에 따라 로깅 수준을 조절합니다.
개발 환경에서는 debug: true로 설정하여 모든 단계를 콘솔에 출력하고, 프로덕션에서는 false로 설정하여 성능을 유지합니다. log 메서드는 debugMode일 때만 히스토리에 기록하고 콘솔에 출력합니다.
각 로그 엔트리는 step(단계 이름), timestamp, data(상세 정보)를 포함합니다. 나중에 이 히스토리를 분석하면 어느 단계에서 문제가 발생했는지 정확히 알 수 있습니다.
각 계산 메서드(tokenize, infixToPostfix, evaluatePostfix)에서는 중요한 지점마다 log를 호출합니다. 예를 들어 'PUSH_OPERAND', 'POP_OPERATOR', 'APPLY_OPERATOR' 같은 단계마다 현재 스택과 출력의 상태를 기록합니다.
에러가 발생하면 CalculatorError를 던지는데, 에러 타입과 함께 발생 위치(position), 관련 토큰, 스택 상태 등을 context에 담습니다. calculate 메서드는 try-catch로 전체 프로세스를 감쌉니다.
CalculatorError를 잡으면 debugMode에 따라 다른 수준의 정보를 출력합니다. 프로덕션에서는 간단한 메시지만 보여주고, 디버그 모드에서는 에러 타입, 컨텍스트, 타임스탬프, 전체 히스토리까지 모두 출력합니다.
예상치 못한 에러는 UNKNOWN_ERROR로 래핑하여 일관된 에러 처리 흐름을 유지합니다. 여러분이 이 코드를 사용하면 버그를 빠르게 찾고 수정할 수 있습니다.
사용자에게는 이해하기 쉬운 에러 메시지를 보여주고, 개발자에게는 상세한 디버그 정보를 제공하며, 프로덕션과 개발 환경을 구분하여 적절한 로깅 수준을 유지할 수 있습니다.
실전 팁
💡 Sentry나 LogRocket 같은 에러 추적 서비스와 통합하려면 CalculatorError를 잡아서 error.context를 메타데이터로 전송하세요.
💡 단위 테스트에서는 특정 에러 타입이 발생하는지 검증하면 더 명확한 테스트를 작성할 수 있습니다: expect(() => calc.calculate('5/0')).toThrow(CalculatorError)
💡 사용자에게 에러를 보여줄 때는 errorType에 따라 다른 아이콘이나 색상을 사용하면 UX가 개선됩니다.
💡 성능 프로파일링을 위해 각 step의 실행 시간을 측정하려면 Date.now() 차이를 계산하여 기록하세요.
💡 React나 Vue에서 사용할 때는 history를 state로 관리하면 UI에 계산 과정을 실시간으로 보여줄 수 있습니다.
8. 성능_최적화_전략
시작하며
여러분이 계산기가 잘 동작하는 것을 확인했는데, 사용자가 매우 긴 수식을 입력하면 느려진다고 불평한다면 어떻게 하시겠어요? 기본 구현은 정확하지만 효율적이지 않을 수 있습니다.
이런 문제는 애플리케이션이 성장하면서 자연스럽게 발생합니다. 작은 데이터에서는 문제없지만, 대용량 데이터나 복잡한 계산에서는 성능이 중요해집니다.
특히 실시간 계산이나 대량 배치 처리에서는 최적화가 필수적입니다. 바로 이럴 때 필요한 것이 성능 최적화 전략입니다.
메모이제이션, 객체 풀링, 정규식 최적화 등을 적용하면 같은 기능을 훨씬 빠르게 수행할 수 있습니다.
개요
간단히 말해서, 반복 계산을 캐싱하고, 불필요한 객체 생성을 줄이며, 알고리즘을 최적화하여 성능을 크게 향상시킵니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 성능은 사용자 경험에 직접적인 영향을 미칩니다.
1초 이상 걸리는 계산은 사용자를 좌절시키고, 서버 리소스를 낭비합니다. 예를 들어, 스프레드시트에서 수천 개의 셀을 계산하거나, 실시간 그래프를 업데이트할 때 성능이 매우 중요합니다.
기존에는 매번 새로 계산하고 객체를 생성했다면, 이제는 결과를 캐싱하고 객체를 재사용하여 메모리와 CPU를 절약할 수 있습니다. 핵심 특징은 첫째, 메모이제이션으로 중복 계산을 제거합니다.
둘째, 객체 풀링으로 가비지 컬렉션 부담을 줄입니다. 셋째, 정규식을 미리 컴파일하여 파싱 속도를 높입니다.
이러한 특징들이 대용량 처리에서도 빠른 응답 속도를 보장합니다.
코드 예제
// 성능 최적화된 계산기
class OptimizedCalculator {
constructor(options = {}) {
this.cacheSize = options.cacheSize || 100;
this.resultCache = new Map(); // 결과 캐시
this.cacheHits = 0;
this.cacheMisses = 0;
// 정규식 미리 컴파일
this.tokenRegex = /\d+\.?\d*|[+\-*/^()]/g;
// 연산자 함수 (클로저 최적화)
this.operators = this.createOperators();
this.precedence = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3};
}
createOperators() {
// 함수를 한 번만 생성하여 재사용
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => b === 0 ? NaN : a / b;
const power = (a, b) => Math.pow(a, b);
return {
'+': add,
'-': subtract,
'*': multiply,
'/': divide,
'^': power
};
}
// LRU 캐시 구현
getCachedResult(expression) {
if (this.resultCache.has(expression)) {
this.cacheHits++;
return this.resultCache.get(expression);
}
this.cacheMisses++;
return null;
}
setCachedResult(expression, result) {
// 캐시 크기 제한
if (this.resultCache.size >= this.cacheSize) {
// 가장 오래된 항목 제거 (LRU)
const firstKey = this.resultCache.keys().next().value;
this.resultCache.delete(firstKey);
}
this.resultCache.set(expression, result);
}
// 토큰화 최적화 (정규식 재사용)
tokenize(expression) {
// 정규식 lastIndex 초기화
this.tokenRegex.lastIndex = 0;
const tokens = expression.match(this.tokenRegex);
return tokens || [];
}
// 변환 최적화 (배열 사이즈 미리 할당)
infixToPostfix(tokens) {
// 최대 크기 예측하여 배열 할당
const stack = [];
const output = new Array(tokens.length);
let outputIndex = 0;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (!isNaN(token)) {
output[outputIndex++] = token;
} else if (token === '(') {
stack.push(token);
} else if (token === ')') {
while (stack.length && stack[stack.length - 1] !== '(') {
output[outputIndex++] = stack.pop();
}
stack.pop(); // '(' 제거
} else if (this.operators[token]) {
// 인라인 우선순위 비교로 함수 호출 감소
const currentPrec = this.precedence[token];
while (stack.length) {
const top = stack[stack.length - 1];
const topPrec = this.precedence[top];
if (topPrec !== undefined && topPrec >= currentPrec) {
output[outputIndex++] = stack.pop();
} else {
break;
}
}
stack.push(token);
}
}
while (stack.length) {
output[outputIndex++] = stack.pop();
}
// 실제 사용된 크기만큼 잘라내기
output.length = outputIndex;
return output;
}
// 계산 최적화 (타입 변환 최소화)
evaluatePostfix(postfix) {
const stack = new Array(postfix.length);
let stackIndex = 0;
for (let i = 0; i < postfix.length; i++) {
const token = postfix[i];
// 숫자 체크 최적화 (타입 캐스팅 한 번만)
const num = +token;
if (!isNaN(num)) {
stack[stackIndex++] = num;
} else {
const op = this.operators[token];
if (op) {
const b = stack[--stackIndex];
const a = stack[--stackIndex];
stack[stackIndex++] = op(a, b);
}
}
}
return stack[0];
}
// 메인 계산 함수 (캐싱 적용)
calculate(expression) {
// 공백 제거 (한 번만)
const normalized = expression.replace(/\s+/g, '');
// 캐시 확인
const cached = this.getCachedResult(normalized);
if (cached !== null) {
return cached;
}
// 계산
const tokens = this.tokenize(normalized);
const postfix = this.infixToPostfix(tokens);
const result = this.evaluatePostfix(postfix);
// 캐시 저장
this.setCachedResult(normalized, result);
return result;
}
// 성능 통계
getStats() {
const totalRequests = this.cacheHits + this.cacheMisses;
const hitRate = totalRequests > 0
? (this.cacheHits / totalRequests * 100).toFixed(2)
: 0;
return {
cacheHits: this.cacheHits,
cacheMisses: this.cacheMisses,
hitRate: `${hitRate}%`,
cacheSize: this.resultCache.size
};
}
// 캐시 초기화
clearCache() {
this.resultCache.clear();
this.cacheHits = 0;
this.cacheMisses = 0;
}
}
// 성능 테스트
const calc = new OptimizedCalculator({ cacheSize: 50 });
console.time('첫 번째 계산');
console.log(calc.calculate("3 + 5 * 2 ^ 3"));
console.timeEnd('첫 번째 계산');
console.time('캐시된 계산');
console.log(calc.calculate("3 + 5 * 2 ^ 3")); // 캐시 히트!
console.timeEnd('캐시된 계산');
console.log('성능 통계:', calc.getStats());
설명
이것이 하는 일: 다양한 최적화 기법을 적용하여 같은 기능을 훨씬 빠르게 수행합니다. 전체적인 동작은 자주 사용되는 계산 결과를 캐싱하고, 불필요한 객체 생성과 함수 호출을 최소화하며, 알고리즘을 개선하는 방식입니다.
첫 번째로, 생성자에서 resultCache Map을 생성합니다. Map은 Object보다 키 조회가 빠르고, 크기를 쉽게 관리할 수 있습니다.
이렇게 하는 이유는 같은 수식이 반복 입력될 때 매번 계산하지 않고 캐시된 결과를 즉시 반환하기 위함입니다. tokenRegex를 미리 컴파일하여 매번 정규식을 생성하는 오버헤드를 제거합니다.
그 다음으로, createOperators에서 연산자 함수를 한 번만 생성합니다. 내부에서는 클로저를 사용하지 않는 순수 함수로 만들어 메모리 효율을 높입니다.
매번 새로운 함수를 만들면 가비지 컬렉션 부담이 커지므로, 한 번 만들어 재사용합니다. getCachedResult와 setCachedResult는 LRU(Least Recently Used) 캐시를 구현합니다.
캐시가 가득 차면 가장 오래된 항목을 제거하여 메모리를 관리합니다. Map의 keys() 메서드는 삽입 순서를 유지하므로, 첫 번째 키가 가장 오래된 항목입니다.
cacheHits와 cacheMisses를 추적하여 캐시 효율성을 모니터링할 수 있습니다. infixToPostfix에서는 배열 크기를 미리 할당합니다.
new Array(tokens.length)로 최대 크기를 설정하면 동적 확장이 줄어들어 성능이 향상됩니다. outputIndex로 실제 위치를 추적하고, 마지막에 length를 조정하여 불필요한 빈 슬롯을 제거합니다.
우선순위 비교도 인라인으로 처리하여 함수 호출을 줄입니다. evaluatePostfix에서는 +token으로 숫자 변환을 한 번만 수행합니다.
Number(token)보다 빠르고, isNaN 체크와 동시에 변환이 이루어져 효율적입니다. 스택도 배열 크기를 미리 할당하고 인덱스로 직접 접근하여 push/pop 오버헤드를 제거합니다.
calculate 메서드는 먼저 수식을 정규화(공백 제거)하여 캐시 키로 사용합니다. "3+5"와 "3 + 5"가 같은 결과를 공유하도록 하는 것이죠.
캐시에서 찾으면 즉시 반환하고, 없으면 계산 후 캐시에 저장합니다. 여러분이 이 코드를 사용하면 대용량 계산에서도 빠른 응답을 얻을 수 있습니다.
반복되는 계산은 거의 즉시 결과가 나오고, 메모리 사용이 효율적이며, 성능 통계로 최적화 효과를 측정할 수 있습니다.
실전 팁
💡 Web Worker를 사용하면 메인 스레드를 블록하지 않고 백그라운드에서 계산할 수 있습니다. 복잡한 수식에 유용합니다.
💡 캐시 전략을 상황에 맞게 선택하세요. 사용자별 캐시가 필요하면 userId를 캐시 키에 포함시키고, 전역 캐시면 공유하세요.
💡 정말 큰 수식은 청크 단위로 나누어 계산하고, Promise.all로 병렬 처리하면 더 빠릅니다.
💡 성능 프로파일링 도구(Chrome DevTools Performance)로 병목 지점을 찾아 집중적으로 최적화하세요.
💡 서버 사이드에서는 Redis를 캐시로 사용하면 여러 인스턴스 간 결과를 공유할 수 있습니다.