티스토리 뷰
안녕하세요, 개발자 여러분! 지난 파트에서는 기본 사칙연산 로직을 구현했습니다. 이번 파트에서는 한 단계 더 나아가, 사용자들이 복잡한 수식을 입력하고 정확한 결과를 얻을 수 있도록 괄호 처리 기능과 연산자 우선순위(예: 곱셈/나눗셈 먼저)를 적용하는 방법을 알아보겠습니다. 이를 위해 유명한 알고리즘인 Shunting-yard 알고리즘과 후위 표기법 계산 방식을 도입할 것입니다.
1. 이론: 복잡한 수식 계산의 원리
중위 표기법 (Infix Notation)
우리가 일상적으로 사용하는 수식 표현 방식입니다. 피연산자 사이에 연산자가 위치합니다 (예: 3 + 4 * 2). 컴퓨터가 이 표기법을 직접 계산하려면 연산자 우선순위와 괄호를 고려해야 해서 복잡합니다.
후위 표기법 (Postfix Notation / Reverse Polish Notation - RPN)
연산자가 피연산자 뒤에 위치하는 방식입니다 (예: 3 4 2 * +). 이 방식은 괄호가 필요 없고, 연산자 우선순위를 따질 필요 없이 앞에서부터 순차적으로 스택(Stack)을 사용하여 계산할 수 있어 컴퓨터가 처리하기에 매우 효율적입니다.
- 3 + 4 * 2 (중위) => 3 4 2 * + (후위)
- (3 + 4) * 2 (중위) => 3 4 + 2 * (후위)
Shunting-yard 알고리즘
Edsger Dijkstra가 고안한 알고리즘으로, 중위 표기법으로 작성된 수식을 후위 표기법으로 변환하는 데 사용됩니다. 이 알고리즘은 연산자들의 우선순위를 고려하며, 괄호 처리도 가능하게 합니다. 주요 구성 요소:
- 입력 (Tokens): 수식을 숫자, 연산자, 괄호 등의 토큰으로 분리합니다.
- 출력 큐 (Output Queue): 변환된 후위 표기식 토큰들이 순서대로 저장됩니다.
- 연산자 스택 (Operator Stack): 연산자와 여는 괄호를 임시로 저장합니다.
후위 표기법 계산
변환된 후위 표기식은 스택을 사용하여 쉽게 계산할 수 있습니다:
- 토큰을 순서대로 읽습니다.
- 피연산자(숫자)가 나오면 스택에 푸시(push)합니다.
- 연산자가 나오면 스택에서 필요한 만큼의 피연산자(보통 2개)를 팝(pop)하여 연산하고, 그 결과를 다시 스택에 푸시합니다.
- 모든 토큰을 처리한 후 스택에 남아있는 마지막 값이 최종 결과입니다.
상태 관리의 변화
이전 파트에서는 _previousInput, _currentInput, _operator 변수로 간단한 계산을 처리했습니다. 하지만 괄호와 연산자 우선순위를 처리하려면 사용자가 입력한 전체 수식을 문자열 형태로 관리하고, 이를 분석하여 계산하는 방식으로 변경해야 합니다.
- _expression: 사용자가 입력하는 전체 중위 표기식 문자열.
- _output: 현재 입력 중인 표현식 또는 최종 계산 결과를 표시.
2. 코딩: Shunting-yard 알고리즘 및 후위 표기법 계산기 구현
lib/main.dart 파일의 _CalculatorHomePageState 클래스를 대폭 수정하여 새로운 계산 로직을 적용합니다.
Dart
import 'package:flutter/material.dart';
import 'dart:math' as math; // pow 함수 사용을 위해
// (이전 파트의 DeveloperCalculatorApp, CalculatorHomePage 클래스 정의는 동일하므로 생략)
// void main(), DeveloperCalculatorApp, CalculatorHomePage 클래스는 Part 2와 동일하게 유지합니다.
// 여기서는 _CalculatorHomePageState의 변경 사항에 집중합니다.
void main() {
runApp(const DeveloperCalculatorApp());
}
class DeveloperCalculatorApp extends StatelessWidget {
const DeveloperCalculatorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Developer Calculator',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blueGrey,
brightness: Brightness.dark,
),
useMaterial3: true,
),
home: const CalculatorHomePage(),
);
}
}
class CalculatorHomePage extends StatefulWidget {
const CalculatorHomePage({super.key});
@override
State<CalculatorHomePage> createState() => _CalculatorHomePageState();
}
class _CalculatorHomePageState extends State<CalculatorHomePage> {
String _expression = ""; // 사용자가 입력하는 전체 수식
String _output = "0"; // 화면에 표시될 값 (입력 중인 수식 또는 결과)
bool _evaluated = false; // 직전에 '=' 연산이 수행되었는지 여부
// 연산자 우선순위 정의
final Map<String, int> _precedence = {
'+': 1,
'-': 1,
'*': 2,
'/': 2,
'^': 3, // 제곱 연산자 (추후 추가 가능)
};
// 버튼 클릭 시 호출될 메인 로직
void _onButtonPressed(String buttonText) {
setState(() {
if (_evaluated && !_isOperator(buttonText) && buttonText != '.') {
// 계산 후 새 숫자 입력 시작 시, 이전 수식 초기화
if (buttonText != "(" && buttonText != ")") { // 괄호는 수식 연결 가능
_expression = "";
}
_evaluated = false;
} else if (_evaluated && (_isOperator(buttonText) || buttonText == '.')) {
// 계산 후 연산자나 소수점 입력 시, 이전 결과(_output)를 이어 사용
_expression = _output; // output이 이전 결과값임
_evaluated = false;
}
if (buttonText == "C") {
_clearAll();
} else if (buttonText == "=") {
_calculateResult();
} else if (buttonText == "⌫" || buttonText == "Del") { // 지우기 버튼
if (_expression.isNotEmpty) {
_expression = _expression.substring(0, _expression.length - 1);
if (_expression.isEmpty) _output = "0"; else _output = _expression;
}
}
else {
// 마지막 문자가 연산자이고, 새로 입력된 것도 연산자이면 마지막 연산자 교체
if (_expression.isNotEmpty && _isOperator(_expression[_expression.length -1]) && _isOperator(buttonText)){
_expression = _expression.substring(0, _expression.length -1) + buttonText;
} else {
_expression += buttonText;
}
_output = _expression;
}
});
}
void _clearAll() {
_expression = "";
_output = "0";
_evaluated = false;
}
bool _isOperator(String s) {
return ['+', '-', '*', '/', '^'].contains(s);
}
bool _isNumeric(String? s) {
if (s == null || s.isEmpty) {
return false;
}
return double.tryParse(s) != null;
}
// Shunting-yard 알고리즘으로 중위 표기법을 후위 표기법으로 변환
List<String> _convertToPostfix(String infix) {
List<String> postfix = [];
List<String> operatorStack = [];
// 숫자와 연산자를 분리 (정규 표현식 사용)
// 음수도 고려해야 함. 여기서는 간단히 공백으로 구분된 토큰을 가정하거나,
// 보다 정교한 토크나이저가 필요. 우선은 숫자, 연산자, 괄호 단위로 처리.
// 이 예제에서는 입력이 버튼 클릭으로 이루어지므로, 각 버튼 텍스트가 하나의 토큰이라고 간주.
// 따라서, 실제로는 _expression을 파싱하여 토큰 리스트로 만드는 과정이 필요.
// 임시 토크나이저: 숫자, 연산자, 괄호를 분리
RegExp exp = RegExp(r"(\d+\.?\d*)|([+\-*/^()])");
Iterable<Match> matches = exp.allMatches(infix);
List<String> tokens = matches.map((m) => m.group(0)!).toList();
for (String token in tokens) {
if (_isNumeric(token)) {
postfix.add(token);
} else if (token == '(') {
operatorStack.add(token);
} else if (token == ')') {
while (operatorStack.isNotEmpty && operatorStack.last != '(') {
postfix.add(operatorStack.removeLast());
}
if (operatorStack.isNotEmpty && operatorStack.last == '(') {
operatorStack.removeLast(); // '(' 제거
} else {
// 괄호 불일치 오류 처리
throw Exception("Mismatched parentheses");
}
} else if (_isOperator(token)) {
while (operatorStack.isNotEmpty &&
operatorStack.last != '(' &&
(_precedence[operatorStack.last] ?? 0) >= (_precedence[token] ?? 0)) {
postfix.add(operatorStack.removeLast());
}
operatorStack.add(token);
}
}
while (operatorStack.isNotEmpty) {
if (operatorStack.last == '(') {
// 괄호 불일치 오류 처리
throw Exception("Mismatched parentheses at end");
}
postfix.add(operatorStack.removeLast());
}
return postfix;
}
// 후위 표기법 수식 계산
double _evaluatePostfix(List<String> postfix) {
List<double> evalStack = [];
for (String token in postfix) {
if (_isNumeric(token)) {
evalStack.add(double.parse(token));
} else if (_isOperator(token)) {
if (evalStack.length < 2) throw Exception("Invalid postfix expression - insufficient operands for $token");
double val2 = evalStack.removeLast();
double val1 = evalStack.removeLast();
switch (token) {
case '+':
evalStack.add(val1 + val2);
break;
case '-':
evalStack.add(val1 - val2);
break;
case '*':
evalStack.add(val1 * val2);
break;
case '/':
if (val2 == 0) throw Exception("Division by zero");
evalStack.add(val1 / val2);
break;
case '^':
evalStack.add(math.pow(val1, val2).toDouble());
break;
}
}
}
if (evalStack.length == 1) return evalStack.first;
throw Exception("Invalid postfix expression - stack not empty");
}
void _calculateResult() {
if (_expression.isEmpty) return;
try {
// 마지막 문자가 연산자이면 제거 (예: "5*")
if (_isOperator(_expression[_expression.length-1])) {
_expression = _expression.substring(0, _expression.length-1);
}
List<String> postfixTokens = _convertToPostfix(_expression);
double result = _evaluatePostfix(postfixTokens);
// 결과를 정수로 표시할 수 있는지 확인
if (result == result.toInt()) {
_output = result.toInt().toString();
} else {
// 소수점 처리 (불필요한 0 제거)
String tempResult = result.toStringAsFixed(8); // 최대 8자리
tempResult = tempResult.replaceAll(RegExp(r'0+$'), ''); // 후행 0 제거
tempResult = tempResult.replaceAll(RegExp(r'\.$'), ''); // 마지막 . 제거
_output = tempResult;
}
_evaluated = true; // 계산 완료 플래그 설정
// _expression = _output; // 다음 계산을 위해 결과를 expression에 둘 수 있으나, UI/UX에 따라 결정
} catch (e) {
_output = "Error"; // e.toString()으로 더 자세한 오류 표시 가능
_expression = ""; // 오류 발생 시 수식 초기화
_evaluated = true; // 오류도 일종의 '평가 완료'로 간주하여 다음 입력 시 초기화
}
}
// 계산기 버튼 위젯 생성 (Part 2와 거의 동일, Del 버튼 추가 및 스타일 조정)
Widget _buildButton(String buttonText, {int flex = 1, Color? fgColor, Color? bgColor}) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
// 기본 색상 설정
fgColor ??= colorScheme.onPrimaryContainer;
bgColor ??= colorScheme.primaryContainer;
if (buttonText == "C" || buttonText == "Del" || buttonText == "⌫") {
bgColor = colorScheme.errorContainer.withOpacity(0.8);
fgColor = colorScheme.onErrorContainer;
} else if (_isOperator(buttonText) || buttonText == "(" || buttonText == ")") {
bgColor = colorScheme.secondaryContainer;
fgColor = colorScheme.onSecondaryContainer;
} else if (buttonText == "=") {
bgColor = colorScheme.primary;
fgColor = colorScheme.onPrimary;
}
return Expanded(
flex: flex,
child: Container(
margin: const EdgeInsets.all(5.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: fgColor,
backgroundColor: bgColor,
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 10.0),
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500), // 폰트 크기 조정
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
)
),
onPressed: () => _onButtonPressed(buttonText),
child: Text(buttonText),
),
),
);
}
@override
Widget build(BuildContext context) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('개발자 계산기 (Adv)'),
backgroundColor: colorScheme.surfaceVariant,
foregroundColor: colorScheme.onSurfaceVariant,
),
backgroundColor: colorScheme.background,
body: Column(
children: <Widget>[
Expanded(
flex: 2,
child: Container(
alignment: Alignment.bottomRight,
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
child: SingleChildScrollView( // 입력이 길어질 경우 스크롤 가능하도록
scrollDirection: Axis.horizontal,
reverse: true, // 오른쪽 정렬 효과 및 새 입력 시 스크롤되도록
child: Text(
_output, // 입력 중인 _expression 또는 결과 _output을 보여줌
style: TextStyle(
fontSize: _output.length > 15 ? 32 : 40, // 내용 길이에 따라 폰트 크기 조절
fontWeight: FontWeight.bold,
color: colorScheme.onBackground,
),
textAlign: TextAlign.right,
),
),
),
),
const Divider(height: 2, thickness: 1),
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: <Widget>[
// 버튼 행들 (UI는 이전과 유사, Del 버튼 추가)
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildButton("C"),
_buildButton("("),
_buildButton(")"),
_buildButton(_isOperator("/") ? "/" : "/"), // 테마에 맞게 아이콘이나 텍스트 변경 가능
],
),
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildButton("7"),
_buildButton("8"),
_buildButton("9"),
_buildButton(_isOperator("*") ? "*" : "*"),
],
),
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildButton("4"),
_buildButton("5"),
_buildButton("6"),
_buildButton("-"),
],
),
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildButton("1"),
_buildButton("2"),
_buildButton("3"),
_buildButton("+"),
],
),
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_buildButton("0", flex: 1), // 0 버튼
_buildButton("."),
_buildButton("⌫"), // 지우기 버튼
_buildButton("=", flex: 1),
],
),
),
],
),
),
),
],
),
);
}
}
2.1. 코드 변경점 설명
- 상태 변수 변경:
- _expression: 사용자가 입력한 전체 수식 문자열을 저장합니다.
- _output: 현재 입력 중인 수식 또는 최종 계산 결과를 화면에 표시합니다.
- _evaluated: 직전에 '=' 연산이 수행되었는지 여부를 나타내는 플래그입니다. 계산 직후 새로운 숫자를 입력하면 이전 수식을 지우고 새 입력을 시작하도록 합니다.
- 기존 _currentInput, _previousInput, _operator 등은 제거되거나 역할이 변경되었습니다.
- _onButtonPressed 로직 변경:
- 'C': 모든 상태를 초기화합니다 (_clearAll).
- '⌫' (또는 'Del'): _expression의 마지막 글자를 삭제합니다.
- '=': _calculateResult를 호출하여 최종 계산을 수행합니다.
- 숫자, 연산자, 괄호: _expression 문자열에 해당 버튼의 텍스트를 추가합니다.
- 계산 직후(_evaluated == true) 숫자 입력 시: _expression을 초기화.
- 계산 직후 연산자 입력 시: 이전 결과(_output)를 _expression의 시작으로 하여 연산을 이어감.
- 연속된 연산자 입력 시: 마지막 연산자를 새 연산자로 교체.
- Shunting-yard 알고리즘 (_convertToPostfix):
- 입력된 중위 표기식 _expression을 토큰 리스트로 변환합니다. (여기서는 정규표현식을 사용한 간단한 토크나이저 포함)
- 숫자, 여는 괄호, 닫는 괄호, 연산자를 각각 규칙에 따라 처리하여 후위 표기식 토큰 리스트를 생성합니다.
- 연산자 우선순위는 _precedence 맵을 참조합니다.
- 괄호 불일치 시 예외를 발생시킵니다.
- 후위 표기법 계산 (_evaluatePostfix):
- 변환된 후위 표기식 토큰 리스트를 입력받아 스택을 이용하여 계산합니다.
- 0으로 나누거나 잘못된 수식일 경우 예외를 발생시킵니다.
- math.pow를 사용하여 제곱 연산(^)도 지원 가능하도록 예시를 추가했습니다 (버튼 UI에는 아직 미반영).
- _calculateResult 로직 변경:
- _expression이 비어있으면 아무것도 하지 않습니다.
- _expression의 마지막 문자가 연산자이면 계산 전에 제거합니다 (예: "5*=").
- _convertToPostfix와 _evaluatePostfix를 순차적으로 호출하여 결과를 얻습니다.
- 결과를 적절히 포맷팅하여 _output에 저장합니다.
- 예외 발생 시 "Error"를 표시합니다.
- UI 변경:
- 디스플레이 영역(Text 위젯)을 SingleChildScrollView로 감싸서 긴 수식도 스크롤하여 볼 수 있도록 했습니다.
- ⌫ (Backspace/Delete) 버튼을 추가했습니다.
- 버튼들의 스타일(색상)을 기능에 따라 좀 더 명확하게 구분했습니다.
- 괄호 '('와 ')' 버튼이 이제 실제 기능과 연결됩니다.
- 폰트 크기를 내용 길이에 따라 동적으로 조절하는 로직을 개선했습니다.
3. SOLID 원칙: 단일 책임 원칙 (SRP) 및 향후 고려사항
- SRP: 이번 파트에서는 Shunting-yard 변환 로직(_convertToPostfix)과 후위 표기법 계산 로직(_evaluatePostfix)을 _CalculatorHomePageState 내에 별도의 private 메소드로 분리했습니다. 이는 각 메소드가 하나의 주요 책임을 갖도록 하여 SRP를 어느 정도 따르고 있습니다.
- 향후 더 복잡해지거나 재사용성을 높이려면, 이 로직들을 별도의 클래스(예: ExpressionParser, PostfixEvaluator)로 완전히 분리하는 것을 고려할 수 있습니다. 이렇게 하면 _CalculatorHomePageState는 UI 상호작용 및 이 클래스들의 조정자 역할에만 집중할 수 있게 됩니다.
- OCP (Open/Closed Principle): 새로운 연산자(예: %, ^, sqrt 등)를 추가할 때, _precedence 맵과 _evaluatePostfix의 switch 문만 수정하면 됩니다. 토큰화 로직이나 Shunting-yard의 핵심 흐름은 크게 변경되지 않아 비교적 확장에 열려있다고 볼 수 있습니다. 더 많은 함수(sin, cos 등)를 추가하려면 토큰 인식 및 처리 방식의 확장이 필요합니다.
4. 디버깅 팁 💡
- 토큰화 검증: _convertToPostfix 메소드 초입에서 infix 문자열이 tokens 리스트로 잘 분리되는지 print(tokens);를 통해 확인하세요. 특히 숫자, 연산자, 괄호가 정확히 인식되는지 중요합니다.
- Shunting-yard 단계별 추적: _convertToPostfix 내부의 for 루프 각 반복마다 postfix 리스트와 operatorStack의 상태를 print로 출력하면 알고리즘의 동작을 이해하는 데 매우 유용합니다.
Dart
// _convertToPostfix 내 루프 안
print('Token: $token, Postfix: $postfix, Stack: $operatorStack');
- 후위 표기법 계산 추적: _evaluatePostfix 내부의 for 루프 각 반복마다 evalStack의 상태를 print로 출력하여 계산 과정을 확인하세요.
- 오류 메시지 활용: try-catch 블록에서 catch (e)의 e.toString()을 _output에 표시하거나 print하면 어떤 종류의 오류(괄호 불일치, 0으로 나누기, 잘못된 표현식 등)가 발생했는지 더 자세히 알 수 있습니다.
- 단위 테스트: 다양한 입력 수식(간단한 것부터 복잡한 괄호 포함 수식, 오류 케이스까지)에 대해 _convertToPostfix와 _evaluatePostfix가 예상대로 동작하는지 별도의 테스트 코드를 작성하면 좋습니다. (Flutter의 test 패키지 활용)
5. Q&A ❓
Q1: Shunting-yard 알고리즘이 무엇이고 왜 필요한가요? A1: Shunting-yard 알고리즘은 우리가 일반적으로 사용하는 중위 표기법 수식(예: 3 + 4 * 2)을 컴퓨터가 계산하기 쉬운 후위 표기법(예: 3 4 2 * +)으로 변환하는 방법입니다. 이 변환 과정에서 연산자 우선순위(곱셈/나눗셈 먼저)와 괄호가 자동으로 처리되기 때문에, 복잡한 수식을 정확하고 체계적으로 계산하기 위해 필요합니다.
Q2: 후위 표기법(Postfix Notation)은 무엇이며 장점은 무엇인가요? A2: 후위 표기법은 연산자를 피연산자 뒤에 놓는 표기법입니다 (예: a b +는 a + b와 동일). 주요 장점은 다음과 같습니다:
- 괄호 불필요: 연산 순서가 표기법 자체에 명확히 나타나므로 괄호가 필요 없습니다.
- 우선순위 불필요: 연산자를 만나는 순서대로 계산하면 되므로 연산자 우선순위를 고려할 필요가 없습니다.
- 간단한 계산 로직: 스택 하나만 사용하여 왼쪽에서 오른쪽으로 읽어가며 간단하게 계산할 수 있습니다.
Q3: 현재 구현에서 연산자 우선순위는 어떻게 처리되나요? A3: _convertToPostfix (Shunting-yard) 메소드 내에서 처리됩니다. _precedence라는 맵에 각 연산자별 우선순위(높은 숫자 = 높은 우선순위)를 정의해두었습니다. 알고리즘은 토큰을 처리할 때 연산자 스택의 최상단 연산자와 현재 처리 중인 연산자의 우선순위를 비교합니다. 스택 위의 연산자 우선순위가 높거나 같으면 스택에서 팝하여 출력 큐에 추가하고, 낮으면 현재 연산자를 스택에 푸시합니다. 이 과정을 통해 우선순위가 높은 연산자가 먼저 후위 표기식에 포함되어 나중에 먼저 계산될 수 있도록 합니다.
이번 파트에서는 계산기의 핵심 두뇌를 상당히 업그레이드했습니다! 다음 파트에서는 개발자용 계산기의 특성을 살려 진수 변환(BIN, HEX, DEC) 기능과 비트 연산자 기능을 추가하는 방법에 대해 알아보겠습니다. 기대해주세요!
'개발 > Flutter' 카테고리의 다른 글
Flutter 개발자 계산기 만들기 - Part 5: 메모리 기능 및 UI/UX 개선 (0) | 2025.05.28 |
---|---|
Flutter 개발자 계산기 만들기 - Part 4: 진수 변환 (DEC, BIN, HEX) 및 기본 비트 연산 (0) | 2025.05.27 |
Flutter 개발자 계산기 만들기 - Part 2: 기본 사칙연산 로직 구현 (0) | 2025.05.23 |
Flutter 개발자 계산기 만들기 - Part 1: 프로젝트 설정 및 기본 UI 레이아웃 (0) | 2025.05.22 |
Flutter : Exception - Unable to generate build files 예외 발생 메시지 확인될 때 (0) | 2023.10.13 |