F-LAB JAVA · 1주차 · Phase 7 · 예외 처리와 자원 관리
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
java.io.File의 6가지 구조적 한계는?resolve, resolveSibling, relativize, normalize의 차이는?Files.list, Files.walk, Files.find는 언제 무엇을 쓰는가?Path Traversal 공격을 어떻게 방어하는가?WatchService는 어떤 시나리오에서 쓰는가?NIO.2 (java.nio.file, Java 7+) =
Path(불변 경로) +Files(정적 유틸의 폭격기) + Stream 통합
—java.io.File의 6가지 한계를 해결하고, 현대 파일 처리의 표준이 됐다.
| 시대 | 도구 | 문제 / 강점 |
|---|---|---|
| java.io.File (Java 1.0~) | 만능 스위스 칼 | 가위·드라이버·병따개 다 들어있지만, 정작 필요한 게 없거나 잘 안 듦. 실패하면 이유도 모름 (false만 반환) |
| java.nio.file (Java 7+) | 전문 부엌 | 칼은 자르기, 도마는 받치기, 냄비는 끓이기 — 역할 분리. 실패하면 정확한 예외 던짐 |
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. 면접 질문 + 자기 점검
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
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); // 따라가지 않음
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. 메모리 사용량 일정.
// 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 안의 파일을 그냥 다룸
}
File은 Channel, ByteBuffer, FileLock 등과 별개 세계.
대용량 처리 시 RandomAccessFile로 우회해야 했음.
NIO.2:
try (FileChannel channel = FileChannel.open(path, READ, WRITE)) {
MappedByteBuffer buffer = channel.map(MapMode.READ_WRITE, 0, size);
// 메모리 매핑 IO — 수 GB도 효율적 처리
}
public interface Path extends Comparable<Path>, Iterable<Path>, Watchable {
Path getFileName();
Path getParent();
Path resolve(Path other);
// ...
}
핵심 성질:
/와 \ 알아서 처리// 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도 안 필요.
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 자동완성으로 거의 모든 것 해결.
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
}
Path absolute = Path.of("/home/seungje/invoices");
Path relative = Path.of("invoices/2024");
absolute.isAbsolute(); // true
relative.isAbsolute(); // false
relative.toAbsolutePath();
// → /현재작업디렉토리/invoices/2024
Path messy = Path.of("/home/seungje/../seungje/./invoices/../invoices/INV.pdf");
messy.normalize();
// → /home/seungje/invoices/INV.pdf
../, ./ 제거. 파일시스템 확인은 안 함 — 순수 문자열 연산.
Path p = Path.of("/usr/bin/java"); // 심볼릭 링크라 가정
p.toRealPath();
// → /usr/lib/jvm/java-21/bin/java (실제 경로, 심볼릭 링크 해소)
⚠️ 파일이 실제로 존재해야 함. 없으면 NoSuchFileException.
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 공격의 핵심.
Path file = Path.of("/home/ilic/invoices/2024/INV-001.pdf");
file.resolveSibling("INV-002.pdf");
// → /home/ilic/invoices/2024/INV-002.pdf
// (parent를 찾아 그 옆에 만듦)
→ 같은 디렉토리에 다른 파일 만들 때 유용.
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
용도:
/files/2024/01/INV-001.pdf⚠️ 두 경로 타입이 같아야 함 (둘 다 절대 또는 둘 다 상대).
다르면 IllegalArgumentException.
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() → 심볼릭 링크 해소 + 실제 확인
// 작은 텍스트 파일 (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);
// 텍스트 한 번에
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);
}
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. 한쪽만 보면 안 됨.
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);
// 복사
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 | 심볼릭 링크 자체를 복사 |
// 자원! 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");
}
| 메서드 | 깊이 | 필터링 | 메모리 | 용도 |
|---|---|---|---|---|
Files.list(dir) | 1단계만 | 호출 후 .filter() | Lazy | 직속 자식만 |
Files.walk(dir) | 무제한/지정 | 호출 후 .filter() | Lazy | 재귀 순회 |
Files.find(dir, depth, m) | 지정 | 호출 시 함께 | Lazy | 속성 기반 필터 |
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
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 (깊이 우선).
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를 만든 뒤 filterfind: 디렉토리 순회 중 BasicFileAttributes를 한 번에 받아 검사 → 속성 검사 시 더 빠름// ❌ 자원 누수
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과 직결되는 핵심 포인트.
파일 생성/수정/삭제 이벤트를 받는다.
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 시나리오):
주의:
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);
Set<PosixFilePermission> perms = PosixFilePermissions.fromString("rw-r-----");
Files.setPosixFilePermissions(path, perms);
// 또는
Files.setPosixFilePermissions(path, Set.of(
OWNER_READ, OWNER_WRITE,
GROUP_READ
));
→ Linux 운영 서버에서 민감 파일(키, 인증서) 권한 강제 시 사용.
국제종합물류 ILIC 코드베이스에서 NIO.2를 어떻게 활용하고 있는가.
@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 — 같은 파일시스템에서 원자적 이동@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; // 원본 파일명 사용 안 함
}
}
포인트:
createDirectories — cargo별 디렉토리 자동 생성Files.copy(InputStream, Path) — Stream 한 번에 처리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 패턴 응용.
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을 가상 디렉토리로@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;
}
}
포인트:
waitUntilStable — 파일 업로드가 끝났는지 확인 (size가 변하지 않을 때까지)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);
}
}
→ 화물별 첨부 디스크 사용량 대시보드용.
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 검증.
// ❌
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 메서드는 자원.
// ❌
path.toFile().delete(); // 실패 시 false만, 이유 모름
// ✅
Files.delete(path); // 실패 시 정확한 예외
toFile()은 NIO.2를 java.io.File로 되돌리는 호환 메서드. 레거시 API와 연동할 때만 사용.
// ❌ 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");
// ❌ 1GB 로그 파일을 메모리에 다 적재
List<String> lines = Files.readAllLines(logPath);
// ✅ Lazy Stream
try (Stream<String> lines = Files.lines(logPath)) {
long count = lines.filter(predicate).count();
}
// ❌ 대상이 있으면 FileAlreadyExistsException
Files.copy(src, dst);
// ✅ 의도가 덮어쓰기라면
Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
Path base = Path.of("/home/ilic"); // 절대
Path target = Path.of("invoices/INV.pdf"); // 상대
base.relativize(target);
// ❌ IllegalArgumentException: 'other' is different type of Path
→ 둘 다 절대 또는 둘 다 상대여야 함. 사용 전 toAbsolutePath()로 통일.
// ❌ 플랫폼 기본 인코딩 (Windows = CP949, Linux = UTF-8)
String content = Files.readString(path); // Java 17까지는 위험
// ✅ 명시
String content = Files.readString(path, StandardCharsets.UTF_8);
📌 Java 18+는 기본이 UTF-8로 바뀜. 그래도 명시 권장.
// ❌ 사용자 입력을 그대로 resolve
Path file = baseDir.resolve(request.getParameter("file"));
return Files.readAllBytes(file);
// 공격: ?file=../../etc/passwd
// → /etc/passwd 읽힘
// ✅ normalize + startsWith 검증 (위 7.7 참조)
// ❌
WatchService watcher = folder.getFileSystem().newWatchService();
folder.register(watcher, ENTRY_CREATE);
// watcher가 안 닫힘
// ✅
try (WatchService watcher = folder.getFileSystem().newWatchService()) {
folder.register(watcher, ENTRY_CREATE);
// 이벤트 루프
}
| Q | 핵심 답변 |
|---|---|
java.io.File 대신 NIO.2를 쓰는 이유? | boolean 반환 한계 · 메타데이터 부족 · Stream 통합 · Symbolic Link · 다중 FS 지원 |
| Path는 어떤 객체인가? | 불변, OS 독립, 경로 표현일 뿐 파일 아님 |
resolve vs resolveSibling vs relativize? | 자식 경로 · 형제 경로 · 두 경로의 상대 차 |
normalize와 toRealPath의 차이? | 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 시스템 콜) |
java.io.File의 6가지 한계를 설명할 수 있다normalize와 toRealPath의 차이를 안다resolve, resolveSibling, relativize를 상황에 맞게 쓸 수 있다Files.list, walk, find를 시나리오에 맞게 선택할 수 있다1. NIO.2 = Path + Files + Stream 통합
list, walk, find, lines2. Files의 Stream 반환 메서드는 자원
Files.lines, list, walk, find, newDirectoryStream3. ILIC 전 영역에서 사용