시시각각 변하는 사용자 요구사항에 어떻게 대응할까? 특히 우리의 엔지니어링적인 비용이 가장 최소화되면 좋을 것이다. 그뿐 아니라 새로 추가한 기능은 쉽게 구현할 수 있어야 하며 장기적인 관점에서 유지보수가 쉬워야 한다.
『모던 자바 인 액션』, 67p
최근 프로젝트를 진행하면서, 수시로 바뀌는 요구사항들에 애를 먹은 적이 한 두번이 아니였다. 매일도 아닌 매 시간마다 바뀔 수 있는 요구사항들에 방대한 양의 코드들에서 해당 요구사항을 찾아 고치는 것과 그로 인한 사이드 이펙트에 대해 대처하기는 매우 힘들었다. 심지어 코드를 잘 찾지 못해 에러가 나면 그제야 어떤 코드를 고치지 않았는지 아는 경우도 많았다.
그래서 이러한 상황에서 어떻게 동작 파라미터화를 통해 비용 최소화 할 수 있을까? 동작 파라미터화는 무엇이고, 좋은 코드를 작성하는 것에 있어 무슨 관계가 있을까?
동작 파라미터화를 이용하면 자주 바뀌는 요구사항에 효과적으로 대응할 수 있다.
『모던 자바 인 액션』, 67p
어떻게 하면 동작 파라미터화가 자주 바뀌는 요구사항에 효과적으로 대응할 수 있을까?
그래서 동작파라미터화가 뭘까?
일반적으로 메서드는 값을 전달받아 특정한 작업을 수행하거나 결과를 반환한다.
하지만 동작 파라미터화를 하게 된다면 나중에 실행될 메서드의 인수로 코드블록을 전달할 수 있다. 결과적으로 코드 블록에 따라 메서드의 동작이 파라미터화된다.
동작 파라미터화란 아직은 어떻게 실행할 것인지 결정하지 않은 코드 블록을 의미한다.
쉬운 말로는 동작 파라미터화는 메서드의 동작을 인자로 전달하여 다양한 동작을 수행하는 코드를 작성하는 방식이다.
이렇게만 설명하면 도대체 무엇인지 이해가 되질 않았다.
하지만, 아래의 예제 상황에서 단계별 코드를 따라가다 보니 쉽게 이해할 수 있었다.
아래의 예제를 봐보자!
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
문의 조건을 바꾸는 방법을 생각할 수 있다.
위의 곰곰히 생각해보면 다양한 색의 사과를 필터링하고 싶다면 변화에 적절하게 대응할 수 있을까?
아닐 것이다. 필러링하고 싶은 색이 추가될 때마다 거의 비슷한 코드가 반복으로 존재하게 된다.
그렇다면 코드를 추상화해서 사용하면 되지 않을까?
어떻게 해야 코드의 반복을 사용하지 않고 다양한 색을 필터링 할 수 있을까?
아래의 코드를 보자.
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);
}
}
return result;
}
지금의 코드도 만족스럽겠지만, 갑자기 농부가 또 나타나선 ‘무게를 바탕으로 필터링 할 수 있는 기능이 존재하면 좋을 것 같아요.’ 라고 또다른 요구 사항을 추가한다.
150g을 기준으로 나누어 달라는데 생각해보면, 이 무게의 기준 또한 수시로 변하는 사항이 아닐까?
그래서 아래와 같이 코드를 작성했다.
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;
}
이 코드를 보고 있으면, 색을 기준으로 나눈 코드와 대부분이 중복된다. 만약 탐색과정을 고쳐서 성능을 개선하려고 한다면, 이러한 로직을 이용하는 모든 메서드를 찾아 전부 고쳐야 한다.
간단한 어플리케이션이면 모르겠지만, 실제 어플리케이션은 매우 복잡하고 방대한 양의 코드 때문에 찾기 힘들뿐더러, 코드를 재작성하면서 어떤 의도하지 않은 결과를 불러올지 모른다.
위의 단점을 생각해보았을 때, 색과 무게를 filter
라는 메서드로 합치는 방법을 생각해보았다.
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.getWeight() > weight) ||
(!flag && apple.getColor().equals(color))) {
result.add(apple);
}
}
return result;
}
// 아래와 같이 사용한다.
List<Apple> greenApples = filterApples(inventory, GREEN, 0, true);
형편없다. 이 코드를 처음 본다면 flag
는 무엇을 의미하는지 알 수 있을까? true
와 false
는 도대체 무엇을 의미하는 것일까? 과연 이러한 코드가 변경되는 요구사항을 대응할 수 있을까?
지금까지는 이 방법이 잘 동작할 수 있지만, 만약 녹색 사과 중에 무거운 사과를 찾고 싶다면? 사과의 모양, 크기로 필터링을 하고 싶다면? 문제가 잘 정의 되어 있지 않는 상황에서는 어떤 기준으로 사과를 필터링 할 것인지 효과적으로 전달할 수 있는 방법이 필요하다.
동작 파라미터화를 이용해서 이러한 상황에서 유연성을 얻을 수 있다 !
위의 코드를 작성하면서, 유연하게 대응할 수 있어야 한다는 것을 깨달았다.
고민해보면, 사과의 어떤 속성에 기초해서 불리언값을 반환하는 방법이 있다. 참 또는 거짓을 반환하는 함수를 프레디케이트라고 한다.
선택 조건을 결정하는 인터페이스를 정의하자.
//알고리즘 패밀리
public interface ApplePredicate {
boolean test(Apple apple);
}
// 전략 1: 무거운 사과만을 선택
public class AppleHeavyWeightPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}
//전략 2: 녹색 사과만 선택
public class AppleGreenColorPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return GREEN.equals(apple.getColor());
}
}
ApplePredicate
는 어떻게 다양한 동작을 수행할 수 있을까? filterApples
에서 ApplePredicate
객체를 받아 애플의 조건을 검사하도록 메서드를 고쳐야 한다.
이렇게 동작 파라미터화, 즉 메서드가 다양한 동작(또는 전략)을 받아서 내부적으로 다양한 동작을 수행할 수 있다.
따라서 추상적 조건으로 필터 메서드를 구현해보자.
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;
}
//전략: 무거운 빨간 사과를 필터
public class AppleRedAndHeavyPredicate implements ApplePredicate {
public boolean test(Apple apple) {
return RED.equals(apple.getColor()) && apple.getWright() > 150;
}
}
List<Apple> redAndHeavyApples = filterApples(inventory,
new AppleRedAndHeavyPredicate());
filterApples
메서드의 동작을 파라미터화했다.
예제를 따라오면서 유연한 대응에 대해 생각하면서 코드를 설계해보았다. 처음 코드와 비교하면 매우 간결하고, 멋진 코드가 완성되었다.
또한 동작 파라미터화에 대해 알게 되었다. 어떤 동작을 할지 파라미터(인수)로 동작에 대한 객체를 넘기면 해당 전략에 따라 동작하게 된다. 즉, 한개의 파라미터로 여러 개의 동작을 할 수 있게 된다!
앞서 본 예제에 따르면 동작 파라미터화의 강점은 컬렉션 탐색 로직과 각 항목에 적용할 동작을 분리 할 수 있다는 것이다. 따라서, 한 메서드가 다른 동작을 수행하도록 재활용할 수 있다.
따라서 유연한 API를 만들 때 동작 파라미터화가 중요한 역할을 한다.
하지만 이 많은 구현 클래스를 하나씩 만들고 인스턴스화 해야하는 과정은 거추장스럽게 느꺼질 수 있다.
익명 클래스는 자바의 지역 클래스(블록 내부에 선언된 클래스)와 비슷한 개념이다. 익명 클래스는 말 그대로 이름이 없는 클래스로, 클래스 선언과 인스턴스화를 동시에 할 수 있다.
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;
}
//사용 예제
List<Apple> redApples =
filter(inventory, (Apple apple) -> RED.equals(apple.getColor()));
List<Integer> evenNumbers =
filter(numbers, (Integer i) -> i % 2 == 0);
이제 바나나, 오렌지, 정수, 문자열 등의 리스트에 필터 메서드를 사용할 수 있게 되었다 !
위의 예제 코드와 처음 예제 코드를 비교하면 얼마나 간단 명료하면서 간결한 코드인지 알게 된다.
자바 8이 아니면 불가능했다.
책에는 많은 실전 예제가 있지만 한 가지만 해보자.
컬렉션을 정렬하는 일은 반복되는 작업이다. 변화하는 요구사항에 쉽게 대응할 수 있는 방법이 없을까?
자바 8의 List
에는 sort
메서드가 포함되어 있다. java.util.Comparator 객체를 이용해서 sort
의 동작을 파라미터화, 즉 동작 파라미터화를 통해 유연하게 대처할 수 있다.
// java.util.Comparator
public interface Comparator<T> {
int compare(T o1, T o2);
}
// 예제1: 익명 클래스를 이용해서 정렬
inventory.sort(new Comparator<Apple>() {
public int compare(Apple a1, Apple a2) {
return a1.getWright().compare(a2.getWeight());
}
});
// 예제2: 람다 표현식을 사용해서 예제1을 더 간단하게 구현
inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
위의 예제를 통해 대표적으로 아래의 항목에 대해 깨닫게 되었다.
동작 파라미터화가 무엇인지
동작 파라미터화가 가져다 주는 장점이 무엇인지
앞서 소개한 자바 8이 혁신적인 이유 한 가지가 무엇인지
익명 클래스와 람다 표현식의 간단한 예제로 어떻게 코드를 간단명료하게 짤 수 있는지
처음 읽었을 때는 무슨 내용이 이렇게 어려운지, 이해가 되지 않았지만, 학습 내용을 정리하고 곱씹어 읽어보면서 점차 이해를 하게 되는 것 같다.
최근 들어 프로젝트를 하면서 문서로만 보았던 객체지향적인 설계를 위한 인터페이스의 활용이나 유지보수가 쉬운 코드에 대한 고민을 실제로 해보면서 많은 혼란이 왔었다. 객체지향적으로 설계했다고 생각한 코드가 한 번의 요구사항 변경으로 모든 계층의 코드를 고칠 때도 있었고, 인터페이스나 추상 클래스, 익명 클래스, 람다, 스트림을 간단하게 사용하면서 왜 사용되는지에 대해 의문을 가질 때가 많았다.
아직 모던 자바 인 액션의 초반이지만, 람다와 스트림 그리고 다양한 객체지향적인 설계의 활용의 예제를 간단하게 진행해보면서 왜 중요한지, 어떻게 생각하며, 어떻게 활용하는지 정말 충격받았다. 이전의 나의 코드가 예제와 겹쳐보였다. 부족한 점이 아직 많다는 것을 많이 느꼈다.
실제 프로젝트에도 적용해보면서 학습에 더욱 정진해야겠다 !
잘 봤습니다. 좋은 글 감사합니다.