✏️[모던 자바 인 액션, 전문가를 위한 자바 8, 9, 10 기법 가이드] 스터디 관련 책 내용을 정리한 글입니다.
📌 이 장의 내용
- 람다 표현식으로 코드 리팩터링하기
- 람다 표현식이 객체지향 설계 패턴에 미치는 영향
- 람다 표현식 테스팅
- 람다 표현식과 스트림 API 사용 코드 디버깅
이 장에서는 기존의 자바 코드를 활용하여 새로운 프로젝트를 시작하는 상황에서 람다 표현식과 스트림 API를 활용하여 가독성과 유연성을 높이는 방법에 대해 설명합니다. 이를 위해 전략, 템플릿 메서드, 옵저버, 의무 체인, 팩토리 등의 객체지향 디자인 패턴을 어떻게 간소화할 수 있는지 살펴보고, 람다 표현식과 스트림 API를 사용하는 코드를 테스트하고 디버깅하는 방법도 설명합니다.
9.1 가독성과 유연성을 개선하는 리팩터링
9.1절에서 람다, 메서드 참조, 스트림 등의 기능을 이용해서 더 가독성이 좋고 유연한 코드로 리팩터링 하는 방법을 설명합니다.
9.1.1 코드 가독성 개선
코드 가독성이란, 어떤 코드도 다른 사람에게 쉽게 이해할 수 있게 함을 의미합니다.
즉, 코드 가독성을 개선한다는 것은 우리가 구현한 코드를 다른 사람이 쉽게 이해하고 유지보수할 수 있게 만드는 것을 의미합니다. 자바 8의 람다 표현식, 메서드 참조, 스트림 API 등의 새 기능을 활용하여 코드의 가독성을 높이고 간결하고 이해하기 쉽게 만들며, 코드의 의도를 명확하게 보여줄 수 있습니다.
9장에서는 람다, 메서드 참조, 스트림을 활용해서 코드 가독성을 개선할 수 있는 세 가지 리팩터링 예제를 소개합니다.
- 익명 클래스를 람다 표현식으로 리팩터링 하기
- 람다 표현식을 메서드 참조로 리팩터링 하기
- 명령형 데이터 처리를 스트림으로 리팩터링 하기
9.1.2 익명 클래스를 람다 표현식으로 리팩터링 하기
익명 클래스를 람다 표현식으로 리팩터링 하면 코드를 간결하고 가독성이 좋게 만들 수 있으며, 익명 클래스에서 발생하는 에러를 방지할 수 있습니다.
예를 들어 Runnable 객체를 만드는 익명 클래스와 이에 대응하는 람다 표현식을 비교하는 코드를 구현합니다.
Runnable r1 = new Runnable() { // 익명 클래스를 사용한 이전 코드
public void run() {
System.out.println("Hello");
}
};
Runnable r2 = () -> System.out.println("Hello"); // 람다 표현식을 사용한 최신 코드
모든 익명 클래스를 람다 표현식으로 변환할 수 없는데, 이는 익명 클래스에서 사용한 this와 super의 의미가 람다 표현식에서 다르고, 익명 클래스에서 변수를 가릴 수 있지만 람다 표현식에서는 변수를 가릴 수 없기 때문입니다.
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);
}
};
익명 클래스와 달리 람다 표현식은 콘텍스트에 따라 형식이 달라질 수 있기 때문에 익명 클래스를 람다 표현식으로 변환할 경우 콘텍스트 오버로딩에 따른 모호함이 발생할 수 있습니다.
예제 코드에서는 Runnable과 같은 시그니처를 갖는 함수형 인터페이스를 Task로 정의하고, 이를 사용하는 람다 표현식과 익명 클래스를 비교하여 콘텍스트 오버로딩에 따른 모호함이 발생할 수 있음을 보여줍니다.
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.getCalores() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}));
람다 표현식을 별도의 메서드로 추출한 다음에 groupingBy에 인수로 전달할 수 있습니다.
다음처럼 코드가 간결하고 의도도 명확해질 수 있습니다.
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel =
menu.stream()
.collect(groupingBy(Dish::getCaloricLevel)); // 랃마 표현식을 메서드로 추출했다.
// 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;
}
}
또한 comparing과 maxBy 같은 정적 헬퍼 메서드가 메서드 참조와 조화를 이루도록 설계되었기 때문에 활용하는 것도 좋습니다.
inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())); // 비교 구현에 신경 써야한다.
inventory.sort(comparing(Apple::getWeight)); // 코드가 문제 자체를 설명한다.
자주 사용하는 리듀싱 연산인 sum, maximum 등은 메서드 참조와 함께 사용할 수 있는 내장 헬퍼 메서드를 제공합니다. 이를 활용하면 람다 표현식과 저수준 리듀싱 연산을 조합하는 것보다 Collectors API를 사용하는 것이 코드의 의도가 더 명확해집니다.
다음은 저수준 리듀싱 연산을 조합한 코드입니다.
int totalCaloires =
menu.stream().map(Dish::getCalories)
.reduce(0, (c1, c2) -> c1 + c2);
// 예를 들어 내장 컬렉터 summingInt를 사용해서 코드 자체로 문제를 더 명확하게 설명할 수 있습니다.
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
9.1.4 명령형 데이터 처리를 스트림으로 리팩터링 하기
컬렉션 데이터 코드를 스트림 API로 변환하는 이유는 스트림 API가 데이터 처리 파이프라인의 의도를 명확하게 보여주며, 쇼트서킷과 게으름 등의 최적화를 제공하고 멀티코어 아키텍처를 활용할 수 있는 지름길을 제공하기 때문입니다. 따라서 스트림 API를 사용하면 더 효율적이고 명확한 코드를 작성할 수 있습니다.
아래 코드는 필터링과 추출의 두 가지 패턴으로 엉켜있어 코드의 의도를 이해하기 어렵고, 병렬로 실행하기도 어려운 코드입니다.
List<String> dishNames = new ArrayList<>();
for (Dish dish : menu) {
if (dish.getCalories() > 300) {
dishNames.add(dish.getName());
}
}
// 스트림을 이용해서 더 직접적으로 기술한 뿐 아니라 쉽게 병렬화할 수 있습니다.
menu.parallelStream()
.filter(d -> d.getCalories() > 300)
.map(Dish::getName)
.collect(toList());
명령형 코드에서 break, continue, return 등의 제어 흐름문을 모두 분석해서 같은 기능을 수행하는 스트림의 연산으로 유추해야 해서 명령형 코드를 스트림 API로 바꾸는 것을 쉬운 일이 아니긴 합니다.
9.1.5 코드 유연성 개선
람다 표현식을 이용하면 다양한 동작 파라미터화를 구현할 수 있어 변화하는 요구사항에 대응할 수 있는 코드를 만들 수 있습니다.
함수형 인터페이스 적용
람다 표현식을 이용하려면 함수형 인터페이스가 필요하며, 조건부 연기 실행과 실행 어라운드 패턴, 객체지향 디자인 패턴을 람다 표현식으로 간결하게 구현할 수 있습니다.
1. 조건부 연기 실행
제어 흐름문이 복잡하게 얽힌 코드는 보안 검사나 로깅 관련 코드에서 흔히 발견됩니다. 이를 람다 표현식으로 리팩터링 하면 가독성과 유지보수성이 향상됩니다.
다음은 내장 자바 Logger 클래스를 사용하는 예제는 다음과 같습니다.
if (logger.isLoggable(Log.FINER) {
logger.finer("Problem : " + generateDiagnostic());
}
위 코드는 다음과 같은 사항에 문제가 있습니다.
- logger의 상태가 isLoggable이라는 메서드에 의해 클라이언트 코드로 노출된다.
- 메시지를 로깅할 때마다 logger 객체의 상태를 매번 확인해야 할까? 이들은 코드를 어지럽힐 뿐이다.
다음처럼 메시지를 로깅하기 전에 logger 객체가 적절한 수준으로 설정되었는지 내부적으로 확인하는 log 메서드를 사용하는 것이 좋습니다.
logger.log(Level.FINER, "Problem : " + generateDiagnostic());
위 코드에서는 인수로 전달된 메시지 수준에서 logger가 활성화되어 있지 않더라도 항상 로깅 메시지를 평가하게 되는 문제가 있습니다. 이 문제를 해결하기 위해서는 람다를 이용하여 메시지 생성 과정을 연기할 수 있습니다. 자바 8 API 설계자는 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()); // 람다 실행
}
}
만약 클라이언트 코드에서 객체 상태를 자주 확인하거나, 객체의 일부 메서드를 호출하는 상황이라면 객체의 상태를 확인한 다음에 메서드를 호출하도록 새로운 메서드를 구현하는 것이 좋습니다. 이렇게 하면 코드의 가독성이 좋아지고 캡슐화도 강화됩니다.
2. 실행 어라운드
실행 어라운드 패턴은 매번 같은 준비, 종료 과정을 반복적으로 수행하는 코드를 람다로 변환하여 코드 중복을 줄이고 재사용성을 높이는 것을 설명합니다. 이를 통해 준비, 종료 과정을 처리하는 로직을 재사용해서 코드 중복을 방지할 수 있습니다.
9.2 람다로 객체지향 디자인 패턴 리팩터링하기
새로운 언어 기능이 추가되면서 기존 코드 패턴이나 관용코드의 인기가 식기도 하며, 예를 들어 자바 5에서 추가된 for-each 루프는 기존의 반복자 코드를 대체하면서 에러 발생률이 줄어들고 간결해졌습니다. 또한 자바 7에서 추가된 다이아몬드 연산자 <> 덕분에 기존의 제네릭 인스턴스를 명시적으로 생성하는 빈도가 줄어들었습니다. 이러한 언어 기능의 추가는 코드의 가독성과 유지보수성을 향상시키는 등의 이점을 가져왔습니다.
디자인 패턴은 공통적인 소프트웨어 문제를 해결할 때 재사용 가능한 청사진을 제공하는 것입니다. 람다 표현식을 이용하면 기존의 객체지향 디자인 패턴을 제거하거나 재구현할 수 있으며, 람다를 적용하면 더욱 쉽고 간단한 방법으로 문제를 해결할 수 있습니다. 이를 통해 코드의 가독성과 유지보수성을 향상시킬 수 있습니다.
9.2절에서는 다음 다섯 가지 패턴을 살펴봅니다.
- 전략(strategy)
- 템플릿 메서드(template method)
- 옵저버(observer)
- 의무 체인(chain of responsibility)
- 팩토리(factory)
9.2.1 전략
전략 패턴은 런타임에 적절한 알고리즘을 선택하는 기법으로, 한 유형의 알고리즘을 보유한 상태에서 필요한 알고리즘을 선택하여 사용합니다. 예를 들어, 다양한 프레디케이트로 목록을 필터링하는 방법을 설명하면서 전략 패턴을 살펴봤습니다. 이 패턴은 다양한 기준을 갖는 입력값을 검증하거나, 다양한 파싱 방법을 사용하거나, 입력 형식을 설정하는 등 다양한 시나리오에 활용될 수 있습니다.
전략 패턴은 세 부분으로 구성됩니다.
- 알고리즘을 나타내는 인터페이스(Strategy 인터페이스)
- 다양한 알고리즘을 나타내는 한 개 이상의 인터페이스 구현(ConcreteStrategyA, ConcreteStartegyB 같은 구체적인 구현 클래스)
- 전략 객체를 사용하는 한 개 이상의 클라이언트
예를 들어 오직 소문자 또는 숫자로만 이루어져야 하는 등 텍스트 입력이 다양한 조건에 맞게 포맷되어 있는지 검증하는 인터페이스를 구현하면 다음과 같습니다.
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.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 numericValidator = new Validator((String s) -> s.matches("\\d+")); // 람다를 직접 전달
boolean b2 = lowerCaseValidator.validate("bbbb");
람다 표현식은 전략 디자인 패턴에서 발생하는 자잘한 코드를 제거할 수 있으며, 코드 조각과 전략을 캡슐화할 수 있습니다. 따라서 람다 표현식으로 전략 디자인 패턴을 대신할 수 있습니다.
9.2.2 템플릿 메서드
템플릿 메서드 디자인 패턴은 알고리즘의 개요를 제시한 다음 일부를 고칠 수 있는 유연함을 제공하는 디자인 패턴입니다. 예를 들어, 온라인 뱅킹 애플리케이션에서 고객 정보를 가져오고 서비스를 제공하는 알고리즘을 구현할 때, 템플릿 메서드를 사용하여 알고리즘의 일부를 고칠 수 있는 유연함을 제공할 수 있습니다. 추상 클래스로 알고리즘의 개요를 제시하고 구체 클래스에서 구체적인 구현을 수행합니다. 이를 통해 다양한 동작 방법을 갖는 애플리케이션을 구현할 수 있습니다.
다음은 온라인 뱅킹 애플리케이션의 동작을 정의하는 추상 클래스입니다.
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());
람다 표현식을 이용하면 템플릿 메서드 디자인 패턴에서 발생하는 자잘한 코드를 제거할 수 있습니다.
9.2.3 옵저버
옵저버 디자인 패턴은 어떤 이벤트가 발생했을 때 한 객체가 다른 객체 리스트에 자동으로 알림을 보내야 하는 상황에서 사용합니다. 주로 GUI 애플리케이션에서 버튼 등의 컴포넌트에 사용되며, 주식 거래 등 다른 예제에서도 사용할 수 있습니다.
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("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);
}
}
}
그리고 주제도 구현해야 하는데, Subject 인터페이스를 정의한 코드입니다.
// Subject 인터페이스의 정의를 구현한 코드
interface Subject {
void registerObserver(Observer o);
void notifyObservers(String tweet);
}
// Feed는 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.registerObserver("The Queen said her favourite book is Modern Java in Action!");
람다 표현식 사용
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 form London... " + tweet);
}
});
옵저버 디자인 패턴에서 동작을 수행하는 클래스가 단순한 경우, 람다 표현식을 이용해서 코드의 간결성을 유지할 수 있습니다. 그러나 상태를 가지거나 여러 메서드를 정의하는 경우, 람다 표현식 대신 기존의 클래스 구현 방식을 사용하는 것이 더 나은 선택일 수 있습니다.
9.2.4 의무 체인
의무 체인 패턴은 작업 처리 객체의 체인을 만들 때 사용되며, 한 객체가 처리한 작업 결과를 다른 객체로 전달하는 방식입니다. 이를 통해 작업 처리를 담당하는 객체들의 연결성과 유연성을 높일 수 있습니다.
의무 체인 패턴은 작업 처리 객체의 체인을 만들 때 사용되며, 한 객체가 작업을 처리한 후 결과를 다음 객체로 전달하는 식으로 동작합니다. 이를 위해 일반적으로 작업 처리 추상 클래스를 만들어 다음으로 처리할 객체 정보를 유지하는 필드를 포함시킵니다.
다음은 작업 처리 객체 예제코드입니다.
public abstract class ProcessingObject<T> {
protected ProcessingObject<T> successor;
public void setSuccessor(ProceeingObject<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);
}
ProcessingObject 클래스를 상속받아 handle 메서드를 구현하여 작업 처리 객체를 만들고, 다음으로 처리할 객체 정보를 유지하는 필드를 포함하는 작업 처리 추상 클래스로 의무 체인 패턴을 구성합니다. 작업 처리 객체는 자신의 작업을 끝낸 후 다음 작업 처리 객체로 결과를 전달합니다.
다음은 두 작업 처리 객체는 텍스트를 처리하는 구현 코드입니다.
public class HeaderTextProcessing extends ProcessingObject<String> {
public String handleWork(String text) {
return "From Raoul, Mario and Alan : " + text;
}
}
public class SpellCheckerProcessiing 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?!!"
람다 표현식 사용
의무 체인 패턴과 함수 체인은 유사한 개념이지만 목적이 다릅니다. 의무 체인 패턴은 객체 간의 작업 처리를 연결하고, 처리 객체는 객체 자체적으로 상태를 유지하며 다음 처리 객체를 선택합니다. 반면 함수 체인은 순수 함수(상태를 갖지 않는 함수)로 이루어진 체인이며, 입력을 받아 출력을 생성하고 연속적인 함수 적용으로 최종 결과를 도출합니다. 람다 표현식을 사용하여 함수 체인을 구현할 수 있습니다.
// 첫 번째 작업 처리 객체
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 팩토리
팩토리 디자인 패턴은 객체를 만들 때 인스턴스화 로직을 클라이언트에 노출하지 않고 만드는 방법입니다. 예를 들어 은행에서 취급하는 다양한 상품을 만들어야 할 때, 팩토리 메서드를 이용하여 상품 객체를 생성하고 클라이언트는 이를 호출함으로써 객체를 생성할 수 있습니다.
다음 코드에서 보여주는 것처럼 다양한 상품을 만드는 Factory 클래스가 필요합니다.
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);
}
}
}
팩토리 디자인 패턴을 사용하여 인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 생성하는 방법을 살펴봤습니다. 이를 통해 클라이언트가 단순하게 상품을 생산할 수 있습니다. 생성자와 설정을 외부로 노출하지 않기 때문에 코드가 더욱 간결해지며, 상품의 생산을 추상화할 수 있습니다.
람다 표현식 사용
생성자도 메서드 참조처럼 접근할 수 있습니다. 예를 들어 다음은 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 RuntimeException("No Such product" + name);
}
자바 8의 새로운 기능으로 팩토리 패턴을 깔끔하게 정리했지만, 상품 생성자로 여러 인수를 전달하는 상황에서는 이를 적용하기 어려울 수 있으며, 단순한 Supplier 함수형 인터페이스로는 이 문제를 해결할 수 없다는 것을 언급했습니다.
9.3 람다 테스팅
람다 표현식을 이용하여 간결하고 가독성 높은 코드를 작성할 수 있지만, 항상 코드의 정확성을 보장할 수는 없습니다. 따라서 개발자는 단위 테스트를 통해 프로그램이 의도한 대로 동작하는지 확인해야 합니다.
예를 들어 다음처럼 그래픽 애플리케이션의 일부인 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 testMoveRightBy() 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 클래스 코드를 테스트할 수 있습니다. 그러나 람다는 익명 함수이므로 테스트 코드의 이름을 호출할 수 없습니다.
람다 표현식을 필드에 저장하면 재사용이 가능해지며, 이를 활용해 메서드를 호출하는 것과 같이 람다를 사용할 수 있습니다. 이를 통해 람다의 로직을 테스트할 수 있습니다.
예를 들어 Point 클래스에 compareByXAndThenY라는 정적 필드를 추가했다. 이를 이용하여 메서드 참조로 생성한 Comparator 객체에 접근할 수 있다는 것이다.
public class Point {
public final static Camparator<Point> compareByXAndThenY =
comparing(Point::getX).thenComparing(Point::getY);
...
}
람다 표현식이 함수형 인터페이스의 인스턴스를 생성하며, 생성된 인스턴스의 동작으로 람다 표현식을 테스트할 수 있다는 것을 기억해야 합니다. 다음처럼 compareByXAndThenY라는 Comparator 객체에 대해 다양한 인수를 가지고 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 람다를 사용하는 메서드의 동작에 집중하라
람다의 목표는 정해진 동작을 캡슐화하여 다른 메서드에서 사용할 수 있게 하는 것입니다. 이를 위해 람다 표현식의 세부 구현을 공개하지 않습니다. 람다를 사용하는 메서드의 동작을 테스트함으로써, 람다의 세부 구현을 공개하지 않고도 검증이 가능합니다.
예를 들어 다음 moveAllPointsRightBy 메서드를 구현하면 다음과 같습니다.
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 메서드를 구현해야 합니다.
9.3.3 복잡한 람다를 개별 메서드로 분할하기
복잡한 람다 표현식을 테스트하기 위한 한 가지 해결책은 람다 표현식을 메서드 참조로 변환하는 것입니다. 이렇게 하면 일반 메서드를 테스트하는 것처럼 람다 표현식을 테스트할 수 있습니다.
9.3.4 고차원 함수 테스팅
함수를 인수로 받거나 반환하는 메서드는 사용이 어렵습니다. 그러나 람다를 인수로 받는 메서드의 경우, 다른 람다를 이용해 동작을 테스트할 수 있습니다. 예를 들어, 다양한 프레디케이트를 사용해 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절에서는 람다 표현식과 스트림 디버깅 방법을 다룹니다.
9.4.1 스택 트레이스 확인
프로그램 실행이 예외 발생으로 중단되면, 스택 프레임을 통해 어디에서 멈추고 어떻게 멈추게 되었는지 확인할 수 있습니다. 프로그램이 메서드를 호출할 때 생성되는 호출 정보는 스택 프레임에 저장됩니다. 프로그램이 멈추면, 스택 트레이스를 통해 문제가 발생한 지점까지의 메서드 호출 리스트를 확인할 수 있으며, 이를 통해 문제 발생 원인을 이해할 수 있습니다.
람다와 스택 트레이스
람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다. 메서드 참조를 이용해도 스택 트레이스에는 메서드 명이 나타나지 않습니다. 메서드 참조를 사용하는 클래스와 같은 곳에 선언되어 있는 메서드를 참조할 때는 메서드 참조 이름이 스택 트레이스에 나타납니다.
9.4.2 정보 로깅
스트림 파이프라인 연산을 디버깅하기 위해, 결과를 출력하거나 로깅하기 위해 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를 사용하면 전체 스트림이 소비되어 결과를 확인할 수 없지만, peek 연산을 사용하면 각 요소를 확인하면서 파이프라인의 다음 연산으로 전달할 수 있습니다. 따라서 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
9.5 마치며
- 람다 표현식으로 가독성이 좋고 더 유연한 코드를 만들 수 있습니다.
- 익명 클래스는 람다 표현식으로 바꾸는 것이 좋습니다. 하지만 이때 this, 변수 섀도 등 미묘하게 의미상 다른 내용이 있음을 주의해야 합니다.
- 메서드 참조로 람다 표현식보다 더 가독성이 좋은 코드를 구현할 수 있습니다.
- 반복적으로 컬렉션을 처리하는 루틴은 스트림 API로 대체할 수 있을지 고려하는 것이 좋습니다.
- 람다 표현식으로 전략, 템플리 메서드, 옵저버, 의무 체인, 팩토리 등의 객체지향 디자인 패턴에서 발생하는 불필요한 코드를 제거할 수 있습니다.
- 람다 표현식도 단위 테스트를 수행할 수 있습니다. 하지만 람다 표현식 자체를 테스트하는 것보다는 람다 표현식이 사용되는 메서드의 동작을 테스트하는 것이 바람직합니다.
- 복잡한 람다 표현식은 일반 메서드로 재구현할 수 있습니다.
- 람다 표현식을 사용하면 스택 트레이스를 이해하기 어려워집니다.
- 스트림 파이프라인에서 요소를 처리할 때 peek 메서드로 중간값을 확인할 수 있습니다.
'📚 서적 및 학습자료 > 모던 자바 인 액션[2회독]' 카테고리의 다른 글
[모던 자바 인 액션] 11장 null 대신 Optional 클래스 (0) | 2023.03.25 |
---|---|
[모던 자바 인 액션] 10장 람다를 이용한 도메인 전용 언어 (0) | 2023.03.23 |
[모던 자바 인 액션] 8장 컬렉션 API 개선 (0) | 2023.03.18 |
[모던 자바 인 액션] 7장 병렬 데이터 처리와 성능 (0) | 2023.03.18 |
[모던 자바 인 액션] 6장 스트림으로 데이터 수집 (0) | 2023.03.18 |