Java - Stream API는 함수형 프로그래밍을 할 수 있게 해준다

자바 8 버전에서 새롭게 추가된 스트림(Stream) API는 자바가 함수형 프로그래밍을 지원한다는 사실을 잘 보여주는 패키지입니다. 여기서 다루지는 않지만 자바 8 버전부터 도입된 람다식(lambda expressions)을 통해 자바는 함수형 프로그래밍을 일부 지원하게 되었고 그 활용의 대표적인 예가 바로 스트림 API입니다.

 

 공식 홈페이지의 말을 인용해 스트림(Stream) API에 대해 설명하면 Collection의 요소를 Stream을 통해 함수형 연산을 지원하는 패키지입니다. 아 그리고 여기서 한 가지 알아 둘 점은 지금 이야기하고 있는 스트림은 데이터 입출력(I/O)을 다룰 때 사용하는 그 스트림이 아니에요. 자바에서 사용하는 List, Map, Set과 같은 자료 구조의 집합인 컬렉션(Collection)을 스트림으로 다루겠다는 의미입니다. 그러면 이제부터 스트림 API가 어떻게 함수형 프로그래밍을 지원하는지 알아보러 가시죠.

스트림? Stream?

 자바의 컬렉션을 다루는 스트림 API에 대해 알아보기 이전에 먼저 스트림(Stream)에 대한 개념을 구체화시켜보도록 하겠습니다. 먼저 스트림을 생각하면 어떤 연상되는 이미지가 있으신가요? 구글을 통해 stream을 검색하면 아래와 같은 이미지들을 찾을 수 있습니다.

Stream에 대한 구글 이미지 검색

 이번에는 스트림을 한국말로 해석해볼까요? 명사로는 흐름, 시내, 연속, 하류 동사로는 흐르다, 끊임없이 계속된다 와 같이 정의할 수 있습니다. 그렇다면 프로그래밍에서 정의하는 스트림은 어떨까요? 앞서 이야기한 스트림과 다른 스트림일까요? 프로그래밍에서 언급되는 스트림도 끊임없이 흐르는 흐름 그 자체를 통칭합니다. 그리고 끊임없이 흘러가는 흐름 속에 있는 것은 무언가는 값(데이터)입니다. 값은 스트림 개념이 사용되는 상황에 따라 변화합니다. 예를 들어 컬렉션에 스트림을 대입하면 컬렉션이 가진 요소가 스트림에서 흐르는 값이 됩니다.

 

 그럼 프로그래밍에서 스트림이라는 개념을 도입한 이유는 무엇일까요? 사실 스트림이 가진 가치는 스트림 자체보다 스트림을 이용해 값(데이터)을 사용할 수 있다는 점에 있습니다. 무질서하게 돌아다니는 값들은 스트림을 통해 흐름을 가지게 되는데 이는 값을 쉽게 제어할 수 있게 되기 때문입니다. 예시를 들어볼까요. 우리에게 빈 장독대와 양동이가 주어지고 이 빈 장독대에 물을 가득 담아야 하는 상황에 놓여 있다면 어떻게 문제를 해결하는 게 좋을까요?

장독대와 양동이

  물이 있는 어딘가를 찾아가서 양동이를 사용해 물을 가져오는 것도 좋은 방법이지만, 옆에 흐르는 강물이 있다면 물속에 양동이를 넣는 행위만으로 손쉽게 물을 담아 올 수 있습니다. 즉 흐르는 강물(스트림)이 있다면 물이 어디 있는지 찾고 물을 얻는 방법을 고민하지 않아도 됩니다.

Stream은 for문을 대체하지 않는다

 자바 8 버전에 스트림이 추가되었을 때 "스트림은 단순히 for문을 대신한다"는 글을 이전에 본 적이 있습니다. 이야기는 반은 맞고 반은 틀린 부분인데요. 스트림은 "흐름"을 코드화 한 부분이기 때문에 for문을 대신하는 게 아니라 for문의 반복 행위만 대신해줍니다. 일반적으로 사용되는 for문을 보면 단순히 반복하지 않습니다. 정의한 규칙만큼 반복하고 정해진 일을 합니다. 그리고 스트림은 여기서 이야기하는 "반복"이라는 단어의 행위만 대신할 뿐입니다. 여기에는 사소하지만 중요한 차이가 있습니다.

 

 그럼 사용자 데이터를 기준으로 나이가 20 이상인 사람들을 찾는 문제를 해결하는 과정을 통해 for문과 스트림의 차이점을 알아보겠습니다.

 

for문

List<User> usersFindByFor = new ArrayList<>();

for (int index = 0; index < users.size(); index++) {
  User user = users.get(index);

  if (user.getAge() >= 20) {
    usersFindByFor.add(user);
  }
}

 위의 예제 코드는 일반적으로 for문을 사용하는 방법입니다. user의 개수만큼(정의한 규칙) for문을 이용해 반복 실행시키고 if문을 이용해 나이가 20 이상 조건에 부합하는 사용자 데이터를 결과 목록에 값을 추가합니다.

 

Stream API

List<User> usersFindByStream = users.stream()
    .filter(user -> user.getAge() >= 20)
    .collect(Collectors.toList());

 이 예제 코드는 Stream API를 이용해 흘러가는 사용자 데이터들을 filter()를 이용해 거르고, collect()를 이용해 결과를 만듭니다. stream()은 기본적으로 컬렉션이 가진 모든 요소들이 흘러가기 때문에 for문처럼 user의 개수만큼이라는 규칙을 정의할 이유가 없고 반복하는 행위도 하지 않습니다. 그저 문제에서 주어진 나이가 20 이상인 조건만 판별하는 식을 만들어주면 됩니다.

 

결론 

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StreamExample {

  public static void main(String[] args) {
    List<User> users = Arrays.asList(
        new User("a", 19),
        new User("b", 20),
        new User("c", 21),
        new User("d", 22),
        new User("e", 19),
        new User("f", 25),
        new User("g", 31),
        new User("h", 24)
    );

    /**
     * 나이가 20 이상인 사람들을 찾으시오
     */

    // for문
    List<User> usersFindByFor = new ArrayList<>();

    for (int index = 0; index < users.size(); index++) {
      User user = users.get(index);

      if (user.getAge() >= 20) {
        usersFindByFor.add(user);
      }
    }

    // Stream API
    List<User> usersFindByStream = users.stream()
        .filter(user -> user.getAge() >= 20)
        .collect(Collectors.toList());
  }
}

class User {

  private String name;
  private int age;

  public User(String name, int age) {
    this.name = name;
    this.age = age;
  }

  public String getName() {
    return name;
  }

  public int getAge() {
    return age;
  }
}

 예제를 통해서 이야기드리고 싶었던 부분은 한 가지입니다. Stream API를 사용하면 비즈니스에 집중한 코드를 만들 수 있다는 것입니다. Stream(흐름)을 이용하면 코드는 자연스럽게 어떻게(How) 사용할지에 대한 고민만 할 수 있게 됩니다. 데이터를 어떻게 사용할지만 고민하세요. 반복에 대해서는 더 이상 고민하지 않아도 돼요. Stream API를 사용하면 코드의 라인수가 줄고 가독성이 좋아진다? 맞는 말이지만 가장 중요한 것은 라인 단위의 코드에서도 관심사를 분리할 수 있게 되었다는 점이 가장 중요한 부분이에요.

Stream API가 지원하는 함수형 프로그래밍

 아직은 자바가 자바스크립트처럼 함수형 프로그래밍을 사용할 수는 없지만 부분적으로는 함수형 프로그래밍을 할 수 있습니다. Stream API도 그 부분에 포함됩니다. 컬렉션을 스트림으로 다루는 방법에는 filter(), map(), reduce()와 같은 대표적인 함수들이 있습니다.

 

map(): map()은 스트림이 가진 요소에 정의한 함수를 적용해 새로운 스트림을 반환합니다. 주로 새로운 데이터를 만들어야 할 때 사용합니다.

List<Integer> number = Arrays.asList(1, 2, 3, 4, 5);

List<Integer> square = number.stream().map(x -> x * x)
    .collect(Collectors.toList());

filter(): filter()는 스트림이 가진 요소를 정의한 함수를 이용해 선택하고 새로운 스트림을 반환합니다. 주로 특정 값을 찾아야 하는 경우에 사용합니다.

List<String> values = Arrays.asList("aa", "ab", "bb", "ba");
List result = values.stream().filter(value -> value.contains("a"))
    .collect(Collectors.toList());

reduce(): reduce()는 스트림이 가진 요소를 줄이는데(reduce) 사용됩니다. 주로 sum과 같이 결과 값을 산출할 때 사용됩니다.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int result = numbers.stream().reduce(0, (sum, number) -> sum + number);

 

 예제를 통해 알 수 있는 Stream API의 사용 방식은 다음과 같습니다.

 

-. Stream API는 0개 이상의 중간(Intermediate) 연산자와 1개의 끝(Terminal) 연산자로 구성된다.

 -. Stream API는 원본 객체를 값을 사용만 할 뿐 변경하지 않는다.

    중간 연산자를 거치면서 새로운 스트림을 반환하고, 끝 연산자를 거치면서는 새로운 객체를 반환한다.

끝맺음

 흐름(스트림)을 잘 이용하게 되면 어떻게 될까요? 자신도 모르는 사이에 "어떤 값을 무슨 방법으로 가져와서 어떻게 사용하지?"라는 주제보다는 "값을 어떻게 사용하지?"라는 주제로 코드 설계할 수 있게 되지 않을까요? Stream API는 보다 멋진 코드를 만들 수 있게 해주는 좋은 기술입니다.

반응형

댓글

Designed by JB FACTORY