1주차 Unit 7.2 — File → NIO.2 (Files, Path)

Psj·2일 전

F-lab

목록 보기
47/52

Unit 7.2 — File → NIO.2 (Files, Path)

F-LAB JAVA · 1주차 · Phase 7 · 예외 처리와 자원 관리


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • java.io.File의 6가지 구조적 한계는?
  • Path · Paths · Files 셋의 역할 차이는?
  • resolve, resolveSibling, relativize, normalize의 차이는?
  • Files.list, Files.walk, Files.find는 언제 무엇을 쓰는가?
  • 왜 NIO.2 메서드는 try-with-resources가 필수인가?
  • Path Traversal 공격을 어떻게 방어하는가?
  • WatchService는 어떤 시나리오에서 쓰는가?
  • ILIC에서 NIO.2를 어떻게 활용하는가?

🎯 핵심 한 문장

NIO.2 (java.nio.file, Java 7+) = Path(불변 경로) + Files(정적 유틸의 폭격기) + Stream 통합
java.io.File의 6가지 한계를 해결하고, 현대 파일 처리의 표준이 됐다.

비유 — 만능 칼 vs 부엌

시대도구문제 / 강점
java.io.File (Java 1.0~)만능 스위스 칼가위·드라이버·병따개 다 들어있지만, 정작 필요한 게 없거나 잘 안 듦. 실패하면 이유도 모름 (false만 반환)
java.nio.file (Java 7+)전문 부엌칼은 자르기, 도마는 받치기, 냄비는 끓이기 — 역할 분리. 실패하면 정확한 예외 던짐

🧭 9개 섹션 로드맵

1. java.io.File의 비극        — 왜 NIO.2가 필요했나
2. NIO.2 핵심 3총사           — Path · Paths · Files
3. Path 깊이 파기              — resolve · relativize · normalize
4. Files 유틸리티 백과         — 읽기 · 쓰기 · 복사 · 메타데이터
5. 디렉토리 순회 3종           — list · walk · find
6. 고급 기능                   — WatchService · FileSystem · Symbolic Link
7. ILIC 실무 코드 7가지        — 인보이스 정리 · 화물 사진 · 정산 파일 감시
8. 흔한 실수 9가지             — Stream 누수 · Path Traversal · Charset
9. 면접 질문 + 자기 점검

1️⃣ java.io.File의 6가지 한계

1.1 boolean만 반환 — 실패 이유 모름

File file = new File("/tmp/important.txt");

if (!file.delete()) {
    // ❌ 왜 실패했나?
    //   - 파일이 없어서?
    //   - 권한이 없어서?
    //   - 다른 프로세스가 잡고 있어서?
    //   - 디스크가 read-only?
    // 모름. 그냥 false.
}

File.delete(), File.mkdir(), File.renameTo() 모두 boolean만 반환.
운영 장애 디버깅 시 이유를 알 수 없는 실패가 가장 답답하다.

NIO.2:

Files.delete(path);
// 실패 시 정확한 예외:
//   NoSuchFileException
//   DirectoryNotEmptyException
//   AccessDeniedException
//   IOException

1.2 메타데이터 부족

File f = new File("/data/cargo/photo.jpg");

f.length();           // ✓ 크기
f.lastModified();     // ✓ 수정 시각
// 그게 전부. 그 외엔?
// - 생성 시각? 없음
- 접근 시각? 없음
- 소유자? 없음
- POSIX 권한? 없음
- 확장 속성? 없음

NIO.2:

BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
attrs.creationTime();          // ✓ 생성 시각
attrs.lastAccessTime();        // ✓ 접근 시각
attrs.lastModifiedTime();      // ✓
attrs.size();
attrs.isSymbolicLink();        // ✓

// POSIX
PosixFileAttributes posix = Files.readAttributes(path, PosixFileAttributes.class);
posix.owner();
posix.group();
posix.permissions();
File f = new File("/usr/bin/java");  // 보통 심볼릭 링크
// 심볼릭 링크인지 알 방법 → 없음
// 따라갈지 말지 옵션 → 없음

NIO.2:

Files.isSymbolicLink(path);
Files.readSymbolicLink(path);              // 링크 대상 경로
Files.exists(path, LinkOption.NOFOLLOW_LINKS);  // 따라가지 않음

1.4 디렉토리 순회 비효율

File dir = new File("/data/cargo/photos");
String[] files = dir.list();   // ❌ 모든 파일을 한 번에 메모리 적재
// 디렉토리에 100만 개 파일이 있다면? OOM

NIO.2:

try (Stream<Path> stream = Files.list(dir)) {
    stream.filter(p -> p.toString().endsWith(".jpg"))
          .forEach(this::process);
}
// Lazy. 메모리 사용량 일정.

1.5 다른 파일시스템 지원 안 됨

// File로는 ZIP 안의 파일을 다룰 수 없음
File f = new File("archive.zip/inner.txt");  // ❌ 안 됨

NIO.2:

try (FileSystem zipFs = FileSystems.newFileSystem(Path.of("archive.zip"))) {
    Path innerFile = zipFs.getPath("/inner.txt");
    String content = Files.readString(innerFile);  // ✓ ZIP 안의 파일을 그냥 다룸
}

1.6 NIO와 통합 안 됨

FileChannel, ByteBuffer, FileLock 등과 별개 세계.
대용량 처리 시 RandomAccessFile로 우회해야 했음.

NIO.2:

try (FileChannel channel = FileChannel.open(path, READ, WRITE)) {
    MappedByteBuffer buffer = channel.map(MapMode.READ_WRITE, 0, size);
    // 메모리 매핑 IO — 수 GB도 효율적 처리
}

2️⃣ NIO.2 핵심 3총사 — Path · Paths · Files

2.1 Path — 경로의 표현 (인터페이스)

public interface Path extends Comparable<Path>, Iterable<Path>, Watchable {
    Path getFileName();
    Path getParent();
    Path resolve(Path other);
    // ...
}

핵심 성질:

  • 불변(immutable) — 모든 메서드가 새 Path 반환 (String과 동일 철학)
  • 경로일 뿐, 파일 아님 — 존재하지 않는 경로도 표현 가능
  • OS 독립적 — 내부적으로 /\ 알아서 처리

2.2 Paths vs Path.of — Java 11 이후

// Java 7~10 (Paths 유틸)
Path p1 = Paths.get("/home/seungje/invoices");
Path p2 = Paths.get("/home", "seungje", "invoices");

// Java 11+ (권장) — Path.of (정적 팩토리)
Path p1 = Path.of("/home/seungje/invoices");
Path p2 = Path.of("/home", "seungje", "invoices");

📌 Paths.get()deprecated되진 않았지만, JDK 11+의 모든 새 코드는 Path.of() 사용 권장. 더 짧고, Path 인터페이스 안에 있어서 import도 안 필요.

2.3 Files — 정적 유틸리티의 폭격기

java.nio.file.Files모든 파일 작업의 진입점이다.
NIO.2의 90% 작업은 Files.xxx() 형태.

Files.exists(path)
Files.size(path)
Files.readString(path)
Files.writeString(path, content)
Files.copy(src, dst)
Files.delete(path)
Files.walk(dir)
// ... 50개 이상의 정적 메서드

Files. 한 번 치고 IDE 자동완성으로 거의 모든 것 해결.


3️⃣ Path 깊이 파기

3.1 경로 해체

Path p = Path.of("/home/seungje/ilic/invoices/2024/01/INV-001.pdf");

p.getFileName();      // INV-001.pdf
p.getParent();        // /home/seungje/ilic/invoices/2024/01
p.getRoot();          // /
p.getNameCount();     // 6
p.getName(0);         // home
p.getName(5);         // INV-001.pdf
p.subpath(1, 3);      // seungje/ilic   (start inclusive, end exclusive)

// 반복도 가능
for (Path part : p) {
    System.out.println(part);   // home, seungje, ilic, invoices, 2024, 01, INV-001.pdf
}

3.2 절대 vs 상대

Path absolute = Path.of("/home/seungje/invoices");
Path relative = Path.of("invoices/2024");

absolute.isAbsolute();    // true
relative.isAbsolute();    // false

relative.toAbsolutePath();
// → /현재작업디렉토리/invoices/2024

3.3 정규화 — normalize()

Path messy = Path.of("/home/seungje/../seungje/./invoices/../invoices/INV.pdf");
messy.normalize();
// → /home/seungje/invoices/INV.pdf

../, ./ 제거. 파일시스템 확인은 안 함 — 순수 문자열 연산.

3.4 toRealPath() — 실제 파일시스템과 대조

Path p = Path.of("/usr/bin/java");   // 심볼릭 링크라 가정
p.toRealPath();
// → /usr/lib/jvm/java-21/bin/java   (실제 경로, 심볼릭 링크 해소)

⚠️ 파일이 실제로 존재해야 함. 없으면 NoSuchFileException.

3.5 resolve — 자식 경로 만들기

Path base = Path.of("/home/ilic/invoices");

base.resolve("2024");
// → /home/ilic/invoices/2024

base.resolve("2024/01/INV-001.pdf");
// → /home/ilic/invoices/2024/01/INV-001.pdf

base.resolve("/etc/passwd");
// → /etc/passwd   (절대 경로면 base 무시!)

⚠️ 마지막 케이스 주의 — 이게 곧 Path Traversal 공격의 핵심.

3.6 resolveSibling — 형제 경로

Path file = Path.of("/home/ilic/invoices/2024/INV-001.pdf");
file.resolveSibling("INV-002.pdf");
// → /home/ilic/invoices/2024/INV-002.pdf
// (parent를 찾아 그 옆에 만듦)

→ 같은 디렉토리에 다른 파일 만들 때 유용.

3.7 relativize — 상대 경로 계산

Path base = Path.of("/home/ilic/invoices");
Path target = Path.of("/home/ilic/invoices/2024/01/INV-001.pdf");

base.relativize(target);
// → 2024/01/INV-001.pdf

용도:

  • URL 생성: /files/2024/01/INV-001.pdf
  • 상대 경로로 파일 목록 표시

⚠️ 두 경로 타입이 같아야 함 (둘 다 절대 또는 둘 다 상대).
다르면 IllegalArgumentException.

3.8 한눈에 보기

base = /a/b
target = /a/b/c/d.txt
sibling = "e.txt"

base.resolve("c/d.txt")           → /a/b/c/d.txt   (자식)
base.resolveSibling(sibling)       → /a/e.txt       (형제)
base.relativize(target)            → c/d.txt        (상대 차)
target.normalize()                 → /a/b/c/d.txt   (정리)
target.toAbsolutePath()            → 이미 절대
target.toRealPath()                → 심볼릭 링크 해소 + 실제 확인

4️⃣ Files 유틸리티 백과

4.1 읽기 — 4가지 패턴

// 작은 텍스트 파일 (Java 11+, 가장 간결)
String content = Files.readString(path);
String content = Files.readString(path, StandardCharsets.UTF_8);

// 라인 단위 — 메모리에 다 적재 (작은 파일만)
List<String> lines = Files.readAllLines(path, UTF_8);

// 라인 단위 — Lazy Stream (대용량 OK, 자원!)
try (Stream<String> lines = Files.lines(path, UTF_8)) {
    long count = lines.filter(l -> l.contains("ERROR")).count();
}

// 바이너리
byte[] bytes = Files.readAllBytes(path);

4.2 쓰기 — 4가지 패턴

// 텍스트 한 번에
Files.writeString(path, "Hello", UTF_8);
Files.writeString(path, "Append", UTF_8, StandardOpenOption.APPEND);

// 라인 컬렉션
Files.write(path, List.of("line1", "line2"), UTF_8);

// 바이너리
Files.write(path, bytes);

// 스트림으로 (대용량)
try (BufferedWriter w = Files.newBufferedWriter(path, UTF_8)) {
    for (String line : hugeList) w.write(line);
}

4.3 메타데이터 한 방에

BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
attrs.creationTime();
attrs.lastModifiedTime();
attrs.lastAccessTime();
attrs.size();
attrs.isDirectory();
attrs.isRegularFile();
attrs.isSymbolicLink();

// 또는 개별 호출
Files.size(path);
Files.getLastModifiedTime(path);
Files.isDirectory(path);
Files.exists(path);
Files.notExists(path);       // !exists()와 다름! (확인 불가 시 false)
Files.isReadable(path);
Files.isWritable(path);
Files.isExecutable(path);
Files.isHidden(path);

📌 exists() vs !notExists(): 권한 부족 등으로 확인 자체가 불가하면 둘 다 false. 한쪽만 보면 안 됨.

4.4 생성

Files.createFile(path);                          // 빈 파일
Files.createDirectory(path);                     // 디렉토리 1단계
Files.createDirectories(path);                   // 중간 디렉토리도 다 생성

Files.createTempFile("invoice-", ".pdf");        // /tmp/invoice-12345.pdf
Files.createTempDirectory("ilic-");              // /tmp/ilic-67890

// 권한 지정
Set<PosixFilePermission> perms = PosixFilePermissions.fromString("rw-r-----");
FileAttribute<?> attr = PosixFilePermissions.asFileAttribute(perms);
Files.createFile(path, attr);

4.5 복사 · 이동 · 삭제

// 복사
Files.copy(src, dst);
Files.copy(src, dst,
    StandardCopyOption.REPLACE_EXISTING,
    StandardCopyOption.COPY_ATTRIBUTES);

// 이동 (같은 파일시스템이면 rename, 아니면 copy+delete)
Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE);

// 삭제
Files.delete(path);                       // 없으면 NoSuchFileException
Files.deleteIfExists(path);               // 없어도 OK

StandardCopyOption:
| 옵션 | 의미 |
|---|---|
| REPLACE_EXISTING | 대상이 있어도 덮어씀 |
| COPY_ATTRIBUTES | 메타데이터(시각, 권한)도 복사 |
| ATOMIC_MOVE | 원자적 이동 (같은 FS만 가능) |
| NOFOLLOW_LINKS | 심볼릭 링크 자체를 복사 |

4.6 스트림 열기

// 자원! try-with-resources 필수
try (InputStream in = Files.newInputStream(path);
     OutputStream out = Files.newOutputStream(dst)) {
    in.transferTo(out);
}

// 텍스트
try (BufferedReader r = Files.newBufferedReader(path, UTF_8);
     BufferedWriter w = Files.newBufferedWriter(dst, UTF_8)) {
    String line;
    while ((line = r.readLine()) != null) w.write(line + "\n");
}

5️⃣ 디렉토리 순회 — list · walk · find

5.1 비교표

메서드깊이필터링메모리용도
Files.list(dir)1단계만호출 후 .filter()Lazy직속 자식만
Files.walk(dir)무제한/지정호출 후 .filter()Lazy재귀 순회
Files.find(dir, depth, m)지정호출 시 함께Lazy속성 기반 필터

5.2 Files.list — 1단계

Path invoicesDir = Path.of("/data/ilic/invoices");

try (Stream<Path> stream = Files.list(invoicesDir)) {
    stream.filter(Files::isDirectory)         // 연도 디렉토리만
          .forEach(System.out::println);
}
// 출력:
// /data/ilic/invoices/2023
// /data/ilic/invoices/2024
// /data/ilic/invoices/2025

5.3 Files.walk — 재귀

Path root = Path.of("/data/ilic/invoices");

// 모든 하위
try (Stream<Path> stream = Files.walk(root)) {
    stream.filter(Files::isRegularFile)
          .filter(p -> p.toString().endsWith(".pdf"))
          .forEach(System.out::println);
}

// 깊이 제한
try (Stream<Path> stream = Files.walk(root, 2)) {
    // 자기 자신 + 2단계 아래까지
}

순회 순서: depth-first (깊이 우선).

5.4 Files.find — 속성 기반 필터

Path root = Path.of("/data/ilic/uploads");

// 10MB 이상 파일 찾기
try (Stream<Path> stream = Files.find(
    root, Integer.MAX_VALUE,
    (path, attrs) -> attrs.isRegularFile() && attrs.size() > 10_000_000
)) {
    stream.forEach(this::handleLargeFile);
}

// 30일 이상 오래된 파일
FileTime cutoff = FileTime.from(Instant.now().minus(30, DAYS));
try (Stream<Path> stream = Files.find(
    root, Integer.MAX_VALUE,
    (path, attrs) -> attrs.lastModifiedTime().compareTo(cutoff) < 0
)) {
    stream.forEach(this::archive);
}

find vs walk + filter 성능 차이:

  • walk: 모든 Path를 만든 뒤 filter
  • find: 디렉토리 순회 중 BasicFileAttributes를 한 번에 받아 검사 → 속성 검사 시 더 빠름

5.5 ⚠️ 셋 다 자원이다 — try-with-resources 필수

// ❌ 자원 누수
List<Path> files = Files.walk(root)
                        .filter(Files::isRegularFile)
                        .collect(toList());
// walk가 연 디렉토리 스트림이 안 닫힘
// → 운영에서 "Too many open files" 발생

// ✅
List<Path> files;
try (Stream<Path> stream = Files.walk(root)) {
    files = stream.filter(Files::isRegularFile)
                  .collect(toList());
}

→ Unit 7.1과 직결되는 핵심 포인트.


6️⃣ 고급 기능

6.1 WatchService — 디렉토리 감시

파일 생성/수정/삭제 이벤트를 받는다.

public void watchInvoicesFolder(Path folder) throws Exception {
    try (WatchService watcher = folder.getFileSystem().newWatchService()) {
        folder.register(watcher,
            StandardWatchEventKinds.ENTRY_CREATE,
            StandardWatchEventKinds.ENTRY_MODIFY,
            StandardWatchEventKinds.ENTRY_DELETE);

        while (true) {
            WatchKey key = watcher.take();   // block until event

            for (WatchEvent<?> event : key.pollEvents()) {
                WatchEvent.Kind<?> kind = event.kind();
                Path fileName = (Path) event.context();
                Path fullPath = folder.resolve(fileName);

                if (kind == ENTRY_CREATE) {
                    log.info("새 파일: {}", fullPath);
                    processNewInvoice(fullPath);
                }
            }

            if (!key.reset()) break;   // 디렉토리 사라짐
        }
    }
}

용도 (ILIC 시나리오):

  • 정산 파일 자동 처리 — 거래처가 SFTP로 올린 CSV 감지
  • 인보이스 PDF 업로드 후 OCR 트리거
  • 로그 디렉토리 모니터링

주의:

  • 재귀 감시 안 됨 — 하위 디렉토리는 각각 등록해야 함
  • macOS에서는 polling 기반이라 느림 (윈도우/리눅스는 OS native)

6.2 FileSystem — ZIP을 디렉토리처럼

Path zipPath = Path.of("/data/ilic/shipment-2024.zip");

try (FileSystem zipFs = FileSystems.newFileSystem(zipPath)) {
    Path inner = zipFs.getPath("/invoices/INV-001.pdf");

    // ZIP 안의 파일을 그냥 Path처럼 다룸
    byte[] content = Files.readAllBytes(inner);

    // ZIP 안의 디렉토리 순회
    try (Stream<Path> stream = Files.walk(zipFs.getPath("/"))) {
        stream.filter(Files::isRegularFile)
              .forEach(p -> System.out.println(p));
    }
}
// zipFs 닫히면서 ZIP 파일도 정리됨

ZIP 안의 파일을 풀지 않고 다룬다. 메모리 효율적.

Path target = Path.of("/data/real/invoices");
Path link = Path.of("/data/current/invoices");

// 심볼릭 링크 생성
Files.createSymbolicLink(link, target);

// 확인
Files.isSymbolicLink(link);                    // true
Files.readSymbolicLink(link);                  // /data/real/invoices

// 따라가지 않기
Files.exists(link, LinkOption.NOFOLLOW_LINKS);

6.4 POSIX 권한

Set<PosixFilePermission> perms = PosixFilePermissions.fromString("rw-r-----");
Files.setPosixFilePermissions(path, perms);

// 또는
Files.setPosixFilePermissions(path, Set.of(
    OWNER_READ, OWNER_WRITE,
    GROUP_READ
));

→ Linux 운영 서버에서 민감 파일(키, 인증서) 권한 강제 시 사용.


7️⃣ ILIC 실무 코드 7가지

국제종합물류 ILIC 코드베이스에서 NIO.2를 어떻게 활용하고 있는가.

7.1 인보이스 PDF 디렉토리 정리 — 90일 이상 아카이브

@Component
public class InvoiceArchiver {

    @Scheduled(cron = "0 0 3 * * *")  // 매일 새벽 3시
    public void archiveOldInvoices() {
        Path activeDir = Path.of("/data/ilic/invoices/active");
        Path archiveDir = Path.of("/data/ilic/invoices/archive");
        FileTime cutoff = FileTime.from(Instant.now().minus(90, DAYS));

        try (Stream<Path> oldFiles = Files.find(
            activeDir, Integer.MAX_VALUE,
            (path, attrs) -> attrs.isRegularFile()
                          && attrs.lastModifiedTime().compareTo(cutoff) < 0
        )) {
            oldFiles.forEach(file -> moveToArchive(file, activeDir, archiveDir));
        } catch (IOException e) {
            throw new ArchiveException("아카이브 실패", e);
        }
    }

    private void moveToArchive(Path file, Path activeRoot, Path archiveRoot) {
        try {
            Path relative = activeRoot.relativize(file);   // 2024/01/INV-001.pdf
            Path target = archiveRoot.resolve(relative);   // archive로 이동

            Files.createDirectories(target.getParent());   // 중간 디렉토리 생성
            Files.move(file, target, StandardCopyOption.ATOMIC_MOVE);
        } catch (IOException e) {
            log.warn("이동 실패: {}", file, e);
        }
    }
}

포인트:

  • Files.find — 속성 기반 효율 필터
  • relativize — 기존 디렉토리 구조 유지하며 이동
  • createDirectories — 중간 단계 자동 생성
  • ATOMIC_MOVE — 같은 파일시스템에서 원자적 이동

7.2 화물 사진 첨부 업로드 — 안전한 저장

@Service
public class CargoPhotoUploader {

    private static final Path UPLOAD_ROOT = Path.of("/data/ilic/cargo-photos");
    private static final long MAX_SIZE = 10 * 1024 * 1024;  // 10MB

    public CargoPhoto upload(Long cargoId, MultipartFile file) {
        validateFile(file);

        Path targetDir = UPLOAD_ROOT.resolve(String.valueOf(cargoId));

        try {
            Files.createDirectories(targetDir);

            String filename = generateSafeFilename(file.getOriginalFilename());
            Path target = targetDir.resolve(filename);

            try (InputStream in = file.getInputStream()) {
                Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
            }

            return new CargoPhoto(cargoId, filename, Files.size(target));
        } catch (IOException e) {
            throw new UploadException("업로드 실패", e);
        }
    }

    private String generateSafeFilename(String original) {
        String ext = "";
        int dot = original.lastIndexOf('.');
        if (dot > 0) ext = original.substring(dot);
        return UUID.randomUUID() + ext;   // 원본 파일명 사용 안 함
    }
}

포인트:

  • 원본 파일명 무시 (Path Traversal · 충돌 방지)
  • createDirectories — cargo별 디렉토리 자동 생성
  • Files.copy(InputStream, Path) — Stream 한 번에 처리

7.3 임시 파일 안전한 생성

public byte[] generateMergedInvoice(List<Invoice> invoices) {
    Path temp = null;
    try {
        temp = Files.createTempFile("merged-invoice-", ".pdf");

        try (OutputStream out = Files.newOutputStream(temp)) {
            pdfMerger.merge(invoices, out);
        }

        return Files.readAllBytes(temp);
    } catch (IOException e) {
        throw new PdfMergeException("병합 실패", e);
    } finally {
        if (temp != null) {
            try {
                Files.deleteIfExists(temp);
            } catch (IOException e) {
                log.warn("임시 파일 삭제 실패: {}", temp, e);
            }
        }
    }
}

또는 더 깔끔하게:

try (var tempFile = new TempFile("merged-", ".pdf")) {
    pdfMerger.merge(invoices, Files.newOutputStream(tempFile.path()));
    return Files.readAllBytes(tempFile.path());
}

// 헬퍼 클래스
public class TempFile implements AutoCloseable {
    private final Path path;
    public TempFile(String prefix, String suffix) throws IOException {
        this.path = Files.createTempFile(prefix, suffix);
    }
    public Path path() { return path; }
    @Override public void close() throws IOException {
        Files.deleteIfExists(path);
    }
}

→ Unit 7.1의 try-with-resources 패턴 응용.

7.4 ZIP으로 첨부 묶기

public Path zipShipmentDocuments(Long shipmentId) {
    Path zipPath = Files.createTempFile("shipment-" + shipmentId + "-", ".zip");
    Path docsDir = Path.of("/data/ilic/shipments", String.valueOf(shipmentId));

    URI zipUri = URI.create("jar:" + zipPath.toUri());
    Map<String, String> env = Map.of("create", "true");

    try (FileSystem zipFs = FileSystems.newFileSystem(zipUri, env);
         Stream<Path> docs = Files.walk(docsDir)) {

        docs.filter(Files::isRegularFile)
            .forEach(src -> {
                try {
                    Path relative = docsDir.relativize(src);
                    Path target = zipFs.getPath("/").resolve(relative.toString());
                    Files.createDirectories(target.getParent());
                    Files.copy(src, target);
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            });

        return zipPath;
    } catch (IOException e) {
        throw new ZipException("ZIP 생성 실패", e);
    }
}

포인트:

  • FileSystems.newFileSystem — ZIP을 가상 디렉토리로
  • 한 번도 압축 라이브러리를 import하지 않음 (JDK 표준)

7.5 정산 파일 자동 처리 (WatchService)

@Component
public class SettlementFileWatcher {

    private static final Path INBOX = Path.of("/data/ilic/inbox/settlement");

    @PostConstruct
    public void startWatching() {
        Thread.startVirtualThread(this::watchLoop);  // Java 21+
    }

    private void watchLoop() {
        try (WatchService watcher = INBOX.getFileSystem().newWatchService()) {
            INBOX.register(watcher, ENTRY_CREATE);

            while (!Thread.currentThread().isInterrupted()) {
                WatchKey key = watcher.take();

                for (WatchEvent<?> event : key.pollEvents()) {
                    if (event.kind() == ENTRY_CREATE) {
                        Path fileName = (Path) event.context();
                        Path fullPath = INBOX.resolve(fileName);

                        // 파일이 완전히 쓰여질 때까지 대기 (안정성)
                        if (waitUntilStable(fullPath)) {
                            settlementProcessor.process(fullPath);
                        }
                    }
                }
                key.reset();
            }
        } catch (Exception e) {
            log.error("WatchService 종료", e);
        }
    }

    private boolean waitUntilStable(Path file) throws IOException, InterruptedException {
        long prevSize = -1;
        for (int i = 0; i < 10; i++) {
            long size = Files.size(file);
            if (size == prevSize && size > 0) return true;
            prevSize = size;
            Thread.sleep(500);
        }
        return false;
    }
}

포인트:

  • Virtual Thread(Java 21+) 활용
  • waitUntilStable — 파일 업로드가 끝났는지 확인 (size가 변하지 않을 때까지)

7.6 디렉토리 크기 계산

public long calculateDirectorySize(Path dir) {
    try (Stream<Path> stream = Files.walk(dir)) {
        return stream.filter(Files::isRegularFile)
                     .mapToLong(p -> {
                         try {
                             return Files.size(p);
                         } catch (IOException e) {
                             return 0;
                         }
                     })
                     .sum();
    } catch (IOException e) {
        throw new DiskUsageException(dir.toString(), e);
    }
}

→ 화물별 첨부 디스크 사용량 대시보드용.

7.7 Path Traversal 방어

public byte[] downloadAttachment(Long cargoId, String userInput) {
    Path baseDir = Path.of("/data/ilic/cargo-photos", String.valueOf(cargoId))
                       .toAbsolutePath()
                       .normalize();

    Path requested = baseDir.resolve(userInput).normalize();

    // 핵심 방어: 정규화 후에도 baseDir 안에 있는가?
    if (!requested.startsWith(baseDir)) {
        throw new SecurityException("Path traversal 시도 차단");
    }

    try {
        return Files.readAllBytes(requested);
    } catch (IOException e) {
        throw new DownloadException(e);
    }
}

왜 필요한가:

userInput = "../../../etc/passwd"
baseDir.resolve(userInput).normalize()
// → /etc/passwd  ❌

// startsWith 검증으로 차단
// → /etc/passwd is NOT startsWith /data/ilic/cargo-photos/123
// → SecurityException

→ 사용자 입력으로 파일 경로를 만들 때 반드시 normalize + startsWith 검증.


8️⃣ 흔한 실수 9가지

실수 1 — Files.lines / walk / list / find를 try 없이 사용

// ❌
List<String> errors = Files.lines(logPath)
                           .filter(l -> l.contains("ERROR"))
                           .collect(toList());
// 파일이 안 닫힘 → "Too many open files"

// ✅
try (Stream<String> lines = Files.lines(logPath)) {
    return lines.filter(l -> l.contains("ERROR")).collect(toList());
}

→ Unit 7.1 핵심. Stream을 반환하는 모든 Files 메서드는 자원.

실수 2 — Path.toFile().delete() — NIO.2 안 쓰는 셈

// ❌
path.toFile().delete();          // 실패 시 false만, 이유 모름

// ✅
Files.delete(path);              // 실패 시 정확한 예외

toFile()은 NIO.2를 java.io.File로 되돌리는 호환 메서드. 레거시 API와 연동할 때만 사용.

실수 3 — 경로 분리자 직접 입력

// ❌ Windows에서 깨짐
String path = "/data/ilic/invoices/" + year + "/" + month + "/INV-" + id + ".pdf";

// ❌ Linux에서 깨짐
String path = "C:\\data\\ilic\\invoices\\" + year + "\\" + month + "\\INV-" + id + ".pdf";

// ✅ OS 독립적
Path path = Path.of("/data/ilic/invoices")
                .resolve(String.valueOf(year))
                .resolve(String.valueOf(month))
                .resolve("INV-" + id + ".pdf");

실수 4 — readAllLines를 큰 파일에 사용

// ❌ 1GB 로그 파일을 메모리에 다 적재
List<String> lines = Files.readAllLines(logPath);

// ✅ Lazy Stream
try (Stream<String> lines = Files.lines(logPath)) {
    long count = lines.filter(predicate).count();
}

실수 5 — Files.copy에 REPLACE_EXISTING 안 줌

// ❌ 대상이 있으면 FileAlreadyExistsException
Files.copy(src, dst);

// ✅ 의도가 덮어쓰기라면
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);

실수 6 — relativize의 타입 불일치

Path base = Path.of("/home/ilic");        // 절대
Path target = Path.of("invoices/INV.pdf"); // 상대

base.relativize(target);
// ❌ IllegalArgumentException: 'other' is different type of Path

→ 둘 다 절대 또는 둘 다 상대여야 함. 사용 전 toAbsolutePath()로 통일.

실수 7 — Charset 명시 안 함

// ❌ 플랫폼 기본 인코딩 (Windows = CP949, Linux = UTF-8)
String content = Files.readString(path);  // Java 17까지는 위험

// ✅ 명시
String content = Files.readString(path, StandardCharsets.UTF_8);

📌 Java 18+는 기본이 UTF-8로 바뀜. 그래도 명시 권장.

실수 8 — Path Traversal 미대응

// ❌ 사용자 입력을 그대로 resolve
Path file = baseDir.resolve(request.getParameter("file"));
return Files.readAllBytes(file);

// 공격: ?file=../../etc/passwd
// → /etc/passwd 읽힘

// ✅ normalize + startsWith 검증 (위 7.7 참조)

실수 9 — WatchService를 자원 처리 없이 사용

// ❌
WatchService watcher = folder.getFileSystem().newWatchService();
folder.register(watcher, ENTRY_CREATE);
// watcher가 안 닫힘

// ✅
try (WatchService watcher = folder.getFileSystem().newWatchService()) {
    folder.register(watcher, ENTRY_CREATE);
    // 이벤트 루프
}

9️⃣ 면접 질문 + 자기 점검

9.1 면접 단골 질문 매핑

Q핵심 답변
java.io.File 대신 NIO.2를 쓰는 이유?boolean 반환 한계 · 메타데이터 부족 · Stream 통합 · Symbolic Link · 다중 FS 지원
Path는 어떤 객체인가?불변, OS 독립, 경로 표현일 뿐 파일 아님
resolve vs resolveSibling vs relativize?자식 경로 · 형제 경로 · 두 경로의 상대 차
normalizetoRealPath의 차이?normalize는 문자열 연산, toRealPath는 파일시스템 확인 + 심볼릭 링크 해소
Files.lines가 자원인 이유?내부적으로 BufferedReader를 들고 있음. 닫지 않으면 파일 핸들 누수
walk vs find?walk는 Path만, find는 (Path, BasicFileAttributes) — 속성 필터 시 더 효율
WatchService 한계?재귀 감시 안 됨, macOS polling 기반
Path Traversal 방어?normalize() + startsWith(baseDir) 검증
Path.of vs Paths.get?기능 동일. Java 11+는 Path.of 권장
ATOMIC_MOVE 조건?같은 파일시스템 안에서만 가능 (rename 시스템 콜)

9.2 자기 점검 체크리스트

기본 이해

  • java.io.File의 6가지 한계를 설명할 수 있다
  • Path · Paths · Files의 역할을 설명할 수 있다
  • Path가 불변이라는 의미를 설명할 수 있다
  • normalizetoRealPath의 차이를 안다

실전 적용

  • resolve, resolveSibling, relativize를 상황에 맞게 쓸 수 있다
  • Files.list, walk, find를 시나리오에 맞게 선택할 수 있다
  • Stream 반환 메서드를 항상 try-with-resources로 감싼다
  • Path Traversal 방어 코드를 작성할 수 있다
  • WatchService로 디렉토리 감시 코드를 작성할 수 있다

면접 대비 — 5분 답변

  • NIO.2 등장 배경 (java.io.File의 한계)
  • Path 인터페이스의 불변성과 OS 독립성
  • Files의 정적 메서드 카테고리 (읽기/쓰기/메타데이터/조작/순회)
  • Stream 통합의 자원 관리
  • WatchService와 FileSystem의 활용 사례

🎯 핵심 요약 — 3줄 정리

1. NIO.2 = Path + Files + Stream 통합

  • Path: 불변 경로 객체, OS 독립
  • Files: 50+ 정적 메서드의 폭격기
  • Stream API와 통합 — list, walk, find, lines

2. Files의 Stream 반환 메서드는 자원

  • Files.lines, list, walk, find, newDirectoryStream
  • 모두 try-with-resources 필수 (Unit 7.1 연결)
  • 누락 시 "Too many open files" 운영 장애

3. ILIC 전 영역에서 사용

  • 인보이스 아카이브 (find + relativize + ATOMIC_MOVE)
  • 화물 사진 업로드 (createDirectories + safeFilename)
  • 정산 파일 자동 처리 (WatchService + Virtual Thread)
  • Path Traversal 방어 (normalize + startsWith)

📚 다음으로...

Unit 7.3 (예고)

  • Charset과 인코딩 — UTF-8, CP949, BOM 처리
  • 또는: 예외 처리 심화 (Checked vs Unchecked, 도메인 예외 설계)
  • 정확한 주제는 커리큘럼 문서 확인 필요

추가 학습 자료

  • JEP 4: NIO.2 (Java 7) — 원래 명세
  • Java Tutorials · File I/O (Featuring NIO.2)
  • Effective Java 3rd Edition · Item 88 (readObject — 파일 관련 직렬화 보안)
  • Path Traversal CWE-22 — OWASP Top 10 관련

1주차 진행 상황

  • ✅ Unit 6.4 HashMap 내부 구조
  • ✅ Unit 7.1 try-with-resources
  • ✅ Unit 7.2 NIO.2 (Files, Path) — 이 문서
  • ⏭ Unit 7.3 (다음)
  • ⏭ Unit 7.4 (Phase 7 마무리)
profile
Software Developer

0개의 댓글