안녕하세요. 오늘은 Java의 Stream API 메소드 중에서, map에 대해서 알아보겠습니다.
0. 추천 독자
이 글은 특히 Stream API에 대해서 어느정도는 알고 있지만, 활용이 쉽지 않으신 분 혹은 Stream에 대해서 사용하면서 익히고 싶은 분이 읽으시면 특히 좋을 것 같습니다.
1. 학습목표
당연하게도 Stream API의 map에 대해서 공부하는게 목적입니다. 그런데 왜 하필 map이냐고요?
왜냐하면, 사실 제가 Stream 사용에 있어서 가장 이해가 안 되고 어려웠던 부분이 map이었습니다. map을 잘못 이해하고 사용하려다 보니 저의 Stream을 활용한 코드는 빨간 줄이 항상 가득했었어요. 그래서 많이 쓰는 Stream 코드를 아예 외워서 사용했었습니다. 이대로는 안 되겠다 싶어서 map에 대해서 쉽게 이해하려고 노력했고, 제가 이해한 부분을 저와 같은 고민을 하시는 분을 위해 공유하고자 합니다.
map에 대해서 이해한 후에는, Stream을 사용하는 게 무척 편해지고 쉬워졌습니다. 사실 map 이후에 여러 작업들은 응용력도 물론 중요하지만, 메소드를 외워서 사용할 수 있는 부분이 많아서 그런 것 같아요. 그래서 저는 map을 자유롭게 사용할 수 있다면 Stream에 대해서 50% 이상 이해한 것이라고 감히 생각합니다.
이제부터 map에 대해서 설명해 볼 텐데요, Stream의 특징, 최종연산... 이런 부분에 대해서는 작성하지 않거나 설명하지 않고 넘어갈 계획입니다. 다른 게시글이나 블로그에 너무나도 잘 정리된 자료가 많기 때문이에요. 코드를 보면서 이해해 보겠습니다.
그럼 이제부터 시작해 보겠습니다.
2. map
그래서 map이 뭘까요? 공식문서의 map 메소드에 대한 설명은 아래와 같습니다.
Returns a stream consisting of the results of applying the given function to the elements of this stream.
간단하게 직역해 보면 '이 스트림의 요소에 주어진 함수를 적용한 결과로 구성된 스트림을 반환합니다.'로 번역할 수 있습니다.
제가 map 메소드에 대해서 헷갈렸던 이유는, 위의 stream에 대한 설명이 무엇인가 와닿지 않고 어렵게 느껴졌습니다. 거기에 더불어서 map이라는 용어가 프로그래밍에서 mapping의 의미로 많이 쓰이는데요, 이러한 추상적인 용어들이 뒤죽박죽 섞여서 제대로 이해하지 못하고 외워서 썼던 것 같아요. (결국엔 공부를 대충 했다는 이야기입니다.)
그래서 저는 이런 방식으로 생각했어요. 'map은 Stream의 요소들을 내가 사용할 형태로 바꾸거나, 사용할 요소를 뽑아내는 것'라고 생각했습니다.
코드를 보면서 같이 생각해 볼게요.
1 2 3 4 5 6 7 8 9 10
알고리즘에 많이 등장할 법한 input인데요, 이러한 input을 받아서 int array로 변환하는 stream을 사용해 보겠습니다.
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
StringTokenizer st = new StringTokenizer(br.readLine());
// 많이 사용하는 방식
int[] arrayWithStringTokenizer = new int[10];
for (int i = 0; i < 10; i++) {
arrayWithStringTokenizer[i] = Integer.parseInt(st.nextToken());
}
// stream으로 배열 만들기
int[] arrayWithStreamAPI = Stream.of(br.readLine()
.split(" "))
.mapToInt(Integer::parseInt)
.toArray();
System.out.println(Arrays.toString(arrayWithStringTokenizer));
System.out.println(Arrays.toString(arrayWithStreamAPI));
}
}
=================================================================
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
JAVA를 사용해서 알고리즘 문제를 푸시는 분이라면 첫 번째 방식은 많이 익숙하시죠? 어떤 작업을 Stream으로 표현할 건지 쉽게 설명하려고 같은 동작을 하는 코드를 넣어봤습니다.
이제 Stream에 대해서 차근차근 설명해 보겠습니다.
먼저, Stream.of를 통해서 Stream을 생성했어요. BufferedReader의 readLine으로 1줄을 String으로 입력받아서, " "를 기준으로 쪼개서 만든 배열을 요소로 사용했습니다.
"1 2 3 4 5 6 7 8 9 10"로 입력받아서, {"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}로 만든 것을 요소로 사용함!
즉, String array로 Stream을 생성했습니다.
그 뒤에, mapToInt(Integer::parseInt)를 사용했어요. 이 말이 무엇이냐면, 위에서의 "1"부터 "10"까지의 String array의 요소 하나하나를 Integer.parseInt를 적용해서 int로 바꾼 거예요. 풀어서 써보자면, 모든 요소에 대해서
Interger.parseInt("1"), Integer.paresInt("2").... Integer.parseInt("10")
의 작업을 해준 거예요. 위와 같은 작업을 통해서 'String'을 'int'로 바꿔 주었어요. 즉 내가 사용할 형태로 바꾼 것입니다.
이 사진을 보시면, mapToInt 전까지 Stream<String> 형태이고, mapToInt를 통해서 IntStream으로 바뀐 것을 알 수 있습니다.
한 번으론 아쉬우니까, 간단한 예시를 한번 더 들어보겠습니다.
import java.util.List;
public class Main {
static class Product {
String name;
int price;
public Product(String name, int price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
}
public static void main(String[] args) {
Product product1 = new Product("하나", 1);
Product product2 = new Product("둘", 2);
Product product3 = new Product("셋", 3);
List<Product> productList = List.of(product1, product2, product3);
List<String> productNameList = productList.stream()
.map(Product::getName)
.toList();
System.out.println(productNameList.toString());
}
}
=========================================
[하나, 둘, 셋]
급하게 간단한 코드를 만들어 보았습니다. 상품의 이름만 뽑아내서 List로 반환하는 코드입니다.
map(Product::getName)을 통해서 이름을 뽑아내고, toList()로 반환했습니다.
결국 map을 쉽게 이해하는 핵심은, 원하는 요소로 뽑아낸다고 생각하면 될 것 같아요.
이 정도면 map을 사용하는 데에 있어서 어려움은 크게 없다고 생각하는데... Stream의 map을 사용할 때마다 의문인 점이 있습니다.
바로 수많은 map의 파생상품들이에요. 얘내는 왜 존재하는 걸까요? 이거에 대해서도 알아보겠습니다.
3. map의 파생상품들
위의 내용을 보면서, '아니 그럼, map만 있으면 되는 거 아닌가? mapToInt는 뭐지?'라는 생각을 하셨을 수도 있을 것 같습니다. 어느 정도는 맞는 말이에요. map 메서드로도 대부분의 작업을 수행할 수 있지만, mapToInt가 존재하는 이유는 '성능의 향상' 때문입니다. 여러 메서드가 있지만, 대표적인 mapToInt에 대해서 알아보겠습니다.
3-1. mapToInt
mapToInt는 정수형(int) 매핑에 특화되어 있습니다. 그리고 이것은 성능의 향상 때문에 존재한다고 말씀드렸는데요, 왜 그런지 이유를 설명하기 전에 코드부터 보고 가겠습니다.
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 10000000; i++) {
list.add(i);
}
long startTime = System.currentTimeMillis();
list.stream()
.map(x -> x * x)
.forEach(x -> {
});
long endTime = System.currentTimeMillis();
System.out.println("map 소요시간 : " + (endTime - startTime) + " ms");
startTime = System.currentTimeMillis();
list.stream()
.mapToInt(x -> x * x)
.forEach(x -> {
});
endTime = System.currentTimeMillis();
System.out.println("mapToInt 소요시간 : " + (endTime - startTime) + " ms");
}
}
==================================================
map 소요시간 : 117 ms
mapToInt 소요시간 : 29 ms
동일한 작업을 하는 코드이고, 단지 map과 mapToInt의 차이일 뿐인데 속도 차이가 상당합니다. 왜냐하면 자바에서 원시타입(primitive type)과 참조타입(reference type(object))을 다루는 방식 때문입니다.
map 메서드는 Stream의 요소를 뽑아내거나 변환한 뒤, ' Stream <T>' 형태로 반환합니다. 즉, 각각의 요소를 Integer로 boxing 하고 있어요. Integer는 객체이기 때문에 메모리 할당과 Garbage Collection에 상당한 시간을 소비하게 됩니다. 또한, 당연히 메모리 소비량도 높아지겠죠? (https://s7won.tistory.com/4 를 참조하시면 이해에 도움이 될 수도 있어요!)
반면, mapToInt 메소드는 원시타입인 'int' 값을 반환하는 'IntStream'의 형태로 반환해요. 이는 앞에서의 Integer(객체) 생성과 관련된 오버헤드를 제거하므로 성능이 향상되는 거예요.
즉, 원시타입을 다루는 mapToInt 메소드가 래퍼 클래스(wrapper class)를 다루는 map보다 더 효율적으로 요소를 처리하는 것입니다.
mapToInt 뿐만 아니라, mapToLong, mapToDouble 모두 원시타입을 다루기 위해 존재하는 것이에요.
mapToInt가 int를 다루는 Stream을 반환한다는 것을 알았으니, 위에서의 int array를 반환하는 코드가 List를 반환하게 해 보겠습니다.
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
// stream으로 List 만들기
List<Integer> listWithStreamMapToInt = Stream.of(br.readLine()
.split(" "))
.mapToInt(Integer::parseInt)
.boxed() // 컬렉션을 만들기 위해 boxing 해주기
.toList();
List<Integer> listWithStreamMap = Stream.of(br.readLine()
.split(" "))
.map(Integer::parseInt)
.toList(); // map은 객체(여기서는 Integer)를 다루므로 바로 컬렉션으로 반환하기
System.out.println(listWithStreamMapToInt.toString());
System.out.println(listWithStreamMap.toString());
}
}
첫 번째 Stream은 mapToInt를 사용하므로 원시타입인 int를 반환할 것이고, 이는 컬렉션 프레임워크의 제네릭 타입 인자로 직접 사용이 불가능하므로 boxed()를 통해서 Integer로 boxing 해주고, toList()를 통해 list로 반환합니다.
두 번째 Stream은 map을 사용하므로 Stream<Integer>를 반환할 것이고, 이는 제네릭 타입 인자로 사용할 수 있으므로 바로 toList()로 list로 반환합니다.
추가로, 원시타입과 래퍼클래스의 실행 시간을 비교한 표입니다. 혹시 Stream으로 double을 다룰 일이 있으면 mapToDouble을 사용하셔야 할 것 같네요.
3-2. mapToObj
앞에서의 mapToInt와 그 친구들을 이해하셨다면, 이제 mapToObj는 이름만 들어도 유추하실 수 있을 것 같습니다. 앞에서의 역할과 반대로, 원시 타입 스트림을 참조(객체) 타입 스트림으로 변경합니다. 바로 코드로 확인해 보겠습니다.
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
int[] array = new int[10];
for (int i = 0; i < 10; i++) {
array[i] = i;
}
String joining = Arrays.stream(array)
.mapToObj(String::valueOf)
.collect(Collectors.joining("-"));
String joining2 = Arrays.stream(array)
.boxed()
.map(String::valueOf)
.collect(Collectors.joining("-"));
System.out.println(joining);
System.out.println(joining2);
}
}
=================================================
0-1-2-3-4-5-6-7-8-9
0-1-2-3-4-5-6-7-8-9
첫 번째 joining은 mapToObj로 원시 타입을 객체 타입 스트림으로 변환했습니다.
두 번째 joining2는 map으로 변환하려고 하였으나, IntStream은 원시타입 스트림이므로 컴파일 에러가 발생해요. boxed()로 Stream<Integer>로 변환한 뒤, 작업을 수행했습니다.
3-3 flatMap
마지막으로 flatMap입니다. flatMap은 중첩된 스트림 구조를 펼쳐서 평면적인 구조로 만들어줍니다.
라고 정의가 되어있지만... 솔직히 와닿지 않고 어렵습니다. 실제로도 자유자재로 사용하기가 어려운 것 같아요.
그러니 코드를 보면서 생각해 봅시다.
List<String> greeting = Arrays.asList("hi", "hello", "bye");
greeting.stream()
.map(String::chars)
.forEach(System.out::println);
List의 각각의 단어들을 하나하나씩 쪼개서(hihellobye) 출력하고 싶어서 위와 같이 코드를 작성해 보았습니다. 그러기 위해서, character로 쪼개고 각각을 출력했어요. 제가 이걸 어떻게 할 수 있을지 생각해서 예전에 작성해 본 코드입니다. 아마 Stream에 익숙하신 분들은 절대 코드를 이렇게 작성하지 않으시겠지만, 그때의 저에게는 이게 한계였어요. 과연 이 코드는 어떤 결과를 출력할까요?
java.util.stream.IntPipeline$Head@448139f0
java.util.stream.IntPipeline$Head@7cca494b
java.util.stream.IntPipeline$Head@7ba4f24f
예상한 결과가 아닌, 무서운 결괏값이 나왔습니다. 왜 이런 결과가 나왔을까요?
제가 예상한 결과와 다르게 map은 IntStream을 반환하고, forEach를 통해서 IntStream의 내부 구조를 나타내는 객체가 출력되었습니다.
그럼, 처음으로 돌아와서 제가 원하는 결과를 어떻게 도출할 수 있을지 처음부터 생각해 볼게요.
우선, List의 각각의 요소에 접근해서 단어를 쪼개야 합니다. 그 뒤에, 쪼갠 단어들을 하나씩 출력해야 해요.
즉, 배열 자체에 접근하는 스트림과 배열의 각 요소에 대한 스트림을 생성해야 하는 것이죠. 이럴 때 쓸 수 있는 게 flatMap입니다. flatMap을 사용해 볼게요.
public class Main {
public static void main(String[] args) throws IOException {
List<String> greeting = Arrays.asList("hi", "hello", "bye");
String str = greeting.stream()
.flatMap(word -> Arrays.stream(word.split("")))
.collect(Collectors.joining(","));
System.out.println(str);
}
}
=============================
h,i,h,e,l,l,o,b,y,e
드디어 원하는 결과가 나왔습니다.
위 코드에서 flatMap은 각 배열 요소에 대해서 word -> Arrays.stream(word.split("") 을 적용합니다. 그 뒤에, 이 String 배열을 가지고 스트림을 생성해요. 그렇게 생성된 스트림들을 평면화해서 하나의 스트림으로 합쳐줍니다.
즉, greeting.stream().flatMap(word -> Arrays.stream(word.split(""))) 을 통해서 List에 접근하는 스트림과 배열 요소에 접근하는 스트림 두 개가 연결돼서 하나의 평면화된 스트림이 생성되는 거예요.
마지막으로, 한 가지 예시만 더 보겠습니다.
public class Main {
public static void main(String[] args) throws IOException {
List<List<Integer>> nestedList = Arrays.asList(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5, 6),
Arrays.asList(7, 8, 9)
);
List<Integer> listWithFlatMap = nestedList.stream()
.flatMap(List::stream)
.toList();
System.out.println(listWithFlatMap.toString());
}
}
=========================================
[1, 2, 3, 4, 5, 6, 7, 8, 9]
nestedList.stream()을 통해서 nestedList에 대한 Stream을 생성했어요. 그 뒤에, flatMap(List::stream)을 통해서 nestedList의 내부의 리스트를 개별 스트림으로 변환한 뒤 모든 요소들을 하나로 합친 단일 스트림으로 반환합니다.(평면화, 평탄화)
그 뒤에, list로 반환하고 있어요.
제가 든 2가지의 예시가 가장 대표적인 사용법입니다. flatMap은 이렇게 강력한 기능을 제공하지만 아무래도 많은 학습이 있지 않으면 IDE의 도움을 받아도 쉽게 사용은 어려운 것 같아요. 능숙하게 사용할 수 있도록 공부해서 다음에 flatMap에 대해서도 글을 작성해 보겠습니다.
4. 마무리
지금까지 Stream의 map에 대해서 알아보았습니다.
사실 글을 가벼운 마음으로 작성하기 시작했는데요, 작성하다 보니 글이 너무 길어졌습니다. JAVA의 기본 내용인 원시타입, 참조타입, 래퍼클래스..부터 람다와 메서드참조, 객체 생성... 등등 많은 내용이 섞여서 글이 어렵게 느껴질 것 같아 걱정입니다. 다른 내용은 최소로 작성하고 오로지 map에 대해서만 작성하려 했는데 욕심이 나서 이런저런 내용이 추가되었어요. 글이 길어졌지만, 너그러운 마음으로 이해해 주시고 모쪼록 이 글이 여러분에게 도움이 되었으면 좋겠습니다.
'JAVA' 카테고리의 다른 글
자바의 Record로 DTO를 만들어보자 - 2 (0) | 2023.08.10 |
---|---|
[JAVA] Garbage Collection (GC)에 대해 알아보자 (0) | 2023.04.23 |
자바의 Record로 DTO를 만들어보자 (2) | 2023.03.22 |