배경
어떤 상황에서 일을 하든 소비자 요구사항은 항상 바뀐다.
변화하는 요구사항은 소프트웨어 엔지니어링에서 피할 수 없는 문제다. 이렇게 시시각각 변하는 사용자 요구사항에 어떻게 대응해야 할까?
특히 우리의 엔지니어링적인 비용이 가장 최소화될 수 있으면 좋을 것이다.
그뿐 아니라 새로 추가한 기능은 쉽게 구현할 수 있어야 하며 장기적인 관점에서 유지보수가 쉬워야 한다.
동작 파라미터화(behavior paramterization)를 이용하면 자주 바뀌는 요구사항에 효과적으로 대응할 수 있다.
동작 파라미터화란 아직은 어떻게 실행할 것인지 결정하지 않은 코드 블록을 의미한다.
이 코드 블록은 나중에 프로그램에서 호출한다. 즉, 코드 블록의 실행은 나중으로 미뤄진다.
변화에 대응하는 코드를 구현하는 것은 어려운 일이다.
일단 하나의 예제를 선정한 다음 예제코드를 점차 개선하면서 유연한 코드를 만드는 모범사례를 보여줄 것이다.
농장 재고목록 애플리케이션에 리스트에서 녹색사과만 필터링하는 기능을 추가한다고 가정하자.
첫 번째 시도
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;
}
녹색 사과를 조건문을 통해 필터링 하는 것을 볼 수 있다.
그런데 갑자기 농부가 변심하여 녹색 사과 말고 빨간 사과도 필터링하고 싶어졌다.
어떻게 고쳐야 할까? 크게 고민하지 않은 사람이라면 메서드를 복사해서 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;
}
List<Apple> greenApples = filterApplesByColor(inventory, GREEN);
List<Apple> redApples = filterApplesByColor(inventory, RED);
색을 파라미터화할 수 있도록 메서드에 파라미터를 추가하면 변화하는 요구사항에 좀 더 유연하게 대응하는 코드를 만들 수 있다.
그런데 갑자기 농부가 나타나서 '색 이외에도 가벼운 사과와 무거운 사과도 구분할 수 있다면 정말 좋겠네요. 보통 무게가 150그램 이상인 사과가 무거운 사과입니다.'라고 요구한다.
세 번째 시도
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);
모든 속성을 메서드 파라미터로 추가한 모습이다.
형편없는 코드다. 대체 true와 false는 뭘 의미하는 걸까? 게다가 앞으로 요구사항이 바뀌었을 때 유연하게 대응할 수도 없다.
다음은 동작 파라미터화를 이용해서 유연성을 얻는 방법을 설명한다.
네 번째 시도
동작 파라미터화
참 또는 거짓을 반환하는 함수를 Predicate라고 한다. 선택 조건을 결정하는 인터페이스를 정의하자.
public interface ApplePredicate {
boolean test (Apple apple);
}
다음 예제처럼 다양한 선택 조건을 대표하는 여러 버전의 ApplePredicate를 정의할 수 있다.
public class AppleGreenColorPredicate implements ApplePredicate{
@Override
public boolean test(Apple apple) {
return GREEN.equals(apple.getColor());
}
}
public class AppleHeavyWeightPredicate implements ApplePredicate{
@Override
public boolean test(Apple apple) {
return apple.getWeight() > 150;
}
}
위 조건에 따라 filter 메서드가 다르게 동작할 것이라고 예상할 수 있다. 이를 전략 디자인 패턴이라고 부른다.
전략 디자인 패턴은 각 알고리즘(전략이라 불리는)을 캡슐화하는 알고리즘 패밀리를 정의해둔 다음에 런타임에 알고리즘을 선택하는 기법이다.
ApplePredicate가 알고리즘 패밀리고 AppleHeavyWeightPredicate와 AppleGreenColorPredicate가 전략이다.
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;
}
첫 번째 코드에 비해 더 유연한 코드를 얻었으며 동시에 가독성도 좋아졌을 뿐 아니라 사용하기도 쉬워졌다.
이제 필요한 대로 다양한 ApplePredicate를 만들어서 filterApples 메서드로 전달할 수 있다.
지금까지 동작을 추상화해서 변화하는 요구사항에 대응할 수 있는 코드를 구현하는 방법을 살펴봤다.
하지만 여러 클래스를 구현해서 인스턴스화 하는 과정이 조금은 거추장스럽게 느껴질 수 있다.
자바는 클래스의 선언과 인스턴스화를 동시에 수행할 수 있도록 익명 클래스라는 기법을 제공한다.
익명클래스를 이용하면 코드의 양을 줄일 수 있다. 하지만 익명 클래스가 모든 것을 해결하는 것은 아니다.
다섯 번째 시도
익명 클래스
익명클래스는 자바의 지역 클래스(local class)와 비슷한 개념이다.
익명 클래스는 말 그대로 이름이 없는 클래스다. 익명 클래스를 이용하면 클래스 선언과 인스턴스화를 동시에 할 수 있다.
List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
@Override
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> inventory, Predicate<T> p) {
List<T> result = new ArrayList<>();
for (T e : inventory) {
if(p.test(e)) {
result.add(e);
}
}
return result;
}
이제 바나나, 오렌지, 정수, 문자열 등의 리스트에 필터 메서드를 사용할 수 있다.
List<Apple> result = filter(inventory, (Apple apple) -> RED.equals(apple.getColor()));
List<Integer> evenNumbers = filter(numbers, (Integer i) -> i % 2 == 0);
참고 자료 : https://ebook-product.kyobobook.co.kr/dig/epd/ebook/E000002942391
'백엔드 > Java' 카테고리의 다른 글
[Java] 스트림(Stream) (0) | 2023.05.11 |
---|---|
[Java] 람다(Lambda) (0) | 2023.05.09 |
[Java] 런타임 데이터 영역 (Runtime Data Area) (0) | 2023.05.05 |
[Java] 생성자에 매개변수가 많다면 빌더를 고려하라 (0) | 2023.04.20 |
[Java] 생성자 대신 정적 팩터리 메서드를 고려하라 (0) | 2023.04.20 |