조건이 복잡한 IF/ELSE-IF 문 리팩토링

2023. 1. 6. 18:38Dev/Java

개발을 처음 시작하면서부터 지금까지 써온 if 문은 과연 얼마나 될까. 사람의 생각 흐름과 가장 비슷하게 표현되는 문법이라서 그런지 무의식적으로도 많이 써온 것 같다. 하지만 구현해야 하는 로직이 복잡할수록, 다뤄야 하는 객체의 양이 많을수록 무한정 늘어나는 if/else-if 들은 점점 내 시야를 아득히 벗어나 제어할 수 없는 지경에 이르고야 만다.

 

앞선 enum 의 활용글에서도 언급한 바와 같이 경우에 따라서 enum 으로 if 문을 리팩토링이 가능하다. enum 으로 리팩토링이 가능한 경우는 if 문의 조건식이 특정 value 와 equal 인지를 판별하고 그 value 를 상수로 정의 가능할때 비로소 리팩토링을 할 수가 있었다. 하지만 언제나 그렇듯 코드는 우리가 원하는대로 그렇게 간단한 조건만을 요구하지는 않는다. 아래의 예제 코드를 보자.

 

public String getResult(String message) {
    LocalDate xmas = LocalDate.of(2023, 12, 25);
    String result;
    if ("ABC".equals(message) || "BCD".equals(message) || "XYZ".equals(message)) {
        result = "알파벳입니다";
    } else if ("마이크로소프트".equals(message)) {
        result = "MS";
    } else if ("!@#!$".equals(message)) {
        result = String.valueOf(10 + 10);
    } else if ("티스토리".equals(message) || "네이버".equals(message) || "다음".equals(message)) {
        result = "https://jnotebook.tistory.com/";
    } else if (xmas.isAfter(LocalDate.now())) {
        result = LocalDate.now().format(DateTimeFormatter.ISO_DATE_TIME);
    } else {
        // default
        result = "NO";
    }

    return result;
}

이 함수는 message 문자열을 파라미터로 받아서 조건분기를 통해 최종적으로 result 문자열을 반환한다. (일부러 지저분한게 조건을 만들어 본다고 만들어 봤지만 현업에서는 이것보다 더 한 조건식도 많이 봐왔을 것이다)

 

Problem

1. 일단 조건식들이 일관적이지 않고 message 변수에 의해서만 결정 되는 것도 아니다(xmas.isAfter).

이러한 식은 enum 으로 바꿔보려고 해도 enum 상수를 정의하는 부분에서 결국 저 if 문의 수식들을 다시 써야 하기 때문에 전혀 문제의 해결이 되지 않는다.

 

2. 각 분기문에서 처리해야 하는 내용이 많아지고 고려해야할 것들이 많아질 수록 라인수는 늘어나고 가독성이 떨어진다.

분기문 안에서 처리해야 하는 내용을 별도의 함수로 분리해서 인지복잡도(cognitive complexity)를 해소한다 하더라도 아래 3번의 이유로 여전히 가독성이 떨어진다.

 

3. 조건이 늘어날 수록 (종적으로 확장될 수록) 유지보수가 힘들어진다.

조건식들은 일부 비슷한 형태를 취하더라도(예제에서는 문자열 equals() or(||) ) 조건식에 필요한 value 들이 달라서 조건식이 추가되는 경우도 생길테고 if/else-if 문은 위에서부터 아래로 순서가 보장되어야 하는데 위쪽 조건에서 만족하였다면 아래쪽 분기문은 모두 pass 하게 되니 조건의 순서도 중요해진다. 

 

 

이런 경우 어떻게 리팩토링을 할 수 있을까.

if/else-if 조건식은 건드리지 못하고 단지 조건문의 안쪽 로직들의 depth 를 줄이는 것만 만족해야 할 것인가.

 

 

Agonizing...

고민해보자. 조건식을 너무 하나의 type 으로 바꾸려고 노력하고 있는 것은 아닌가. 너무 날로 먹으려고 하는 것은 아닌가. 조금만 멀리 떨어져서 생각해보자.

if ( true/false 를 결정하는 조건식 ) {
	// 본문
} else if ( true/false 를 결정하는 조건식 ) {
	// 본문
} ... 반복 ...
  • true / false 를 결정하는 조건식과 조건식이 만족할때 실행되는 본문의 반복이다.
  • 조건식의 결과는 특정한 타입이나 value 가 아니라 TRUE/FALSE 단 두가지다.
    • 그렇기 때문에 equals(), == 는 사용할 수 없다. (이 이유로 enum 으로 리팩토링 할 수 없다)
  • 조건식의 결과가 아닌 조건식 자체를 이용해야 할 것 같다.
  • <조건식 : 본문> 의 쌍으로 collection / map 을 구성하되 순서는 지켜저야 한다.
    • if/else-if 는 순서(위 -> 아래)대로 조건문을 검사한다.
  • Collection / Map 에 조건식 코드와 실행될 본문 코드를 넣어야 하는데...

 

 

Solution

해결법은 JAVA 8 에서 등장한 Functional Interface 에 있었다. 이번 상황에서 쓰일 수 있는 함수형 인터페이스의 가장 큰 특징은 함수 자체를 하나의 타입으로 취급이 가능하고 지연 실행이 가능하다는 것이다. 

 

함수를 하나의 타입으로 취급한다라는 표현이 애매할 수도 있다. 아래의 코드를 보자

// 1
public String getName(String name) {
    return "name : " + name;
}

// 2
public Function<String, String> nameFunc() {
    return name -> "name : " + name;
}

1번(getName)과 2번(nameFunc) 모두 'name' 파라미터를 받아서 문자열을 반환하는 메소드(함수) 로 실행 결과 자체는 동일하다. 하지만 메소드의 정의된 타입이 String / Function<String, String> 으로 다르다. 실제로 이 함수들의 타입을 찍어보면 아래와 같다.

Arrays.stream(클래스이름.class.getMethods()).forEach(m -> {
    System.out.println(m.getName() + " : " + m.getReturnType());
});

// 결과
// getName : class java.lang.String
// nameFunc : interface java.util.function.Function

1번은 getName 이 호출되는 즉시 코드가 실행되어 결과를 반환형인 String type 으로 리턴하고, 2번은 호출이 되었을때 반환되는건 처리결과가 아니라 함수식 name -> "name : " + name; 를 가지는 함수가 반환이 되는 것이다.

 

이는 함수의 로직이 즉시 실행되는 것이 아니라 함수식 자체를 응답받은 것으로 우리가 원하는 결과를 받기 위해서는 응답받은 함수를 처리하는 apply() 를 실행해야 한다.(지연실행)

// 위 두 함수가 정의된 class
Condition condition = new Condition();
String name = "Jay";

String result1 = condition.getName(name);
Function<String, String> nameFunc = condition.nameFunc();

// 지연호출 : 미리 함수를 assign 한 뒤에 필요한 타이밍에 apply 할 수 있다.
name = "Mary";
String result2 = nameFunc.apply(name);

System.out.println(result1);			// name : Jay
System.out.println(result2);			// name : Mary

2번의 함수를 assign 한 시점의 name 은 "Jay" 였지만 apply를 하기 전에 "Mary" 로 바꾸고 나서 실행하였기 때문에 바뀐 "Mary" 로 출력됨을 확인 할 수 있다.

 

이와 같은 특성으로 우리는 <조건식 : 본문> 을 Functional Interface 로 구성해서 Collection/Map 을 구성해서 처리 할 수 있다.

 

Refactoring

BEFORE

// before
public class Condition {
    public String getResult(String message) {
        LocalDate xmas = LocalDate.of(2023, 12, 25);
        String result;
        if ("ABC".equals(message) || "BCD".equals(message) || "XYZ".equals(message)) {
            result = "name : " + message;
        } else if ("마이크로소프트".equals(message)) {
            result = "name : " + message;
        } else if ("!@#!$".equals(message)) {
            result = String.valueOf(10 + 10);
        } else if ("티스토리".equals(message) || "네이버".equals(message) || "다음".equals(message)) {
            result = "https://jnotebook.tistory.com/";
        } else if (xmas.isAfter(LocalDate.now())) {
            result = LocalDate.now().format(DateTimeFormatter.ISO_DATE);
        } else {
            // default
            result = "NO";
        }

        return result;
    }
}

 

AFTER

public class RefactoringCondition {
	// 조건식 : 본문로직 캐싱
    private static final Map<Predicate<String>, Function<String, String>> CONDITION_MAP;

    static {
        // 순서를 지키기 위한 LinkedHashMap
        CONDITION_MAP = new LinkedHashMap<>();
        
        // 이하 <조건식 : 실행로직>
        CONDITION_MAP.put(predicateOfStream(Arrays.asList("ABC", "BCD", "XYZ")), getNameFunction());	// 미리 작성해둔 함수를 사용
        CONDITION_MAP.put(predicateOfString("마이크로소프트"), getNameFunction());
        CONDITION_MAP.put(predicateOfString("!@#!$"), str -> String.valueOf(10 + 10));					// 람다식 사용 가능
        CONDITION_MAP.put(predicateOfStream(Arrays.asList("티스토리", "네이버", "다음")), str -> "https://jnotebook.tistory.com/");

		// 람다식을 코드에 직접 선언하여 사용 가능
        Predicate<String> xmasPredicate = str -> LocalDate.of(2023, 12, 25).isAfter(LocalDate.now());
        Function<String, String> xmasFunction = str -> LocalDate.now().format(DateTimeFormatter.ISO_DATE);
        CONDITION_MAP.put(xmasPredicate, xmasFunction);
    }

    public String getResult(String message) {
        Optional<Map.Entry<Predicate<String>, Function<String, String>>> optional = CONDITION_MAP.entrySet().stream()
                .filter(entry -> entry.getKey().test(message))
                .findFirst();

        if (optional.isPresent()) {
            return optional.get().getValue().apply(message);
        }

		// default
        return "NO";
    }

    /**
     * 여러개의 문자열 중 일치(equals) 하는지 판별하는 식
     * @param strList 비교할 문자열 리스트
     * @return 일치하는 값이 있는지 판별하는 식
     */
    private static Predicate<String> predicateOfStream(List<String> strList) {
        return str -> strList.stream().anyMatch(str::equals);
    }

    /**
     * 단일 문자열 일치여부 판별하는 식
     * @param str 비교할 문자열
     * @return 일치여부를 판별하는 식
     */
    private static Predicate<String> predicateOfString(String str) {
        return message -> message.equals(str);
    }

    /**
     * 문자(name) 입력시 "name : {입력한 문자}}" 를 리턴하는 함수
     * @return "name : {name}" 함수
     */
    private static Function<String, String> getNameFunction() {
        return name -> "name : " + name;
    }
}

자주 사용하는 '패턴'의 식을 미리 정의하여 사용하거나 일회성으로 사용하는 함수식은 람다로 선언해서 사용

 

 

TEST

class RefactoringConditionTest {

    @Test
    void refactoringTest() {
        List<String> testMessages = Arrays.asList("BCD", "구글", "다음");

        testMessages.forEach(this::test);
    }

    private void test(String message) {
        Condition condition = new Condition();
        RefactoringCondition refactoringCondition = new RefactoringCondition();

        assertEquals(condition.getResult(message), refactoringCondition.getResult(message));
    }
}

 

 

Consideration

반복되던 if/else-if 의 조건문을 정리하긴 했는데... 상황을 가정해서 억지로 예시를 들다보니 이렇게 리팩토링 해야하는 동기가 부족해 보이기도 한다. 게다가 리팩토링 이후의 코드의 양은 더 많아지고 보기에 따라서 가독성이 더 떨어져 보일수도 있겠다는 생각이 든다. (굳이 이렇게까지 해서 리팩토링을 해야 했나... 라는 생각)

 

무조건적으로 if/else-if 를 피하고 Functional Interface 를 쓰라는 의도는 아니다. 다만, 비슷한 패턴의 조건식이 무수히 반복되고 코드가 종적으로 확장되는 경우 이렇게도 정리할 수 있다 라는 것을 보여주고 싶었다.