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

Junseo Kim·2021년 3월 8일
0

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

코드 가독성 개선

코드 가독성이 좋다는 것은 어떤 코드를 다른 사람도 쉽게 이해할 수 있음을 의미한다. 즉 코드 가독성을 개선한다는 것은 우리가 구현한 코드를 다른 사람이 쉽게 이해하고 유지보수할 수 있게 만드는 것이다.

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

하나의 추상 메서드를 구현하는 익명 클래스는 람다 표현식으로 리팩터링할 수 있다.(익명 클래스는 람다 표현식으로 바꾸는 것이 좋다) 하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아니다. 아래의 정보를 참고하여 변환해야한다.

  1. 익명 클래스에서 사용한 this와 super는 람다 표현식에서 다른 의미를 갖는다. 익명클래스에서 this는 자기 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다.

  2. 익명 클래스는 감싸고 있는 클래스 변수를 가릴 수 있다. 하지만 람다 표현식으로는 변수를 가릴 수 없다.

  3. 익명 클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래될 수 있다. 익명클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라진다.(명시적 형변환을 해줘야한다.)

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

메서드 참조를 사용하면 가독성이 높아지고 코드의 의도를 명확하게 알릴 수 있다. 람다 표현식을 별도의 메서드로 추출한 다음 메서드 참조로 변경할 수 있다.

정적 헬퍼 메서드(comparing, maxBy 등)를 활용할 수도 있다.

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

이론적으로 반복자를 이용한 기존의 모든 컬렉션 처리 코드를 스트림 API로 바꿀 수 있다.

명령형 코드의 break, continue, return 등의 제어 흐름문을 분석하여 같은 기능을 하는 스트림 연산으로 유추해야 하므로 쉬운일은 아니지만 몇 가지 도구의 도움을 받을 수 있다고한다.

코드 유연성 개선

조건부 연기 실행
만일 클라이언트 코드에서 객체 상태를 자주 확인하거나, 객체의 일부 메서드를 호출하는 상황이라면 람다나 메서드 참조를 인수로 사용하여, 내부적으로 객체의 상태를 확인한 다음에 메서드를 호출하도록 새로운 메서드로 구현하는 것이 좋다. 가독성도 좋아지고 캡슐화도 강화된다.

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

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

디자인 패턴은 재사용할 수 있는 부품이다. 디자인 패턴에 람다 표현식을 이용하면 더 쉽고 간단하게 해결할 수 있다. 기존의 많은 객체지향 디자인 패턴을 제거하거나 간결하게 재구현할 수 있다.

전략 패턴

한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다. 전략 패턴은 3부분으로 나눠진다.
1) 알고리즘을 나타내는 인터페이스
2) 인터페이스의 구현체
3) 전략 객체를 사용하는 한 개 이상의 클라이언트

람다 표현식을 이용하면 구현체를 클래스로 만들 필요 없이 직접 전달할 수 있다.

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

템플릿 메서드

알고리즘의 개요를 제시한 다음에 알고리즘의 일부를 고칠 수 있는 유연함을 제공해야 할 경우 템플릿 메서드 디자인 패턴을 사용한다.(알고리즘을 사용할 때 조금 고쳐서 사용해야할 경우)

고치길 원하는 메서드의 시그니처와 일치하는 함수형 인터페이스를 인수로 받는다.

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

옵저버 패턴

어떤 이벤트가 발생했을 때 한 객체가 다른 객체 리스트에 자동으로 알림을 보내야 하는 상황에서 사용.

다양한 옵저버를 그룹화 할 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);
        }
    }
}

Observer 구현체들을 등록하고 Observer 구현체들에게 알리는 역할을 하는 Subject도 구현한다.

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));
    }
}
Feed f = new Feed();
f.registerObserver(new NYTimes());
...

이를 람다 표현식을 사용해서 줄일 수 있다. 바로 Observer의 구현체를 클래스로 만들지 않고, 람다 표현식을 전달할 수 있다.

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

의무 체인

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

일반적으로 다음으로 처리할 객체 정보를 유지하는 필드를 포함하는 작업 처리 추상 클래스로 의무 체인 패턴을 구성한다.

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 메서드를 구현하여 다양한 작업 처리 객체를 만들 수 있다.

만든 작업 처리 객체들을 연결하여 사용할 수 있다.

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?!!");

이를 람다 표현식을 조합하는 식으로 줄일 수 있다. 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?!!");

팩토리

인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다. 생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 상품을 생산할 수 있다.

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

위의 코드를 이렇게 바꿀 수 있다.

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

람다 테스팅

코드를 깔끔하게 짜는 것도 중요하지만 제대로 동작하는 코드가 훨씬 더 중요하다. 이를 위해 단위 테스팅을 진행해야한다. 람다 표현식 자체를 테스트하기 보다 람다 표현식이 사용되는 메서드의 동작을 테스트해야한다.

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

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

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

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

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

많은 로직을 포함하는 복잡한 람다 표현식이 있을 때, 람다 표현식을 메서드 참조로 바꾸면(새로운 일반 메서드 선언) 일반 메서드를 테스트하듯이 람다 표현식을 테스트할 수 있다.

고차원 함수 테스팅

메서드가 람다를 인수로 받는다면 다른 람다로 메서드의 동작을 테스트할 수 있다. 여러 람다식을 메서드에 넘겨보고 그 결과값을 테스트해본다.

디버깅

코드를 디버깅할 때 아래 두 가지를 확인해야한다.

  • 스택 트레이스
  • 로깅

스택 트레이스 확인

프로그램이 예외 발생으로 중단되었다면 어디서 멈췄는지 확인해야한다. 이 정보가 스택 프레임에 있다. 메서드를 호출할 때마다 호출 위치, 호출할 때의 인수값, 호출된 메서드의 지역 변수 등을 포함한 정보가 생성되어 스택 프레임에 저장된다.

따라서 프로그램이 멈췄을 때 어떻게 멈추게되었는지 프레임별로 보여주는 스택 트레이스를 얻을 수 있다.(메서드 호출 리스트)

하지만 람다 표현식은 이름이 없기 때문에 복잡한 스택 트레이스가 생긴다. 따라서 예외 발생시 컴파일러가 이름을 만들어낸다.

// 예시
lambda$main$0 

여러 람다식이 포함된 경우 알아보기 힘들다. 메서드 참조를 사용해도 알아보기 힘든 이름으로 스택 트레이스가 생긴다.(메서드 참조를 사용하는 클래스와 같은 곳에서 메서드 참조 사용시 제대로 된 이름이 나옴)

정보 로깅

스트림 파이프라인 연산을 디버깅할때 forEach 등을 사용해 출력해볼 수 있다. 하지만 forEach를 호출하는 순간 전체 스트림이 소비되고 중간 연산들의 결과는 확인해볼 수 없다.

이때 peek 이라는 연산을 사용할 수 있다. peek은 스트림의 각 요소를 소비한 것처럼 동작을 실행하지만 실제로 요소를 소비하지는 않는다.

List<Integer> result = Stream.of(2, 3, 4, 5)
    .peek(x -> System.out.println("taking from stream: " + x))
    .map(x -> x + 17)
    .peek(x -> System.out.println("after 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());
}

0개의 댓글