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

handa·2022년 12월 7일
1

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

람다, 메서드 참조, 스트림 등의 기능을 이용해서 더 가독성이 좋고 유연한 코드로 리팩터링

9.1.1 코드 가독성 개선

  • 익명클래스 -> 람다표현식
  • 람다표현식 -> 메서드 참조
  • 명령형 데이터 처리 -> 스트림

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

람다 표현식을 이용해서 간결하고, 가독성이 좋은 코드를 구현할 수 있다.
3장 Runnable 객체를 만드는 익명 클래스

//익명 클래스를 사용한 이전 코드
Runnable r1 = new Runnable() {
	public void run() {
    	System.out.println("Hello");
    }
};
//람다 표현식을 사용한 최신 코드
Runnable r2 = () -> System.out.println("Hello");

익명 클래스를 람다로 바꿀 때 주의할 점

  • 익명클래스에서 this는 익명클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다.
private String str = "hello";

private void testRunnable() {
	Runnable run1 = new Runnable() {
    	String str = "World";
    	@Override
        public void run() {
        	System.out.println(this.str); //World
        }
    };
    run1.run();
    
    Runnable run2 = () -> System.out.println(this.str); //Hello
    run2.run();
}
  • 익명클래스는 감싸고 있는 클래스의 변수를 가릴 수 있다. 하지만 람다 표현식으로는 변수를 가릴 수 없다.
int a = 10;

Runnable r1 = () -> {
	int a = 2; //컴파일 에러 - already define in the scope
    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(); }

//Task를 구현하는 익명 클래스를 전달할 수 있다.
doSomething(new Task() {
	public void execute() {
    	System.out.println("Danger danger!!");
    }
});

doSomething(() -> System.out.println("Danger danger!!")); //어느 것을 가리키는지 알 수 없는 모호함 발생
doSomething((Task)() -> System.out.println("Danger danger!!")); //모호함 제거

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

메서드 참조의 메서드명으로 코드의 의도를 명확하게 알릴 수 있기 때문에 람다 표현식 대신 메서드 참조를 이용하면 가독성을 높일 수 있다.
6장 칼로리 수준으로 요리를 그룹화하는 코드

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

조건문을 별도의 메서드로 추출한 다음에 groupingBy에 인수로 전달할 수 있다.

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel =
	menu.stream().collect(groupingBy(Dish::getCaloricLevel));
public class Dish {
	...
    public CaloricLevel getCaloricLevel() {
    	if (this.getCalories() <= 400) return CaloricLevel.DIEF;
        else if (this.getCalories() <= 700) return CaloricLevel.NORMAL;
        else return CaloricLevel.FAT;
    }
}

3장 sort

inventory.sort(
	(Apple a1, Apple a2) -> a1.getWeigth().compareTo(a2.getWeight()));
    
inventory.sort(comparing(Apple::getWeight)); //메서드 참조

저수준 리듀싱 연산

int totalCalries = 
	menu.stream().map(Dish::getCalories)
    			 .reduce(0, (c1, c2) -> c1+ c2);
}
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories)); //내장 컬렉터 사용

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

스트림 API는 데이터 처리 파이프라인의 의도를 더 명확하게 보여준다.

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(Collectors.toList());

9.1.5 코드 유연성 개선

잘모름
조건부 연기 실행
실행 어라운드

조건부 연기 실행

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

위 코드의 문제

  • logger의 상태가 isLoggable이라는 메서드에 의해 클라이언트 코드로 노출된다.
  • 메시지를 로깅할 때마다 logger 객체의 상태를 매번 확인한다.
public void log(Level level, Supplier<String> msgSupplier)

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

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

public void log(Level level, Supplier<String> msgSupplier) {
	if (logger.isLoaggable(level)) {
    	log(level, msgSupplier.get());
   	}
}

실행 어라운드

메서드 실행 전이나 후에 매번 같은 동작을 반복적으로 수행하는 코드가 있다면 람다로 변환해서 코드 중복을 줄일 수 있다.
3장 파일을 열고 닫는 코드

String oneLine = 
	processFile((BufferedReader b) -> b.readLine()); //람다 전달
String twoLines =
	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); //인수로 전달된 BufferedReaderProcessor를 실행
    }
}
public interface BufferedReaderProcessor {
	String process(BufferedReader b) throws IOException;
}

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

람다 표현식이 더해져 자바에 여러가지 디자인 패턴을 적용해서 코드를 작성하는 것이 수월해졌다.

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

9.2.1 전략

한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법

  • 호출하는 쪽에서 전략을 취할수 있도록 한다.
public interface ValidationStrategy {
	boolean execute(String s);
}
public class IsAllLowerCase implements ValidationStrategy {
    @Override
    public boolean execute(String s) {
        return s.matches("[a-z]+");
    }
}

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

Validator를 생성할 때 ValidationStrategy를 주입

public class Validator {
    private final ValidationStrategy strategy;

    public Validator(ValidationStrategy strategy) {
        this.strategy = strategy;
    }

    public boolean validate(String s) {
        return strategy.execute(s);
    }
}
Validator numericValidator = new Validator(new isNumeric());
numericValidator.validate("aaa");

Validator lowerCaseValidator = new Validator(new IsAllLowerCase());
lowerCaseValidator.validate("bbbb");

//람다 사용
Validator lowerCaseValidator2 = new Validator((String s) -> s.matches("[a-z]+")); //람다를 직접 전달
lowerCaseValidator2.validate("bbbb");

Validator numericValidator2 = new Validator((String s) -> s.matches("\d+")); //람다를 직접 전달
numericValidator2.validate("1234");

9.2.2 템플릿 메서드

알고리즘의 일부를 고칠 수 있는 유연함을 제공해야 할 때 템플릿 메서드 디자인 패턴을 사용한다.

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

    abstract void makeCustomerHappy(Customer c);
}

onlineBanking 클래스를 상속받지 않고 직접 람다 표현식을 전달해서 다양한 동작을 추가할 수 있다.
동작파라미터화 처럼?

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

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

9.2.3 옵저버

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

다양한 옵저버를 그룹화할 Observer 인터페이스

public interface Observer {
    void notify(String tweet);
}

다양한 키워드에 다른 동작을 수행할 수 있는 여러 옵저버를 정의

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

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

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

주제 구현하고, 새로운 옵저버를 등록해야하는 리스트를 만들어준다.

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


public class Feed implements Subject {

    private final List<Observer> observers = new ArrayList<>();

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

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


public class TweeterApplication {
    public static void main(String[] args) {
        Feed feed = new Feed();
        feed.registerObserver(new NYTimes());
        feed.registerObserver(new Guardian());
        feed.registerObserver(new LeMonde());
        feed.notifyObserver("The queen said her favourite book is Modern Java in Action!");
    }
}

람다 표현식 사용

feed.registerObserver(tweet -> {
    if (tweet != null && tweet.contains("book")) {
        System.out.println("something about book! " + tweet);
    }
});

feed.registerObserver(tweet -> {
    if (tweet != null && tweet.contains("Java")) {
        System.out.println("something about java! " + tweet);
    }
});

9.2.4 의무 체인

작업 처리 객체의 체인을 만들 때는 의무 체인 패턴을 사용한다.
이해 부족

public abstract class ProcessingObject<T> {
    
    protected ProcessingObject<T> successor; // 계승자들의 Chain

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

}

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

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

두 작업 처리 객체를 연결해서 사용

    ProcessingObject<String> p1 = new HeaderTextProcessing();
    ProcessingObject<String> p2 = new SpellCheckerProcessing();
    p1.setSuccessor(p2); // 객체 연결

    String input = "Aren't labdas really sexy!?";
    String result = p1.handle(input);
    System.out.println(result);

람다 표현식 사용

    String str = "Aren't labdas really sexy!?";

    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);
    String result2 = pipeline.apply(str);
    System.out.println(result2);

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 new RuntimeException("");
        }
    }
}

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

람다 표현식 사용

public class ProductFactory {
    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);
    }
}
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);
}

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

9.3 람다 테스팅

프로그램이 의도대로 동작하는지 확인할 수 있는 단위 테스팅을 진행한다.

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

일반적인 메서드는 이름이 존재하기 때문에 단위 테스트를 문제없이 진행할 수 있지만, 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다. 따라서 필요하다면 람다를 필드에 저장해 테스트 할 수 있다. 람다 표현식은 함수형 인터페이스의 인스턴스를 생성한다.

public class OrderProduct {
    private String name;
    private int count;
    private int price;
	....
}

public static class Order {
    public static final ToIntFunction<Order> getTotalPrice =
        (Order o) -> o.getProducts().stream().mapToInt(p -> p.getPrice() * p.getCount()).sum();

    private List<OrderProduct> products;
    ...
}

@Test
void test() {
    Order order = new Order(List.of(
        new OrderProduct("TV", 1, 300_000),
        new OrderProduct("공책", 3, 1_000),
        new OrderProduct("컴퓨터", 1, 1_000_000)
    ));
    int totalPrice = Order.getTotalPrice.applyAsInt(order);
    Assertions.assertEquals(totalPrice, 1_303_000);
}

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

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

public static class Order {
    ...

    public static Order limitProductPrice(Order order, int maxPrice) {
        List<OrderProduct> newProducts = order.getProducts().stream()
            .filter(p -> p.getPrice() <= maxPrice).collect(Collectors.toList());
        return new Order(newProducts);
    }
}

@Test
void test() {
    Order order = new Order(List.of(
        new OrderProduct("TV", 1, 300_000),
        new OrderProduct("공책", 3, 1_000),
        new OrderProduct("컴퓨터", 1, 1_000_000)
    ));
    Order newOrder = Order.limitProductPrice(order, 990_000);
    int totalPrice = Order.getTotalPrice.applyAsInt(newOrder);
    Assertions.assertEquals(totalPrice, 303_000);
}

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 디버깅

문제가 발생한 코드를 디버깅할 때 개발자는 스택 트레이스와 로깅을 가장 먼저 확인해야 한다.
하지만 람다 표현식과 스트림은 기존의 디버깅 기법을 무력화 한다.

9.4.1 스택 트레이스 확인

예를 들어 예외 발생으로 프로그램 실행이 갑자기 중단되었다면 먼저 어디에서 멈췄고 어떻게 멈추게 되었는지 스택 프레임(stack frame)을 살펴보아야 한다.

프로그램이 메서드를 호출할 때마다 프로그램에서 호출 위치, 호출할 떄의 인수값, 호출된 메서드의 지역 변수 등을 포함한 호출 정보가 생성되며 이들 정보는 스택 프레임에 저장된다.
람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다.

@Test
void exceptionTest() {
    List<Order> orderList = List.of(new Order("000001"), null);
    orderList.stream().map(Order::getOrderNumber).forEach(System.out::println);
}


ImmutableCollections$List12와 같은 이상한 문자는 람다 표현식 내부에서 에러가 발생했음을 가리킨다.
람다 표현식은 이름이 없으므로 컴파일러가 람다를 참조하는 이름을 만들어 낸 것이다.
해당 람다 표현식을 사용한 메서드 정보는 스택 트레이스에 표기가 되므로 아직까지는 해당 메서드를 찾아 수정해주는 방법 밖에 없다.

9.4.2 정보 로깅

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
    .map(x -> x + 1)
    .filter(x -> x % 2 == 0)
    .limit(2)
    .forEach(System.out::println);

forEach를 호출하는 순간 전체 스트림이 소비되어 각각의 연산(map, filter, limit)이 어떤 결과를 도출하는지 확인할 수 없게 된다. 이럴때 peek이라는 스트림 연산을 활용할 수 있다.

peek는 스트림의 각 요소를 소비한 것처럼 동작을 실행한다.

하지만 forEach 처럼 실제로 스트림의 요소를 소비하지는 않는다.

peek는 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달한다.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
    .peek(x -> System.out.println("number : " + x)) // number : 1
    .map(x -> x + 1)
    .peek(x -> System.out.println("map : " + x)) // map : 2
    .filter(x -> x % 2 == 0)
    .peek(x -> System.out.println("filter : " + x)) // filter : 2
    .limit(2)
    .peek(x -> System.out.println("limit : " + x)) // limit : 2
    .collect(Collectors.toList());
profile
진짜 해보자

0개의 댓글