사용자의 요구는 항상 바뀔 수 있다. 이에 대응하기 위해 개발 비용을 최소화하는 것이 가장 좋다. 또한, 새로 추가한 기능은 쉽게 구현할 수 있어야 하며 유지보수가 쉬워야 한다.
동작 파라미터화를 이용하면 자주 바뀌는 요구사항에 효과적으로 대응할 수 있다. 동작 파라미터화는 어떻게 실행할 것인지 결정하지 않은 코드 블록으로, 나중에 프로그램에서 호출하게 된다. 코드 블록의 실행이 나중으로 미뤄지기 때문에 메소드의 인수로 코드 블록을 전달할 수 있고, 코드 블록에 따라 메소드의 동작이 파라미터화된다.
위와 같이 컬렉션이나 여러 곳에서 다양한 기능을 수행하도록 정의할 수 있게 되는 것이다. 동작 파라미터화를 추가할 때 쓸데없는 코드가 늘어난다는 단점이 있는데, 이는 람다 표현식으로 해결이 가능하다.
예제를 통해 동작 파라미터화가 필요한 이유와 코드의 개선 과정을 확인할 수 있다.
1장의 예제에서 농부가 녹색 사과만 필터링하는 프로그램을 요구했다고 가정하자.
다음과 같이 색을 정의하는 enum이 존재한다고 가정하자. 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 메소드를 만들어야할까? 잘못된 방법은 아니지만, 다른 색깔의 사과도 필터링을 요구했을 시 일일히 메소드를 만들 수는 없다. 거의 비슷한 코드가 반복 존재한다면 그 코드를 추상화한다.라는 좋은 규칙을 이용해서 메소드를 수정하면 된다.
색을 파라미터화할 수 있도록 메소드에 파라미터를 추가하면 요구사항에 유연하게 대처가 가능하다.
public static List<Apple> filterApplesByColor(List<Apple> inventory, Color color) {
List<Apple> result = new ArrayList<>();
for(Apple apple : inventory) {
if(apple.getColor().equals(color)) {
result.add(apple);
}
}
}
인수인 color의 값만 바꾸면 원하는 색깔의 사과를 필터링할 수 있다.
이번에는 농부가 무게를 기준으로 필터링을 요구한다. 다음과 같이 무게를 기준으로 필터링하는 함수를 만들 수 있다.
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);
}
}
}
구현 자체는 쉬우나 색깔을 기준으로 필터링하는 함수와 대부분이 중복된다. 이는 소프트웨어 공학의 DRY(Don't Repeat Yourself) 원칙에 어긋난다. 이번에는 색깔과 무게를 한 번에 필터링하는 플래그를 추가해보자.
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, true);
List<Apple> heavyApples = filterApples(inventory, null, 150, false);
호출 부분만 보면 flag 값을 이해할 수도 없고, 새로운 요구사항이 생겼을 때 유연하게 대처할 수도 없다. 이러한 상황에서 동작 파라미터화를 사용하면 유연하게 대처가 가능하다.
앞선 예제에서 파라미터를 추가하는 방법이 아니라 요구사항에 유연하게 대응할 수 있는 방법이 필요하다는 것을 알 수 있었다.
이번에는 사과의 속성에 따라 Boolean 값을 반환하는 함수(Predicate)를 인터페이스를 통해 구현하여 파라미터로 전달해보자.
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());
}
}
이러한 방식은 전략 디자인 패턴이라고 불리며, 각 알고리즘을 캡슐화하여 런타임에 알고리즘을 선택하는 기법이다.
ApplePredicate 인터페이스를 사용해 필터링을 하기 위해, 다음처럼 객체를 인수로 넘겨지게 변경할 수 있다.
public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
List<Apple> result = new ArrayList<>();
for(Apple apple : inventory) {
if(p.test(apple)) { // predicate 객체로 검사 조건을 캡슐화함
result.add(apple);
}
}
return result;
}
처음 코드에 비해 유연한 코드를 얻었고, 가독성도 좋아졌으며 사용하기도 쉬워졌다. 요구사항에 따라 ApplePredicate를 구현하는 클래스만 새롭게 만든다면 쉽게 필터링이 가능할 것이다.
결국 전달하는 ApplePredicate 객체에 따라 filterApples 메소드의 동작이 결정된 것인데, 이는 filterApples 메소드의 동작을 파라미터화한 것이다.
동작 파라미터화의 강점은 컬렉션 탐색 로직과 각 항목에 적용할 동작을 분리할 수 있다는 것이다. 이를 통해 한 메소드가 다른 동작을 수행하도록 재활용할 수 있는데, 유연한 API를 만들 때 중요한 역할을 한다.
인터페이스를 사용한 동작 파라미터화를 통해, 처음 코드보다는 좋은 코드를 만들 수 있었다.
public class AppleHeavyWeightPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}
하지만, 위 코드에서 중요한 부분은 return apple.getWeight() > 150;
한 줄 뿐이다. 나머지 부분은 클래스의 구현을 위해 작성한 부분으로 로직과는 관련이 없다.
익명 클래스는 말 그대로 이름이 없는 클래스로, 클래스의 선언과 인스턴스화를 동시에 수행할 수 있다.
List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
public boolean test(Apple apple) {
return RED.equals(apple.getColor());
}
});
하지만, 익명 클래스도 불필요한 코드가 존재하며 모호함이 존재한다는 단점이 있다. 익명 클래스 내부의 서로 다른 블록 레벨에서 같은 이름을 가진 변수를 사용하면 어떤 변수를 의미하는지 알기가 어렵다.
람다 표현식을 사용하면 이를 간단하게 재구현할 수 있다.
List<Apple> redApples = 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;
}
제네릭 타입으로 선언했기 때문에 정수, 문자열, 바나나, 오렌지 등 다양한 리스트에서 다음과 같이 사용할 수 있다.
List<Integer> evenNumbers = filter(numbers, (Integer i) -> i % 2 == 0);
List<String> longStrings = filter(strings, (String s) -> s.length() > 10);