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: 병렬 스트림 내부에서 또 다른 병렬 스트림을 사용함 — 성능 저하로 이어지는 경우가 많습니다.
GO TO FULL VERSION