지나공 : 지식을 나누는 공간
모던 자바 인 액션 스트림에 대해 읽다가 생긴 의문들 (테스트로 해결하기!) 본문
요즘 동기들이랑 매주 모던 자바 인 액션의 챕터를 읽고 궁금한 내용을 대화로 풀어가는 시간을 가지고 있다.
이번엔 스트림에 대해 읽었는데 읽으면서 우리가 되게 헷갈려서 궁금해했던 질문이 있었다. 흥미로운 것 같아 기록하려고 한다.
보고 잘못된 부분 있으면 답글 달아주세요~!!!
의문들
- 모든 요소가 만족되는 filter를 만들어놨을 때. stream에 findAny를 하면 filter건 조건에 만족하는 것들 중에 반드시 맨 첫 번째 요소가 나오고, parallelStream에 findAny를 하면 첫째 요소가 아닐 수도 있겠네!? 확인해보고 싶다!
- stream과 parallelStream에 각각 findFirst를 하면 어떨까? 1의 결과로 보아 parallelStream은 과연 진짜로 first인 값을 보장해서 find해줄지 궁금하고, 그걸 보장해준다면 어떻게 보장해줄 지 궁금하다!
- findAny가 뱉어낸 optional은 내부 값이 없을 수도 있고 객체가 잘 들어있을 수도 있는데, 여기에 추가 연산을 실행하면 값이 없을 땐 예외가 발생할까? 아니면 empty하더라도 스무스하게 넘기게 될까? 예상되는 결과가 당연하다고 생각했지만 그래도 좀 확인해보고 싶었다. ( 아 이거 말로 설명이 안됨. 걍 밑에서 코드로 말하는 게 나을 듯 하다ㅠㅠ)
스트림을 사용하면 가능한 일
- 선언형으로 컬렉션 데이터를 처리할 수 있다. 즉 임시 구현 코드 말고 질의를 통해 어떻게 처리할 지를 선언함으로써 컬렉션 데이터를 처리할 수 있다.
-> 이를 통해 간결하고 가독성 좋은 코드를 만들 수 있다. - 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다. (with parallelStream())
-> 이를 통해 성능을 높일 수 있다.
스트림이란?
데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소.
- 연속된 요소 : 컬렉션처럼, 스트림도 특정한 형태의 요소들로 이뤄진 연속된 값을 제공한다. filter, sorted, map 처럼 표현 계산식들이 요소로서 연속되어 있다. 컬렉션은 데이터가 주제지만 스트림에서는 filter인지 map인지 등등 어떤 계산인지가 주제다.
- 소스 : 스트림에서는 데이터를 제공하는 소스로부터 데이터를 소비한다. 그러므로 그 소스에서 제공한 데이터의 순서가 스트림 작업이 끝난 뒤에도 유지된다.
- 데이터 처리 연산 : filter, map, reduce, find, match, sort 등 데이터를 조작할 수 있다. 스트림 연산은 순차적으로 혹은 병렬로 실행할 수 있다.
스트림의 특징은?
- 파이프라이닝 : 스트림에 선언된 여러 연산들을 연결해서 하나의 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다. 이로 인해 laziness, short-circuiting과 같은 최적화도 얻을 수 있다.
- 내부 반복 : 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.
중간연산과 최종연산 : laziness, loop fusion
스트림에서 filter나 sorted 같은 중간 연산을 다른 스트림을 반환한다. 이렇게 연결할 수 있는 스트림 연산을 중간 연산이라고 하고, 스트림을 닫는 forEach나 collect 같은 연산을 최종연산이라고 한다.
중간연산의 중요한 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 수행하지 않는다는 것이다. 이게 lazy 하다는 말이고, 중간 연산을 합친 다음에 이렇게 합쳐진 전체 중간 연산을 최종연산을 실행할 때 한번에 처리하기 때문에 lazy하다고 한다.
(최종연산은 중간연산이 연속적으로 이뤄져 있는 스트림 파이프라인에서 결과를 도출한다.)
List<String> names = menu.stream()
.filter(dish -> {
System.out.println("filtering:" + dish.getName());
return dish.getCalories() > 300;
}) 필터링한 요리명을 출력한다.
.map(dish -> {
System.out.println("mapping:" + dish.getName());
return dish.getName();
}) 추출한 요리명을 출력한다.
.limit(3)
.collect(toList());
System.out.println(names);
위 코드를 실행해보면 아래와 같은 결과가 나온다.
filtering:pork
mapping:pork
filtering:beef
mapping:beef
filtering:chicken
mapping:chicken
[pork, beef, chicken]
여기서 filter, map, limit은 중간연산이고 얘네들이 하나의 스트림 파이프라인으로 이루어져 있다. 단말 연산인 filter나 map을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 수행하지 않고 얘네를 다 합쳤다가 최종 연산인 collect를 통해 한 번에 처리한다. (lazy)
stream()의 병렬과 parallelStream()의 병렬
분명 filter랑 map은 서로 다른 연산이고, 이걸 일반 for문으로 실행하면 filtering:pork/beef/chicken 끝나고 나서 map:pork/beef/chicken일텐데, 스트림에서는 하나의 파이프라인으로 병합되었기 때문에 위와 같이 실행된다. 즉 여러가지 연산들이 병렬로 처리되는 것이다. 모든 요소에 대한 filter가 다 끝나야만 첫 map 이 실행되지 않고, 병렬로 filter 실행과 map 실행이 이뤄진다.
위에서 말한 병렬은 스트림으로 데이터를 제공한 초기 컬렉션 내의 데이터 순서가 뒤바뀐다는 게 아니다. 데이터가 1-2-3이 있을 때 1-2-3에 대한 a 연산이 다 끝나야만 1-2-3에 대한 b 연산이 실행되는 게 아니고, 1의 a연산, 1의 b 연산, 2의 a 연산.. 이런식으로 a랑 b가 1-2-3에 대해 동시에 실행되고 있다. 단지 실행하기로 한 여러가지 연산들이 병렬로 처리될 뿐, 들어온 데이터 1-2-3 자체는 순차로 처리된다. 중간연산들이 병렬로 처리되는 것도, 데이터를 제공한 소스에서의 순서를 유지하는 순차처리도 모두 일반적인 stream의 특징이다. 위에 적어놨다.
우리는 이 부분에 대해 얘기하다가 방금 막 말한 병렬과, 스트림의 parallelStream()이 말하는 병렬을 헷갈려 했었다.
계속 반복하지만...
- 방금 막 말한 병렬은 1-2-3이 순차로 들어오는데 중간 연산들이 하나의 파이프라인으로서 병렬로 실행되는 것이고,
- parallelStream()의 병렬은 1&2&3 데이터가 멀티스레드로 동시에 파이프라인에 들어가서 실행되는... 그런 병렬이다.
윗 말이 진짜일지 궁금해서 동기가 의문을 던졌다. 그 의문은 아래에서 ㄱㄱ
우리 생각 1 :
모든 요소가 만족되는 filter를 만들어놨을 때. stream에 findAny를 하면 반드시 맨 첫 번째 요소가 나오고, parallelStream에 findAny를 하면 첫째 요소가 아닐 수도 있겠네!?
stream은 순차적으로 데이터가 파이프라인으로 들어오니 findAny를 하면 가장 첫 번째 요소가 filter를 만족해서 출력되고, parallel은 멀티스레드 병렬로 데이터가 파이프라인에 들어오니까 요소가 다 만족되는 filter를 가졌을 때 어떤 값이 먼저 findAny로 건져질지 알 수 없을 거라고 생각했다.
filter의 조건을 전부 만족하게 만들어놨더니 stream은 항상 첫 번째 값이 나왔고, parallelStream은 그렇지 않았다.
우리 생각 2 :
findAny말고 findFirst는 어떨까? parallelStream은 first 값을 찾아줄까? 어떻게 찾아줄까?
위에서 findAny를 보니 parallelStream은 멀티스레드로 데이터들이 처리되니까 어떤 값이 가장 먼저 파이프라인으로 들어올지 사람이 눈으로 예상할 수 없었다. findFirst를 하면 제대로 first인 값을 찾아줄까? 궁금했다. 이번에도 그냥 알아보기 쉽게 filter는 모든 요소가 만족하는 조건으로 세팅했다.
이번에도 우리 예상이 맞았다. 그래서 이걸 어떻게 보장해주는지 보고 싶어서 메소드를 열어봤다. short-circuiting이 나오는데 이거 뭐였는지 까먹었다... 다시 봐야겠다..ㅋㅋㅋ
아무튼 구현체 내용을 다시 열어보면 아래와 같다.
파라미터에 대한 설명을 보면 반드시 첫 번째 요소를 생산해서 줘야 하는지를 따지는 파라미터임을 알 수 있다. findAny는 이런 거 안 따진다. 즉 parallelStream이더라도 첫 번째 요소만을 반환하도록 findFirst는 별도의 조치가 있음을 알 수 있다.
다시 ReferencePipeline.java로 돌아와서 findFirst와 findAny의 차이를 보자.
우리가 처음에 의문을 가진 이유는 parallelStream에 대한 이해가 부족한 채로 책의 위 문장을 봤기 때문이다. 일단 일반 stream에서 자꾸 중간연산들이 병렬로 처리된다고 설명하는 것 때문에 parallelStream을 이해하는게 더 헷갈렸다.ㅋㅋ;;
병렬 실행에서는, 즉 parallerStream에서는 첫 번째 요소를 찾기 힘드니까 반환 순서 상관이 없다면 웬만하면 findAny 써라 뭐 이런 말이다. 멀티스레드 병렬처리 환경에서는 구현 코드 속 파라미터인 mustFindFirst가 true일 경우 첫째 값 찾는 로직이 추가로 필요하니까 더 어렵다는 말 같다.
이후에 잘 살다가 신입 과제 프로젝트 진행 중에 또다른 의문이 생겼었다. 이건 스터디는 아니고 개인 의문이니까 내 생각이라고 소제목 해야쥐...ㅎㅎㅎ
내 생각 3 :
findAny가 뱉어낸 optional은 내부 값이 없을 수도 있고 객체가 잘 들어있을 수도 있는데, 여기에 추가 연산을 실행하면 값이 없을 땐 예외가 발생할까? 아니면 empty하더라도 스무스하게 넘기게 될까?
findAny를 해서 Optional<LandRegion>을 얻었고, LandRegion이 제대로 들어왔을 수도 있지만 그냥 비어있을 수도 있는 이 상황에서 map(LandRegion::getCd)를 해도 되는지 걱정됐었다. LandRegion이 잘 들어왔을 때야 상관 없지만 아닐 때는 getCd를 할 때 에러가 발생할테니까?!.. 기존에는 이런 적이 없고 그냥 항상 findAny().orElse()나 orElseThrow()로 마무리를 지었던 것 같은데 갑자기 Optional의 어떤 특정 경우에만 실행 가능한 메소드를 호출하려니 문득 의문이 생겼다.
신입 과제 도중에 급하게 테스트한 거라 좀 이해가 안될 수 있는데... 존재하는 값의 getCdFrom을 하면 저렇게 알 수 없는 숫자와 알파벳 조합의 코드가 나오는 게 맞다. 반면 존재하지 않는 값은 orElse로 설정한 값이 잘 들어왔다.
즉, 내가 느낀 걱정은 할 필요 없었다. Optional 내부 값이 null 이더라도 예외 발생하지 않고 잘 동작한다.
그럼 이제 왜 그런지 알아보자..! Optional<>에 대해 map을 실행하고 있으므로 Optional의 map을 살펴봤다.
Optional 값이 null인지 아닌지 isPresent부터 실행하고 있다. 그래서 isPresent가 false면 empty를 반환한다. 그래서 내가 한 맨 마지막 연산인 orElse는 emtpy optional에 대해 실행이 되고, 그에 대해 orElse를 호출하니까 안전하게 다 잘 처리된다.
함께 고민해준 동기들에게 감사를 표합니다!! 💕💕💕💕