F-LAB JAVA · 3주차 · Phase 7 · I/O 시스템 큰 그림
🏆 Phase 7 완주 — I/O 시스템 마스터
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
오버헤드 (Overhead) 는 "본 작업 외에 추가로 드는 간접 비용" 이며, I/O 는 시스템 호출, 버퍼링, 직렬화, 보조 스트림 중첩 등 곳곳에서 오버헤드가 발생한다.
File 객체 는 Java 1.0 의 파일 추상화로createNewFile,getAbsolutePath,getCanonicalPath,getName,getParent등 메서드를 제공하지만,
boolean 반환의 진단 불가, 디렉토리 자동 생성 안 함, 심볼릭 링크 처리 X, 풍부한 속성 X 등 한계로 NIO.2 의Path+Files가 권장된다.
특히file.createNewFile()이 실패하는 가장 흔한 이유는 부모 디렉토리가 없어서 —mkdirs()를 먼저 호출해야.
Phase 7 완주: I/O 의 정의 → IO/NIO/NIO.2 진화 → Stream/Channel 정밀 → 동시성 모델 → 실무 함정 의 5단계로 자바 I/O 의 큰 그림 완성.
본 작업: 음식 만들기 (5분)
오버헤드:
- 주문 받기 (2분)
- 포장 (1분)
- 배달원 호출 (1분)
- 배달 (15분)
- 결제 처리 (1분)
총: 25분
본 작업: 5분 (20%)
오버헤드: 20분 (80%)
I/O 의 오버헤드:
본 작업: 데이터 읽기 (수 마이크로초)
오버헤드:
- 시스템 호출 (수 마이크로초)
- 컨텍스트 스위칭
- 버퍼 복사
- 보조 스트림 중첩
→ I/O 자체가 오버헤드의 결정체
→ 오버헤드 = 본 작업 외의 모든 간접 비용.
1. 오버헤드의 정의와 종류
2. I/O 의 오버헤드가 발생하는 지점
3. File 객체의 정의와 구조
4. File 의 주요 메서드 정밀
5. "디렉토리는 자동 생성 안 됨" 함정
6. mkdir vs mkdirs, 그 외 함정들
7. File 의 한계 종합 + NIO.2 해결
8. Phase 7 완주 정리 + Phase 8 예고
9. 면접 + 자기 점검
오버헤드 (Overhead):
본 작업을 수행하는 데 필요하지만,
본 작업 자체가 아닌 추가 비용.
종류:
- 시간 (CPU 사이클)
- 메모리 (버퍼, 객체)
- 자원 (스레드, 소켓, 파일 핸들)
- 네트워크 (대역폭)
예:
- 10초 작업이 20초 걸림 → 오버헤드 10초
- 1KB 데이터 처리에 10KB 메모리 → 9KB 오버헤드
시간 오버헤드:
- 작업 시작 전 준비 (객체 생성, 초기화)
- 작업 중 추가 처리 (변환, 검증)
- 작업 종료 후 정리 (close, GC)
메모리 오버헤드:
- 버퍼 크기
- 객체 헤더
- 캐시
- 임시 데이터
자원 오버헤드:
- 스레드 (메모리 + 컨텍스트 스위칭)
- 파일 핸들
- 소켓 연결
- DB 연결
네트워크 오버헤드:
- HTTP 헤더
- TCP 헤더
- TLS 핸드셰이크
- 재전송
좋은 오버헤드 (피할 수 없음):
- try-with-resources 의 자원 정리
- 트랜잭션 관리
- 보안 검증
- 로깅 (적절히)
나쁜 오버헤드 (피할 수 있음):
- 불필요한 객체 생성
- 과도한 직렬화
- 매번 새 Connection
- 비효율적 알고리즘
// 시간 오버헤드 측정
long start = System.nanoTime();
operation();
long elapsed = System.nanoTime() - start;
System.out.println("Took " + elapsed + " ns");
// 메모리 오버헤드 측정
Runtime runtime = Runtime.getRuntime();
long beforeMem = runtime.totalMemory() - runtime.freeMemory();
operation();
long afterMem = runtime.totalMemory() - runtime.freeMemory();
System.out.println("Used " + (afterMem - beforeMem) + " bytes");
// 프로파일링 도구
// - JProfiler, YourKit
// - JFR (Java Flight Recorder)
// - async-profiler
오버헤드는 항상 나쁜가? — 아니다.
좋은 trade-off:
- 버퍼링: 메모리 ↑, 속도 ↑↑ → 좋음
- 캐시: 메모리 ↑, 시간 ↓↓ → 좋음
- try-with-resources: 코드 ↑, 안전성 ↑↑ → 좋음
나쁜 trade-off:
- 과도한 추상화: 복잡도 ↑, 효과 미미
- 매번 새 Connection: 자원 ↑, 무의미
판단 기준:
- 본 작업 대비 합리적 비율?
- 가치를 더하는가?
// 예: ShipmentService.findById
@Transactional
public Shipment findById(Long id) {
// 1. 트랜잭션 시작 — 오버헤드 (~1ms)
// 2. JPA 의 EntityManager — 오버헤드 (~0.5ms)
Shipment s = repository.findById(id).orElseThrow();
// 3. SQL 실행 + 결과 매핑 — 본 작업 (~10ms)
return s;
// 4. 트랜잭션 종료 — 오버헤드 (~0.5ms)
}
// 분석:
// 본 작업: 10ms (DB 쿼리)
// 오버헤드: 2ms (트랜잭션, JPA)
// 비율: 오버헤드 20%
// 합리적
오버헤드의 정의와 종류는?
답:
1. 정의:
종류:
합리성:
측정:
I/O 오버헤드 발생 지점:
1. 시스템 호출 (System Call)
- JVM ↔ OS 전환 비용
- 매 read/write 마다
2. 컨텍스트 스위칭
- user space ↔ kernel space
- 한 번에 ~1μs
3. 버퍼 복사
- kernel buffer → user buffer
- 또는 그 반대
4. 추상화 비용
- Decorator 중첩
- 객체 생성
- 가상 메서드 호출
// 1바이트씩 읽기 — 매번 시스템 호출
FileInputStream fis = new FileInputStream("file.txt");
int b;
while ((b = fis.read()) != -1) {
// 매 read() 가 read() 시스템 호출
// 1MB 파일 = 1,048,576번 호출
// 각 호출 ~1μs → 총 ~1초
}
// 8KB 단위 — 시스템 호출 ↓
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) != -1) {
// 1MB / 8KB = 128번 호출
// ~0.128ms
// 시스템 호출 오버헤드 ~7,800배 절감
}
일반 read 의 데이터 흐름:
디스크 → kernel buffer (DMA)
↓
user buffer (CPU 복사)
↓
JVM heap (Heap Buffer 인 경우 한 번 더)
매 복사가 메모리 대역폭 소모.
해결:
- Direct Buffer: kernel ↔ direct buffer 만
- zero-copy: kernel 내에서 직접 (transferTo)
- Memory-mapped: 페이지 캐시 활용
// Decorator 중첩의 비용
ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(
new FileInputStream("data.dat")));
// 매 read():
// 1. ObjectInputStream.read()
// 2. → BufferedInputStream.read()
// 3. → FileInputStream.read()
// 4. → native read system call
// 5. → kernel
// 6. → 데이터 반환 (역순)
// 매 호출마다 4-5번의 메서드 호출
// JIT 가 인라이닝하지만 약간의 오버헤드
// Connection 매번 생성 (안 좋음)
public void process() {
Connection conn = DriverManager.getConnection(url); // 매번 연결
// ~100ms 비용
// ...
conn.close();
}
// Connection Pool 활용 (좋음)
public void process() {
try (Connection conn = dataSource.getConnection()) { // 풀에서 가져옴
// ~0.1ms 비용
// ...
}
}
// 오버헤드 절감: ~1000배
// 직렬화는 비싼 작업
class Shipment implements Serializable {
private Long id;
private String blNo;
// ...
}
// 오버헤드:
// 1. Reflection 기반 필드 접근 (느림)
// 2. 클래스 메타데이터 저장
// 3. transient 필드 처리
// 4. ObjectStream 자체의 오버헤드
// 측정:
// 작은 객체: ~10μs per 직렬화
// 큰 객체: ~ms
// 대안:
// - JSON (Jackson)
// - Protobuf, Avro
// - 더 효율적
HTTP 요청의 오버헤드:
1. DNS lookup: ~10ms
2. TCP 핸드셰이크: 1-RTT
3. TLS 핸드셰이크: 2-RTT
4. HTTP 헤더: ~500바이트
5. 응답 헤더: ~500바이트
작은 응답 (1KB):
- 총 시간: ~100ms
- 본 데이터: 1KB
- 오버헤드 헤더: 1KB (100%)
큰 응답 (1MB):
- 본 데이터: 1MB
- 오버헤드: 1KB (~0.1%)
→ 큰 요청 1번 > 작은 요청 1000번
public class ShipmentExportService {
// ❌ 나쁜 패턴 — 매번 새 Connection
public void exportAllBad() throws Exception {
for (Shipment s : repository.findAll()) {
Connection conn = DriverManager.getConnection(url);
// 매번 ~100ms
// ...
conn.close();
}
}
// ✓ 좋은 패턴 — Connection Pool + Buffered + Stream
public void exportAllGood(Path dest) throws IOException {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT * FROM shipments");
ResultSet rs = ps.executeQuery();
BufferedWriter writer = Files.newBufferedWriter(dest)) {
writer.write("id,blNo,weight\n");
while (rs.next()) {
writer.write(rs.getLong("id") + ",");
writer.write(rs.getString("bl_no") + ",");
writer.write(rs.getBigDecimal("weight") + "\n");
}
}
// 한 Connection, 한 Statement, 한 ResultSet
// BufferedWriter 로 디스크 I/O ↓
}
// ✓✓ 더 좋은 패턴 — Streaming + Bulk Insert
@Transactional
public void importBulk(List<Shipment> shipments) {
// 1000개씩 묶어서
for (List<Shipment> chunk : Lists.partition(shipments, 1000)) {
repository.saveAll(chunk);
}
// saveAll 이 내부적으로 batch insert
}
}
I/O 오버헤드가 발생하는 4가지 지점은?
답:
1. 시스템 호출:
컨텍스트 스위칭:
버퍼 복사:
추상화 비용:
최적화:
java.io.File:
파일과 디렉토리의 추상적 경로 표현.
Java 1.0 부터 존재.
특징:
- 인스턴스 객체
- 경로 + 약간의 메타데이터
- 파일이 실제로 존재하지 않아도 객체 생성 가능
// 5가지 생성자
// 1. 경로 문자열
File f1 = new File("file.txt");
File f2 = new File("/home/user/file.txt");
File f3 = new File("C:\\Users\\user\\file.txt");
// 2. 부모 + 자식 (이름)
File f4 = new File("/home/user", "file.txt");
// → /home/user/file.txt
// 3. 부모 File + 자식 (이름)
File parent = new File("/home/user");
File f5 = new File(parent, "file.txt");
// 4. URI
URI uri = URI.create("file:///home/user/file.txt");
File f6 = new File(uri);
// 5. (private 또는 deprecated 생성자 있음)
// File 은 경로의 추상화 — 실제 파일과 별개
File f = new File("nonexistent.txt");
// 객체 생성 성공
// 실제 파일은 없음
f.exists(); // false
f.length(); // 0
f.canRead(); // false
f.getAbsolutePath(); // 작동 (경로만)
// 즉:
// File 객체 = 경로 정보
// 실제 파일 = OS 의 파일 시스템 자원
// 두 개념 분리
정보 조회:
- exists(), length(), lastModified()
- canRead(), canWrite(), canExecute()
- isFile(), isDirectory(), isHidden()
경로 조회:
- getName(), getPath(), getParent()
- getAbsolutePath(), getCanonicalPath()
- toURI(), toPath()
생성/삭제:
- createNewFile(), delete()
- mkdir(), mkdirs()
- renameTo()
목록:
- list(), listFiles()
- listFiles(FileFilter)
- listFiles(FilenameFilter)
권한:
- setReadable(), setWritable(), setExecutable()
- setLastModified()
- setReadOnly()
File 의 한계 (다음 섹션에서 정밀):
1. boolean 반환 — 진단 어려움
2. 디렉토리 자동 생성 안 함
3. 심볼릭 링크 처리 X
4. 풍부한 속성 X (POSIX 권한 등)
5. 비효율적 디렉토리 순회 (한 번에 전부)
6. 파일 시스템 추상화 X
7. 비동기 I/O X
// 1. 레거시 코드
public void legacyMethod(File file) {
// 기존 API 가 File 받음
}
// 2. 라이브러리 호환
SomeLibrary.process(new File("file.txt"));
// 3. 간단한 작업
File config = new File("config.properties");
if (config.exists()) {
// 처리
}
// 4. Path 와의 변환
File f = new File("file.txt");
Path p = f.toPath(); // Path 로 변환
Path p2 = Path.of("file.txt");
File f2 = p2.toFile(); // File 로 변환
File 객체의 본질과 구조는?
답:
1. 본질:
생성:
메서드 그룹:
현재 권장:
File f = new File("/path/to/file.txt");
boolean created = f.createNewFile();
// 반환:
// - true: 성공적으로 생성
// - false: 이미 존재 또는 실패
// 예외:
// - IOException: I/O 실패 (디렉토리 없음, 권한 없음 등)
// 함정:
// - 부모 디렉토리가 없으면 IOException
// - "디렉토리는 자동 생성 안 됨"
// 사용 패턴
File file = new File("/var/data/file.txt");
try {
if (file.createNewFile()) {
System.out.println("Created: " + file);
} else {
System.out.println("Already exists: " + file);
}
} catch (IOException e) {
// 부모 디렉토리 없음, 권한 없음 등
e.printStackTrace();
}
// NIO.2 의 대안
Path path = Path.of("/var/data/file.txt");
try {
Files.createFile(path);
} catch (FileAlreadyExistsException e) {
// 이미 존재
} catch (NoSuchFileException e) {
// 부모 디렉토리 없음 (명확!)
} catch (IOException e) {
// 기타
}
// 절대 경로 vs 상대 경로
// 상대 경로
File rel = new File("file.txt");
rel.getPath(); // file.txt (입력 그대로)
rel.getAbsolutePath(); // /current/dir/file.txt (현재 작업 디렉토리 기준)
// 절대 경로
File abs = new File("/home/user/file.txt");
abs.getPath(); // /home/user/file.txt
abs.getAbsolutePath(); // /home/user/file.txt (동일)
// getAbsolutePath 의 동작:
// 1. 이미 절대 경로면 그대로
// 2. 상대 경로면 user.dir 시스템 프로퍼티 prefix
// System.getProperty("user.dir")
// 주의:
// - 심볼릭 링크는 해소 안 함
// - . 과 .. 는 그대로 둠
// Canonical = 정규화된 (심볼릭 링크 해소 + . / .. 정리)
File f = new File("/home/user/./docs/../file.txt");
f.getAbsolutePath(); // /home/user/./docs/../file.txt
f.getCanonicalPath(); // /home/user/file.txt (정규화)
// 심볼릭 링크
File link = new File("/path/to/symlink");
// 가정: /path/to/symlink → /real/path/file.txt
link.getAbsolutePath(); // /path/to/symlink
link.getCanonicalPath(); // /real/path/file.txt
// 예외:
// - IOException: 파일 시스템 접근 실패
// 활용:
// - 두 경로가 같은 파일을 가리키는지 비교
// - 보안 검사 (Path Traversal 방지)
File requested = new File(userInput);
File baseDir = new File("/var/data");
if (!requested.getCanonicalPath().startsWith(baseDir.getCanonicalPath())) {
// 안전하지 않음 — userInput 이 baseDir 외부
throw new SecurityException();
}
File f = new File("/home/user/docs/report.pdf");
f.getName(); // report.pdf (파일명 + 확장자)
f.getPath(); // /home/user/docs/report.pdf
f.getParent(); // /home/user/docs
// 확장자 분리 (별도)
String name = f.getName();
int dot = name.lastIndexOf('.');
String base = (dot > 0) ? name.substring(0, dot) : name;
String ext = (dot > 0) ? name.substring(dot + 1) : "";
// NIO.2 의 대안
Path p = Path.of("/home/user/docs/report.pdf");
p.getFileName(); // report.pdf (Path 객체)
p.getFileName().toString(); // "report.pdf"
File f = new File("/home/user/docs/report.pdf");
f.getParent(); // "/home/user/docs" (String)
f.getParentFile(); // File 객체
// 루트의 경우
File root = new File("/");
root.getParent(); // null
root.getParentFile(); // null
// 상대 경로
File rel = new File("file.txt");
rel.getParent(); // null (부모가 명시 안 됨)
rel.getAbsoluteFile().getParent(); // 현재 작업 디렉토리
// 활용
File f = new File("/var/data/file.txt");
File dir = f.getParentFile();
if (!dir.exists()) {
dir.mkdirs(); // 부모 디렉토리 생성
}
f.createNewFile();
File f = new File("file.txt");
long size = f.length();
// 반환:
// - 파일 크기 (바이트)
// - 파일 없으면 0
// - 디렉토리도 0 (대부분 OS)
// 함정:
// - 0 이 "비어있는 파일" 또는 "없는 파일" 둘 다 가능
// - 구분 필요: f.exists() + f.length()
if (f.exists()) {
if (f.length() == 0) {
// 빈 파일
} else {
// 데이터 있음
}
} else {
// 없는 파일
}
File f = new File("file.txt");
long timestamp = f.lastModified(); // millisecond since epoch
// 변환
Instant instant = Instant.ofEpochMilli(timestamp);
LocalDateTime dateTime = instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
// 변경
f.setLastModified(System.currentTimeMillis());
// NIO.2 의 대안 (더 풍부)
Path p = Path.of("file.txt");
FileTime modifiedTime = Files.getLastModifiedTime(p);
Instant inst = modifiedTime.toInstant();
File dir = new File("/var/data");
// 모든 파일 (배열, 한 번에 메모리에)
File[] all = dir.listFiles();
// 필터링
File[] txtFiles = dir.listFiles((d, name) -> name.endsWith(".txt"));
File[] regularFiles = dir.listFiles(File::isFile);
// 이름만
String[] names = dir.list();
// 함정:
// - 대용량 디렉토리에서 OOM 위험
// - null 반환 가능 (디렉토리 아니거나 접근 불가)
if (dir.isDirectory()) {
File[] files = dir.listFiles();
if (files != null) {
for (File f : files) { ... }
}
}
// NIO.2 의 대안 — Stream (lazy)
try (Stream<Path> paths = Files.list(Path.of("/var/data"))) {
paths.filter(p -> p.toString().endsWith(".txt"))
.forEach(System.out::println);
}
public class ShipmentFileUtil {
private static final File EXPORT_DIR = new File("/var/shipment/exports");
// 파일 생성 (부모 디렉토리 자동 생성 포함)
public File createExportFile(String name) throws IOException {
// 부모 디렉토리 확인 + 생성
if (!EXPORT_DIR.exists() && !EXPORT_DIR.mkdirs()) {
throw new IOException("Cannot create directory: " + EXPORT_DIR);
}
File file = new File(EXPORT_DIR, name);
if (!file.createNewFile() && !file.exists()) {
throw new IOException("Cannot create file: " + file);
}
return file;
}
// 안전한 경로 확인 (Path Traversal 방지)
public File getSafeFile(String userInput) throws IOException {
File requested = new File(EXPORT_DIR, userInput);
String canonical = requested.getCanonicalPath();
String baseCanonical = EXPORT_DIR.getCanonicalPath();
if (!canonical.startsWith(baseCanonical)) {
throw new SecurityException("Path traversal attempt: " + userInput);
}
return requested;
}
// 오래된 파일 삭제
public void deleteOldFiles(int days) {
long threshold = System.currentTimeMillis() - (long) days * 24 * 3600 * 1000;
File[] files = EXPORT_DIR.listFiles();
if (files == null) return;
for (File f : files) {
if (f.isFile() && f.lastModified() < threshold) {
if (!f.delete()) {
log.warn("Failed to delete: {}", f);
}
}
}
}
}
File 의 주요 메서드 5가지의 정확한 차이는?
답:
1. createNewFile():
getAbsolutePath():
getCanonicalPath():
getName():
getParent():
// 가장 흔한 실수
File file = new File("/var/data/2026/05/report.txt");
try {
file.createNewFile();
// ❌ IOException: No such file or directory
// 부모 디렉토리 /var/data/2026/05 가 없음
} catch (IOException e) {
// 잡혔지만 원인 모름
}
이유:
1. 안전성
- 사용자가 의도하지 않은 디렉토리 생성 방지
- 권한 문제 회피
2. 명시성
- 디렉토리 생성은 명시적 의도
- mkdir() 또는 mkdirs() 호출
3. 성능
- 매번 디렉토리 검사는 비용
- 호출자가 알아서 보장
설계 결정:
- createNewFile() 은 단일 책임
- "파일 생성만"
- 부모 디렉토리는 별도
// mkdir() — 단일 디렉토리만
File dir = new File("/var/data/2026/05/reports");
dir.mkdir();
// ❌ false
// 부모 /var/data/2026/05 가 없음
// mkdir 은 "마지막 하나만" 생성
// mkdirs() — 중간 디렉토리 모두
dir.mkdirs();
// ✓ true
// 필요한 모든 부모 디렉토리 생성
// /var/data/2026/05/reports
// /var/data/2026/05
// /var/data/2026
// /var/data
// 모두 생성 시도
// 안전한 파일 생성 패턴
public File safeCreateFile(File file) throws IOException {
// 1. 부모 디렉토리 확보
File parent = file.getParentFile();
if (parent != null && !parent.exists()) {
if (!parent.mkdirs()) {
throw new IOException("Cannot create parent directory: " + parent);
}
}
// 2. 파일 생성
if (!file.createNewFile() && !file.exists()) {
throw new IOException("Cannot create file: " + file);
}
return file;
}
// 사용
File f = new File("/var/data/2026/05/report.txt");
safeCreateFile(f);
// 부모 디렉토리들 자동 생성
// NIO.2 의 createDirectories — 명시적, 명확
Path file = Path.of("/var/data/2026/05/report.txt");
Files.createDirectories(file.getParent()); // 부모 디렉토리 모두 생성
Files.createFile(file); // 파일 생성
// 또는 한 번에
Path file = Path.of("/var/data/2026/05/report.txt");
Files.createDirectories(file.getParent());
Files.writeString(file, "content");
// 이미 존재해도 OK (createDirectories)
// 첫 시도: 디렉토리 생성
// 두 번째 시도: 이미 존재, 그냥 통과
// 시나리오 1: 날짜별 디렉토리에 저장
public class DailyExporter {
public void export(List<Shipment> shipments) throws IOException {
LocalDate today = LocalDate.now();
File dir = new File("/var/exports",
String.format("%d/%02d/%02d",
today.getYear(),
today.getMonthValue(),
today.getDayOfMonth()));
// ❌ 잊으면 IOException
// dir.exists() check + mkdirs() 누락
File file = new File(dir, "shipments.csv");
file.createNewFile(); // 실패!
// ✓ 올바른 패턴
if (!dir.exists()) {
dir.mkdirs();
}
// 또는 NIO.2
Files.createDirectories(dir.toPath());
}
}
// 시나리오 2: 업로드 파일 저장
public class FileUploader {
public void save(MultipartFile file, String userId) throws IOException {
File targetDir = new File("/var/uploads", userId);
// ❌ 누락
// targetDir.mkdirs();
File target = new File(targetDir, file.getOriginalFilename());
file.transferTo(target); // 실패!
// ✓ NIO.2
Path targetPath = Path.of("/var/uploads", userId, file.getOriginalFilename());
Files.createDirectories(targetPath.getParent());
file.transferTo(targetPath);
}
}
// 시나리오 3: 로그 파일 회전
public class LogRotator {
public void rotate(String logName) throws IOException {
File current = new File("/var/log", logName);
File archive = new File("/var/log/archive",
logName + "." + System.currentTimeMillis());
// ❌ /var/log/archive 가 없으면 실패
current.renameTo(archive);
// ✓ 올바른 패턴
Path archivePath = Path.of("/var/log/archive",
logName + "." + System.currentTimeMillis());
Files.createDirectories(archivePath.getParent());
Files.move(current.toPath(), archivePath);
}
}
파일 작업 전 체크리스트:
1. 부모 디렉토리 존재 확인
- 없으면 mkdirs() 또는 Files.createDirectories()
2. 권한 확인
- canWrite() 또는 Files.isWritable()
3. 디스크 공간 확인
- 큰 파일 작업 시 free space 확인
4. 파일 시스템 한계
- 파일명 길이
- 디렉토리 안 파일 수
5. 동시성
- 다른 프로세스가 동시 작업?
- Lock 필요?
@Component
public class SafeFileService {
private final Path baseDir = Path.of("/var/shipment");
public Path writeShipmentReport(String content, LocalDate date) throws IOException {
// 1. 날짜별 경로 생성
Path file = baseDir
.resolve("reports")
.resolve(String.valueOf(date.getYear()))
.resolve(String.format("%02d", date.getMonthValue()))
.resolve("shipment_" + date + ".txt");
// 2. 부모 디렉토리 보장
Files.createDirectories(file.getParent());
// 3. 권한 확인
if (!Files.isWritable(file.getParent())) {
throw new IOException("No write permission: " + file.getParent());
}
// 4. 안전한 쓰기 (이미 있으면 덮어쓰기)
Files.writeString(file, content,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
return file;
}
public Path uploadShipmentAttachment(String userId, String filename, byte[] data)
throws IOException {
// 1. Path Traversal 방지
if (filename.contains("/") || filename.contains("..")) {
throw new SecurityException("Invalid filename: " + filename);
}
// 2. 사용자별 디렉토리
Path userDir = baseDir.resolve("uploads").resolve(userId);
Files.createDirectories(userDir);
// 3. 안전한 파일명 (timestamp 추가)
Path file = userDir.resolve(System.currentTimeMillis() + "_" + filename);
// 4. 쓰기
Files.write(file, data);
return file;
}
}
"디렉토리는 자동 생성 안 됨" 함정과 해결은?
답:
1. 함정:
file.createNewFile() 가 부모 디렉토리 없으면 IOExceptionfile.renameTo(dest) 도 dest 의 부모 디렉토리 필요이유:
해결:
mkdirs() (중간 디렉토리 모두)Files.createDirectories()권장 패턴:
Files.createDirectories(file.getParent());
Files.writeString(file, content);
체크리스트:
mkdir:
- 마지막 디렉토리만 생성
- 부모 없으면 실패 (false)
mkdirs:
- 필요한 모든 부모 디렉토리 생성
- 이미 있으면 그 부분은 건너뜀
- 최종 디렉토리도 이미 있으면 false
// 비교
File dir = new File("/var/data/2026/05");
// 가정: /var 만 존재
dir.mkdir(); // false (부모 /var/data 없음)
dir.mkdirs(); // true (모두 생성)
// /var/data/2026/05 모두 생성됨
// 또 호출
dir.mkdir(); // false (이미 존재)
dir.mkdirs(); // false (이미 존재)
// 함정: mkdirs() 가 false 반환해도
// "이미 존재" 일 수도, "실패" 일 수도
// 구분 필요
if (!dir.mkdirs() && !dir.exists()) {
throw new IOException("Cannot create: " + dir);
}
File dir = new File("/var/data");
// 디렉토리 안에 파일 있으면?
dir.delete(); // ❌ false (디렉토리가 비어있지 않음)
// 디렉토리 삭제 = 비어있을 때만
// 안의 파일/디렉토리 먼저 삭제 필요
// 재귀 삭제 (직접 구현 필요)
public static void deleteRecursively(File f) {
if (f.isDirectory()) {
File[] children = f.listFiles();
if (children != null) {
for (File child : children) {
deleteRecursively(child);
}
}
}
f.delete();
}
// NIO.2 의 대안
Files.walkFileTree(Path.of("/var/data"), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
});
// 또는 Apache Commons IO
FileUtils.deleteDirectory(new File("/var/data"));
File src = new File("/var/data/file.txt");
File dest = new File("/other/dir/file.txt");
boolean renamed = src.renameTo(dest);
// 함정 1: 부모 디렉토리 없음
// /other/dir 없으면 false
// 함정 2: 다른 파일 시스템
// /var 와 /other 가 다른 마운트 포인트면 실패 가능
// 일부 OS
// 함정 3: 이미 존재
// dest 가 이미 있으면 OS 따라 다름
// 함정 4: 실패 원인 모름
// false 만 반환
// NIO.2 의 대안
Files.move(src.toPath(), dest.toPath());
// 명확한 예외:
// - NoSuchFileException
// - FileAlreadyExistsException
// - AtomicMoveNotSupportedException
// 옵션
Files.move(src.toPath(), dest.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
File dir = new File("/var/data");
// 함정 1: null 반환
File[] files = dir.listFiles();
// - 디렉토리 아님
// - 접근 권한 없음
// - I/O 에러
// 모두 null
// 안전한 사용
File[] files = dir.listFiles();
if (files != null) {
for (File f : files) { ... }
}
// 함정 2: 메모리 사용
// 1만 파일 = 1만 File 객체 한 번에 메모리
// 대용량 디렉토리에서 OOM 위험
// NIO.2 의 대안 — Stream
try (Stream<Path> paths = Files.list(Path.of("/var/data"))) {
paths.forEach(System.out::println);
}
// Lazy, 메모리 효율
// OS 별 경로 구분자
// Windows: \
// Unix/Linux/Mac: /
// File 사용 시
File f1 = new File("/home/user/file.txt"); // Unix
File f2 = new File("C:\\Users\\user\\file.txt"); // Windows
// 구분자 가져오기
String sep = File.separator;
// Windows: "\\"
// Unix: "/"
// File 은 OS 자동 변환
File f3 = new File("/home/user");
File f4 = new File(f3, "docs");
f4.getPath();
// Unix: /home/user/docs
// Windows: \home\user\docs
// NIO.2 의 일관된 처리
Path p = Path.of("/home/user/docs");
// 또는 multi-arg
Path p2 = Path.of("/home", "user", "docs");
// OS 자동 처리
// 함정: TOCTOU (Time-Of-Check Time-Of-Use)
File f = new File("file.txt");
if (f.exists()) {
// ★ 여기서 다른 프로세스가 삭제할 수 있음
f.delete(); // 실패 가능
}
// 더 안전: 직접 시도 + 예외 처리
try {
Files.delete(Path.of("file.txt"));
} catch (NoSuchFileException e) {
// 없음
} catch (IOException e) {
// 기타
}
// File 자체는 immutable (경로만)
// 하지만 가리키는 파일은 외부 자원
File f = new File("file.txt");
// 동시에 여러 스레드가 작업
Thread t1 = new Thread(() -> writeToFile(f));
Thread t2 = new Thread(() -> readFromFile(f));
// 동기화 필요 (외부 자원)
// 1. 파일 락
// 2. 애플리케이션 락
// 3. NIO.2 의 FileLock
// FileLock 활용
try (FileChannel ch = FileChannel.open(f.toPath(), READ, WRITE);
FileLock lock = ch.lock()) {
// 배타적 락
// ...
}
File 사용 시 흔한 실수:
1. 부모 디렉토리 안 만들고 createNewFile
2. mkdir vs mkdirs 혼동
3. delete 의 디렉토리 비어있어야 함
4. renameTo 의 부모 디렉토리 누락
5. listFiles 의 null 체크 누락
6. 대용량 디렉토리 listFiles
7. 경로 구분자 하드코딩
8. TOCTOU race condition
9. 동시성 무시
10. 진단 어려움 (boolean 반환)
→ 대부분 NIO.2 의 Path/Files 에서 명시적 예외로 해결
File 사용 시 함정 5가지는?
답:
1. 디렉토리 자동 생성 X: mkdirs() 필수
2. mkdir vs mkdirs: 단일 vs 중간 모두
3. delete 의 디렉토리: 비어있어야
4. renameTo 의 부모 누락: dest 부모 디렉토리 필요
5. listFiles 의 null: 권한/오류 시 null
해결: NIO.2 의 Files API
File 의 7가지 한계:
1. boolean 반환 — 진단 어려움
2. 디렉토리 자동 생성 안 함
3. 심볼릭 링크 처리 X
4. 풍부한 속성 X
5. 비효율적 디렉토리 순회
6. 파일 시스템 추상화 X
7. 비동기 I/O X
| 한계 | File | NIO.2 |
|---|---|---|
| 에러 처리 | boolean | 구체적 예외 |
| 디렉토리 | mkdirs | Files.createDirectories |
| 심볼릭 링크 | exists 모호 | LinkOption.NOFOLLOW_LINKS |
| 속성 | length, lastModified | BasicFileAttributes, PosixFileAttributes |
| 순회 | listFiles (메모리) | Files.list (Stream) |
| 파일 시스템 | 로컬만 | FileSystems (ZIP 등) |
| 비동기 | X | AsynchronousFileChannel |
NIO.2 의 예외 계층:
IOException
├── NoSuchFileException - 파일 없음
├── FileAlreadyExistsException - 이미 존재
├── AccessDeniedException - 권한 없음
├── DirectoryNotEmptyException - 디렉토리 비어있지 않음
├── NotDirectoryException - 디렉토리가 아님
├── FileSystemException - 파일 시스템 일반
├── AtomicMoveNotSupportedException - 원자적 이동 불가
└── ClosedFileSystemException - 닫힌 FS
활용:
try {
Files.delete(path);
} catch (NoSuchFileException e) {
// 명확
} catch (DirectoryNotEmptyException e) {
// 명확
} catch (AccessDeniedException e) {
// 명확
}
// File 의 빈약한 속성
File f = new File("file.txt");
f.length();
f.lastModified();
f.canRead();
f.canWrite();
// 끝
// NIO.2 의 풍부한 속성
Path p = Path.of("file.txt");
// 기본 속성
BasicFileAttributes basic = Files.readAttributes(p, BasicFileAttributes.class);
basic.creationTime();
basic.lastAccessTime();
basic.lastModifiedTime();
basic.size();
basic.isRegularFile();
basic.isDirectory();
basic.isSymbolicLink();
basic.fileKey();
// POSIX 속성 (Linux/Mac)
PosixFileAttributes posix = Files.readAttributes(p, PosixFileAttributes.class);
posix.owner();
posix.group();
posix.permissions();
// Set<PosixFilePermission>
// DOS 속성 (Windows)
DosFileAttributes dos = Files.readAttributes(p, DosFileAttributes.class);
dos.isHidden();
dos.isReadOnly();
dos.isSystem();
// 권한 변경
Set<PosixFilePermission> perms = EnumSet.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE);
Files.setPosixFilePermissions(p, perms);
// NIO.2 의 심볼릭 링크 정밀
Path link = Path.of("/path/to/symlink");
// 링크 자체 검사
boolean linkExists = Files.exists(link, LinkOption.NOFOLLOW_LINKS);
// 원본 검사 (기본)
boolean targetExists = Files.exists(link);
// 링크 vs 원본 구분
Files.isSymbolicLink(link); // true
Files.isRegularFile(link); // false (링크니까)
Files.isRegularFile(link, LinkOption.NOFOLLOW_LINKS); // 옵션
// 링크 따라가서 원본 정보
Path real = link.toRealPath(); // 원본 경로
// 링크 자체 정보
Path target = Files.readSymbolicLink(link); // 가리키는 경로
// 링크 생성
Files.createSymbolicLink(link, target);
Files.createLink(link, target); // 하드 링크
// File 의 비효율
File dir = new File("/large/dir");
File[] all = dir.listFiles(); // 메모리에 모두
// NIO.2 의 Stream
try (Stream<Path> paths = Files.list(Path.of("/large/dir"))) {
paths.filter(p -> p.toString().endsWith(".txt"))
.limit(100)
.forEach(System.out::println);
}
// Lazy, 메모리 효율적
// 재귀 (walk)
try (Stream<Path> paths = Files.walk(Path.of("/large/dir"))) {
long count = paths.filter(Files::isRegularFile).count();
}
// 조건 (find)
try (Stream<Path> paths = Files.find(
Path.of("/large/dir"), 5, // 깊이 5
(path, attrs) -> attrs.size() > 1_000_000)) {
paths.forEach(System.out::println);
}
// FileVisitor (가장 정밀)
Files.walkFileTree(Path.of("/large/dir"), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
if (attrs.size() > 1_000_000) {
System.out.println(file);
}
return FileVisitResult.CONTINUE;
}
});
// ZIP 파일을 파일 시스템처럼
try (FileSystem zipFs = FileSystems.newFileSystem(
Path.of("archive.zip"), Map.of())) {
// ZIP 안의 파일 작업
Path inZip = zipFs.getPath("/inside/file.txt");
String content = Files.readString(inZip);
// 새 파일 추가 (실제 ZIP 수정)
Path newFile = zipFs.getPath("/added.txt");
Files.writeString(newFile, "new content");
// 디렉토리 순회
try (Stream<Path> paths = Files.walk(zipFs.getPath("/"))) {
paths.forEach(System.out::println);
}
}
// 동일 API 로 다양한 FS 사용
// - 로컬 FS
// - ZIP
// - JAR
// - 메모리 (jimfs 같은 라이브러리)
// - 네트워크 FS
// NIO.2 의 비동기 파일 채널
AsynchronousFileChannel channel = AsynchronousFileChannel.open(
Path.of("file.txt"), StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 방법 1: Future
Future<Integer> future = channel.read(buffer, 0);
// 즉시 리턴, 백그라운드 진행
while (!future.isDone()) {
// 다른 일
}
int bytesRead = future.get();
// 방법 2: CompletionHandler (콜백)
channel.read(buffer, 0, "attachment",
new CompletionHandler<Integer, String>() {
@Override
public void completed(Integer result, String attachment) {
System.out.println("Read " + result + " bytes");
}
@Override
public void failed(Throwable exc, String attachment) {
exc.printStackTrace();
}
});
// 옛 코드 (File 기반)
public class OldFileService {
public void process(String dirPath, String fileName) throws IOException {
File dir = new File(dirPath);
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("Cannot create directory");
}
File file = new File(dir, fileName);
if (!file.createNewFile() && !file.exists()) {
throw new IOException("Cannot create file");
}
// 처리
try (FileWriter writer = new FileWriter(file)) {
writer.write("data");
}
}
}
// 새 코드 (Path + Files)
public class NewFileService {
public void process(String dirPath, String fileName) throws IOException {
Path file = Path.of(dirPath, fileName);
Files.createDirectories(file.getParent());
Files.writeString(file, "data");
// 명확, 간결
}
}
// 점진적 마이그레이션
// 1. File ↔ Path 변환 활용
// 2. 새 메서드만 Path 로
// 3. 점진적으로 옛 코드 교체
File 의 한계와 NIO.2 의 해결은?
답:
1. 에러 처리: boolean → 구체적 예외
2. 디렉토리: mkdirs → Files.createDirectories
3. 심볼릭 링크: 모호 → LinkOption
4. 속성: 빈약 → BasicFileAttributes, PosixFileAttributes
5. 순회: 메모리 → Stream
6. FS 추상화: 로컬만 → FileSystem (ZIP 등)
7. 비동기: X → AsynchronousFileChannel
권장:
Phase 7 — I/O 시스템 큰 그림
Unit 7.1 — I/O 란 무엇인가
- JVM 기준 Input/Output
- I/O 의 4가지 종류
- 자바 I/O 진화 (1.0 → 1.4 → 7)
- I/O 모델 4가지 (Blocking/Non-blocking × Sync/Async)
Unit 7.2 — IO vs NIO (역사적 진화)
- 3 시대 비교
- File vs Files
- Decorator 패턴
- 채널 + 버퍼
Unit 7.3 — Stream vs Channel
- Buffer 의 4속성 정밀
- Heap vs Direct Buffer
- zero-copy
- MappedByteBuffer
Unit 7.4 — Blocking vs Non-blocking (★ 마스터)
- OS 레벨 동작
- Selector 정밀
- 동시성 모델 4가지
- 1만 연결 시나리오
- Tomcat/Netty/WebFlux/Loom
Unit 7.5 — 오버헤드와 File 객체
- 오버헤드의 정의
- I/O 오버헤드 4지점
- File 메서드 정밀
- 자주 발생하는 함정
1. I/O 모델 선택
- 시나리오 분석
- Blocking/Non-blocking/Reactive/Loom 선택
2. 효율적 파일 작업
- Stream 활용
- Buffered + Direct Buffer
- zero-copy
3. 대규모 동시 연결
- Selector, Netty 이해
- WebFlux 활용
- Project Loom
4. 함정 회피
- 디렉토리 자동 생성 X
- mkdir vs mkdirs
- listFiles null
- TOCTOU race condition
5. 마이그레이션
- File → Path
- 점진적 전환
- 변환 메서드 활용
6. 면접 자신감
- I/O 의 본질
- JVM 기준 I/O
- 동시성 모델
- C10K 문제
- 자바 I/O 의 진화
I/O 의 본질:
- JVM 기준 데이터 흐름
- 외부 ↔ 내부
3 시대 진화:
IO (1.0) → NIO (1.4) → NIO.2 (7)
- 단방향 → 양방향
- Blocking → Non-blocking
- File → Path + Files
동시성:
- Thread-per-connection (단순, 한계)
- Event Loop (효율, 복잡)
- Reactive (함수형)
- Virtual Threads (미래)
실무 함정:
- 디렉토리 자동 생성 X
- 예외 마스킹
- 메모리 효율 (Stream vs 배열)
- 자원 누수
다음 Phase 는 자바 IO Stream 의 실전.
Phase 8 — Stream 실전 (6 Unit)
Unit 8.1 — System.in (한글 안 되는 이유)
- System.in 의 정체
- 1바이트씩 vs 한글
- 인코딩 이슈
Unit 8.2 — FileInputStream
- 파일 바이트 스트림
- read() 의 정밀
- 파일 끝 (-1)
Unit 8.3 — byte[] 배열로 효율적 읽기
- 버퍼링
- 마지막 읽기 함정 (n 만큼만)
Unit 8.4 — FileOutputStream
- 파일 쓰기
- 이어쓰기 모드 (append)
- 인코딩 매핑
Unit 8.5 — 한글 처리 (FileReader, InputStreamReader)
- Reader vs InputStream
- 인코딩 명시
- 두 방식 비교
Unit 8.6 — FileWriter (한글 쓰기)
- Writer 의 정밀
- 다양한 write 메서드
Phase 7: I/O 의 큰 그림 (개념)
↓
Phase 8: Stream 의 실전 (코드)
↓
Phase 9: Stream 의 강화 (Buffered/Data/Serialization)
↓
Phase 10: 함수형 (람다, Stream API)
연결:
- Phase 7 의 개념을 Phase 8 에서 코드로
- Phase 8 의 기본 위에 Phase 9 의 보조 스트림
- Phase 10 의 함수형 Stream API (다른 개념)
✅ Phase 1 — Pass by Value (1.1 ~ 1.3 완주, 3 Unit)
✅ Phase 2 — 컬렉션 프레임워크 (2.1 ~ 2.6 완주, 6 Unit)
✅ Phase 3 — 해시의 원리 (3.1 ~ 3.4 완주, 4 Unit)
✅ Phase 4 — 추상화의 두 도구 (4.1 ~ 4.4 완주, 4 Unit)
✅ Phase 5 — 제네릭과 와일드카드 (5.1 ~ 5.5 완주, 5 Unit)
✅ Phase 6 — 객체 비교 (6.1 ~ 6.4 완주, 4 Unit)
✅ Phase 7 — I/O 시스템 큰 그림 (7.1 ~ 7.5 완주, 5 Unit) ← 여기
🚀 Phase 8 — Stream 실전 (6 Unit 예정)
⏭ Phase 9 — I/O 강화 (5 Unit)
⏭ Phase 10 — 함수형 프로그래밍 (4 Unit)
총: 31/43 Unit 작성 (Phase 7 완주, 약 72%)
Phase 7 학습의 종합은?
답:
1. 개념:
정밀:
동시성:
실무:
미래:
| Q | 핵심 답변 |
|---|---|
| 오버헤드 정의? | 본 작업 외 간접 비용 |
| I/O 오버헤드 4지점? | 시스템 호출, 컨텍스트 스위칭, 버퍼 복사, 추상화 |
| File 정의? | 경로의 추상화, Java 1.0+ |
| createNewFile vs Files.createFile? | boolean vs 예외 |
| getAbsolutePath vs getCanonicalPath? | 절대 경로 vs 정규화 (심볼릭 링크 해소) |
| 디렉토리 자동 생성? | X — mkdirs() 또는 Files.createDirectories() |
| mkdir vs mkdirs? | 단일 vs 중간 모두 |
| File 의 한계 7가지? | boolean/디렉토리/심볼릭/속성/순회/FS/비동기 |
| listFiles null? | 권한, 오류, 디렉토리 아님 |
| File → Path? | toPath(), Path.of |
| TOCTOU? | 시간차 race condition |
답:
답:
File temp = new File("temp.txt");
temp.deleteOnExit();
// JVM 종료 시 자동 삭제
// 단, 정상 종료만 (kill -9 같은 강제 종료는 X)
답:
File f1 = new File("/path/file.txt");
File f2 = new File("/path/./file.txt");
f1.equals(f2); // false (다른 경로 문자열)
// 동일성 비교는
f1.getCanonicalFile().equals(f2.getCanonicalFile()); // true
답:
// File
File temp = File.createTempFile("prefix", ".txt");
// /tmp/prefix12345.txt 같은
temp.deleteOnExit();
// NIO.2
Path temp = Files.createTempFile("prefix", ".txt");
Path tempDir = Files.createTempDirectory("dirPrefix");
// 사용 후 삭제 권장
try (Stream<...> s = ...) {
// 처리
} finally {
Files.deleteIfExists(temp);
}
답:
// FileInputStream 도 Path 받음 (Java 7+)
FileInputStream fis = new FileInputStream("file.txt");
// 그래도 NIO.2 권장
InputStream is = Files.newInputStream(Path.of("file.txt"));
1. 오버헤드와 I/O
2. File 의 한계
3. NIO.2 가 해결
🚀 Phase 7 — I/O 시스템 큰 그림
✅ Unit 7.1 I/O 란 무엇인가
✅ Unit 7.2 IO vs NIO (역사적 진화)
✅ Unit 7.3 Stream vs Channel
✅ Unit 7.4 Blocking vs Non-blocking (★ 마스터 깊이)
✅ Unit 7.5 오버헤드와 File 객체 ← 여기, Phase 7 완주
→ 자바 I/O 시스템 정복
→ JVM 기준 I/O 의 본질
→ 3 시대 진화 (IO/NIO/NIO.2)
→ 동시성 모델 4가지 마스터
→ 실무 함정 회피
다음 Phase 는 자바 IO Stream 의 실전.
Phase 8 — Stream 실전 (6 Unit)
Unit 8.1 — System.in (한글 안 되는 이유)
Unit 8.2 — FileInputStream
Unit 8.3 — byte[] 배열로 효율적 읽기
Unit 8.4 — FileOutputStream
Unit 8.5 — 한글 처리 (FileReader, InputStreamReader)
Unit 8.6 — FileWriter (한글 쓰기)
Phase 7 와의 연결: