[Java] Stream API

양성욱·2023년 10월 4일
1
post-thumbnail

이 글은 프로그래머스 - 실무 자바 개발을 위한 OOP와 핵심 디자인 패턴 강의를 정리한 내용입니다.

Stream API는 그 자체로는 객체지향적인 코드와 크게 연관성이 없습니다. 그러나 Stream API를 활용한 코드는 객체지향적인 코드와 함께 사용되었을 때 가독성을 크게 향상시켜줍니다.

Stream API는 기능적으로 for문if문을 대체할 수 있습니다. 우선 Stream API를 적용할 수 있는 대상이 어떤 것이 있는지 먼저 알아보겠습니다.

Stream API를 적용할 수 있는 대상

Stream API는 Collection 인터페이스 내부에 존재하는 메서드들입니다. 컬렉션 인터페이스를 구현하는 다른 구현체들에서 사용할 수 있습니다.

그림에서 볼 수 있듯, MapCollection 인터페이스를 상속 받지 않으므로 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
  • filter
  • map

forEach - 반복시키기

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와 for문의 차이점

인덱스 존재 여부

우선 forEach로 반복하면 i같은 인덱스가 없습니다. 따라서 현재 반복하고 있는 요소의 이전, 혹은 다음 요소에 직접 접근할 수 없습니다.

물론 별도의 변수에 저장해 비슷한 효과를 낼 수는 있지만, 그렇게 코드를 작성하면 오히려 for문을 사용하는 것 보다 가독성이 떨어지는 상황이 올 수 있습니다.

break문

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()입니다.

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는 각각 다음과 같은 의미를 가집니다.

  • 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가 한번 더 온다고 하면 그때에는 그냥 전부 실행해버립니다.

map - 요소를 매핑


그림과 같이 기존 리스트에 들어있던 값에 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 컬렉션으로 변환하는 메서드입니다.

filter와 map의 결합

이번에는 좀 더 복잡한 요구사항을 예시로 들어보겠습니다.


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 인터페이스에 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를 생성해서 활용할 수 있습니다.

이모저모

Stream API는 for문 보다 느린가?

👀 경우에 따라 다르지만, 실제로 느릴 수 있습니다!
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를 활용해서 코드를 작성하고, 개발이 끝난 후 튜닝을 고려할 수 있습니다.

Parallel Stream

Stream API와 함께 자주 거론되는 친구입니다!

Parallel Stream은 Stream API와 다르게 병렬적으로 멀티스레드를 통해 실행을 시켜줄 수 있습니다. 멀티스레드로 실행을 시킬 수 있기 때문에 경우에 따라서는 성능을 향상시킬 수 있습니다.

하지만 순서에 대한 보장을 할 수 없고, 항상 좋은 성능을 내는건 아니므로 개발 상황에 맞춰 고려해야합니다.

profile
개발의 신이시여... 제게 집중할 수 있는 ㅎ... 네? 맥주요?

0개의 댓글