소비자의 요구사항을 항상 바뀐다. 변화하는 요구사항 속 엔지니어링적인 비용이 가장 최소화되어야 하며, 새로 추가한 기능은 쉽게 구현할 수 있어야 하고, 장기적인 관점에서 유지보수가 쉬워야 한다.
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;
}
해결책: 거의 비슷한 코드가 반복 존재한다면 그 코드를 추상화한다.
두 번째 시도: 색을 파라미터화
색을 파라미터화할 수 있도록 메서드에 파라미터를 추가하면 변화하는 요구사항에 좀 더 유연하게 대응하는 코드를 만들 수 있습니다.
public static List<Apple> filterAppleByColor(List<Apple> inventory, Color color) {
List<Apple> result = new ArrayList<>();
for(Apple apple: inventory) {
if(apple.getColor().equals(color)) {
result.add(apple);
}
}
return result;
}
그런데 갑자기 농부가 다시 나타나서는 '색 이외에도 가벼운 사과와 무거운 사과로 구분할 수 있다면 좋겠네요. 보통 무게가 150그램 이상인 사과가 무거운 사과입니다'라고 요구합니다.
public static List<Apple> filterAppleByColor(List<Apple> inventory, int weight) {
List<Apple> result = new ArrayList<>();
for(Apple apple: inventory) {
if(apple.getWeight() > weight) {
result.add(apple);
}
}
return result;
}
색과 무게를 filter라는 메서드로 합치는 방법도 있지만, 어떤 기준으로 사과를 필터링할지 구분하는 또 다른 방법이 필요합니다. 따라서 색이나 무게 중 어떤 것을 기준으로 필터링할지 가리키는 플래그를 추가할 수 있습니다.
세 번째 시도: 가능한 모든 속성으로 필터링
public static List<Apple> filterAppleByColor(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;
}
형편없는 코드입니다. 대체 true와 false가 뭘 의미하는 걸까요? 게다가 앞으로 요구사항이 바뀌었을 때 유연하게 대응할 수도 없습니다.
public interface ApplePredicate {
boolean test(Apple apple);
}
public class AppleHeavyWeightPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}
public class AppleGreenColorPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return GREEN.equals(apple.getColor());
}
}
이렇게 동작 파라미터화, 즉 메서드가 다양한 동작(또는 전략)을 받아서 내부적으로 다양한 동작을 수행할 수 있습니다.
네 번째 시도: 추상화 조건으로 필터링
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;
}
filterApples 메서드의 새로운 동작을 정의하는 것은 test 메서드입니다. 안타깝게도 메서드는 객체만 인수로 받으므로 test 메서드를 ApplePredicate 객체로 감싸서 전달해야 합니다. 람다를 이용하면 여러 개의 ApplePredicate 클래스를 정의하지 않고도 표현식을 filterApples 메서드로 전달할 수 있습니다.
지금까지 동작을 추상화해서 변화하는 요구사항에 대응할 수 있는 코드를 구현하는 방법을 살펴봤다. 하지만 여러 클래스를 구현해서 인스턴스화하는 과정이 조금은 거추장스럽게 느껴질 수 있는데 이 부분을 어떻게 개선할 수 있는지 확인해보자.
인터페이스를 정의하고 여러 구현 클래스를 정의한 다음에 인스턴스화하는 작업은 상당히 번거로운 작업이며 시간 낭비입니다.
다섯 번째 시도: 익명 클래스 사용
List<Apple redApples = filterApples(inventory, new ApplePredicate() {
public boolean test(Apple apple) {
return RED.Equals(apple.getColor());
}
});
장황한 코드는 구현하고 유지보수하는 데 시간이 오래 걸릴 뿐 아니라 읽는 즐거움을 빼앗는 요소로, 개발자로부터 외면받습니다. 한눈에 이해할 수 있어야 좋은 코드입니다.
익명 클래스로 인터페이스를 구현하는 여러 클래스를 선언하는 과정을 조금 줄일 수 있지만 코드 조각을 전달하는 과정에서 결국은 객체를 만들고 명시적으로 새로운 동작을 정의하는 메서드를 구현해야 한다는 점은 변하지 않습니다. 람다 표현식을 이용해서 어떻게 코드를 간결하게 정리할 수 있는지 간단히 살펴보겠습니다.
여섯 번째 시도: 람다 표현식 사용
List<Apple> result = filterApples(inventory, (Apple apple) -> RED.equals(apple.getColor()));
public interface Predicate<T> {
boolean test(T t);
}
public static <T> List<T> filter(List<T> list, Predicate<T> p) {
List<T> result = new ArrayList<>();
for(T e: list) {
if(p.test(e)) {
result.add(e);
}
}
return result;
}