본문 바로가기
Java & Spring

NIO.2로 폴더 스캔/감시 구현(FileVisitor/WatchService)

by yamoojin83 2025. 9. 29.

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();
    }
  }
}
주의: 감시는 OS 의존적입니다. 이벤트가 합쳐질 수 있고(특히 수정), 이름 변경은 보통 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()로 정리
  • 보안: 외부 입력 파일은 크기/확장자/시그니처 검증 후 처리

이벤트 요약(이미지)

WatchService events &amp; tips
WatchService 기본 이벤트와 흔한 처리 방법 요약.

요약

  • 대량 일괄 처리 → 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편: 예외 처리 베스트 프랙티스(체크/언체크, 경계 설계)