[Java] if문 제거하기

양성욱·2023년 10월 5일
0
post-thumbnail

이 글은 프로그래머스 - 실무 자바 개발을 위한 OOP와 핵심 디자인 패턴 강의를 정리한 내용입니다.

if문은 자바 문법의 일부라는 것은 틀림없지만, 너무 많은 if문은 코드를 읽기 어렵게 만듭니다. 읽기 어려워진 코드는 코드를 수정하기도 어렵고 문제가 발생했을 때 디버깅도 어렵게 만듭니다. 따라서 같은 로직이라면 if문이 없이 작성된 코드가 더 가독성 높은 코드가 될 가능성이 높습니다.

이번 포스팅에서는 if문이 많은 코드의 문제점에서 시작하여 코드에서 어떻게 if문을 제거할 수 있는지 알아보겠습니다.

if문이 많은 코드

ClientV1

public class Client {
    public int someMethod(CalculateCommand calculateCommand) {
        CalculateType calculateType = calculateCommand.getCalculateType();
        int num1 = calculateCommand.getNum1();
        int num2 = calculateCommand.getNum2();

        int result = 0;

        if(calculateType.equals(CalculateType.ADD)) {
            result = num1 + num2;
        } else if(calculateType.equals(CalculateType.MINUS)) {
            result = num1 - num2;
        } else if(calculateType.equals(CalculateType.MULTIPLY)) {
            result = num1 * num2;
        } else if(calculateType.equals(CalculateType.DIVIDE)) {
            result = num1 / num2;
        }

        return result;
    }
}

위 코드는 if문을 활용해서 calculateType별로 적절한 연산을 수행하고 있습니다. 사실 이정도는 그나마 읽을만 하긴 합니다. 하지만 위 코드는 가독성 외에 동작에도 몇 가지 문제점이 있습니다.

첫 번째로 CalculateType에 대한 null 체크가 이루어져 있지 않습니다. CalculateType은 Enum Type이므로 calculate.equals를 호출하기 전, 반드시 null 체크를 진행해줘야합니다.

두 번째로 나누기를 수행하는 divide 연산에 관한 이야기입니다. 나누기에서는 0으로 나누는 행위가 금지되어 있습니다. 따라서 num2의 값이 0이 아닌지 꼭 체크해줘야합니다.

위 두가지 문제를 해결하기 위해 코드는 다음과 같이 수정됩니다.

ClientV2

public class Client {
    public int someMethod(CalculateCommand calculateCommand) {
        CalculateType calculateType = calculateCommand.getCalculateType();
        int num1 = calculateCommand.getNum1();
        int num2 = calculateCommand.getNum2();

        int result = 0;

        if(calculateType != null && calculateType.equals(CalculateType.ADD)) {
            result = num1 + num2;
        } else if(calculateType != null && calculateType.equals(CalculateType.MINUS)) {
            result = num1 - num2;
        } else if(calculateType != null && calculateType.equals(CalculateType.MULTIPLY)) {
            result = num1 * num2;
        } else if(calculateType != null && calculateType.equals(CalculateType.DIVIDE)) {
            if(num2 == 0) {
                throw new RuntimeException("0으로 나눌 수 없습니다.");
            } else {
                result = num1 / num2;
            }
        }

        return result;
    }
}

위에서 기술한 문제는 해결되었을지 몰라도 코드의 가독성은 더 떨어졌습니다.

각각의 if문에서 calculateType에 대한 null 체크를 진행해주고있고, 나누기 연산에서는 num2에 대해 0인지 체크도 하고 있습니다.

이로 인해 if문 중첩과 더불어 코드의 중복이 굉장히 심해져서 더 읽기 힘들어진 코드가 되었습니다. 만약 이 코드가 남이 작성한 코드라면 파악하는데 더 어려움이 있을겁니다.

이런 코드는 어떻게 개선할 수 있을까요?

빠르게 반환하기(Early Return)

public class Client {
    public int someMethod(CalculateCommand calculateCommand) {
        CalculateType calculateType = calculateCommand.getCalculateType();
        int num1 = calculateCommand.getNum1();
        int num2 = calculateCommand.getNum2();

        int result = 0;

        if(calculateType == null) {
            return result;
        }

        if(calculateType.equals(CalculateType.DIVIDE) && num2 == 0) {
            throw new RuntimeException("0으로 나눌 수 없습니다.");
        }

        if(calculateType.equals(CalculateType.ADD)) {
            result = num1 + num2;
        } else if(calculateType.equals(CalculateType.MINUS)) {
            result = num1 - num2;
        } else if(calculateType.equals(CalculateType.MULTIPLY)) {
            result = num1 * num2;
        } else if(calculateType.equals(CalculateType.DIVIDE)) {
            result = num1 / num2;
        }

        return result;
    }
}

가장 먼저 생각해볼 수 있는 방법은 빠르게 리턴하는 것입니다.

기존 null 체크와 나누기의 피연산자가 0인지에 대한 체크 로직을 앞으로 빼서, 정수 0 반환 혹은 예외를 발생시키도록 코드를 수정하였습니다.

덕분에 if문이 사용된 부분은 예전 사이즈로 돌아왔습니다.

😵‍💫 하지만 위 코드 역시 여전히 가독성 좋지 않은 코드인건 마찬가지입니다...

Enum에 로직 포함시키기

CalculateType 개선 코드

public enum CalculateType {
    ADD ((num1, num2) -> num1 + num2),
    MINUS ((num1, num2) -> num1 - num2),
    MULTIPLY ((num1, num2) -> num1 * num2),
    DIVIDE ((num1, num2) -> num1 / num2);

    CalculateType(BiFunction<Integer, Integer, Integer> expression) {
        this.expression = expression;
    }

    private BiFunction<Integer, Integer, Integer> expression;

    public int calculate(int num1, int num2) {
        return this.expression.apply(num1, num2);
    }
}

Client 개선 코드

public class Client {
    public int someMethod(CalculateCommand calculateCommand) {
        CalculateType calculateType = calculateCommand.getCalculateType();
        int num1 = calculateCommand.getNum1();
        int num2 = calculateCommand.getNum2();

        if(calculateType == null) {
            return 0;
        }

        if(calculateType.equals(CalculateType.DIVIDE) && num2 == 0) {
            throw new RuntimeException("0으로 나눌 수 없습니다.");
        }

        int result = calculateType.calculate(num1, num2);

        return result;
    }
}

다른 방식으로는 enum 내부에 연산 로직을 포함시키는 것입니다. 이 방법 역시 if문을 줄일 수 있는 좋은 방식 중 하나입니다.

덕분에 Client 코드에는 if문을 사용하는 로직이 대폭 줄었습니다.

여기서 아직 남아있는 null과 0을 체크하는 로직에 대해서 좀 더 생각해보겠습니다.

현재 검증이 진행되는 시점은 calculateType이 사용되는 시점 이전에 진행되고 있는데, 이러한 방식으로 검증을 하게 된다면 앞으로 calculateType을 사용하는 모든 로직에서 똑같이 if문을 사용하여 null과 0에 대한 검증 로직을 작성해야합니다. 이건 불필요한 코드 중복을 발생시킵니다.

이러한 코드 중복 문제를 피하기 위해 Client 코드를 더 개선해보겠습니다.

생성 시점에 유효성 검사하기

CalculateCommand

public class CalculateCommand {
    private CalculateType calculateType;
    private int num1;
    private int num2;

    public CalculateCommand(CalculateType calculateType, int num1, int num2) {
        if(calculateType == null) {
            throw new RuntimeException("CalculateType은 필수 값 입니다.");
        }

        if(calculateType.equals(CalculateType.DIVIDE) && num2 == 0) {
            throw new RuntimeException("0으로 나눌 수 없습니다.");
        }

        this.calculateType = calculateType;
        this.num1 = num1;
        this.num2 = num2;
    }

    public CalculateType getCalculateType() {
        return calculateType;
    }

    public int getNum1() {
        return num1;
    }

    public int getNum2() {
        return num2;
    }
}

Client

public class Client {
    public int someMethod(CalculateCommand calculateCommand) {
        CalculateType calculateType = calculateCommand.getCalculateType();
        int num1 = calculateCommand.getNum1();
        int num2 = calculateCommand.getNum2();

        int result = calculateType.calculate(num1, num2);

        return result;
    }
}

기존 calculateType에 대한 null 체크와 피연산자 0 여부 체크 로직을 CalculateCommand 생성자 내부에 넣어주었습니다.

이제 CalculateType을 검증하는 책임은 Client에게 해당 필드를 제공하는 CalculateCommand가 가지게 되었고, 그 덕분에 Client는 더이상 값에 대한 검증을 수행할 필요가 없어져 if문들이 깔끔하게 사라졌습니다. 그리고 앞으로는 CalculateType을 사용하는 모든 로직에서 중복되는 검증 로직을 작성할 필요가 없어졌습니다.

여기서 null을 체크하는 로직이 기존 0을 리턴하는 방식이 아닌 RuntimeException을 던지도록 변경되었습니다. 이는 CalculateCommand에 생성에 실패했다는 사실을 명시적으로 알릴 수 있게 해줍니다.

이제 Client코드에는 불필요한 if문이 사라져 코드의 가독성이 훨씬 높아졌습니다!

🤔 하지만 Client는 아직 개선의 여지가 더 남아있습니다! 이 부분은 다음 포스팅 예정인 'Getter & Setter'에서 다루겠습니다!

리팩토링의 정의

리팩토링의 사전적인 의미는 다음과 같습니다.

결과의 변경 없이, 코드의 구조를 재조정 하는 행위. 코드의 가독성을 높이거나 유지보수를 편하게 하는 목적으로 수행한다.

지금까지 우리가 진행한 과정들은 리팩토링의 일부라고 볼 수 있습니다. 하지만 위에서 기술한 사전적인 정의대로라면 우리가 진행한 변경 중 리팩토링이라고 부르기 힘든 부분도 있습니다.

변경하기 이전 코드는 null과 0을 체크하는 로직이 없었습니다. 따라서 입력으로 null이나 0이 들어왔다면 에러가 발생하였을 것입니다. 하지만 우리가 리팩토링 과정에서 두 상황에 대한 유효성 검사를 추가함으로써 이러한 에러 발생 가능성을 막을 수 있었습니다. 이는 어떻게 보면 기존 코드와 다른 결과를 만든 것 입니다.

어떻게 보면 버그를 수정했다고 볼 수도 있겠습니다. 하지만 이 부분을 제외하면 나머지 변경은 리팩토링이라고 볼 수 있습니다!

그럼 이런 생각이 들 수 있습니다. 결과의 변경 없이 코드를 수정하는게 리팩토링 이라면, 결과가 변경되지 않았다는 것을 어떻게 알 수 있을까요?

그 방법은 바로 테스트 코드를 활용하는 것 입니다.

리팩토링과 테스트 코드의 관계

리팩토링을 하기 전 해당 기능이 수행하는 동작에 대한 테스트 코드를 먼저 작성합니다. 작성한 테스트가 리팩토링 후에도 정상적으로 동작한다면 우리가 변경한 코드들이 기존 코드와 같은 결과를 준다고 보장할 수 있을겁니다.

물론 우리가 테스트 하지 않는 부분에 대해서는 결과의 변경 여부를 당장 확인할 수 없습니다. 이런 부분은 프로그램을 직접 실행시켜서 확인하는 방법 밖에 없고, 혹여나 리팩토링 과정에서 결과의 변경이 발생하였다면 예상치 못한 버그로 이어질 수 있습니다.

따라서 기능에 대한 테스트 코드를 반드시 작성하는 것을 권장합니다. 객체지향의 의의는 어떻게 보면 테스트와도 깊은 관련이 있다고 볼 수 있겠네요.

아직 스스로의 힘으로 리팩토링을 하는게 어려울 수 있습니다. 리팩토링을 시작한다면 우선 불필요한 if문을 제거하는 것이 좋은 시작점이 될 수 있을겁니다!

profile
개발의 신이시여... 제게 집중할 수 있는 ㅎ... 네? 맥주요?

0개의 댓글