안동민 개발노트

본문 시작

Path와 Files 심화

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

ch24-1에서는 상대 Path를 만들고 Files.writeString과 readString으로 작은 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);
        }
    }
}
정규화한 경로 포함 검사는 실제 파일 위치 검사와 다르다

WorkspacePath.resolveInside의 Path.startsWith 검사와 수행하지 않는 파일 시스템 검사를 구분합니다.

정규화한 경로 포함 검사는 실제 파일 위치 검사와 다르다
입력 또는 조건resolveInside의 판단남는 경계
reports/2026.txt정규화한 경로가 base 내부이므로 반환파일 생성은 함수 바깥에서 수행
../outside.txtbase 밖이므로 IllegalArgumentException문자열 접두사 대신 Path 요소를 비교
루트 내부의 절대 경로정규화 뒤 base 내부이면 반환 가능relative 인자 이름만으로 절대 입력을 금지하지 않음
내부 경로가 심볼릭 링크를 경유문법상 내부이면 이 검사만으로 거부하지 않음toRealPath·부모 확인·링크 교체 경쟁은 별도 검토
reports/2026.txt
resolveInside의 판단: 정규화한 경로가 base 내부이므로 반환
남는 경계: 파일 생성은 함수 바깥에서 수행
../outside.txt
resolveInside의 판단: base 밖이므로 IllegalArgumentException
남는 경계: 문자열 접두사 대신 Path 요소를 비교
루트 내부의 절대 경로
resolveInside의 판단: 정규화 뒤 base 내부이면 반환 가능
남는 경계: relative 인자 이름만으로 절대 입력을 금지하지 않음
내부 경로가 심볼릭 링크를 경유
resolveInside의 판단: 문법상 내부이면 이 검사만으로 거부하지 않음
남는 경계: toRealPath·부모 확인·링크 교체 경쟁은 별도 검토

이 함수는 실제 파일을 조회하지 않습니다. main에는 정상 상대 경로와 ../ 탈출 입력이 있으며, 절대 경로·심볼릭 링크 사례를 실행하는 코드는 없습니다.

실제 업로드 서비스는 서버가 생성한 무작위 파일명을 사용하고 원래 이름은 메타데이터로만 저장하는 편이 안전합니다.

경로 검증 하나에 보안 전체를 의존하지 않습니다.

파일 권한, 링크 금지, 저장 용량, 확장자와 실제 형식 검사도 함께 적용합니다.


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는 생성·이동·삭제·속성 조회 같은 작업을 정적 메서드로 수행합니다.

새 코드는 Path와 Files를 기본으로 사용하고, 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, "board", 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_LINKS와 toRealPath의 의미를 구분합니다.


Files.lines의 자원 범위

readString과 readAllLines는 전체 내용을 메모리에 보관합니다.

로그 통계처럼 각 줄을 한 번만 처리한다면 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.linesStream 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);
        }
    }
}
크기 확인과 원자 이동, 대체 이동의 보장은 다르다

VerifiedAtomicCopySolution.copy의 준비·이동·정리 경로를 구분합니다.

크기 확인과 원자 이동, 대체 이동의 보장은 다르다
코드 경로원문의 처리보장하지 않는 것
임시 파일 복사와 크기 확인source와 temp의 size가 같아야 이동같은 크기의 내용 손상·복사 중 원본 변경 검출
ATOMIC_MOVE 시도원자 이동을 요청기존 대상 교체 여부는 구현 의존 · 다른 이동 옵션은 무시됨
원자 이동 미지원REPLACE_EXISTING으로 일반 이동이동 중 실패하면 기존 대상 보존·완성 상태 보장 없음
complete가 false인 finally남은 임시 파일 삭제를 시도삭제 실패도 예외이며 원래 실패를 가릴 수 있음
임시 파일 복사와 크기 확인
원문의 처리: source와 temp의 size가 같아야 이동
보장하지 않는 것: 같은 크기의 내용 손상·복사 중 원본 변경 검출
ATOMIC_MOVE 시도
원문의 처리: 원자 이동을 요청
보장하지 않는 것: 기존 대상 교체 여부는 구현 의존 · 다른 이동 옵션은 무시됨
원자 이동 미지원
원문의 처리: REPLACE_EXISTING으로 일반 이동
보장하지 않는 것: 이동 중 실패하면 기존 대상 보존·완성 상태 보장 없음
complete가 false인 finally
원문의 처리: 남은 임시 파일 삭제를 시도
보장하지 않는 것: 삭제 실패도 예외이며 원래 실패를 가릴 수 있음

main은 새 대상에 3바이트를 복사하는 입력입니다. 기존 대상 교체·원자 이동 미지원·이동 또는 정리 실패를 재현하는 입력은 없습니다.

중요한 파일은 해시를 비교하고 권한과 속성 복사 요구도 추가합니다.

대체 이동을 허용할지 실패시킬지는 애플리케이션의 원자성 요구에 따라 결정합니다.


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

Path와 Files를 안전하게 쓰려면 경로를 문자열이 아닌 파일 시스템 값으로 확인하고, 작업별 실패와 옵션을 코드에 드러냅니다.

문법적 경로 검사와 실제 위치 확인을 구분하고, Charset·자원 범위·교체 방식과 실패 처리 정책을 파일 시스템의 보장에 맞춰 선택합니다.