람다 표현식으로 전략(strategy), 템플릿 메서드(template method), 옵저버(observer), 의무 체인(chain of responsibillity), 팩토리(factory)등의 객체지향 디자인 패턴을 어떻게 간소화할 수 잇는지도 살펴보자.
가독성과 유연성을 개선하는 리팩터링
코드 가독성 개선
익명 클래스트를 람다 표현식으로 리팩터링하기
모든 익명 클래스를 람다 표현식으로 변환할 수 잇는 것은 아니다.
첫째, 익명 클래스에서 사용한 this와 super는 람다 표현식에서 다른 의미를 갖는다. 익명 클래스에서 this는 익명클래스 자신을 가르키지만 람다에서 this는 람다를 감싸는 클래스를 가리킨다.
둘째, 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있다. 새됴 변수(shadow variable) 하지만 람다 표현식으로는 변수를 가릴 수 없다.
셋째, 익명 클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래 될 수 있다. 익명 클래스는 인스턴스화할 때 명시적으로 형식이 정해지는 반면 람다의 형식은 콘텍스트에 따라 달라지기 때문이다.
아래 코드에서는 Task라는 Runnable과 같은 시그니처를 갖는 함수형 인터페이스를 선언한다.
interfase 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!!”);
}
});
하지만 익명 클래스를 람다 표현식으로 바꾸면 메서드를 호출할 때 Runnalbe과 Task 모두 대상 형식이 될 수 있으므로 문제가 생긴다.
doSomething(() -> System.out.println(“Danger danger!!”));
doSomething(Runnable)과 doSomething(Task)중 어느 것을 가리키는지 알 수 없는 모호함이 발생한다.
명시적 형변환(Task )를 이용해서 모호함을 제거할 수 있다.
doSomething((Task) () -> System.out.println(“Danger danger!!”));
IDE 리팩터링 기능이 자동으로 해결해준다. 걱정하지 않아도 된다.
람다 표현식을 메서드 참조로 리팩터링하기
람다 표현식은 쉽게 전달할 수 잇는 짧은 코드다. 하지만 람다 표현식 대신 메서드 참조를 이용하면 가독성을 높일수 있다. 메서드 참조의 메서드명으로 코드의 의도를 명확하게 알릴 수 있다.
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)): // 람다 표현식을 메서드로 추출했다.
이제 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 같은 정적 헬퍼 메서드를 활용하는 것도 좋다.
inverntory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())); // 비교 구현에 신경 써야 한다.
inventory.sort(comparing(Apple::getWeight)); // 코드가 문제 자체를 설명한다.
저수준 리듀싱 연산을 조합한 코드이다.
int totalCalories =
menu.stream().map(Dish::getCalories)
.reduce(0, (c1, c2) -> c1 + c2);
내장 컬렉터를 이용하면 코드 자체로 문제를 더 명확하게 설명할 수 있다.
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
명령형 데이터 처리를 스트림으로 리팩터링 하기
List<String> dishNames = new ArrayList<>();
for(Dish dish: menu) {
if(dish.getCalories() > 300) {
dishNames.add(dish.getName());
}
}
스트림 API를 이용하면 문제를 더 직접적으로 기술할 수 있을 뿐 아니라 쉽게 병렬화할 수 있다.
menu.parallelStream()
.filter(d -> d.getCalories() > 300)
.map(Dish::getName)
.collect(toList();
코드 유연성 개선
함수형 인터페이스 적용
조건부 연기 실행(conditional deferred execution)과 실행 어라운드(execute around) 두가지 패턴이 있다.
조건부 연기 실행
if (logger.isLoggable(Log.FINER)) {
logger.finer(“Problem: “ + generateDiagnostic());
}
위 코드는 다음과 같은 문제가 있다.
logger의 상태가 isLoggable이라는 메서드에 의해 클라이언드 코드로 노출된다.
메시지를 로깅할 때마다 logger 객체의 상태를 매번 확인해야 하는 것일까?
logger.log(Level.FINER, “Problem: “ + generateDiagnostic());
위 코드는 logger가 활성화 되어 있지 않더라도 항상 로깅 메시지메 평가하게 된다.
위 문제를 해결하도록 Supplier를 인수로 갖는 오버로드된 log 메서드를 제공한다.
public void log(Level level, Supplier<String> msgSupplier)
logger.log(Level.FINER, () -> “Problem: “ + generateDiagnostic());
public void log(Level level, Supplier<String> msgSupplier) {
if(logger.isLoggable(level)){
log(level, msgSupplier.get()); // 람다 실행
}
}
코드의 가독성과 캡슐화도 강화된다.
실행 어라운드
반복적으로 수행되는 준비, 종료 과정을 처리하는 로직을 새아용함으로써 코드 중복을 줄일 수 있다.
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 FilReader(" ModernJavaInAction/chap9/data.txt "))) {
return p.process(br); // 인수로 전달된 BufferedReaderProcessor를 실행
}
} // IOException을 던질 수 있는 람다의 함수형 인터페이스
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
람다로 객체지향 디자인 패턴 리팩터링하기
전략(strategy), 템플릿 메서드(template method), 옵저버(observer), 의무 체인(chain of responsibility), 팩토리(factory) 5가지 패턴을 알아본다.
전략
한 유형의 알고리즘을 보유한 상태에서 런타임에 적절한 알고리즘을 선택하는 기법이다.
1. 알고리즘을 나타내는 인터페이스(Strategy 인터페이스)
2. 다양한 알고리즘을 나타내는 한 개 이상의 인터페이스 구현(ConcreateStrategyA, ConcreteStrategyB 같은 구체적인 구현 클래스)
3. 전략 객체를 사용하는 한 개 이상의 클라이언트
오직 소문자 또는 숫자로 이루어져야 하는 등 텍스트 입력이 다양한 조건에 맞게 포맷 되어 있는지 검증한다고 가정하자.
문자열을 검증하는 인터페이스부터 구현한다.
public interface ValidationStrategy {
boolean execute(String s);
}
위에서 정릐한 인터페이스를 구현하는 클래스를 하나 이상 정의한다.
public class IsAllLowerCase implements ValidationStrategy {
public Boolean execut(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”);
람다 표현식을 이용하면 전략 디자인 패턴에서 발생하는 자잘한 코드를 제거할 수 있다. 람다 표현식은 코드 조각(또는 전략)을 캡슐화한다. 비슷한 문제에서는 람다 표현식을 사용할 것을 추천한다.
템플릿 메서드
템플릿 메서드는 ‘이 알고리즘을 사용하고 싶은데 그대로는 안되고 조금 고쳐야 하는’상황에 적합하다.
예를 들어 고객 계좌에 보너스를 입금한다고 가정하자. 은행마다 다양한 온라인 뱅킹 어플리케이션을 사용하며 동작 방법도 다르다. 다음은 추상 클래스다.
abstract class OnlineBanking {
public void processCustomer(int id) {
Customer c = Database.getCustomerWithId(id);
}
abstract void makeCustomerHappy(Customer c);
}
각각의 지점은 OnlineBanking 클래스를 상속받아 makeCustomerHappy 메서드가 원하는 동작을 수행하도록 구현할 수 있다.
람다 표현식
이전에 정의한 makeCustomerHappy의 메서드 시그니처와 일치하도록 consumer<Customer> 형식을 갖는 두 번째 인수를
processCustomer에 추가한다.
public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
Customer c = Database.getCustomerWithId(id);
makeCustomerHappy.accept©;
}
new OnlineBankingLambda().processCustomer(1337, (Customer c) -> System.out.println(“Hello ” + c.getName());
옵저버
어떤 이벤트가 발생했을 때 한 객체(주제(subject라 불리는)가 다른 객체 리스트(옵저버observer라 불리는)에 자동으로 알림을 보내야 하는 상황에서 옵저버 디자인 패던을 사용한다.
GUI, 주식처럼 변동에 반응하는 곳에 옵저버 패턴을 사용할 수 있다.
우선 다양한 옵저버를 그룹화할 Observer 인터페이스가 필요하다. Observer 인터페이스는 새로운 트윗이 있을 때 주제(Feed)가 호출할 수 있도록 notify라는 메서드를 제공한다.
interface Observer {
void notity(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 인터페이스를 정의한다.
interface Subject {
void registerObserver(Observer o);
void notifyObservers(String tweet);
}
registerObserver 메서드로 새로운 옵저버를 등록한 다음에 notifyObservers메서드로 트윗 옵저버에 알린다.
class Feed implements Subject {
private final List<Observer> observers = new ArrayList<>();
public void registerObserver(Observer o) {
this.observers.add(o);
}
public void notityObservers(String tweet) {
observers.forEach(o -> o.notity(tweet));
}
}
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!");
람다 표현식 사용하기
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);
}
});
항상 람다 표현식을 사용해야 하는 것은 아니다. 여러 메서드를 정의하는 등 복잡하다면 람다 표현식 보다 기존의 클래스 구현방식을 고수하는 것이 바랍직할 수도 있다,
의무 체인
한 객체가 어떤 작업을 처리한 다음에 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음에 또 다른 객체로 전달하는 방식이다.
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);
}
템플릿 메서드 디자인 패턴이 사용되었음을 알 수 있다.
public class HeaderTextProcessing extends ProcessingObject<String> {
public String handleWork(String text) {
return "From Raoul, Mario and Alan: " + text;
}
}
public class SpellCheckerProcessing extends ProceessingObject<String> {
public String handleWork(String text) {
return text.replaceAll("labda", "lambda"); // 이런 'lambda'에서m을 빠뜨렸네!
}
}
두 작업 처리 객체를 연결해서 작업 체인을 만들 수 있다.
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> 형식의 인스턴스로 표현할 수 있다.
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?!!");
팩토리
인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때 팩토리 디자인 패턴을 사용한다.
다양한 상품을 만드는 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, Stock, Bond는 모두 Product의 서브형식이다. createProduct 메서드는 생산된 상품을 설정하는 로직을 포함할 수 있다. 생성자와 설정을 외부로 노출하지 않는 것이 장점이다.
Product p = ProductFactory.createProduct(“loan”);
람다 표현식 사용
메서드 참조처럼 접근할 수 있다. 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);
}
람다 테스팅
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;}
publid 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());
}
보이는 람다 표현식의 동작 테스팅
람다는 익명이므로 테스트 코드 이름을 호출할 수 없다. 람다 표현식은 함수형 인터페이스의 인스턴스를 생성한다는 사실을 기억하자.
따라서 생성된 인스턴스의 동작으로 람다 표현식을 테스트 할 수 있다.
@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);
}
람다를 사용하는 메서드의 동작에 집중하라
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 testMoveAllPointRightBy() 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);
}
고차원 함수 테스팅
@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);
}
디버깅
문제가 발생한 코드를 디버깅할 때 개발자는 다음 두 가지를 가장 먼저 확인해야 한다.
- 스택 트레이스
- 로깅
스택 트레이스 확인
스택 프레임(stack frame)에서 이 정보를 얻을 수 있다. 그로그램이 메서드를 호출할 때마다 프로그램에서의 호출 위치, 호출할 때의 인수값, 호출된 메서드의 지역 변수 등을 포함한 호출 정보가 생성되며 이들 정보는 스택 프레임에 저장된다.
람다와 스팩 트레이스
람다 표현식은 이름이 없으므로 컴파일러가 람다를 참조하는 이름을 만들어 낸다. 메서드 참조를 사용해도 스택 트레이스에는 메서드명이 나타나지 않는다. 메서드 참조를 사용하는 클래스와 같은곳에 선언되어 있는 메서드를 참조할 때는 메서드 참조 이름이 스택 트레이스에 나타난다.
정보 로깅
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
스트림 파이프라인에 적용된 각각의 연산(map, filter, limit)이 어떤 결과를 도출하는지 확인할 수 있는 방법은 peek이라는 스트림 연산을 활용하는 것이다. peek은 스트림의 각 요소를 소비한 것처럼 동작을 실행한다. 하지만 forEach처럼 실제로 스트림의 요소를 소비하지 않는다.
List<Integer> result =
numbers.stream()
.peek(x -> System.out.println("from stream: " + x)) // 소스에서 처음 소비한 요소를 출력한다.
.map(x -> x + 17)
.peek(x -> System.out.println("from map: " + x)) // map 동작 실행 결과를 출력한다.
.filter(x -> x % 2 == 0)
.peek(x -> System.out.println("after filter: " + x)) // filter 동작 후 선택된 숫자를 출력한다.
.limit(3)
.peek(x -> System.out.println("after limit: " + x)) // limit 동작 후 선택된 숫자를 출력한다.
.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
'Programming > Java' 카테고리의 다른 글
| EPISODE 11. 모던 자바 인 액션(CHAPTER 11 null 대신 Optional 클래스) (0) | 2023.07.20 |
|---|---|
| EPISODE 10. 모던 자바 인 액션 (CHAPTER 10. 람다를 이용한 도메인 전용 언어) (0) | 2023.07.18 |
| EPISODE 8. 모던 자바 인 액션(CHAPTER 8 컬렉션 API 개선) (0) | 2023.07.14 |
| EPISODE 7. 모던 자바 인 액션(CHAPTER 7 벙렬 데이터 처리와 성능) (0) | 2023.07.13 |
| EPISODE 6. 모던 자바 인 액션(CAHPTER 6 스트림으로 데이터 수집) (0) | 2023.07.11 |