Chap09. 리팩터링, 테스팅, 디버깅

728x90

람다 표현식을 이용해 가독성과 유연성을 높이려면 기존 코드를 어떻게 리팩터링해야 하는지 설명한다. 또한 람다 표현식으로 전략, 템플릿 메서드, 옵저버, 의무 체인, 팩토리 등의 객체지향 디자인 패턴을 어떻게 간소화할 수 있는지도 살펴본다. 마지막으로 람다 표현식과 스트림 API를 사용하는 코드를 테스트하고 디버깅하는 방법을 설명한다.

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

람다 표현식을 이용한 코드는 다양한 요구사항 변화에 대응할 수 있도록 동작을 파라미터화한다.

코드의 가독성을 높이려면 코드의 무선화를 잘하고, 표준 코딩 규칙을 준수하는 등의 노력을 기울여야 한다.

자바 8의 새 기능을 이용해 코드의 가독성을 높일 수 있다. 코드를 간결하고 이해하기 쉽게 만들 수 있다. 또한 메서드 참조와 스트림 API를 이용해 코드의 의도를 명확하게 보여줄 수 있다.

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

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

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

익명 클래스는 람다 표현식을 이용해서 간결하고, 가독성이 좋은 코드로 구현할 수 있었다. 하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다.

  1. 익명 클래스에서 사용한 this와 super는 람다 표현식에서 다른 의미를 갖는다.
    익명 클래스에서 this는 익명클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다.
  2. 익명 클래스는 감싸고 있는 클래스의 변수를 가리킬 수 있지만 람다 표현식으로는 변수를 가릴 수 없다. (섀도 변수shadow variable)
  3. 익명 클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래될 수 있다.
    익명 클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라지기 때문이다. 이런 문제는 명시적 형변환 (타입) 람다식를 이용해서 모호함을 제거할 수 있다.

대부분의 IDE는 똑똑하게 이런 문제점들을 자동으로 해결해주니 너무 걱정하지는 말자! (갓텔리제이 💪)

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

람다 표현식은 쉽게 전달할 수 있는 짧은 코드다. 하지만 람다 표현식 대신 메서드 참조를 이용하면 가독성을 높일 수 있다. 메서드 참조의 메서드명으로 코드의 의도를 명확하게 알릴 수 있기 때문이다.

또한 comparing과 maxBy 같은 정적 헬퍼 메서드를 활용하는 것도 좋다. 이들은 메서드 참조와 조화를 이루도록 설계되었다.
sum, maximum 등 자주 사용하는 리듀싱 연산은 메서드 참조와 함께 사용할 수 있는 내장 헬퍼 메서드를 제공한다. 예를 들어 최댓값이나 합계를 계산할 때 람다 표현식과 저수준 리듀싱 연산을 조합하는 것보다 Collectors.API를 사용하면 코드의 의도가 더 명확해진다.

내장 컬렉터를 이용하면 코드 자체로 문제를 더 명확하게 설명할 수 있다. 다음 코드에서는 컬렉터 summingInt를 사용했다.

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

이론적으로는 반복자를 이용한 기존의 모든 컬렉션 처리 코드를 스트림 API로 바꿔야 한다. 스트림 API는 데이터 처리 파이프라인의 의도를 더 명확하게 보여준다. 스트림은 쇼트서킷게으름이라는 강력한 최적화뿐 아니라 멀티코어 아키텍처를 활용할 수 있는 지름길을 제공한다.

코드 유연성 개선

람다 표현식을 이용하면 동작 파라미터화를 쉽게 구현할 수 있음을 살펴봤다. 즉, 다양한 람다를 전달해서 다양한 동작을 표현할 수 있다. 따라서 변화하는 요구사항에 대응할 수 있는 코드를 구현할 수 있다.

함수형 인터페이스 적용

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

조건부 연기 실행

실제 작업을 처리하는 코드 내부에 제어 흐름문이 복잡하게 얽힌 코드를 흔히 볼 수 있다. 흔히 보안 검사나 로깅 관련 코드가 이처럼 사용이된다. 다음은 내장 자바 Logger 클래스를 사용하는 예제다.

if (logger.isLoggable(Log.FINER) {
    logger.finer("Problem: " + generateDiagnostic());
}

위 코드는 다음과 같은 사항에 문제가 생긴다.

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

다음처럼 메시지를 로깅하기 전에 logger 객체가 적절한 수준으로 설정되었는지 내부적으로 확인하는 log 메서드를 사용하는 것이 바람직하다.

logger.log(Lever.FINER, "Problem: " + generateDiagnostic());

덕분에 불필요한 if문을 제거할 수 있고 logger의 상태를 클라이언트에 노출할 필요도 없으므로 위 코드가 더 바람직한 구현이다. 하지만 위 코드도 완전하지는 않다. 인수로 전달된 메시지 수준에서 logger가 활성화되어 있지 않더라도 항상 로깅 메시지를 평가하게 된다.

람다를 이용하면 이 문제를 쉽게 해결할 수 있다. 특정 조건에서만 메시지가 생성될 수 있도록 메시지 생성 과정을 연기defer 할 수 있어야 한다. 자바 8 API 설계자는 이와 같은 logger 문제를 해결할 수 있도록 Supplier를 인수로 갖는 오버로드된 log메서드를 제공했다. 다음은 새로 추가된 log 메서드의 시그니처다.

public void log(Level level, Supplier<String> msgSupplier)

다음처럼 log 메서드를 호출할 수 있다.

logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());

log 메서드는 logger의 수준이 적절하게 설정되어 있을 때만 인수로 넘겨진 람다를 내부적으로 실행한다. 다음은 log 메서드의 내부 구현 코드다.

public void log(Level level, Supplier<String> msgSupplier) {
    if(logger.isLoggable(level)) {
        log(level, msgSupplier.get()); // 람다 실행
    }
}

이 기법으로 어떤 문제를 해결할 수 있을까? 만일 클라이언트 코드에서 객체 상태를 자주 확인하거나 객체의 일부 메서드를 호출하는 상황이라면 내부적으로 객체의 상태를 확인한 다음에 메서드를 호출하도록 새로운 메서드를 구현하는 것이 좋다. 그러면 코드 가독성이 좋아질 뿐 아니라 캡슐화도 강화된다.

실행 어라운드

매번 같은 준비, 종료 과정을 반복적으로 수행하는 코드가 있다면 이를 람다로 변환할 수 있다. 준비, 종료 과정을 처리하는 로직을 재사용함으로써 코드 중복을 줄일 수 있다.

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

언어에 새로운 기능이 추가되면서 기존 코드 패턴이나 관용코드의 인기가 식기도 한다. 다양한 패턴을 유형별로 정리한 것이 디자인 패턴(design pattern)이다. 디자인 패턴은 공통적인 소프트웨어 문제를 설계할 때 재사용할 수 있는, 검증된 청사진을 제공한다.

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

이 절에서는 다음 다섯 가지 패턴을 살펴본다.

  • 전략(strategy)
  • 템플릿 메서드(template method)
  • 옵저버(observer)
  • 의무 체인(chain of responsibility)
  • 팩토리(factory)

각 디자인 패턴에서 람다를 어떻게 활용할 수 있는지 설명한다.

전략

전략 패턴은 한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다.

아래의 그림에서 보여주는 것처럼 전략 패턴은 세 부분으로 구성된다.

image

  • 알고리즘을 나타내는 인터페이스 (Strategy 인터페이스)
  • 다양한 알고리즘을 나타내는 한 개 이상의 인터페이스 구현(ConcreteStrategyA, ConcreteStrategyB 같은 구체적인 구현 클래스)
  • 전략 객체를 사용하는 한 개 이상의 클라이언트

오직 소문자 또는 숫자로 이루어져야 하는 등 텍스트 입력이 다양한 조건에 맞게 포맷되어 있는지 검증한다고 가정하자. 먼저 String 문자열을 검증하는 인터페이스부터 구현한다.

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.metches("\\d+");
    }
}

지금까지 구현한 클래스를 다양한 검증 전략으로 활용할 수 있다.

public class Validator {
    private final ValidationStrategy strategy;

    public Validator(ValidationStrategy 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");

람다 표현식 사용

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

Validator numericValidator = new Validator((String s) -> s.matches("[a-z]+")); // 람다를 직접 전달
boolean b1 = numericValidator.validate("aaaa");
Validator lowerCaseValidator = new Validator((String s) -> s.matches("\\d+")); // 람다를 직접 전달 
boolean b2 = lowerCaseValidator.validate("bbbb");

템플릿 메서드

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

예를 들어 간단한 온라인 뱅킹 애플리케이션을 구현한다고 가정하자. 사용자가 고객 ID를 애플리케이션에 입력하면 은행 데이터베이스에서 고객 정보를 가져오고 고객이 원하는 서비스를 제공할 수 있다. 은행마다 다양한 온라인 뱅킹 애플리케이션을 사용하며 동작 방법도 다르다. 다음은 온라인 뱅킹 애플리케이션의 동작을 정의하는 추상 클래스다.

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

processCustomer 메서드는 온라인 뱅킹 알고리즘이 해야 할 일을 보여준다. 우선 주어진 고객 ID를 이용해서 고객을 만족시켜야 한다. 각각의 지점은 OnlineBanking 클래스를 상속받아 makeCustomerHappy 메서드가 원하는 동작을 수행하도록 구현할 수 있다.

람다 표현식 사용

람다를 이용해서 문제를 해결할 수 있다. 람다나 메서드 참조로 알고리즘에 추가 할 다양한 컴포넌트를 구현할 수 있다. 이전에 정의한 makeCustomerHappy의 메서드 시그니처와 일치하도록 Consumer<Customer> 형식을 갖는 두 번째 인수를 processCustomer에 추가한다.

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

이제 onlineBanking 클래스를 상속받지 않고 직접 람다 표현식을 전달해서 다양한 동작을 추가할 수 있다.

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

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

옵저버

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

예를 들어 주식의 가격 변동에 반응하는 다수의 거래자 예제에서도 옵저버 패턴을 사용할 수 있다. 아래의 그림은 옵저버 패턴의 UML 다이어그램을 보여준다.

image

실제 코드로 옵저버 패턴이 어떻게 동작하는지 살펴보자. 옵저버 패턴으로 트위터 같은 커스터마이즈된 알림 시스템을 설계하고 구현할 수 있다.

우선 다양한 옵저버를 그룹화할 Observer 인터페이스가 필요하다. Observer 인터페이스는 새로운 트윗이 있을 때 주제가 호출할 수 있도록 notify라고 하는 하나의 메서드를 제공한다.

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("Today cheese, wine and news"! " + tweet);
        }
    }
}

그리고 주제도 구현해야 한다. 다음은 Subject 인터페이스의 정의다.

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

주제는 registerObserver 메서드로 새로운 옵저버를 등록한 다음에 notifyObservers 메서드로 트윗의 옵저버에 이를 알린다.

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

구현은 간단하다. Feed는 트윗을 받았을 때 알림을 보낼 옵저버 리스트를 유지한다. 이제 주제와 옵저버를 연결하는 데모 애플리케이션을 만들 수 있다.

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 Moder Java in Action!");

람다 표현식 사용하기

여기서 Observer 인터페이스를 구현하는 모든 클래스는 하나의 메서드 notify()를 구현했다. 즉, 트윗이 도착했을 때 어떤 동작을 수행할 것인지 감싸는 코드를 구현한 것이다. 지금까지 살펴본 것처럼 람다는 불필요한 감싸는 코드 제거를 제거해준다. 즉, 세 개의 옵저버를 명시적으로 인스턴스화하지 않고 람다 표현식을 직접 전달해서 실행할 동작을 지정할 수 있다.

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

그렇다면 항상 람다 표현식을 사용해야 할까? 물론 아니다. 만약 옵저버가 상태를 가지며, 여러 메서드를 정의하는 등 복잡하다면 람다 표현식보다 기존의 클래스 구현방식을 고수하는 것이 바람직할 수도 있다.

의무 체인

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

다음은 작업 처리 객체 예제 코드다.

public abstract class ProcessingObject<T> {
    protected ProcessingObject<T> successor;

    public void setSuccessor(ProcessingObject<T> successor) {
        this.sucessor != null;
    }

    public T handle(T input) {
        T r = handleWork(input);
        if(successor != null) {
            return successor.handle(r);
        }
        return r;
    }

    abstract protected T handleWork(T input);
}

image

위의 그림을 자세히 보면 이전에 다루었던, 템플릿 메서드 디자인 패턴이 사용되었음을 알 수 있다. handle 메서드는 일부 작업을 어떻게 처리해야 할지 전체적으로 기술한다. ProcessingObject 클래스를 상속받아 handleWork 메서드를 구현하여 다양한 종류의 작업 처리 객체를 만들 수 있다.

이 패턴을 어떻게 활용할 수 있는지 실질적인 예제를 살펴보자. 다음의 두 작업 처리 객체는 텍스트를 처리하는 예제이다.

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

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

람다 표현식 사용

이 패턴은 함수 체인과 비슷하다! 작업 처리 객체를 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 result = pipeline.apply("Aren't labdas really sexy?!!");

팩토리

인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다. 예를 들어 은행에서 취급하는 대출, 채권, 주식 등 다양한 상품을 만들어야 한다고 가정하자.

다음 코드에서 보여주는 것처럼 다양한 상품을 만드는 Factory 클래스가 필요하다.

public class ProductFactory {
    switch(name) {
        case "loan" : return new Load();
        case "stock" : return new Stock();
        case "bond" : return new Bond();
        default: throw new RuntimeException("No such product " + name):
    }
}

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

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

람다 표현식 사용

예를 들어 다음은 Loan 생성자를 사용하는 코드다.

Supplier<Product> loanSupplier = Loan::new; Loan loan = loanSupplier.get();

이제 다음 코드처럼 상품명을 생성자로 연결하는 Map을 만들어서 코드를 구현할 수 있다.

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

이제 Map을 이용해 팩토리 디자인 패턴에서 했던 것처럼 다양한 상품을 인스턴스화할 수 있다.

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

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

예를 들어 세 인수를 받는 경우에는 세 인수를 지원하기위해 TriFunction이라는 특별한 함수형 인터페이스를 만들어야 한다. 결국 Map의 시그니처가 복잡해진다.

람다 테스팅

개발자의 최종 엄무 목표는 제대로 작동하는 코드를 구현하는 것이지 깔끔한 코들르 구현하는 것이 아니다.

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

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

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

이 테스트는 문제없이 작동한다. 하지만 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다. 하지만 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다. 따라서 필요하다면 람다를 필드에 저장해서 재사용할 수 있으며 람다의 로직을 테스트할 수 있다.

람다 표현식은 함수형 인터페이스의 인스턴스를 생성한다는 사실을 기억하자. 따라서 생성된 인스턴스의 동작으로 람다 표현식을 테스트할 수 있다. 다음은 Comparator 객체 compareByXAndThenY 에 다양한 인수로 compare 메서드를 호출하면서 예상대로 동작하는지 테스트하는 코드다.

@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);
}

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

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

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 메서드를 구현한 코드일 뿐이다. 이제 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 메서드를 적절하게 구현해야 한다.

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

복잡한 람다 표현식을 접할 수 있다. 그런데 테스트 코드에서 람다 표현식을 참조할 수 없는데, 복잡한 람다 표현식을 어떻게 테스트할 것인가? 한 가지 해결책은 람다 표현식을 메서드 참조로 바꾸는 것이다. 그러면 일반 메서드를 테스트하듯이 람다 표현식을 테스트할 수 있다.

고차원 함수 테스팅

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

@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에서 살펴봤던 것러럼 함수형 인터페이스의 인스턴스로 간주하고 함수의 동작을 테스트할 수 있다.

디버깅

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

  • 스택 트레이스
  • 로깅

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

람다와 스택 트레이스

유감 스럽게도 람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다. 다음은 고의적으로 문제를 일으키도록 구현한 간단한 코드다.

import java.util.*;

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 Debugging.lambda$0(Debugging.java:6)
at Debugging$$Lambda$5/284720968.apply(Unknow Source)
at java.util.stream.ReferencePipeline$3$1 .accept(ReferencePipeline.java:193)
at java.util.Spliterators$ArraySpliterator.forEachRemaining(Spliterators.java:948)

points 리스트의 둘째 인수가 null이므로 프로그램의 실행이 멈췄다. 스트림파이프라인에서 에러가 발생했으므로 스트림 파이프라인 작업과 관련된 전체 메서드 호출 리스트가 출력되었다. 이와 같은 이상한 문자는 람다 표현식 내부에서 에러가 발생했음을 가리킨다. 람다 표현식은 이름이 없으므로 컴파일러가 람다를 참조하는 이름을 만들어낸 것이다. lambda$main$0는 다소 생소한 이름이다. 클래스에 여러 람다 표현식이 있을 때는 골치 아픈 일이 벌어진다.

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

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

정보 로깅

스트림의 파이프라인 연산을 디버깅한다고 가정했을 때 무엇을 할 수 있을까? 스트림은 최종연산 구문을 호출하는 순간 전체 스트림이 바로 소비된다. 스트림 파이프라인에 적용된 각각의 연산(map, filter, limit)이 어떤 결과를 도출하는지 확인할 수 있다면 좋을 것 같다. 바로 이때, peek이라는 스트림 연산을 활용할 수 있다. peek은 스트림의 각 요소를 소비할 것처럼 동작을 실행한다. 하지만 forEach처럼 실제로 스트림의 요소를 소비하지는 않는다. peek는 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달한다.

728x90

'개발서적 > 모던 자바 인 액션' 카테고리의 다른 글

Chap 12 - 새로운 날짜와 시간 API  (0) 2021.03.19
Chap06. 스트림으로 데이터 수집  (0) 2021.03.05
Chap05. 스트림 활용  (0) 2021.03.05
Chap04. 스트림 소개  (0) 2021.02.26
Chap03. 람다 표현식  (0) 2021.02.25