devseop08 님의 블로그

[API] 람다와 스트림: Mordern Java - 9. 리팩터링, 테스팅, 디버깅 본문

Language/Java

[API] 람다와 스트림: Mordern Java - 9. 리팩터링, 테스팅, 디버깅

devseop08 2025. 7. 10. 01:35
  • 람다 표현식을 이용해 가독성과 유연성을 높이기 위한 코드 리팩터링
  • 람다 표현식으로 전략, 템플릿 메서드, 옵저버, 팩토리 등의 객체지향 디자인 패턴을 어떻게 간소화 시킬지
  • 람다 표현식과 스트림 API를 사용하는 코드를 테스트하고 디버깅하는 방법

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

9.1.1 코드 가독성 개선

  • 람다, 메서드 참조, 스트림을 황용해서 코드 가동성 개선 예제 3가지
    • 익명 클래스를 람다 표현식으로 리팩터링
    • 람다 표현식을 메서드 참조로 리팩터링
    • 명령형 데이터 처리를 스트림으로 리팩터링

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

  • 익명 클래스는 코드를 장황하게 만들고 쉽게 에러를 일으킨다.
  • 람다 표현식으로 간결하고 가독성이 좋은 코드를 만들 수 있다.
Runnable r1 = new Runnable() {
    public void run() {
        System.out.println("Hello");
    }
}

Runnable r2 = () -> System.out.println("Hello");
  • 하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다.
      1. 익명 클래스에서 사용한 this와 super는 람다 표현식에서 다른 의미: 익명 클래스에서 this는 익명 클래스 자신을 가리키지만, 람다에서 this는 람다를 감싸는 클래스 가리킴
      1. 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있지만(shadowing 가능), 람다 표현식으로는 람다를 감싸고 있는 클래스의 변수를 가릴 수 없기 때문에 컴파일 에러
      int a = 10;
      Runnable r1 = () -> {
        int a = 10; // 컴파일 에러
        System.out.println(a);
      }
      
      Runnable r2 = new Runnable(){
        public void run(){
            int a = 2;
            System.out.println(a);
        }
      }
      1. 익명 클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래될 가능성
      interface Task{
        public void execute();
      }
      
      public static void doSomething(Runnable r){
        r.run();
      }
      public static void doSomething(Task t){
        t.execute();
      }
      doSomething(new Task(){
        public void execute(){
            System.out.println("Danger");
        }
      })
    • 익명 클래스를 람다 표현식으로 바꾸면 Runnable과 Task 모두 대상 형식이 될 수 있으므로 문제가 생긴다.
    • doSomething(() -> System.out.println("Danger"));
    • 명시적 형변환으로 문제 해결
    • doSomething((Task) () -> System.out.println("Danger"));

9.1.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(this.getCalories() <= 400) return CaloricLevel.DIET;
        else if(this.getCalories() <= 700) return CaloricLevel.NORMAL;
        else return CaloricLevel.FAT;
    }
}
  • comparing과 maxBy 같은 정적 헬퍼 메서드를 활용하는 것도 좋다.
  • 이들은 메서드 참조와 조화를 이루도록 설계되었다.
inventory.sort(
    (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
inventory.sort(comparing(Apple::getWeight));
  • sum, maximum 등 리듀싱 연산은 메서드 참조와 함께 사용할 수 잇는 내장 헬퍼 메서드를 제공
  • 최댓값이나 합계를 계산할 때 람다 표현식과 저수준 리듀싱 연산을 조합하는 것보다 Collectors API를 사용하면 코드의 의도가 더 명확해진다.
int totalCalories = 
    menu.stream().map(Dish::getCalories)
                 .reduce(0, (c1, c2) -> c1 + c2);
int totalCalories = 
    menu.stream().collect(summingInt(Dish::getCalories))

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

List<String> dishNames = new ArrayList<>();
for(Dish dish: menu){
    if(dish.getCalories() > 300) {
        dishNames.add(dish.getName());
    }
}
menu.parallelStream()
    .filter(dish -> dish.getCalories() > 300)
    .map(Dish::getName)'
    .collect(toList());

9.1.5 코드 유연성 개선

조건부 연기 실행
  • 변수가 특정 상태를 만족하면 실행 동작을 결정하는 if문이 자주 반복되는 코드 구조에서 반복을 없애기 위해 해당 if문을 메서드화 시킨다고 해보자
  • 이 때 메서드의 매개 변수 중 하나를 함수형 인터페이스 타입으로 선언하고 if문 안의 실행 동작을 바디의 실행문으로 하는 람다를 매개 변수의 매개값으로 전달하면 반복을 제거함과 동시에 유연한 코드 구조를 얻을 수 있다.
실행 어라운드
  • 매번 같은 준비, 종료 과정을 반복적으로 수행하고 실행 동작은 달라지는 코드가 있다면 이를 람다로 변환할 수 있다.
  • 준비, 종료 과정을 처리하는 로직을 재사용함으로써 코드 중복을 제거하고 유연한 코드 구조를얻을 수 있다.
String oneLine = processFile((BufferedReader br) -> br.readLine());
String twoLine = processFile((BufferedReader br) -> br.readLine() + br.readLine());

public static String processFile(BufferedReaderProcessor p) 
    throws IOException{
        try( // 준비 과정
            BufferedReader br = new BuffredReader(new FileReader("data.txt"));    
        ){
            return p.process(br);
        }
}

@FunctionalInterface
public interface BufferedReaderProcessor{
    String process(BufferedReader br) throws IOException;
}
  • 람다로 BufferedReader 객체의 동작을 결정할 수 있는 것은 함수형 인터페이스 BufferedReaderProcessor 덕분이다.
실행 어라운드

9.2 람다로 객체지향 디자인 패턴 리팩터링

9.2.1 전략

  • 전략 패턴의 세 가지 부분
      1. 알고리즘을 나타내는 인터페이스
      1. 다양한 알고리즘을 나타내는 한 개 이상의 인터페이스 구현
      1. 전략 객체를 사용하는 한 개 이상의 클라이언트
public interface ValidatorStrategy { // 알고리즘을 나타내는 인터페이스
    boolean execute(String s);
}
public class IsAllLowerCase implements ValidatorStrategy { // 인터페이스 구현
    public boolean execute(String s){
        return s.matches("[a-z]+");
    }
}
public class IsNumeric implements ValidatorStrategy {
    public boolean execute(String s){
        return s.matches("\\d+");
    }
}
public class Validator {
    private ValidatorStrategy strategy;
    public Validator(ValidatorStrategy v){
        this.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")
람다 표현식 사용
Validator numericValidator = new Validator((String s) -> s.matches("\\d+"));
boolean b1 = numericValidator.validate("aaaa");
Validator lowerCaseValidator = new Validator((String s) -> s.matches("\\d+"));
boolean b2 = lowerCaseValidator.validate("bbbb")

9.2.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> makeCutomerHappy) {
        Customer c = Database.getCustomerWithId(id); 
        makeCustomerHappy.accept(c);
    }
new OnlineBankingLamda().processCustomer(1337, (Customer c) -> 
        System.out.println("Hello " + c.getName()));

9.2.3 옵저버

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

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) {
        this.observers.add(o);
    }

    public void notifyObservers(String tweet) {
        observers.forEach(
            o -> o.notify(tweet);
        );
    }
} 
interface Observer {
    void notify(String tweet);
}
class NYTimes implements Observer {
    public void notify(String tweet) {
        if(tweet != null && tweet.contains("money")){
            System.out.println("Breaking news in NY! " + tweet);
        }
    }
}

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

class NYTimes implements Observer {
    public void notify(String tweet) {
        if(tweet != null && tweet.contains("wine")){
            System.out.println("Today cheese, wine and news! " + tweet);
        }
    }
}
Feed f = new Fee();
f.registerObserver(new NYTimes());
f.registerObserver(new Guardian());
f.registerObserver(new LeMonde());
f.notifyObservers("The queen said her favorite book is Modern Java in Action");
람다 표현식 사용
f.registerObserver( (String tweeet) ->
    if(tweet != null && tweet.contains("money")){
            System.out.println("Breaking news in NY! " + tweet);
        }
);

f.registerObserver( (String tweeet) ->
    if(tweet != null && tweet.contains("queen")){
            System.out.println("Yet more news from London... " + tweet);
        }
);

9.2.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);
        }
    }
    abstract protected T handleWork(T input);
}
  • ProcessingObject 클래스를 상속받아 handleWork 메서드를 구현하여 다양한 종류의 작업 처리 객체를 만들 수 있다.
public class HeaderTextProcessing extends ProcessingObject<String> {
     protected String handleWork(String text) {
         return "From Raoul, Mario and Alan: " + text;
     }
}
public class SpellCheckerProcessing extends ProcessingObject<String> {
    protected 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);
// From Raoul, Mario and Alan: Aren't lambdas really sexy?
람다 표현식 사용
  • 의무 체인 패턴은 함수 체인, 함수 조합과 비슷하다.
  • UnaryOperator< T > 타입의 작업 처리 객체들을 andThen 메서드로 연결, 조합하여
    Function< T, R> 타입의 체인 객체를 만들 수 있다.
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 result = pipeline.apply("Aren't labdas really sexy?");

9.2.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 RuntimeException("No such product" + name);
        }
    }
}
  • 팩토리 디자인 패턴 코드의 장점은 생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 상품을 클라이언트가 단순하게 상품을 생산할 수 있다는 것이다.
Product p = Product.createProduct("loan");
람다 표현식 사용
  • 생성자도 메서드 참조처럼 접근 가능
public class ProductFactory {
    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) {
        Suppllier<Product> p = map.get(name);
        if(p != null) return p.get();
        throw new IllegalArgumentException("No such product " + name);
    }
}
  • 팩토리 메서드 createProduct가 상품 생성자로 여러 인수를 전달하는 상황에서는 Supplier 함수형 인터페이스가 아닌 여러 인수를 전달받는 추상 메서드를 갖는 함수형 인터페이스를 선언해 사용해야 한다.
@FunctionalInterface
public interface TriFunctino<T, U, V, R> {
    R apply(T t, U u, V v);
}
Map<String, TriFunction<Integer, Integer, String, Product>> map
    = new HashMap<>();

9.3 람다 테스팅

public class Point {
    private final int x;
    private final int y;

    private Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    public int getX() { return x; }
    public int getY() { return y; }
    public Point moveRightBy(int x) {
        return new Point(this.x + x, this.y)
    }
}
  • moveRightBy 메서드가 의도한 대로 동작하는 지 확인하는 단위 테스트
@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());
}

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

  • 람다는 익명이므로 람다 자체를 테스트 코드 이름으로 호출할 수 없다.
  • 필요하다면 람다를 필드에 저장해서 재사용할 수 있으며 람다의 로직을 테스트할 수 있다.
  • 이렇게 하면 메서드를 호출하는 것처럼 람다를 사용할 수 있다.
  • Point 클래스에 Compartor< Point > 타입의 정적 필드 compareByXAndThenY 선언
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);
}

9.3.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())을 테스트하는 부분은 없다.
@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);
}
  • assertEquals 메서드를 위해 Point 클래스의 equals 메서드는 중요한 메서드다.
  • Object의 기본적인 equals 구현을 그대로 사용하지 않으려면 equals 메서드를 적절하게 구현해 한다.
  • 9.3.3 복잡한 람다를 개별 메서드로 분할하기
  • 람다 표현식을 메서드 참조로 바꾸면 일반 메서드를 테스트하듯이 람다 표현식을 테스트할 수 있다.

9.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);
}
  • 테스트해야 할 메서드가 다른 함수를 반환한다면 반환하는 함수를 함수형 인터페이스의 인스턴스로 간주하고 함수의 동작을 테스트할 수 있다.

9.4 디버깅

  • 문제가 발생한 코드를 디버깅할 때 확인할 두 가지
      1. 스택 트레이스
      1. 로깅
  • 람다 표현식과 스트림은 기존의 디버깅 기법을 무력화한다.

9.4.1 스택 트레이스 확인

  • 예외 발생으로 프로그램 실행이 갑자기 중단되었다면 먼저 어디에서 멈췄고 어떻게 멈추게 되었는지 살펴봐야 한다.
  • 스택 프레임에서 이 정보를 얻을 수 있다.
  • 프로그램이 멈췄다면 프로그램이 어떻게 멈추게 되었는지 프레임별로 보여주는 스택 트레이스를 얻을 수 있다.

람다와 스택 트레이스

  • 람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다.
  • 람다 표현식과 메서드 참조 모두 에러 발생 시 스택 트레이스에서는 알아보기 힘든 정보로 제공된다.
  • 다만 메서드 참조를 사용하는 클래스와 같은 곳에 선언되어 있는 메서드를 참조할 때는 메서드 참조 이름이 스택 트레이스에 나타난다.

9.4.2 정보 로깅

  • 스트림의 파이프라인 디버깅
  • peek 스트림 연산 활용
  • peek은 스트림의 각 요소를 소비한 것처럼 동작을 실행하지만 forEach 처럼 실제로 스트림의 요소를 소비하지는 않는다.
  • peek은 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달한다.
List<Integer> result = 
    numbers.stream()
           .peek(x -> System.out.println("from stream: " + x))
           .map(x -> x + 17)
           .peek(x -> System.out.println("afer map: " + x))
           .filter(x -> x % 2 == 0)
           .peek(x -> System.out.println("after filter" + x))
           .limit(3)
           .peek(x -> System.out.println("after limit: " + x))
           .collect(toList());