on
Modern Java in Action - Ch.9
참고: 책 - Modern Java in Action
책 Modern Java in Action을 읽고 정리합니다. 이번 포스트에서는 Ch 9.1 ~ Ch 9.4의 내용을 읽고 정리합니다.
Ch 9. 리팩터링, 테스팅, 디버깅
9.1 가독성과 유연성을 개선하는 리팩터링
- 9.1.1 코드 가독성 개선
- 9.1.2 익명 클래스를 람다 표현식으로 리팩터링하기
- 9.1.3 람다 표현식을 메서드 참조로 리팩터링
- 9.1.4 명령형 데이터 처리를 스트림으로 리팩터링하기
- 9.1.5 코드 유연성 개선
9.2 람다로 객체지향 디자인 패턴 리팩터링하기
- 9.2.1 전략
- 9.2.2 템플릿 메서드
- 9.2.3 옵저버
- 9.2.4 의무 체인
- 9.2.5 팩토리
9.3 람다 테스팅
- 9.3.1 보이는 람다 표현식의 동작 테스팅
- 9.3.2 람다를 사용하는 메서드의 동작에 집중하라
- 9.3.3 복잡한 람다를 개별 메서드로 분할하기
- 9.3.4 고차원 함수 테스팅
9.4 디버깅
- 9.4.1 스택 트레이스 확인
- 9.4.2 정보 로깅
기존 코드를 이용해서 새로운 프로젝트를 시작하는 상황을 가정할 때, 람다 표현식을 이용해 가독성과 유연성을 높이려면 기존 코드를 어떻게 리팩터링해야 하는지 설명합니다. 또한 람다 표현식으로 객체지향 디자인 패턴을 어떻게 간소화할 수 있는지 알아보고 람다 표현식과 스트림 API를 사용하는 코드를 테스트하고 디버깅하는 방법을 설명하도록 하겠습니다.
9.1 가독성과 유연성을 개선하는 리팩터링
람다 표현식은 익명 클래스보다 코드를 좀 더 간결하게 만들며 동작 파라미터화의 형식을 지원하므로 다양한 요구사항 변화에 대응할 수 있도록 동작을 파라미터화합니다.
9.1.1 코드 가독성 개선
일반적으로 코드 가독성이 좋다는 것은 ‘어떤 코드를 다른 사람도 쉽게 이해할 수 있음’을 의미합니다. 즉, 코드 가독성을 개선한다는 것은 우리가 구현한 코드를 다른 사람이 쉽게 이해하고 유지보수할 수 있게 만드는 것을 의미합니다. 코드 가독성을 높이려면 코드의 문서화를 잘하고, 표준 코딩 규칙을 준수하는 등의 노력을 기울여야 합니다.
자바 8의 새 기능을 이용해 코드의 가독성을 높일 수 있고 코드를 간결하고 이해하기 쉽게 만들 수 있습니다. 또한 메서드 참조 스트림 API를 이용해 코드의 의도를 명확하게 보여줄 수 있습니다.
9.1.2 익명 클래스를 람다 표현식으로 리팩터링하기
하나의 추상 메서드를 구현하는 익명 클래스는 람다 표현식으로 리팩터링할 수 있습니다. 익명 클래스는 코드를 장황하게 만들고 쉽게 에러를 일으킬 수 있어 람다 표현식을 이용해서 간결하고, 가독성이 좋은 코드를 구현할 수 있습니다.
ex. Runnable 객체를 만드는 익명 클래스와 이에 대응하는 람다 표현식을 비교하는 예제 입니다.
Runnable r1 = new Runnable() {
@Override
public void run() {
System.out.println("Hello");
}
};
Runnable r2 = () -> System.out.println("Hello");
하지만 모든 익명 클래스를 람다 표현식으로 변환할 수 있는 것은 아닙니다.
1) 익명 클래스에서 사용한 this와 super는 람다 표현식에서 다른 의미를 갖습니다.
- 익명 클래스에서 this는 익명클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스를 가리킵니다.
ex. Shadow Variable 예제입니다.
int a = 10;
Runnable r1 = () -> {
int a = 2; // 컴파일 에러
System.out.println(a);
};
Runnable r2 = new Runnable() {
@Override
public void run() {
int a = 2; // 정상 동작
System.out.println(a);
}
};
2) 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있지만 (섀도 변수 shadow variable) 람다 표현식으로는 변수를 가릴 수 없습니다.
3) 익명 클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래될 수 있습니다.
- 익명 클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라지기 때문입니다.
9.1.3 람다 표현식을 메서드 참조로 리팩터링하기
람다 표현식은 쉽게 전달할 수 있는 짧은 코드입니다. 하지만 람다 표현식 대신 메서드 참조를 이용하면 가독성을 높일 수 있는데 메서드명으로 코드의 의도를 명확하게 알릴 수 있기 때문입니다.
ex. 칼로리 수준으로 요리를 그룹화하는 예제입니다.
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;
}
}
- 람다 표현식을 별도의 메서드로 추출한 다음에 groupingBy에 인수로 전달할 수 있습니다.
//before
inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
//after - comparing 활용
inventory.sort(comparing(Apple::getWeight)); // 코드가 문제 자체를 설명
- 또한 comparing과 maxBy 같은 정적 헬퍼 메서드를 활용하는 것도 좋습니다. 람다 표현식보다는 메서드 참조가 코드의 의도를 더 명확하게 보여주기 때문입니다.
ex. 저수준 리듀싱 연산을 조합한 예제
int totalCalories = menu.stream().map(Dish::getCalories)
.reduce(0, (c1, c2) -> c1 + c2);
- sum, maximum 등 자주 사용하는 리듀싱 연산은 메서드 참조와 함께 사용할 수 있는 내장 헬퍼 메서드를 제공합니다.
- 최댓값이나 합계를 계산할 때 람다 표현식과 저수준 리듀싱 연산을 조합하는 것보다 Collectors API를 사용하면 코드의 의도가 더 명확해집니다.
ex. 컬렉터 summingInt를 사용하는 예제입니다.(자신이 어떤 동작을 수행하는지 메서드 이름으로 설명함)
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
- 내장 컬렉터를 이용하면 코드 자체로 문제를 더 명확하게 설명할 수 있습니다.
9.1.4 명령형 데이터 처리를 스트림으로 리팩터링하기
이론적으로는 반복자를 이용한 기존의 모든 컬렉션 처리 코드를 스트림 API로 바꿔야 하는데 스트림 API는 데이터 처리 파이프라인의 의도를 더 명확하게 보여주기 때문입니다. 스트림은 short-circuiting(모든 스트림의 요소를 처리하지 않고도 원하는 결과를 찾았으면 즉시 반환)과 laziness(여러 중간 연산을 연결해 질의를 할 수 있으며, 단말 연산을 스트림 파이프라인에 실행하기 전까지 아무 연산을 수행하지 않음)라는 강력한 최적화뿐 아니라 멀티코어 아키텍처를 활용할 수 있는 지름길을 제공합니다.
ex. 필터리과 추출로 엉킨 명령형 코드를 개선하는 예제 입니다.
//Before
List<String> dishNames = new ArrayList();
for (Dish dish: menu) {
if (dish.getCalories() > 300) {
dishNames.add(dish.getName());
}
}
//After
menu.parallelStream()
.filter(d -> d.getCalories()> 300)
.map(Dish::getName)
.collect(toList());
- 스트림 API를 이용하면 문제를 더 직접적으로 기술할 수 있을 뿐 아니라 쉽게 병렬화할 수 있습니다.
9.1.5 코드 유연성 개선
람다 표현식을 이용하면 동작 파라미터화 behaviour parameterization를 쉽게 구현할 수 있어 다양한 람다를 전달해서 다양한 동작을 표현할 수 있습니다. 따라서 변화하는 요구사항에 대응할 수 있는 코드를 구현할 수 있습니다(예를 들어 프레디케이트로 다양한 필터링 기능을 구현하거나 비교자로 다양한 비교 기능을 만들 수 있습니다).
함수형 인터페이스 적용
먼저 람다 표현식을 이용하려면 함수형 인터페이스가 필요합니다. 따라서 함수형 인터페이스를 코드에 추가해야 합니다.
조건부 연기 실행
실제 작업을 처리하는 코드 내부에 제어 흐름문이 복잡하게 얽힌 코드를 흔히 볼 수 있습니다. 흔히 보안 검사나 로깅 관련 코드가 이처럼 사용됩니다.
ex. 다음은 내장 자바 Logger 클래스를 사용하는 예제입니다.
if (logger.isLoggable(Log.FINER)){
logger.finer("Problem: " + generateDiagnostic());
}
위 코드의 문제는?
- logger의 상태가 isLoggable이라는 메서드에 의해 클라이언트 코드로 노출됩니다.
- 메시지를 로깅할 때마다 logger 객체의 상태를 매번 확인합니다.
logger.log(Level. FINER, "Problem: " + generateDiagnostic());
- 따라서 메시지를 로깅하기 전에 logger 객체가 적절한 수준으로 설정되었는지 내부적으로 확인하는 log 메서드를 사용하는 것이 바람직합니다.
- 덕분에 불필요한 if 문을 제거할 수 있으며 logger의 상태를 노출할 필요도 없으므로 위 코드가 더 바람직한 구현이지만 인수로 전달된 메시지 수준에서 logger가 활성화되어 있지 않더라도 항상 로깅 메시지를 평가하게 된다는 문제가 있습니다.
public void log(Level level, Supplier<String> msgSupplier)
...
logger.log(Level.FINER, ()-> "Problem: " + generateDiagnostic());
- 람다를 이용하면 특정 조건(예제에서는 Logger 수준을 FINER로 설정)에서만 메시지가 생성될 수 있도록 메시지 생성 과정을 연기할 수 있습니다.
- 자바 8 API 설계자는 이와 같은 logger 문제를 해결할 수 있도록 Supplier를 인수로 갖는 오버로드된 log 메서드를 제공합니다.
public void log (Level level, Supplier<String> msgSupplier){
if(logger.isLoggable(level)) {
log(level,msgSupplier.get());-람다 실행
}
}
- log 메서드는 logger의 수준이 적절하게 설정되어 있을 때만 인수로 넘겨진 람다를 내부적으로 실행합니다.
- 클라이언트 코드에서 객체 상태를 자주 확인하거나 (ex. logger의 상태), 객체의 일부 메서드를 호출하는 상황(ex. 메시지 로깅)이라면 내부적으로 객체의 상태를 확인한 다음에 메서드를 호출(람다나 메서드 참조를 인수로 사용)하도록 새로운 메서드를 구현하는 것이 좋습니다.
- => 그러면 코드 가독성이 좋아질 뿐 아니라 캡슐화도 강화됩니다 (객체 상태가 클라이언트 코드로 노출되지 않음)
실행 어라운드
매번 같은 준비, 종료 과정을 반복적으로 수행하는 코드가 있다면 이를 람다로 변환할 수 있는데 준비, 종료 과정을 처리하는 로직을 재사용함으로써 코드 중복을 줄일 수 있습니다.
ex. 파일을 열고 닫을 때 같은 로직을 사용했지만 람다를 이용해서 다양한 방식으로 파일을 처리할 수 있도록 파라미터화되는 예제입니다.
String oneLine = processFile((BufferedReader b) -> b.readLine()); //람다 전달
String twolines = processFile((BufferedReader b) -> b.readLine() + b.readLine()); //다른 람다 전달
public static String processFile (Buffered Reader Processor p) throws IOException {
try(BufferedReader br = new BufferedReader(new FileReader("ModernJavaInAction/chap/data.txt") {
return p.process(br); //인수로 전달된 BufferedReaderProcessor를 실행
}
}
public interface Buffered Reader Processor {
String process (BufferedReader b) throws IOException; //IOException을 던질 수 있는 람다의 함수형 인터페이스
}
- 람다로 BufferedReader 객체의 동작을 결정할 수 있는 것은 함수형 인터페이스 BufferedReaderProcessor 덕분입니다.
9.2 람다로 객체지향 디자인 패턴 리팩터링하기
다양한 패턴을 유형별로 정리한 것이 디자인 패턴 입니다. 디자인 패턴은 공통적인 소프트웨어 문제를 설계할 때 재사용할 수 있는 검증된 청사진을 제공하는데 디자인 패턴에 람다 표현식이 더해지면 색다른 기능을 발휘할 수 있습니다. 즉, 람다를 이용하면 이전에 디자인 패턴으로 해결하던 문제를 더 쉽고 간단하게 해결할 수 있습니다. 또한 람다 표현식으로 기존의 많은 객체지향 디자인 패턴을 제거하거나 간결하게 재구현할 수 있습니다.
9.2.1 전략
전략 패턴은 한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법입니다. 다양한 기준을 갖는 입력값을 검증하거나, 다양한 파싱 방법을 사용하거나, 입력 형식을 설정하는 등 다양한 시나리오에 전략 패턴을 활용할 수 있습니다.
- 알고리즘을 나타내는 인터페이스(Strategy 인터페이스)
- 다양한 알고리즘을 나타내는 한 개 이상의 인터페이스 구현(ConcreteStrategyA. ConcreteStrategyB같은 구체적인 구현 클래스)
- 전략 객체를 사용하는 한 개 이상의 클라이언트
ex. 오직 소문자 또는 숫자로 이루어져야 하는 등 텍스트 입력이 다양한 조건에 맞게 포맷되어 있는지 검증하는 예제입니다.
public interface ValidationStrategy {
boolean execute(String s);
}
- 먼저 String 문자열을 검증하는 인터페이스부터 구현합니다.
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.matches("\\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"); //false 반환
Validator lowerCaseValidator = new Validator(new IsAllLowerCase ());
boolean b2 = lowerCaseValidator.validate("bbbb"); //true 반환
람다 표현식 사용
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"); //람다를 직접 전달
- 람다 표현식을 이용하면 전략 디자인 패턴에서 발생하는 자잘한 코드를 제거할 수 있습니다.
- 람다 표현식은 코드 조각(또는 전략)을 캡슐화합니다. 즉, 람다 표현식으로 전략 디자인 패턴을 대신할 수 있습니다.
9.2.2 템플릿 메서드
알고리즘의 개요를 제시한 다음에 알고리즘의 일부를 고칠 수 있는 유연함을 제공해야 할 때 템플릿 메서드 디자인 패턴을 사용합니다. 템플릿 메서드는 ‘이 알고리즘을 사용하고 싶은데 그대로는 안 되고 조금 고쳐야 하는 상황’에 적합합니다.
ex. 간단한 온라인 뱅킹 애플리케이션 예제입니다.
- 사용자가 고객 ID를 애플리케이션에 입력하면 은행 데이터베이스에서 고객 정보를 가져오고 고객이 원하는 서비스를 제공할 수 있습니다.
- 예를 들어 고객 계좌에 보너스를 입금한다고 가정하고, 은행마다 다양한 온라인 뱅킹 애플리케이션을 사용하며 동작 방법도 다릅니다.
// 온라인 뱅킹 애플리케이션의 동작을 정의하는 추상 클래스
abstract class OnlineBanking {
public void processCustomer(int id) {
Customer c = Database.getCustomerWithId(id);
makeCustomerHappy(c);
}
abstract void makeCustomerHappy(Customer c);
// 더미 Customer 클래스
static private class Customer {}
// 더미 Database 클래스
static private class Database {
static Customer getCustomerWithId(int id) {
return new Customer();
}
}
}
processCustomer()
메서드는 온라인 뱅킹 알고리즘이 해야 할 일(주어진 고객 ID를 이용해서 고객 만족)을 보여줍니다.- 각각의 지점은 OnlineBanking 클래스를 상속받아 makeCustomerHappy 메서드가 원하는 동작을 수행하도록 구현할 수 있습니다.
람다 표현식 사용
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());
- 이전에 정의한
makeCustomerHappy
의 메서드 시그니처와 일치하도록Consumer<Customer>
형식을 갖는 두 번째 인수를 processCustomer에 추가합니다. - 이제 onlineBanking 클래스를 상속받지 않고 직접 람다 표현식을 전달해서 다양한 동작을 추가할 수 있습니다.
9.2.3 옵저버
- 어떤 이벤트가 발생했을 때 한 객체(주제)가 다른 객체 리스트(옵저버)에 자동으로 알림을 보내야 하는 상황에서 옵저버 디자인 패턴을 사용합니다.
ex. 옵저버 패턴으로 트위터 같은 커스터마이즈된 알림 시스템을 설계하는 예제입니다.
interface Observer {
void notify(String tweet);
}
- 우선 다양한 옵저버를 그룹화할 Observer 인터페이스가 필요합니다.
- Observer 인터페이스는 새로운 트윗이 있을 때 주제(Feed)가 호출할 수 있도록 notify라고 하는 하나의 메서드를 제공합니다.
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 LeMonde implements Observer {
public void notify(String tweet) {
if (tweet != null && tweet.contains("wine")) {
System.out.println("Today cheese, wine and news! " + tweet);
}
}
}
- 이제 트윗에 포함된 다양한 키워드에 다른 동작을 수행할 수 있는 여러 옵저버를 정의할 수 있습니다.
interface Subject {
void registerObserver(Observer o);
void notifyObservers(String tweet);
}
- 주제를 구현하기 위해 Subject 인터페이스를 정의합니다.
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));
}
}
- 주제는
registerObserver
메서드로 새로운 옵저버를 등록한 다음에notifyObservers
메서드로 트윗의 옵저버에 이를 알립니다.
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 Modern Java in Action!");
- Feed는 트윗을 받았을 때 알림을 보낼 옵저버 리스트를 유지합니다.
- 이제 주제와 옵저버를 연결하는 데모 애플리케이션을 만들 수 있습니다.
람다 표현식 사용하기
- Observer 인터페이스를 구현하는 모든 클래스는 트윗이 도착했을 때 어떤 동작을 수행할 것인지 감싸는 하나의 메서드 notify를 구현했습니다.
- 세 개의 옵저버를 명시적으로 인스턴스화하지 않고 람다 표현식을 직접 전달해서 실행할 동작을 지정할 수 있습니다.
f.registerObserver((String tweet) -> {
if(tweet!=null&&tweet.contains("money")) {
System.out.println("Breaking news in NY! "+ tweet);
}
});
f.registerObserver((String tweet) -> {
if(tweet != null && tweet.contains("queen")) {
System.out.println("Yet more news from London... " + tweet);
}
});
- 이처럼 실행해야 할 동작이 비교적 간단할 때는 람다 표현식으로 불필요한 코드를 제거하는 것이 바람직합니다.
- 하지만 옵저버가 상태를 가지며, 여러 메서드를 정의하는 등 복잡하다면 람다 표현식보다 기존의 클래스 구현방식을 고수하는 것이 바람직할 수도 있습니다.
9.2.4 의무 체인
- 작업 처리 객체의 체인(동작 체인 등)을 만들 때는 의무 체인 패턴을 사용합니다.
- 한 객체가 어떤 작업을 처리한 다음에 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음에 또 다른 객체로 전달합니다.
- 일반적으로 다음으로 처리할 객체 정보를 유지하는 필드를 포함하는 작업 처리 추상 클래스로 의무 체인 패턴을 구성합니다.
- 작업 처리 객체가 자신의 작업을 끝냈으면 다음 작업 처리 객체로 결과를 전달합니다.
ex. 작업 처리 예제 코드입니다.
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 메서드를 구현하여 다양한 종류의 작업 처리 객체를 만들 수 있습니다.
ex. 텍스트를 처리하는 예제입니다.
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); //From Raoul, Mario and Alan: Aren't lambdas really sexy?!! 출력
- 두 작업 처리 객체를 연결해서 작업 체인을 만들 수 있습니다.
람다 표현식 사용
- 이 패턴은 함수 체인 (함수 조합)과 비슷합니다.
- 작업 처리 객체를
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?!!");
9.2.5 팩토리
인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 팩토리 자인 패턴을 사용합니다.
ex. 은행에서 취급하는 대출 채권, 주식 등 다양한 상품을 만드는 상황을 가정한 예제입니ㅏㄷ.
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);
}
}
}
- 다양한 상품을 만드는 Factory 클래스가 필요하고 여기서 Loan, Stock, Bond는 모두 Product의 서브형식입니다.
- createProduct 메서드는 생산된 상품을 설정하는 로직을 포함할 수 있습니다.
- 위 코드의 진짜 장점은 생성자와 설정을 외부로 노출하지 않음으로써 클라이언트가 단순하게 상품을 생산할 수 있다는 것입니다.
Product p = ProductFactory.createProduct("loan")
람다 표현식 사용
ex. 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 함수형 인터페이스로는 이 문제를 해결할 수 없습니다.
ex. 세 인수(Integer 둘, 문자열 하나)를 받는 상품의 생성자가 있다고 가정합니다.
public interface TriFunction<T, U, V, R> {
R apply(T t, U u, V v);
}
Map<String, TriFunctionInteger<Integer, String, Product>> map = new HashMap<>();
- 세 인수를 지원하려면 TriFunction이라는 특별한 함수형 인터페이스를 만들어야 해 결국 Map의 시그니처가 복잡해진다.
9.3 람다 테스팅
일반적으로 좋은 소프트웨어 공학자라면 프로그램이 의도대로 동작하는지 확인할 수 있는 단위 테스팅을 진행합니다. 우리는 소스 코드의 일부가 예상된 결과를 도출할 것이라 단언하는 테스트 케이스를 구현합니다.
ex. 그래픽 애플리케이션의 일부인 Point 클래스가 있다고 가정합니다.
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 test MoveRightBy() throws Exception {
Point p1 = new Point(5, 5);
Point p2 = p1.moveRightBy(10);
assertEquals(15, p2.getX());
assertEquals(5, p2.getY());
}
9.3.1 보이는 람다 표현식의 동작 테스팅
- moveRightBy는 public이므로 테스트 케이스 내부에서 Point 클래스 코드를 테스트할 수 있습니다.
- 하지만 람다는 익명(결국 익명 함수)이므로 테스트 코드 이름을 호출할 수 없는데, 따라서 필요하다면 람다를 필드에 저장해서 재사용할 수 있으며 람다의 로직을 테스트할 수 있습니다.
ex. Point 클래스에 compareByXAndThenY라는 정적 필드를 추가했다고 가정합니다. (compareByXAndThenY를 이용하면 메서드 참조로 생성한 Comparator 객체에 접근할 수 있습니다.)
public class Point {
public final static Comparator<Point> compareByXAndThenY
= comparing(Point::getX).thenComparing(Point::getY);
...
}
- 람다 표현식은 함수형 인터페이스의 인스턴스를 생성합니다.
- 따라서 생성된 인스턴스의 동작으로 람다 표현식을 테스트할 수 있습니다.
ex. 다음은 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);
}
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);
}
- 위 단위 테스트에서 보여주는 것처럼 Point 클래스의
equals 메서드
는 중요한 메서드인데 따라서 Object의 기본적인 equals 구현을 그대로 사용하지 않으려면 equals 메서드를 적절하게 구현해야 합니다.
9.3.3 복잡한 람다를 개별 메서드로 분할하기
테스트 코드에서 람다 표현식을 참조할 수 없는데, 그렇다면 복잡한 람다 표현식을 어떻게 테스트할 지를 알아보겠습니다. 한 가지 해결책은 8.1.3절에서 설명한 것처럼 람다 표현식을 메서드 참조로 바꾸는 것입니다.(새로운 일반 메서드 선언) 그러면 일반 메서드를 테스트하듯이 람다 표현식을 테스트할 수 있습니다.
9.3.4 고차원 함수 테스팅
함수를 인수로 받거나 다른 함수를 반환하는 메서드(고차원 함수 higher-order functions)는 좀 더 사용하기 어렵습니다. 메서드가 람다를 인수로 받는다면 다른 람다로 메서드의 동작을 테스트할 수 있습니다.
ex. 예를 들어 다양한 프레디케이트로 filter 메서드를 테스트할 수 있습니다.
@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에서 살펴봤던 것처럼 함수형 인터페이스의 인스턴스로 간주하고 함수의 동작을 테스트할 수 있습니다.
9.4 디버깅
문제가 발생한 코드를 디버깅할 때 개발자는 다음 두 가지를 가장 먼저 확인해야 합니다.
- 스택 트레이스
- 로깅 하지만 람다 표현식과 스트림은 기존의 디버깅 기법을 무력화합니다.
9.4.1 스택 트레이스 확인
예를 들어 예외 발생으로 프로그램 실행이 갑자기 중단되었다면 먼저 어디에서 멈췄고 어떻게 멈추게 되었는지 살펴봐야 합니다. 바로 스택 프레임에서 이 정보를 얻을 수 있는데, 프로그램이 메서드를 호출할 때마다
- 프로그램에서의 호출 위치,
- 호출할 때의 인수값,
- 호출된 메서드의 지역 변수 등을 포함한 호출 정보가 생성되며 이들 정보는 스택 프레임에 저장됩니다.
따라서 프로그램이 멈췄다면 프로그램이 어떻게 멈추게 되었는지 프레임별로 보여주는 스택 트레이스, 즉, 문제가 발생한 지점에 이르게 된 메서드 호출 리스트를 얻을 수 있습니다. 메서드 호출 리스트를 통해 문제가 어떻게 발생했는지 이해할 수 있습니다.
람다와 스택 트레이스
유감스럽게도 람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성됩니다.
ex. 다음은 고의적으로 문제를 일으키도록 구현한 간단한 코드입니다.
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);
}
}
프로그램을 실행하면 다음과 같은 스택 트레이스가 출력됩니다.
- 물론 의도했던 대로 points 리스트의 둘째 인수가 null이므로 프로그램의 실행이 멈췄습니다.
- 스트림 파이프라인에서 에러가 발생했으므로 스트림 파이프라인 작업과 관련된 전체 메서드 호출 리스트가 출력되었습니다.
- 메서드 호출 리스트에 다음처럼 수수께끼 같은 정보도 포함되어 있습니다.
.Debugging.lambda$main$0
- 이와 같은 이상한 문자는 람다 표현식 내부에서 에러가 발생했음을 가리킵니다.
- 람다 표현식은 이름이 없으므로 컴파일러가 람다를 참조하는 이름을 만들어낸 것입니다.
- 기존의 람다 표현식
p -> p.getX()
를 메서드 참조Point::getX
로 고쳐서 메서드 참조를 사용해도 스택 트레이스에는 메서드명이 나타나지 않습니다. - 여전히 스택 트레이스로는 이상한 정보가 출력된다.
ex. 메서드 참조를 사용하는 클래스와 같은 곳에 선언되어 있는 메서드를 참조할 때는 메서드 참조 이름이 스택 트레이스에 나타나는 것을 확인할 수 있는 예제입니다.
import java.util.Arrays;
import java.util.List;
public class Debugging {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3);
numbers.stream().map(Debugging::divideByZero).forEach(System.out::println);
}
public static int divideByZero(int n) {
return n / 0;
}
}
- divideByZero 메서드는 스택 트레이스에 제대로 표시됩니다.
- 따라서 람다 표현식과 관련한 스택 트레이스는 이해하기 어려울 수 있다는 점을 염두에 두고 이는 미래의 자바 컴파일러가 개선해야 할 부분입니다.
9.4.2 정보 로깅
스트림의 파이프라인 연산을 디버깅한다고 가정합니다.
ex. 다음처럼 forEach로 스트림 결과를 출력하거나 로깅할 수 있습니다.
List<Integer> numbers = Arrays.asList(2, 3, 4, 5);
numbers.stream()
.map(x -> x + 17)
.filter(x -> x % 2 == 0)
.limit(3)
.forEach(System.out::println);
//결과
20
22
- 안타깝게도 forEach를 호출하는 순간 전체 스트림이 소비됩니다.
- 스트림 파이프라인에 적용된 각각의 연산(map, filter, limit)이 어떤 결과를 도출하는지 확인할 수 있다면 좋을 것 같은데 이 때
peek
이라는 스트림 연산을 활용할 수 있습니다. peek
은 스트림의 각 요소를 소비한 것처럼 동작을 실행합니다.- 하지만 forEach처럼 실제로 스트림의 요소를 소비하지는 않습니다.
- peek은 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달합니다.
- 다음 코드에서는 peek으로 스트림 파이프라인의 각 동작 전후의 중간값을 출력한다.
List<Integer> result = numbers.stream()
.peek(x -> System.out.println("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());
//결과
from stream: 2
after map: 19
from stream: 3
after map: 20
after filter: 20
after limit: 20
from stream: 4
after map: 21
from stream: 5
after map: 22
after filter: 22
after limit: 22