본문 바로가기

다우 & Web/JAVA

ENUM + Funcational Interface 활용하기

안녕하세요 개발세끼의 '첫끼'입니다.

오늘은 ENUM과 함수형 인터페이스를 활용해서 복잡하고 유지보수가 어려운 코드들을 단순화시키는 방법에 대해서 포스팅해보겠습니다.

제가 오늘 만들어 볼 기능은,
'1+1', '551/1' , '641-12'와 같은 단순한 계산 스트링을 입력받아서,
결과를 반환시켜주는 계산기를 만들어 볼 생각입니다.

우선, 아래와 같은 테스트 케이스가 필요하겠군요

    @Test
    public void plusTest() throws CloneNotSupportedException {
        Calculator calculator = new Calculator("123 + 123");
        Assert.isTrue(calculator.calculate().equals(246.0));
    }

    @Test
    public void minusTest() throws CloneNotSupportedException {
        Calculator calculator = new Calculator("123 - 123");
        Assert.isTrue(calculator.calculate().equals(0.0));
    }

    @Test
    public void divisionTest() throws CloneNotSupportedException {
        Calculator calculator = new Calculator("123 / 123");
        Assert.isTrue(calculator.calculate().equals(1.0));
    }
    @Test
    public void multiplicationTest() throws CloneNotSupportedException {
        Calculator calculator = new Calculator("123 * 2");
        Assert.isTrue(calculator.calculate().equals(246.0));
    }

위와 같은 테스트 케이스가 성공하기 위해서는,
Calculator class에서 String을 파라미터로 받는 생성자가 필요하고,
calculate에서 각 부호에 맞는 적절한 계산을 해서 리턴시켜주는 calculate 함수가 필요하다는 것을 유추할 수 있습니다.

먼저 ENUM을 전혀 활용하지 않고 코드를 작성해보았습니다.


public class Calculator {

    private final Double valueA;
    private final String symbol;
    private final Double valueB;


    public Calculator(@NonNull String arithmeticText) {
        String[] splitText = arithmeticText.split(" ");
        assert splitText.length == 3;
        int idx = 0;

        this.valueA = Double.valueOf(splitText[idx++]);
        this.symbol = splitText[idx++];
        this.valueB = Double.valueOf(splitText[idx++]);
    }

    public Double calculate() throws CloneNotSupportedException {

        switch (symbol) {
            case "+":
                return valueA + valueB;
            case "-":
                return valueA - valueB;
            case "*":
                return valueA * valueB;
            case "/":
                return valueA / valueB;
        }
        throw new CloneNotSupportedException("symbol is not supported " + this.symbol);
    }


}

일반적으로 가장 많이 작성하는 코드 작성법이죠, 흠잡을 데가 많지 않은 코드지만,
10년간 유지보수를 해야 한다는 가정을 해보면 굉장히, 좋지 않은 방법입니다.

  1. 저 계산하는 부호들('+', '-', '*', '/') 들이 여기서만 사용한다는 전제가 보장되지 않습니다. 때문에, 여러 곳에서 중복 코드가 추가적으로 계속 발생할 수 있죠
  2. 계산 부호들이 추가되었을 때, 혹은 계산 부호에 의한 계산 방법이 변경되었을 때, 해당 정책에 대한 코드가 여러 곳에서 관리되고 있다면, 리팩터링 하는 것이 매우 어렵습니다.
  3. 실제 계산을 하는 코어 로직이 텍스트를 파싱 하는 로직과 같은 레벨에 작성되어있기 때문에, 테스트 코드를 작성하는 것이 더 어렵습니다.

뭐,, 그래서 공통 적인 로직인데, 특정 블록에서만 type에 의해서 ( if, switch case )를 사용해야 하는 경우가 생긴다면
아래와 같이 반드시 ENUM을 정의하는 것을 추천드리고 싶습니다.

public enum CalculatorSymbol {
    PLUS, MINUS, DIVISION, MULTIPLICATION;
}

아직은, 그냥 단지, 타입만 ENUM으로 정의해둔 정도에 불과하네요.
특정 계산식을 받았을 때 적절한 타입을 찾을 수 있는 함수를 ENUM안에 정의해 주겠습니다.


public enum CalculatorSymbol {
    PLUS("+"), MINUS("-"), DIVISION("/"), MULTIPLICATION("*"), UNKNOWN(null);

    private final String symbol;

    CalculatorSymbol(String symbol) {
        this.symbol = symbol;
    }

    public static CalculatorSymbol findSymbol(String arithmeticText) {
        return Arrays.stream(CalculatorSymbol.values()).filter(type -> type.symbol != null)
                .filter(type -> arithmeticText.contains(type.symbol)).findFirst().orElse(CalculatorSymbol.UNKNOWN);
    }

}

기존 Calculator class는 어떻게 대응해서 바뀌었을까요


public class Calculator {

    private final Double valueA;
    private final CalculatorSymbol symbol;
    private final Double valueB;


    public Calculator(@NonNull String arithmeticText) {
        String[] splitText = arithmeticText.split(" ");
        assert splitText.length == 3;

        this.valueA = Double.valueOf(splitText[0]);
        this.symbol = CalculatorSymbol.findSymbol(arithmeticText);
        this.valueB = Double.valueOf(splitText[2]);
    }

    public Double calculate() throws CloneNotSupportedException {

        if(CalculatorSymbol.PLUS.equals(this.symbol)){
            return this.valueA + this.valueB;
        }
        else if(CalculatorSymbol.MINUS.equals(this.symbol)){
            return this.valueA - this.valueB;
        }
        else if(CalculatorSymbol.DIVISION.equals(this.symbol)){
            return this.valueA / this.valueB;
        }
        else if(CalculatorSymbol.MULTIPLICATION.equals(this.symbol)){
            return this.valueA * this.valueB;
        }

        throw new CloneNotSupportedException("symbol is not supported " + this.symbol);
    }


}

기존 코드와 비교해 아직은 크게 개선됐다고 보이진 않네요,
아직 기존에 가지고 있던 문제들을 거의 그대로 갖고 있는 것으로 보입니다.

### 이제 마지막으로 ENUM에 함수형 인터페이스를 적용해서 리팩터링 해보도록 하겠습니다.



public enum CalculatorSymbol {
    PLUS("+", (a, b) -> a + b),
    MINUS("-", (a, b) -> a - b),
    DIVISION("/", (a, b) -> a / b),
    MULTIPLICATION("*", (a, b) -> a * b),
    UNKNOWN(null, null);

    private final String symbol;
    private final BiFunction<Double, Double, Double> calculateFunc;

    CalculatorSymbol(String symbol, BiFunction<Double, Double, Double> calculateFunc) {
        this.symbol = symbol;
        this.calculateFunc = calculateFunc;
    }

    public Double calculate(Double a, Double b) {
        assert this != UNKNOWN;
        return this.calculateFunc.apply(a, b);
    }

    public static CalculatorSymbol findSymbol(String arithmeticText) {
        return Arrays.stream(CalculatorSymbol.values()).filter(type -> type.symbol != null)
                .filter(type -> arithmeticText.contains(type.symbol)).findFirst().orElse(CalculatorSymbol.UNKNOWN);
    }

}

기존 ENUM안에 BIFunction(두 개의 double 인자를 받아서 , double을 리턴) 함수형 인터페이스 필드를 추가해 주었습니다.
실제 타입의 속성 안에 계산 식이 포함되니, 실제 사용하는 로직에서 타입별로 기능 코드를 작성하는 것에 비해서 굉장히 자연스러워 보입니다.

또한, 타입이 추가되었을 때, 다른 참조 클래스들에 대해서 추가적인 수정이 필요 없으니, 유지보수 측면에서도 훌륭해 보이네요

자 그럼 마지막으로 이 Enum을 사용하는 Calculator class는 어떻게 변했을지 보겠습니다.


public class Calculator {

    private final Double valueA;
    private final CalculatorSymbol symbol;
    private final Double valueB;


    public Calculator(@NonNull String arithmeticText) {
        String[] splitText = arithmeticText.split(" ");
        assert splitText.length == 3;

        this.valueA = Double.valueOf(splitText[0]);
        this.symbol = CalculatorSymbol.findSymbol(arithmeticText);
        this.valueB = Double.valueOf(splitText[2]);
    }

    public Double calculate() {
        return this.symbol.calculate(this.valueA, this.valueB);
    }
}

실제 계산하는 코어 코드를 ENUM이 가지고 갔기 때문에 Caculator는 굉장히 단순해졌습니다.

이렇게 되면, ENUM의 calculate 만을 테스트하는 코드와, String을 파싱 하는 코드의 테스트 코드 또한 따로 작성할 수 있고,
계산하는 쪽의 코어 로직의 테스트 신뢰도 또한 더욱 100%에 수렴할 수 있게 됩니다.

또한, ENUM 타입이 해야 하는 코어 기능에 대해서 가장 잘 알고 있을 수밖에 없는,
TYPE자신 안에 코어 로직을 추가하면서, 이후 유지보수 시에 발생할 수 있는 사이드 이펙트 또한 최소화시킬 수 있겠죠

이상으로 개발 세끼의 '첫끼'였습니다.