본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
24장 : Path·Files와 파일 영속화

Path와 Files 심화

ch24-1에서 배운 Path와 Files를 바탕으로 정규화, 심볼릭 링크, 파일 속성, 효율적 복사와 원자 교체를 다룹니다.

ch24-1에서는 상대 Path를 만들고 Files.writeStringreadString으로 작은 UTF-8 파일을 다뤘습니다. 여기서는 같은 API를 운영 환경에서 안전하게 사용하기 위한 경로 정규화, 링크, 속성, 복사와 원자 교체를 살펴봅니다. 과거 File은 경로와 작업 메서드를 한 객체에 모았지만 실패 원인이 boolean으로만 보이는 경우가 많았습니다. 새 코드는 예외와 옵션이 명시적인 NIO API를 우선할 수 있습니다.

Path 객체를 만들었다고 실제 파일이 생기지는 않습니다. 상대 경로는 프로세스 작업 디렉터리를 기준으로 해석되며 ...을 포함할 수 있습니다. 사용자 입력 경로를 작업 공간 아래로 제한할 때 문자열 접두사 비교만 하면 우회가 생깁니다.

경로 정규화 순서

workspace.resolve("reports/../../secret.txt")는 표면상 workspace로 시작하지만 계산을 끝내면 상위 디렉터리를 가리킬 수 있습니다. 경로 문자열의 startsWith와 파일 시스템 경로의 Path.startsWith를 혼동해도 우회가 생깁니다.

bad/UncheckedResolvedPath.java
import java.nio.file.Path;

public final class UncheckedResolvedPath {
    public static void main(String[] args) {
        Path workspace = Path.of("safe-workspace").toAbsolutePath();
        String userInput = "reports/../../outside.txt";
        Path unresolved = workspace.resolve(userInput);
        System.out.println("looksInside=" + unresolved.startsWith(workspace));
        System.out.println("normalized=" + unresolved.normalize());
        System.out.println("reallyInside=" + unresolved.normalize().startsWith(workspace));
    }
}

정규화는 ...을 문법적으로 계산하지만 심볼릭 링크가 가리키는 실제 위치까지 확인하지 않습니다. 기존 파일을 열 때는 toRealPath 결과를 허용 루트의 real 경로와 비교합니다. 새 파일 생성에서는 부모 디렉터리의 real 경로를 확인하고 링크 교체 경쟁까지 위협 모델에 포함해야 합니다.

작업 공간 내부 경로 제한

경로 검사 함수는 기준을 절대 정규 경로로 만들고 사용자 조각을 resolve한 뒤 다시 정규화합니다. 절대 경로 입력을 허용할지, 빈 경로와 예약 이름을 어떻게 다룰지도 API 계약으로 정합니다. 아래 함수는 문법적 탈출을 막는 기본 단계입니다.

src/WorkspacePath.java
import java.nio.file.Files;
import java.nio.file.Path;

public final class WorkspacePath {
    static Path resolveInside(Path root, String relative) {
        Path base = root.toAbsolutePath().normalize();
        Path candidate = base.resolve(relative).normalize();
        if (!candidate.startsWith(base)) throw new IllegalArgumentException("path escapes root");
        return candidate;
    }

    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("workspace-");
        try {
            Path report = resolveInside(root, "reports/2026.txt");
            Files.createDirectories(report.getParent());
            Files.writeString(report, "ready");
            System.out.println(report.startsWith(root.toAbsolutePath()));
            try {
                resolveInside(root, "../outside.txt");
            } catch (IllegalArgumentException expected) {
                System.out.println("escape rejected");
            }
            Files.deleteIfExists(report);
            Files.deleteIfExists(report.getParent());
        } finally {
            Files.deleteIfExists(root);
        }
    }
}

실제 업로드 서비스는 서버가 생성한 무작위 파일명을 사용하고 원래 이름은 메타데이터로만 저장하는 편이 안전합니다. 경로 검증 하나에 보안 전체를 의존하지 않습니다. 파일 권한, 링크 금지, 저장 용량, 확장자와 실제 형식 검사도 함께 적용합니다.

Files 작업의 예외·옵션 해석

Files.createFile은 이미 존재하면 FileAlreadyExistsException을 던집니다. 존재 확인 뒤 생성하는 검사-행동 조합은 경쟁할 수 있으므로 예외 자체를 결과로 처리합니다. 디렉터리 계층은 createDirectories가 이미 존재하는 부모를 허용해 반복 실행에 적합합니다.

src/Utf8FilesWorkflow.java
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;

public final class Utf8FilesWorkflow {
    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("files-flow-");
        Path file = root.resolve("notes/today.txt");
        try {
            Files.createDirectories(file.getParent());
            try {
                Files.createFile(file);
            } catch (FileAlreadyExistsException ignored) {
                System.out.println("reusing existing file");
            }
            Files.write(file, List.of("첫 줄", "second"), StandardCharsets.UTF_8,
                    StandardOpenOption.TRUNCATE_EXISTING);
            System.out.println("size=" + Files.size(file));
            System.out.println(Files.readAllLines(file, StandardCharsets.UTF_8));
        } finally {
            Files.deleteIfExists(file);
            Files.deleteIfExists(file.getParent());
            Files.deleteIfExists(root);
        }
    }
}

기본 write 옵션이 생성과 덮어쓰기를 어떻게 처리하는지 API 문서를 확인하고, 추가가 필요한 로그와 전체 교체 문서를 구분합니다. 민감 파일은 생성 권한을 명시하고 다른 사용자가 읽을 수 없는지 배포 운영체제에서 확인합니다.

File·Path·Files 책임 구분

java.io.File은 오래된 API와의 호환을 위해 경로 표현과 일부 파일 작업을 한 객체에 함께 제공합니다. Path는 경로 자체를 표현하고, Files는 생성·이동·삭제·속성 조회 같은 작업을 정적 메서드로 수행합니다. 새 코드는 PathFiles를 기본으로 사용하고, File을 요구하는 기존 라이브러리 접점에서만 path.toFile()file.toPath()로 변환합니다.

여러 속성이 필요할 때 Files.size, isDirectory, getLastModifiedTime을 각각 호출하면 조회 사이에 파일 상태가 바뀔 수 있고 시스템 호출도 늘어납니다. Files.readAttributes(path, BasicFileAttributes.class)는 기본 속성을 한 묶음으로 읽어 파일 종류, 크기, 생성·수정 시각을 같은 조회 결과에서 다루게 합니다.

src/FileToPathBridge.java
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;

public final class FileToPathBridge {
    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("file-bridge-");
        Path path = root.resolve("note.txt");
        try {
            Files.writeString(path, "study", StandardCharsets.UTF_8);

            File legacyFile = path.toFile();
            Path restored = legacyFile.toPath();
            BasicFileAttributes attributes = Files.readAttributes(
                    restored, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);

            System.out.println("name=" + restored.getFileName());
            System.out.println("regular=" + attributes.isRegularFile());
            System.out.println("size=" + attributes.size());
        } finally {
            Files.deleteIfExists(path);
            Files.deleteIfExists(root);
        }
    }
}
name=note.txt
regular=true
size=5

File.exists()로 확인한 뒤 생성하는 두 단계 코드는 검사와 실행 사이에 다른 프로세스가 끼어들 수 있습니다. Files.createFile, move, delete를 바로 실행하고 구체적인 예외를 결과로 처리합니다. BasicFileAttributes도 읽은 순간의 스냅샷일 뿐 이후 상태를 잠그지 않으며, 생성 시각 지원과 정밀도는 파일 시스템마다 다릅니다. 링크를 따라가도 되는지 먼저 정하고, 보안 판단에서는 NOFOLLOW_LINKStoRealPath의 의미를 구분합니다.

Files.lines의 자원 범위

readStringreadAllLines는 전체 내용을 메모리에 보관합니다. 로그 통계처럼 각 줄을 한 번만 처리한다면 Files.lines가 지연 스트림을 제공해 메모리를 제한합니다. 이 Stream은 열린 파일을 보유하므로 try-with-resources 안에서 최종 연산까지 끝냅니다.

src/StreamingLineStats.java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public final class StreamingLineStats {
    record Stats(long lines, long nonBlank, long codePoints) {}

    static Stats analyze(Path file) throws Exception {
        try (var lines = Files.lines(file, StandardCharsets.UTF_8)) {
            long[] values = lines.map(line -> new long[] {
                    1,
                    line.isBlank() ? 0 : 1,
                    line.codePointCount(0, line.length())
            }).reduce(new long[3], (left, right) -> new long[] {
                    left[0] + right[0], left[1] + right[1], left[2] + right[2]
            });
            return new Stats(values[0], values[1], values[2]);
        }
    }

    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("line-stats-", ".txt");
        try {
            Files.writeString(file, "alpha\n\n한글😀\n", StandardCharsets.UTF_8);
            System.out.println(analyze(file));
        } finally {
            Files.deleteIfExists(file);
        }
    }
}

스트림 파이프라인이 지나치게 복잡해지면 반복문이 더 읽기 쉽고 중간 배열 할당도 줄어듭니다. 집계 항목이 늘거나 행별 오류 위치를 기록해야 한다면 명시적 루프로 바꿔 제어 흐름을 드러내는 편이 낫습니다.

파일 복사 API 선택

단순 복사는 Files.copy(source, target, REPLACE_EXISTING)가 의도를 가장 잘 표현하며 구현이 운영체제 최적화를 활용할 수 있습니다. 스트림 transferTo는 열린 입출력 사이 복사에 적합합니다. 내용 변환이나 해시 진행률이 있으면 직접 청크를 사용합니다.

대상을 직접 덮어쓰면 실패 중 부분 파일이 보일 수 있습니다. 임시 파일에 복사하고 크기 또는 해시를 확인한 뒤 원자 이동을 시도합니다. 파일이 이미 존재할 때 덮을지 실패할지, 속성과 권한도 복사할지, 심볼릭 링크를 따라갈지 옵션을 명시합니다.

Path 선택 기준

작업API핵심 확인
경로 조합resolve와 normalize허용 루트
실제 위치toRealPath심볼릭 링크
부모 생성createDirectories권한
작은 텍스트readString/writeStringCharset과 상한
큰 줄 처리Files.줄Stream close
단순 복사Files.copy교체 옵션

연습 문제

원본을 대상 디렉터리의 임시 파일로 복사하고 크기가 같을 때만 최종 이름으로 이동하세요. 실패하면 임시 파일을 삭제하며 기존 대상은 유지합니다. 같은 파일 시스템에서 원자 이동을 우선 시도합니다.

정답과 해설
exercise/VerifiedAtomicCopySolution.java
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;

public final class VerifiedAtomicCopySolution {
    static void copy(Path source, Path target) throws Exception {
        Path parent = target.toAbsolutePath().getParent();
        Files.createDirectories(parent);
        Path temp = Files.createTempFile(parent, ".copy-", ".tmp");
        boolean complete = false;
        try {
            Files.copy(source, temp, StandardCopyOption.REPLACE_EXISTING);
            if (Files.size(source) != Files.size(temp)) throw new IllegalStateException("size mismatch");
            try {
                Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE,
                        StandardCopyOption.REPLACE_EXISTING);
            } catch (AtomicMoveNotSupportedException unsupported) {
                Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING);
            }
            complete = true;
        } finally {
            if (!complete) Files.deleteIfExists(temp);
        }
    }

    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("verified-copy-");
        Path source = root.resolve("source.bin");
        Path target = root.resolve("target.bin");
        try {
            Files.write(source, new byte[] {1, 2, 3});
            copy(source, target);
            System.out.println(Files.size(target));
        } finally {
            Files.deleteIfExists(source);
            Files.deleteIfExists(target);
            Files.deleteIfExists(root);
        }
    }
}

크기 검사는 우발적인 짧은 복사를 찾지만 같은 크기의 내용 손상은 찾지 못합니다. 중요한 파일은 해시를 비교하고 권한과 속성 복사 요구도 추가합니다. 대체 이동을 허용할지 실패시킬지는 애플리케이션의 원자성 요구에 따라 결정합니다.

안전한 파일 작업을 입증하는 조건

Path와 Files를 안전하게 쓰려면 경로를 문자열이 아닌 파일 시스템 값으로 확인하고, 작업별 실패와 옵션을 코드에 드러냅니다. 정규화·실제 경로·Charset·자원 범위·임시 교체를 함께 적용하면 편의 API를 사용하면서도 경로 탈출과 부분 파일을 막을 수 있습니다.