본문 바로가기
Java/모던 자바 인 액션

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

by Mong-_- 2023. 12. 2.

이 장의 내용

  • 변화하는 요구사항에 대응
  • 동작 파라미터화
  • 익명 클래스
  • 람다 표현식 미리보기
  • 실전 예제 : Comparator, Runnable, GUI

변화하는 요구사항은 소프트웨어 엔지니어링에서 피할 수 없는 문제다. 

 

예를 들어 농부가 재고목록 조사를 쉽게 할 수 있도록 돕는 애플리케이션이 있다고 가정하자.

농부는 이렇게 말할 것이다. "녹색 사과를 모두 찾고 싶어요." 그런데 하룻밤 자고 일어났더니 농부가 또 말한다. "150그램 이상인 사과를 모두 찾고 싶어요." 또 하룻밤을 자고 일어났더니 "150그램 이상이면서 녹색인 사과를 모두 찾을 수 있다면 좋겠네요."라고 말하는 것이다.

 

이렇게 시시각각 변하는 사용자 요구사항에 대응하기 위해  동작 파라미터화를 사용하면 자주 바뀌는 요구사항에 효과적으로 대응할 수 있다. 

 

동작 파라미터란 아직은 어떻게 실행할 것인지 결정하지 않은 코드 블록을 의미한다. 이 코드 블록은 나중에 프로그램에서 호출한다. 즉, 코드 블록의 실행은 나중으로 미뤄진다.

 


2.1 변화하는 요구사항에 대응하기

하나의 예제를 선정한 다음 예제 코드를 점차 개선하면서 유연한 코드를 만드는 모범사례를 보여줄 것이다. 기존의 농장 재고목록 애플리케이션에 리스트에서 녹색 사과만 필터링하는 기능을 추가한다고 가정하자.

 

2.1.1 첫 번째 시도 : 녹색 사과 필터링

사과 색을 정의하는 다음과 같은 Color num이 존재한다고 가정하자.

  enum Color {
    RED,
    GREEN
  }

다음은 첫 번째 시도 결과 코드다.

  public static List<Apple> filterGreenApples(List<Apple> inventory) {
    List<Apple> result = new ArrayList<>(); // 사과 누적 리스트

    for (Apple apple : inventory) {
      if (apple.getColor() == Color.GREEN) { // 녹색 사과 필터링
        result.add(apple);
      }
    }
    
    return result;
  }

if 문은 녹색 사과를 선택하는 데 필요한 조건을 가리킨다. 그런데 갑자기 농부가 변심하여 녹색 사과 말고 빨간 사과도 필터링하고 싶어졌다. 크게 고민하지 않는다면 filterReadAplles라는 메서드를 만들고, if문의 조건을 빨간 사과로 바꾸는 방법을 선택할 수 있다.

 

하지만 이러한 방식은 다양한 색으로 필터링하는 등의 변화는 적절하게 대응할 수 없다. 이런 상황에서는 다음과 같은 좋은 규칙을 따르는 것이 좋다.

거의 비슷한 코드가 반복 존재한다면 그 코드를 추상화한다.

 

2.1.2 두 번째 시도 : 색을 파라미터화

filterGreenApples의 코드를 반복 사용하지 않고 색을 파라미터화할 수 있도록 메서드에 파라미터를 추가하면 변화하는 요구사항에 좀 더 유연하게 대응하는 코드를 만들 수 있다.

  public static List<Apple> filterApplesByColor(List<Apple> inventory, Color color) {
    List<Apple> result = new ArrayList<>();

    for (Apple apple : inventory) {
      if (apple.getColor() == color) {
        result.add(apple);
      }
    }
    
    return result;
  }

다음처럼 구현한 메서드를 호출할 수 있다.

List<Apple> greenApples = filterApplesByColor(inventory, Color.GREEN);
List<Apple> redApples = filterApplesByColor(inventory, Color.RED);

그런데 갑자기 농부가 다시 나타나서는 '색 이외에도 가벼운 사과와 무거운 사과로 구분할 수 있다면 정말 좋겠네요. 보통 무게가 150그램 이상인 사과가 무거운 사과입니다.'라고 요구한다.

(미친 농부 아냐 이거)

 

농부의 다양한 요구사항을 듣다 보면 색과 마찬가지로 앞으로 무게의 기준도 얼마든지 바뀔 수 있다는 사실을 눈치챘는가? 이를 위해 다양한 무게에 대응할 수 있도록 무게 정보 파라미터도 추가해 보자.

  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라는 메서드로 합치는 방법도 있다. 어떤 기준으로 사과를 필터링할지 구분하는 또 다른 방법이 필요하다. 따라서 색이나 무게 중 어떤 것을 기준으로 필터링할지 가리키는 플래그를 추가할 수 있다.

(실제로는 절대 이 방법을 사용하지 말아야 한다고 한다!)

 

2.1.3 세 번째 시도 : 가능한 모든 속성으로 필터링

다음은 만류에도 불구하고 모든 속성을 메서드 파라미터로 추가한 모습이다.

  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는 뭘 의미하는지도, 게다가 앞으로 요구사항이 바뀌었을 때 유연하게 대응할 수도 없다.

 

2.2 동작 파라미터화

앞선 예제를 통해 파라미터를 추가하는 방법이 아닌 변화하는 요구사항에 좀 더 유연하게 대응할 수 있는 방법이 절실하다는 것을 확인했다. 전체를 보자. 우리의 선택 조건을 다음처럼 결정할 수 있다. 사과의 어떤 속성에 기초해서 불리언값을 반환(예를 들어 사과가 녹색인가? 150그램 이상인가?)하는 방법이 있다. 참 또는 거짓을 반환하는 함수를 프레디케이트라고 한다. 선택 조건을 결정하는 인터페이스를 정의하자.

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

다음 예제처럼 다양한 선택 조건을 대표하는 여러 버전의 ApplePredicate를 정의할 수 있다.

  class AppleHeavyWeightPredicate implements ApplePredicate {
    @Override
    public boolean test(Apple apple) {
      return apple.getWeight() > 150;
    }
  }

  class AppleGreenColorPredicate implements ApplePredicate {
    @Override
    public boolean test(Apple apple) {
      return apple.getColor() == Color.GREEN;
    }
  }

ApplePredicate는 사과 선택 전략을 캡슐화함

 

위 조건에 따라 filter 메서드가 다르게 동작할 것이라고 예상할 수 있다. 이를 전략 디자인 패턴이라고 부른다. 전략 디자인 패턴은 각 알고리즘(필터 조건이 해당)을 캡슐화하는 알고리즘 패밀리를 정의해 둔 다음에 런타임에 알고리즘을 선택하는 기법이다.

 

이 예제에서는 ApplePredicate가 알고리즘 패밀리고 AppleHeavyWeightPredicate와 AppleGreenColorPredicate가 전략이다.

 

이제 다 왔다. filterApples에서 ApplePredicate 객체를 받아 사과의 조건을 검사하도록 메서드를 고쳐야 한다. 이렇게 동작 파라미터화, 즉 메서드가 다양한 동작을 받아서 내부적으로 다양한 동작을 수행할 수 있다.

 

filterApples 메서드가 ApplePredicate 객체를 인수로 받도록 고치면, filterApple 메서드 내부에서 컬렉션을 반복하는 로직과 컬렉션의 각 요소에 적용할 동작을 분리할 수 있다는 점에서 소프트웨어 엔지니어링적으로 큰 이득을 얻는다.

 

2.2.1 네 번째 시도 : 추상적 조건으로 필터링

다음은 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 메서드로 전달할 수 있다.

 

만약 농부가 150그램이 넘는 빨간 사과를 검색해 달라고 부탁하면 우리는 ApplePredicate를 적절하게 구현하는 클래스만 만들면 된다. 이제 Apple의 속성과 관련한 모든 변화에 대응할 수 있는 유연한 코드를 준비한 것이다.

static class AppleRedAndHeavyPredicate implements ApplePredicate {
    @Override
    public boolean test(Apple apple) {
      return apple.getColor() == Color.RED && apple.getWeight() > 150;
    }
}

다음처럼 구현한 메서드를 호출할 수 있다.

List<Apple> redAndHeavyApples = filterApples(inventory, new AppleRedAndHeavyPredicate());

이로써! 우리가 전달한 ApplePredicate 객체에 의해 filterApples 메서드의 동작이 결정된다! 즉, 우리는 filterApples 메서드의 동작을 파라미터화한 것이다.

 

여기서 핵심은 test 메서드다. filterApples 메서드의 새로운 동작을 정의하는 것이 test 메서드다. 안타깝게도 메서드는 객체만 인수로 받으므로 test 메서드를 ApplePredicate 객체로 감싸서 전달해야 한다. test 메서드를 구현하는 객체를 이용해서 불리언 표현식 등으로 전달할 수 있으므로 이는 '코드를 전달'할 수 있는 것이나 다름없다.

(2.3 절에서 람다를 이용해서 여러 개의 ApplePredicate 클래스를 정의하지 않고 동작을 메서드로 전달하는 방법을 설명한다.)

 

한 개의 파라미터, 다양한 동작

지금까지 살펴본 것처럼 컬렉션 탐색 로직과 각 항목에 적용할 동작을 분리할 수 있다는 것이 동작 파라미터화의 강점이다.

여기서 동작 파라미터화의 개념을 좀 더 확실하기 익히기 위해 퀴즈 타임을 가져보자.

 

사과 리스트를 인수로 받아 다양한 방법으로 문자열을 생성할 수 있도록 파라미터화된 prettyPrintApple 메서드를 구현하시오. 예를 들어 prettyPrintApple 메서드가 각각의 사과 무게를 출력하도록 지시할 수 있다. 혹은 각각의 사과가 무거운지, 가벼운지 출력하도록 지시할 수 있다. 

public static void prettyPrintApple(List<Apple> inventory, ???) {
    for(Apple apple: inventory) {
        String output = ???.???(apple);
        System.out.println(output);
    }
}

 

 


정답

우선 Apple을 인수로 받아 정해진 형식의 문자열로 반환할 수단이 있어야 한다. 이는 ApplePredicate 인터페이스를 떠올리면 된다.

public interface AppleFomatter {
  String accept(Apple apple);
}

이제 AppleFormatter 인터페이스를 구현해 여러 포맷 동작을 만들 수 있다.

public class AppleFancyFormatter implements AppleFormatter {
    public String accept(Apple apple) {
        String characteristic = apple.getWeight() > 150 ? "heavy" : "light";
        return "A " + characteristic + " " + apple.getColor() + " apple";
    }
}
public class AppleSimpleFormatter implements AppleFormatter {
    public String accept(Apple apple) {
        return "An apple of " + apple.getWeight() + "g";
    }
}

마지막으로 prettyPrintApple 메서드가 AppleFormatter 객체를 인수로 받아 내부적으로 사용하도록 지시한다. 즉, prettyPrintApple에 파라미터를 추가한다.

public static void prettyPrintApple(List<Apple> inventory, AppleFormatter formatter) {
    for (Apple apple : inventory) {
        String output = formatter.accept(apple);
        System.out.println(output);
    }
}

이제 다양한 동작을 prettyPrintApple 메서드로 전달할 수 있다. AppleFormatter의 구현을 객체화한 다음에 prettyPrintApple의 인수로 전달한다.

prettyPrintApple(inventory, new AppleFancyFormatter());
// 다음은 코드를 실행한 결과다.
A light green apple
A heavy red apple

prettyPrintApple(inventory, new AppleSimpleFormatter());
// 다음은 코드를 실행한 결과다.
An apple of 80g
An apple of 155g

여러 동작을 추상화해서 변화하는 요구사항에 대응할 수 있는 코드를 구현하는 방법을 살펴봤다. 하지만 여러 클래스를 구현해서 인스턴스화하는 과정이 조금은 거추장스럽게 느껴질 수 있다. 이 부분을 어떻게 개선할 수 있는지 확인하자.

 

2.3 복잡한 과정 간소화

현재 filterApples 메서드로 새로운 동작을 전달하려면 ApplePredicate 인터페이스를 구현하는 여러 클래스를 정의한 다음에 인스턴스화해야 한다. 이는 상당히 번거러운 작업이며 시간 낭비다.

 

자바는 클래스의 선언과 인스턴스화를 동시에 수행할 수 있는 익명 클래스라는 기법을 제공한다.

(2.3.3절에서는 간단하게 람다 표현식으로 더 가독성 있는 코드를 구현하는 방법을 설명한다.)

 

2.3.1 익명 클래스

익명 클래스는 자바의 지역 클래스(블록 내부에 선언된 클래스)와 비슷한 개념이다. 익명 클래스는 말 그대로 이름이 없는 클래스다. 익명 클래스를 이용하면 클래스 선언과 인스턴스화를 동시에 할 수 있다. 즉, 즉석에서 필요한 구현을 만들어서 사용할 수 있다.

 

2.3.2 다섯 번째 시도 : 익명 클래스 사용

다음은 익명 클래스를 이용해서 ApplePredicate를 구현하는 객체를 만드는 방법으로 필터링 예제를 다시 구현한 코드다.

List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
  @Override
  public boolean test(Apple a) {
    return a.getColor() == Color.RED;
  }
});

익명 클래스로도 아직 부족한 점이 있다. 첫째, 익명 클래스는 여전히 많은 공간을 차지한다. 둘째, 많은 프로그래머가 익명 클래스의 사용에 익숙하지 않다. 코드의 장황함은 나쁜 특성이다. 장황한 코드는 구현하고 유지보수하는 데 시간이 오래 걸릴 뿐 아니라 읽는 즐거움을 빼앗는 요소로, 개발자에게 외면받는다.

 

2.3.3절을 살펴보며 람다 표현식을 이용해서 어떻게 코드를 간결하게 정리할 수 있는지 간단히 살펴보자.

 

2.3.3 여섯 번째 시도 : 람다 표현식 사용

자바 8의 람다 표현식을 이용해서 위 예제 코드를 다음처럼 간단하게 재구현할 수 있다.

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

이전 코드보다 훨씬 간단해졌다! 간결해지면서 문제를 더 잘 설명하는 코드가 되었다.

 

2.3.4 일곱 번째 시도 : 리스트 형식으로 추상화

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) -> Color.RED.equals(apple.getColor()));
List<Integer> evenNumbers = filter(numbers, (Integer i) -> i % 2 == 0);

이렇게 해서 유연성과 간결함이라는 두 마리 토끼를 모두 잡을 수 있었다. 자바 8이 아니면 불가능한 일이다.

 

2.4 실전 예제

지금까지 동작 파라미터화가 변화하는 요구사항에 쉽게 적응하는 유용한 패턴임을 확인했다.

이 절에는 코드 전달 개념을 더욱 확실히 익힐 수 있도록 Comparator로 정렬하기, Runnable로 코드 블록 실행하기, Callable을 결과로 반환하기, GUI 이벤트 처리하기 예제를 소개한다.

 

2.4.1 Comparator로 정렬하기

컬렉션 정렬은 반복되는 프로그래밍 작업이다. 개발자에게는 변화하는 요구사항에 쉽게 대응할 수 있는 다양한 정렬 동작을 수행할 수 있는 코드가 절실하다.

 

자바 8의 List에는 sort 메서드가 포함되어 있다. (Collections.sort도 존재한다). 다음과 같은 인터페이스를 갖는 java.util.Comparator 객체를 이용해서 sort의 동작을 파라미터화할 수 있다.

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

Comparator를 구현해서 sort 메서드의 동작을 다양화할 수 있다.

+ sort 메서드는 Comparator 인터페이스를 구현하는 객체를 인수로 받는다.

 

예를 들어 익명 클래스를 이용하여 무게가 적은 순서로 목록에서 사과를 정렬할 수 있다.

+compareTo 메서드는 Integer에서 지원한다.

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

농부의 요구사항이 바뀌면 새로운 요구사항에 맞는 Comparator를 만들어 sort 메서드에 전달할 수 있다. 실제 정렬 세부사항은 추상화되어 있으므로 신경 쓸 필요가 없다. 람다 표현식을 이용하면 다음처럼 간단하게 코드를 구현할 수 있다.

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

 

2.4.2 Runnable로 코드 블록 실행하기

자바 스레드를 이용하면 병렬로 코드 블록을 실행할 수 있다. 어떤 코드를 실행할 것인지 스레드에게 알려줄 수 있을까? 여러 스레드가 각자 다른 코드를 실행할 수 있다. 나중에 실행할 수 있는 코드를 구현할 방법이 필요하다. 자바 8까지는 Thread 생성자에게 객체만을 전달할 수 있었으므로 보통 결과를 반환하지 않는 void run 메서드를 포함하는 익명 클래스가 Runnable 인터페이스를 구현하도록 하는 것이 일반적인 방법이었다.

 

자바에서는 Runnable 인터페이스를 이용해서 실행할 코드 블록을 지정할 수 있다. 아래 코드에서 볼 수 있는 것처럼 코드 블록을 실행한 결과는 void다.

// java.lang.Runnable
public interface Runnable {
  public void run();
}

Runnable을 이용해서 다양한 동작을 스레드로 실행할 수 있다.

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

자바 8부터 지원하는 람다 표현식을 이용하면 다음처럼 스레드 코드를 구현할 수 있다.

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

 

2.4.3 Callable을 결과로 변환하기

지금은 Callable 인터페이스를 이용해 결과를 반환하는 태스크를 만든다는 사실만 알아두자. 이 방식은 Runnable의 업그레이드 버전이라고 생각할 수 있다.

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

아래 코드에서 볼 수 있듯이 실행 서비스에 태스크를 제출해서 위 코드를 활용할 수 있다. 다음 예제는 태스크를 실행하는 스레드의 이름을 반환한다.

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());

 

2.4.4 GUI 이벤트 처리하기

일반적으로 GUI 프로그래밍은 마우스 클릭이나 문자열 위로 이동하는 등의 이벤트에 대응하는 동작을 수행하는 식으로 동작한다. GUI 프로그래밍에서도 변화에 대응할 수 있는 유연한 코드가 필요하다. 모든 동작에 반응할 수 있어야 하기 때문이다. 자바FX에서는 setOnAction 메서드에 EventHandler를 전달함으로써 이벤트에 어떻게 반응할지 설정할 수 있다.

Button button = new Button("Send");
button.setOnAction(new EventHandler<ActionEvent>() {
  @Override
  public void handle(ActionEvent event) {
    label.setText("Sent!!");
  }
});

즉, EventHandler는 setOnAction 메서드의 동작을 파라미터화 한다. 람다 표현식으로 다음처럼 구현할 수 있다.

button.setOnAction((ActionEvent event) -> label.setText("Sent!!"));

 

2.5 마치며

  • 동작 파라미터화에서는 메서드 내부적으로 다양한 동작을 수행할 수 있도록 코드를 메서드 인수로 전달한다.
  • 동작 파라미터화를 이용하면 변화하는 요구사항에 더 잘 대응할 수 있는 코드를 구현할 수 있으며 나중에 엔지니어링 비용을 줄일 수 있다.
  • 코드 전달 기법을 이용하면 동작을 메서드의 인수로 전달할 수 있다. 하지만 자바 8 이전에는 코드를 지저분하게 구현해야 했다. 익명 클래스로도 어느 정도 코드를 깔끔하게 만들 수 있지만 자바 8에서는 인터페이스를 상속받아 여러 클래스를 구현해야 하는 수고를 없앨 수 있는 방법을 제공한다.
  • 자바 API의 많은 메서드는 정렬, 스레드, GUI 처리 등을 포함한 다양한 동작으로 파라미터화할 수 있다.

interface를 정의한 후 클래스, 익명 클래스, 람다를 이용해 필터링하는 과정이 매우 흥미로웠다.

내일도 화이팅!

 

끗!