Files
sam-api/app/Helpers/SafeMathEvaluator.php
김보곤 d8560d889c fix: [security] eval() 제거 — SafeMathEvaluator로 교체
- FormulaParser의 eval() 2곳 제거 (executeSimpleMath, evaluateCondition)
- FormulaEvaluatorService의 eval() 1곳 제거 (calculateExpression)
- Shunting-yard 알고리즘 기반 SafeMathEvaluator 신규 추가
- 사칙연산, 비교연산, 단항 마이너스, 괄호, 나머지 연산 지원
2026-03-15 10:20:39 +09:00

311 lines
9.0 KiB
PHP

<?php
namespace App\Helpers;
use InvalidArgumentException;
/**
* eval() 없이 산술 수식을 안전하게 계산하는 평가기
*
* Shunting-yard 알고리즘으로 중위 표기법 → 후위 표기법(RPN) 변환 후 계산
* 지원: 숫자, +, -, *, /, %, (, ), 단항 마이너스
*/
class SafeMathEvaluator
{
private const OPERATORS = ['+', '-', '*', '/', '%'];
private const PRECEDENCE = [
'+' => 2,
'-' => 2,
'*' => 3,
'/' => 3,
'%' => 3,
'UNARY_MINUS' => 4,
];
/**
* 산술 수식을 계산하여 float 반환
*/
public static function calculate(string $expression): float
{
$expression = trim($expression);
if ($expression === '') {
return 0;
}
$tokens = self::tokenize($expression);
if (empty($tokens)) {
return 0;
}
$rpn = self::toRPN($tokens);
return self::evaluateRPN($rpn);
}
/**
* 비교식을 평가하여 bool 반환
* 예: "3000 <= 6000", "100 == 100", "5 > 3 && 2 < 4"
*/
public static function compare(string $expression): bool
{
$expression = trim($expression);
// && 논리 AND 처리
if (str_contains($expression, '&&')) {
$parts = explode('&&', $expression);
foreach ($parts as $part) {
if (! self::compare(trim($part))) {
return false;
}
}
return true;
}
// || 논리 OR 처리
if (str_contains($expression, '||')) {
$parts = explode('||', $expression);
foreach ($parts as $part) {
if (self::compare(trim($part))) {
return true;
}
}
return false;
}
// 비교 연산자 추출 (2문자 먼저 검사)
$operators = ['>=', '<=', '!=', '==', '>', '<'];
foreach ($operators as $op) {
$pos = strpos($expression, $op);
if ($pos !== false) {
$left = self::calculate(substr($expression, 0, $pos));
$right = self::calculate(substr($expression, $pos + strlen($op)));
return match ($op) {
'>=' => $left >= $right,
'<=' => $left <= $right,
'!=' => $left != $right,
'==' => $left == $right,
'>' => $left > $right,
'<' => $left < $right,
};
}
}
// 비교 연산자가 없으면 수치를 boolean으로 평가
return (bool) self::calculate($expression);
}
/**
* 수식 문자열을 토큰 배열로 분리
*/
private static function tokenize(string $expression): array
{
$tokens = [];
$len = strlen($expression);
$i = 0;
while ($i < $len) {
$char = $expression[$i];
// 공백 건너뛰기
if ($char === ' ' || $char === "\t") {
$i++;
continue;
}
// 숫자 (정수, 소수)
if (is_numeric($char) || ($char === '.' && $i + 1 < $len && is_numeric($expression[$i + 1]))) {
$num = '';
while ($i < $len && (is_numeric($expression[$i]) || $expression[$i] === '.')) {
$num .= $expression[$i];
$i++;
}
$tokens[] = ['type' => 'number', 'value' => (float) $num];
continue;
}
// 괄호
if ($char === '(') {
$tokens[] = ['type' => 'lparen'];
$i++;
continue;
}
if ($char === ')') {
$tokens[] = ['type' => 'rparen'];
$i++;
continue;
}
// 연산자
if (in_array($char, self::OPERATORS)) {
// 단항 마이너스 판별: 맨 앞이거나, 앞이 연산자 또는 여는 괄호인 경우
if ($char === '-') {
$isUnary = empty($tokens)
|| $tokens[count($tokens) - 1]['type'] === 'operator'
|| $tokens[count($tokens) - 1]['type'] === 'lparen';
if ($isUnary) {
$tokens[] = ['type' => 'operator', 'value' => 'UNARY_MINUS'];
$i++;
continue;
}
}
$tokens[] = ['type' => 'operator', 'value' => $char];
$i++;
continue;
}
throw new InvalidArgumentException("허용되지 않는 문자: '{$char}' (위치 {$i})");
}
return $tokens;
}
/**
* 중위 표기법 토큰 → 후위 표기법(RPN) 변환 (Shunting-yard)
*/
private static function toRPN(array $tokens): array
{
$output = [];
$operatorStack = [];
foreach ($tokens as $token) {
if ($token['type'] === 'number') {
$output[] = $token;
continue;
}
if ($token['type'] === 'operator') {
$op = $token['value'];
$prec = self::PRECEDENCE[$op] ?? 0;
while (! empty($operatorStack)) {
$top = end($operatorStack);
if ($top['type'] === 'lparen') {
break;
}
$topPrec = self::PRECEDENCE[$top['value']] ?? 0;
// 단항 연산자는 오른쪽 결합
if ($op === 'UNARY_MINUS') {
if ($topPrec > $prec) {
$output[] = array_pop($operatorStack);
} else {
break;
}
} else {
// 이항 연산자는 왼쪽 결합 (같은 우선순위면 먼저 pop)
if ($topPrec >= $prec) {
$output[] = array_pop($operatorStack);
} else {
break;
}
}
}
$operatorStack[] = $token;
continue;
}
if ($token['type'] === 'lparen') {
$operatorStack[] = $token;
continue;
}
if ($token['type'] === 'rparen') {
while (! empty($operatorStack) && end($operatorStack)['type'] !== 'lparen') {
$output[] = array_pop($operatorStack);
}
if (empty($operatorStack)) {
throw new InvalidArgumentException('괄호 불일치: 여는 괄호 없음');
}
array_pop($operatorStack); // 여는 괄호 제거
continue;
}
}
while (! empty($operatorStack)) {
$top = array_pop($operatorStack);
if ($top['type'] === 'lparen') {
throw new InvalidArgumentException('괄호 불일치: 닫는 괄호 없음');
}
$output[] = $top;
}
return $output;
}
/**
* 후위 표기법(RPN) 계산
*/
private static function evaluateRPN(array $rpn): float
{
$stack = [];
foreach ($rpn as $token) {
if ($token['type'] === 'number') {
$stack[] = $token['value'];
continue;
}
if ($token['type'] === 'operator') {
$op = $token['value'];
// 단항 마이너스
if ($op === 'UNARY_MINUS') {
if (empty($stack)) {
throw new InvalidArgumentException('수식 오류: 단항 마이너스 피연산자 없음');
}
$stack[] = -array_pop($stack);
continue;
}
// 이항 연산자
if (count($stack) < 2) {
throw new InvalidArgumentException('수식 오류: 피연산자 부족');
}
$right = array_pop($stack);
$left = array_pop($stack);
$stack[] = match ($op) {
'+' => $left + $right,
'-' => $left - $right,
'*' => $left * $right,
'/' => $right != 0 ? $left / $right : throw new InvalidArgumentException('0으로 나눌 수 없음'),
'%' => $right != 0 ? fmod($left, $right) : throw new InvalidArgumentException('0으로 나눌 수 없음'),
default => throw new InvalidArgumentException("알 수 없는 연산자: {$op}"),
};
}
}
if (count($stack) !== 1) {
throw new InvalidArgumentException('수식 오류: 결과가 하나가 아님');
}
return (float) $stack[0];
}
}