Java 9에서의 컬렉션 API 개선 사항컬렉션 팩토리컬렉션 유틸리티List, Set 유틸리티 메서드Map 유틸리티 메서드람다로 전환 하기리팩터링익명 클래스 → 람다식으로 전환하기람다식 → 메서드 참조로 전환하기코드 유연성 개선디자인 패턴테스팅디버깅람다와 도메인 전용 언어기초DSL의 장점DSL의 단점순수 자바로 DSL 만들기
Java 9에서의 컬렉션 API 개선 사항
컬렉션 팩토리
- 임의의 값 몇개로 쉽게 컬렉션을 생성하는 방법을 제공한다.
- 단, 이렇게 생성된 컬렉션은 새로운 요소를 추가, 기존 요소를 교체, 삭제할 수 없다 (immutable).
- 만일 수정하려는 시도를 할 경우 UnsupportedOperationException이 발생한다.
List.of(elements…)
,Set.of(elements…)
,Map.of(K1, V1, K2, V2, …)
- 단,
Map
의 경우 10개 이상의 쌍으로 맵을 생성할 때는Map.ofEntries(entries…)
를 사용한다.
import static java.util.Map.entry; Map<String, String> kvs = Map.ofEntries( entry("Seoul", "02"), entry("Gyeonggi", "031"), ...);
컬렉션 유틸리티
List
, Set
유틸리티 메서드
.removeIf(predicate)
: 조건을 만족하는 요소를 제거한다.Iterator
사용 중에 컬렉션을 직접 조작하는 실수를 방지한다.- 내부 구현에서는
Iterator
를 사용하여 요소를 제거한다.
.replaceAll(operator)
:UnaryOperator<T>
함수를 이용해 요소를 변경한다.stream.map(operator)
와 비슷한데 스트림에서는 새로운 컬렉션 객체를 생성하지만 이 메서드는 컬렉션을 변경한다는 점이 다르다.
Map
유틸리티 메서드
.forEach(biConsumer)
:BiConsumer<T, U>
를 통해서 맵의 entry를 순회할 수 있다.
.getOrDefault(key, default)
: 주어진 키의 값이 존재하지 않는 경우 두번째 인자로 주어진 기본값을 반환한다. 주의할 점은 주어진 키의 값이null
로 설정되어 있는 경우에는 기본값을 돌려주지 않고null
을 돌려준다는 점이다.
.computeIfPresent(biFunction)
: 주어진 키에 대한 값이 존재하는 경우BiFunction<K, V, V>
을 통해서 존재하는 키, 값으로부터 새로운 값을 생성하여 맵에 설정한다.
.computeIfAbsent(function)
: 주어진 키에 대한 값이 존재하지 않는 경우Function<K>
를 통해서 키로부터 새로운 값을 생성하여 맵에 설정한다.
.compute(biFunction)
:.computeIfPresent(biFunction)
와 동일하지만 주어진 키에 대해서 설정된 값이 있는지 여부와 관계없이 새로운 값을 생성하여 맵에 설정한다. 단, 값이 존재하지 않는 경우null
이 주어진다.
.remove(key, value)
: 기존의.remove(key)
와 달리 주어진 키의 값이 입력된 파라미터와 동일한 경우에만 제거한다.
.replace(key, [oldValue,] newValue)
: 특정 키에 대한 값을 교체한다. 이전 값이 주어진 경우 해당 키에 설정된 값과 파라미터로 주어진 값을 비교하여 일치하는 경우에만 새로운 값으로 교체한다.
.replaceAll(biFunction)
:BiFunction<K, V, V>
를 통해서 맵의 모든 entry에 대해 새로운 값을 생성 및 설정한다.List
와 마찬가지로 대상 컨테이너를 수정한다.
.merge(key, newValue, biFunction)
: 키에 설정된 값과 새로운 값을 합친다.- 키에 설정된 값이 없거나 설정된 값이
null
인 경우 새로운 값을 맵에 설정한다. - 키에 설정된 값이 있고
null
이 아닌 경우 (즉, 반대의 경우)BiFunction<V, V, V>
을 통해서 예전 값과 새로운 값을 받아서 합쳐진 값을 생성하여 맵에 설정한다. - 만일 생성된 값이
null
이면 맵에서 해당 키를 삭제한다.
Map.Entry.comparingByKey([comparator])
:Map.Entry
를 키를 통해 비교하는Comparator
를 생성한다.stream.sorted(comparator)
에서 사용할 수 있다.
Map.Entry.comparingByValue([comparator])
:Map.Entry
를 값을 통해 비교하는Comparator
를 생성한다.stream.sorted(comparator)
에서 사용할 수 있다.
람다로 전환 하기
리팩터링
익명 클래스 → 람다식으로 전환하기
// before Runnable r1 = new Runnable() { public void run() { System.out.println("Hello"); } }; // after Runnable r2 = () -> System.out.println("Hello");
- 단, 람다식에서는 shadow variable을 사용할 수 없다.
- 또한 동일한 시그니처를 가진 서로 다른 함수형 인터페이스를 인자로 받는 메서드들이 있는 경우 어떤 메서드를 호출해야 될 지 모호한 경우가 있다. 이 경우에는 타입 캐스팅을 통해서 문제를 해결할 수 있다.
람다식 → 메서드 참조로 전환하기
- 객체의 상태 값으로 부터 새로운 상태 값을 생성하는 경우
// before Map<LogPriority, List<LogEntry>> logsByPriority = logEntries.stream().collect(groupingBy((logEntry) -> { if (logEntry.getLevel() <= 300) return LogPriority.DEBUG; else if (logEntry.getLevel() <= 500) return LogPriority.INFO; else if (logEntry.getLevel() <= 700) return LogPriority.WARN; else return Priority.ERROR; })); // after class LogEntry { ... // 새로운 상태 값을 생성하는 메소드로 추출 public LogPriority getPriority() { ... 람다식의 내용과 동일 ... } }; Map<LogPriority, List<LogEntry>> logsByPriority = logEntries.stream().collect(groupingBy(LogEntry::getPriority));
- 유틸리티 함수를 이용하는 경우
// before entries.sort( (Entry e1, Entry e2) -> e1.getValue().compareTo(e2.getValue())); // after entries.sort(comparing(Entry::getValue));
- for-loop을 스트림으로 전환하는 경우
// before List<String> errorMessages = new ArrayList<>(); for (LogEntry logEntry : logEntries) { if (logEntry.getPriority() == LogPriority.ERROR) errorMessages.add(logEntry.getMessage()); } // after List<String> errorMessages = logEntries.parallelStream() .filter((logEntry) -> logEntry.getPriority() == LogPriority.ERROR) .map(LogEntry::getMessage) .collect(toList());
코드 유연성 개선
- 실행 지연
// before: FINER level 일때만 로그를 남기고 싶은데 // 로그를 남기지 않는 경우에도 매우 비싼 작업이 실행된다 logger.log(Level.FINER, "Internal info: " + veryExpensiveDiagnosticOperation()); // after: 람다식을 이용해 실행을 지연시킴으로서 // 로그를 남기는 경우에만 실행되도록 변경했다 // 이를 위해서 함수형 인터페이스를 인자로 받는 메서드 오버로드를 추가했다. logger.log(Level.FINER, () -> "Internal info: " + veryExpensiveDiagnosticOperation());
- 실행 어라운드
// 공통되는 패턴을 유틸리티 함수로 추출 public static String processFile(BufferedReaderProcessor p, String path) throws IOException { try (BufferedReader br = new BufferedReader(new FileReader(path))) { return p.process(br); } } // 동작 파라미터화를 위한 함수형 인터페이스 선언 // (예외를 던질 필요가 없다면 유틸로 제공되는 함수형 인터페이스를 사용해도 된다) public interface BufferedReaderProcessor { String process(BufferedReader br) throws IOException; }; // 활용 String oneLine = processFile((br) -> br.readLine()); String twoLines = processFile((br) -> br.readLine() + br.readLine());
디자인 패턴
- Strategy 패턴의 경우 객체로 표현하던 알고리즘 부분을 람다식으로 대체할 수 있다.
- Template method 패턴의 경우 abstract method & 상속으로 동작을 변경하는 부분을 함수형 인터페이스 파라미터로 전환하여 동작을 파라미터화 할 수 있다.
- Observer 패턴의 경우 객체로 구현된 옵저버를 람다식으로 대체할 수 있다.
- Chain of responsibility 패턴도 함수형 인터페이스를 활용하여 구현 가능하다.
// 함수형 인터페이스 선언 @FunctionalInterface interface Middleware<T> { // 현재 값 + 다음 미들웨어를 받아서 작업을 처리하거나 위임한다 T process(T target, Middleware<T> next); // 기본적으로는 다음 미들웨어가 없는 경우 호출을 가정한다 default T process(T target) { return this.process(target, null); } // 다음 미들웨어를 조합할 수 있는 방법을 제공한다 default Middleware<T> andThen(Middleware<T> next) { return (target, ignored) -> this.process(target, next); } }; // 활용 Middleware<Integer> divider = (target, next) -> { // 짝수는 나누어 결과를 돌려주고 (직접 처리) 홀수는 다음 미들웨어가 처리한다 (위임) if (target % 2 == 0) return target / 2; else return next.process(target); }; Middleware<Integer> adder = (target, ignored) -> target + 1; Middleware<Integer> calculator = divider.andThen(adder); System.out.println("calculation of 8: " + calculator.process(8)); System.out.println("calculation of 5: " + calculator.process(5));
- Factory 패턴의 경우 인자가 없다면 람다식으로
Supplier<T>
를 간단하게 구현할 수 있다. 만일 인자가 여러개인 경우라면 별도의 함수형 인터페이스를 선언해야 한다.
테스팅
- 테스트를 대상이 되는 로직(람다식)을 외부에서 사용 별도의 필드로 추출하고 이를 통해 테스트를 진행한다.
- 예를 들어 아래와 같이 static field로 람다식을 참조할 수 있게 만든다.
public class Point { // 람다식 선언 public final static Comparator<Point> comparingByXAndThenY = comparing(Point::getX).thenComparing(Point::getY); ... }; // 활용 import static Point.comparingByXAndThenY; Point p1 = new Point(10, 15); Point p2 = new Point(10, 20); int result = comparingByXAndThenY.compare(p1, p2); assertTrue(result < 0);
- 테스트를 작성할 때는 사용된 람다식의 동작에 집중하여 테스트 케이스를 작성한다.
- 복잡한 람다식이 존재하는 경우 로직을 분할하여 몇개의 구분된 람다식으로 만들어 테스트 한다.
- 고차원 함수 (high-order function)의 경우 함수를 받아서 다른 함수를 생성하므로 테스트 작성 시 적절한 람다식을 활용하여 테스트 케이스를 작성한다.
디버깅
- 람다식과 스트림은 기존의 디버깅 기법으로 추적하기 어렵다.
- 디버깅을 위해서 스트림의 중간에 연산을 삽입해야 한다면 스트림 요소를 소비하지 않는
.peek(function)
을 사용한다.
람다와 도메인 전용 언어
기초
- 도메인 전용 언어(Domain Specific Language)로 비즈니스 로직을 표현함으로서 개발자 뿐 아니라 도메인 전문가 또한 애플리케이션이 올바르게 작성 되었는지 파악하기 쉽게 한다.
- DSL은 external DSL, internal DSL이 있다.
- external DSL은 미니 언어를 통해서 DSL을 표현하는 방식이다. 따라서 새로운 문법을 정의해야하며 해당 문법에 따라 작성된 구문을 파싱하고 실행하는 로직을 구현해야 한다.
- internal DSL은 비즈니스 로직을 좀 더 도메인에 맞는 형태로 적절하게 모듈화하여 클래스 및 메서드 형태로 제공하는 방식이다.
DSL의 장점
- 간결함: API는 비즈니스 로직을 간편하게 캡슐화하므로 반복을 피하고 코드를 간결하게 만들 수 있다.
- 가독성: 도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 쉽게 이해할 수 있다.
- 유지보수: 잘 설계된 DSL로 구현한 코드는 쉽게 유지보수 가능하다.
- 높은 수준의 추상화: DSL은 마인드 모델과 같은 수준의 추상화 단계에서 동작하므로 도메인의 문제와 직접 관련되지 않은 세부 사항을 숨긴다.
- 관심사 분리, 집중: 도메인의 규칙을 표현할 목적으로 설계되었으므로 (사용하는) 프로그래머 입장에서 비즈니스 로직에 집중할 수 있으며 이로 인해 생산성이 좋아진다.
DSL의 단점
- 설계의 어려움: 도메인 지식을 적절하게 추상화되며 간결한 형태로 언어에 담는 것은 쉬운일이 아니다.
- 개발 비용: DSL을 만드는 것은 많은 비용과 시간을 소모하는 작업이다. 또한 개발 이후에도 유지보수에 비용을 지불해야 한다.
- 추가 계층: DSL은 프로그래밍 언어나 라이브러리에서 제공하는 기능 위에 새로운 계층을 도입하는 것이다. DSL 구현이 너무 크고 복잡하면 성능 문제를 야기할 수 있으므로 가능한 작고 단순한 형태를 유지해야 한다.
- 진입 장벽: 비즈니스 로직을 작성하는 입장에서는 새로 배워야 하는 언어가 추가된 셈으로 진입 장벽으로 작용할 수 있다. 여러개의 DSL을 조합하여 사용해야 하는 경우에는 더더욱 그렇다.
- 호스팅 언어의 한계: internal DSL로 제공하는 경우 사용하는 프로그래밍 언어가 엄격한 형식을 가지고 있다면 DSL을 간결하게 표현하기 어려울 수 있다.
순수 자바로 DSL 만들기
- 순수 자바로 DSL을 만든다면 다음과 같은 장점이 있다.
- external DSL 대비 새로운 패턴과 기술을 배워 DSL을 구현하는데 들어가는 노력이 줄어든다.
- 또한 DSL을 만들기 위해 외부 도구 (파서 등)가 필요 없다.
- 사용하는 입장에서도 익숙한 자바의 DSL과 유사한 형태로 새로운 DSL을 사용할 수 있다 (IDE 통합에서의 이점도 있다).
- 이런 방식으로 개발된 DSL 간에는 쉽게 결합하여 사용 가능하다.