Java8 Stream은 loop가 아니다.

Java8에서는 Lambda와 Stream이라는 새로운 개념을 통해 기존 Java를 이용하는 프로그램 방식과는 완전 다른 형태의 프로그램을 가능하도록 하고 있습니다. 필자도 최근 Presto의 소스 코드를 보면서 이제부터 만드는 코드는 Java8 스타일로 개발을 해야 겠다고 생각하고 몇가지 프로그램을 만들어 보고 있습니다.  Java8 스타일로 프로그램을 만드는 중에 첫번째 부딪힌 문제가 for loop에서의 break 문제 였습니다.

java_8_lambdas

Stream forEach는 loop가 아니다.

Stream의 forEach는 loop가 아닙니다. 따라서 특정 조건이 발생했을 때 Stream 자체를 중단 시킬 방법이 없습니다. 따라서 다음과 같은 코드의 경우 반드시 주의를 기울여야 합니다.

1
2
3
4
5
6
7
for (int i = 0; i < 100; i++) {
  //Do something
  if (i > 50) {
    break;
  }
  System.out.println(i);
}

이런 코드를 무심결에 Stream의 forEach로 만들면 다음과 같이 break 문 대신 return 문으로 대체하여 구현할 수도 있습니다.

1
2
3
4
5
6
7
IntStream.range(1, 100).forEach(i -> {
  //Do Something
  if (i > 50) {
    return;
  }
  System.out.println(i);
});

일반 for 문을 사용한 경우 for loop 내부는 50번만 수행되는 반면, 위와 같이 Stream을 사용하는 경우에는 100번 모두 수행됩니다.  주석 처리된 Do something 부분에서 복잡한 처리 연산이 있고 반복 조건이 수십만 수행되는 경우라면 위 코드는 성능에 엄청나게 영향을 미치게 됩니다.

아래와 같이 Filter를 이용하여 forEach로 넘어가는 Stream의 item을 제한하는 방법이 원래 Stream의 사용 방법에 맞는 구현이라 할 수 있습니다.

1
IntStream.range(1, 100).filter(i -> i <= 50).forEach(System.out::println);

이런 filter를 사용한다 하더라도 실제 Stream은 100개의 item에 대해 모두 수행하게 됩니다. Stream 에서 filter와 같은 intermediate operator는 lazy 처리되기 때문입니다. 이런 특징 때문에 프로그램을 만들다 보면 애매한 상황이 발생할 수 있습니다.

예를 들어 파일에서 특정 문자를 포함한 첫번째 라인을 찾는 기능이라면 filter(line -> line.indexOf(keyword) >= 0).findFirst() 라고 하면 해당 라인까지만 입력 파일을 읽고 Stream을 종료하기 때문에 성능에 문제가 없습니다. 하지만 특정 문자를 포함한 라인 몇개를 찾는 경우라면 어떨까요? 일반 for 또는 while 문을 이용할 경우 다음과 같이 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
try(FileOutputStream out = new FileOutputStream("test.dat")) {
 IntStream.range(1, 100).forEach(i -> {
 try {
 out.write((i + "\n").getBytes());
 } catch (IOException e) {
 e.printStackTrace();
 }
 } );
}
String keyword = "5";
int howManyMatchedLines = 2; 
String fileName = "test.dat";
BufferedReader reader = null;
long numLines = 0;
try {
 reader = new BufferedReader(new InputStreamReader(new FileInputStream(fileName)));
 String line = null;
 int matchedLines = 0;
 while ((line = reader.readLine()) != null) {
   if (line.indexOf(keyword) >= 0) {
     System.out.println("메칭된 라인: " + line);
     matchedLines++;
     if (matchedLines >= howManyMatchedLines) {
       break;
     }
   }
   numLines++;
 }
} finally {
 if (reader != null) {
   reader.close();
 }
}

위 코드의 출력은 다음과 같습니다.

1
2
3
매칭된 라인: 5
매칭된 라인: 15
읽은 라인 수: 14

위 코드를 Stream을 이용하도록 변경하면 다음과 같이 구현할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
final AtomicInteger howManyMatchedLines = new AtomicInteger(2);
final AtomicLong lineNum = new AtomicLong(1);
final AtomicInteger matchedLines = new AtomicInteger(0);
try (Stream<String> stream = Files.lines(Paths.get("test.dat"))) {
  stream.peek(line -> lineNum.incrementAndGet())
      .filter(line -> line.indexOf(keyword) >= 0)
      .forEach(line -> {
        if (matchedLines.get() < howManyMatchedLines.get()) {
          System.out.println("매칭된 라인: " + line);
          if (matchedLines.incrementAndGet() >= howManyMatchedLines.get()) {
            return;
          }
        }
      });
}
System.out.println("읽은 라인 수: " + lineNum.get());

Stream의 filter에는 Predict 타입만 가능하기 때문에 실제 Stream에서 읽은 line수를 확인하기 위해 peek() 을 사용하였습니다. 확실히 코드의 길이나 코드의 가독성 등은 Stream을 이용하는 경우가 더 깔끔해 보입니다. 위 코드의 출력은 다음과 같습니다.

1
2
3
매칭된 라인: 5
매칭된 라인: 15
읽은 라인 수: 100

결과는 5, 15로 동일하지만 파일에서 읽은 라인 수는 단순 while 문을 사용했을 경우 15 라인만 읽었지만 Stream을 사용할 경우 100라인 모두 읽었습니다. 구글링해보면 break 와 비슷한 효과를 얻을 수 있는 방법이 있기는 합니다. lambda 식 내에서 Exception을 던지는 방법입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class BreakException extends RuntimeException {}
final AtomicInteger howManyMatchedLines = new AtomicInteger(2);
final AtomicLong lineNum = new AtomicLong(1);
final AtomicInteger matchedLines = new AtomicInteger(0);
try (Stream<String> stream = Files.lines(Paths.get("test.dat"))) {
  stream.peek(line -> lineNum.incrementAndGet())
      .filter(line -> line.indexOf(keyword) >= 0)
      .forEach(line -> {
        if (matchedLines.get() < howManyMatchedLines.get()) {
          System.out.println("매칭된 라인: " + line);
          if (matchedLines.incrementAndGet() >= howManyMatchedLines.get()) {
            throw new BreakException();
          }
        }
      });
} catch (BreakException e) {}
System.out.println("읽은 라인 수: " + lineNum.get());

이렇게 하면 파일에서 필요한 라인수까지만 읽고 더 이상 읽지 않고 Stream을 종료합니다.

필자도 이제 막 Java8 스타일로 개발을 시작했기 때문에 이런 상황에서 Stream을 이용하면서도 필요한 line 수만 읽을 수 있도록 만드는 것이 가능할 수도 있습니다. 아시는 분은 댓글로 달아주시면 바로 본문에 반영하겠습니다.

이글에서 필자가 강조하고 싶은 내용은 Stream이 직관적이면서도 코드의 라인수 등을 줄일 수 있고 Lambda 식과 연동하여 사용할 수 있으며, 쉽게 병렬 처리가 가능하다는 등의 장점이 많지만 단순히 for, while 문의 대체가 아니라는 사실을 인식하고 각 상황에 맞게 적절하게 선택해서 사용한다는 것입니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.