[모던 자바 인 액션] chap09. 리팩터링, 테스팅, 디버깅

2023. 3. 21. 23:36Java

내용
람다 표현식으로 코드 리팩터링하기
람다 표현식이 객체지향 설계 패턴에 미치는 영향
람다 표현식 테스팅
람다 표현식과 스트림 API 사용 코드 디버깅

1. 가독성과 유연성을 개선하는 리팩터링

9.1절에서는 지금까지 배운 람다, 메서드 참조, 스트림 등의 기능을 이용해서 더 가독성이 좋고 유연한 코드로 리팩터링하는 방법을 설명한다.

 

1. 코드 가독성 개선

9장에서는 람다, 메서드 참조, 스트림을 활용해서 코드 가독성을 개선할 수 있는 간단한 세 가지 리팩터링 예제를 소개한다.

  • 익명 클래스를 람다 표현식으로 리팩터링하기
  • 람다 표현식을 메서드 참조로 리팩터링하기
  • 명령형 데이터 처리를 스트림으로 리팩터링하기

 

2. 익명 클래스를 람다 표현식으로 리팩터링하기

모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다. 첫째, 익명 클래스에서 사용한 this와 super는 람다 표현식에서 다른 의미를 갖는다. 익명 클래스에서 this는 익명클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다. 둘째, 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있다. 하지만 다음 코드에서 보여주는 것처럼 람다 표현식으로는 변수를 가릴 수 없다.

int a = 10;
Runnable r1 = () -> {
	int a = 2;				// 컴파일 에러
    System.out.println(a);
};

Runnable r2 = new Runnable(){
	public void run() {
    	int a = 2;			// 가능
        System.out.println(a);
    }
}

익명 클래스를 람다로 바꿀 때 컨텍스트 오버로딩에 따른 모호함이 초래될 수 있다. 익명 클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라지기 때문이다.

interface Task { public void execute(); }
public static void doSomething(Runnable r) { r.run(); }
public static void doSomething(Task a) { r.execute(); }

doSomething(new Task() {
	public void execute() {
    	System.out.println("Danger danger!!");
    }
});

doSomething(() -> System.out.println("Danger danger!!"));
// 명시적이지 않아, 무엇을 구현하는 건지 알 수가 없음

doSomething((Task)() -> System.out.println("Danger danger!!"));
// 형변환을 시켜 구현체를 명시함

 

3. 람다 표현식을 메서드 참조로 리팩터링하기

람다 표현식 대신 메서드 참조를 이용한다면 가독성을 높일 수 있다. 메서드 참조의 코드명으로 코드의 의도를 명확하게 알릴 수 있기 때문이다.

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = 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>> dishesByCaloricLevel =
	menu.stream().collect(groupingBy(Dish::getCaloricLevel));
// 메서드 참조로 대체한다면 깔끔해진다.

public class Dish{
	...
    
    public CaloricLevel getCaloricLevel() {
    	if (dish.getCalories() <= 400) return CaloricLevel.DIET;
        else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
        else return CaloricLevel.FAT;
    }
}
// 구현은 해야한다.
inventory.sort(
	(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
    // 구현에 신경을 써야한다.

inventory.sort(comparing(Apple::getWeight));
// 간결하게 만들 수 있다.
int totalCalories =
	menu.stream().map(Dish::getCalories).reduce(0, (c1, c2) -> c1 + c2);

int totalCaories = menu.stream().collect(summingInt(Dish::getCalories));

 

4. 명령형 데이터 처리를 스트림으로 리팩터링하기

다음 명령형 코드는 두 가지 패턴(필터링과 추출)으로 엉킨 코드다. 이 코드를 접한 프로그래머는 전체 구현을 자세히 살펴본 이후에야 전체 코드의 의도를 이해할 수 있다. 게다가 이 코드를 병렬로 실행시키는 것은 매우 어렵다.

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());
// 쉽게 병렬화 할 수 있다.

명령형 코드의 break, continue, return 등의 제어 흐름문을 모두 분석해서 같은 기능을 수행하는 스트림 연산으로 유추해야 하므로 명령형 코드를 스트림 API로 바꾸는 것은 쉬운 일이 아니다.

 

5. 코드 유연성 개선

함수형 인터페이스 적용 - 람다 표현식을 이용하려면 함수형 인터페이스가 필요하다. 따라서 함수형 인터페이스를 코드에 추가해야 한다. 이번에는 조건부 연기 실행과 실행 어라운드 두 가지 자주 사용하는 패턴으로 람다 표현식 리팩토링을 살펴본다.

  • 조건부 연기 실행
if (logger.isLoggable(LOG.FINER)) {
	logger.finer("Problem: " + generateDiagnostic());
}

// 이 코드에는 문제가 있다.
// - logger의 상태가 isLoggable이라는 메서드에 의해 클라이언트 코드로 노출된다.
// - 메시지를 로깅할 때마다 logger 객체의 상태를 매번 확인해야 할까? 
// 이들은 코드를 어지럽힐 뿐이다.

logger.log(Level.FINER, "Problem: " + generateDiagnostic());
// logger의 상태 노출을 없애고, 불필요한 if를 제거했지만 아직은..

public void log(Level level, Supllier<String> msgSupllier) {
	if(logger.isLoggable(level)) {
    	log(level, msgSupllier.get());
    }
}
// 객체의 내부 상태를 확인할 수 있고, 캡슐화도 강화된다!

 

  • 실행 어라운드

매번 같은 준비, 종료 과정을 반복적으로 수행하는 코드가 있다면 이를 람다로 변환할 수 있다.

String oneLine = processFile((BufferedReader b) -> b.readLine());
// 람다 전달
String twoLine = processFile((BufferedReader b) -> b.readLine() + b.readLine());
// 람다 전달

public static String processFile(BufferedReaderProcessor p) throws IOEXCEPTION {
	try(BufferedReader br = new BufferedReader(
    	new FileReader("ModernJavaInAction/chap9/data.txt"))) {
        return p.process(br);	// 인수로 전달된 인터페이스 실행
    }
}	// IOException을 던질 수 있는 람다의 함수형 인터페이스

public interface BufferedReaderProcessor {
	String process(BufferedReader b) throws IOException;
}

람다로 BufferedReader 객체의 동작을 결정할 수 있는 것은 함수형 인터페이스 BufferedReaderProcessor 덖분이다.

 

2. 람다로 객체지향 디자인 패턴 리팩터링하기

디자인 패턴에 람다 표현식이 더해지면 색다른 기능을 발휘할 수 있다. 즉, 람다를 이용하면 이전에 디자인 패턴으로 해결하던 문제를 더 쉽고 간단하게 해결할 수 있다. 또한 람다 표현식으로 기존의 많은 객체지향 디자인 패턴을 제거하거나 간결하게 재구현할 수 있다.이 절에서는 다음 다섯가지 패턴을 살펴본다.

  • 전략
  • 템플릿 메서드
  • 옵저버
  • 의무 체인
  • 팩토리
1. 전략

다양한 기준을 갖는 입력값을 검증하거나, 다양한 파싱 방법을 사용하거나, 입력 형식을 설정하는 등 당야한 시나리오에 전략 패턴을 활용할 수 있다.

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

public class IsAllLowerCase implements ValidationStrategy {
	public boolean execute(String s) {
    	return s.matches("[a-z]+");
    }
}

public class IsNumeric implements ValidationStrategy {
	public boolean execute(String s) {
      return s.matches("\\d+");
    }
}

public class Validator {
    private final ValidationStrategy strategy;

    public Validator(ValidationStrategy v) {
      strategy = v;
    }

    public boolean validate(String s) {
      return strategy.execute(s);
    }
    
    Validator numericValidator = new Validator(new IsNumeric());
    boolean b1 = numericValidator.validate("aaaa");
    Validator lowerCaseValidator = new Validator(new IsAllLowerCase());
    boolean b2 = lowerCaseValidator.validate("bbbb");
}

람다 표현식 사용

ValidationStrategy는 함수형 인터페이스며 Predicate<String>과 같은 함수 디스크립터를 갖고 있음을 파악했을 것이다. 따라서 다양한 전략을 구현하는 새로운 클래스를 구현할 필요 없이 람다 표현식을 직접 전달하면 코드가 간결해진다.

Validator numericValidator = new Validator((String s) -> s.matches("\\d+"));
boolean b1 = numericValidator.validate("aaaa");
Validator lowerCaseValidator = new Validator((String s) -> s.matches("[a-z]+"));
boolean b2 = lowerCaseValidator.validate("bbbb");

위 코드에서 확인할 수 있듯이 람다 표현식을 이용하면 전략 디자인 패턴에서 발생하는 자잘한 코드를 제거할 수 있다. 람다 표현식은 코드 조각을 캡슐화한다. 즉, 람다 표현식으로 전략 디자인 패턴을 대신할 수 있다. 따라서 이와 비슷한 문제에서는 람다 표현식을 사용할것을 추천한다.

 

2. 템플릿 메서드

알고리즘의 개요를 제시한 다음에 알고리즘의 일부를 고칠 수 있는 유엲마을 제공해야 할 때 템플릿 메서드 디자인 패턴을 사용한다. 템플릿 메서드는 '이 알고리즘을 사용하고 싶은데 그대로는 안되고 조금 고쳐야 하는' 상황에 적합하다.

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

new OnlineBankingLambda().processCustomer(1337, (Customer c) -> 
	System.out.println("Hello!" + c.getName()));

람다 표현식을 이용하면 템플릿 메서드 디자인 패턴에서 발생하는 자잘한 코드도 제거할 수 있다.

 

3. 옵저버

어떤 이벤트가 발생했을 때 한 객체가 다른 객체 리스트에 자동으로 알림을 보내야 하는 상황에서 옵저버 디자인 패턴을 사용한다. GUI 애플리케이션에서 옵저버 패턴이 자주 등장한다.

interface Observer {
	void inform(String tweet);
}

class NYTimes implements Observer {
    public void inform(String tweet) {
      if (tweet != null && tweet.contains("money")) {
        System.out.println("Breaking news in NY!" + tweet);
      }
    }
}

class Guardian implements Observer {
    public void inform(String tweet) {
      if (tweet != null && tweet.contains("queen")) {
        System.out.println("Yet another news in London... " + tweet);
      }
    }
}

class LeMonde implements Observer {
    public void inform(String tweet) {
      if (tweet != null && tweet.contains("wine")) {
        System.out.println("Today cheese, wine and news! " + tweet);
      }
    }
}

interface Subject {
    void registerObserver(Observer o);
    void notifyObservers(String tweet);
}

class Feed implements Subject {
	private final List<Observer> observers = new ArrayList<>();

    public void registerObserver(Observer o) {
      observers.add(o);
    }

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

Feed f = new Feed();
f.registerObserver(new NYTimes());
f.registerObserver(new Guardian());
f.registerObserver(new LeMonde());
f.notifyObservers("The queen said her favourite book is Java 8 & 9 in Action!");

람다 표현식 사용하기

아직까지 람다 표현식을 옵저버 디자인 패턴에서 어떻게 사용할 수 있는지 감이 잡히지 않을 것이다. 여기서 Observer 인터페이스를 구현하는 모든 클래스는 하나의 메서드 notify를 구현했다. 지금까지 살펴본 것처럼 람다는 불필요한 감싸는 코드 제거 전문가다. 즉 세 개의 옵저버를 명시적으로 인스턴스화하지 않고 람다 표현식을 직접 전달해서 실행할 동작을 지정할 수 있다.

f.registerObserver((String tweet) -> {
      if (tweet != null && tweet.contains("money")) {
        System.out.println("Breaking news in NY! " + tweet);
      }
});
f.registerObserver((String tweet) -> {
      if (tweet != null && tweet.contains("queen")) {
        System.out.println("Yet another news in London... " + tweet);
      }
});

그렇다면 항상 람다 표현식을 사용해야 할까? 물론 아니다. 이 예제에서는 실행해야 할 동작이 비교적 간단하므로 람다 표현식으로 불필요한 코드를 제거하는 것이 바람직하다. 하지만 옵저버가 상태를 가지며, 여러 메서드를 정의하는 등 복잡하다면 람다 표현식보다 기존의 클래스 구현방식을 고수하는 것이 바람직할 수도 있다.

 

4. 의무 체인

작업 처리 객체의 체인을 만들 때는 의무 체인 패턴을 사용한다. 한 객체가 어떤 작업을 처리한 다음에 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음에 또 다른 객체로 전달하는 식이다. 일반적으로 다음으로 처리할 객체 정보를 유지하는 필드를 포함하는 작업 처리 추상 클래스로 의무 체인 패턴을 구성한다. 작업 처리 객체가 자신의 작업을 끝냈으면 다음 작업 처리 객체로 결과를 전달한다.

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 = handleWork(input);
      if (successor != null) {
        return successor.handle(r);
      }
      return r;
    }

    abstract protected T handleWork(T input);
}

handle 메서드는 일부 작업을 어떻게 처리해야 할지 전체적으로 기술한다. ProcessingObject 클래스를 상속받아 handleWork 메서드를 구현하여 다양한 종류의 작업 처리 객체를 만들 수 있다.

class HeaderTextProcessing extends ProcessingObject<String> {
    public String handleWork(String text) {
      return "From Raoul, Mario and Alan: " + text;
    }
}

class SpellCheckerProcessing extends ProcessingObject<String> {
    public String handleWork(String text) {
      return text.replaceAll("labda", "lambda");
    }
}

ProcessingObject<String> p1 = new HeaderTextProcessing();
ProcessingObject<String> p2 = new SpellCheckerProcessing();
p1.setSuccessor(p2);
String result = p1.handle("Aren't labdas really sexy?!!");
System.out.println(result);

 

람다 표현식 사용

잠깐! 이 패턴은 함수 체인과 비슷하지 않은가! 람다 표현식을 조합하는 방법은 3장에서 살펴봤다. 작업 처리 객체를 Function<String, String>, 더 정확히 표현하자면 UnaryOperator<String> 형식의 인스턴스로 표현할 수 있다. andThen 메서드로 이들 함수를 조합해서 체인을 만들 수 있다.

UnaryOperator<String> headerProcessing = 
	(String text) -> "From Raoul, Mario and Alan: " + text;
UnaryOperator<String> spellCheckerProcessing = 
	(String text) -> text.replaceAll("labda", "lambda");
Function<String, String> pipeline = headerProcessing.andThen(spellCheckerProcessing);
String result2 = pipeline.apply("Aren't labdas really sexy?!!");
// 'From Raoul, Mario and Alan: Aren't lambdas really sexy?!!' 출력

 

5. 팩토리

인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다.

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("No such product " + name);
      }
    }
}

Product p = ProductFactory.createProduct("loan");

여기서 Loan, Stock, Bond는 모두 Product의 서브 형식이다. createProduct 메서드는 생산된 상품을 설정하는 로직을 포함할 수 있다. 이는 부가적인 기능일 뿐 위 코드의 진짜 장점은 생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 상품을 생산할 수 있다는 것이다.

 

람다 표현식 사용

Supplier<Product> loanSupplier = Loan::new;
Loan loan = loanSupplier.get();
// Supplier을 이용해 람다 표현식으로 구현할 수 있다.

final static private Map<String, Supplier<Product>> map = new HashMap<>();
static {
    map.put("loan", Loan::new);
    map.put("stock", Stock::new);
    map.put("bond", Bond::new);
}
// Map으로 만들어서 코드를 재구현할 수 있다.

public static Product createProductLambda(String name) {
      Supplier<Product> p = map.get(name);
      if (p != null) {
        return p.get();
      }
      throw new RuntimeException("No such product " + name);
}
// 이렇게 인스턴스화할 수 있다.

팩토리 패턴이 수행하던 작업을 자바 8의 새로운 기능으로 깔끔하게 정리했다. 하지만 팩토리 메서드 createProduct가 상품 생성자로 여러 인수를 전달하는 상황에서는 이 기법을 적용하기 어렵다. 단순한 Supplier 함수형 인터페이스로는 이 문제를 해결할 수 없다.

예를 들어 세 인수 (Integer 둘, 문자열 하나) 를 받는 상품의 생성자가 있다고 가정하자. 세 인수를 지원하려면 TriFunction이라는 특별한 함수형 인터페이스를 만들어야 한다. 결국 다음 코드처럼 Map의 시그니처가 복잡해진다.

public interface TriFunction<T, U, V, R> {
	R apply(T t, U u, V v);
}

Map<String, TriFunction<Integer, Integer, String, Product>> map = new HashMap<>();

 

3. 람다 테스팅

일반적으로 좋은 소프트웨어 공학자라면 프로그램이 의도대로 동작하는지 확인할 수 있는 단위 테스팅을 진행한다. 우리는 소스 코드의 일부가 예상된 결과를 도출할 것이라 단언하는 테스트 케이스를 구현한다.

class Point {
	private int x;
    private int y;

    private Point(int x, int y) {
      this.x = x;
      this.y = y;
    }
    public int getX() {
      return x;
    }
    public void setX(int x) {
      this.x = x;
    }
    
    public Point moveRightBy(int x) {
    	return new Point(this.x + x, this.y);
    }
}

@Test
public void testMoveRightBy() throws Exception {
	Point p1 = new Point(5, 5);
    Point p2 = p1.moveRightBy(10);
    assertEquals(15, p2.getX());
    assertEquals(5, p2.getY());
}
// moveRightBy 메서드 테스트 코드

 

1. 보이는 람다 표현식의 동작 테스팅

람다는 익명이므로 테스트 코드 이름을 호출할 수 없다. 따라서 필요하다면 람다를 필드에 재사용할 수 있으며 람다의 로직을 테스트할 수 있다.

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

@Test
public void testComparingTwoPoints() throws Exception {
	Point p1 = new Point(10, 15);
    Point p2 = new Point(10, 20);
   	int result = Point.compareByXAndThenY.compare(p1, p2);
    assertTrue(result < 0);
}

람다 표현식은 함수형 인터페이스의 인스턴스를 생성한다는 것을 기억하자. 따라서 생성된 인스턴스의 동작으로 람다 표현식을 테스트할 수 있다.

 

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

람다의 목표는 정해진 동작을 다른 메서드에서 사용할 수 있도록 하나의 조각으로 캡슐화하는 것이다. 그러려면 세부 구현을 포함하는 람다 표현식을 공개하지 말아야 한다. 람다 표현식을 사용하는 메서드의 동작을 테스트함으로써 람다를 공개하지 않으면서도 람다 표현식을 검증할 수 있다.

public static List<Point> moveAllPointsRightBy(List<Point> points, int x) {
	return points.stream()
    			.map(p -> new Point(p.getX() + x, p.getY()))
                .collect(toList());
}
// 람다 표현식 p -> new Point(p.getX() + x, p.getY());를 테스트하는 부분이 없다.
// 그냥 moveAllPointsRightBy 메서드를 구현한 코드일 뿐이다.

@Test
public void testMoveAllPointsRightBy() throws Exception {
	List<Point> points =
    	Arrays.asList(new Point(5, 5), new Point(10, 5));
    List<Point> expectedPoints =
    	Arrays.asList(new Point(15, 5), new Point(20, 5));
    List<Point> newPoints = Point.moveAllPointsRightBy(points, 10);
    assertEquals(expectedPoints, newPoints);
}

위 단위 테스트에서 보여주는 것처럼 Point 클래스의 equals 메서드는 중요한 메서드다. 따라서 Object의 기본적인 equals 구현을 그대로 사용하지 않으려면 equals 메서드를 적절하게 구현해야 한다.

 

3. 복잡한 람다를 개별 메서드로 분할하기

테스트 코드에서는 람다 표현식을 참조할 수 없는데, 복잡한 람다 표현식은 어떻게 테스트 할 것인가? 한 가지 해결책은 위에서 설명한 것처럼 람다 표현식을 메서드 참조로 바꾸는 것이다(새로운 일반 메서드 선언). 그러면 일반 메서드를 테스트하듯이 람다 표현식을 테스트할 수 있다.

 

4. 고차원 함수 테스팅

함수를 인수로 받거나 다른 함수를 반환하는 메서드는 좀 더 사용하기 어렵다. 메서드가 람다를 인수로 받는다면 다른 람다로 메서드의 동작을 테스트할 수 있다.

@Test
public void testFilter() throws Exception {
	List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
    List<Integer> even = filter(numbers, i -> i%2 == 0);
    List<Integer> smallerThanThree = filter(numbers, i -> i<3);
    assertEquals(Arrays.asList(2, 4), even);
    assertEquals(Arrays.asList(1, 2), smallerThanThree);
}

테스트해야 할 메서드가 다른 함수를 반환한다면 어떻게 해야 할까? 이때는 Comparator에서 살펴봤던 것처럼 함수형 인터페이스의 인스턴스로 간주하고 함수의 동작을 테스트할 수 있다. 모든 일이 한 번에 잘 되는 법은 없다. 코드를 테스트하면서 람다 표현식에 어떤 문제가 있음을 발견하게 될 것이다. 그래서 디버깅이 필요하다.

 

4. 디버깅

문제가 발생한 코드를 디버깅할 때 개발자는 다음 두 가지를 가장 먼저 확인해야 한다.

  • 스택 트레이스
  • 로깅

하지만 람다 표현식과 스트림은 기존의 디버깅 기법을 무력화한다.

 

1. 스택 트레이스 확인

유감스럽게도 람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다.

public class Debugging {
  public static void main(String[] args) {
    List<Point> points = Arrays.asList(new Point(12, 2), null);
    points.stream().map(p -> p.getX()).forEach(System.out::println);
  }
}
Exception in thread "main" java.lang.NullPointerException
	at org.chap09.Debugging.lambda$main$0(Debugging.java:9)
    // $0은 무슨 의미일까?
	at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
	at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)
	at java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:482)
	at java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:472)

무슨 일이 일어난 걸까? 물론 의도했던 대로 points 리스트의 둘째 인수가 null이므로 프로그램의 실행이 멈췄다. 스트림 파이프라인에서 에러가 발생했으므로 스트림 파이프라인 작업과 관련된 전체 메서드 호출 리스트가 출력되었다. 메서드 호출 리스트에 다음처럼 수수께끼 같은 정보도 포함되어 있다. 

at org.chap09.Debugging.lambda$main$0(Debugging.java:9)

이와 같은 이상한 문자는 람다 표현식 내부에서 에러가 발생했음을 가리킨다. 람다 표현식은 이름이 없으므로 컴파일러가 람다를 참조하는 이름을 만들어낸 것이다. lambda$main$0는 다소 생소한 이름이다.

메서드 참조를 사용해도 스택 트레이스에는 메서드 명이 나타나지 않는다. 기존의 람다 표현식 p -> p.getX()를 메서드 참조 Point::getX로 고쳐도 여전히 스택 트레이스로는 이상한 정보가 출력된다.

points.stream().map(Point::getX).forEach(System.out::println);

at Debugging$$Lambda$5/284720968.apply(Unknown Source)
// 나는 이건 직접 실행해봐도 안뜬다.. 무슨 의미일까?
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)

메서드 참조를 사용하는 클래스와 같은 곳에 선언되어 있는 메서드를 참조할 때는 메서드 참조 이름이 스택 트레이스에 나타난다.

public class Debugging {
  public static void main(String[] args) {
  	List<Integer> numbers = Arrays.asList(1, 2, 3);
    numbers.stream().map(Debugging::divideByZero).forEach(System.out::println);
  }
  
  public static int divideByZero(int n) {
  	return n/0;
  }
}

Exception in thread "main" java.lang.ArithmeticException: / by zero
	at org.chap09.Debugging.divideByZero(Debugging.java:17)
// divideByZero 메서드는 스택 트레이스에 제대로 표시된다.

따라서 람다 표현식과 관련한 스택 트레이스는 이해하기 어려울 수 있다는 점을 염두에 두자.

 

2. 정보 로깅

스트림의 파이프라인 연산을 디버깅한다고 가정하자. 무엇을 할 수 있을까? 다음처럼 forEach로 스트림 결과를 출력하거나 로깅할 수 있다.

List<Integer> numbers = Arrays.asList(2, 3, 4, 5);

numbers.stream()
	.map(x -> x+17)
    .filter(x -> x%2 == 0)
    .limit(3)
    .forEach(System.out::println);
    
// 프로그램의 출력 결과는 20, 22다.

안타깝게도 forEach를 호출하는 순간 전체 스트림이 소비된다. 스트림 파이프라인에 적용된 각각의 연산(map, filter, limit)이 어떤 결과를 도출하는지 확인할 수 있다면 좋을 것 같다. 바로 peek라는 스트림 연산을 활용할 수 있다. peek은 스트림의 각 요소를 소비한 것처럼 동작을 실행한다. 하지만 forEach처럼 실제로 스트림의 요소를 소비하지는 않는다. peek은 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달한다.

List<Integer> result = numbers.stream()
		// 2, 3, 4, 5
        .peek(x -> System.out.println("from stream: " + x))
        // 소스에서 처음 소비한 요소를 출력한다.
        .map(x -> x + 17)
        .peek(x -> System.out.println("after map: " + x))
        // map 동작 실행 결과를 출력한다.
        .filter(x -> x % 2 == 0)
        .peek(x -> System.out.println("after filter: " + x))
        // filter 동작 후 선택된 숫자를 출력한다.
        .limit(3)
        .peek(x -> System.out.println("after limit: " + x))
        // limit 동작 후 선택된 숫자를 출력한다.
        .collect(toList());
        
// 다음은 파이프라인의 각 단계별 상태를 보여준다.

from stream: 2
after map: 19
from stream: 3
after map: 20
after filter: 20
after limit: 20
from stream: 4
after map: 21
from stream: 5
after map: 22
after filter: 22
after limit: 22

 

마치며

  • 람다 표현식으로 가독성이 좋고 더 유연한 코드를 만들 수 있다.
  • 익명 클래스는 람다 표현식으로 바꾸는 것이 좋다. 하지만 이때 this, 변수 섀도 등 미묘하게 의미상 다른 내용이 있음을 주의하자.
  • 메서드 참조로 람다 표현식보다 더 가독성이 좋은 코드를 구현할 수 있다.
  • 반복적으로 컬렉션을 처리하는 루틴은 스트림 API로 대체할 수 있을지 고려하는 것이 좋다.
  • 람다 표현식으로 전략, 템플릿 메서드, 옵저버, 의무 체인, 팩토리 등의 객체지향 디자인 패턴에서 발생하는 불필요한 코드를 제거할 수 있다.
  • 람다 표현식도 단위 테스트를 수행할 수 있다. 하지만 람다 표현식 자체를 테스트하는 것 보다는 람다 표현식이 사용되는 메서드의 동작을 테스트하는 것이 바람직하다.
  • 복잡한 람다 표현식은 일반 메서드로 재구현할 수 있다.
  • 람다 표현식을 사용하면 스택 트레이스를 이해하기 어려워진다.
  • 스트림 파이프라인에서 요소를 처리할 때 peek 메서드로 중간값을 확인할 수 있다.