코드 카타 - 문자열 계산기

코드 카타 - 문자열 계산기

TDD와 헥사고날 아키텍처 훈련


코드카타(Code Kata)


(주의… 의역이 심합니다 😅)

어떻게 하면 훌륭한 음악가가 될 수 있을까요?

우선, 일단 이론을 알면 악기의 역학을 이해하는데 분명 도움이 됩니다.

하지만 궁극적으로 훌륭한 실력은 이렇게 이론만 알아서 되는것이 아닌, 지속적이고 반복적인 훈련에서 나온다는 것을 깨달아야 합니다.

반복적인 훈련에 이론을 계속 적용하며 결과를 얻고, 결과에 대한 피드백도 얻는다면 결과는 계속해서 좋아질 것입니다.


위대한 스포츠 선수가 되려면 어떻게 해야 할까요?

체력과 같은 선천적인 재능들은 분명히 큰 도움이 됩니다.

그러나 이러한 재능들과 별개로 모든 위대한 스포츠 선수들은 보이지 않는 곳에서 매일 긴 시간 동안의 피나는 훈련을 했음을 알아야 합니다.


일반적으로 소프트웨어 산업현장에서 우리는 이론만 훈련받은 개발자들을 데려와 프로젝트에 투입시키고, 프로젝트가 잘 돌아가길 원합니다.

이것은 건강한 아이들을 몇 모으면 워싱턴 커맨더스를 이길 수 있다고 말하는 것과 다르지 않습니다.


즉, 우리는 직장에서 훈련을 하기 때문에 실수를 하는 것입니다.

따라서, 우리는 일과 훈련을 분리하는 방법을 찾고 지속적으로 훈련해 성장해야만 합니다.


코드 카타, 어떻게 해야 하는가?


  • 누구에게도 방해받지 않는 시간이 필요하며, 가볍게 시도해보고 싶은 단순한 주제가 필요합니다.

  • 필요하거나 만족스러울 만큼 여러 번 쉽게 시도하고, 무엇보다 실수를 하는 것이 두렵지 않고 편안해야만 합니다.

  • 개선을 위해 노력할 수 있도록 매번 피드백을 찾아야 합니다.

  • 외부의 압력이 없어야 합니다. 심신이 편안해야 합니다. 그리하여 오로지 훈련에만 몰입 할 수 있어야 합니다.


이것이 프로젝트 환경에서 성장하기 어렵고, 실수가 잦아지는 이유입니다.


카타는 한 형태를 여러 번 반복하면서 조금씩 개선되는 가라데의 훈련입니다.

코드 카타의 의도는 가라데의 카타와 비슷합니다.

둘 모두 단순히 짧은 반복 훈련입니다.


카타를 처음 하면 지루할수도 있지만, 계속해서 반복 할 수록 코드가 점점 더 좋아지는 것을 보고 느끼게 될 것이기 때문에 하면 할 수록 이 훈련이 아주 좋다는 것을 스스로 인식하게 될 것입니다.

결과적으로, 스스로가 성장한다는 것을 직접적으로 느낄 수 있기 때문에 카타는 개발에 재미를 느끼는 데에도 큰 도움이 됩니다.


누군가는 카타를 하며 단순히 코딩하는 것을 떠나 설계까지 고려 할 겁니다.

또 누군가는 카타를 진행하며 프로그래밍 이면의 문제에 대한 고민을 할 겁니다.

즉, 카타에는 정답이 없습니다.


원하는 훈련을 하고 원하는 공예에 충분한 시간을 투자하세요.

카타의 요점은 정답이 없다는 것이며, 설령 정답이 있다고 하더라도 그 정답이 중요하지 않을 것이라는 점입니다.

중요한 것은 결과가 아닌 카타를 진행하며 고민하는 과정 그 자체입니다.


소프트웨어 개발 3대 원칙


나는 최대한 소프트웨어 개발 3대 원칙과 객체지향 5대 원칙(SOLID)을 고민하면서 훈련에 임할 것이다.

  • KISS(Keep It Simple Stupid): 최대한 단순하게 해라
  • YAGNI(You Ain’t Gonna Need It): 필요한 것만 해라
  • DRY(Do not Repeat Yourself): 같은 것을 반복하지 마라

문자열 계산기

Java_17



요구사항


  • 사용자가 입력한 문자열 값에 따라 사칙연산을 수행할 수 있는 계산기를 구현해야 한다.
  • 문자열 계산기는 사칙연산의 계산 우선순위가 아닌 입력 값에 따라 계산 순서가 결정된다. 즉, 수학에서는 곱셈, 나눗셈이 덧셈, 뺄셈 보다 먼저 계산해야 하지만 이를 무시한다.
  • 예를 들어 2 + 3 * 4 / 2와 같은 문자열을 입력할 경우 2 + 3 * 4 / 2 실행 결과인 10을 출력해야 한다.


프로그래밍 요구사항

  • 모든 로직을 TDD로 구현한다. 단, UI(System.out, System.in) 로직은 제외
  • 자바 코드 컨벤션을 지키면서 프로그래밍한다.
    • 기본적으로 Google Java Style Guide을 원칙으로 한다.
    • 단, 들여쓰기는 ‘2 spaces’가 아닌 ‘4 spaces’로 한다.
  • indent(인덴트, 들여쓰기) depth를 2가 넘지 않도록 구현한다. 1까지만 허용한다.
    • 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.
  • else 예약어를 쓰지 않는다.
  • 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다.
  • 3항 연산자를 쓰지 않는다.
  • 함수(또는 메소드)가 한 가지 일만 하도록 최대한 작게 만들어라.


기능 정의 및 책임 할당

우선 꼭 필요하다고 생각되는 기능들을 정의해야한다.

구현 중간에 세세하게 바뀔수는 있겠지만 거시적으로 보면 크게 변하지는 않을 것이다.

  • 콘솔로 수식을 입력받을 수 있어야 한다 (ConsoleInput)
  • 콘솔에 계산 결과를 출력할 수 있어야 한다(ConsoleOutput)
  • 숫자와 사칙연산자로 이뤄지고 공백으로 분리된 올바른 수식을 판별해야 한다(Expression)
  • 순차적으로 사칙연산이 되어야 한다(StringCalculator)


프로젝트 구성


일단 다음과 같이 패키지 구조를 잡았다


image


의존성 설정을 할 것인데, 자바 17을 사용할 것이며, 빌드툴은 Gradle을 사용한다.

테스트를 원활하게 하기 위해 AssertJJUnit5를 추가했다.


// file: 'build.gradle'
plugins {
    id 'java'
}

group 'io.github.shirohoo'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.assertj:assertj-core:3.22.0'
    testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
}

test {
    useJUnitPlatform()
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}


문자열 계산기 구현



우선 수식과 연산이 가장 중요하다고 생각하므로 둘을 먼저 타겟으로 잡고 수식에 대한 테스트 코드를 먼저 작성했다.


@DisplayName("수식 테스트")
class ExpressionTests {
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {
        "2+4-7*1/0", "5+7", "1 + +", "+", "-", "/", "*", "1 + +", "1 - -", "1 / /", "1 * *"
    })
    @DisplayName("유효하지 않은 수식이 입력되면 예외가 발생해야 한다")
    void validate(String expr) {
        assertThatThrownBy(() -> {
            Expression expression = Expression.from(expr);
        }).isInstanceOf(IllegalArgumentException.class);
    }
}


예외 케이스는 비정상적인 수식인 경우(1++ 같은), 정상적인 수식이지만 공백으로 분리되지 않은 경우다.

후자는 공백이 포함되지 않아도 파싱하도록 해보려 했는데, 공백으로 분리가 안되면 2+55 같은 입력이 들어올 때 분리하기가 힘들 것 같아서 일단 보류했다.

규식이형을 좀 더 잘 알았다면 어떻게 해볼 수 있었을 것 같기도 한데…

어쨌든 일단 이정도로 해두고 테스트 코드 통과를 위한 약간의 구현을 했다.


public class Expression {
    private static final Pattern PATTERN = Pattern.compile("^([0-9]*)|[0-9]*(\\s[+\\-*/]\\s[0-9].*)$");

    private final String expr;

    private Expression(String expr) {
        if (expr == null || expr.isBlank() || !PATTERN.matcher(expr).matches()) {
            throw new IllegalArgumentException("Expression must be space-separated and must be valid. for example, you cannot enter a expression such as 1 + +.");
        }
        this.expr = expr;
    }

    public static Expression from(String expr) {
        return new Expression(expr);
    }
}


여기서도 규식이형에 대해 할 말이 참 많은데, 처음에는 수식을 공백으로 분리해서 배열방의 홀수는 숫자, 짝수는 연산자로 검사를 하려고 했다.


image


근데 그렇게 코드를 작성하고 보니 너무 장황한 것 같아서 규식이형을 사용하기로 노선을 변경했는데, 이게 이번 카타에서 가장 많은 시간을 빼앗아갔다 (-ㅅ-)

알량한 지식을 갖고 주먹구구식으로 만들어낸 규식이형을 보니 뭔가 매우 구린거 같아서 썩 마음에 들진 않는데, 이게 지금의 내가 할 수 있는 최선인 것 같아서 받아들이기로했다…


image


보면 볼수록 규식이형이 문자열 처리에서 치트키인데 요상하게 난해해서 우선순위가 계속 밀렸던 것 같다… 어쨌든 조만간 규식이형을 한번 각잡고 정복해야겠다는 생각이 들어 책을 한권 주문하게 되는 계기가 됐다.


Pattern.compileprivate static final로 작성한 이유는, 이렇게 정적 멤버로 선언하지 않고 생성자에서 매번 Pattern 인스턴스를 새로 만들면, 생성자가 종료될때마다 Pattern 인스턴스가 가비지 컬렉션 대상이 되기 때문이다.


다음으로 수식을 분리하는 테스트 코드를 작성했다.


@DisplayName("수식 테스트")
class ExpressionTests {
    ...
  
    @Test
    @DisplayName("수식을 공백단위로 분리할 수 있어야 한다")
    void split() {
        // ...given
        String expr = "2 + 4 - 7 * 1 / 0";
        String[] expected = {"2", "+", "4", "-", "7", "*", "1", "/", "0"};

        // ...when
        Expression expression = Expression.from(expr);

        // ...then
        assertThat(expression.split()).isEqualTo(expected);
    }
}


이번 구현은 아주 쉽다.


public class Expression {
    private static final Pattern PATTERN = Pattern.compile("^([0-9]*)|[0-9]*(\\s[+\\-*/]\\s[0-9].*)$");
    
    private final String expr;
    
    private Expression(String expr) {
        if (expr == null || expr.isBlank() || !PATTERN.matcher(expr).matches()) {
            throw new IllegalArgumentException("Expression must be space-separated and must be valid. for example, you cannot enter a expression such as 1 + +.");
        }
        this.expr = expr;
    }
    public static Expression from(String expr) {
        return new Expression(expr);
    }

    public String[] split() {
        return expr.split(" ");
    }
}


몇가지 테스트 케이스를 더 추가해준다.

수식 객체에 대한 최종적인 테스트 코드는 하기와 같다.


@DisplayName("수식 테스트")
class ExpressionTests {
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {
        "2+4-7*1/0", "5+7", "1 + +", "+", "-", "/", "*", "1 + +", "1 - -", "1 / /", "1 * *"
    })
    @DisplayName("유효하지 않은 수식이 입력되면 예외가 발생해야 한다")
    void validate(String expr) {
        assertThatThrownBy(() -> {
            Expression expression = Expression.from(expr);
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    @DisplayName("수식을 공백단위로 분리할 수 있어야 한다")
    void split() {
        // ...given
        String expr = "2 + 4 - 7 * 1 / 0";
        String[] expected = {"2", "+", "4", "-", "7", "*", "1", "/", "0"};

        // ...when
        Expression expression = Expression.from(expr);

        // ...then
        assertThat(expression.split()).isEqualTo(expected);
    }

    @Test
    @DisplayName("수식이 단순 숫자 한개일 경우 분리할 수 없음을 알려준다")
    void isSplit() {
        Expression expression = Expression.from("1");
        assertThat(expression.isSplit()).isFalse();
    }

    @Test
    @DisplayName("수식이 단순 숫자 한개일 경우 수식을 바로 반환 할 수 있다")
    void export() {
        Expression expression = Expression.from("1");
        assertThat(expression.export()).isEqualTo(1);
    }

    @Test
    @DisplayName("수식이 단순 숫자 한개일 경우 수식을 바로 반환 할 수 없다")
    void exportException() {
        Expression expression = Expression.from("1 + 2");
        assertThatThrownBy(expression::export).isInstanceOf(IllegalStateException.class);
    }
}


최종적인 구현은 다음과 같다.


public class Expression {
    private static final Pattern PATTERN = Pattern.compile("^([0-9]*)|[0-9]*(\\s[+\\-*/]\\s[0-9].*)$");

    private final String expr;

    private Expression(String expr) {
        if (expr == null || expr.isBlank() || !PATTERN.matcher(expr).matches()) {
            throw new IllegalArgumentException("Expression must be space-separated and must be valid. for example, you cannot enter a expression such as 1 + +.");
        }
        this.expr = expr;
    }

    public static Expression from(String expr) {
        return new Expression(expr);
    }

    public String[] split() {
        return expr.split(" ");
    }

    public boolean isSplit() {
        return expr.length() > 1;
    }

    public double export() {
        if (!isSplit()) {
            return Double.parseDouble(split()[0]);
        }
        throw new IllegalStateException("Current expression is not a single number");
    }
}


image


image


다음은 계산기를 구현할 차례다.

항상 그랬듯이 테스트 코드의 시작은 초기화 테스트부터다.


@DisplayName("문자열 계산기 테스트")
class StringCalculatorTests {
    @Test
    @DisplayName("정상적인 수식이 입력되면 초기화에 성공한다")
    void from() {
        Expression expression = Expression.from("2 + 4 - 7 * 1 / 0");

        assertThatCode(() -> {
            StringCalculator calculator = StringCalculator.from(expression);
        }).doesNotThrowAnyException();
    }

    @Test
    @DisplayName("비정상적인 수식이 입력되면 초기화에 실패한다")
    void fromException() {
        assertThatThrownBy(() -> {
            StringCalculator calculator = StringCalculator.from(null);
        }).isInstanceOf(NullPointerException.class);
    }
}


public class StringCalculator {
    private final Expression expression;

    private StringCalculator(Expression expression) {
        this.expression = Objects.requireNonNull(expression);
    }

    public static StringCalculator from(Expression expression) {
        return new StringCalculator(expression);
    }
}


이미 수식에 대한 검증은 모두 마쳤으므로 계산기는 오로지 계산에만 집중하면 된다.

수식 객체가 null로 들어오는지만 검증하도록 하고 바로 계산기 구현에 들어간다.

항상 유효한 상태로 초기화되도록 검증된 수식 객체가 들어올 것이기 때문에 입력이 null만 아니면 된다.

우선 요구사항대로 상식적인 순서의 사칙연산이 아닌 순차적인 순서의 사칙연산을 거친 결과가 나오도록 테스트 케이스들을 정의한다.


@DisplayName("문자열 계산기 테스트")
class StringCalculatorTests {
    @Test
    @DisplayName("정상적인 수식이 입력되면 초기화에 성공한다")
    void from() {
        Expression expression = Expression.from("2 + 4 - 7 * 1 / 0");

        assertThatCode(() -> {
            StringCalculator calculator = StringCalculator.from(expression);
        }).doesNotThrowAnyException();
    }

    @Test
    @DisplayName("비정상적인 수식이 입력되면 초기화에 실패한다")
    void fromException() {
        assertThatThrownBy(() -> {
            StringCalculator calculator = StringCalculator.from(null);
        }).isInstanceOf(NullPointerException.class);
    }

    @MethodSource
    @ParameterizedTest
    @DisplayName("수식에 대한 순차적인 연산 결과를 반환한다")
    void calculate(String expr, double expected) {
        // ...given
        Expression expression = Expression.from(expr);
        StringCalculator calculator = StringCalculator.from(expression);

        // ...when
        double result = calculator.calculate();

        // ...then
        assertThat(result).isEqualTo(expected);
    }

    static Stream<Arguments> calculate() {
        return Stream.of(
            Arguments.of("1", 1),
            Arguments.of("5 - 1", 4),
            Arguments.of("0 - 1", -1),
            Arguments.of("2 + 4 - 1 * 5 / 5", 5),
            Arguments.of("2 + 4 - 1 * 5 / 10", 2.5),
            Arguments.of("100 + 100 - 5 * 2 / 3", 130),
            Arguments.of("100 + 100 - 5 * 2 / 4", 97.5)
        );
    }

    @Test
    @DisplayName("0으로 나누려 하는 경우 예외가 발생한다")
    void calculateDividedByZero() {
        Expression expression = Expression.from("5 / 0");
        StringCalculator calculator = StringCalculator.from(expression);
        assertThatThrownBy(calculator::calculate).isInstanceOf(ArithmeticException.class);
    }
}


0으로 나누려 하는 경우는 무한루프가 발생할 수 있기 때문에 이에 대한 예외 처리를 해준다.

이에 대해 약간 설명을 해보자면, 컴퓨터는 오로지 덧셈밖에 못한다. 그럼 덧셈을 제외한 다른 연산은 어떻게 하냐는 의문이 들 수 있다.

곱셈은 덧셈을 반복하며, 뺄셈은 보수를 구해 더하는 식으로 동작한다.

즉, 프로그래밍에서 2-2는 2+(-2)이다.

그리고 마지막으로 나눗셈은 위의 뺄셈 작업을 반복하게 된다.

예를 들어 10을 2로 나눈다고 가정하면 뺄셈해서 나온 결과가 2보다 작을 때까지 계속해서 빼게 되므로 다섯 번을 뺄 것이고, 이 경우 몫은 5, 나머지는 0이 된다.

이를 수학적인 용어로는 피제수에서 제수를 뺀다고 표현한다.

그럼 이제 예외 처리를 해야하는 어떤 값을 0으로 나누는 경우를 생각해보자.

위에서 언급한대로 동작하면 결국 피제수에서 0을 뺀 결과가 0보다 작을 때까지 반복하게 되는데, 피제수에서 0을 빼 봐야 피제수는 항상 같다.

즉, 프로그래밍에서 10 / 010+(-0)+(-0)+(-0)+(-0)+(-0)+(-0)+(-0)... (무한반복)이 되는 것이다.


연산에 대한부분은 구현이 좀 긴데, 일단 누산기를 생각해 구현했다.

일단 수식을 공백으로 분리해 나온 배열에는 수와 연산자가 들어있다.

여기에 연산에 사용할 스택을 한개 만들고, 배열의 크기 만큼 반복하며 스택에 수와 연산자를 집어넣는다.

집어넣다가 스택의 사이즈가 3이 되면 3개를 모두 꺼냈을 때 가장 먼저 pop된 수는 제수고, 두번째로 pop된 수는 연산자, 세번째로 pop 된 수는 피제수이다.

즉, 그림으로 보면 다음과 같다.

수식 2 + 5 / 1이 입력됐다고 가정하자.


image


image


image


image


image


image


이렇게 N번 순회하고 스택에 남아있는 최종 결과를 반환하면 원하는 값이 나올 것이다.

더 우아한 방법이 있는지는 잘 모르겠지만 일단 이게 내가 생각해낼 수 있는 최선의 솔루션인 것 같다.


public class StringCalculator {
    private final Stack<String> accumulator;
    private final Expression expression;

    private StringCalculator(Expression expression) {
        this.accumulator = new Stack<>();
        this.expression = Objects.requireNonNull(expression);
    }

    public static StringCalculator from(Expression expression) {
        return new StringCalculator(expression);
    }

    public double calculate() {
        if (!expression.isSplit()) {
            return expression.export();
        }

        accumulator.clear();
        for (String expr : expression.split()) {
            accumulateIfEqualSize3();
            pushIfLessThanSize3(expr);
        }
        return Double.parseDouble(accumulate());
    }

    private void accumulateIfEqualSize3() {
        if (accumulator.size() == 3) {
            accumulate();
        }
    }

    private String accumulate() {
        double right = Double.parseDouble(accumulator.pop());
        String operator = accumulator.pop();
        double left = Double.parseDouble(accumulator.pop());

        return accumulator.push(
            ArithmeticCalculator.findBy(operator)
                .applyAsDouble(left, right)
        );
    }

    private void pushIfLessThanSize3(String expr) {
        if (accumulator.size() < 3) {
            accumulator.push(expr);
        }
    }

    private enum ArithmeticCalculator {
        ADDITION("+", (x, y) -> x + y),
        SUBTRACTION("-", (x, y) -> x - y),
        MULTIPLICATION("*", (x, y) -> x * y),
        DIVISION("/", (x, y) -> x / y);

        private static final Map<String, ArithmeticCalculator> MAP = stream(values())
            .collect(toMap(ArithmeticCalculator::operator, identity()));

        private final String operator;
        private final DoubleBinaryOperator function;

        ArithmeticCalculator(String operator, DoubleBinaryOperator function) {
            this.operator = operator;
            this.function = function;
        }

        private static ArithmeticCalculator findBy(String operator) {
            if (MAP.containsKey(operator)) {
                return MAP.get(operator);
            }
            throw new NoSuchElementException("'%s' is not operator or not supported operator.".formatted(operator));
        }

        private String applyAsDouble(double left, double right) {
            if (isDivideByZero(right)) {
                throw new ArithmeticException("It cannot be divided by zero.");
            }
            return String.valueOf(function.applyAsDouble(left, right));
        }

        private boolean isDivideByZero(double right) {
            return this == DIVISION && right == 0;
        }

        private String operator() {
            return operator;
        }
    }
}


image


image


최종적인 도메인 구현은 여기서 끝났고, 이번 카타에서 콘솔 입출력은 너무 간단하기 때문에 스킵했다.

만들고 보니 굳이 스택사이즈가 3이 될때까지 push하지 않고 사이즈 2까지만 확인했어도 될 것 같긴하다.

그리고 StringCalculator의 클래스 멤버인 Stack<String> accumulator 도 일급 컬렉션으로 포장했으면 더 좋았을 것 같다.



© 2022. All rights reserved.