이 글은 프로그래머스 - 실무 자바 개발을 위한 OOP와 핵심 디자인 패턴 강의를 정리한 내용입니다.
Stream API
는 그 자체로는 객체지향적인 코드와 크게 연관성이 없습니다. 그러나 Stream API를 활용한 코드는 객체지향적인 코드와 함께 사용되었을 때 가독성을 크게 향상시켜줍니다.
Stream API는 기능적으로 for문
과 if문
을 대체할 수 있습니다. 우선 Stream API를 적용할 수 있는 대상이 어떤 것이 있는지 먼저 알아보겠습니다.
Stream API는 Collection 인터페이스
내부에 존재하는 메서드들입니다. 컬렉션 인터페이스를 구현하는 다른 구현체들에서 사용할 수 있습니다.
그림에서 볼 수 있듯, Map
은 Collection 인터페이스
를 상속 받지 않으므로 Map
에서는 Stream API를 사용할 수 없습니다.
❗️ 하지만
Map
에서도 우회하여 Stream API를 사용하는 방법이 있기는 합니다. 이 방법은 포스트 마지막에 알아보겠습니다.
컬렉션 인터페이스를 상속 받는 List
, Set
, Queue
는 Stream API를 사용할 수 있고, 당연히 해당 인터페이스들을 구현하고 있는 구현체들에서도 사용 가능합니다.
public static void main(String[] args) {
List<Integer> integerList = new ArrayList<>();
integerList.stream()...;
}
위 코드는 컬렉션 인터페이스의 서브 타입인 List
인터페이스를 대상으로한 예시입니다.
stream()
이라는 이름의 메서드를 호출하며, 이 메서드의 호출을 통해 반환된 결과를 통해서 Stream API들을 실행시킵니다.
이 포스팅에서는 가장 많이 사용되는 다음 세 가지 메서드를 확인해보겠습니다.
forEach()
Stream API를 활용하면 for문
을 활용한 반복문을 대체할 수 있습니다.
loopV1
List<Integer> integerList = new ArrayList<>();
integerList.add(10);
integerList.add(20);
integerList.add(30);
integerList.add(40);
integerList.add(50);
integerList.add(60);
integerList.add(70);
for (int i = 0; i < integerList.size(); i++) {
System.out.println(integerList.get(i));
}
위 코드에서는 10 ~ 70의 정수가 들어있는 List를 for문 반복을 통해 출력하고 있습니다. 이 코드를 forEach
를 활용해서 개선해보겠습니다.
loopV2
// Case 01
integerList.stream()
.forEach(integer -> {
System.out.println(integer);
});
// Case 02
integerList.stream().forEach(num -> System.out.println(num));
// Case 03
integerList.forEach(System.out::println);
참고로 forEach()
에 경우 stream()
메서드를 생략할 수 있습니다.
우선 forEach
로 반복하면 i
같은 인덱스가 없습니다. 따라서 현재 반복하고 있는 요소의 이전, 혹은 다음 요소에 직접 접근할 수 없습니다.
물론 별도의 변수에 저장해 비슷한 효과를 낼 수는 있지만, 그렇게 코드를 작성하면 오히려 for문을 사용하는 것 보다 가독성이 떨어지는 상황이 올 수 있습니다.
List<Integer> integerList = new ArrayList<>();
integerList.add(10);
integerList.add(20);
integerList.add(30);
integerList.add(40);
integerList.add(50);
integerList.add(60);
integerList.add(70);
Integer findNumber = null;
for (int i = 0; i < integerList.size() -1; i++) {
System.out.println(integerList.get(i));
if (integerList.get(i).equals(40)) {
findNumber = integerList.get(i);
break;
}
}
다음으로 for문은 실행 도중 break가 가능하지만, Stream API에서는 break를 할 수 없습니다. 왜냐하면 람다 표현식은 반복문이 아니라 함수이기 때문입니다.
😵💫 그럼 함수니까
return
은 가능하죠...??? 그럼return
으로 종료하면...
return
이 가능하긴 하지만 해당 forEach
의 동작을 끝내는건 아닙니다. 그냥 해당 순서의 반복을 종료하고, 다음 순서의 반복으로 넘어갑니다. continue
와 비슷한 역할을 한다고 볼 수 있겠네요.
그럼 중간에 종료하는 방법이 없을까요? 있기는합니다.
integerList.stream()
.forEach(integer -> {
System.out.println(integer);
if (integer.equals(40)) {
throw new RuntimeException();
}
});
위 코드처럼 특정 조건을 만족했을 때 RuntimeException
을 던지면 해당 예외를 잡을 때 까지 계속해서 예외가 던져지므로 한 번에 나갈 수 있습니다.
다만 일반적인 방식은 아닙니다. 이건 다시 말하면 특정 값을 발견했을 때 해당 값을 저장하고 중단하는 로직에 forEach()
가 활용되는건 적절하지 않다는 겁니다.
이런 상황에서 사용하기 적절한건 바로 filter()
입니다.
for문을 사용하고 있는 코드
Integer findNumber = null;
for (int i = 0; i < integerList.size() -1; i++) {
System.out.println(integerList.get(i));
if (integerList.get(i).equals(40)) {
findNumber = integerList.get(i);
break;
}
}
위 로직은 filter()
를 적용하기 좋은 예시입니다.
filter() 적용
// Case 01
Integer findNumber = integerList.stream()
.filter(integer -> {
System.out.println(integer);
if (integer.equals(40))
return true;
return false;
})
.findAny()
.get();
// Case 02 (이렇게 줄일 수도 있다.)
Integer findNumber = integerList.stream()
.filter(integer -> {
System.out.println(integer);
return integer.equals(40);
})
.findAny()
.get();
filter()
를 적용하면 위와 같이 코드를 개선할 수 있습니다.
람다식에 return true & false
를 하는 부분이 있습니다. 여기서 true & false
는 각각 다음과 같은 의미를 가집니다.
filter()
는 반복되는 요소 중 true
인 요소만 뽑아서 새로운 stream을 만들어줍니다. 이렇게 새로 생성된 stream 요소에 대해서는 다시 한번 Stream API를 적용해줄 수 있습니다.
for문 대신 filter를 사용했을 때 이점을 하나 더 얘기하면, 기존 코드는 미리 findNumber
변수를 선언해줘야 하기 때문에 null
이 들어가 NPE
가 발생할 수 있는 여지가 있지만, filter
를 활용한 코드는 filter
가 변수 값을 직접 초기화해주기 때문에 NPE
가 발생할 수 있는 위험에서 자유롭습니다.
filter(integer -> {
System.out.println(integer);
return integer.equals(40);
})
.findAny()
.get();
filter()
사용 시 주의할 점이 있습니다. 위 코드를 보면filter()
다음에 findAny()
라는 메서드가 붙어있는 걸 볼 수 있습니다.
findAny
에 경우 조건에 맞는 요소가 아무거나 하나 발견되면 그 다음 요소의 반복을 실행하지 않습니다. 따라서 자바에서는 최적화를 진행해주는데, 위 예제에서 찾고자 하는 요소인 40
을 찾으면 실행이 멈추지만, 이어서 Stream API가 한번 더 온다고 하면 그때에는 그냥 전부 실행해버립니다.
그림과 같이 기존 리스트에 들어있던 값에 10을 곱한 요소들의 새로운 리스트를 만드는 코드를 작성한다고 가정하겠습니다. 이런 경우에는 Stream API에 map
을 활용할 수 있습니다.
map()
은 기존의 Collection에 대해서 특정 연산을 수행 후 새로운 Collection을 만들어 내는 역할을 합니다.
코드로 살펴보겠습니다.
for문을 활용한 예시
List<Integer> integerList = new ArrayList<>();
integerList.add(10);
integerList.add(20);
integerList.add(30);
integerList.add(40);
integerList.add(50);
integerList.add(60);
integerList.add(70);
List<Integer> x10IntegerList = new ArrayList<>();
for (int i = 0; i < integerList.size(); i++) {
x10IntegerList.add(integerList.get(i) * 10);
}
map()을 활용한 예시
List<Integer> x10IntegerList =
integerList.stream()
.map(integer -> integer * 10)
.toList();
그림 속 요구사항을 각각 for문
과 map()
을 활용해 구현한 예시입니다.
map()
도 다른 Stream API들과 마찬가지로 람다 표현식을 파라미터로 받습니다. 여기서 toList()
는 반환 된 stream을 그대로 List
컬렉션으로 변환하는 메서드입니다.
이번에는 좀 더 복잡한 요구사항을 예시로 들어보겠습니다.
1 ~ 10의 정수 중 짝수만 뽑아서 10을 곱한 결과들을 담은 새로운 List를 반환하는 코드를 작성한다고 가정해보겠습니다.
if문 & for문을 활용한 예시
int[] integerArray = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
List<Integer> integerList = Arrays.stream(integerArray)
.boxed()
.toList();
List<Integer> evenX10NumberList = new ArrayList<>();
for (int i = 0; i < integerList.size(); i++) {
if (integerList.get(i) % 2 == 0) {
evenX10NumberList.add(integerList.get(i) * 10);
}
}
Stream API를 활용한 예시
int[] integerArray = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
List<Integer> integerList = Arrays.stream(integerArray)
.boxed()
.toList();
List<Integer> evenX10NumberList = integerList
.stream()
.filter(integer -> integer % 2 == 0)
.map(integer -> integer * 10)
.toList();
가독성 측면에서 for과 if문이 섞여있는 첫 번째 코드보다는 Stream API를 사용한 두 번째 코드가 더 직관적입니다.
마지막으로 Map 인터페이스
에 Stream API를 사용하는 방법을 알아보겠습니다.
Map
은 Collection Interface가 아닌 독자적인 Interface를 구현하고 있기 때문에 Stream API를 사용할 수 없습니다.
따라서 Collection Interface인 Set
으로 변환 후 사용해야합니다.
Map<String, Integer> someMap = new HashMap<>();
Set<Map.Entry<String, Integer>> entries = someMap.entrySet();
Set<String> keySet = someMap.keySet();
List<Integer> valueList = someMap.values()
.stream()
.toList()
entries
.stream()
.forEach(stringIntegerEntry -> {
String key = stringIntegerEntry.getKey();
Integer value = stringIntegerEntry.getValue();
// key, value 활용
});
key, value, 혹은 둘 모두를 Stream API로 활용하기 위해 각각의 데이터가 들어있는 Set
Collection Interface를 생성해서 활용할 수 있습니다.
👀 경우에 따라 다르지만, 실제로 느릴 수 있습니다!
https://sigridjin.medium.com/java-stream-api%EB%8A%94-%EC%99%9C-for-loop%EB%B3%B4%EB%8B%A4-%EB%8A%90%EB%A6%B4%EA%B9%8C-50dec4b9974b
하지만 사실 크게 체감되는 속도 차이는 아닙니다. 가독성 높은 코드와 성능이 높은 코드 사이에서 고민할 수 있는데, 우선 가독성을 높이기 위해 Stream API를 활용해서 코드를 작성하고, 개발이 끝난 후 튜닝을 고려할 수 있습니다.
Stream API와 함께 자주 거론되는 친구입니다!
Parallel Stream
은 Stream API와 다르게 병렬적으로 멀티스레드를 통해 실행을 시켜줄 수 있습니다. 멀티스레드로 실행을 시킬 수 있기 때문에 경우에 따라서는 성능을 향상시킬 수 있습니다.
하지만 순서에 대한 보장을 할 수 없고, 항상 좋은 성능을 내는건 아니므로 개발 상황에 맞춰 고려해야합니다.