NIO.2로 폴더 스캔/감시 구현(FileVisitor/WatchService)
파일 시스템 작업은 두 가지가 핵심입니다.
한 번 훑기(스캔)와 계속 지켜보기(감시).
NIO.2는 이 두 가지를 각각 Files.walkFileTree(FileVisitor)와 WatchService로 제공합니다.
이 글에서는 실무에서 바로 쓰는 템플릿 코드와 운영 팁을 정리했습니다.
1) 폴더 스캔: FileVisitor
대량 파일을 일괄 처리하거나, 특정 확장자만 모아 통계를 낼 때 유용합니다. 심볼릭 링크 순회 여부, 최대 깊이, 예외 처리 등을 세밀하게 제어할 수 있습니다.
import java.io.IOException;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.concurrent.atomic.AtomicLong;
public class CsvScanner extends SimpleFileVisitor<Path> {
private final AtomicLong count = new AtomicLong();
private final AtomicLong totalBytes = new AtomicLong();
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (file.toString().endsWith(".csv")) {
count.incrementAndGet();
try { totalBytes.addAndGet(Files.size(file)); } catch (IOException ignored) {}
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
// 숨김 디렉터리 스킵(예)
if (dir.getFileName().toString().startsWith(".")) return FileVisitResult.SKIP_SUBTREE;
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) {
System.err.println("fail: " + file + " -> " + exc.getMessage());
return FileVisitResult.CONTINUE; // 실패해도 계속
}
public long count() { return count.get(); }
public long totalBytes() { return totalBytes.get(); }
public static void main(String[] args) throws IOException {
Path root = Paths.get("/data/inbox");
CsvScanner v = new CsvScanner();
Files.walkFileTree(root,
EnumSet.of(FileVisitOption.FOLLOW_LINKS), // 링크도 따라가려면
Integer.MAX_VALUE, // 최대 깊이
v);
System.out.printf("csv: %d files, %,d bytes%n", v.count(), v.totalBytes());
}
}
FOLLOW_LINKS를 켜면 순환 링크가 있을 수 있습니다. 필요하면 FileSystemLoopException을 캐치하거나 방문한 경로를 Set으로 추적해 중복을 막으세요.2) 폴더 감시: WatchService
디렉터리에서 생성/수정/삭제 이벤트를 받아 즉시 반응할 수 있습니다. 기본은 재귀 등록이 아닙니다. 처음에 모든 하위 디렉터리를 등록하고, 새로 만들어진 디렉터리는 이벤트를 통해 동적으로 추가 등록해야 합니다.
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
import static java.nio.file.StandardWatchEventKinds.*;
public class DirWatcher implements AutoCloseable {
private final WatchService ws;
private final Map<WatchKey, Path> keyToDir = new HashMap<>();
public DirWatcher(Path root) throws IOException {
this.ws = FileSystems.getDefault().newWatchService();
registerAll(root);
}
private void registerAll(Path start) throws IOException {
// 시작 시 재귀 등록
Files.walk(start).filter(Files::isDirectory).forEach(dir -> {
try {
WatchKey key = dir.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
keyToDir.put(key, dir);
} catch (IOException e) { throw new RuntimeException(e); }
});
}
public void loop() throws InterruptedException {
for (;;) {
WatchKey key = ws.take(); // blocking
Path dir = keyToDir.get(key);
if (dir == null) { key.reset(); continue; }
for (WatchEvent<?> ev : key.pollEvents()) {
WatchEvent.Kind<?> kind = ev.kind();
if (kind == OVERFLOW) {
// 이벤트 유실 → 안전하게 전체 스캔
System.out.println("OVERFLOW, rescan " + dir);
continue;
}
@SuppressWarnings("unchecked")
Path name = ((WatchEvent<Path>) ev).context(); // 상대 경로
Path child = dir.resolve(name);
System.out.printf("[%s] %s%n", kind.name(), child);
// 새 디렉터리 생성 시 재귀 등록
if (kind == ENTRY_CREATE && Files.isDirectory(child)) {
try {
registerAll(child);
} catch (IOException e) {
System.err.println("register fail: " + child + " -> " + e.getMessage());
}
}
// TODO: 여기서 파일 처리 로직 실행(이동/업로드/파싱 등)
}
boolean valid = key.reset();
if (!valid) {
keyToDir.remove(key);
if (keyToDir.isEmpty()) break; // 모든 디렉터리 등록 해제
}
}
}
@Override public void close() throws IOException { ws.close(); }
public static void main(String[] args) throws Exception {
try (DirWatcher watcher = new DirWatcher(Paths.get("/data/inbox"))) {
watcher.loop();
}
}
}
DELETE + CREATE 조합으로 도착합니다. 네트워크 드라이브/가상 파일 시스템에서는 동작이 제한적일 수 있습니다.3) 실전 예: CSV 드롭박스 파이프라인
/data/inbox에 떨어지는 .csv를 감지하여 파싱 후 /data/processed로 옮기는 예입니다. “쓰기 완료 전 이벤트” 문제를 피하려면 임시 확장자(예: .part)로 저장 후 완료 시 .csv로 rename 하도록 제작팀과 합의하면 가장 안전합니다.
void handle(Path p) throws IOException {
if (!p.toString().endsWith(".csv")) return;
// debounce: 파일이 아직 쓰이는 중인지 확인(간단 대기)
try { Thread.sleep(150); } catch (InterruptedException ignored) {}
List<String> lines = Files.readAllLines(p);
// TODO: 파싱/DB 적재
Path dest = Paths.get("/data/processed").resolve(p.getFileName());
Files.move(p, dest, StandardCopyOption.REPLACE_EXISTING);
}
4) 운영 팁
- 재귀 등록: 시작 시 전부 등록 +
ENTRY_CREATE에서 하위 디렉터리 추가 등록 - OVERFLOW: 전체 재스캔으로 복구하는 루틴 준비
- 디바운스: 수정 이벤트가 여러 번 올 수 있으니, 짧은 지연 또는 타임스탬프 기반 집계
- 중단/종료: 별도 스레드에서
loop()를 돌리고close()로 정리 - 보안: 외부 입력 파일은 크기/확장자/시그니처 검증 후 처리
이벤트 요약(이미지)

요약
- 대량 일괄 처리 → FileVisitor, 실시간 반응 → WatchService
- 감시는 재귀가 아님. 하위 디렉터리 등록을 직접 관리
OVERFLOW대비, 수정 이벤트는 디바운스
관련 글
👉 1편: ArrayList vs LinkedList: 언제 무엇을 쓰나 (+O(1) 착각 5가지)
👉 2편: 제네릭 와일드카드 완전정복(PECS 암기팁 포함)
👉 3편: Stream API: for→stream 리팩터링 10가지(성능/가독 균형)
👉 4편: Optional.orElse vs orElseGet 차이와 NPE 방지
👉 5편: java.time 제대로 쓰기(타임존/포맷 실수 7가지)
👉 6편: Java Record vs Lombok DTO 선택 기준
👉 7편: NIO.2로 폴더 스캔/감시 구현(FileVisitor/WatchService)
👉 8편: ExecutorService 스레드풀 사이즈/큐 전략
👉 9편: CompletableFuture allOf/anyOf로 외부 API 병렬화
👉 10편: 예외 처리 베스트 프랙티스(체크/언체크, 경계 설계)
'Java & Spring' 카테고리의 다른 글
| CompletableFuture allOf/anyOf로 외부 API 병렬화 (0) | 2025.09.29 |
|---|---|
| ExecutorService 스레드풀 사이즈/큐 전략 (0) | 2025.09.29 |
| Java Record vs Lombok DTO 선택 기준: 언제 무엇을 쓸까 (0) | 2025.09.28 |
| java.time 제대로 쓰기: 타임존/포맷 실수 7가지와 안전한 사용법 (0) | 2025.09.28 |
| Optional.orElse vs orElseGet 차이와 NPE 방지: 실무 규칙 7가지 (0) | 2025.09.28 |