언어/JAVA

자바 8 Stream API 과 주의사항

벨포트조던 2022. 6. 29.
반응형

 

이 글은 자바 8 Stream API 를 아는 사람이 주의해야 할 것에 대해 쓰여진   글이지만 , 몰라도 상관없습니다.

이 글 읽어보면 대충 이런거구나 알 수 있으니깐요. 

Java 8 Stream API 을 배워야하는 이유로  "가독성/간편성" 과 "성능/공짜점심" 으로 보통 꼽습니다.

* 가독성


코어 자바

1
2
3
4
5
6
7
8
9
10
11
private static int sumIterator(List<Integer> list) {
    Iterator<Integer> it = list.iterator();
    int sum = 0;
    while (it.hasNext()) {
        int num = it.next();
        if (num > 10) {
            sum += num;
        }
    }
    return sum;
}

 

Stream API

1
2
3
private static int sumStream(List<Integer> list) {
    return list.stream().filter(i -> i > 10).mapToInt(i -> i).sum();
}

 

여러줄의 코드가 한줄로 되어버렸습니다. 가독성이 좋아졌고, 실수할 여지를 줄여 놓았습니다. 

라고 광고합니다. 만은  저렇게 되기 위해서는 팀원들이  Stream API 에 익숙하다라는 조건이 충족되야겠지요. 또한 어떻게 보면 순수한 FOR 문을 돌리는게 더  실수할 여지를 없애는것이기도 합니다.  단순할 수록 실수도 단순하니깐요. 저렇게 추상층이 높은 코드는 내부적으로 어떻게 실수가 유발될지 상상하기 어렵습니다. 

 


* 성능

간단한 업무를 (하지만 양은 많은)  손쉽게 병렬로 돌려주는 고마운 존재입니다. 
개인적으로    기능 > UI/UX >> 보안 > 성능  으로 우선순위를 생각합니다. 성능이란 항상 최후에 적절한 핫 스팟을 찾아서 처리해주는것이죠. 그 때 고려하면 되는것이지, 애초에 성능때문에 이걸 배워서 써먹어야겠다라고는 말해드리지 못할거 같네요. 


 

Java 8 Streams API 를 사용할때 발생할수있는 미묘한 실수 10가지

 

1. 재사용 스트림 문제 

모든 사람들이 적어도 한번은 겪어 봤을 법 한 문제로 , 스트림은 오직 한번만 소비 할 수 있어서 다음 코드는 작동하지 않아요. 

1
2
3
4
5
IntStream stream = IntStream.of(12);
stream.forEach(x -> System.out::println(x));
 
// 다시 사용~!!
stream.forEach(x -> System.out::println(x));

이런 에러를 만날것이고

java.lang.IllegalStateException: 
  stream has already been operated upon or closed

스트림을 소비할때는 조심해야합니다. 오직 한번만 완료될 수 있어요.

2.  "무한" 스트림 생성 문제 

무한 스트림을 특별한 주의를 기울이지 않아도 쉽게 생성 할 수 있습니다.  다음 예를 보시죠.

1
2
3
// 무한이 돌아감
IntStream.iterate(0, i -> i + 1)
         .forEach(System.out::println);

원래 그 역할을 하게 짠 거라면 어쩔수 없지만,  의도되지 않은것이라면 적절한 한계를 두는게 좋겠지요.

1
2
3
4
// 이게 더 좋을거 같습니다. (0~9)
IntStream.iterate(0, i -> i + 1)
         .limit(10)
         .forEach(System.out::println);

 

3. 의도치 않게  생성된 무한 스트림 

이것도 무한 스트림이 될 수 있습니다. limit(10) 을 사용했는데도 불구하고 말이지요. 

1
2
3
4
IntStream.iterate(0, i -> ( i + 1 ) % 2)
         .distinct()
         .limit(10)
         .forEach(System.out::println);
 
System.out.println("complete");

각 라인을  설명해보면 

  • 0 과 1 을 반복적으로 생성 할 것 입니다. 
  • 그리고 개별 값 하나를  유지할 것 입니다. 즉 단일 0 과 1 이겠지요.
  • 그리고 10개의 크기로 스트림에 제한을 걸 것입니다.
  • 그리고 그것을 (10개까지)  출력합니다.

여기서 문제는 distinct() 연산자는 iterate() 함수가 오직 0과 1이라는 값만 생성 할 것을 알지 못해요. 결국 스트림으로부터 무한히 값을 받아드려서 사용 할 것입니다.  limit(10)  에는 결코 다다르지 못하죠. 즉 "complete" 가 절대로 찍히지 않습니다.

4. 의도치 않게 생성된 병렬 무한 스트림 

만약 distinct() 연산자가 병렬로 수행될 수 있다고 믿는다면 다음과 같이 작성할 수 있을 것 입니다. 

1
2
3
4
5
IntStream.iterate(0, i -> ( i + 1 ) % 2)
         .parallel()
         .distinct()
         .limit(10)
         .forEach(System.out::println);

이게 영원히 돌것이라는것은 이미 위에서 확인했는데요. 그래도 이전에는 cpu 를 하나만 혹사시킨 반면 , 여기서는 더 많이  사용할 것입니다. 잠재적으로 당신 시스템의 모든 리소스를 점거 할 것이란 얘기죠. 

If I were a laptop, this is how I’d like to go.

5. 연산자 순서 섞기  

limit() 와  distinct() 의 순서를 바꾸면 잘 작동 할 것입니다. 

1
2
3
4
IntStream.iterate(0, i -> ( i + 1 ) % 2)
         .limit(10)
         .distinct()
         .forEach(System.out::println); System.out.println("complete");

이렇게 출력 될 것입니다.

0 1 "complete"

먼저 10개의 값을 먼저 받은후에 (0 1 0 1 0 1 0 1 0 1),  distinct 를 통해 줄여서  (0 1) 만 남게 될 것입니다. 재밌는건 , 당신이 만약 SQL 개발자 출신이라면, 저런 차이를 기대하기 어려웠을겁니다.  SQL Server 2012에서는  다음 2개의 SQL문은 같기 때문입니다. ( 역주 : SQL Server 2012 가 없어서 테스트는 못해봄) 

1
2
3
4
5
6
7
8
9
10
11
-- Using TOP
SELECT DISTINCT TOP 10 *
FROM i
ORDER BY ..
 
-- Using FETCH (
SELECT *
FROM i
ORDER BY ..
OFFSET 0 ROWS
FETCH NEXT 10 ROWS ONLY
 
 

6. 연산자 순서 섞기 2

SQL 이야기가 나와서 하는 말인데, 만약 당신이  MySQL 나  PostgreSQL 개발자라면 , LIMIT .. OFFSET 절을 사용했을겁니다 . 

만약 MySQL / PostgreSQL’s 방언을  streams 으로 바꾼다면 이렇게 했을텐데요. 

1
2
3
4
IntStream.iterate(0, i -> i + 1)
         .limit(10// LIMIT
         .skip(5)   // OFFSET
         .forEach(System.out::println);

아래와 같이 나옵니다.

5
6
7
8
9

네. 9 이후로 계속되지 않습니다. 먼저 10개로 제한한후에 5개를 건너뛰고 출력한 모습으로 당신이 의도하는대로 안됬을 겁니다.  (직관적으로는 저게 맞음) 

"LIMIT .. OFFSET" vs. "OFFSET .. LIMIT" 의 차이를 살펴 보세요!  sql과  다릅니다. 

(역주: postgresql 에서  select * from table order by id asc  (offset 5 limit 10  와   limit 10 offset 5)  순서를 바꾸어도 차이없음. 10개 출력 )

7. 필터와 함께 파일시스템  walking  사용하기

 

1
2
3
Files.walk(Paths.get("."))
     .filter(p -> !p.toFile().getName().startsWith("."))
     .forEach(System.out::println);

위의 스트림은 오직 숨겨지지 않은 디렉토리들을 순회하는것으로 보여집니다. 즉  닷(.) 으로 시작하지 않는 디렉토리 말이죠. 운이 없게도,  #5 와  #6 의 실수를 다시 복할 겁니다. walk() 는 이미 현재 디렉토리의 전체 서브디렉토리의 스트림이 생산되어졌습니다. 게으른방식으로 말이죠.  논리적으로 모든 서브패스들을 포함합니다. 지금 필터는 정확히 닷(.) 으로 시작하는 이름을 필터링할것이구요. 예를들어 .git or .idea 같은것은 결과 스트림에서 제외될 것 입니다. .\.git\refs or .\.idea\libraryies 이런 패스들 까지  당신이 의도하는건 아니죠. 


8. 스트림의 Backing 콜렉션을 수정하기 

List 를 순회하는 동안, 이터레이션 바디안의 동일한 리스트를 수정하지 말아야합니다. Java8 이전까지는 사실이였지만 Java 8 스트림에서는 좀 더 영리해졌습니다. 

1
2
3
4
5
// list 를 streams 을 이용해서 ArrayList 로 (0..9) :
List<Integer> list =
IntStream.range(010)
         .boxed()
         .collect(toCollection(ArrayList::new));
 

각각의 요소들을 그것을 사용하고나서 제거한다고 가정해봅시다.

1
2
3
4
list.stream()
    // remove(Object), not remove(int)!
    .peek(list::remove)
    .forEach(System.out::println);

재밌게도, 이것은 요소들의 부분으로 작동을 합니다. 아웃풋은 다음과 같을수 있습니다.

0
2
4
6
8
null
null
null
null
null
java.util.ConcurrentModificationException

예외를 잡고나서 리스트를 확인해보면, 재밌게도 다음과 같습니다. 

[1, 3, 5, 7, 9]

전부 홀수네요. 버그 일까요??  아닙니다. 만약 jdk 코드안으로 파헤처들어가면 당신은 이런것을 발견할수가 있어요.  ArrayList.ArraListSpliterator:    (역주: http://okky.kr/article/279692 참고) 

/*
 * If ArrayLists were immutable, or structurally immutable (no
 * adds, removes, etc), we could implement their spliterators
 * with Arrays.spliterator. Instead we detect as much
 * interference during traversal as practical without
 * sacrificing much performance. We rely primarily on
 * modCounts. These are not guaranteed to detect concurrency
 * violations, and are sometimes overly conservative about
 * within-thread interference, but detect enough problems to
 * be worthwhile in practice. To carry this out, we (1) lazily
 * initialize fence and expectedModCount until the latest
 * point that we need to commit to the state we are checking
 * against; thus improving precision.  (This doesn't apply to
 * SubLists, that create spliterators with current non-lazy
 * values).  (2) We perform only a single
 * ConcurrentModificationException check at the end of forEach
 * (the most performance-sensitive method). When using forEach
 * (as opposed to iterators), we can normally only detect
 * interference after actions, not before. Further
 * CME-triggering checks apply to all other possible
 * violations of assumptions for example null or too-small
 * elementData array given its size(), that could only have
 * occurred due to interference.  This allows the inner loop
 * of forEach to run without any further checks, and
 * simplifies lambda-resolution. While this does entail a
 * number of checks, note that in the common case of
 * list.stream().forEach(a), no checks or other computation
 * occur anywhere other than inside forEach itself.  The other
 * less-often-used methods cannot take advantage of most of
 * these streamlinings.
 */

sorted() 를 추가했을때 어떻게 변화되는지 보겠습니다:

1
2
3
4
list.stream()
    .sorted()
    .peek(list::remove)
    .forEach(System.out::println);

기대대로 아웃풋이 생성됩니다.

0
1
2
3
4
5
6
7
8
9

스트림을 소비한후에 리스트는? 네 비어있습니다.

[]

모든 요소들이 소모되었습니다. 그리고 정확히 제거되었지요. sorted() 연산자는“stateful intermediate operation” 입니다.  이 의미는  다음차례들의  연산들이 더 이상  backing 콜렉션으로 작동하지 않고 내부 상태하에 있는것이죠. 이것이 리스트의 요소를 제거할때 안전하게 만들어주게 됩니다. 

정말 그렇다면   parallel(), sorted() 를 사용하고 제거해볼까요:

1
2
3
4
5
list.stream()
    .sorted()
    .parallel()
    .peek(list::remove)
    .forEach(System.out::println);

다음과 같이 나오고 

7
6
2
5
8
4
1
0
9
3

리스트에는 하나 남아있습니다.

[8]

헐... 모두 지워지지 않았네요. !? 

이런것들 모두 꽤 미묘하고 랜덤하게 나타납니다.  결국 우리는 스트림을 소비하는동안 backing collection을 수정하는 짓을 웬만하면 삼가하거나 조심히(?) 해야할 거 같습니다. 

 

9. 스트림을 소비하는것을 까먹음 

다음 예를 보면 어떤 생각이 드나요?

1
2
3
4
5
6
IntStream.range(15)
         .peek(System.out::println)
         .peek(i -> {
              if (i == 5)
                  throw new RuntimeException("bang");
          });

(1 2 3 4 5) 를 프린트하고나서 예외를 던질것으로 예상할수 있는데요. 그러나 틀렸습니다. 이것은 아무것도 하질 않아요. 스트림은 결코 소비되지 않습니다.

이것은   jOOQ    를 실행할때도 동일한데요. execute() or fetch() 를 까먹었다면 말이죠:

1
2
3
4
5
DSL.using(configuration)
   .update(TABLE)
   .set(TABLE.COL1, 1)
   .set(TABLE.COL2, "abc")
   .where(TABLE.ID.eq(3));

헐!!!! 아무것도 실행되지 않습니다.

10. 병렬 스트림의 데드락 

마지막을 장식하기에 딱 좋은 아이템입니다.
만약 당신이 적절하게 동기화 방식을 사용하지 않는다면 모든 병렬 시스템들은 데드락에 걸릴수 있는데요. 실세계에서 사용되는 프로그램에선 명백하지 않을때가 많습니다.  데드락에 걸리는 전형적인 예를 보시죠.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Object[] locks = { new Object(), new Object() };
 
IntStream
    .range(15)
    .parallel()
    .peek(Unchecked.intConsumer(i -> {
        synchronized (locks[i % locks.length]) {
            Thread.sleep(100);
 
            synchronized (locks[(i + 1) % locks.length]) {
                Thread.sleep(50);
            }
        }
    }))
    .forEach(System.out::println);

위 쓰레드가 각각 첫번째 synchronized 에 진입한후에 두번째 synchronized 에 진입하기위해 무한정 기다립니다.  

 

10 Subtle Mistakes When Using the Streams API  : 원문 (번역/정리 하였습니다.)
 
 
반응형

댓글