9장: 람다를 이용한 리팩토링, 테스팅, 디버깅

리팩토링

  • 코드 가독성을 높이려면 코드의 문서화를 잘하고 표준 코딩 규칙을 준수하는 등의 노력이 필요하다.

  • 자바 8의 람다, 메서드 참조, 스트림을 활용하여 기존 코드의 가독성을 개선할 수 있다.

익명 클래스를 람다 표현식으로 리팩토링

  • 익명 클래스는 코드를 장황하게 만들고 쉽게 에러를 일으키기 때문에 람다를 사용도록 변경해 간결하고 가독성 좋은 코드를 구현할 수 있다.

// 익명 클래스
Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello");
    }
};

// 람다
Runnable r2 = () -> System.out.println("Hello");
  • 익명 클래스와 람다에서의 this, super 지시자는 다른 의미를 갖는다.

    • 익명 클래스의 this는 자기 자신의 클래스를 의미하지만, 람다의 this는 람다를 감싸는 클래스를 의미한다.

  • 익명 클래스는 감싸고 있는 클래스의 변수를 가릴수 있지만, 람다 표현식으로는 가릴수 없다.

int a = 10;

Runnable r1 = new Runnable() {
    int a = 20;    
    @Override
    public void run() {
        System.out.println("Hello");
    }
};

Runnable r2 = () -> {
    int a = 20; // compile error
    System.out.println("Hello");
};
  • 익명 클래스는 인스턴스화 할때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라지기 때문에 익명 클래스를 람다 표현식으로 변경하면 콘텍스트 오버로딩에 따른 모호함이 발생한다.

  • 아래와 같이 람다식이 Task 구체 클래스와 Runnable 구체 클래스 중 어떤 것인지 알 수 없게된다. 따라서 명시적 형변환을 해주어야 해결할 수 있다.

interface Task {
    public void execute();
}

public static void doSomeThing(Runnable r) {
    r.run();
}

public static void doSomeThing(Task t) {
    t.execute();
}

doSomeThing(() -> System.out.println("do")); // 어떤 것이 실행되어야 하는지 알 수 없다. 
doSomeThing((Task)() -> System.out.println("do")); 
  • 인텔리제이에서도 이 부분에 대해 오류가 있다고 감지해주며, 명시적 형변환을 하도록 제안해주고 있다.

람다 표현식을 메서드 참조로 리팩토링

  • comparing이나 maxBy , groupingBy 같은 정적 헬퍼 메서드를 메서드 참조와 조합해 사용할 수 있다.

// 람다
Map<CaloricLevel, List<Dish>> dishByCaloricLevel = menu.stream()
                .collect(groupingBy(dish -> {
                    if (dish.getCalories() <= 400) return CaloricLevel.DIET;
                    else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
                    else return CaloricLevel.FAT;
                }));

// 메서드 참조
Map<CaloricLevel, List<Dish>> dishByCaloricLevel = menu.stream()
                .collect(groupingBy(Dish::getCaloricLevel));

public class Dish {
    // ...
    public CaloricLevel getCaloricLevel() {
        if (this.calories <= 400) return CaloricLevel.DIET;
        else if (this.calories <= 700) return CaloricLevel.NORMAL;
        else return CaloricLevel.FAT;
    }
}
// 람다
inventory.sort((Apple a1, Apple a2) -> a1. getWeight().compareTo(a2.getWeight()));

// 메서드 참조
inventory.sort(comparing(Apple::getWeight))

명령형 데이터 처리를 스트림으로 리팩토링

  • 스트림 API는 데이터 처리 파이프라인의 의도를 명확하게 보여주고 최적화할 수 있으며 멀티코어 아키텍처를 쉽게 활용할 수 있도록 한다.

List<String> dishNames = new ArrayList<>();
for (Dish dish : menu) {
  if (dish.getCalories() > 300) {
    dishNames.add(dish.getName());
  }
}

menu.parallelStream()
  .filter(d -> d.getCalories() > 300)
  .map(Dish::getName)
  .collect(toList());

코드 유연성 개선

  • conditional deferred execution 패턴

    • 클라이언트 코드에서 객체 상태를 자주 확인하거나 객체의 일부 메서드를 호출하는 상황이라면 내부적으로 객체의 상태를 확인한 후 메서드를 호출하도록 하는 새로운 메서드를 구현하는 것이 좋다.

    • 아래는 3단계를 거쳐 리팩토링한 것으로, 조건에 맞는 상황이 되었을 때에만 메시지를 생성하도록 지연하는 conditional defferred execution 패턴을 사용했다.

    // 1단계: 직접 로그 객체의 상태를 확인한 후 메시지를 생성해 로깅
    if (logger.isLoggable(Log.FINER)) {
      logger.finer("Problem: " + generateDiagnostic());
    }
    
    // 2단계: log 메서드 내부에서 로그 객체의 상태를 확인하고 메세지를 로깅
    logger.log(Level.FINER, "Problem: " + generateDiagnostic());
    
    // 3단계: log 메서드 내부에서 로그 객체의 상태를 확인하고 적합한 상황에만 람다를 통해 메세지를 생성해 로깅
    logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());
  • execute around 패턴

    • 매번 같은 준비, 종료 과정을 반복하는 코드를 람다로 변환하여 재사용할 수 있다.

    • 아래는 file.txt 파일을 try-with-resource 문으로 접근하는 과정을 템플릿화 하고, 내부적으로 파일의 데이터를 어떻게 처리할지는 함수형 인터페이스인 BufferedReaderProcessor를 구현한 람다로 작성하도록 해 execute around 패턴을 구현한 예제이다.

    String oneLine = processFile((BufferedReader b) -> b.readLine()); // 람다 전달
    String twoLine = processFile((BufferedReader b) -> b.readLine() + b.readLine()); // 다른 람다 전달
    
    public static String processLine(BufferedReaderProcessor p) throws IOException {
      try (BufferdReader br = new BufferedReader(new FileReader("file.txt"))) {
        return p.process(br); // 인수로 전달된 BufferedReaderProcessor 실행
      }
    }
    
    public interface BufferedReaderProcessor {
      String process(BufferedReader b) throws IOException;
    }

람다와 디자인 패턴

전략 패턴

  • 전략 클래스에서는 다양하게 갈아끼울 수 있도록 인터페이스필드를 가지고 있으며, 이 인터페이스 필드에 구체적인 구현 클래스가 주입되는 패턴이다.

  • 전략 패턴에서 구현 클래스를 주입할 때 직접 구체 클래스를 정의하고 인스턴스를 생성하는 대신 람다를 사용해 간단히 주입할 수 있다.

  • 아래 예제에서는 String을 입력받아 유효성을 검사할 수 있는 ValidationStrategy 인터페이스를 Validator 전략 클래스가 필드로 가지고 있으며, 이 전략 클래스 내부에서 어떻게 유효성 검사를 할 지는 람다로 주입받도록 한다.

@FunctionalInterface
public interface ValidationStrategy {
    boolean execute(String s);
}

public class Validator {
    private final ValidationStrategy validationStrategy;

    public Validator(ValidationStrategy validationStrategy) {
        this.validationStrategy = validationStrategy;
    }
    
    public boolean validate(String s) {
        return validationStrategy.execute(s);
    }
}

// 람다로 원하는 검증 방식 주입
Validator lowerCaseValidator = new Validator((String s) -> s.matches("[a-z]+"));
lowerCaseValidator.validate("bbbb");

Validator numericValidator = new Validator((String s) -> s.matches("\d+"));
numericValidator.validate("1234");

템플릿 메서드 패턴

  • 템플릿 메서드 패턴이란 변하는 부분을 추상 클래스의 추상 메서드를 구현하도록 하고 변하지 않는 부분은 추상 클래스의 메서드에 구현해두는 방식이다.

abstract class OnlineBanking {
    public void processCustomer(int id) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy.accept(c);
    }
    
    abstract void makeCustomerHappy(Customer c);
}
  • 람다를 사용할 경우 아래와 같이 추상 클래스의 추상 메서드를 두는 대신 람다식을 입력받아 수행하도록 할 수 있다.

abstract class OnlineBanking {
    public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy.accept(c);
    }
}

new OnlineBanking().processCustomer(1337, (Customer c) -> print("hello" + c.getName()));

옵저버 패턴

  • 어떤 이벤트가 발생했을 때 한 객체(Subject)가 다른 객체 리스트(Observer)에 자동으로 알림을 보내야 하는 패턴에서 옵저버 디자인 패턴을 사용한다.

  • NotiSubject 인터페이스에서 새로운 메시지를 notifyObservers 메서드를 통해 발행하면 등록된 모든 Observer로 해당 메시지가 전달되어 Observer의 notify 메서드가 호출된다.

interface Observer {
  void notify(String tweet);
}

public class NyTimes implements NotiObserver {
    @Override
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("monety")) {
            System.out.println("Breaking news in NY ! " + tweet);
        }
    }
}

public class Guardian implements NotiObserver {
    @Override
    public void notify(String tweet) {
        if (tweet != null && tweet.contains("queen")) {
            System.out.println("Yet more new from London .. " + tweet);
        }
    }
}

public interface NotiSubject {
    void registerObserver(NotiObserver o);
    void notifyObservers(String tweet);
}

public class Feed implements NotiSubject {
    private final List<NotiObserver> observers = new ArrayList<>();
    @Override
    public void registerObserver(NotiObserver o) {
        observers.add(o);
    }

    @Override
    public void notifyObservers(String tweet) {
        observers.forEach(o -> o.notify(tweet));
    }
}

Feed f = new Feed();
f.registerObserver(new NyTimes());
f.registerObserver(new Guardian());
f.notifyObservers("The Queen ...")
  • 이 옵저버 패턴에서 각 Observer 구현체를 만들지 않고 람다식을 넘겨 옵저버를 등록하여 코드를 간결하게 만들 수 있다.

feed.registerObserver((String tweet) -> {
    if(tweet != null && tweet.contains("money")) {
        System.out.println("Breaking news in NY ! " + tweet);
    }
});
  • 람다 표현식으로 불필요한 코드가 제거 되는것이 바람직하다 할때만 람다식으로 사용하는 것을 권장한다.

  • 복잡한 로직이나 여러 메서드가 정의된다면 람다 표현식 보다 기존 방식을 고수하는 것이 오히려 가독성이 좋다.

의무 체인 패턴

  • 작업 처리 객체의 체인을 만들 때 사용하는 패턴으로 한 객체가 작업을 처리한 다음 다른 객체로 결과를 전달하고 다음 객체가 작업을 처리후 그 다음 객체로 전달하는 형식이다.

  • 일반적으로 아래와 같이 추상 클래스에 다음 객체를 필드로 갖고 있다가 본 클래스의 작업이 끝나면 다음 클래스의 작업을 수행해 결과를 반환한다.

public abstract class ProcessingObject<T> {
    protected ProcessingObject<T> successor;
    
    public void setSuccessor(ProcessingObject<T> successor) {
        this.successor = successor;
    }
    
    public T handle(T input) {
        T r = hadleWork(input);
        if (successor != null) {
            return successor.handle(r);
        }
        return r;
    }
    
    abstract protected T hadleWork(T input);
}

public class HandleTextProcessing extends ProcessingObject<String> {
    @Override
    protected String hadleWork(String input) {
        return "From Raoul, Mario and Alan: " + input;
    }
}

public class SpellCheckProcessing extends ProcessingObject<String> {
    @Override
    protected String hadleWork(String input) {
        return input.replaceAll("labda", "lambda");
    }
}

ProcessingObject<String> p1 = new HandleTextProcessing();
ProcessingObject<String> p2 = new SpellCheckProcessing();
p1.setSuccessor(p2);
String result = p1.handle("Aren't ladbas really sexy?");
// 결과: From Raoul, Mario and Alan: Aren't lambdas really sexy?
  • 람다를 사용하여 UnaryOperator<String> 타입을 만들면, 구체 클래스를 생성하지 않고도 간단하게 부가 작업을 담당하는 구성 요소들을 만들어 체이닝할 수 있다.

UnaryOperator<String> headerProcessing = (String text) -> "From Raoul, Mario and Alan : " + text;
UnaryOperator<String> spellCheckProcessing = (String text) -> text.replaceAll("labda", "lambda");
Function<String, String> pipeline = headerProcessing.andThen(spellCheckProcessing);
pipeline.apply("Aren't ladbas really sexy?");

정적 팩토리 메서드 보완

  • 인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 정적 팩토리 메서드를 사용한다.

  • 아래는 상품명을 입력받아 상품 객체를 생성해 반환하는 메서드이다.

public class ProductFactory {
    public static Product createProduct(String name) {
        switch (name) {
            case "loan" : return new Loan();
            case "stock" : return new Stock();
            case "bond" : return new Bond();
            default: throw new RuntimeException("");
        }
    }
}

Product p = ProductFactory.createProduct("loan");
  • 위와 같은 정적 팩토리 메서드를 람다를 사용해 아래와 같이 구현할 수 있다. (개인적으로는 이 부분은 람다 사용하지 않는 것이 더 깔끔해보인다.)

final static Map<String, Supplier<Product>> map = new HashMap<>();
static {
  map.put("loan", Loan::new);
  map.put("stock", Stock::new);
  map.put("bond", Bond::new);
}

public static Product createProduct(String name) {
  Supplier<Product> p = map.get(name);
  if (p != null) {
    return p.get();
  }
  throw new IllegalArgumentException("No such product " + name);
}

테스팅

보이는 람다 표현식의 동작 테스트

  • 람다식은 함수형 인터페이스의 인스턴스를 생성하기 때문에 테스트할 때 인스턴스의 동작을 통해 테스트할 수 있다.

public class Point {
    public static final Comparator<Point> compareByXAndThenY = comparing(Point::getX).thenComparing(Point::getY);
    // ...
}

@Test
void test() {
    // ...
    assertTrue(Point.compareByXAndThenY.compare(p1, p2) < 0);
}

람다를 사용하는 메서드의 동작에 집중

  • 람다의 목표는 정해진 동작을 다른 메서드에서 사용할 수 있도록 하나의 조각으로 캡슐화 하는 것이다.

  • 람다 표현식을 사용하는 메서드의 동작을 테스트 함으로써 람다 내부 구현을 공개하지 않으면서도 람다 표현식을 검증 할 수 있다.

  • 아래와 같이 람다를 적용한 결과가 예상대로인지 테스트하면 된다.

assertEquals(new Point(15, 10), Point.moveAllPointsRightBy(new Point(5, 10), 10));

복잡한 람다 개별 메서드로 분할

  • 복잡한 람다 표현식은 테스트코드에서 참조할 수 없으므로, 람다 표현식을 새로운 일반 메서드로 선언하고 메서드 참조할 수 있도록 바꾸어 테스트해야 한다.

고차원 함수 테스트

  • 함수를 입력받는 메서드의 경우 테스트 코드에서 직접 함수를 입력하여 동작을 검증할 수 있다.

List<Integer> nums = Arrays.asList(1, 2, 3, 4);
List<Integer> filtered = filter(nums, i -> i%2 ==0);
assertEquals(Arrays.asList(2, 4), filtered);
  • 함수를 반환하는 메서드의 경우 반환된 함수의 동작을 테스트하여 검증해야 한다.

디버깅

  • 문제가 발생한 코드를 디버깅할 때에는 스택 트레이스와 로깅을 확인해야 한다.

  • 람다는 이름이 없기 때문에 스택 트레이스의 정보를 이해하기 어려울 수 있다.

  • 스트림 파이프라인에서 요소를 처리할 때 peek 메서드로 스트림 파이프라인의 중간 데이터를 로깅할 수 있다.

numbers.stream()
       .peek(x -> System.out.println("from stream: " + x)
       .map(x -> x + 17)
       .peek(x -> System.out.println("after map: " + x)
       .limit(3)
       // ...

Last updated