모던 자바 인 액션 - 3장

Daniel_Yang·2025년 5월 1일
0

chapter 8. 컬렉션 API 개선

새로운 컬렉션 API의 기능
1. 작은 리스트, 집합, 맵을 쉽게 만들 수 있도록 자바 9에 새로 추가된 컬 렉션 팩토리를 살펴본다.
2. 자바 8의 개선 사항으로 리스트와 집합에서 요소를 삭제하 거나 바꾸는 관용 패턴을 적용하는 방법을 배운다.
3. 맵 작업과 관련해 추가된 새로 운 편리 기능을 살펴본다.

컬렉션 팩토리

  • 리스트 팩토리 List.of()
    - 데이터 처리 형식을 설정하거나 데이터를 변환할 필요가 없다면, Collectors.toList () 컬렉터 같은 스트림 API 대신 사용하기 간편한 팩토리 메서드를 이용할 것을 권장.
  • 집합 팩토리 Set.of()
  • 맵 팩토리 Map.of(), 많으면 Map.Entry

리스트와 집합 처리

  • 기존 컬렉션을 바꾸는 동작 추가(vs 스트림은 새로운 결과를 만든다)
    - removeIf(Predicate), replaceAll(UnaryOperator), sort(Comparator)

맵 처리

  • Map 인터페이스에 몇가지 디폴트 메서드 추가
    - forEach(k, v 반복), Entry.comparingByValue / key, getOrDefault, 계산 패턴(computeIfAbsent, computeIfPresent, compute), 삭제 패턴, 교체패턴, 합침(putAll, merge)

개선된 ConcurrentHashMap

ConcurrentHashMap 클래스는 멀티스레드 환경에서 안정성과 성능을 모두 고려한 해시맵.
동시성 친화적이며 최신 기술을 반영한 HashMap 버전. ConcurrentHashap은 내부 자료구조의 특정 부분만 잠궈 동시 추가, 갱신 작업을 허용한다. 따라서 동기화된 Hashtable 버전에 비해 읽기 쓰기 연산 성능이 월등하다(참고로, 표준 HashMap은 비동기로 동작함).

개선된 특징

  • 분할 잠금 (lock striping) 으로 성능 향상
  • 병렬 처리 지원: forEach, reduce, search 등의 메서드
  • 집합 뷰(Set view) 제공

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

요약

  • 람다 표현식으로 코드 리팩터링하기
    - 익명 클래스는 람다 표현식으로 바꾸는 것이 좋다. 하지만 이때 this, 변수 섀도 등 미묘하게 의미상 다른 내용이 있음을 주의
  • 람다 표현식이 객체지향 설계 패턴에 미치는 영향
    - 람다 표현식으로 전략, 템플릿 메서드, 옵저버, 의무 체인, 팩토리 등의 객체지향 디자인 패턴에서 발생하는 불필요한 코드를 제거할 수 있다
  • 람다 표현식 테스팅
  • 람다 표현식과 스트림 API 사용 코드 디버깅
    - 스트림 파이프라인에서 요소를 처리할 때 peek 메서드로 중간값을 확인할 수 있다

코드 가독성 개선 방법

  1. 익명 클래스를 람다 표현식으로 리팩토링
    • 익명 클래스에서 this는 익명 클래스 자신을 가리키지만 람다에서 this는 람다를 감싸는 클래스
    • 익명 클래스는 감싸고 있는 클래스의 변수를 가릴 수 있으나 람다 표현식으로는 변수를 가릴 수 없다
    • 익명 클래스를 람다 표현식으로 바꾸면 콘텍스트 오버로딩에 따른 모호함이 초래 될 수 있다
  2. 람다 표현식을 메서드 참조로 리팩토링
    • 람다 표현식을 메소드 참조로 변환하면 가독성을 높아지고 코드의 의도를 명확하게 표현 가능
  3. 명령형 데이터 처리를 스트림으로 리팩토링
    • break, continue, return 등의 제어 흐름문을 모두 분석해서 같은 기능을 수행하는 스트림 연산으로 유추해야 하므로 리팩토링이 쉽지는 않다.
// # 1. 익명 클래스를 람다 표현식으로 리팩토링
// 같은 시그니처를 갖는 함수형 인터페이스를 선언 시,
// 람다 표현식으로는 어떤 인터페이스를 사용하는지 알 수 없다. 
doSomeThing(() -> System.out.println("Danger danger!!")); 
// 명시적 형변환을 이용해서 모호함을 제거할 수 있다. 
doSomeThing((Task)() -> System.out.println("Danger danger!!"));


// # 2. 람다 표현식을 메서드 참조로 리팩토링
.collect( groupingBy(dish -> { 
	if (dish.getCalories() <= 400) return CaloricLevel.DIET; 
	else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL; 
	else return CaloricLevel.FAT;

.collect( groupingBy(dish::getCaloricLevel));


// # 3. 명령형 데이터 처리를 스트림으로 리팩토링
List<String> dishNames = new ArrayList<>(); 
for (Dish dish : menu) { 
	if (dish.getCalories() > 300) { 
		dishNames.add(dish.getName()); 
	} 
} 

// 스트림 API로 변환하면 더 직접적으로 기술 & 쉽게 병렬화 가능 
menu.stream() 
	.filter(d -> d.getCalories() > 300) 
	.map(Dish::getName) 
	.collect(toList());

코드 유연성 개선

  • 함수형 인터페이스 적용
    • 람다 표현식을 이용하려면 함수형 인터페이스가 필요하며, 조건부 연기 실행과 실행 어라운드 패턴, 객체지향 디자인 패턴을 람다 표현식으로 간결하게 구현할 수 있다.
  1. 조건부 연기 실행 - 코드 가독성, 캡슐화

    • 함수형 인터페이스는 코드 실행 시점을 제어하는 핵심 도구로 작동한다. 특정 조건이 충족될 때만 람다 표현식이 실행되도록 설계.
    // 1. 행위 캡슐화: Supplier<String> 인터페이스로 로그 생성 로직을 감싸기
    logger.log(Level.FINER, () -> "Problem : " + generateDiagnostic()); // 실제 실행 지연
    
    public void log(Level level, Supplier<String> msgSupplier){  
    	if(logger.isLoggable(level)){  // 2. 조건 검증: 로그 레벨 활성화 상태 확인
    		// 3. 필요시 실행: 조건 충족 시에만 get() 호출로 실제 로그 생성
    		log(level, msgSupplier.get()); // 람다 실행  
    	}  
    }
  2. 실행 어라운드 - 재사용성

    • 함수형 인터페이스를 통해 동작 파라미터화 구현
      - 설정/정리 코드 재사용
      - 가변 처리 로직 분리: 실제 작업 내용을 함수형 인터페이스로 추상화
    • 매번 같은 준비, 종료 과정을 반복적으로 수행하는 코드를 람다로 변환하여 코드 중복을 줄이고 재사용성을 높인다.
    String oneLine = processFile((BufferedReader b) -> b.readline()); //람다 전달  
    
    public static String processFile(BufferedReaderProcessor p) throws IOException {  
    		try(BufferedReader br = new BufferedReader(new fileReader("data.txt")))                  {  
    			return p.process(br); //인수로 전달된 BufferedReaderProcessor 실행  
    		}  
    }  
    
    public interface BufferedReaderProcessor {  
    		string process(BufferedReader b) throws IOException;  
    }

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

람다 표현식함수형 인터페이스를 이용하면 일부 디자인 패턴은 더 간결하게 표현 가능

1. 전략 패턴

  • 전략 패턴은 런타임에 알고리즘을 유연하게 바꿔야 할 때 사용합니다. 예를 들어 문자열의 조건을 다르게 검증
  • ValidationStrategy가 함수형 인터페이스이므로 람다로 간결하게 대체 가능
  • ex) Validator
	// 기존
	public interface ValidationStrategy {
	    boolean execute(String s);
	}

	public class IsNumeric implements ValidationStrategy {
	    public boolean execute(String s) {
	        return s.matches("\\d+");
	    }
	}

	public class Validator {
	    private final ValidationStrategy strategy;
	    public Validator(ValidationStrategy strategy) {
	        this.strategy = strategy;
	    }
	
	    public boolean validate(String s) {
	        return strategy.execute(s);
	    }
	}
	
	// 람다로 리팩토링
	Validator numericValidator = new Validator(s -> s.matches("\\d+"));	

2. 템플릿 메서드 패턴

  • 알고리즘의 뼈대를 정의하고, 일부 로직만 서브 클래스에서 변경할 수 있도록 할 때
  • 비즈니스 로직을 매개변수로 전달하면 템플릿 메서드 패턴 없이도 유연함을 유지 가능
// 기존
abstract class OnlineBanking {
    public void processCustomer(int id) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy(c);
    }

    abstract void makeCustomerHappy(Customer c);
}

// 람다로 리팩토링
public class OnlineBankingLambda {
    public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy.accept(c);
    }
}

new OnlineBankingLambda().processCustomer(1337, 
    c -> System.out.println("Hello " + c.getName()));

3. 옵저버 패턴

  • 이벤트가 발생했을 때 여러 구독자에게 알림을 전달해야 할 때 사용
  • 옵저버 로직이 단순할 경우, 람다를 통해 불필요한 클래스를 제거 가능
// 기존 구현
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);  
		}  
	}  
}

// 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());
f.notifyObservers("The queen said her favorite book is Java 8 in Action!");

// 람다로 리팩토링
Feed f = new Feed(); 
feed.registerObserver(tweet -> {
    if (tweet.contains("money")) {
        System.out.println("Breaking news in NY! " + tweet);
    }
});

4. 의무 체인(책임 연쇄 패턴)

  • 한 객체가 어떤 작업을 처리한 다음에 다른 객체로 결과를 전달하고, 다른 객체도 해야 할 작업을 처리한 다음에 또 다른 객체로 전달하는 식
  • 작업처리 객체를 Function, 더 정확히 표현하자면 UnaryProcessingObject 형식의 인스턴스로 표현할 수 있다. => FunctionandThen 메서드를 활용하면 체인 구조를 명확하게 표현

5. 팩토리 패턴

  • 인스턴스화 로직을 클라이언트에 노출하지 않고 객체를 만들 때
  • Supplier를 활용하면 조건문 없이 동적으로 객체를 생성할 수 있다.
// 기존
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);
        }
    }
}

// 람다로 리팩토링
Map<String, Supplier<Product>> productMap = new HashMap<>();
productMap.put("loan", Loan::new);
productMap.put("stock", Stock::new);
productMap.put("bond", Bond::new);

테스팅

  1. 보이는 람다 표현식의 동작 테스팅
    • 람다는 익명이므로 테스트 코드 이름을 호출할 수 없다. 따라서 필요하다면 람다를 필드에 저장해 테스트 할 수 있다.
  2. 람다를 사용하는 메서드의 동작에 집중하라
  3. 고차원 함수 테스팅
    • 함수를 인수로 받거나 다른 함수를 반환하는 메서드를 고차원 함수

정리

  • 람다 자체를 직접 테스트하지 않고 람다가 사용된 메서드/함수의 결과를 검증하는 것이 핵심

    • 구현 세부사항 노출 없이 비즈니스 로직 검증
      public static List<Point> moveAllPointsRightBy(List<Point> points, int x) {  
      	return points.stream().map(p -> new Point(p.getX() + x, p.getY())).collect(toList());  
       }
  • 함수를 인자로 받는 메서드는 다양한 람다 케이스로 검증하자

        // 필터 조건을 달리한 여러 테스트 케이스
        filter(numbers, i -> i%2==0)  // 짝수 검증
        filter(numbers, i -> i<3)     // 3 미만 검증

디버깅

  • 람다 표현식과 스트림은 기존 디버깅 무시

  • stacktrace 확인

    • 람다 표현식은 이름이 없기 때문에 조금 복잡한 스택 트레이스가 생성된다. 따라서 람다 표현식과 관련한 스택 트레이스는 이해하기 어려울 수 있다는 점
  • 정보 logging

    • 각각의 스트림 연산이 어떤 결과를 도출하는지 확인
    • 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))  
        .lmit(3)  
        .peek(x -> System.out.println("after limit: " + x))  
        .collect(toList());  
    
    // result  
    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

chapter 10. 람다를 이용한 도메인 전용 언어

프로그래밍에서 "코드가 마치 문장처럼 읽히면 좋겠다"는 바람.
개발팀과 도메인 전문가가 이해할 수 있는 코드는 생산성과 직결되기 때문에 코드는 읽기 쉽고 이해하기 쉬워야 한다.
=> 도메인 전용 언어(Domain-Specific Language)**는 이런 요구를 해결하기 위한 방법 중 하나

도메인 전용 언어

  • DSL은 범용 프로그래밍 언어가 아니라 특정 비즈니스 도메인의 문제를 해결하려고 만든 언어
  • 자바는 기본적으로 DSL 작성에 불리한 문법 구조를 가지고 있지만, 람다와 메서드 참조, 메서드 체이닝 기법 등을 활용하면 비교적 깔끔한 DSL 스타일의 API를 만들 수 있다.

장단점

  • 장점: 간결함(캡슐화), 가독성, 유지보수, 높은 수준의 추상화, 집중, 관심사 분리
  • 단점: DSL 설계 어려움, 개발 비용, 추가 우회 계층, 러닝 커브, 호스팅 언어 한계
구분내부 DSL외부 DSL다중 DSL
의존성호스트 언어에 종속 (예: Java/Kotlin)독립적인 문법 체계 (예: SQL)JVM 호환 언어 혼용 (예: Scala+Java)
구현호스트 언어의 API 확장별도 파서/컴파일러 필요언어 간 인터페이스 브리징
예시Querydsl, Kotlin DSLSQL, HTML/CSSScala+Java 조합
// 외부 DSL (SQL)
SELECT name FROM users WHERE age > 18;

// 내부 DSL (JOOQ)
DSLContext create = DSL.using(SQLDialect.POSTGRES);
Result<Record> result = create.select(USER.NAME)
                              .from(USER)
                              .where(USER.AGE.gt(18))
                              .fetch();

최신 자바 API의 작은 DSL

  • 자바 8 이후의 몇몇 API는 DSL 스타일로 설계되었다.
  • ex) Stream API, DateTimeFormatter
    - 메서드 체이닝, 빌더 패턴, 람다 등을 조합해 코드가 의미론적으로 자연스럽고 직관적
// Stream API
List<String> highCaloricDishes = menu.stream()
    .filter(d -> d.getCalories() > 300)
    .map(Dish::getName)
    .collect(toList());

// DateTimeFormatter
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
    .appendText(ChronoField.DAY_OF_MONTH)
    .appendLiteral(". ")
    .appendText(ChronoField.MONTH_OF_YEAR)
    .appendLiteral(" ")
    .appendText(ChronoField.YEAR)
    .toFormatter();

자바로 DSL을 만드는 패턴과 기법

마치 미니 언어를 자바 위에 얹은 듯한 효과를 준다.

1. 메서드 체이닝 (Fluent API)

각 메서드가 자신을 반환하면 연속 호출이 가능합니다.

builder.add("milk").add("sugar").add("coffee");

2. 빌더 패턴

객체 생성 과정을 단계적으로 표현할 수 있습니다.

Order order = new OrderBuilder()
    .forCustomer("BigBank")
    .buy(100).stock("IBM").on("NYSE").at(125.00)
    .sell(50).stock("GOOGLE").on("NASDAQ").at(375.00)
    .end();

3. 중첩 함수 호출 방식

람다로 계층 구조를 표현할 수 있습니다.

Order order = order(o -> {
    o.forCustomer("BigBank");
    o.buy(t -> {
        t.quantity(100);
        t.stock("IBM");
        t.market("NYSE");
        t.price(125.00);
    });
});

4. 스태틱 임포트와 도우미 메서드

Order order = order(
    forCustomer("BigBank"),
    buy(100, stock("IBM", on("NYSE")), at(125.00)),
    sell(50, stock("GOOGLE", on("NASDAQ")), at(375.00))
);

실생활의 자바 8 DSL

  • 자바로 작성된 유명 프레임워크나 라이브러리에서도 DSL 스타일이 자주 활용된다.
  • AssertJ (테스트용 DSL)
	assertThat(frodo.getName()).isEqualTo("Frodo");
	assertThat(frodo.getAge()).isBetween(30, 40);
    
    // 추가로
	// RestAssured DSL 스타일
	given()
	    .param("query", "dsl")
	.when()
	    .get("/search")
	.then()
	    .statusCode(200)
	    .body("results.size()", greaterThan(0));
  • 그 외 ex) JOOQ, Cucumber,

정리

자바는 원래 DSL을 만들기에는 문법적 제약이 많은 언어였지만 자바 8부터는 람다, 메서드 참조, 스트림 API, 메서드 체이닝 등 덕분에 DSL 스타일의 API 작성이 훨씬 수월해졌다.

DSL은 다음과 같은 상황에서 유용하다:

  • 도메인 전문가가 코드를 쉽게 이해해야 할 때
  • API 사용자의 학습 비용을 줄이고 싶을 때
  • 반복되는 구성/설정을 추상화하고 싶을 때

자바에서 DSL을 구현할 때는 유연성과 가독성의 균형을 신중히 고려해야한다. 너무 복잡하게 설계하면 오히려 코드가 읽기 어려워질 수 있기 때문.

0개의 댓글