티스토리 뷰

  • 아래 내용은 '모던 자바 인 액션'을 읽고 정리한 글로 책 내용의 순서를 따라간다.

  • 상세한 내용이나 예시는 책과 상이할 수 있다.

  • 예제 코드에서 사용되고 있는 스펙

    1. Java 15 preview (record라는 새로운 클래스 개념을 사용하기 위해서 해당 프리뷰 버전을 사용. 하위 버전의 경우는 일반 클래스를 생성한 후 getter를 만들고 사용하면 됨) - 참고
    2. JUnit 5
    3. Gradle 6.7
  • 소스코드


배경

  • 소프트웨어 개발에서 요구사항은 항상 변한다.
  • 이러한 요구사항을 반영하면서도 엔지니어링적인 비용이 가장 최소화될 수 있으면 좋다.
  • 그뿐 아니라 새로 추가한 기능은 쉽게 구현할 수 있어야 하며 장기적인 관점에서 유지보수가 쉬어야 한다.

변화에 대응하기

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

      enum Color { RED, GREEN }
    
      public List<Apple> filterGreenApples(List<Apple> inventory) {
          final var result = new ArrayList<Apple>();
          for (final var apple : inventory) {
              if (GREEN.equals(apple.color())) {
                  result.add(apple);
              }
          }
          return result;
      }
    • 위의 코드는 녹색 사과를 필터링하는 것이다.
    • 현재의 요구사항은 녹색 사과만 필터링하는 것이지만 빨간색 사과만 필터링하라는 요구사항이 올 수도 있다.
    • 이럴 경우 위의 코드와 유사하게 작성될 것이다.
    • 거의 비슷한 코드가 반복 존재한다면 그 코드를 추상화해본다.
  • 두 번째 시도 : 색을 파라미터화

      public List<Apple> filterApplesByColor(List<Apple> inventory, Color color) {
          final var result = new ArrayList<Apple>();
          for (final var apple : inventory) {
              if (color.equals(apple.color())) {
                  result.add(apple);
              }
          }
          return result;
      }
    • 지금까지 을 이용한 필터링을 잘 구현했다.

    • 여기서 이외에 무게를 이용해서 사과를 필터링하고 싶다는 요구사항이 생기면 어떻게 해야할까?

        public List<Apple> filterApplesByWeight(List<Apple> inventory, int weight) {
              final var result = new ArrayList<Apple>();
              for (final var apple : inventory) {
                  if (apple.weight() > weight) {
                      result.add(apple);
                  }
              }
              return result;
        }
    • 을 파라미터화 했듯이 무게를 파라미터화해서 위와 같이 해결할 수 있다.

    • 하지만 , 무게를 각각 파라미터화해서 조건에 적용한 것 외에는 대부분 중복되는 코드다.

    • 이는 소프트웨어 공학의 DRY(do not repeat yourself) 원칙을 어기는 것이다.

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

      public List<Apple> filterApples(List<Apple> inventory, Color color, int weight, boolean flag) {
          final var result = new ArrayList<Apple>();
          for (final var apple : inventory) {
              if ((flag && apple.color().equals(color)) ||
                      (!flag && apple.weight() > weight)) {
                  result.add(apple);
              }
          }
          return result;
      }
    • 무게를 합쳐서 하나의 filter를 구현하는 방법도 있지만 이것 또한 어떤 상황에서 무게를 구분할지 별도의 기준이 필요하다.
    • 위의 코드에서는 무게 중 어떤 기준에 따라 필터링할지를 결정하기 위해 boolean 타입의 flag라는 파라미터를 사용했는데, 이 메서드를 사용하는 사용자 입장에서 어떤 것이 true이고 어떤 것이 false인지 불명확하다.
    • 게다가 무게 이외에 Apple 클래스에 또 다른 속성(크기, 모양, 출하지 등)이 부여된다면 현재의 코드로는 유연하게 대처할 수 없다.
    • 심지어 빨간색 사과 중에 무거운 사과를 필터링하고 싶다면? 결국 중복된 메서드를 계속해서 만들 수 밖에 없다.

지금까지는 문자열, 정수, 불리언 등의 값으로 filterApples 메서드를 파라미터화했다.


동작 파라미터화(Behavior Parameterization)

  • 아직은 어떻게 실행할 것인지 결정하지 않은 코드 블럭을 의미한다.
  • 즉, 코드 블럭의 실행은 나중으로 미뤄진다.
  • 결과적으로 코드 블럭에 따라 메서드의 동작이 파라미터화된다.

위의 예시를 다시 보자

  • 우리는 선택 조건을 다음처럼 결정할 수 있다.

  • 사과의 어떤 속성에 기초해서 불리언값을 반환 (예를 들어 사과가 녹색인가? 200그램 이상인가?)하는 방법이 있다.

  • 참 또는 거짓을 반환하는 함수를 프레디케이트라고 한다.

  • 선택 조건을 결정하는 인터페이스를 정의하자.

      public interface ApplePredicate {
          boolean test(Apple apple);
      }
    • 다음과 같이 다양한 선택 조건을 대표하는 ApplePredicate를 정의할 수 있다.

        public class AppleHeavyWeightPredicate implements ApplePredicate {
            @Override
            public boolean test(Apple apple) {
                return apple.weight() > 200;
            }
        }
        public class AppleGreenColorPredicate implements ApplePredicate {
            @Override
            public boolean test(Apple apple) {
                return GREEN.equals(apple.color());
            }
        }

    • 위 조건에 따라 filter 메서드가 다르게 동작할 것이라고 예상할 수 있다.

    • 이를 전략 디자인 패턴(strategy design pattern)이라고 부른다.

      • 전략 디자인 패턴은 각 알고리즘(전략이라 불리는)을 캡슐화하는 알고리즘 패밀리를 정의해둔 다음에 런타임에 알고리즘을 선택하는 기법이다.
  • filterApples에서 ApplePredicate 객체를 받아 애플의 조건을 검사하도록 메서드를 고쳐야 한다.

    • 이렇게 동작 파라미터화, 즉 메서드가 다양한 동작(또는 전략)을 받아서 내부적으로 다양한 동작을 수행할 수 있다.
  • 이렇게 하면 filterApples 메서드 내부에서 컬렉션을 반복하는 로직과 컬렉션의 각 요소에 적용할 동작(우리 예제에서는 프레디케이트)을 분리할 수 있다는 점에서 소프트웨어 엔지니어링적으로 큰 이득을 얻는다.

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

      public List<Apple> filterApples(List<Apple> inventory, ApplePredicate applePredicate) {
          final var result = new ArrayList<Apple>();
          for (final var apple : inventory) {
              if (applePredicate.test(apple)) {
                  result.add(apple);
              }
          }
          return result;
      }
    • 첫 번째 코드에 비해 더 유연한 코드를 얻었으며 동시에 가독성도 좋아졌을 뿐 아니라 사용하기도 쉬워졌다.

    • 필요한 조건에 따라 ApplePredicate를 만들어서 filterApples 메서드에 전달해 주기만 하면 된다.

    • 이제 세 번째 시도 마지막 부분에서 언급했던 빨간색 사과 중에 무거운 사과를 다음과 같이 만들 수 있다.

          public class AppleRedAndHeavyPredicate implements ApplePredicate {
              @Override
              public boolean test(Apple apple) {
                  return RED.equals(apple.color()) &&
                          apple.weight() > 180;
              }
          }
      • 이렇게 우리가 전달한 ApplePredicate 객체에 의해 filterApples 메서드의 동작이 결정된다.

      • 위의 그림에서 보여주는 것처럼 ApplePredicate의 실제 구현은 test 메서드에 있다.

      • 안타깝게도 메서드는 객체만 인수로 받으므로 test 메서드를 ApplePredicate 객체로 감싸서 전달해야 한다.

        • Java 8에 새롭게 추가된 람다 표현식을 이용하게 되면 여러 개의 ApplePredicate를 정의하지 않고도 test 메서드의 내부 구현 filter 메서드로 전달할 수 있다.
  • 지금까지 살펴본 것처럼 컬렉션 탐색 로직과 각 항목에 적용할 동작을 분리할 수 있다는 것이 동작 파라미터화의 강점이다.

Q. 퀴즈-1) 유연한 prettyPrintApple 메서드 구현하기

  • 사과 리스트를 인수로 받아 다양한 방법으로 문자열을 생성(커스터마이즈된 다양한 toString 메서드와 같이)할 수 있도록 파라미터화된 prettyPrintApple 메서드를 구현하시오.

    • 예를 들어, prettyPrintApple 메서드가 각각의 사과 무게를 출력하도록 지시할 수 있다.

    • 혹은 각각의 사과가 무거운지, 가벼운지 출력하도록 지시할 수 있다.

    • prettyPrintApple 메서드는 지금까지 살펴본 필터링 예제와 비슷한 방법으로 구현할 수 있다.

    • 대략적인 코드는 다음과 같다.

        public void prettyPrintApple(List<Apple> inventory, ???) {
              for (Apple apple : inventory) {
                String output = ???.???(apple);
                System.out.println(output);
            }
        }
    • ??? 에 들어갈 내용을 생각해보면 된다.

A. 정답

  • 정답

    • 우선 Apple을 인수로 받아 정해진 형식의 문자열로 반환할 수단이 있어야 한다.

        public interface AppleFormatter {
              String accept(Apple a);
        }
    • 이제 다음과 같이 다양한 동작을 하는 AppleFormatter를 만든다.

        public class AppleFancyFormatter implements AppleFormatter {
              @Override
              public String accept(Apple apple) {
                  String characteristic = apple.weight() > 200 ? "heavy" : "light";
                  return "A " + characteristic + " " + apple.color() + " apple";
            }
        }
        public class AppleSimpleFormatter implements AppleFormatter {
              @Override
              public String accept(Apple apple) {
                  return "An apple of " + apple.weight() + "g";
              }
        }
    • 마지막으로 prettyPrintApple 메서드가 AppleFormatter 객체를 인수로 받아 내부적으로 사용하도록 지시한다.

        public void prettyPrintApple(List<Apple> inventory, AppleFormatter appleFormatter) {
              for (final var apple : inventory) {
                  String output = appleFormatter.accept(apple);
                  System.out.println(output);
              }
        }
    • AppleFormatter의 구현을 객체화한 다음에 prettyPrintApple의 인수로 전달한다.

    • 다음과 같은 데이터가 있다고 하자.

        private final List<Apple> inventory = List.of(
              new Apple(Color.GREEN, 200),
              new Apple(Color.GREEN, 220),
              new Apple(Color.RED, 180),
              new Apple(Color.GREEN, 200),
              new Apple(Color.RED, 190));
        @Test
        @DisplayName("AppleFancyFormatter를 이용한 사과 출력")
        void prettyPrintAppleWithAppleFancyFormatter() {
              quizAnswer.prettyPrintApple(inventory, new AppleFancyFormatter());
        }
    • 다음 코드를 실행한 결과다.

        @Test
        @DisplayName("AppleSimpleFormatter를 이용한 사과 출력")
        void prettyPrintAppleWithAppleSimpleFormatter() {
              quizAnswer.prettyPrintApple(inventory, new AppleSimpleFormatter());
        }
    • 다음 코드를 실행한 결과다.


복잡한 과정 간소화

  • 지금까지의 작업에서 filterApples 메서드로 새로운 동작을 전달하려면 ApplePredicate 인터페이스를 구현하는 여러 클래스를 정의한 다음에 인스턴스화해야 한다.

  • 로직과 관련 없는 코드가 많이 추가되었다.

    • 여기서 로직은 실제로 사과를 필터링하는 test 메서드 내부의 동작
    • test 메서드의 내부 동작을 구현하기 위해서 ApplePredicate 인터페이스를 구현한 클래스들을 만들었고 인스턴스화했다.
  • 자바는 클래스의 선언과 인스턴스화를 동시에 수행할 수 있도록 익명 클래스(anonymous class) 라는 기법을 제공한다.

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

      @Test
      @DisplayName("익명 클래스를 이용한 필터링")
      void filterApplesWithApplePredicateUsingAnonymousClassTest() {
          final var expected = List.of(new Apple(Color.RED, 180), new Apple(Color.RED, 190));
          final var result = behaviorParameterizationExample.filterApples(inventory, new ApplePredicate() {
              @Override
              public boolean test(Apple apple) {
                  return Color.RED.equals(apple.color());
              }
          });
          assertIterableEquals(expected, result);
      }
    • 자바 스윙이나 AWT 같은 GUI 애플리케이션에서 이벤트 핸들러 객체를 구현할 때 익명 클래스를 종종 사용한다

    • 익명 클래스를 사용해도 아직 부족한 점이 있다.

      1. 여전히 많은 공간을 차지한다. (클래스의 생성과 인스턴스화를 동시에 하는 것이지만 생성을 위해 메서드 내부에 클래스를 정의해야 한다.)
      2. 많은 프로그래머가 익명 클래스의 사용에 익숙하지 않다.

Q. 퀴즈-2) 익명 클래스 문제

  • 다음 코드를 실행한 결과는 4, 5, 6, 42 중 어느 것일까?
public class MeaningOfThis {
    public final int value = 4;

    public void doIt() {
        final var value = 6;
        Runnable r = new Runnable() {
            public final int value = 5;
            @Override
            public void run() {
                final var value = 10;
                System.out.println(this.value);
            }
        };
        r.run();
    }
}

class MeaningOfThisTest {
    private final MeaningOfThis meaningOfThis = new MeaningOfThis();

    @Test
    @DisplayName("익명 클래스 문제")
    void anonymousClassTest() {
        meaningOfThis.doIt();
    }
}

A. 정답

  • 정답

    • 코드에서 thisMeaningOfThis가 아니라 Runnable을 참조하고 있으므로 5가 정답이다.

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

    • Java 8의 람다 표현식을 이용해서 익명 클래스로 만든 예제 코드를 다음과 같이 간단하게 재구현할 수 있다.

      @Test
      @DisplayName("람다를 이용한 필터링")
      void filterApplesWithApplePredicateUsingLambdaTest() {
        final var expected = List.of(new Apple(Color.RED, 180), new Apple(Color.RED, 190));
        final var result = behaviorParameterizationExample.filterApples(inventory, apple -> Color.RED.equals(apple.color()));
        assertIterableEquals(expected, result);
      }
    • 코드가 훨씬 더 간결해졌다.

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

    • 지금까지 사과를 사용한 예제를 만들었다.

    • 이제는 바나나, 오렌지, 정수, 문자열 등의 리스트에 필터 메서드를 사용하려고 한다.

      public <T> List<T> filter(List<T> list, Predicate<T> p) {
        final var result = new ArrayList<T>();
        for (T e : list) {
            if (p.test(e)) {
                result.add(e);
            }
        }
        return result;
      }
      
      @Test
      @DisplayName("리스트 요소를 추상화한 후 필터링")
      void filterApplesWithListElementsAbstractionTest() {
        final var expected = List.of("brown");
        final var names = List.of("hong", "brown", "mike");
        final var result = behaviorParameterizationExample.filter(names, name -> name.length() > 4);
        assertIterableEquals(expected, result);
      }
    • 위 코드에서 리스트의 요소들을 추상화(제네릭을 사용)했다.

      • 문자열 형태(이름)의 리스트를 받아서 길이가 4보다 큰 요소로 필터링했다.

실전 예제

Comparator로 정렬하기

  • Java 8의 List에는 sort 메서드가 포함되어 있다.
  • 다음과 같은 인터페이스를 갖는 java.util.Comparator 객체를 이용해서 sort의 동작을 파라미터화할 수 있다.
// java.util.Comparator
public interface Comparator<T> {
    int compare(T o1, T o2);
}
  • 사과를 무거운 순서대로 정렬해보자.
public class ComparatorTest {
    private final List<Apple> inventory = List.of(
            new Apple(Color.GREEN, 200),
            new Apple(Color.GREEN, 220),
            new Apple(Color.RED, 180),
            new Apple(Color.GREEN, 200),
            new Apple(Color.RED, 190));

    @Test
    @DisplayName("익명 클래스를 이용한 정렬")
    void comparatorUsingAnonymousClassTest() {
        final var inventory = new ArrayList<>(this.inventory);
        final var expected = List.of(
                new Apple(Color.GREEN, 220),
                new Apple(Color.GREEN, 200),
                new Apple(Color.GREEN, 200),
                new Apple(Color.RED, 190),
                new Apple(Color.RED, 180));
        inventory.sort(new Comparator<Apple>() {
            @Override
            public int compare(Apple a1, Apple a2) {
                return Integer.compare(a2.weight(), a1.weight());
            }
        });

        assertIterableEquals(expected, inventory);
    }

    @Test
    @DisplayName("람다를 이용한 정렬")
    void comparatorUsingLambdaTest() {
        final var inventory = new ArrayList<>(this.inventory);
        final var expected = List.of(
                new Apple(Color.GREEN, 220),
                new Apple(Color.GREEN, 200),
                new Apple(Color.GREEN, 200),
                new Apple(Color.RED, 190),
                new Apple(Color.RED, 180));
        inventory.sort((a1, a2) -> Integer.compare(a2.weight(), a1.weight()));

        assertIterableEquals(expected, inventory);
    }
}
  • 이렇게 익명 클래스나 람다를 이용해서 Comparator를 구현한 후 요구 사항에 맞게 정렬을 할 수 있다.

Runnable로 코드 블럭 실행하기

  • 자바 스레드를 이용하면 병렬로 코드 블럭을 실행할 수 있다.
  • Java 8까지는 Thread 생성자에 객체만을 전달할 수 있었으므로 보통 결과를 반환하지 않는 void run 메서드를 포함하는 익명 클래스가 Runnable 인터페이스를 구현하도록 하는 것이 일반적인 방법이었다.
// java.lang.Runnable
public interface Runnable {
    void run();
}
  • Runnable을 이용해서 다양한 동작을 스레드로 실행할 수 있다.
public class RunnableTest {
    @Test
    @DisplayName("익명 클래스를 이용한 실행")
    void runnableUsingAnonymousClassTest() {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello World");
            }
        });
        thread.start();
    }

    @Test
    @DisplayName("람다를 이용한 실행")
    void runnableUsingLambdaTest() {
        Thread thread = new Thread(() -> System.out.println("Hello World"));
        thread.start();
    }
}

ExecutorService로 실행 결과 반환받기

  • Java 5부터는 ExecutorService 추상화 개념을 지원한다.
  • ExecutorService 인터페이스는 태스크를 제출과 실행 과정의 연관성을 끊어준다.
  • ExecutorService를 이용하면 태스크를 스레드 풀로 보내조 결과를 Future로 저장할 수 있다는 점이 스레드와 Runnable을 이용하는 방식과는 다르다.
  • 여기서는 ExecutorService의 개념에 대해서는 잘 몰라도 되고 여기서는 Callable 인터페이스를 이용해 결과를 반환하는 태스크를 만들수 있다는 사실만 알면된다.
// java.util.concurrent.Callable
public interface Callable<V> {
    V call();
}
  • 아래 코드에서 볼 수 있듯이 ExecutorService에 태스크를 제출해서 위 코드를 활용할 수 있다.
public class ExecutorServiceTest {
    @Test
    @DisplayName("익명 클래스를 이용한 서비스 실행")
    void executorServiceUsingAnonymousClassTest() throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        Future<String> threadName = executorService.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {
                return Thread.currentThread().getName();
            }
        });
        System.out.println(threadName.get());
    }

    @Test
    @DisplayName("람다를 이용한 서비스 실행")
    void executorServiceUsingLambdaTest() throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newCachedThreadPool();
        Future<String> threadName = executorService.submit(() -> Thread.currentThread().getName());
        System.out.println(threadName.get());
    }
}

GUI 이벤트 처리하기

  • 일반적으로 GUI 프로그래밍은 마우스 클릭이나 문자열 위로 이동하는 등의 이벤트에 대응하는 동작을 수행하는 식으로 동작한다.
  • 자바FX(JavaFX) 에서는 setOnAction 메서드에 EventHandler를 전달함으로써 이벤트에 어떻게 반응할지 설정할 수 있다.
Button button = new Button("Send");

// 익명 클래스 이용
button.setOnAction(new EventHandler<ActionEvent>() {
    public void handle(ActionEvent event) {
        label.setText("Sent!!");
    }
});

// Lambda 이용
button.setOnAction(event -> label.setText("Sent!!"));

메서드로 인자를 전달할 때 값을 파라미터화 하는 것보다 실제 동작을 파라미터화해서 전달하면 좀 더 유연한 코드를 작성할 수 있다.
동작을 파라미터화해서 전달할 때 Java 8의 람다를 사용하면 좀 더 간결한 코드를 작성할 수 있다.

댓글