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

Posted by yunki kim on March 17, 2022

  이번 장에서는 기존 코드를 이용해 새로운 프로젝트를 시작하는 상황을 가정한다. 그 후 람다를 이용해 가독성과 유연성을 높이면서 기존 코드를 리팩토링 하는 방법을 소개한다.

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

  람다, 메서드 참조, 스트림 등을 이용해 가독성이 높고 유연한 코드로 리팩터링 하는 과정을 살펴보자

코드 가독성 개선

  코드 가독성이 개선되면 다른 사람이 코드를 이해하기 쉬워지고 유지보수 할 수 있게 된다. 자바 8의 새로운 기능을 사용하면 코드가 간결하면서도 이해하기 쉬워진다.

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

  추상 메서드를 구현하는 익명 클래스를 람다로 리팩터링 할 수 있지만 모든 경우에 적용할 수 있는 것은 아니다. 다음과 같은 경우 람다로 변환할 수 없다.

1. 익명 클래스에서 this또는 super를 사용한다.

  익명 클래스에서 this는 익명 클래스 자신을 가리키는 반면 람다의 this는 람다를 감싸는 클래스를 가르킨다.

2. 섀도우 변수(shadow variable)를 사용한다.

  익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있다(섀도우 변수를 선언 할 수 있다). 하지만 람다는 변수를 가릴 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
    }
}
 
cs

  Variable Shadowing:

    변수 하나가 특정 스코프에 선언되 있고 해당 변수 이름과 해당 변수를 둘러싸고 있는 외부 스코프에 존재하는 변수의 이름이 같은

  것을 의미한다.  이때, 바깥 스코프의 변수는 내부 스코프의 변수에 의해 shadowed 되었다 말하고, 내부 스코프의 변수는 바깥

  스코프의 변수를 mask한다 말한다.

3. 익명 클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래될 수 있다.

  익명 클래스는 인스턴스화 할 때 명시적으로 형식이 정해지지만 람다의 형식은 콘텍스트에 따라 달라진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface Task {
    public void evecute();
}
 
public static void doSomething(Runnable r) {
    r.run();
}
 
public static void doSomething(Task a) {
    r.execute();
}
 
doSomething(new Taks() {
    public void execute() {
        System.out.println("hello");
    }
});
 
// 위 익명 클래스를 다음과 같이 람다로 바꾸면
doSomething(() -> System.out.println("hello"));
// Runnanle과 Task 중 어느 것을 가리키는지 알 수 없다.
// 따라서 명시적 형변환을 통해 모호함을 제거해야 한다.
doSomething((Taks)() -> System.out.println("hello"));
 
cs

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

  람다 표현식도 짧은 코드를 쉽게 전달할 수 있지만, 메서드 참조를 사용하면 메서드명으로 코드의 의도를 명확히 알릴 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream()
    .collect(groupingBy(dish -> {
        if (dish.getCalories() <= 400return CaloricLevel.DIET;
        else if (dish.getCalories() <= 700return 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() <= 400return CaloricLevel.DIET;
        else if (dish.getCalories() <= 700return CaloricLevel.NORMAL;
        else return CaloricLevel.FAT;
    }
}
 
cs

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

  스트림을 이용하면 쇼트서킷, 게으름, 간편한 멀티코어 아키텍처 이용 같은 이점을 누릴 수 있다. 따라서 가능하다면 기존 모든 컬렉션 처리 코드를 스트림API로 바꿔야 한다.

코드 유연성 개선

  동작파라미터화와 람다를 사용하면 변화하는 요구사항에 대응할 수 있는 코드를 구현할 수 있다. 

조건부 실행 연기

  메시지를 로깅하는 자바의 내장 메서드 log는 다음과 같이 사용할 수 있다. 

1
2
logger.log(Level.FINER, "Problem: " + generateDiagnostic());
 
cs

  log의 내부 구현은 다음과 같다.

1
2
3
4
5
6
7
public void log(Level level, String msg) {
    if (!isLoggable(level)) {
        return;
    }
    LogRecord lr = new LogRecord(level, msg);
    doLog(lr);
}
cs

  따라서 log 메서드를 위와 같이 사용할 경우 필요하지 않더라도 할상 로깅 메시지를 평가한다는 문제가 존재한다. 이는 log 메서드의 두 번째 파라미터를 Supplier로 오버로드만 메서드 활용을 통해 메시지 생성 과정을 연기해 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
logger.log(Level.FINER, () -> "Problem: " + generageDiagnostic());
 
// 오버로드된 log 메서드
public void log(Level level, Supplier<String> msgSupplier) {
    if (!isLoggable(level)) {
        return;
    }
    LogRecord lr = new LogRecord(level, msgSupplier.get());
    doLog(lr);
}
 
cs

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

실행 어라운드 패턴

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

1
2
3
4
5
6
7
8
9
10
11
12
14
public interface BufferedReaderProcessor {
    String process(BufferedReader b) throws IOException;
}
 
public static String processFile(BufferedReaderProcessor p) throws IOException{
    try (BufferedReader br = new BufferedReader(new FileReader("somePath/example"))) {
        return p.process(br);
    }
}
 
String oneLine = processFile((BufferedReader b) -> b.readLine());
String twoLine = processFile((BufferedReader b) -> b.readLine() + b.readLine());
 
cs

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

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

전략

  전략 패턴은 한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다. 람다를 이용하면 전략 패턴을 다음과 같이 사용할 수 있다.

1
2
3
4
5
6
7
public interface ValidationStrategy {
    boolean execute(String s);
}
 
Validator numericValidator = new Validator((String s) -> s.matches("[a-z]+"));
boolean b1 = numericValidator.validate("aaaa");
 
cs

템플릿 메서드

  템플릿 메서드는 알고리즘을 사용할 때 조금 고쳐야 하는 상황에 유용하다. 템플릿 메서드의 예시는 다음과 같다.

1
2
3
4
5
6
7
8
9
abstract class OnlilneBanking {
    public void processCustomer(int id) {
        Customer c = Database.getCutomerWithId(id);
        makeCutomerHappy(c);
    }
 
    abstract void makeCutomerHappy(Customer c);
}
 
cs

  이 코드를 다음과 같이 수정해 템플릿 메서드에 람다를 활용할 수 있다.

 
1
2
3
4
5
6
7
8
9
10
class OnlilneBanking {
    public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
        Customer c = Database.getCutomerWithId(id);
        makeCustomerHappy.accept(c);
    }
}
 
new OnlilneBanking.processCustomer(1337, (Customer c) -> 
    System.out.println("hello" + c.getName()));
 
cs

옵저버

  다음과 같이 뉴스 알림에 대한 옵저버 코드가 있다 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 new in NY! " + tweet);
        }
    }
}
 
interface Subject {
    void registerObserver(Observer o);
    void notifyObservers(String name);
}
 
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));
    }
 
}
 
cs

  위 코드를 람다를 활용해 다음과 같이 리팩토링 할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
interface Observer {
    void notify(String tweet);
}
 
interface Subject {
    void registerObserver(Observer o);
    void notifyObservers(String name);
}
 
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 f = new Feed;
f.registerObserver((String tweet) -> {
    if (tweet != null && tweet.contains("money")) {
        Syste.out.println("Breaking news in NY! " + tweet);
    }
})
 
cs

의무 체인

  작업 처리 객체의 체인을 만들 때 사용한다. 한 객체가 어떤 작업을 처리한 뒤 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음 또 다른 객체로 전달한다.

  통상적으로 의무 체인은 다음과 같이 구현된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public abstract class ProcessingObject<T> {
 
    protected ProcessingObject<T> successor;
 
    public void setSucessor(ProcessingObject<T> successor) {
        this.successor = successor;
    }
 
    public T handle(T input) {
        T r = handleWork(intput);
        if (successor != null) {
            return successor.hadnler
        }
        return r;
    }
 
    abstract protected T handleWork(T input);
}
 
public class HeaderTextProcessing extends ProcessingObject<String> {
    public String handleWork(String next) {
        return "From Raoul, Mario and Alan: " + text;
    }
}
 
public class SpellCheckerProcessing extends ProcessingObject<String> {
    public String handleWork(String text) {
        reutrn text.replaceAll("labda""lambda");
    }
}
 
ProcessingObject<String> p1 = new HeaderTextProcessing();
ProcessingObject<String> p2 = new SpellCheckerProcessing();
p1.setSucessor(p2);
String result = p1.handle("Hello labda");
System.out.println(result);
 
cs

이는 다음과 같이 람다로 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public abstract class ProcessingObject<T> {
 
    protected ProcessingObject<T> successor;
 
    public void setSucessor(ProcessingObject<T> successor) {
        this.successor = successor;
    }
 
    public T handle(T input) {
        T r = handleWork(intput);
        if (successor != null) {
            return successor.hadnler
        }
        return r;
    }
 
    abstract protected T handleWork(T input);
}
 
UnaryOperator<String> headerProcessing = 
    (String text) -> "From Raoul, Mario and Alan: " + text;
UnaryOperator<String> spellCheckerProcessing = 
    (String text) -> text.replaceAll("labda""lambda");
Function<StringString> pipeline =
    header.Processing.andThen(spellCheckerProcessing);
String result pipeline.apply("Hello labda");
System.out.println(result);
 
 
cs

팩토리

  팩토리 메서드 역시 람다를 사용해 간략화할 수 있다. 우선 통상적인 팩토리는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);
        }
    }
}
 
cs

 이를 다음과 같이 상품명을 생성자로 연결하는 Map을 만들어 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final static Map<String, Supplier<Product>> map = new HashMap();
static {
    map.put("loan", Load::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);
}
 
cs

람다 테스팅

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

  람다는 이름이 없기 때문에 일반 메서드 처럼 단위 테스트를 작성할 수 없다. 하지만 람다를 필드에 저장해 사용한다면, 람다 로직을 테스트할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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(1015);
    Point p2 = new Point(1020);
    int result = Point.compareByXAndThenY.compare(p1, p2);
    assertTrue(result < 0);
}
 
cs

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

  람다는 정해진 동작을 다른 메서드에서 사용할 수 있게 하나의 조각으로 캡슐화한다. 따라서 세부 구현을 포함하는 람다 표현식을 공개하지 말아햐 한다. 람다 표현식을 사용하는 메서드의 동작을 테스트해 람다를 공개하지 않고 람다를 테스트할 수 있다.

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

  복잡한 람다식을 테스트해야 한다면 람다를 메서드 참조로 바꾸는 것이 좋다. 그러면 일반 메서드를 테스트하듯이 람다를 테스트할 수 있다.

고차원 함수 테스팅

  메서드가 람다를 인수로 받는다면 다른 람다로 메서드의 동작을 테스트할 수 있다.

1
2
3
4
5
6
7
8
9
@Test
public void testFilter() throws Exception {
    List<Integer> numbers = Arrays.asList(1234);
    List<Integer> event = filter(numbers, i -> i % 2 == 0);
    List<Integer> smallerThanThree = filter(numbers, i -> i < 3);
    assertEquals(Arrays.asList(24), even);
    assertEquals(Arrays.asList(12), smallerThanThree);
}
 
cs

  테스트 해야 할 메서드가 다른 함수를 반환한다면 함수형 인터페이스의 인스턴스로 간주하고 함수의 동작을 테스트할 수 있다. 즉, 위에서 "보이는 람다 표현식의 동작 테스팅"의 예시 처럼 할 수 있다.

디버깅

람다와 스택 트레이스

  람다는 별도의 이름을 가지지 않기 때문에 컴파일 시 별도의 이름을 부여한다. 그때문에 메서드나 함수에 비해 에러를 알아보기 어렵다. 예를 들어 다음과 같은 코드는 

1
2
3
4
List<Point> points = Arrays.asList(new Point(122), null);
points.stream()
    .map(Point::getX)
    .forEach(System.out::println);
cs

  다음과 같은 알아보기 힘든 에러를 띄운다.

정보 로깅

  스트림 파이프라인 연산은 종료 연산을 호출하는 순한 전체 스트림이 소비된다. 따라서 만약 각 중간 연산이 어떤 결과를 호출하는지를 보고 싶다면 peek 스트림 연산을 사용해야 한다.

 

출처 - 모던 자바 인 액션