본문 바로가기
교육 및 책/TDD, 클린 코드 with Java

TDD, 클린코드 - 문자열 사칙연산 계산기

by oneny 2023. 4. 19.

2번째 미션 - 로또, TDD

로또 미션 이전에 자동차 경주 5단계까지 마무리했는데 스프링이나 DB 공부나 과제 미션 클리어하는데 집중한다고 제대로 블로그를 작성하지 못했다.. 지금부터라도 제대로 작성해보자! 이번 첫 단계인 문자열 사칙연산 계산기는 좋은 피드백도 많이 받아서 기분이 좋다ㅎㅎ 

 

문자열 사칙연산 계산기

 

Operand 클래스

public class Operand {
  private static final Pattern NUMBER_PATTERN = Pattern.compile("^[0-9]*$");
  private static final String ILLEGAL_NUMBER_MESSAGE = "양수를 입력해주세요.";
  private final String operand; // 양수만 입력가능하다면 변환후 int 로 관리하는것도 좋을것 같습니다.

  public Operand(String operand) {
    this.operand = validatedOperand(operand);
  }

  public int number() {
    return Integer.parseInt(operand);
  }

  private String validatedOperand(String operand) {
    if (!isOperand(operand)) {
      throw new IllegalArgumentException(ILLEGAL_NUMBER_MESSAGE);
    }

    return operand;
  }

  private boolean isOperand(String operand) {
    Matcher operandMatcher = NUMBER_PATTERN.matcher(operand);

    return operandMatcher.find();
  }
}

Operand의 인스턴스 변수 number를 String으로 관리한다는게 조금 이상하지 않나 생각했는데 역시 int로 관리하는게 좋다는 피드백을 받았다.

Operator 클래스

// 연산자에대한 enum 선언 및 캐싱처리 👍
public enum Operator {

  ADDITION("+", (operand1, operand2) -> operand1 + operand2),
  SUBTRACTION("-", (operand1, operand2) -> operand1 - operand2),
  MULTIPLICATION("*", (operand1, operand2) -> operand1 * operand2),
  DIVISION("/", (operand1, operand2) -> {
    validateDivision(operand2);
    return operand1 / operand2;
  });

  private static final String ILLEGAL_OPERATOR_MESSAGE = "연산자 기호를 입력해주세요.";
  private static final String ILLEGAL_DIVISION_MESSAGE = "0으로 나눌 수 없습니다. 다른 숫자를 입력해주세요.";
  private static final Map<String, Operator> BY_OPERATOR = new HashMap<>();

  static {
    for (Operator operator : values()) {
      BY_OPERATOR.put(operator.operator, operator);
    }
  }

  private final String operator;
  private final BinaryOperator<Integer> calculation;

  Operator(final String operator, final BinaryOperator<Integer> calculation) {
    this.operator = operator;
    this.calculation = calculation;
  }

  public int applyCalculation(int operand1, int operand2) {
    return calculation.apply(operand1, operand2);
  }

  public static Operator valueOfOperator(String operator) {
    validateContainsKey(operator);

    return BY_OPERATOR.get(operator);
  }

  private static void validateDivision(int operand2) {
    if (operand2 == 0) {
      throw new IllegalArgumentException(ILLEGAL_DIVISION_MESSAGE);
    }
  }

  private static void validateContainsKey(String operator) {
    if (!BY_OPERATOR.containsKey(operator)) {
      throw new IllegalArgumentException(ILLEGAL_OPERATOR_MESSAGE);
    }
  }
}

enum 구현과 어떻게 하면 캡슐화할 수 있을지 정말 머리 꽁꽁 싸맸던 부분인데

연산자에 대한 enum 선언과 캐싱 처리에 대해 따봉 받았는데 이것 자체만으로 너무 보상받는 기분이다ㅎㅎ

 

Operators 클래스

public class Operators {

  private final Queue<Operator> operators; // 일급 컬렉션 사용 👍

  public Operators(Queue<Operator> operators) {
    this.operators = operators;
  }

  public static Operators of(List<String> operators) {
    Queue<Operator> operatorList = new LinkedList<>();

    for (String operator : operators) {
      operatorList.add(Operator.valueOfOperator(operator));
    }

    return new Operators(operatorList);
  }

  public Operator next() { // getter를 작성하기 보다는 스트림에서 하나씩 꺼내 쓰도록 해당 메서드를 작성했다.
    return operators.poll();
  }
}

 

Calculator 클래스

public class Calculator {

  private static final int ZERO_CONDITION = 0;
  private static final int ODD_CONDITION = 1;
  private static final int EVEN_CONDITION = 2;
  private static final String ILLEGAL_OPERATOR_MESSAGE = "올바른 사칙 연산을 입력해주세요.";
  private static final String INCALCULABLE_MESSAGE = "계산이 가능한 수식을 입력해주세요.";
  private static final Pattern OPERATORS_SYMBOL = Pattern.compile("^(\\+|-|\\*|\\/)$");
  private final Operators operators;
  private final Operands operands;

  public Calculator(List<String> operators, List<String> operands) {
    validateOperation(operators, operands);

    this.operators = Operators.of(operators);
    this.operands = Operands.of(operands);
  }
 
  // ...
  
  // peek 메서드는 javadoc을 확인해보면 "This method exists mainly to support debugging"이라 설명한다.
  // 이는 stream의 중간 연산자로서 로그 출력 등의 디버깅을 위함이기 때문에 목적에 맞춰서 사용하는 것이 좋다는 피드백을 받았다.
  private static List<String> validateOperators(List<String> operators) {
    return operators.stream()
            .peek(Calculator::validateOperator)
            .collect(Collectors.toList());
  }
}

 

CalculatorTest

public class CalculatorTest {

  @Test
  @DisplayName("홀수 인덱스에는 operand, 짝수 인덱스에 operator가 주어진 경우 계산하여 반환")
  public void calculate_정상_계산() {
    Calculator calculator = Calculator.createCalculator(List.of("1", "+", "6", "-", "2"));

    assertThat(calculator.calculate()).isEqualTo(5);
  }

  @Test
  @DisplayName("홀수 인덱스에 숫자가 아닌 입력값이 들어간 경우 IllegalArgumentException throw")
  public void create_Calculator_숫자_아닌_값() {
    assertThatThrownBy(() -> Calculator.createCalculator(List.of("1", "-", "d")))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("양수를 입력해주세요.");
  }

  @Test
  @DisplayName("짝수 인덱스에 사칙연산 기호가 아닌 값이 들어간 경우 IllegalArgumentException throw")
  public void create_Calculator_사칙연산_아닌_값() {
    assertThatThrownBy(() -> Calculator.createCalculator(List.of("1", "^", "3")))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("올바른 사칙 연산을 입력해주세요.");
  }

  @Test
  @DisplayName("계산이 불가능한 수식이 입력된 경우 IllegalArgumentException throw")
  public void create_Calculator_계산_불가() {
    assertThatThrownBy(() -> Calculator.createCalculator(List.of("1", "+")))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("계산이 가능한 수식을 입력해주세요.");
  }

  @Test
  @DisplayName("0으로 나누려고 하는 경우 IllegalArgumentException throw")
  public void calculate_0_나누기() {
    Calculator calculator = Calculator.createCalculator(List.of("1", "/", "0", "-", "2"));
    assertThatThrownBy(() -> calculator.calculate())
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("0으로 나눌 수 없습니다. 다른 숫자를 입력해주세요.");
  }
}

테스트코드 작성 굉장히 꼼꼼하게 잘했다는 피드백을 받고 그대로 올려봤다 😊

 

문자열 사칙연산 계산기 리팩토링

 

Operand 클래스 리팩토링

public class Operand {
  private static final Pattern NUMBER_PATTERN = Pattern.compile("^[0-9]*$");
  private static final String ILLEGAL_NUMBER_MESSAGE = "양수를 입력해주세요.";
  private final int operand;

  public Operand(String operand) {
    this.operand = validatedOperand(operand);
  }

  public int operand() {
    return operand;
  }

  private int validatedOperand(String operand) {
    if (!isOperand(operand)) {
      throw new IllegalArgumentException(ILLEGAL_NUMBER_MESSAGE);
    }

    return Integer.parseInt(operand);
  }

  private boolean isOperand(String operand) {
    Matcher operandMatcher = NUMBER_PATTERN.matcher(operand);

    return operandMatcher.find();
  }
}

객체를 사용할 때 검증된 형태로 사용하는 것이 좋고, 정규식을 통해 0을 포함한 양수라는 것을 검증했기 때문에 사용하는 부분에서 별도 검증이 필요없다.

 

Calculator 클래스 리팩토링

  private static List<String> validateOperators(List<String> operators) {
    return operators.stream()
            .map(Calculator::validateOperator)
            .collect(Collectors.toList());
  }

peek 메서드는 stream의 중간 연산자로서 로그 출력 등의 디버깅을 위해 사용하기 때문에 다음과 같이 map 메서드로 Operator 객체들이 "+", "-", "*", "-"인지 확인한 후 반환하도록 리팩토링했다.