Study/Modern Java in Action

#.1 동작 파라미터화

voider 2021. 8. 1. 21:53

1. 동작 파라미터화

동작 파라미터화(behavior parameterization)란 아직은 어떻게 실행할지 결정하지 않은 코드 블록을 의미한다. 이 코드 블록은 나중에 프로그램에서 호출한다. 즉, 코드 블록의 실행을 나중으로 미룬다. 예를 들어 나중에 실행될 메서드 인수로 코드 블록을 전달할 수 있다. 결과적으로 코드 블록에 따라 메서드의 동작이 파라미터화된다. 예를 들어 컬렉션을 처리할 때 다음과 같은 메서드를 구현한다고 가정하자.

  • 리스트의 모든 요소에 대해 '어떤 동작'을 수행할 수 있음
  • 리스트 관련 작업을 끝낸 다음, '어떤 다른 동작'을 수행할 수 있음
  • 에러가 발생하면 '정해진 어떤 다른 동작'을 수행할 수 있음

동작 파라미터화로 이처럼 다양한 기능을 수행할 수 있다.

1.1 변화하는 요구사항에 대응하기

변화에 대응하는 코드를 구현하는 것은 어렵다. 일단 하나의 예제를 점진적으로 개선하면서 유연한 코드를 만드는 모범사례를 살펴본다. 기존 농장 재고목록 애플리케이션 리스트에 녹색 사과만 필터링하는 기능을 추가한다고 가정하자.

1.1.1 첫 번째 시도 : 녹색 사과 필터링

사과 색을 정의하는 다음과 같은 Color num이 존재한다.

enum Color { RED, GREEN }

다음은 첫 번째 시도다.

public static List<Apple> filterGreenApples(
    List<Apple< inventory
){
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if(**GREEN.equals(apple.getColor())**) {
            result.add(apple);
        }
    }
    return result;
}

굵게 표시한 행의 코드는 녹색 사과를 선택하는 데 필요한 조건이다. 그런데 갑자기 농부가 변심하여 녹색 사과 말고 빨간사과도 필터링하고 싶어졌다면?

가장 간단한 방법은 위 메서드를 복사해서 filterRedApples() 메서드를 하나 만든 다음, if문 조건만 바꿔주면 된다. 하지만 농부가 더 다양한 색, 옅은 녹색이나 어두운 빨간색, 노란색 등으로 필터링하는 등 변화에 유연하게 대응할 수 없다. 이런 상황에서는 다음과 같은 규칙이 있다.

거의 비슷한 코드가 반복 존재한다면 그 코드를 추상화한다.

1.1.2 두 번째 시도 : 색을 파라미터화

메서드에 색Color 파라미터를 추가하면 위 메서드의 코드를 반복하지 않으면서 변화하는 요구사항에 좀 더 유연하게 대응하는 코드를 만들 수 있다.

public static List<Apple> filterApplesByColor(
    List<Apple> inventory, Color color
) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if(apple.getColor().equals()) {
            result.add(apple);
        }
    }
    return result;
}

이렇게 하면 다음처럼 구현한 메서드를 호출할 수 있다.

List<Apple> greenApples = filterApplesByColor(inventory, GREEN);
List<Apple> redApples = filterApplesByColor(inventory, RED);

그런데 농부가 '색 이외에 가벼운 사과와 무거운 사과(150그램 이상)로 구분하고 싶다'고 한다면 어떨까?

농부의 요구사항은 언제든 바뀔 수 있다. 그래서 다음 ㅗ드에 확인할 수 있는 것처럼 바뀔 수 있는 다양한 무게에 대응할 수 있도록 무게 정보 파라미터도 추가했다.

public static List<Apple> filterApplesByWeight(
    List<Apple> inventory,
    int weight
) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if(**apple.getWeight() > weight**) {
            result.add(apple);
        }
    }
    return result;
}

위 코드도 좋은 해결책일 수 있지, 구현 코드를 자세히 보면 목록을 검색하고, 각 사과에 필터링 조건을 적용하는 부분의 코드가 색 필터링 코드와 대부분 중복이다. 이것은 소프트웨어 공학에서 소위 말하는 DRY(dont't repeat yourself) 위반이다. 탐색 과정을 고쳐서 성능을 개선하려면 한 줄이 아니라 메서드 전체 구현을 고쳐야 한다.

색과 무게를 filter라는 메서드로 합치는 방법도 있다. 그러면 어떤 기준으로 사과를 필터링할 지 구분하는 또 다른 방법이 필요하다. 플래그를 둘 수도 있겠지만 이 방법은 추천하지 않는다(고 한다).

1.1.3 세 번째 시도 : 가능한 모든 속성으로 필터링

다음 코드는 만류에도 불구하고, 모든 속성을 메서드 파라미터로 추가한 모습이다.

public static List<Apple> filterApples(
    List<Apple> inventory, 
    Color color, 
    int weight,
    boolean flag
) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if((flag && apple.getColor().equals(color))
                    || (!flag && apple.getWeight() > weight)) {
            result.add(apple);    
        }
    }
    return result;
}

아래 처럼 위 메서드를 사용할 수 있다.

List<Apple> greenApples = filterApples(inventory, GREEN, 0, false);
List<Apple> heavyApples = filterApples(inventory, null, 150, false);

이런 코드는 대체로 형편없다. 도대체 true와 false는 뭘 의미하는지 알 수가 없다. 뿐만 아니라 요구사항이 변경되었을 때 유연하게 대처할 수도 없다. 이를 테면 사과의 크기, 모양, 출하지 등으로 사과를 필터링하고 싶다면? 또는 녹색 사과 중에 무거운 사과를 필터링하고 싶다면 어떨까? 결국 중복된 필터 메서드를 만들거나 모든 것을 처리하는 거대한 하나의 필터 메서드를 구현해야 한다.

지금까지는 정수, 문자열, 불리언 등으로 filterApples 메서드를 파라미터화 했다. 문제가 잘 정의되어 있는 상황에서 이것은 큰 문제가 되지 않을 수 있다. 하지만 filterapples에 어떤 기준으로 사과를 필터링할 것인지 효과적으로 전달할 수 있다면 더 좋을 것이다. 이것을 동작 파라미터화로 해결할 수 있다.

1.2 동작 파라미터화

참 또는 거짓을 반환하는 함수를 Predicate라고 한다. 선택 조건을 결정하는 인터페이스를 먼저 정의한다.

public interface ApplePredicate {
    boolean test(Apple apple);
}

다음 예제처럼 다양한 조건을 대표하는 여러 버전의 ApplePredicate를 정의할 수 있다.

public class AppleHeavyWeightPredicate implements ApplePredicate {
    public boolean test(Apple apple) {
        return appl.getWeight > 150;
    }
}

public class AppleGreenColorPredicate implements ApplePredicate {
    public boolean test(Apple apple) {
        return GREEN.equals(apple.getColor());
    }
}

위 조건에 따라 filter 메서드가 다르게 동작할 것이라고 예상할 수 있다. 이를 전략 디자인 패턴이라고 부른다. 전략 디자인 패턴은 각 알고리즘을 캡슐화하는 알고리즘 패밀리(?)를 정의해둔 다음, 런타임에 알고리즘을 선택하는 기법이다. 예제에서는 ApplePredicate 가 알고리즘 패밀리고, AppleHeavyWeightPredicate 가 전략이다.

그런데 ApplePredicate는 어떻게 다양한 동작을 할 수 있을까? filterApples에 ApplePredicate 객체를 받아 애플의 조건을 검사하도록 메서드를 고쳐야 한다. 이렇게 동작 파라미터화, 즉 메서드가 다양한 동작(또는 전략)을 받아서 내부적으로 다양한 동작을 수행할 수 있다.

이제 filterApples 메서드가 ApplePredicate 객체를 인수로 받도록 수정할 것이다. 이렇게 하면 filterApples 메서드 내부에서 컬렉션을 반복하는 로직과 컬렉션의 각 요소에 적용할 동작을 분리할 수 있다.

1.2.1 네 번째 시도 : 추상적으로 조건 필터링

public static List<Apple> filterApples(
    List<Apple> inventory,
    ApplePredicate p) {
    List<Apple> result = new ArrayList<>();
    for(Apple apple : inventory) {
        if(p.test(apple)) // 프레디케이트 객체로 사과 검사 조건을 캡슐화
            result.add(apple);
    }
    return result;
}

코드/동작 전달하기

첫 번째 시도에 비해 훨씬 유연하면서도 가독성 좋고 사용하기도 쉬워졌다. 이제 필요한 대로 ApplePredicate를 만들어서 filterApples 메서드로 전달할 수 있다. 만약 농부가 150g이 넘는 빨간 사과를 필터링 해달라고 요청하면 ApplePredicate를 적절하게 구현하면 된다.

public class AppleRedAndHeavyPredicate implements ApplePredicate {
    public boolean test(Apple apple) {
        return RED.equals(apple.getColor()) && apple.getWeight > 150;
    }
}

List<Apple> redAndHeavyApples = 
        filterApples(inventory, new AppleRedAndHeavyPredicate());

위 그림에서 보는 것처럼 위 예제에서 가장 중요한 구현은 test() 메서드다. filterApples 메서드의 새로운 동작을 정의하는 것이 test 메서드다. 메서드는 객체만 인수로 받으므로 test 메서드를 ApplePredicate 객체로 감싸서 전달해야 한다. test 메서드를 구현하는 객체를 이용하여 불리언 표현식 등을 전달할 수 있다. 이것은 코드를 전달하는 것과 다름 없다.

한 개의 파라미터, 다양한 동작

지금까지 살펴본 것처럼, 컬렉션 탐색 로직과 각 항목에 적용할 동작을 분리할 수 있다는 것이 동작 파라미터화의 강점이다. 아래 그림에서 볼 수 있는 것처럼 한 메서드가 다른 동작을 수행하도록 재활용할 수 있다.

1.3 여섯 번째 시도 : 람다식 사용

매번 새로운 Predicate가 추가될 때마다 ApplePredicate 인터페이스를 구현하는 일은 매우 수고로운 일이다. 익명 클래스를 쓸 수도 있겠지만 람다 이후 거의 사용되지 않으니 생략한다. 람다식을 사용하여 인터페이스를 구현하는 클래스를 만들지 않고 test메서드를 구현할 수 있다.

List<Apple> result =
    filterApples(inventory, apple -> RED.equals(apple.getColor());

위에서 했던 값 파라미터화가 매우 뻣뻣하고 장황한데 반해ㅡ 동작 파라미터화는 유연하다.

1.4 일곱 번째 시도 : 리스트 형식으로 추상화

public interface Pedicate<T> {
    boolean test(T t);
}

public static <T> List<T> filter(List<T> list, Pedicate<T> p) {
    List<T> result = new ArrayList<>();
    for (T e : list) {
        if(p.test(e))
            result.add(e);
    }
    return result;
}

이제 바나나 오렌지, 정수, 문자열 등 리스트에 필터 메서드를 사용할 수 있다. 다음은 람다식을 사용한 예제다.

List<Apple> redApples = filter(inventory, apple -> RED.equals(apple.getColor));
List<Integer> evenNumbers =
    filter(numbers, i -> i % 2 == 0);

정리

  • 동작 파라미터화에서는 메서드 내부적으로 다양한 동작을 수행할 수 있도록 코드를 메서드 인수로 전달한다.
  • 동작 파라미터화를 이용하면 변화하는 요구사항에 더 잘 대응할 수 있는 코드를 구현할 수 있으며, 나중에 엔지니어링 비용을 줄일 수 있다.
  • 코드 전달 기법을 이용하면 동작을 메서드의 인수로 전달할 수 있다.