현재의 자바는 정말 다양한 버전들이 나와 있는 것을 확인 할 수 있습니다. 그러면 왜??? 이렇게 다양한 버전의 자바가 개발이 되고 업데이트가 되는 것 일까요?
자바는 왜 계속 버전이 바뀔까?
시장에서 요구되는 기술들이 변함에 따라서, 더욱 발전된 기술을 사용해서 개발해야하는 상황이 계속해서 발생하게 됩니다. 그렇기 때문에 계속 새로운 언어가 생기고, 버전도 발전을 하는 것이라고 생각하면 됩니다.
위의 요구 사항에 의해서 점점 자바가 발전하고 나온 대표적이고 많이 사용되는 기술들은 아래와 같습니다.
- 빅데이터 처리가 필요함에 따라 컴퓨터와 같은 병렬 프로세싱이 가능한 장비들이 보급이 되어 이에 따른 요구사항이 생기게 됩니다.
우리가 기존에 배웠던 `Multi-Thread`의 개념을 응용한 것 입니다. 이렇게 멀티 쓰레드에는 2가지 모델로 구분할 수 있습니다.
Data 병렬 처리 모델

숫자가 100개가 있는 데이터를 처리해야 하는 경우에는 기존에는 loop를 100번 돌아야 했습니다. 여기서, 숫자가 1회당 4개의 데이터가 처리가 가능하다고 했을 경우 반복문은 25번으로 급속도로 빨라지게 되는 것입니다. 이런 처리 모델을 Data 병렬 처리 모델 이라고 합니다.
▶ CPU SSE (Streaming SIMD Extensions)
SIMD (Single Instruction Multiple Data)라고도 합니다.
단일 명령어로 복수의 데이터를 처리합니다.
xmm이라는 8개의 128 bit 레지스터(샌드브릿지 아키텍처부터는 256 bit 레지스터) 존재하여 해당 레지스터를 통해 복수 데이터를 처리하는 기술
▶ OpenMP (Open Multi-Processing)
공유 메모리 다중 처리 프로그래밍 API
특정 구간을 병렬화 시켜주는 기술
▶ Intel TBB (Threading Building Blocks)의 Generic Parallel Algorithms
parallel_for, parallel_do, parallel_while, parallel_reduce, parallel_scan 등등
▶ CUDA
NVIDIA 사 GPGPU의 매니코어 사용할 수 있는 라이브러리
▶ OpenCL (Open Computing Language)
CUDA 보다 좀 더 확장된 개념으로 CPU, GPU, FPGA 등등의 멀티/매니코어 플랫폼을 이용하기 위한 표준 기술 등이 있습니다.
▶ SDAccel
Xilinx 사의 FPGA 기반 프로그램을 보다 손쉽게 작성할 수 있도록 하는 개발 플렛폼으로 OpenCL, C, C++등의 언어로 작성할 수 있습니다.
Task 병렬 처리 모델
차동차를 만드는 공장이라고 가정했을 때, Task 병렬 처리 모델은 가중치가 필요합니다. 제시 되는 예제에서는 자중치를 작업 시간으로 가정합니다.

해당 각각의 테스크를 3개의 PE(Processing Element, 실행의 기본 단위가 되는 하드웨어 유닛)ㅇ로 나누어 보면(3 core 로 나눈다는 표현과 같습니다.)

해당 과정의 작업을 3 core로 나누고 병렬처리를 진행을 하니, 소요되는 시간이 24시간에서 12시간으로 한번에 줄어든 것을 확인 할 수 있습니다. 하지만, 위의 예시는 Task를 잘못 분할한 예시입니다.
다시한번 분할하면 아래와 같습니다.
올바른 테스크의 구분을 통해서 작업을 수행하니 위에 예시보다 시간이 훨씬 줄어든것을 확인 할 수 있습니다. 이런 것을 Task 병렬 처리 모델이라고 합니다.
▶ Intel TBB (Threading Building Blocks)의 Flow Graph 와 Task Scheduler
▶ CUDA
: NVIDIA 사 GPGPU의 매니코어 사용할 수 있는 라이브러리
▶ OpenCL (Open Computing Language)
: CUDA 보다 좀 더 확장된 개념으로 CPU, GPU, FPGA 등등의 멀티/매니코어 플랫폼을 이용하기 위한 표준 기술
▶ SDAccel
: Xilinx 사의 FPGA 기반 프로그램을 보다 손쉽게 작성할 수 있도록 하는 개발 플렛폼으로 OpenCL, C, C++등의 언어로 작성할 수 있습니다.
실제로 프로그래밍을 하는 과정에서는 위의 Data 처리 / Task 처리 모델을 잘 조합하여 사용해야 합니다.
import java.util.ArrayList;
import java.util.List;
public class LambdaAndStream {
public static void main(String[] args) {
ArrayList<Car> carsWantToPark = new ArrayList<>(); // 주차 대상 차량
ArrayList<Car> parkingLot = new ArrayList<>(); // 주차장
ArrayList<Car> weekendParkingLot = new ArrayList<>(); // 주말 주차장
// 5개의 자동차 instance
Car car1 = new Car("Benz", "Class E", true, 0);
Car car2 = new Car("BMW", "Series 7", false, 100);
Car car3 = new Car("BMW", "X9", false, 0);
Car car4 = new Car("Audi", "A7", true, 0);
Car car5 = new Car("Hyundai", "Ionic 6", false, 10000);
carsWantToPark.add(car1);
carsWantToPark.add(car2);
carsWantToPark.add(car3);
carsWantToPark.add(car4);
carsWantToPark.add(car5);
// parkingLot.addAll(parkingCarWithTicket(carsWantToPark));
parkingLot.addAll(parkCars(carsWantToPark, Car::hasTicket));
// parkingLot.addAll(parkingCarWithMoney(carsWantToPark));
parkingLot.addAll(parkCars(carsWantToPark, Car::noTicketButMoney));
// 익명함수 적용
parkingLot.addAll(parkCars(carsWantToPark), (Car car) -> car.hasParkingTicket()) && car.getParkingMoney > 1000));
for (Car car : parkingLot) {
System.out.println("Parked Car : " + car.getCompany() + "-" + car.getModel());
}
}
// 위의 두 메서드를 하나로 합친다
public static List<Car> parkCars(List<Car> carsWantToPark, Predicate<Car> function){
List<Car> cars = new ArrayList<>();
for (Car car : carsWantToPark){
// 전달된 함수를 통해서 구현합니다.
if(function.test(car)) {
cars.add(car);
}
}
}
}
class Car {
private final String company; // 자동차 회사
private final String model; // 자동차 모델
private final boolean hasParkingTicket;
private final int parkingMoney;
public Car(String company, String model, boolean hasParkingTicket, int parkingMoney) {
this.company = company;
this.model = model;
this.hasParkingTicket = hasParkingTicket;
this.parkingMoney = parkingMoney;
}
public String getCompany() {
return company;
}
public String getModel() {
return model;
}
public boolean hasParkingTicket() {
return hasParkingTicket;
}
public int getParkingMoney() {
return parkingMoney;
}
public static boolean hasTicket(Car car){
return car.hasParkingTicket;
}
public static boolean noTicketButMoney(Car car){
return !car.hasParkingTicket && car.getParkingMoney > 1000;
}
}
interface Predicate<T> {
boolean test(T t);
}
스트림이라는 것은 직역하면 흐름이라고 할 수 있습니다. 그 흐름동안 사용할 수 있는 메서드들을 api로 제공하는 것입니다.
▶ 스트림의 특징
default Stream<E> stream(){
return StreamSupport.stream(spliterator(), false);
}
위의 Stream은 collection에 정의되어 있기 때문에 모든 컬렉션을 상속하는 구현체들은 스트림을 반환 할 수 있습니다.
▶ 스트림의 전체적인 과정
전체 -> 맵핑 -> 필터링 1 -> 필터링 2 -> 결과 만들기 -> 결과물
: 보통 배열과 컬렉션을 이용해서 스트림을 만들지만 이 외에도 다양한 방법으로 스트림을 만들 수 있습니다. 하나씩 살펴보겠습니다.
- 배열 스트림
스트림을 이용하기 위해서는 먼저 생성을 해야 합니다. 스트림은 배열 또는 컬렉션 인스턴스를 이용해서 생성할 수 있습니다. 배열은 다음과 같이 Arrays.stream 메서드를 사용합니다.
String[] arr = new String[]{"a", "b", "c"};
Stream<String> stream = Arrays.stream(arr);
Stream<String> streamOfArrayPart =
Arrays.stream(arr, 1, 3); // 1~2 요소 [b, c]
- 컬렉션 스트림
컬렉션 타입의 경우 인터페이스에 추가된 디폴트 메서드를 이용해서 만들 수 있습니다.
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
Stream<String> parallelStream = list.parallelStream(); // 병렬 처리 스트림
- 비어 있는 스트림
비어 있는 스트림도 생성할 수 있습니다. 이런 빈 스트림은 요소가 안에 없을 때 null 값을 대신하여 사용할 수 있습니다.
Public Stream<String> streamOf(List<String> list){
return list == null || list.isEmpty()
? Stream.empty()
: list.stream();
}
위의 삼항 연산자를 사용해서, 리스트가 비었을 경우 그냥 빈 스트림을 생성하거나 아니면 list.stream한 결과를 도출하는 코드임을 확인 할 수 있습니다.
- Stream.builder()
빌더를 사용하면 스트림에 직접적으로 원하는 값을 넣을 수 있습니다. 마지막에 build 메서드로 스트림을 리턴합니다.
Stream<String> builderStream =
Stream.<String>builder()
.add("Eric").add("Elena").add("Java")
.build(); // ["Eric","Elena","Java"]
- Stream.generate()
generate 메서드를 이용하면 Supplier<T>에 해당하는 람다로 값을 넣은 수 있습니다. Supplier<T>는 인자는 없고 리턴값만 있는 함수형 인터페이스 입니다. 람다에서 리턴하는 값이 들어 갑니다.
public static<T> Stream<T> generate(Supplier<T> s) { ... }
이 때 생성되는 스트림은 크기가 정해져 있지 않고 무한하기 때문에 특정 사이즈로 최대 크기를 제한해야 합니다.
Stream<String> generatedStream = Stream.generate(() -> "gen").limit(5); // [gen, gen, gen, gen, gen]
- Stream.iterate()
iterate 메서드를 이용하면 초기값과 해당 값을 다루는 람다를 이용해서 스트림에 들어갈 요소를 만듭니다. 다음 예제에서는 30이 초기값이고 값이 2씩 증가하는 값들이 들어가게 됩니다. 해당 방법에서도 스트림의 크기는 한정되어 있기 때문에 제한을 두어야 합니다.
Stream<Integer> iteratedStream = Stream.iterate(30, n -> n+2).limit(5) // [30, 32, 34, 36, 38]
- 기본 타입형 스트림
물론 제네릭을 사용하면 리스트나 배열을 이용해서 기본 타입 스트림을 생성할 수 있습니다. 하지만, 제네릭을 사용하지 않고 직접적으로 해당 타입의 스트림을 다룰 수 도 있습니다. range와 rangeClosed는 범위의 차이입니다. 두 번째 인자인 종료지점이 포함되느냐 안되느냐의 차이입니다.
IntStream intStream = IntStream.range(1,5); // [1,2,3,4]
LongStream longStream = LongStream.rangeClosed(1,5) // [1,2,3,4,5]
DoubleStream doubles = new Random().doubles(3); // 난수 3개 생성
제네릭을 사용하지 않기 때문에 불필요한 오토박싱이 일어나지 않습니다. 필요한 경우 boxed 메서드를 이용하여 박싱을 할 수 있습니다.
Stream<Integer> boxedIntStream = IntStream.range(1,5).boxed();
그냥 일반 타입으로 선언이 되어 있는 배열을 Array.stream()을 이용하여 리스트로 만들면 처음에 언급한, 기본 타입 스트림을 생성할 수 있습니다.
- 문자열 스트링
스트링을 이용해서 스트림을 생성할 수도 있습니다. 다음은 스트링의 각 문자를 IntStream 으로 변환한 예제입니다.
IntStream charStream = "Stream".chars(); // [83, 116, 114, 101, 97, 109]
다음 예시는 Regrex를 통해서 문자열을 자르고, 스트림을 만든 예제입니다.
Stream<String> stringStream = Pattern.compile(", ").splitAsStream("Eric, Elena, Java");
// [Eric, Elena, Java];
- **파일 스트림**
자바 NIO(입출력)의 Files 클래스의 lines메서드는 해당 파일의 각 라인을 스트링 타입의 스트림으로 만들어 줍니다.
Stream<String> lineStream =
Files.lines(Path.get("file.txt"),
Charset.forName("UTF-8"));
- **병렬 스트림**
스트림 생성 시 사용하는 stream 대신 parallelStream 메서드를 사요해서 병렬 스트림을 쉽게 생성 할 수 있습니다. 내부적으로는 쓰레드를 처리하기 위해 자바 7부터 도입된 Fork/Join framework를 사용합니다.
boolean isMany = parallelStream
.map(product -> product.getAmount() * 10)
.anyMatch(amount -> amount > 200);
컬렉션과 배열이 아닌 경우는 다음과 같이 parallel 메서드를 이용해서 처리합니다.
IntStream intStream = IntStream.range(1, 150).parallel();
boolean isParallel = intStream.isParallel();
다시 시퀸셜 모드로 돌리고 싶다면 다음처럼 sequential 메서드를 사용합니다.
- 스트림 연결하기
Stream.concat 메서드를 이용해 두 개의 스트림을 연결해서 새로운 스트림을 만들어 낼 수 있습니다.
Stream<String> stream1 = Stream.of("Java", "Scala", "Groovy");
Stream<String> stream2 = Stream.of("Python", "Go", "Swift");
Stream<String> concat = Stream.concat(stream1, stream2);
// [Java, Scala, Groovy, Python, Go, Swift]
전체 요소 중에서 다음과 같은 API를 이요앻서 내가 원하는 것만 뽑아낼 수 있습니다. 이러한 가공 단계를 중간 작업이라고 하는데, 이러한 작업은 스트림을 리턴하기 때문에 여러 작업을 이어 붙여서 작성할 수 있습니다.
List<String> names = Arrays.asList("Eric", "Elena", "Java");
filtering
위 처럼 정의 되어 있는 Collection 같은 경우에는 데이터를 가공하는데 크게 , Mapping 과 Filtering을 사용할 수 있습니다. 우선은 Filtering에 대해서 설명하겠습니다.
필터링은 스트림 내 요소들을 하나씩 평가해서 걸러내는 작업입니다. 인자로 받는 Predicate 는 boolean을 리턴하는 함수형 인터페이스로 평가식이 들어가게 됩니다.
Stream<T> filter(Predicate <? super T> predicate);
💡 알아 둡시다
간단한 예제를 들어 설명드리겠습니다.
System.out.println("여행 분류 책 추천!");
bookList.stream()
.filter(book -> {
return book.getCategory().equals("여행");
}).forEach(i -> System.out.println("제목 : " + i.getBookName()));
위의 코드를 설명하기 전에 전체적인 구조에 대해서 설명 드리면, Book라는 클래스에 책의 이름, 가격 등을 저장하고 List를 해당 Book클래스의 타입으로 선언을 했습니다. 그것을 bookList라고 정의를 하고 위의 작업을 진행했습니다.
filter를 통해서 걸러내는 작업을 진행하고 있습니다.
Mapping
맵은 스트림 내 요소들을 하나씩 특정 값으로 변환해줍니다. 이때 값을 변환하기 위한 람다를 인자로 받습니다.
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
스트림에 들어가 있는 값이 input이 되어서 특정 로직을 거친 후 output이 되어 새로운 스트림에 담기게 됩니다. 이러한 작업을 매핑이라고 합니다.
Stream<String> stream = names.stream()
.map(String::toUpperCase);
위의 예시 처럼 요소 내에 들어있는 것을 대문자로 변환하거나 내부에 정의되어 있는 getter / setter 등을 이용해서 값을 얻어와 다른 List로 선언을 하거나 설정을 하는 등의 작업을 할 수 있습니다.
System.out.println("이 중 가장 비싼 책!");
double maxValue = bookList.stream()
.mapToDouble(Book::getPrice)
.max().getAsDouble();
System.out.println("가장 비싼 책 금액 : " + maxValue);
System.out.println();
Sorting
정렬의 방법은 다른 정렬과 마찬가지로 Comparator를 이용합니다.
Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);
인자 없이 그냥 호출할 경우 오름차순으로 정렬합니다.
IntStream.of(14,11,20,39,23)
.sorted().boxed()
.collect(Collectors.toList());
// [11,14,20,23,39]
List<String> lang =
Arrays.asList("Java", "Scala", "Groovy", "Python", "Go", "Swift");
lang.stream()
.sorted()
.collect(Collectors.toList());
// [Go, Groovy, Java, Python, Scala, Swift]
lang.stream()
.sorted(Comparator.reverseOrder())
.collect(Collectors.toList());
// [Swift, Scala, Python, Java, Groovy, Go]
인자를 넘기는 과정에서, Collection에 담기 위해서 boxed를 해주고 정렬을 같이 해주면서 정렬이 마쳐진 리스트를 얻게 되었습니다. 여기서 다른 정렬을 하고 싶은 경우에는 sorted내부에 구현이 되어 있는 comparator.compare()메서드를 오버라이딩 해주면 됩니다.
lang.stream()
.sorted(Comparator.comparingInt(String::length))
.collect(Collectors.toList());
// [Go, Java, Scala, Swift, Groovy, Python]
lang.stream()
.sorted((s1, s2) -> s2.length() - s1.length())
.collect(Collectors.toList());
// [Groovy, Python, Scala, Swift, Java, Go]
Iterating
스트림 내 요소들 각각을 대상으로 특정 연산을 수행하는 메소드로는 peek이 있습니다. 해당 메서드는 그냥 확인해본다는 단어 뜻처럼 특정 결과를 반환하지 않는 함수형 인터페이스 Consumer를 인자로 받습니다.
Stream<T> peek(Consumer<? esuper T> action);
따라서 스트램 내 요소들 각각에 특정 작업을 수행할 분 결과에 영향을 미치지 않습니다. 다음 처럼 작업을 처리하는 중간에 결과를 확인해 볼 때 사용할 수 있습니다.
int sum = IntStream.of(1,3,5,7,9)
.peek(System.out::println)
.sum();
: 가공한 스트림을 가지고 내가 사용할 결과값으로 만들어내는 단계로, terminal operations라고 할 수 있습니다.
스트림 API는 다양한 종료 작업을 제공합니다. 최소, 최대, 합, 평균 등 기본형 타입으로 결과를 만들어낼 수 있습니다.
long count = IntStream.of(1,3,5,7,9).count();
long sum = LongStream.of(1,3,5,7,9).sum();
만약 스트림이 비어 있는 경우 count와 sum은 0을 출력하면 됩니다. 하지만 평균, 최소, 최대의 경우에는 표현할 수가 없기 때문에 Optional을 이용해 리턴합니다.
OptionalInt min = IntStream.of(1,3,5,7,9).min();
OptionalInt max = IntStream.of(1,3,5,7,9).max();
스트림에서 바로 ifPresent 메서드를 이용해서 Optional을 처리할 수 있습니다.
DoubleStream.of(1.1,2.2,3.3,4.4,5.5)
.average()
.ifPresent(System.out::println);
이 외에도 사용자가 원하는대로 결과를 만들어내기 위해 reduce와 collect 메서드를 제공합니다. 이 두 가지 메서드를 좀 더 알아보면 아래와 같습니다.
스트림은 reduce라는 메서드를 이용해서 결과를 만들어냅니다. 이러한 메서드는 아래의 세가지 파라미터를 받을 수 있습니다.
1. accumulator : 각 요소를 처리하는 계산 로직. 각 요소가 올 때마다 중간 결과를 생성하는 로직
2. identity : 계산을 위한 초기값으로 스트림이 비어서 계산할 내용이 없더라도 이 값은 리턴합니다.
3. combiner : 병렬 스트림에서 나눠 계산한 결과를 하나로 합치는 동작 로직
// 1개 (accumulator)
Optional<T> reduce(BinaryOperator<T> accumulator);
// 2개 (identity)
T reduce(T identity, BinaryOperator<T> accumulator);
// 3개 (combiner)
<U> U reduce(U identity,
BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner);
먼저 인자가 하나만 있는 경우입니다. 여기서 BinaryOperator<T>는 같은 타입의 인자 두 개를 받아 같은 타입의 결과를 반환하는 함수형 인터페이스입니다. 다음 예제에서는 두 값을 더하는 람다를 넘겨 주고 있습니다.
OptionalInt reduced =
IntStream.range(1, 4) // [1, 2, 3]
.reduce((a, b) -> {
return Integer.sum(a, b);
});
그 다음은 두개의 인자를 받는 경우에 대해서 보여드리겠습니다. 여기서 10은 초기값이고, 스트림 내 값을 더해서 결과는 16이 됩니다.
int reducedTwoParams =
IntStream.range(1, 4) // [1, 2, 3]
.reduce(10, Integer::sum); // method reference
마지막으로 세 개의 인자를 받는 경우입니다. Combiner가 하는 역할을 설명만 봤을 때는 잘 이해가 안갈 수 있지만, 코드를 통해서 보겠습니다.
// Combiner : 병렬 스트림에서 나눠 계산한 결과를 하나로 합칩니다.
// 하지만 이 경우는 실행이 되질 않는다.
Integer reducedParams = Stream.of(1,2,3)
.reduce(10, // identity : 계산을 위한 초기값
Integer::sum, // accumulator : 계산하는 로직
(a,b) -> {
System.out.println("combiner was called");
return a+b;
});
)
위 코드는 combiner 부분이 실행이 되질 않은 코드 입니다. 그 이유는 combiner는 multi-thread 환경에서 동작을 하는데 여기서는 순차적으로 프로그램이 동작을 하기 때문입니다. 그래서 해당 코드를 동작하게 만드려면 parallel() 혹은 parallelStream()을 추가 해야합니다.
Integer reducedParallel = Arrays.asList(1, 2, 3)
.parallelStream()
.reduce(10,
Integer::sum,
(a, b) -> {
System.out.println("combiner was called");
return a + b;
});
위 과정을 조금 아날로그하게 설명을 진행하겠습니다.
identity = 10을 기준으로 list의 요소를 각각 병렬적으로 계산을 진행하는 것(accumulator) 입니다. 그리고 마지막으로 11 + 12 + 13을 더한 결과를 출력합니다.
combiner was called
combiner was called
36
이러한 병렬 스트림이 무조건 시퀀셜 보다 좋은 것은 아니기 때문에 상황에 맞게 사용하는 것은 권장합니다.
collect 메소드는 또 다른 종료 작업입니다. Collector 타입의 인자를 받아서 처리를 하는데요, 자주 사용하는 작업은 Collectors 객체에서 제공하고 있습니다.
이번 예제에서는 다음과 같은 간단한 리스트를 사용합니다. Product 객체는 수량(amout)과 이름(name)을 가지고 있습니다.
List<Product> productList =
Arrays.asList(new Product(23, "potatoes"),
new Product(14, "orange"),
new Product(13, "lemon"),
new Product(23, "bread"),
new Product(13, "sugar"));
// toList()
List<String> collectorCollection =
productList.stream()
.map(Product::getName)
.collect(Collectors.toList());
// [potatoes, orange, lemon, bread, sugar]
// joining()
String listToString =
productList.stream()
.map(Product::getName)
.collect(Collectors.joining());
// potatoesorangelemonbreadsugar
// delimeter : 각 요소 중간에 들어가 요소를 구분시켜주는 구분자
// prefix : 결과 맨 앞에 붙는 문자
// suffix : 결과 맨 뒤에 붙는 문자
String listToString =
productList.stream()
.map(Product::getName)
.collect(Collectors.joining(", ", "<", ">"));
// <potatoes, orange, lemon, bread, sugar>
// Collectors.averageingInt() : 숫자들의 평균
Double averageAmount =
productList.stream()
.collect(Collectors.averagingInt(Product::getAmount));
// 17.2
// summingInt() : 숫자 값의 합
Integer summingAmount =
productList.stream()
.collect(Collectors.summingInt(Product::getAmount));
// 86
// 위의 summingInt를 조금 더 쉽게 작성한 버전
Integer summingAmount =
productList.stream()
.mapToInt(Product::getAmount)
.sum(); // 86
// summarizingInt()
IntSummaryStatistics statistics =
productList.stream()
.collect(Collectors.summarizingInt(Product::getAmount));
위의 summarizingInt()는 객체 내의 다음과 같은 정보가 담겨 있습니다.
IntSummaryStatistics {count=5, sum=86, min=13, average=17.200000, max=23}
이를 이용하면 map을 따로 호출하여 변환하는 과정을 거칠 필요 없이 값을 구할 수 있습니다.
References
1. Naver-cloud-platform
2. Stream()