-
대부분의 프로그램은 데이터를 처리하는 작업이 필요하다.
그리고 이는 주로 컬렉션 형태로 처리된다. 명령형 접근법은 각 요소를 순서대로 반복하여 처리하는 루프(loop)에 의존한다.
반면 함수형 언어는 선언적 접근법을 선호하며 때때로 전통적인 반복문 자체를 사용하지 않는다.
거의 모든 데이터 처리는 파이프라인 형태로 동작한다.
컬렉션과 같은 자료 구조는 요소를 제공하며, 필터링이나 변환과 같은 다양한 작업을 거쳐 결과를 제공하는 것에서 그러하다.
그 결과는 하나의 자료 구조가 될 수도, 다른 작업에 사용될 수 있지만 어찌 되었든 파이프처럼 순서대로 진행된다는 것에 변함은 없다.
가령 Book 인스턴스 목록에서 1970년 이전에 출간된 책의 제목 순으로 세개의 요소에 대한 정렬을 원한다고 하자.
그렇다면 기존의 for-loop를 사용한다면 아래와 유사하게 작성될 것이다.
//생성 생략 List<Book> books = ...; cCollections.sort(books, Comparator.comparing(Book::titile)); List<String> result = new ArrayList<>(); for (var book : books){ if(book.year() >= 1970){ continue; } var title = book.title(); result.add(title); if(result.size()==3){ break; } }
year()가 get이 아닌 이유는 record로의 생성을 가정했기 때문이다.
아무튼 반복문인 for loop나 while loop는 각 반복에 대한 새로운 범위를 생성하기 위해 반복문 내에서 데이터 처리 로직을 포함한다. (add 등)
이는 요구 사항에 따라 충분히 길어질 수 있으며 여러 문장까지 포함될 수 있을 여지가 충분하다.
전반적으로 이런 보일러플레이트(반복) 코드로 인해 데이터 처리 코드가 눈에 잘 띄지 않고, 복잡할 수록 코드가 난잡해지는 문제가 발생한다.
이러한 문제의 근본적인 원인은 '무엇을 하느냐(데이터 처리)'와 '어떻게 수행하느냐(요소를 반복)'을 혼용하기 때문에 발생하는 일이다.
전통적인 loop방식에서는 종료 조건에 도달할때까지 요소들을 순회하며 반복해야 하는 것이다.
이러한 반복을 외부 반복이라 하고, 그것에 반대되는 내부 반복또한 존재한다.
내부 반복을 통해 개발자가 순회 과정을 직접 제어하는 것을 포기하고, 데이터 소스 자체가 '어떻게 수행되는지'만을 담당하도록 하는 것이다.
순회를 직접 제어하기 위한 반복자를 사용하는 대신, 데이터 처리 로직은 스스로 반복을 수행하는 파이프라인을 사전에 구성하는 것이 특징이다.
이로 인해 반복 과정이 불투명해지며 로직이 어떤 요소들이 파이프라인을 순회하는지에 영향을 준다.
이러한 방식을 통해 '무엇을 하고 싶은지'에 집중할 수 있으며, '어떻게 수행되는지'에 대한 부분은 관심을 가질 필요가 없어진다.
스트림은 이러한 내부 반복을 가진 데이터 파이프라인이다.
스트림은 다른데이터 처리 방식처럼 작업을 수행하지만 내부 반복자라는 장점이 있다.
이 장점은 함수형 관점에서 유리하다. 주로 선언적 접근법(단일 호출 체인을 총해 간결하고 명료한 다단계 데이터 파이프라인 구축), 조합성(스트림의 연산들은 데이터 처리 로직을 위한 고차 함수로 되어있으며, 필요에 따라 조합가능), 지연처리, 성능 최적화, 병렬 데이터 처리(스트림은 내장된 병력 처리 기능을 가지고 있어, 호출 체인에서 단일 호출을 간단히 변경해 활용 가능) 등이 있다.
개념적으로만 보면 스트림은 데이터 처리를 위한 전통적인 루프 구조에 대한 또 다른 대안으로 볼 수 있다.
하지만 실제로 스트림은 데이터 처리 기능을 제공하는 방식임에 유의.
그에 스트림의 전반적인 작업 흐름을 고려할 때, 스트림은 '그늣한 순차 데이터 파이프라인'으로 간단히 설명할 수 있다.
그럼 스트림을 사용한다면 위의 loop 코드는 아래와 같이 변환이 가능해진다.
List<Book> books = ...; List<String> result = books.stream() .filter(book -> book.year() < 1970) .map(Book::title) //책의 이름 정보만 추출. getTitle와 동일 .sorted() .limit(3L) .collect(Collectors.toList());
비교를 위해 위의 코드를 다시 가져왔다.
List<Book> books = ...; Collections.sort(books, Comparator.comparing(Book::titile)); List<String> result = new ArrayList<>(); for (var book : books){ if(book.year() >= 1970){ continue; } var title = book.title(); result.add(title); if(result.size()==3){ break; } }
위의 스트림 연산을 사용한다면 continue나 break에 대한 고민을 할 필요가 없어진다.
우선 스트림의 동작방식은 아래와 같다.
순서 연산 반환타입
1. 소스 | Book,Book,Book...... | List<Book>
2. 생성 | .stream | Stream<Book>
// 요소들은 데이터 처리에 필요한 최소한의 양으로 스트림을 통해 하나씩 통과한다.
// 즉 filter가 처리하는 것은 각 개별 요소들이지 Stream<Book> 전체가 아니다.
3. 중간 | .filter(book -> book.year() > 1970 | Stream<Book>
4. 중간 | .map(Book::title) | Stream<String>
5. 중간 | .limit(3L) | Stream<String>
6. 중간 | .sorted() | Stream<String>
7. 종료 | .collect(Collectors.toList()) | Stream<String>
스트림은 특정한 동작과 예상치가 내장된 함수형 API이다.
전통적인 루트의 빈 캔버스와 비교하였을때 스트림의 가능성을 제한하는 것 처럼 보일 수 있다. 그러나 비어있지 않은 캔버스로써, 스트림은 다른 방식으로 직접 생성해야 할 수많은 사전 정의된 컴포넌트와 보장된 속성들을 제공한다.
'Devloper > 자바JAVA' 카테고리의 다른 글
자바 - 람다(Lambda) 문법 & 메서드 참조(::) (0) 2025.01.31 자바 - 레코드(Record) (0) 2025.01.23 자바 - 함수형 인터페이스 (0) 2025.01.21 댓글