모던자바인액션 Chapter 2_동작 파라미터화 코드 전달하기

woply·2022년 9월 2일
0

Modern Java In Action

목록 보기
2/7

Chapter 2 미리보기

동작 파라미터화를 기반으로 하는 람다 표현식의 등장으로 변화하는 요구사항에 더 유연하고 빠른 대응이 가능해졌다. 모던자바인액션 2장의 전반부는 동작을 메서드의 인수로 전달하는 방식이 어떻게 발전되어 왔는지 살펴보고, 후반부에서는 몇 가지 예제로 이해를 돕는다. 예제는 함수형 인터페이스에 람다 표현식을 적용하였을 때 얼마나 요구사항 변화에 유연하게 대응할 수 있는지 설명한다.

요구사항에 유연하다는 의미

소프트웨어는 변화하는 환경에 의해 지속해서 요구사항이 변경될 수 있다. 언제 어떻게 변할지 모르는 사용자의 요구를 유연하게 수용하고 빠르게 적용하기 위해서는 어떤 소프트웨어 엔지니어링 기법이 필요할까? 아마도 가장 비용이 적게 들이면서 요구사항을 반영할 수 있다면 가장 좋을 것이다.소프트웨어에서의 비용이란 곧 시간을 의미한다. 새로운 요구사항을 프로그래밍으로 구현하는 것이 간단할수록 유지보수가 쉬워지고, 결과적으로 비용이 줄어든다.

자바는 이에 대한 해결책을 동작 파라미터화에서 찾았다. 동작 파라미터화란 구체적인 실행 로직이 결정되지 않은 추상화된 코드 블록에 동작을 전달하는 것을 의미한다. 메서드가 다양한 동작을 인수로 받아 내부적으로 캡슐화되어 있는 메서드 시그니처에 따라 다양한 동작을 수행한다. 실제 코드 블록은 런타임 환경에서 호출되기 때문에 결과적으로 메서드에 어떤 동작을 전달하는지에 따라 실행될 코드가 결정된다. 따라서 동작 파라미터를 이용하면 컬렉션과 같은 일련의 데이터 묶음을 아래와 같이 처리할 수 있다.

  • 리스트의 모든 요소에 대해서 '어떤 동작'을 수행할 수 있음
  • 리스트 관련 작업을 끝낸 다음에 '어떤 다른 동작'을 수행할 수 있음
  • 에러가 발생하면 '정해진 어떤 다른 동작'을 수행할 수 있음

중요한 점은 위에 언급한 '어떤 동작'을 매번 새롭게 정의하여 실행할 수 있다는 것이다. '어떤 동작'이라는 추상화된 코드 블럭만 선언이 되어 있고, 실제 동작할 '어떤 동작'의 상세한 로직은 실행 시점에 전달되기 때문에 요구사항이 변하였을 때 유연하게 대응할 수 있다. 실제 구현해야 할 세부 로직만 수정하여 전달하면 되기 때문이다.


단계적 개선을 통해 더 유연하고 간결한 코드 만들기

첫 번째 개선: 요구사항이 변경되면 조건문을 수정한다

동작 파라미터화를 추가하려면 쓸데없는 코드가 늘어난다는 단점이 있다. 자바 8에서는 람다 표현식을 이용해 이 문제를 해결했다. 동작 파라미터화의 가장 기본적인 패턴부터 람다 표현식 까지 어떤 과정을 거쳐 코드 개선이 가능한지 살펴보자.

이 예제에서는 농장 재고를 관리하는 애플리케이션이 있다고 가정한다. 리스트에서 녹색(Green) 사과만 필터링하는 기능을 추가해보자. 가장 단순하고 직관적인 형태로는 반복문과 조건문을 이용해 Color enum으로 조건에 일치하는 객체만 필터링하는 방법일 것이다.

enum Color { RED, GREEN }
public static List<Apple> filterGreenApples(List<Apple> inventory) {
	List<Apple> result = new Arraylist<>(); // 녹색 사과만 필터링하여 담을 List를 선언한다
	for (Apple apple: inventory) {
		if (GREEN.equals(apple,getColor()) { // 조건문을 이용해 Color.Green이 일치하는 객체만 선별한다.
                    result.add(apple);
		}
	}
	return result;
}

이때 필터링 조건을 Green Apple에서 Red Apple로 변경하려면 어떻게 코드를 수정해야 할까? 단순한 방법으로는 filterRedApples라는 새로운 메서드를 만들고, if문의 조건을 빨간 사과로 바꾸는 방법을 선택할 수 있다. Green Apple과 Red Apple은 if문의 조건만 다르고 나머지 코드가 거의 유사하다. 이처럼 반복되는 코드가 존재한다면 추상화를 통해 중복을 제거할 수 있다. 다음 단계로 넘어가보자.

두 번째 개선: 추상화를 통해 색을 파라미터화 한다.

filterGreenApples과 filterRedApple은 많은 코드가 중복된다. 유일하게 달라지는 부분은 메서드의 이름과 조건에 해당하는 사과의 색이다. 이때 변경이 필요한 색은 파라미터로 받을 수 있게 처리하고, 나머지 코드를 공통화하는 방법으로 요구사항에 더 유연한 코드를 구현할 수 있다.

public static List<Apple> filterApplesByColor(List<Apple> inventory, Color color) {
    List<Apple> result = new Arraylist<>() ; 
    for (Apple apple: inventory) {
        if (apple.getColorO .equals(color)) {
            result.add(apple);
        }
    }
    return result;
}

필터 조건을 파라미터로 직접 받으면 매번 새로운 메서드를 만들지 않고도 파라미터 전달 값을 통해 요구사항을 충족시킬 수 있다. 하지만 Color 타입이 아닌 무게나 품종 등을 기준으로 새로운 필터링 작업이 필요하다면 어떻게 해야 할까? 색을 파라미터로 전달하는 방식만으로는 더욱 폭 넓은 요구조건을 충족하기 어려울 것이다. 새로운 필터링 조건이 등장할 때마다 filterApplesByWeight(List inventory, int weight) 나 filterApplesByKind(List inventory, Kind kind) 와 같은 메서드를 만드는 것도 하나의 방법일 것이다. 하지만 Green Apple과 Red Apple을 필터링하는 메서드에서 중복 코드가 발생한 것처럼, 이 또한 필터 조건을 제외한 나머지 부분은 중복된 코드가 발생하게 된다.

문제가 잘 정의되어 있는 경우라면 여러가지 필터 조건을 파라미터로 전달하는 것도 하나의 방법일 수 있다. 하지만 변화하는 요구사항에 맞춰 유연하면서도 엔지니어링 비용이 적게 드는 방법을 선택한다면 한 단계 더 추상화가 필요하다. 이때 함수형 인터페이스의 사용은 좋은 방법이 된다. 사과를 필터링하는 예제의 경우 Predicate 인터페이스를 사용할 수 있다. Predicate는 test() 메서드의 구현체 로직에 따라 boolean을 반환하는 인터페이스다.

public interface ApplePredicate {
	boolean test (Apple apple);
}

predicate 인터페이스를 사용하면 다양한 필터 로직을 수행하는 클래스를 구현할 수 있다. 컬렉션을 생성하고 필터를 통과하는 객체만 컬렉션에 추가하는 공통 로직을 추상화하여 코드를 더 줄일 수 있다.

public class AppleHeavyWeightPredicate implements ApplePredicate {
	public boolean test(Apple apple) { 
		return apple.getWeight() > 150;
	}
}
public class AppleHeavyWeightPredicate implements ApplePredicate {
	public boolean test(Apple apple) { 
		return GREEN.equals(apple.getColor());
	}
}

위와 같은 방식을 전략 디자인 패턴(strategy design pattern)이라고도 부른다. 전략 디자인 패턴은 전략(실제 로직에 해당하는 코드)을 캡슐화하는 추상화된 패밀리를 먼저 정의해두고, 실제 전략은 런타임에 결정된다. 위의 예제에서는 ApplePredicate가 패밀리에 해당하고, AppleHeavyWeightPredicate와 AppleHeavyWeightPredicate의 필터 로직이 전략이 된다.

세 번째 개선: 파라미터를 이용해 조건 구현체를 전달한다.

앞서 예시로 들었던 filterApples()에 파라미터로 ApplePredicate를 해보자. 필터 조건을 파라미터로 직접 받았던 filterApples 방식과 큰 차이가 없어보일 수 있으나, 필터 로직을 test()로 추상화했다는 점이 가장 큰 차이다.

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;
}

필요에 따라 필터 로직을 test()로 캡슐화하는 구현체만 만들어주면 되기 때문에 파라미터로 필터 조건이나 타입을 직접 받는 방식보다 변화에 유연하다. 무게가 150 이상은 빨간색 사과를 필터링하는 ApplePredicate 구현체를 예시로 살펴보자. 아래와 같이 ApplePredicate를 이용하여 필요한 로직을 test()에 캡슐화하고 있는 구현체만 만들면 된다. 컬렉션을 탐색하고 필터를 통과한 객체를 다시 컬렉션에 담는 로직과 적용할 동작(test 메서드)를 분리했기 때문에 위에 만든 filterApples은 전혀 수정할 필요가 없다. 필요에 따라 인수로 전달하는 ApplePredicate만 바꿔주면 되기 때문에 객체지향 원칙 중 하나인 OCP(Open-Closed Principle)를 충족한다.

public class AppleRedAndHeavyPredicate implements ApplePredicate {
	public boolean test(Apple apple) {
		return RED.equals(apple.getColor()) 
			&& apple.getWeight() > 150;
	}
}
// filterApples 수정 없이 인수로 전달하는 구현체만 변경하면 된다.
List<Apple> redAndHeavyApples = filterApples(inventory, new AppleRedAndHeavyPredicate());

이와 같이 추상화된 인터페이스를 파라미터로 받는 메서드에 필요한 구현체 로직(동작)을 전달하는 방식을 동작 파라미터화라고 한다. 이미 많은 개선이 이루어졌지만, 여전히 ApplePredicate의 구현 클래스를 매번 만들어서 인수로 전달해야한다는 불편함이 남아있다. 이 때, 람다를 이용한다면 더 간결하면서도 유연한 코드 구현이 가능하다.

사실 ApplePredicate를 사용하는 위의 방식과 구조 자체는 동일하다고 볼 수 있으나, 람다 표현식을 이용하면 test()에 해당하는 로직만 심플하게 전달하는 방식으로 구현체 생성에 필요한 부수적인 코드를 생략할 수 있다. 함수형 인터페이스는 단 하나의 추상 메서드만 가지고 있기 때문에 람다 표현식을 통해 ApplePredicate 구현체의 인스턴스 생성과 동작을 정의하는 test() 전달이 가능하다.

네 번째 개선: 람다 표현식을 적용한다.

ApplePredicate 구현 클래스를 생성하는 수고 줄이는 방법으로, 클래스 선언과 인스턴스화를 동시에 할 수 있는 익명 클래스를 사용하는 방법도 있다. 하지만, 람다 표현식은 더 간결한 표현식으로 익명 클래스를 생성하고 인스턴스화하여 인자로 전달한다. filterApples은 이미 ApplePredicate 인터페이스를 인자로 받고 있기 때문에 아래와 같이 바로 람다 표현식을 사용하는 것이 가능하다.

List<Apple> result = filterApples(inventory, (Apple apple) -> RED.equals(apple.getColor()));

여기에 형식 파라미터 T를 이용하면 사과 이외에 다양한 형식으로 활용하는 것도 가능해진다.

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;
}
// 사과뿐만 아니라, Integer의 필터링에도 사용이 가능하다

List<Apple> redApples = filter(inventory, (Apple apple) -> RED.equals(apple.getColor()));
List<Integer> evenNumbers = filter(numbers, (Integer i) -> i %2 == 0);

람다 표현식을 적용할 수 있는 다양한 함수형 인터페이스 예제

java.util.Comparator 객체를 이용해서 sort의 동작을 파라미터화 할수있다.
Comparator를 구현해서 sort 메서드의 동작을 다양화할 수 있다.

public interface Comparator<T> {
int compare(T o1, T o2);
}

익명 클래스를 이용해 무게가 적은순서로 목록에서 사과를 정렬하는 예시 코드를 만들어보자.Comparator를 구현해서 sort 메서드의 동작을 다양화할 수 있다.

inventory.sort(new Comparator<Apple>() {
    public int compare(Apple a1, Apple a2) {
        return a1 _getWeight().compareTo(a2.getWeight());
    }
});

람다를 이용한다면 아래와 같이 더 간단하게 코드 구현이 가능하다.

inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2_getWeight()));

농부의 요구사항이 바뀌면 람다 표현식을 이용해 Comparator 인터페이스의 구현 메서드만 변경할 수 있다. 필요한 기능을 추상화하고 있기 때문에 반환값만 확인하면 된다.

*참고로 compareTo() 메서드는 Integer와 String 모두 가지고 있다. 따라서 "문자열의 비교" 와 "숫자의 비교"가 모두 가능하다. 어떤 경우든지 필요한 기능을 추상화하고 있기 때문에 반환값만 확인하면 된다. 숫자의 비교 같은 경우는 단순히 크다(1), 같다(0), 작다(-1) 의 관한 결과값을 리턴한다. 문자열의 경우 문자의 순서가 동일할 경우 길이의 차이를 숫자로 반환하고, 위치가 다를 경우 아스키 값을 기준으로 비교처리한 숫자를 반환한다.

동일한 패턴을 가진 예제로 Runnable 인터페이스도 살펴보자. Comparator 인터페이스와 마찬가지로 Runnable 역시 단 하나의 추상 메서드를 가지고 있는 함수형 인터페이스다. 자바 8 이전에는 스레드를 이용해 병렬로 코드 블록을 실행하려면 결과를 반환하지 않는 void run 메서드를 포함하는 익명 클래스를 사용했다. 생성자에 객체만 전달할 수 있었기 때문이다.

자바 8 이후 동작을 파라미터로 전달할 수 있게 되면서 아래와 같이 Thread 생성자에 Runnable 익명 클래스를 전달하면 다양한 동작을 Tread로 실행할 수 있다.

public interface Runnable {
	void run();
}
Thread t = new Thread(new Runnable() {
    public void run () {
        System.out.println("Hello world");
    }
});

Comparator 예제와 마찬가지로 람다를 이용해 익명 클래스를 더 단순화 할 수 있다. 함수형 인터페이스는 추상 메서드를 단 하나만 가지고 있기 때문에 람다로 전달하는 파라미터와 동작 코드는 추상 메서드의 구현체로 사용 된다.

Thread t = new Thread(() -> System.out.println("Hello world"));

ExecutorService 인터페이스와 Callable 함수형 인터페이스를 사용하는 예제를 하나 더 살펴보자. ExecutorService 인터페이스는 태스크 제출과 실행 과정의 연관성을 끊어주는 기능을 가지고 있다. ExecutorService를 이용하면 태스크를 스레드 풀로 보내고 결과는 Future로 저장한다. 이 점이 Thread와 Runnable을 이용히는 방식과는 다른 점이다. 이 예제는 단순하게 보면 Callable 인터페이스를 이용해 결과를 반환하는 과정 정도로 이해할 수 있다.

// java.util.concurrent.Callable 
public interface Callable<V> {
	V call( );
}

아래와 같이 executorService.submit()에 Callable 인터페이스를 이용해 익명 클래스 구현체를 전달할 수 있다.

ExecutorService executorService = Executors.newCachedThreadPool();
Future<String> threadName = executorService.submit(new Callable<String>() {
    @Override
        public String call() throws Exception {
        return Thread.currentThread().getName();
    }
});

이어서 같은 동작을 수행하는 람다 코드로 리팩토링이 가능하다. 람다를 사용하면 익명 클래스를 만들기 위해 필요했던 메서드의 시그니처나 클래스의 생성자 호출 패턴을 과감하게 생략할 수 있다. 오직 인터페이스를 구현체로 만드는데 필요한 인수와 메서드 로직만 전달하면 되기 때문에 코드가 간결해진다.

Future<String> threadName = executorService.submit(
    () -> Thread.currentThread().getName());

Chapter 2 정리하기

동작 파라미터화가 가능하다는 말은 인터페이스 형식에 맞춰 람다 표현식을 자유롭게 변경할 수 있다는 의미가 된다. 람다 표현식만으로 인터페이스의 구현 클래스 생성과 인스턴스 전달이 가능하기 때문에 인터페이스의 구현체를 직접 만들고 인스턴스화 하여 객체로 전달하는 것과 동일하게 사용할 수 있다.

자바는 동작 파라미터화를 도입하면서 요구사항에 더 유연하게 대응할 수 있는 언어가 되었다. 불필요하거나 반복되는 코드를 줄일 수 있다는 사실만으로 엔지니어링 비용이 줄어든다. 코드의 가독성이 높아지는 것은 덤이다.

profile
7년간 마케터로 일했고, 현재는 헤렌에서 백엔드 개발자로 일하고 있습니다. 고객 가치를 설계하는 개발자를 지향하며, 개발, 독서, 글쓰기를 좋아합니다. 업이 심오한 놀이이길 바라는 덕업일치 주의자입니다.

0개의 댓글