동작 파라미터화란?
우리가 어떤 상황에서 일을 하든 소비자 요구사항은 항상 바뀐다. 변화하는 요구사항은 소프트웨어 엔지니어링에서 피할 수 없는 문제다.
회사에서 매일 점심 메뉴를 정해야 하는데 매일 변하는 과장님의 입맛을 맞춰서 정해야 한다고 예를 들어보자.
월요일 - "주말에 과식을 했으니 칼로리가 적은 음식을 먹어야겠어"
화요일 - "어제는 칼로리가 적은 음식을 먹었으니 오늘은 푸짐한 음식을 먹고 싶어"
수요일 - "어제 퇴근하고 술을 먹어서 해장으로 국물이 있는 음식을 먹고 싶어"
목요일 - "스트레스를 받으니 매운 음식을 먹고 싶군"
금요일 - "오늘은 금요일이라 기뻐서 칼로리가 높은 음식을 먹을래!"
이렇게 자주 변화하는 요구사항에 동작 파라미터화(Behavior Parameterization)를 이용하면 효과적으로 대응할 수 있다.
동작 파라미터화란 아직은 어떻게 실행할 것인지 결정하지 않은 코드 블록을 의미한다. 이 코드 블록은 나중에 프로그램에서 호출된다. 즉, 코드 블록의 실행은 나중으로 미뤄지고, 나중에 실행할 메서드의 인수로 코드 블록을 전달할 수 있다.
이런 많은 알고리즘들을 거치는 로직을 구현할 때 간혹 아무렇지 않게 if문과 switch문을 쓰는 개발자분들이 존재한다.
public void getLunchMenu(String yoil) {
if (yoil.equals("Mon")) {
...
} else if (yoil.equals("Tue")) {
...
} else if ...
switch (yoil) {
case "Mon":
....
break;
case "Tue":
....
break;
...
}
}
이렇게 코드를 작성한다고 했을 때 지금은 처리하는 알고리즘이 몇 개뿐이지만, 변화하는 모든 알고리즘은 처리하기 위해선 코드의 양이 엄청나게 늘어나게 되고 가독성이 좋지 않아 유지보수에도 엄청난 비용을 쓰게 된다.
내 경험상으론 분명 나중에 엄청난 대가와 후회를 치르게 될 것이다....
그래서 동작 파라미터화는 어떻게 하는 건데?
자, 그럼 위에 과장님의 비위에 맞춰 매일 점심 메뉴를 정하는 알고리즘으로 동작 파라미터화를 구현해보자.
// 음식의 정보
@ToString
@Getter
@AllArgsConstructor
public class Food {
private String name; // 음식명
private int calorie; // 칼로리
private int gram; // 1인분 당 그램수
private int SHU; // 스코빌 지수(매운정도)
private boolean isSoup; // 국물 존재 여부
}
// 동작을 넘겨주기 위해 필요한 인터페이스
public interface FoodPredicate {
boolean test(Food food);
}
// 필요한 동작을 구현
public class LowCalorieFoodPredicate implements FoodPredicate {
@Override
public boolean test(Food food) {
return food.getCalorie() <= 300;
}
}
// 여러 음식중에 동작 조건에 맞는 음식을 필터링
public List<Food> filterFoods(List<Food> foods, FoodPredicate predicate) {
return foods.stream().filter(predicate::test).collect(Collectors.toList());
}
메서드는 객체만 인수로 받을 수 있기 때문에 FoodPredicate로 동작을 감싸서 전달해야 한다.
위에 예제 코드에서 FoodPredicate를 구현한 LowCalorieFoodPredicate은 음식이 300칼로리 이하인지 판단하는 동작을 구현하였다.
filterFoods 메서드는 인수를 FoodPredicate로 받아 test 메서드를 거쳐 필터링된 음식들은 반환한다. 즉, filterFoods 메서드의 동작을 파라미터화 한 것이다.
사용 시에는 매개변수 FoodPredicate에 LowCalorieFoodPredicate를 넘겨주면 foods를 LowCalorieFoodPredicate에서 구현한 동작으로 필터링하여 음식 목록을 반환한다.
List<Food> foods = List.of(
new Food("샐러드", 200, 200, 0, false),
new Food("왕돈까스", 700, 800, 0, false),
new Food("뼈해장국", 500, 400, 10, true),
new Food("닭발", 900, 400, 100, false),
new Food("햄버거", 1000, 400, 10, false)
);
List<Food> lowCalorieFoods = filterFoods(foods, new LowCalorieFoodPredicate());
System.out.println("lowCalorieFoods : " + lowCalorieFoods.toString());
// lowCalorieFoods : [Food{name='샐러드', calorie=200, gram=200, SHU=0, isSoup=false}]
이제 FoodPredicate를 상속받아 test 함수를 구현만 한다면 어떠한 요구사항도 맞출 수 있는데 필터링 기능이 생긴 것이다!
이제 과장님께 자신감 있게 요구사항을 받을 수 있을 것이다!!
이처럼 동작 파라미터화의 강점은 컬렉션 탐색 로직과 각 항목에 적용할 동작을 분리할 수 있다는 것이다.
따라서 동작 파라미터 화가 유연한 API를 만들 때 중요한 역할을 한다.
하지만, 모든 요구사항을 맞춰주기 위해선 그만큼에 FoodPredicate를 구현하고 인스턴스 화해야 한다.
이는 상당히 번거로운 작업이 될 수 있다. 이러한 문제들을 해결할 수 있는 방법을 아래에서 알아보자.
1. 익명 클래스를 사용하여 코드의 양을 줄이자.
익명 클래스를 이용하면 코드의 양을 줄일 수 있다.
익명 클래스는 클래스의 선언과 인스턴스화를 동시에 수행할 수 있도록 해준다. 위 예제에 익명 클래스를 적용해보자.
List<Food> foods = List.of(
new Food("샐러드", 200, 200, 0, false),
new Food("왕돈까스", 700, 800, 0, false),
new Food("뼈해장국", 500, 400, 10, true),
new Food("닭발", 900, 400, 100, false),
new Food("햄버거", 1000, 400, 10, false)
);
List<Food> plentifulFoods = filterFoods(foods, new FoodPredicate() {
@Override
public boolean test(Food food) {
return food.getGram() >= 800;
}
});
System.out.println("plentifulFoods : " + plentifulFoods.toString());
// plentifulFoods : [Food{name='왕돈까스', calorie=700, gram=800, SHU=0, isSoup=false}]
위 코드는 화요일의 요구사항인 푸짐한 음식(1인분에 800g 이상의 음식)을 필터링하는 동작을 익명 클래스로 구현한 것이다.
이렇게 익명 클래스를 사용하게 된다면 FoodPredicate를 구현하는 클래스를 계속해서 만들지 않아도 된다.
하지만 익명 클래스가 모든 것을 해결하는 것은 아니다.
익명 클래스는 여전히 많은 공간을 차지하고, 많은 프로그래머가 익명 클래스 사용에 익숙하지 않다.
코드의 장황함은 나쁜 특성이다. 장황한 코드는 구현하고 유지 보수하는 데 시간이 오래 걸릴 뿐 아니라 읽는 즐거움을 빼앗는 요소로, 개발자로부터 외면받는다. 한눈에 이해할 수 있어야 좋은 코드다.
조금 더 공간을 차지하지 않고 가독성 좋게 바꿀 수 없을까?
2. 람다 표현식을 사용하자.
자바 8의 람다 표현식을 이용해서 위 예제 코드를 다음처럼 간단하게 구현할 수 있다.
그럼 국물 음식을 원하시는 과장님의 수요일 요구사항을 람다 표현식을 이용해 구현해보자.
List<Food> foods = List.of(
new Food("샐러드", 200, 200, 0, false),
new Food("왕돈까스", 700, 800, 0, false),
new Food("뼈해장국", 500, 400, 10, true),
new Food("닭발", 900, 400, 100, false),
new Food("햄버거", 1000, 400, 10, false)
);
List<Food> soupFoods = filterFoods(foods, (Food food) -> food.isSoup);
System.out.println("soupFoods : " + soupFoods.toString());
// soupFoods : [Food{name='뼈해장국', calorie=500, gram=400, SHU=10, isSoup=true}]
이전 코드보다 훨씬 간단해지지 않았는가! 간결해지면서 문제를 더 잘 설명하는 코드가 되어 가독성도 좋아지게 되었다.
하지만 문제는 여전히 존재한다... 내가 보기엔 프로그래밍에서 문제는 없어지지 않는 것이다..
이제 점심 메뉴 요구사항을 들어주는 건 문제가 없다!!
회사원이 점심을 먹었으면 무엇을 하겠는가? 커피를 마시고 싶은 과장님의 요구사항도 들어줘야 한단다.. 이상한 과장님이다. (실제 얘기가 아니라 예를 들기 위해 지어낸 얘기 ㅋㅋ)
그렇다면 FoodPredicate처럼 CoffeePredicate를 만들어 구현하면 되지 않는가!?
맞다. 하지만 커피뿐만 아니라 요구사항에 맞춰야 하는 것이 수십 가지라면? 수십 번 만들 것인가?
이럴 땐 형식 파라미터 사용해 추상화하면 된다.
public interface Predicate<T> {
boolean test(T t);
}
public <T> List<T> filter(List<T> objs, Predicate<T> predicate) {
return objs.stream().filter(predicate::test).collect(Collectors.toList());
}
이렇게 필터링할 객체를 미리 지정하지 않고 해당 메서드를 사용할 때 설정할 수 있도록 하면 어떠한 객채라도 필터링이 가능해진다.
List<Food> foods = List.of(
new Food("샐러드", 200, 200, 0, false),
new Food("왕돈까스", 700, 800, 0, false),
new Food("뼈해장국", 500, 400, 10, true),
new Food("닭발", 900, 400, 100, false),
new Food("햄버거", 1000, 400, 10, false)
);
List<Food> spicyFoods = filter(foods, food -> food.getSHU() >= 100);
System.out.println("spicyFoods : " + spicyFoods);
// spicyFoods : [Food{name='닭발', calorie=900, gram=400, SHU=100, isSoup=false}]
List<Coffee> coffees = List.of(
new Coffee("아이스 아메리카노", "ICE", true),
new Coffee("아메리카노", "HOT", true),
new Coffee("디카페인 카페라떼", "HOT", false)
);
List<Coffee> deCaffeineCoffee = filter(coffees, coffee -> !coffee.isCaffeine);
System.out.println("deCaffeineCoffee : " + deCaffeineCoffee);
// deCaffeineCoffee : [Coffee{name='디카페인 카페라떼', temperature='HOT', isCaffeine=false}]
이제 객체마다 Predicate 인터페이스를 생성하지 않아도 된다.
'Java' 카테고리의 다른 글
[모던 자바] Java 8 API에서 지원하는 함수형 인터페이스 (0) | 2022.02.28 |
---|---|
[모던 자바] 람다란 무엇인가? (0) | 2022.02.28 |
JitPack을 이용한 Java 패키지 배포 (0) | 2021.12.10 |
Java로 Slack 메세지 발송하기 (0) | 2021.11.23 |
컬렉션 프레임워크(Collection Framework) (0) | 2021.09.30 |