계산기로 TDD 경험해보기

Lim MyeongSeop·2022년 6월 7일
0

elon-techcamp

목록 보기
1/2

모든 코드는 여기 있습니다.

회사에서 파일럿으로 운영하는 스터디 과제를 진행하면서 객체지향적인 코드에 대해 생각해보는 기록입니다.

계산기 기능 정의하기


요구사항

  • 사칙연산
  • 연산이 되지 않는 경우는 에러 처리
  • 소수점 3자리 버림
  • 콘솔 입출력
  • 에러 처리
    • [ ERROR ] {message}

추가적으로 객체지향 생활체조, TDD를 적용해서 프로젝트를 진행하게 되었습니다.

테스트 만들기


TDD 사이클에 따라 처음에 테스트를 요구사항에 따라 간단하게 작성하였습니다.

class AppTest {

    @Test
    @DisplayName("[성공] 더하기")
    void given_value_add_when_calculation_then_ok() {
        String input = "2+1";
        FormulaParser parser = new FormulaParser(input);
        Formula formula = parser.toFormula();
        assertEquals(formula.calculate(), 3.0);
    }

    @Test
    @DisplayName("[성공] 빼기")
    void given_value_minus_when_calculation_then_ok() {
        String input = "2-1";
        FormulaParser parser = new FormulaParser(input);
        Formula formula = parser.toFormula();
        assertEquals(formula.calculate(), 1.0);
    }

    @Test
    @DisplayName("[성공] 곱하기")
    void given_value_multiply_when_calculation_then_ok() {
        String input = "8*7";
        FormulaParser parser = new FormulaParser(input);
        Formula formula = parser.toFormula();
        assertEquals(formula.calculate(), 56.0);
    }

    @Test
    @DisplayName("[성공] 나누기")
    void given_value_divide_when_calculation_then_ok() {
        String input = "60/5";
        FormulaParser parser = new FormulaParser(input);
        Formula formula = parser.toFormula();
        assertEquals(formula.calculate(), 12.0);
    }

    @Test
    @DisplayName("[성공] 나누기 소수점 3자리 버림")
    void given_value_divide_when_calculation_then_throwing_away_three_decimal_places_ok() {
        String input = "5/3";
        FormulaParser parser = new FormulaParser(input);
        Formula formula = parser.toFormula();
        assertEquals(formula.calculate(), 1.666);
    }

    @Test
    @DisplayName("[예외] 숫자가 아닌 문자를 이용한 계산")
    void given_text_when_calculation_then_exception() {
        String input = "abc/5";
        assertThrows(IllegalArgumentException.class, () -> {
            FormulaParser parser = new FormulaParser(input);
            Formula formula = parser.toFormula();
            formula.calculate();
        }, "[ ERROR ] 계산식을 다시 입력해주세요.");
    }
}

테스트에서 반드시 수행되어야 하는 요구사항인 사칙연산, 소수점 3자리 버림, 예외 처리를 작성하였습니다.

단위 선정하기


다음으로 기능을 구현하기 위해 계산기 기능에 대해 생각해보게 되었습니다.

계산기의 흐름은 다음과 같이 정의하였습니다.

  1. 사용자에게 계산할 사칙연산 계산식을 입력받는다.
  2. 계산을 수행한다.
  3. 사용자에게 계산된 결과를 알려준다.

저는 여기서 조건 1, 2, 3을 각각의 클래스로 나누어 역할을 분리하야 되겠다고 생각이 들어, 각 기능을 수행하는 클래스를 작성하였습니다.

콘솔 입력


먼저 문자열을 콘솔에 입력하는 기능을 구현하도록 하겠습니다.

여기서도 TDD의 방법론에 따라 테스트를 작성하겠습니다.

class InputViewTest {

    @Test
    @DisplayName("[성공] 문자열 입력")
    void given_text_when_input_console_then_ok() {
        String userInput = "그냥아무문자열이나입력";
        setIn(new ByteArrayInputStream(userInput.getBytes()));

        String inputExpression = "그냥아무문자열이나입력";
        String expression = InputView.expression();
        assertEquals(inputExpression, expression);
    }
}

InputView.expression() 메서드를 통해 콘솔에 입력할 문자열이 일치하는지 확인합니다.

public class InputView {

    private static Scanner scanner = new Scanner(System.in);

    public static String expression() {
        System.out.println("계산식을 입력하세요. ");
        return scanner.next();
    }
}

콘솔 입력 기능을 작성하고 테스트 코드를 수행합니다.

계산 기능


사칙연산 기능은 다양한 방법으로 구현할 수 있는데 제가 생각한 계산 기능은 다음과 같습니다.

  1. 입력받은 계산식의 숫자를 단일 문자로 치환한다.
  2. 치환된 계산식을 후위 표현식으로 변경한다.
  3. 후위 표현식 계산을 수행한다.

디자인 패턴 중 인터프리터 패턴을 사용하여 계산할 수 있도록 위와 같이 계산 순서를 생각하였습니다.

그리고 1, 2번의 기능은 문득 “계산식이 핵심인데 두 기능은 계산식을 분리해내는 작업만 수행하게 된다고 판단해도 되지 않을까?” 생각되어 parser를 통해 처리하도록 하였습니다.

계산식 분리


class FormulaParserTest {

    @Test
    @DisplayName("[성공] 수식계산 parser 생성 및 수식계산 객체 생성")
    void given_expression_when_create_parser_then_ok() {
        String expression = "3*4/5";
        FormulaParser formulaParser = new FormulaParser(expression);
        Formula formula = formulaParser.toFormula();
        assertEquals(formula.getClass(), Formula.class);
    }

    @Test
    @DisplayName("[예외] 수식계산 객체 생성 예외 발생")
    void given_wrong_expression_when_create_parser_then_exception() {
        assertAll(
                () -> {
                    assertThrows(
                            IllegalArgumentException.class,
                            () -> {
                                String expression = "3*4/";
                                FormulaParser formulaParser = new FormulaParser(expression);
                                formulaParser.toFormula();
                            },
                            "[ ERROR ] 계산식의 시작과 끝은 숫자형 문자를 입력해주세요.");
                },
                () -> {
                    assertThrows(
                            IllegalArgumentException.class,
                            () -> {
                                String expression = "ㅁ/ㅇer2";
                                FormulaParser formulaParser = new FormulaParser(expression);
                                formulaParser.toFormula();
                            },
                            "[ ERROR ] 계산식을 다시 입력해주세요.");
                }
        );
    }

}

정상적인 계산식과 잘못된 계산식을 테스트합니다.

  • 계산식을 변형하는 클래스인 FormulaParser를 생성합니다.
    /**
     * 계산식을 변형하는 클래스
     *  - 숫자는 단일 문자열로 치환
     *  - 중위 표현식 -> 후위 표현식
     */
    public class FormulaParser {
        private final List<Character> brackets = Arrays.asList('(', ')');
        private final String numberReplaceRegex = "[^0-9.]";                                                // 숫자 치환을 위해 검사 정규식
        private final String syntaxRegex = "[0-9|\\.|\\+|\\-|\\*|\\/|\\(|\\)]*$";                           // 계산식 유효성 검사 정규식
        private final String numberOfEndToEndRegex = "^\\d{1}[0-9|\\.|\\+|\\-|\\*|\\/|\\(|\\)]*\\d{1}$";    // 계산식의 앞뒤 문자 타입이 숫자 정규식
    
        private final String infixExpression;           // 입력 받은 중위 표현 계산식
        private String replacedExpression;              // 숫자가 치환된 계산식
        private String postfixExpression;               // 후위 표현 계산식
        private Numbers numbers;                        // 치환된 숫자를 가지는 context
    
        public FormulaParser(String infixExpression) {
            this.infixExpression = infixExpression;
            this.replacedExpression = infixExpression;
            this.numbers = new Numbers();
        }
    
        /**
         * 계산식 수행 클래스로 반환
         * @return
         */
        public Formula toFormula() {
            validate();
            replaceNumbers();
            parsePostfix();
            return new Formula(this.postfixExpression, this.numbers);
        }
    
        /**
         * 유효성 검사
         */
        private void validate() {
            if (!this.infixExpression.matches(syntaxRegex)) throw new IllegalArgumentException("[ ERROR ] 계산식을 다시 입력해주세요.");
            if (!this.infixExpression.matches(numberOfEndToEndRegex))
                throw new IllegalArgumentException("[ ERROR ] 계산식의 시작 또는 끝은 숫자를 입력해주세요.");
        }
    
        /**
         * 계산식에서 숫자를 단일 문자로 치환
         */
        private void replaceNumbers() {
            for (String stringNumber : this.infixExpression.split(this.numberReplaceRegex)) {
                if (stringNumber.length() == 0) {
                    continue;
                }
    
                String key = this.numbers.set(stringNumber);
                this.replacedExpression = this.replacedExpression.replaceFirst(stringNumber, key);
            }
        }
    
        /**
         * 후위 표현식으로 변환
         */
        private void parsePostfix() {
            StringBuilder stringBuilder = new StringBuilder();
            Stack<Character> stack = new Stack<>();
    
            for (char character : this.replacedExpression.toCharArray()) {
                if (isOperators(character)) {
                    setOperators(stringBuilder, stack, character);
                    continue;
                }
                stringBuilder.append(character);
            }
    
            while (!stack.isEmpty()) {
                stringBuilder.append(stack.pop());
            }
    
            this.postfixExpression = stringBuilder.toString();
        }
    
        /**
         * 후위 표현식 변환 중 계산식 우선순위에 따라 수식을 stringBuilder에 입력한다.
         * @param stringBuilder
         * @param stack
         * @param character
         */
        private void setOperators(StringBuilder stringBuilder, Stack<Character> stack, char character) {
            if (')' == character) {
                setOperatorsWithBrackets(stringBuilder, stack);
                return;
            }
            prioryFor(stringBuilder, stack, character);
        }
    
        /**
         * 수식 우선순위에 따라 stringBuilder에 입력한다.
         * @param stringBuilder
         * @param stack
         * @param character
         */
        private void prioryFor(StringBuilder stringBuilder, Stack<Character> stack, char character) {
            while (!stack.isEmpty() && Operator.symbolFor(character).prioritize(stack.peek())) {
                stringBuilder.append(stack.pop());
            }
            stack.push(character);
        }
    
        /**
         * 사용자가 우선순위를 지정하지 않은 계산식 부분을 stringBuilder에 입력한다.
         * @param stringBuilder
         * @param stack
         */
        private void setOperatorsWithBrackets(StringBuilder stringBuilder, Stack<Character> stack) {
            while (!stack.isEmpty() && stack.peek() != '(') {
                stringBuilder.append(stack.pop());
            }
            // 스택의 시작 괄호('(')를 제거한다.
            stack.pop();
        }
    
        /**
         * 단일 문자가 수식 또는 소괄호인지 확인한다.
         * @param character
         * @return
         */
        private boolean isOperators(char character) {
            return Operator.symbols().contains(character) || this.brackets.contains(character);
        }
    }

테스트를 수행해 정상적으로 동작하는지 확인합니다.

계산 수행


class FormulaTest {

    @Test
    @DisplayName("[성공] 수식 계산")
    void given_expression_when_calculate_then_ok() {
        Numbers numbers = new Numbers();
        String first = numbers.set("4");
        String second = numbers.set("5");
        String postfix = first + second + "*";

        Formula formula = new Formula(postfix, numbers);
        double calculate = formula.calculate();

        assertEquals(calculate, 20.0);
    }

    @Test
    @DisplayName("[예외] 잘못된 수식 계산")
    void given_wrong_number_when_calculate_then_exception() {
        assertThrows(
                IllegalArgumentException.class,
                () -> {
                    Numbers numbers = new Numbers();
                    String first = numbers.set("ㅁ");
                    String second = numbers.set("5");
                    String postfix = first + second + "*";
                    Formula formula = new Formula(postfix, numbers);
                    formula.calculate();
                },
                "[ ERROR ] 계산식을 확인해주세요."
        );
    }
}

수식 계산에 대한 테스트 코드를 작성합니다.

후위 표현식 계산을 할 인터프리터 패턴 로직을 구현합니다.

/**
 * 후위 표현식 계산 인터페이스
 */
public interface PostfixProcess {

    /**
     * 수식 계산 수행
     * @param context
     * @return
     */
    double interpret(Numbers context);

    static PostfixProcess plus(PostfixProcess left, PostfixProcess right) {
        return context -> left.interpret(context) + right.interpret(context);
    }

    static PostfixProcess minus(PostfixProcess left, PostfixProcess right) {
        return context -> left.interpret(context) - right.interpret(context);
    }

    static PostfixProcess multiply(PostfixProcess left, PostfixProcess right) {
        return context -> left.interpret(context) * right.interpret(context);
    }

    static PostfixProcess devide(PostfixProcess left, PostfixProcess right) {
        return context -> left.interpret(context) / right.interpret(context);
    }
}
/**
 * 값을 처리하는 후위 표현식 계산 인터페이스 구현체
 */
public class VariableProcess implements PostfixProcess {

    private String key;

    public VariableProcess(char key) {
        this.key = String.valueOf(key);
    }

    @Override
    public double interpret(Numbers context) {
        return context.get(key).doubleValue();
    }
}

후위 표현 계산식이 계산되는 클래스를 구현합니다.

/**
 * 계산 수행
 *  - 후위 표현식 계산
 */
public class Formula {
    private final String postfixExpression;
    private final Numbers numbers;

    private Stack<PostfixProcess> stack;

    public Formula(String postfixExpression, Numbers numbers) {
        this.postfixExpression = postfixExpression;
        this.numbers = numbers;
        this.stack = new Stack<>();
    }

    /**
     * 계산을 수행한다.
     * @return
     */
    public double calculate() {
        try {
            return formatting(this.restore().interpret(this.numbers));
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("[ ERROR ] 계산식을 확인해주세요.");
        }
    }

    /**
     * 소수점 3자리 버림
     * @param calculation
     * @return
     */
    private double formatting(double calculation) {
        return Math.floor(calculation * 1000) / 1000.0;
    }

    /**
     * 후위 표현식을 계산 처리 클래스, 값 처리 클래스로 변환해준다.
     * @return
     */
    private PostfixProcess restore() {
        for (char character : this.postfixExpression.toCharArray()) {
            Operator operator = Operator.symbolFor(character);
            if (operator != null) {
                this.stack.push(operator.process(this.stack));
                continue;
            }
            this.stack.push(new VariableProcess(character));
        }
        return this.stack.pop();
    }
}

계산을 수행할 때, 값을 변환해주는 클래스인 VariableProcess는 숫자 타입이 아닌 경우 NumberFormatException이 발생할 수 있어 예외 처리를 해줍니다.

후위 표현식을 인터프리터 패턴의 클래스로 변환할 때 스택을 사용하는데, 만약 계산식이 2*3+5라고 가정했을 때 후위 표현식은 23*5+가 됩니다.

이 계산식은 다음과 같은 순서로 클래스 변환되어 스택에 담기게 됩니다.

결국 계산식을 변환하면 스택에는 하나의 인터프리터 패턴의 클래스 객체가 담기게 됩니다. 이 클래스 객체는 calcultate() 메서드를 통해 계산되어 결과를 반환하게 됩니다.

테스트를 수행해 확인합니다.

결과 출력


/**
 * 결과 출력
 */
public class ResultView {

    /**
     * 계산 결과 출력
     * @param expression
     * @param calculation
     */
    public static void print(String expression, double calculation) {
        System.out.println(expression + " = " + calculation);
    }
}

마무리


처음 작성한 AppTest 테스트를 수행하여 정상적으로 동작하는지 확인합니다.

위 프로젝트의 코드 작성방식이 진짜 TDD인지 객체지향적으로 코드를 작성했는지 정확하지 않습니다.

아직 코드 리뷰를 받지 않았고 제가 이해한 수준에서 작성한 것입니다. 혹시나 글을 읽으시고 잘못된 점이 있거나 의견이 있으시면 댓글로 작성해주세요.

다음 글에는 “스터디 코드 리뷰 후기”를 작성하여 올리도록 하겠습니다.

느낀점

개발자로 일한지 3년차가 되었을 때 저는 아래와 같은 생각으로 코드 구현을 하고 있었습니다.

  • 일정이라는 핑계 때문에 테스트 코드를 작성하지 않고 비즈니스 로직을 개발한다.
  • 객체지향적인 사고를 통해 비즈니스 문제 해결을 하지 않고 if, for를 사용하여 모든 로직을 구현한다.

전형적인 SI 방식의 개발 방식이라고 생각됩니다.

작년 11번가 프로젝트를 진행했을 때 다양한 기술을 접하고 처음으로 코드 리뷰를 받으면서 새로운 세계에 눈을 뜬거 같았습니다. 그 방향으로 공부를 지속했지만 아직 미흡하다고 생각되어 이번 스터디를 통해 다양한 고민을 할 수 있었으면 좋겠습니다.

profile
조금 더 좋은 코드를 위해 고민해봅니다.

0개의 댓글