문자열 계산기
요구사항
- 사용자가 입력한 문자열 값에 따라 사칙연산을 수행할 수 있는 계산기를 구현해야 한다.
- 문자열 계산기는 사칙연산의 계산 우선순위가 아닌 입력 값에 따라 계산 순서가 결정된다. 즉, 수학에서는 곱셈, 나눗셈이 덧셈, 뺄셈 보다 먼저 계산해야 하지만 이를 무시한다.
- 예를 들어 "2 + 3 * 4 / 2"와 같은 문자열을 입력할 경우 2 + 3 * 4 / 2 실행 결과인 10을 출력해야 한다.
익숙해지기 위해 모든 버전들은 전 버전에서 리팩토링하는 것이 아닌 전 버전의 수정해야할 부분을 토대로 다시 처음부터 새롭게 작성하고 있다.
V1
📌 CalculatorTest
public class CalculatorTest {
@ParameterizedTest
@ValueSource(strings = {
"3 + 3 + 3 + 3 + 3 + 3 /",
"+ 3 + 3 + 3 + 3 + 3 + 3",
"2 * / 2",
"4 + 8 -"
})
void 올바른_연산식이_아니면_예외가_발생한다(String expression) {
assertThatThrownBy(() -> Calculator.create(expression))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("올바른 연산식이 아닙니다.");
}
@ParameterizedTest
@CsvSource(value = {
"2 + 3 * 4 / 2:10",
"2 + 6 / 4 - 2:0",
"3:3",
"0 / 5:0",
"-5 - 4 * 2 + 5:-13",
"+3 + 3 + 3 + 3 + 3 + 3:18"
}, delimiter = ':')
void 입력값_순서가_계산순서를_결정한다(String expression, int calculationResults) {
Calculator calculator = Calculator.create(expression);
assertThat(calculator.calculate()).isEqualTo(calculationResults);
}
@ParameterizedTest
@ValueSource(strings = {
"4 / 0"
})
void 숫자_0을_나누면_예외가_발생한다(String expression) {
Calculator calculator = Calculator.create(expression);
assertThatThrownBy(() -> calculator.calculate())
.isInstanceOf(ArithmeticException.class)
.hasMessage("0을 나눌 수 없습니다.");
}
}
📌 Calculator
public class Calculator {
private final String expression;
public static Calculator create(String expression) {
if (!validatingExpression(expression))
throw new IllegalArgumentException("올바른 연산식이 아닙니다.");
return new Calculator(expression);
}
private static boolean validatingExpression(String expression) {
Pattern pattern = Pattern.compile("^[+\\-]?\\d( ?[*\\-+/] ?\\d)*$");
Matcher matcher = pattern.matcher(expression);
return matcher.find();
}
private Calculator(String expression) {
this.expression = expression;
}
public int calculate() {
String[] parsingExpression = expression.split(" ");
int calculationResult = Integer.parseInt(parsingExpression[0]);
String operator = "";
int operand = 0;
for(int i=0; i< parsingExpression.length; i++) {
if (i%2 != 0) {
operator = parsingExpression[i];
continue;
}
operand = Integer.parseInt(parsingExpression[i]);
if ("+".equals(operator)) calculationResult += operand;
if ("-".equals(operator)) calculationResult -= operand;
if ("*".equals(operator)) calculationResult *= operand;
if ("/".equals(operator)) {
checkDivideZero(operand);
calculationResult /= operand;
}
}
return calculationResult;
}
private void checkDivideZero(int operand) {
if (operand ==0) {
throw new ArithmeticException("0을 나눌 수 없습니다.");
}
}
}
🔍 보완해야할 점
Calculator 객체가 계산 기능도 담당하고, 연산식이 올바른지 아닌지에 대한 검증 기능도 담당한다. 너무 많은 책임을 지니고 있다. 책임을 분산할 필요가 보인다. 객체지향과 사실과 오해에서 배운 내용대로 협력을 통해 책임을 분산시키는 것이 좋은 방법 같다. Calculator 객체는 계산기 객체이므로 계산만 담당하고 연산식에 대한 검증은 다른 협력체가 하는 것이 좋아보인다. 그래서 사용자 정의 객체인 'Expression' 객체를 만들어서 연산식에 대한 검증 책임을 부여하면 될 것 같다.
현재 테스트 코드를 보면 연산식 검증에 대한 예외가 발생하는 경우에만 테스트를 하고 있다. 올바른 연산식이 예외가 발생하지 않고 제대로 생성하는지에 대한 테스트 코드가 필요해 보인다.
V2
🔍 V1에서 보완한 해야할 부분 해결 내용
협력을 통해 책임을 분산시키기 위해 Expression 객체를 새롭게 정의했다. 이 객체는 연산식에 대한 검증을 당담한다. 기존에 V1 방식대로 @ValueSource 애노테이션을 이용하여 테스트 코드에 사용자 정의 객체 타입인 Expression 타입을 테스트 코드의 인자로 전달하려고 했으나 해당 방식으로는 전달할 수 가 없었다. 그래서 다른 방법을 찾아야 한다. 구글링 한 결과 @MethodSource 애노테이션을 이용해서 사용자 정의 객체 타입을 테스트 코드의 파라미터로 전달할 수 있다는 것을 알았다.
assertThatCode() 메서드를 통해서 올바른 연산식일 때 예외가 발생하지 않는 경우도 테스트 코드를 작성하여 검증했다.
📌 CalculatorTest
public class CalculatorTest {
@ParameterizedTest
@ValueSource(strings = {
"3 + 3 + 3 + 3 + 3 + 3 /",
"+ 3 + 3 + 3 + 3 + 3 + 3",
"2 * / 2",
"4 + 8 -"
})
void 올바른_연산식이_아니면_예외가_발생한다(String expression) {
assertThatThrownBy(() -> Expression.create(expression))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("올바른 연산식이 아닙니다.");
}
@ParameterizedTest
@ValueSource(strings = {
"2 - 3 - 7",
"2 + 3 + 3",
"2 * 5 + 4 / 7",
"+3 + 3 + 3 + 3 + 3 + 3",
"-5 - 4 * 2 + 5",
"7 / 0"
})
void 올바른_연산식이면_예외를_발생시키지_않는다(String expression) {
assertThatCode(() -> Expression.create(expression))
.doesNotThrowAnyException();
}
@ParameterizedTest
@MethodSource("provideCorrectExpression")
void 입력값_순서가_계산순서를_결정한다(Expression expression, int calculationResults) {
Calculator calculator = Calculator.create(expression);
assertThat(calculator.calculate()).isEqualTo(calculationResults);
}
private static Stream<Arguments> provideCorrectExpression() {
return Stream.of(
Arguments.of(Expression.create("2 + 3 * 4 / 2"), 10),
Arguments.of(Expression.create("2 + 6 / 4 - 2"), 0),
Arguments.of(Expression.create("3"), 3),
Arguments.of(Expression.create("0 / 5"), 0),
Arguments.of(Expression.create("3 + 0 / 5"), 0),
Arguments.of(Expression.create("-5 - 4 * 2 + 5"), -13),
Arguments.of(Expression.create("+3 + 3 + 3 + 3 + 3 + 3"), 18)
);
}
@ParameterizedTest
@MethodSource("provideDivideZeroExpression")
void 숫자_0을_나누면_예외가_발생한다(Expression expression) {
Calculator calculator = Calculator.create(expression);
assertThatThrownBy(() -> calculator.calculate())
.isInstanceOf(ArithmeticException.class)
.hasMessage("0을 나눌 수 없습니다.");
}
private static Stream<Arguments> provideDivideZeroExpression() {
return Stream.of(
Arguments.of(Expression.create("4 / 0"))
);
}
}
📌 Expression
public class Expression {
private final String expression;
public static Expression create(String expression) {
if (!validatingExpression(expression))
throw new IllegalArgumentException("올바른 연산식이 아닙니다.");
return new Expression(expression);
}
private static boolean validatingExpression(String expression) {
Pattern pattern = Pattern.compile("^[+\\-]?\\d( ?[*\\-+/] ?\\d)*$");
Matcher matcher = pattern.matcher(expression);
return matcher.find();
}
private Expression(String expression) {
this.expression = expression;
}
public String getExpression() {
return expression;
}
}
📌Calculator
public class Calculator {
private final Expression expression;
public static Calculator create(Expression expression) {
return new Calculator(expression);
}
private Calculator(Expression expression) {
this.expression = expression;
}
public int calculate() {
String arithmeticExpression = expression.getExpression();
String[] parsingExpression = arithmeticExpression.split(" ");
int calculationResult = Integer.parseInt(parsingExpression[0]);
String operator = "";
int operand = 0;
for(int i=1; i< parsingExpression.length; i++) {
if (i%2 != 0) {
operator = parsingExpression[i];
continue;
}
operand = Integer.parseInt(parsingExpression[i]);
if ("+".equals(operator)) calculationResult += operand;
if ("-".equals(operator)) calculationResult -= operand;
if ("*".equals(operator)) calculationResult *= operand;
if ("/".equals(operator)) {
checkDivideZero(operand);
calculationResult /= operand;
}
}
return calculationResult;
}
private void checkDivideZero(int operand) {
if (operand == 0) {
throw new ArithmeticException("0을 나눌 수 없습니다.");
}
}
}
🔍 보완해야 할 점
내가 작성한 테스트 코드가 잘 작성 했는지에 대한 피드백을 받고 싶어서 개발자 오픈채팅방에 있는 경력 개발자분들에게 피드백을 받았다. 피드백 결과 Calculator 클래스를 세부 구현한 코드들이 백준 알고리즘을 풀 떄의 코드 스타일로 코드를 작성한다고 했다. 그리고 Calculator 클래스의 calculator() 메서드에서 연산자를 추출하는 과정에서 "+".equals(operator) 로 리터럴 값으로 비교하기 보단 연산자들을 Enum으로 구현하여 작성하는 것이 더 좋다는 피드백을 받았다. 피드백 받은 내용들을 V3에서 구현하도록 하겠다.
V3-1
🔍 실패 사례
Calculator 클래스를 구현할 때 연산자를 Enum으로 구현하여 사용했다. 그런데 Enum을 사용하기 전보다 더 복잡한 코드가 되어버렸다.
📌 CalculatorTest
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.*;
public class CalculatorTest {
@ParameterizedTest
@ValueSource(strings = {
"+3 + 3 + 3 + 3 +",
"5 /",
"5 *",
"/ 3",
"* 7",
"+ / 5",
"5 / 2 +"
})
void 올바른_연산식이_아니면_예외가_발생한다(String expression) {
assertThatThrownBy(() -> Expression.create(expression))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("올바른 연산식이 아닙니다.");
}
@ParameterizedTest
@ValueSource(strings = {
"3",
"+5",
"-7",
"5 / 3",
"7 + 5 * 2",
"9 / 0",
"5 + 7 + 3 + 3 + 3 + 3 + 3 + 3 + 3"
})
void 올바른_연산식이면_예외가_발생하지않는다(String expression) {
assertThatCode(() -> Expression.create(expression))
.doesNotThrowAnyException();
}
@ParameterizedTest
@MethodSource("provideDivideZeroExpression")
void 숫자_0으로_나누면_예외가_발생한다(Expression expression) {
Calculator calculator = Calculator.create(expression);
assertThatThrownBy(() -> calculator.calculate())
.isInstanceOf(ArithmeticException.class)
.hasMessage("0으로 나눌 수 없습니다.");
}
private static Stream<Arguments> provideDivideZeroExpression() {
return Stream.of(
Arguments.of(Expression.create("4 / 0")),
Arguments.of(Expression.create("3 + 7 / 0")),
Arguments.of(Expression.create("7 - 7 / 0"))
);
}
@ParameterizedTest
@MethodSource("provideExpression")
void 계산순서는_입력순서다(Expression expression, int calculationResult) {
Calculator calculator = Calculator.create(expression);
assertThat(calculator.calculate()).isEqualTo(calculationResult);
}
private static Stream<Arguments> provideExpression() {
return Stream.of(
Arguments.of(Expression.create("2 * 5 + 1"), 11),
Arguments.of(Expression.create("5 / 1 + 2"), 7),
Arguments.of(Expression.create("2 + 7 / 3"), 3),
Arguments.of(Expression.create("0 / 7 + 5"), 5),
Arguments.of(Expression.create("7 - 7 / 4"), 0)
);
}
}
📌 Expression
public class Expression {
private final String expression;
public static Expression create(String expression) {
if(!validateExpression(expression)) {
throw new IllegalArgumentException("올바른 연산식이 아닙니다.");
}
return new Expression(expression);
}
private static boolean validateExpression(String expression) {
Pattern pattern = Pattern.compile("^[+\\-]?\\d( [+\\-*/] \\d)*$");
Matcher matcher = pattern.matcher(expression);
return matcher.find();
}
private Expression(String expression) {
this.expression = expression;
}
public String getExpression() {
return expression;
}
}
📌 Operator.enum
public enum Operator {
PLUS("+"), MINUS("-"), MULTIPLE("*"), DIVIDE("/");
private final String arithmeticOperation;
Operator(String arithmeticOperation) {
this.arithmeticOperation = arithmeticOperation;
}
public String getArithmeticOperation() {
return arithmeticOperation;
}
}
연산자를 enum으로 만들었다.
📌 Calculator (오류 발생 코드)
public class Calculator {
private final Expression expression;
private Operator operator;
private Calculator(Expression expression) {
this.expression = expression;
}
public static Calculator create(Expression expression) {
return new Calculator(expression);
}
public int calculate() {
String expression = this.expression.getExpression();
String[] arr = expression.split(" ");
int firstNumber = Integer.parseInt(arr[0]);
int operand = 0;
int result = firstNumber;
for (int i = 1; i < arr.length; i++) {
if (i % 2 != 0) {
operator = Operator.valueOf(arr[i]);
continue;
}
operand = Integer.parseInt(arr[i]);
result = arithmeticCalculate(operator, operand, result);
}
return result;
}
private int arithmeticCalculate(Operator operator, int operand, int result) {
if (operator == Operator.PLUS) result += operand;
if (operator == Operator.MINUS) result -= operand;
if (operator == Operator.MULTIPLE) result *= operand;
if (operator == Operator.DIVIDE) {
checkDivideZero(operand);
result /= operand;
}
return result;
}
private void checkDivideZero(int operand) {
if (operand == 0) throw new ArithmeticException("0으로 나눌 수 없습니다.");
}
}
Calculator의 calcaulate()에서 이 부분이 문제다. Operator에서 valueOf() 메서드를 사용해서 연산자를 인자로 넘겨주면 연산자에 맞는 Operator enum이 생길 줄 알았다. 예를 들어서, 'Operator.valueOf("+")' 가 되면 Operator.PLUS가 생성될 줄 알았다. 하지만 IllegalArgumentException 예외가 발생했다.
왜냐하면 valueOf 메서드의 인자에 Operator에 정의된 열거형 상수를 넣었어야 한다. 즉, 'operator = Operatro.valueOf(PLUS)' 이런식으로 작성해야 한다. 그래서 오류가 안나도록 변경해야한다.
📌 Calculator (오류가 안나도록 변경)
public class Calculator {
private final Expression expression;
private Operator operator;
private Calculator(Expression expression) {
this.expression = expression;
}
public static Calculator create(Expression expression) {
return new Calculator(expression);
}
public int calculate() {
String expression = this.expression.getExpression();
String[] parsingExpression = expression.split(" ");
int firstNumber = Integer.parseInt(parsingExpression[0]);
int operand = 0;
int result = firstNumber;
for (int i = 1; i < parsingExpression.length; i++) {
if (i % 2 != 0) {
if ("+".equals(parsingExpression[i])) operator = Operator.PLUS;
if ("-".equals(parsingExpression[i])) operator = Operator.MINUS;
if ("*".equals(parsingExpression[i])) operator = Operator.MULTIPLE;
if ("/".equals(parsingExpression[i])) operator = Operator.DIVIDE;
continue;
}
operand = Integer.parseInt(parsingExpression[i]);
result = arithmeticCalculate(operator, operand, result);
}
return result;
}
private int arithmeticCalculate(Operator operator, int operand, int result) {
if (operator == Operator.PLUS) result += operand;
if (operator == Operator.MINUS) result -= operand;
if (operator == Operator.MULTIPLE) result *= operand;
if (operator == Operator.DIVIDE) {
checkDivideZero(operand);
result /= operand;
}
return result;
}
private void checkDivideZero(int operand) {
if (operand == 0) throw new ArithmeticException("0으로 나눌 수 없습니다.");
}
}
그래서 연산자가 무엇인지 비교를 통해 알아내서 operator에 값을 직접 넣어줘야한다. 하지만 이 방식에는 단점이 존재한다. 코드의 복잡성이 올라간다는 것이다. V2 방식보다 더 복잡한 코드가 되었다. Enum을 사용하는 의미를 잃어버린 셈이다.
'for문 → if문 → if문'으로 코드 depth가 3이 되어 가독성이 매우 떨어졌다.
심지어 arithmeticCalculate() 메서드에서도 비슷한 코드가 보인다.
V2 방식에서는 enum을 사용하지 않고 즉시 비교를 통해 'a → b' 였다.
V3-1 방식은 'a → c→ b' 다. enum 사용으로 인해 c가 추가된 것이다. 복잡성이 올라갔다. 이것은 내가 Enum에 대한 숙련도가 낮아서 발생한 문제같다. 단순하게 표현할 방법을 알아봐야겠다.
🔍 보완해야 할 점
연산자를 Enum으로 정의하고, Calculator클래스를 구현할 때 단순하게 표현하는 방법 찾아보기.
V3-2
🔍 보완한 점
Operator enum에 연산식도 추가하여 연산도 담당하도록 하여 Calculator 클래스에 if문을 전부 제거했다.
그리고 Calculator에 map을 불변 객체로 사용하여 리소스를 새로 만들지 않고, 연산자를 캐싱하였다.
V3-1 문제를 해결하기 위해서 먼저 예전에 봤던 자바의 정석의 enum 부분을 다시 공부했다. 하지만 책에서 나온 깊이와 내가 지금 해결해야하는 깊이의 거리가 있다. 그래서 구글링으로도 많이 해보고, V2에서 피드백을 줬었던 2년차 개발자분인 '시로호'님에게도 enum을 사용했을 때 어떻게 해야 더 잘 사용할 수 있을까에 대한 질문도 했다. 시로호님이 잠깐 enum의 구현 예시를 간단하게 보여줬다. 그것을 빠르게 터득하고 내가 스스로 배운 것을 적용하여 새롭게 V3-2를 작성했다.
📌 CalculatorTest
public class CalculatorTest {
@ParameterizedTest
@ValueSource(strings = {
"+3 + 3 + 3 + 3 +",
"5 /",
"5 *",
"/ 3",
"* 7",
"+ / 5",
"5 / 2 +"
})
void 올바른_연산식이_아니면_예외가_발생한다(String expression) {
assertThatThrownBy(() -> Expression.create(expression))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("올바른 연산식이 아닙니다.");
}
@ParameterizedTest
@ValueSource(strings = {
"3",
"+5",
"-7",
"5 / 3",
"7 + 5 * 2",
"9 / 0",
"5 + 7 + 3 + 3 + 3 + 3 + 3 + 3 + 3"
})
void 올바른_연산식이면_예외가_발생하지않는다(String expression) {
assertThatCode(() -> Expression.create(expression))
.doesNotThrowAnyException();
}
@ParameterizedTest
@MethodSource("provideDivideZeroExpression")
void 숫자_0으로_나누면_예외가_발생한다(Expression expression) {
Calculator calculator = Calculator.create(expression);
assertThatThrownBy(() -> calculator.calculate())
.isInstanceOf(ArithmeticException.class)
.hasMessage("0으로 나눌 수 없습니다.");
}
private static Stream<Arguments> provideDivideZeroExpression() {
return Stream.of(
Arguments.of(Expression.create("4 / 0")),
Arguments.of(Expression.create("3 + 7 / 0")),
Arguments.of(Expression.create("7 - 7 / 0"))
);
}
@ParameterizedTest
@MethodSource("provideExpression")
void 계산순서는_입력순서다(Expression expression, int calculationResult) {
Calculator calculator = Calculator.create(expression);
assertThat(calculator.calculate()).isEqualTo(calculationResult);
}
private static Stream<Arguments> provideExpression() {
return Stream.of(
Arguments.of(Expression.create("2 * 5 + 1"), 11),
Arguments.of(Expression.create("5 / 1 + 2"), 7),
Arguments.of(Expression.create("2 + 7 / 3"), 3),
Arguments.of(Expression.create("0 / 7 + 5"), 5),
Arguments.of(Expression.create("7 - 7 / 4"), 0)
);
}
}
📌 Expression
public class Expression {
private final String expression;
public static Expression create(String expression) {
if(!validateExpression(expression)) {
throw new IllegalArgumentException("올바른 연산식이 아닙니다.");
}
return new Expression(expression);
}
private static boolean validateExpression(String expression) {
Pattern pattern = Pattern.compile("^[+\\-]?\\d( [+\\-*/] \\d)*$");
Matcher matcher = pattern.matcher(expression);
return matcher.find();
}
private Expression(String expression) {
this.expression = expression;
}
public String getExpression() {
return expression;
}
}
📌 Operator.enum
public enum Operator {
PLUS("+", Integer::sum),
MINUS("-", (num1, num2) -> num1 - num2),
MULTIPLE("*", (num1, num2) -> num1 * num2),
DIVIDE("/", (num1, num2) -> {
checkDivideZero(num2);
return num1 / num2;
});
private final String arithmeticOperator;
private final BinaryOperator<Integer> expression;
Operator(String arithmeticOperator, BinaryOperator<Integer> expression) {
this.arithmeticOperator = arithmeticOperator;
this.expression = expression;
}
public Integer operatorCalculate(Integer num1, Integer num2) {
return expression.apply(num1, num2);
}
private static void checkDivideZero(int operand) {
if (operand == 0) throw new ArithmeticException("0으로 나눌 수 없습니다.");
}
}
📌 Calculator
public class Calculator {
private final Expression expression;
private final Map<String, Operator> operatorMap = Collections.unmodifiableMap(new LinkedHashMap<>() {
{
put("+", Operator.PLUS);
put("-", Operator.MINUS);
put("/", Operator.DIVIDE);
put("*", Operator.MULTIPLE);
}
});
private Calculator(Expression expression) {
this.expression = expression;
}
public static Calculator create(Expression expression) {
return new Calculator(expression);
}
public int calculate() {
String expression = this.expression.getExpression();
String[] parsingExpression = expression.split(" ");
int firstNumber = Integer.parseInt(parsingExpression[0]);
int operand = 0;
int result = firstNumber;
String stringOperator = "";
for (int i = 1; i < parsingExpression.length; i++) {
if (i % 2 != 0) {
stringOperator = parsingExpression[i];
continue;
}
operand = Integer.parseInt(parsingExpression[i]);
result = arithmeticCalculate(result, stringOperator, operand);
}
return result;
}
private int arithmeticCalculate(int result, String operator, int operand) {
return Optional.ofNullable(operatorMap.get(operator))
.orElseThrow(() -> new IllegalArgumentException("잘못된 연산자입니다."))
.operatorCalculate(result, operand);
}
}
🔍 보완해야할 점 (피드백 받은 점)
또, 시로호님에게 피드백을 받았다. 피드백 결과!
- 연산결과가 정수형이 아닌 실수형이이어야 한다.
- 깃허브에 올릴 때 ide 의존성을 줄여야 한다. 그러므로 깃 이그노어를 잘 사용하자.
- 불필요한 공백 줄 줄이기
- Operator enum에서 사칙연산인 +, -, /, *은 한 글자이므로 String 타입보단 더 싼 char 타입으로 할 것.
- 생성자 팩토리 메서드 명칭을 'create' 보단 'from'이 더 읽기 쉽다.
- Pattern은 매우 비싸다. Pattern을 인스턴스로 하면 생성하고 지우고 생성하고 지워야하므로 GC 비용이 매우 비싸게 든다. 그러므로 인스턴스 변수로 만들지 말고 static으로 만들어서 한 번만 생성하도록 하자.
- Calculator의 for문은 스스로 생각하고 리팩토링 해볼 것.
V-4
https://github.com/kureung/code-kata
'[TDD]' 카테고리의 다른 글
문자열 계산기 (Next Step) (2) | 2022.09.24 |
---|