경로 정규화와 안전한 파일 복사·교체
경로를 문자열이 아니라 Path 요소로 검사하고, Files의 예외와 옵션을 읽으며, 임시 파일과 원자적 이동으로 파일을 안전하게 복사하고 교체하는 방법을 익힙니다.
Path와 Files로 텍스트 파일을 다룬 글에서는 상대 Path를 만들고 Files.writeString과 readString으로 작은 UTF-8 파일을 읽고 썼습니다. 학습용 프로그램은 그 정도로 충분하지만, 서버처럼 다른 사람이 보낸 입력으로 파일을 다루는 프로그램은 몇 가지를 더 따져야 합니다. 사용자가 보낸 경로가 정해진 폴더 밖을 가리키지는 않는지, 같은 이름의 파일이 이미 있으면 어떻게 할지, 복사하다 멈추면 반쯤 쓴 파일이 남지 않는지 같은 것들입니다. 이 글은 같은 Path·Files API로 이런 상황을 다루는 방법을 차례로 봅니다.
먼저 짚어 둘 점이 있습니다. Path 객체를 만들었다고 실제 파일이 생기지는 않습니다. Path는 위치를 나타내는 값일 뿐이고, 파일을 만들거나 읽는 일은 Files의 메서드가 합니다. 또 상대 경로는 프로세스의 작업 디렉터리를 기준으로 해석되며, 그 안에 현재 디렉터리를 뜻하는 .과 상위 디렉터리를 뜻하는 ..이 들어 있을 수 있습니다. 바로 이 .. 때문에, 경로를 문자열처럼 다루면 생각지 못한 곳으로 빠져나가는 일이 생깁니다.
..이 섞인 경로는 정규화한 뒤에 검사하기
사용자가 보낸 경로 조각을 작업 폴더 뒤에 붙인 다음, 결과가 작업 폴더로 시작하는지 확인하는 코드를 생각해 봅시다. workspace.resolve("reports/../../outside.txt")는 겉보기에는 작업 폴더 아래 경로입니다. 하지만 .. 두 개가 reports와 작업 폴더를 차례로 되돌리므로, 실제로 가리키는 곳은 작업 폴더의 상위 디렉터리입니다. 다음 프로그램으로 정규화 전후의 검사 결과를 비교해 봅니다.
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));
}
}작업 디렉터리가 /home/you일 때의 출력은 다음과 같습니다. 가운데 줄의 앞부분은 프로그램을 실행한 디렉터리의 절대 경로라서 컴퓨터마다 다릅니다.
looksInside=true
normalized=/home/you/outside.txt
reallyInside=falsePath.startsWith는 문자열이 아니라 경로 요소 단위로 비교합니다. 정규화하기 전의 경로는 /home/you/safe-workspace/reports/../../outside.txt라서 앞쪽 요소가 작업 폴더와 똑같고, 그래서 첫 줄이 true입니다. normalize()가 ..을 바로 앞 요소와 하나씩 상쇄해 지우고 나면 reports와 safe-workspace가 모두 사라지고, 같은 검사가 셋째 줄에서 false로 바뀝니다. 그러니 포함 여부는 반드시 정규화한 뒤에 검사해야 합니다.
문자열의 startsWith로 검사하면 또 다른 구멍이 생깁니다. 작업 폴더가 /srv/app/data일 때 /srv/app/data-backup/x.txt는 문자열로는 /srv/app/data로 시작하지만, 경로 요소로 보면 data와 data-backup은 다른 디렉터리입니다. Path.startsWith는 요소 단위로 비교하므로 이 경우 false를 돌려주지만, String.startsWith는 true를 돌려줍니다.
정규화에도 한계가 있습니다. normalize()는 .과 ..을 글자 그대로 계산할 뿐 파일 시스템을 조회하지 않으므로, 경로 중간의 심볼릭 링크가 실제로 어디를 가리키는지는 알지 못합니다. 이미 있는 파일을 열 때는 toRealPath()로 링크까지 따라간 실제 경로를 구해 허용 루트의 실제 경로와 비교합니다. 새 파일을 만들 때는 아직 파일이 없어 toRealPath()가 NoSuchFileException을 던지므로, 부모 디렉터리의 실제 경로를 확인합니다. 이 부분은 다음 절의 '더 깊이'에서 직접 실행해 봅니다.
작업 폴더 밖으로 나가는 경로 막기
이제 앞의 순서를 함수 하나로 묶습니다. 기준 폴더를 절대 경로로 바꿔 정규화하고, 사용자 조각을 resolve한 결과를 다시 정규화한 뒤, Path.startsWith로 포함 여부를 확인합니다. 이 함수는 경로의 글자만 보고 판단하는 첫 단계입니다.
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);
}
}
}true
escape rejected첫 줄은 reports/2026.txt가 임시 작업 폴더 안의 경로로 풀려, 부모 디렉터리를 만들고 파일을 쓴 뒤 그 경로가 작업 폴더로 시작하는지 확인한 결과입니다. 둘째 줄은 ../outside.txt가 정규화 뒤 작업 폴더의 상위를 가리켜 예외로 거부된 결과입니다. 만든 파일과 디렉터리는 안쪽부터 지우고, 작업 폴더는 finally에서 지웁니다.
이 함수가 받아들이는 입력을 몇 가지 더 따져 보면 한계가 보입니다. reports/../ok.txt는 reports를 되돌려 작업 폴더 바로 아래의 ok.txt가 되므로 그대로 돌려줍니다. 빈 문자열은 작업 폴더 자체를 돌려줍니다. 작업 폴더 안을 가리키는 절대 경로도 거부하지 않는데, resolve에 절대 경로를 넘기면 기준 경로를 무시하고 그 절대 경로를 그대로 돌려주기 때문입니다. 인자 이름이 relative라고 해서 절대 경로가 막히는 것은 아니므로, 새 파일 하나만 받아야 하는 곳이라면 빈 입력과 절대 경로, 디렉터리를 허용할지 따로 정해야 합니다.
WorkspacePath.resolveInside의 Path.startsWith 검사와 수행하지 않는 파일 시스템 검사를 구분합니다.
경로 제한 · 문법과 실제 위치
정규화한 경로 포함 검사는 실제 파일 위치 검사와 다르다
normalize와 Path.startsWith는 문법적 포함을 확인하지만 심볼릭 링크의 실제 목적지는 조회하지 않습니다.
reports/2026.txt
- resolveInside의 판단
- 정규화한 경로가 base 내부이므로 반환
- 남는 경계
- 파일 생성은 함수 바깥에서 수행
../outside.txt
- resolveInside의 판단
- base 밖이므로 IllegalArgumentException
- 남는 경계
- 문자열 접두사 대신 Path 요소를 비교
루트 내부의 절대 경로
- resolveInside의 판단
- 정규화 뒤 base 내부이면 반환 가능
- 남는 경계
- relative 인자 이름만으로 절대 입력을 금지하지 않음
내부 경로가 심볼릭 링크를 경유
- resolveInside의 판단
- 문법상 내부이면 이 검사만으로 거부하지 않음
- 남는 경계
- toRealPath·부모 확인·링크 교체 경쟁은 별도 검토
이 함수는 실제 파일을 조회하지 않습니다. main에는 정상 상대 경로와 ../ 탈출 입력이 있으며, 절대 경로·심볼릭 링크 사례를 실행하는 코드는 없습니다.
실제 업로드 서비스는 사용자가 보낸 파일 이름을 경로로 쓰지 않고, 서버가 만든 무작위 이름으로 저장한 뒤 원래 이름은 메타데이터로만 보관하는 편이 안전합니다. 경로 검사 하나에 보안 전체를 맡기지 말고, 파일 권한과 링크 금지, 저장 용량 제한, 확장자와 실제 내용 형식 검사를 함께 적용합니다.
더 깊이: 심볼릭 링크까지 따라가 확인하기
resolveInside는 경로 중간에 있는 심볼릭 링크를 알아채지 못합니다. 아래 프로그램은 작업 폴더 안에 바깥 디렉터리를 가리키는 링크 shared를 만들고, 글자만 보는 검사와 toRealPath()로 실제 위치를 보는 검사를 비교합니다.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public final class RealPathCheck {
static Path resolveExistingInside(Path root, String relative) throws IOException {
Path realRoot = root.toRealPath();
Path real = realRoot.resolve(relative).normalize().toRealPath();
if (!real.startsWith(realRoot)) throw new IllegalArgumentException("path escapes root");
return real;
}
public static void main(String[] args) throws Exception {
Path root = Files.createTempDirectory("workspace-");
Path outside = Files.createTempDirectory("outside-");
Path note = outside.resolve("note.txt");
Path link = root.resolve("shared");
try {
Files.writeString(note, "outside");
Files.createSymbolicLink(link, outside);
Path base = root.toAbsolutePath().normalize();
Path lexical = base.resolve("shared/note.txt").normalize();
System.out.println("lexicalInside=" + lexical.startsWith(base));
try {
resolveExistingInside(root, "shared/note.txt");
} catch (IllegalArgumentException rejected) {
System.out.println("realPath: " + rejected.getMessage());
}
} finally {
Files.deleteIfExists(link);
Files.deleteIfExists(note);
Files.deleteIfExists(outside);
Files.deleteIfExists(root);
}
}
}lexicalInside=true
realPath: path escapes root글자로 보면 shared/note.txt는 작업 폴더 안에 있으므로 첫 줄은 true입니다. 그러나 toRealPath()는 링크를 따라가 바깥 디렉터리의 note.txt라는 실제 위치를 돌려주므로 둘째 검사에서 거부됩니다. 허용 루트도 toRealPath()로 바꿔 비교하는 데는 이유가 있습니다. macOS에서는 임시 디렉터리가 있는 /var가 /private/var를 가리키는 링크라서, 한쪽만 실제 경로로 바꾸면 정상 파일까지 거부됩니다. 또 Windows에서 심볼릭 링크를 만들려면 관리자 권한이나 개발자 모드가 필요해, 그렇지 않으면 createSymbolicLink가 FileSystemException을 던집니다. 마지막으로 이 검사를 통과한 뒤 실제로 파일을 여는 사이에 누군가 링크를 바꿔치기할 수 있다는 점은 이 방법으로도 막지 못합니다. 앞에서 서버가 만든 이름을 쓰라고 한 이유가 여기에 있습니다.
Files 작업의 예외와 옵션 읽기
Files의 메서드는 실패하면 false를 돌려주는 대신 이유가 담긴 예외를 던집니다. 예를 들어 Files.createFile은 같은 이름의 파일이 이미 있으면 FileAlreadyExistsException을 던집니다. 그래서 '파일이 있는지 먼저 확인하고, 없으면 만든다'처럼 두 단계로 나누지 말고, 바로 만들어 보고 그 예외를 '이미 있음'이라는 결과로 처리합니다. 확인과 생성 사이에 다른 프로세스가 같은 파일을 만들 수 있기 때문입니다. 반면 createDirectories는 이미 있는 디렉터리를 정상으로 받아들이므로 여러 번 실행해도 같은 결과가 나옵니다.
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);
}
}
}Linux에서 실행한 결과는 다음과 같습니다. size=는 파일의 바이트 수이고 다음 줄은 다시 읽은 줄 목록인데, 줄 구분자가 운영체제마다 달라 바이트 수도 달라지므로 실행한 운영체제를 밝혀 두었습니다.
size=15
[첫 줄, second]새로 만든 임시 폴더라 createFile이 성공하므로 reusing existing file은 출력되지 않습니다. 같은 경로에 createFile을 한 번 더 부르면 그때 예외가 나고 이 메시지가 찍힙니다. Files.write는 목록의 각 요소 뒤에 운영체제의 줄 구분자를 붙여 씁니다. UTF-8에서 첫 줄은 한글 두 글자가 3바이트씩, 공백이 1바이트라 7바이트이고, 여기에 \n 1바이트가 붙어 8바이트입니다. second는 6바이트에 \n이 붙어 7바이트이므로 합이 15입니다. 줄 구분자가 \r\n인 Windows에서는 같은 코드가 size=17을 출력합니다.
옵션에도 주의할 점이 있습니다. Files.write에 옵션을 하나도 주지 않으면 CREATE, TRUNCATE_EXISTING, WRITE를 준 것처럼 동작해, 파일이 없으면 만들고 있으면 내용을 비웁니다. 그런데 이 코드처럼 옵션을 하나라도 직접 주면 그 기본값은 적용되지 않습니다. TRUNCATE_EXISTING만 준 이 호출은 파일이 없으면 NoSuchFileException을 던지므로, 앞에서 createFile로 파일을 만들어 둔 덕분에 동작하는 것입니다. 로그처럼 뒤에 이어 붙여야 하는 파일에는 APPEND를 주고, 전체를 바꾸는 문서에는 기본값이나 Path와 Files로 텍스트 파일을 다룬 글의 임시 파일 교체를 씁니다.
민감한 파일은 만들 때부터 권한을 정하고, 다른 사용자가 읽을 수 없는지 배포할 운영체제에서 확인합니다. POSIX 시스템이라면 PosixFilePermissions.asFileAttribute로 만든 속성을 createFile에 넘겨, 처음부터 소유자만 읽고 쓰게 만들 수 있습니다.
File과 Path·Files가 하는 일
java.io.File은 경로 표현과 파일 작업을 한 객체에 모은 오래된 API입니다. 문제는 실패를 알리는 방식인데, 예를 들어 File.delete()나 mkdir()는 실패하면 false만 돌려주므로 권한이 없어서인지 파일이 없어서인지 알 수 없습니다. 새 API는 역할을 나눴습니다. Path는 경로 자체만 나타내고, Files는 생성·이동·삭제·속성 조회 같은 작업을 정적 메서드로 수행하며 실패하면 구체적인 예외를 던집니다. 그래서 새 코드는 Path와 Files를 기본으로 쓰고, File을 요구하는 기존 라이브러리와 만나는 곳에서만 path.toFile()과 file.toPath()로 바꿉니다.
속성을 여러 개 읽을 때도 차이가 있습니다. Files.size, isDirectory, getLastModifiedTime을 따로 부르면 호출 사이에 파일 상태가 바뀔 수 있고 시스템 호출도 늘어납니다. Files.readAttributes(path, BasicFileAttributes.class)는 기본 속성을 한 번에 읽어, 파일 종류와 크기, 생성·수정 시각을 같은 조회 결과에서 다루게 해 줍니다.
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=5Path를 File로 바꿨다가 다시 Path로 돌려도 같은 파일을 가리키므로 이름이 그대로 나옵니다. board는 UTF-8로 5바이트라 크기는 5입니다. NOFOLLOW_LINKS를 주었기 때문에, 만약 note.txt가 심볼릭 링크였다면 링크가 가리키는 대상이 아니라 링크 자체의 속성을 읽어 isRegularFile()이 false가 되었을 것입니다.
다만 이렇게 묶어 읽은 속성이 하나의 원자적 조회로 얻어지는지는 구현에 따라 다릅니다. BasicFileAttributes는 읽은 순간의 값일 뿐 이후의 파일 상태를 잠그지 않습니다. 생성 시각을 지원하는지와 그 정밀도도 파일 시스템마다 달라서, 지원하지 않는 곳에서는 creationTime()이 마지막 수정 시각이나 1970-01-01 같은 구현별 기본값을 돌려줍니다. 보안 판단에서는 링크를 따라가도 되는지부터 정하고, 링크 자체를 보는 NOFOLLOW_LINKS와 링크 끝의 실제 위치를 구하는 toRealPath()를 구분해 씁니다.
Files.lines로 큰 파일을 한 줄씩 처리하기
readString과 readAllLines는 파일 내용을 한꺼번에 메모리에 올립니다. 로그 통계처럼 각 줄을 한 번씩만 보면 되는 작업이라면, Files.lines로 전체 줄 목록을 만들지 않고 한 줄씩 흘려보내며 처리할 수 있습니다. 다만 한 줄이 아주 길거나 파이프라인이 결과를 계속 쌓는다면 메모리 사용은 따로 따져야 합니다. 중요한 점은 이 Stream이 열린 파일을 붙잡고 있다는 것입니다. try-with-resources 안에서 최종 연산까지 끝내야 블록을 나갈 때 파일이 닫힙니다.
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);
}
}
}Stats[lines=3, nonBlank=2, codePoints=8]파일 내용은 alpha, 빈 줄, 한글😀 세 줄입니다. 마지막 \n은 셋째 줄을 끝낼 뿐 넷째 빈 줄을 만들지 않으므로 lines는 3이고, 빈 줄을 뺀 nonBlank는 2입니다. 코드 포인트는 alpha의 5개와 한글😀의 3개를 더해 8개입니다. 자바 문자열에서 이모지 😀는 char 두 개(서로게이트 쌍)로 저장되어 "한글😀".length()는 4이지만, codePointCount는 이를 문자 하나로 셉니다.
스트림 파이프라인이 이보다 복잡해지면 반복문이 더 읽기 쉽고, 줄마다 새 배열을 만드는 이 코드의 중간 할당도 줄일 수 있습니다. 집계 항목이 늘거나 몇 번째 줄에서 오류가 났는지 기록해야 한다면 명시적인 반복문으로 바꿔 흐름을 드러내는 편이 낫습니다.
직접 확인: CRLF와 공백만 있는 줄
analyze 함수에 UTF-8 파일 내용 " A😀\r\n \r\n"을 준다면 lines·nonBlank·codePoints는 각각 얼마일까요? 줄 구분자 바이트나 이모지의 두 UTF-16 단위가 codePoints에 더해지는지도 설명해 보세요.
풀이
lines=2, nonBlank=1, codePoints=4입니다. Files.lines는 \r\n도 줄 구분자로 인식해 떼어 내므로, 줄은 공백·A·😀로 된 첫 줄과 공백 하나뿐인 둘째 줄 두 개입니다. 구분자 바이트는 어느 줄에도 들어가지 않습니다. isBlank()는 공백만 있는 둘째 줄을 빈 줄로 보므로 nonBlank는 1이지만, codePoints 계산에는 그 공백도 들어갑니다. 첫 줄은 코드 포인트 3개(이모지는 하나로 셈), 둘째 줄은 1개라서 합은 4입니다.
복사에 맞는 API 고르기
단순히 파일 하나를 복사할 때는 Files.copy(source, target, REPLACE_EXISTING)가 의도를 가장 잘 드러내고, 구현이 운영체제의 복사 최적화를 쓸 여지도 있습니다. 이미 열어 둔 입력 스트림과 출력 스트림 사이에서 옮길 때는 InputStream.transferTo가 맞습니다. 복사하면서 내용을 바꾸거나 해시를 계산하거나 진행률을 보여 줘야 한다면, 버퍼를 직접 두고 청크 단위로 읽고 씁니다.
어떤 방법이든 대상 파일에 직접 쓰면, 복사 도중 실패했을 때 반쯤 쓴 파일이 다른 프로그램에 보일 수 있습니다. 그래서 대상과 같은 디렉터리의 임시 파일에 먼저 복사하고, 크기나 해시를 확인한 뒤, 원자적 이동으로 최종 이름을 붙입니다. 같은 디렉터리에 만드는 이유는 원자적 이동이 대개 같은 파일 시스템 안에서만 가능하기 때문입니다. 실제로 이 글의 Linux 환경에서 ext4 디스크의 파일을 tmpfs인 /dev/shm으로 ATOMIC_MOVE 하자 AtomicMoveNotSupportedException이 났습니다. 대상이 이미 있을 때 덮을지 실패할지, 속성과 권한도 복사할지, 심볼릭 링크를 따라갈지도 옵션으로 분명히 적습니다.
권한도 확인해야 합니다. Files.createTempFile은 POSIX 시스템에서 소유자만 읽고 쓸 수 있는 rw------- 권한으로 파일을 만들고, 원자적 이동은 파일을 그대로 옮기므로 임시 파일에 직접 내용을 써서 옮기면 최종 파일도 이 권한을 갖습니다. 반면 아래 연습 문제처럼 Files.copy에 REPLACE_EXISTING을 주어 임시 파일 위에 복사하면 임시 파일이 원본의 권한을 따라갑니다. 이 글의 환경에서는 새 파일의 권한에서 지울 비트를 정하는 umask가 022였고, rw-r--r--인 원본을 복사한 임시 파일도 rw-r--r--이었습니다. umask 계산은 리눅스 권한 체계에서 다룹니다. 다른 사용자가 읽어야 하는 파일인지, 반대로 읽으면 안 되는 파일인지에 따라 이동 전에 권한을 맞춥니다.
작업별로 쓸 API
| 작업 | API | 확인할 것 |
|---|---|---|
| 경로 조합 | resolve와 normalize | 허용 루트 안인지 |
| 실제 위치 | toRealPath | 심볼릭 링크, 파일이 있어야 함 |
| 부모 생성 | createDirectories | 권한 |
| 작은 텍스트 | readString·writeString | Charset과 크기 상한 |
| 큰 파일 줄 처리 | Files.lines | Stream을 닫았는지 |
| 단순 복사 | Files.copy | 교체 옵션 |
| 안전한 교체 | 임시 파일과 Files.move | ATOMIC_MOVE 지원 여부 |
연습 문제
원본을 대상 디렉터리의 임시 파일로 복사하고, 크기가 같을 때만 최종 이름으로 옮기는 copy 함수를 작성하세요. 같은 파일 시스템에서 원자적 이동을 먼저 시도하고, 지원하지 않으면 일반 이동으로 대신합니다. 이동하기 전에 실패하면 임시 파일 삭제를 시도합니다. 일반 이동으로 대신하는 경우 이동이 실패하면 기존 대상이 그대로 남는다고 장담할 수 없는데, 그 이유도 설명해 보세요.
정답과 해설
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);
}
}
}3main은 3바이트짜리 원본을 아직 없는 대상으로 복사하고 대상의 크기를 출력합니다. copy는 대상의 부모 디렉터리에 임시 파일을 만들어 같은 파일 시스템에 두고, 복사한 뒤 크기를 비교합니다. 이동이 성공해야만 complete가 true가 되므로, 복사·검사·이동 중 어디서 실패하든 finally가 남은 임시 파일을 지웁니다.
크기 확인과 원자 이동, 대체 이동의 보장은 다르다
임시 복사본 생성, 크기 비교, 이동과 대체 경로, 실패 시 임시 파일 정리의 실행 순서입니다. 크기 일치가 내용 동일성의 증명이 아니라는 조건을 유지하며 공개 시점을 설명할 수 있습니다.
VerifiedAtomicCopySolution.copy의 준비·이동·정리 경로를 구분합니다.
파일 복사 · 검증과 이동 경계
크기 확인과 원자 이동, 대체 이동의 보장은 다르다
같은 크기는 같은 내용을 증명하지 않으며, 대체 이동과 정리 실패의 보장도 따로 봐야 합니다.
크기 검사는 이동 전에 내용 검증을 대신하지 못한다
임시 복사 뒤 크기 불일치는 이동하지 않는다. 크기 일치만으로 내용 손상이나 원본 변경을 검출할 수 없다.
원자 이동과 대체 이동의 다른 보장
AtomicMoveNotSupportedException인 때만 REPLACE_EXISTING으로 일반 이동한다. 원자 이동과 일반 이동 모두 성공하면 complete=true로 끝나 임시 파일 정리를 생략한다. 다른 원자 이동 실패 또는 일반 이동 실패는 complete=false로 finally에서 남은 임시 파일 삭제를 시도한다. 일반 이동에는 원자성이 없고 실패 시 대상 상태도 확정할 수 없으며 삭제 예외가 원 실패를 가릴 수 있다.
상태별 상세 해설
임시 파일 복사와 크기 확인
- 원문의 처리
- source와 temp의 size가 같아야 이동
- 보장하지 않는 것
- 같은 크기의 내용 손상·복사 중 원본 변경 검출
ATOMIC_MOVE 시도
- 원문의 처리
- 원자 이동을 요청
- 보장하지 않는 것
- 기존 대상 교체 여부는 구현 의존 · 다른 이동 옵션은 무시됨
원자 이동 미지원
- 원문의 처리
- REPLACE_EXISTING으로 일반 이동
- 보장하지 않는 것
- 이동 중 실패하면 기존 대상 보존·완성 상태 보장 없음
complete가 false인 finally
- 원문의 처리
- 남은 임시 파일 삭제를 시도
- 보장하지 않는 것
- 삭제 실패도 예외이며 원래 실패를 가릴 수 있음
main은 새 대상에 3바이트를 복사하는 입력입니다. 기존 대상 교체·원자 이동 미지원·이동 또는 정리 실패를 재현하는 입력은 없습니다.
이 예의 조건
- main은 새 대상의 3바이트 복사이며 실패·대체 이동은 미재현 코드 경로입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 크기 확인과 원자 이동, 대체 이동의 보장은 다르다 크기 확인과 원자 이동, 대체 이동의 보장은 다르다 VerifiedAtomicCopySolution.copy의 준비·이동·정리 경로를 구분합니다. 파일 복사 · 검증과 이동 경계 크기 확인과 원자 이동, 대체 이동의 보장은 다르다 같은 크기는 같은 내용을 증명하지 않으며, 대체 이동과 정리 실패의 보장도 따로 봐야 합니다. 임시 파일 복사와 크기 확인 원문의 처리 source와 temp의 size가 같아야 이동 보장하지 않는 것 같은 크기의 내용 손상·복사 중 원본 변경 검출 ATOMIC_MOVE 시도 원문의 처리 원자 이동을 요청 보장하지 않는 것 기존 대상 교체 여부는 구현 의존 · 다른 이동 옵션은 무시됨 원자 이동 미지원 원문의 처리 REPLACE_EXISTING으로 일반 이동 보장하지 않는 것 이동 중 실패하면 기존 대상 보존·완성 상태 보장 없음 complete가 false인 finally 원문의 처리 남은 임시 파일 삭제를 시도 보장하지 않는 것 삭제 실패도 예외이며 원래 실패를 가릴 수 있음 main은 새 대상에 3바이트를 복사하는 입력입니다. 기존 대상 교체·원자 이동 미지원·이동 또는 정리 실패를 재현하는 입력은 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 임시 복사 검증 source와 temp의 size가 같은지 확인합니다. |
|
|
| 이동 시도 ATOMIC_MOVE를 요청하고 미지원이면 일반 이동합니다. |
|
|
| 실패 뒤 정리 complete=false이면 남은 임시 파일 삭제를 시도합니다. |
|
|
크기 확인과 원자 이동, 대체 이동의 보장은 다르다
임시 복사본 생성, 크기 비교, 이동과 대체 경로, 실패 시 임시 파일 정리의 실행 순서입니다. 크기 일치가 내용 동일성의 증명이 아니라는 조건을 유지하며 공개 시점을 설명할 수 있습니다.
VerifiedAtomicCopySolution.copy의 준비·이동·정리 경로를 구분합니다.
파일 복사 · 검증과 이동 경계
크기 확인과 원자 이동, 대체 이동의 보장은 다르다
같은 크기는 같은 내용을 증명하지 않으며, 대체 이동과 정리 실패의 보장도 따로 봐야 합니다.
크기 검사는 이동 전에 내용 검증을 대신하지 못한다
임시 복사 뒤 크기 불일치는 이동하지 않는다. 크기 일치만으로 내용 손상이나 원본 변경을 검출할 수 없다.
원자 이동과 대체 이동의 다른 보장
AtomicMoveNotSupportedException인 때만 REPLACE_EXISTING으로 일반 이동한다. 원자 이동과 일반 이동 모두 성공하면 complete=true로 끝나 임시 파일 정리를 생략한다. 다른 원자 이동 실패 또는 일반 이동 실패는 complete=false로 finally에서 남은 임시 파일 삭제를 시도한다. 일반 이동에는 원자성이 없고 실패 시 대상 상태도 확정할 수 없으며 삭제 예외가 원 실패를 가릴 수 있다.
상태별 상세 해설
임시 파일 복사와 크기 확인
- 원문의 처리
- source와 temp의 size가 같아야 이동
- 보장하지 않는 것
- 같은 크기의 내용 손상·복사 중 원본 변경 검출
ATOMIC_MOVE 시도
- 원문의 처리
- 원자 이동을 요청
- 보장하지 않는 것
- 기존 대상 교체 여부는 구현 의존 · 다른 이동 옵션은 무시됨
원자 이동 미지원
- 원문의 처리
- REPLACE_EXISTING으로 일반 이동
- 보장하지 않는 것
- 이동 중 실패하면 기존 대상 보존·완성 상태 보장 없음
complete가 false인 finally
- 원문의 처리
- 남은 임시 파일 삭제를 시도
- 보장하지 않는 것
- 삭제 실패도 예외이며 원래 실패를 가릴 수 있음
main은 새 대상에 3바이트를 복사하는 입력입니다. 기존 대상 교체·원자 이동 미지원·이동 또는 정리 실패를 재현하는 입력은 없습니다.
이 예의 조건
- main은 새 대상의 3바이트 복사이며 실패·대체 이동은 미재현 코드 경로입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 크기 확인과 원자 이동, 대체 이동의 보장은 다르다 크기 확인과 원자 이동, 대체 이동의 보장은 다르다 VerifiedAtomicCopySolution.copy의 준비·이동·정리 경로를 구분합니다. 파일 복사 · 검증과 이동 경계 크기 확인과 원자 이동, 대체 이동의 보장은 다르다 같은 크기는 같은 내용을 증명하지 않으며, 대체 이동과 정리 실패의 보장도 따로 봐야 합니다. 임시 파일 복사와 크기 확인 원문의 처리 source와 temp의 size가 같아야 이동 보장하지 않는 것 같은 크기의 내용 손상·복사 중 원본 변경 검출 ATOMIC_MOVE 시도 원문의 처리 원자 이동을 요청 보장하지 않는 것 기존 대상 교체 여부는 구현 의존 · 다른 이동 옵션은 무시됨 원자 이동 미지원 원문의 처리 REPLACE_EXISTING으로 일반 이동 보장하지 않는 것 이동 중 실패하면 기존 대상 보존·완성 상태 보장 없음 complete가 false인 finally 원문의 처리 남은 임시 파일 삭제를 시도 보장하지 않는 것 삭제 실패도 예외이며 원래 실패를 가릴 수 있음 main은 새 대상에 3바이트를 복사하는 입력입니다. 기존 대상 교체·원자 이동 미지원·이동 또는 정리 실패를 재현하는 입력은 없습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 임시 복사 검증 source와 temp의 size가 같은지 확인합니다. |
|
|
| 이동 시도 ATOMIC_MOVE를 요청하고 미지원이면 일반 이동합니다. |
|
|
| 실패 뒤 정리 complete=false이면 남은 임시 파일 삭제를 시도합니다. |
|
|
크기 비교는 최소한의 검사일 뿐입니다. 원본 [1, 2, 3]과 손상된 임시 파일 [3, 2, 1]은 크기가 둘 다 3이라 이 검사를 통과합니다. 중요한 파일이라면 MessageDigest로 해시를 계산해 비교하고, 권한과 속성을 복사해야 하는지도 정합니다. 복사하는 동안 원본이 바뀌는 것까지 막아야 한다면 원본의 스냅샷이나 저장 시스템이 제공하는 일관성 기능이 필요합니다.
이동 부분은 Files.move의 공식 문서에 따라 읽어야 합니다. ATOMIC_MOVE를 주면 다른 옵션은 무시되므로 함께 준 REPLACE_EXISTING은 효과가 없고, 대상이 이미 있을 때 바꿀지 실패할지는 구현에 달려 있습니다. 이 글의 Linux 환경에서는 원자적 이동이 기존 대상을 새 파일로 바꿨습니다. 반면 원자적이지 않은 일반 이동 도중 IOException이 나면 파일 상태가 정의되지 않으며, 예를 들어 대상이 불완전하게 남을 수 있습니다. 그래서 일반 이동으로 대신하는 순간 기존 대상이 보존된다고 장담할 수 없습니다. finally의 삭제마저 실패하면 그 예외가 원래 실패를 가릴 수 있다는 점도 도표에 나와 있습니다. 원자성이 꼭 필요한 프로그램이라면 일반 이동으로 대신하지 말고 실패로 처리합니다.
정리
- 경로를 검사할 때는 문자열이 아니라
Path요소로 비교하고, 반드시normalize()한 뒤에startsWith로 확인합니다. normalize()는 글자만 계산하므로 심볼릭 링크는toRealPath()로 따라가 허용 루트의 실제 경로와 비교합니다. 그래도 검사와 사용 사이의 바꿔치기는 남으므로, 외부 입력으로 경로를 정하지 않는 설계가 가장 안전합니다.Files는 실패를 예외로 알려 줍니다. 확인 후 생성 대신 바로 시도하고 예외를 결과로 처리하며, 옵션을 직접 주면 기본 옵션이 사라진다는 점에 주의합니다.- 파일 교체는 같은 디렉터리의 임시 파일에 쓰고 검증한 뒤 원자적 이동으로 마무리합니다. 원자적 이동을 지원하지 않을 때 대신할지 실패할지는 프로그램의 요구에 따라 정합니다.
다음 장에서는 파일 대신 네트워크로 바이트를 주고받는 TCP 프로그램을 만듭니다.