포스팅에서는 명령형/선언형 프로그래밍의 특징과 Stream API가 가지고 있는 특징을 가볍게 설명합니다.
정리에 앞서 모던 자바 인 액션을 읽으며, 짧은 시간이었지만 Javascript를 공부하면서 느꼈던 경험을 토대로 개인적인 생각을 적어보겠습니다.
Java만을 사용하던 나에게 함수를 변수로 할당하고 혹은 또 다른 함수의 인자로 넘길 수 있는 Js는 혼란스러웠습니다. 그렇다. Java에서 메서드는 1급 객체가 아니기 때문에 이런 혼란은 자연스러울지도 모릅니다. Java 진영에서 이런 한계를 극복하기 위해 등장한 것이 함수형 인터페이스이죠. 이것은 아마도? 그리고 책을 살펴보면 함수형 프로그래밍 언어에서 영감을 받아 Java8에서 만들어진 것으로 보입니다.
모던 자바 인 액션의 내용을 인용하면
Stream API는 위에서 설명한 메서드를 1급 객체로 사용하는 함수형 프로그래밍의 아이디어를 활용한다. 사실 가변 공유 상태가 없는 병렬 실행을 이용해서 효율적이고 안전하게 메서드를 호출할 수 있다는 아이디어도 활용했다고 합니다. 이 부분은 다소 내게는 어려운 내용이라 그렇구나하고 넘어가도록 하겠습니다.
Stream API는 이와 같은 아이디어를 활용했다. 그리고 자바 진영에서는 익명 함수는 람다식으로 변환이 가능하고 경우에 따라 메소드 참조도 가능하다. 이에 따라 Stream API에서는 명령형 프로그래밍이 아니라 선언형 프로그래밍이 가능해졌다.
명령형 VS 선언형
두 개념의 차이는 생각보다 간단하다. 커피 머신에 아메리카노 한 잔을 내리기 위한 요청을 보낸다고 해보자.
"에스프레소 1컵, 얼음 많이, 물 적게 해서 머그잔에 타줘" 이런 식으로 아메리카노 한 잔을 만들기 위한 내용을 구체적으로 명령하면 그것이 명령형 프로그래밍이다.
"아메리카노 한 잔 타줘"
이것은 선언형 프로그래밍이다.
그렇다. 람다식을 이용하거나 메서드 참조를 이용하는것은 선언형 프로그래밍이다.
두 프로그래밍 방식을 코드로 표현하면 아래와 같다.
선언형
List<Order> orders = orderers.stream()
.flatMap(orderer -> menus.stream()
.filter(Dish::isVegetarian)
.filter(dish -> dish.getKcal() < 300)
.map(dish -> new Order(orderer, dish.getName()))
).toList();
명령형
List<Order> orders1 = new ArrayList<>();
for (String orderer : orderers) {
for (Dish dish : menus) {
if (dish.isVegetarian()) {
if (dish.getKcal() < 300) {
orders1.add(new Order(orderer, dish.getName()));
}
}
}
}
스트림 API를 모른다면 아래 방식이 가독성이 뛰어나다고 생각할 수 있지만 스트림 API를 알고 난 뒤라면 위 방식이 훨씬 가독성이 좋다는 것을 알게 될 것이다. 그리고 무엇보다 더 복잡해져서 depth가 더 깊어진다면 답이 없어질 수도 있다. 물론 아래 방식을 고수하더라도 최적화를 포기하거나 리팩토링을 한다면 가독성이 좋아질 수도 있지만 그럴바에 스트림을 사용하는게 좋지 않을까 싶다.
이제 눈으로만은 볼 수 없는 스트림의 특징들에 대해 알아보자.
1. 스트림은 딱 한 번만 탐색할 수 있다.
2. 스트림 최적화 Loop fusion
3. 스트림 최적화 Short curcuit
4. 내부 반복
(1) 스트림은 딱 한 번만 탐색할 수 있다.
스트림은 단 한 번만 소비할 수 있다. 그렇기 때문에 한 번 탐색한 요소를 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.
@Test
void stream_is_only_one_search() {
List<String> fruits = List.of("apple", "banana", "strawberry");
Stream<String> fruitStream = fruits.stream();
assertThatCode(() -> {
System.out.println("스트림 첫 번째 사용");
fruitStream.forEach(System.out::println);
}).doesNotThrowAnyException();
assertThatCode(() -> {
System.out.println("스트림 두 번째 사용");
fruitStream.forEach(System.out::println);
}).isInstanceOf(IllegalStateException.class);
}
(2) 스트림 최적화 전략 1. Loop fusion
더 쉽게 설명하면 2가지 이상의 반복문을 조합해서 1개의 반복문으로 병합하는 것을 의미한다. 코드를 살펴봅시다.
@Test
void stream_is_loop_fusion() {
List<String> fruits = List.of("apple", "banana", "strawberry");
Stream<String> fruitStream = fruits.stream();
fruitStream
.filter(f -> {
System.out.println("filtering " + f);
return true;
})
.map(f -> {
System.out.println("mapping " + f);
return f;
})
.toList();
}
이런 코드가 존재한다고 했을 때 우리는 아래와 같은 출력 결과를 기대할 수도 있습니다.
filtering apple
filtering banana
filtering strawberry
mapping apple
mapping banana
mapping strawberry
아마도 이게 가장 일반적인 생각이 아닐까요?
이제 실제 결과를 봅시다. 예상했던 것과 다르게 filtering/mapping 출력이 번갈아 나오는 것을 볼 수 있습니다.
이러한 기법을 루프 퓨전이라고 합니다. 만약 루프 푸젼이 적용되지 않는다면 중간 결과를 어딘가에 저장해야 합니다. 하지만 루프 퓨전 기법을 사용하면 이러한 과정이 불필요하기 때문에 오버헤드를 줄일 수 있습니다.
(3) 스트림 최적화 전략 2. Short curcuit
별거 없지만 강력하다. upStream의 조건을 만족하지 못하면 downStream 조건은 실행 조차 하지 않는 것이 Short curcuit을 말한다. 물론 명령형으로 짠다고 해서 이걸 구현하기 어려운 것은 아니다. 정말 복잡한 로직이 아니라면 말이다.
menus.stream()
.filter(Dish::isVegetarian)
.filter(dish -> dish.getKcal() < 300)
.toList();
별거 없으니 몇 가지 정리하도록 하겠다. 스트림 API는 중간 연산과 최종 연산이 존재한다.
toList()는 stream을 List로 만든다고 생각하면 된다. 최종 연산 중 하나이다. stream API의 최종 연산은 대부분 Collections 인터페이스로 변환한다.
중간 연산은 많은 것들이 존재한다. 알고 넘어가면 될 것은 stream을 반환한다는 것이다. 그렇기 때문에 저렇게 닷(.)을 연속으로 찍을 수 있는 것이다. 이걸 메서드 체이닝이라고 한다. 이것 덕분에 depth가 깊어지지 않은 채로 코드가 유지되고 가독성이 엄청 높일 수 있다.
마지막으로 첫번째 filter 에 있는 Dish::isVegetarian이 메서드 참조이다. 이 용어를 처음 듣는 사람에게는 낯설 수 있다. 메서드 참조에서는 이중 콜론(::)이 연산자이다. 인스턴스의 연산자를 닷(.)으로 사용하는 것과 동일하게 이해하면 된다.
filter(Dish::isVegetarian) == filter(dish -> dish.isVegetarian())
(4) 내부 반복
아래와 같은 리스트가 존재한다고 했을 때 컬렉션을 사용했을 때와, 스트림을 사용했을 때를 비교해보자.
List<String> fruits = List.of("apple", "banana", "strawberry");
for (String fruit : fruits) {
System.out.println(fruit);
}
컬렉션을 사용할 경우, 명시적으로 반복자를 선언해야한다. 위의 경우에는 향상된 for문을 통해 반복자를 설정했다. 이것을 외부 반복이라고 한다.
fruits.stream().forEach(f -> System.out.println(f));
반면 스트림을 사용할 경우 스트림 내부에서 반복을 호출한다. 이것을 내부 반복이라고 한다. 내부 반복의 장점은 아래와 같다. 첫째로는 외부 반복에서는 병렬성을 스스로 관리해야 한다. 하지만 내부 반복에서는 그러지 않아도 된다. 둘째는 내부 반복을 이용하면 작업을 투명하게 병렬로 처리하거나 더 최적화된 다양한 순서로 처리할 수 있다고 한다.
이 부분은 책의 내용을 그대로 발췌했다. 어떤 의미인지는 마음에 와닿지 않아서 이후 책을 읽어가며 깨달은 점이 생기면 이 부분을 수정하도록 하겠습니다.
'Java' 카테고리의 다른 글
모던 자바 인 액션 - Executor와 쓰레드 풀 (0) | 2023.07.12 |
---|---|
Java - Executor (0) | 2023.07.12 |
record class는 정말로 불변할까 (0) | 2023.05.25 |
제네릭이란 무엇일까? 타입의 안정성과 다형성 (0) | 2023.03.29 |
객체 지향 프로그래밍이란 무엇인가? 추상화/다형성/캡슐화, SOILD (0) | 2023.03.27 |