CodeGym /행동 /JAVA 25 SELF /파일 시스템 병렬 순회: Files.walk + parallel() 및 ForkJoin

파일 시스템 병렬 순회: Files.walk + parallel() 및 ForkJoin

JAVA 25 SELF
레벨 59 , 레슨 2
사용 가능

1. 문제: 디렉터리의 많은 파일을 효율적으로 처리하는 방법

현대 애플리케이션에서는 폴더와 그 하위 디렉터리의 많은 파일을 처리해야 하는 일이 자주 발생합니다. 예를 들어:

  • 프로젝트의 모든 ".java" 파일에서 전체 줄 수를 계산하기.
  • 지난 한 달 동안 수정된 모든 파일 찾기.
  • 특정 기준에 따라 파일을 복사하거나 삭제하기.

파일이 적다면 보통의 루프면 충분합니다. 그러나 수천, 수만 개로 늘어나고, 각 파일마다 읽기, 파싱, 분석 같은 무거운 작업을 수행하면 시간이 크게 증가합니다.

질문: 많은 파일을 더 빠르게 처리하려면?
답: 병렬 처리를 사용하세요 — 여러 스레드에서 파일을 동시에 처리합니다.

2. 파일 시스템 순회를 위한 도구

Files.walk()

Java 8+에는 디렉터리 트리를 순회하는 편리한 방법인 메서드 Files.walk()java.nio.file 패키지에 추가되었습니다. 지정한 디렉터리부터 시작하는 모든 파일과 폴더에 대한 Stream<Path>를 반환합니다.

예:

import java.nio.file.*;
import java.util.stream.Stream;

Path start = Paths.get("src");
try (Stream<Path> stream = Files.walk(start)) {
    stream.forEach(System.out::println);
}
  • Files.walk(start) — 하위 디렉터리를 포함해 모든 파일과 폴더의 스트림을 반환합니다.
  • 최대 순회 깊이를 지정할 수 있습니다: Files.walk(start, 3).

Files.find()

처음부터 조건으로 필터링해야 한다면(예: ".java" 파일만), Files.find()를 사용하세요:

import java.nio.file.*;
import java.util.stream.Stream;

Path start = Paths.get("src");

try (Stream<Path> stream = Files.find(
        start,
        Integer.MAX_VALUE,
        (path, attr) -> path.toString().endsWith(".java"))) {
    stream.forEach(System.out::println);
}
  • Files.find()는 경로와 파일 속성을 받는 필터(BiPredicate<Path, BasicFileAttributes>)를 인수로 받습니다.

3. 병렬 처리: parallel() 및 ForkJoinPool

병렬 스트림: .parallel()

모든 Stream에는 parallel() 메서드가 있습니다. 이를 호출하면 요소 처리가 여러 스레드에서 수행됩니다.

Files.walk(start)
    .parallel()
    .forEach(path -> processFile(path));

각 파일이 가능한 한 병렬로 처리되며, 특히 읽기, 파싱, 계산처럼 무거운 작업에서 효과적입니다.

내부 동작은 어떻게 될까? ForkJoinPool

병렬 스트림은 공용 스레드 풀 — ForkJoinPool.commonPool()을 사용합니다. 이는 작업을 스레드 간에 효율적으로 분배하는 ‘스마트’ 풀입니다.

  • 기본적으로 스레드 수 = 사용 가능한 프로세서 수: Runtime.getRuntime().availableProcessors().
  • ‘fork/join’ 병렬 모델은 개별 파일 처리처럼 서로 독립적인 작업에 잘 맞습니다.

언제 .parallel()를 사용해야 할까?

  • 각 파일의 처리가 서로 독립적일 때.
  • 작업이 ‘무겁다’(CPU를 많이 쓰거나 IO 대기가 길다)고 판단될 때.
  • 파일이 많을 때(수백, 수천).

다음과 같은 경우에는 병렬 스트림을 사용하지 마세요:

  • 파일이 적다면(병렬화 오버헤드가 이득보다 클 수 있음).
  • 엄격한 순서 보장이 필요하거나 요소 간 의존성이 있을 때.

4. 대안과 병렬도 튜닝

언제 ExecutorService를 사용하는 것이 더 나을까?

병렬 스트림은 단순한 케이스에 적합합니다. 하지만 다음이 필요하다면:

  • 정확한 스레드 수 제어(IO-bound 작업은 코어 수보다 더 많은 스레드가 유리한 경우가 많음).
  • 큐, 취소, 재시도, 오류 처리 등을 세밀하게 제어.
  • 더 복잡한 작업 파이프라인 구성.

그렇다면 ExecutorService를 사용하세요:

import java.nio.file.*;
import java.util.concurrent.*;

ExecutorService executor = Executors.newFixedThreadPool(8);
Files.walk(start)
    .filter(Files::isRegularFile)
    .forEach(path -> executor.submit(() -> processFile(path)));
executor.shutdown();

ForkJoinPool 튜닝

기본적으로 공용 풀은 프로세서 개수와 동일한 스레드 수를 사용합니다. 시스템 속성을 통해 이를 변경할 수 있습니다(병렬 스트림을 처음 사용하기 전):

System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "16");
  • 설정 후 모든 병렬 스트림은 최대 16개의 스레드를 사용합니다.

CPU-bound vs IO-bound 작업

  • CPU-bound: 수학 연산, 파싱, 압축처럼 CPU 사용량이 높은 작업. 스레드 수 ≈ 코어 수.
  • IO-bound: 디스크/네트워크 대기가 많음. 종종 코어 수보다 많은 스레드가 유리함.

IO-bound 작업에는 병렬 스트림이 항상 최적은 아닙니다 — 더 큰 풀을 가진 자체 ExecutorService가 더 좋은 경우가 많습니다.

5. 예제: 파일의 병렬 검색과 처리

병렬 순회를 사용해 프로젝트의 모든 ".java" 파일에서 총 줄 수를 계산해 봅니다.

import java.nio.file.*;
import java.util.stream.*;
import java.io.IOException;

public class LineCounter {
    public static void main(String[] args) throws IOException {
        Path start = Paths.get("src");

        long totalLines = Files.walk(start)
            .parallel() // 병렬 처리!
            .filter(p -> p.toString().endsWith(".java"))
            .mapToLong(LineCounter::countLines)
            .sum();

        System.out.println("코드 총 줄 수: " + totalLines);
    }

    // 파일의 줄 수를 세는 메서드
    private static long countLines(Path path) {
        try (Stream<String> lines = Files.lines(path)) {
            return lines.count();
        } catch (IOException e) {
            System.err.println("파일 읽기 오류: " + path);
            return 0;
        }
    }
}

무슨 일이 일어나는가:

  • Files.walk(start) — 모든 경로를 순회합니다.
  • parallel() — 병렬 처리를 켭니다.
  • filter(...)".java" 파일만 남깁니다.
  • mapToLong(...) — 각 파일의 줄 수를 셉니다.
  • sum() — 결과를 합산합니다.

장점: 여러 스레드를 활용하면서도 코드가 간결합니다.

6. 중요한 사항과 흔한 실수

  • 모든 작업이 병렬화로 빨라지는 것은 아닙니다. 파일 수가 적거나 작업이 매우 빠르면 오히려 느려질 수 있습니다.
  • 리소스를 꼭 닫으세요. 파일 작업 시 try-with-resources를 사용하면 디스크립터가 누수되지 않습니다. 예: Files.lines(path)try(...)에서 사용.
  • 중첩 병렬성. 다른 병렬 작업 내부에서 병렬 스트림(nested parallelism)을 실행하는 것은 드물게만 효과적이며 성능 저하를 유발할 수 있습니다.
  • 부작용. 동기화 없이 공용 구조/파일에 기록하는 것을 피하세요. 요소에 대한 ‘순수한’ 연산을 선호하세요.

7. 다이어그램: 병렬 파일 순회가 동작하는 방식

flowchart TD
    A["Files.walk(start)"] --> B["Stream<Path>"]
    B --> C{".parallel()?"}
    C -- 아니오 --> D[일반 forEach]
    C -- 예 --> E["병렬 forEach (ForkJoinPool)"]
    E --> F[여러 스레드에서 파일 처리]

8. 파일 병렬 처리의 흔한 실수

오류 #1: 작은 작업에 병렬 스트림을 사용함 — 오버헤드가 이득보다 큽니다.

오류 #2: 병렬 스트림이 IO-bound 작업도 CPU-bound만큼 빨라질 것이라 기대함. IO의 경우에는 보통 더 큰 풀을 가진 ExecutorService가 필요합니다.

오류 #3: 람다에서 예외를 처리하지 않음 — IOException을 처리하지 않으면 스트림이 중단되어 결과가 불완전해질 수 있습니다.

오류 #4: 공유 변수나 파일에 기록할 때 레이스 컨디션 — 접근을 동기화하거나 부작용을 피하세요.

오류 #5: 리소스를 닫지 않음 — 파일 작업 전반에 try-with-resources를 사용하세요.

오류 #6: 첫 사용 이후에 ForkJoinPool.commonPool()을 변경하려 함 — System.setProperty(...)를 통한 설정은 사전에 해야 합니다.

오류 #7: 병렬 스트림 내부에서 또 다른 병렬 스트림을 사용함 — 성능 저하로 이어지는 경우가 많습니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION