Day 39 — 모던 파일 API: 한 줄로 끝내는 java.nio
목차 19
지난 시간 Day 38 에서 우리는 java.io 로 파일을 읽고 쓰는 법을 배웠어요. 전원을 꺼도 사라지지 않는 파일에 데이터를 저장하고, 다시 불러오는 도구를 손에 넣었죠. 그런데 마지막에 한 가지 아쉬움이 남았어요. 파일 한 줄을 읽는 일조차 손이 꽤 많이 갔다는 거예요.
기억을 더듬어볼게요. 캡션 파일을 읽으려고 BufferedReader 로 감싸고, while 로 빙빙 돌리고, readLine() 으로 한 줄씩 꺼내 리스트에 모았죠. "파일 내용을 그냥 통째로 줘" 같은 단순한 일에도 코드가 대여섯 줄은 필요했어요. 자주 하는 일이 이렇게 번거로우면, 매번 똑같은 코드를 쓰다 실수도 생겨요.
자바도 이 불편을 알고 있었어요. 그래서 더 현대적인 파일 도구 묶음을 따로 만들었는데, 그게 바로 오늘 배울 java.nio 예요. nio 는 "New I/O" 의 줄임말이에요. Files.readString(파일) 한 줄이면 파일 전체가 문자열로 들어오고, 폴더 안의 파일을 훑는 것도 간단해져요. 지난 시간의 java.io 가 든든한 바탕이 되어줄 거예요. 시작해봐요!
🎯 학습 목표
java.io.File의 후계자인Path가 무엇인지 — "경로를 가리키는 순수 객체" 로 이해해요.Files의 한 줄 메서드로 파일을 읽고(readString/readAllLines/lines) 써요(writeString/write).StandardOpenOption으로 "덮어쓰기 / 이어쓰기" 를 골라요.- 파일과 폴더를 다뤄요 —
exists·createDirectories·copy·move·deleteIfExists·size. - 폴더를 통째로 훑어요 —
Files.list(한 단계)와Files.walk(재귀), 그리고 Stream 을 안전하게 닫는 법. WatchService로 파일이 새로 생기면 알림을 받아요 (폴링 vs 이벤트).- 버퍼와 채널 의 개념을 잡고, nio 가 왜 큰 파일에 효율적인지 이해해요.
오늘의 로드맵
- Step 1 —
File의 후계자,Path를 만나요 (경로를 가리키는 순수 객체). - Step 2 — 파일 읽기, 이제 한 줄이면 돼요 (
Files.readString/readAllLines/lines). - Step 3 — 파일 쓰기도 한 줄로 (
Files.writeString/write+StandardOpenOption). - Step 4 — 파일과 폴더 다루기 (복사·이동·삭제·용량).
- Step 5 — 업로드 폴더 통째로 훑기 (
list/walk+ Stream 닫기). - Step 6 — 파일이 바뀌면 알려줘요 (
WatchService). - Step 7 — 버퍼와 채널, nio 의 진짜 엔진.
- Step 8 — java.io vs java.nio, 그리고 다음 여정.
Step 1: File 의 후계자, Path 를 만나요
지난 시간 우리는 파일 위치를 java.io.File 로 가리켰어요. new File("uploads/post1.jpg") 처럼요. 잘 동작했죠. 그런데 이 File 은 한 가지 어정쩡한 점이 있었어요. 경로를 가리키는 일도 하고, 파일을 읽고 쓰는 일도 일부 거들고, 폴더 목록을 가져오는 일까지 한 객체가 다 떠맡았거든요. 한 사람이 여러 일을 겸하면 코드가 헷갈리기 쉬워요.
java.nio 는 이 역할을 둘로 깔끔하게 나눴어요. 경로를 가리키는 일 은 Path 가, 실제로 읽고 쓰는 일 은 Files 가 맡아요. Path 는 주소를 적은 쪽지, Files 는 그 주소로 찾아가 일하는 일꾼이라고 보면 돼요.
java.io.File (Day 38, 예전 방식)
File 객체 하나가 → 경로도 알고 + 읽기·쓰기도 거들고 + 목록도 가져온다 (여러 역할 겸함)
java.nio (Day 39, 모던 방식)
Path → 경로(주소)만 담당. 파일에 손대지 않음
Files → 읽기·쓰기·복사·삭제 담당. Path 를 받아 실제로 일함
→ 역할이 나뉘어 더 깔끔하고 헷갈리지 않음
Path 를 만드는 법은 Path.of(...) 예요. 그리고 경로를 이어 붙일 때는 슬래시를 문자열로 직접 잇지 말고 resolve(...) 를 써요. 그래야 윈도우(\)든 맥(/)이든 운영체제에 맞는 구분자를 자바가 알아서 넣어줘요.
// com/instagram/javabasic/nio/PathBasicsDemo.java
// 경로 조립: 문자열을 직접 잇지 않고 of/resolve 로 안전하게 이어요.
Path base = Path.of("uploads");
Path userDir = base.resolve("jaehoon"); // uploads/jaehoon
Path photo = userDir.resolve("post1.jpg"); // uploads/jaehoon/post1.jpg
System.out.println("파일 이름 → " + photo.getFileName()); // post1.jpg
System.out.println("부모 폴더 → " + photo.getParent()); // uploads/jaehoon
System.out.println("경로 깊이 → " + photo.getNameCount()); // 3
getFileName() 은 맨 끝 이름(post1.jpg), getParent() 는 부모 폴더(uploads/jaehoon), getNameCount() 는 경로가 몇 단계인지(3)를 알려줘요. 경로를 문자열로 직접 자르고 붙이느라 고생할 필요 없이, Path 가 메서드로 다 꺼내줘요.
여기서 가장 중요한 한 가지. Path 는 경로를 가리키기만 해요. 위 코드를 실행해도 실제 파일을 읽거나 만들지 않아요. uploads/jaehoon/post1.jpg 라는 파일이 디스크에 없어도 Path 객체는 멀쩡히 만들어져요. 그냥 "이런 주소" 라고 적어둔 쪽지일 뿐이니까요. 진짜 파일 작업은 다음 Step 부터 Files 가 맡아요.
💡
Path는 파일 경로를 가리키는 순수 객체예요(java.io.File의 후계자).Path.of로 만들고resolve로 안전하게 이어 붙여요.Path자체는 디스크에 손대지 않아요 — 실제 읽기·쓰기는Files가 해요.
Step 2: 파일 읽기, 이제 한 줄이면 돼요
자, 오늘의 하이라이트예요. 지난 시간 가장 손이 많이 갔던 "파일 읽기" 가 얼마나 간단해지는지 보여드릴게요. 먼저 Day 38 에서 캡션 파일을 줄 단위로 읽던 코드를 다시 볼게요.
// Day 38 방식 (java.io) — 한 줄 읽는 데 이만큼 필요했어요
public List<String> readLines(File file) throws IOException {
List<String> lines = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new FileReader(file, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
}
return lines;
}
BufferedReader 로 감싸고, 빈 리스트를 만들고, while 로 돌리며 readLine() 한 줄씩 꺼내 담았죠. 틀린 코드는 아니지만, "그냥 줄 목록을 달라" 는 단순한 부탁치고는 길어요. 이제 java.nio 로 같은 일을 해볼게요.
// com/instagram/javabasic/nio/NioCaptionReader.java
// 파일 전체를 문자열 하나로 읽어요. 지난 시간 여러 줄 코드가 이 한 줄이 됐어요.
public String readWhole(Path file) throws IOException {
return Files.readString(file);
}
// 파일을 줄 단위 리스트로 읽어요.
public List<String> readLines(Path file) throws IOException {
return Files.readAllLines(file);
}
Files.readString(file) 은 파일 전체를 문자열 하나로, Files.readAllLines(file) 은 줄 단위 리스트로 읽어줘요. 그 길던 while 묶음이 단 한 줄 로 줄었어요. 게다가 인코딩 걱정도 없어요. 이 메서드들은 기본이 UTF-8 이라, 따로 지정하지 않아도 한글이 깨지지 않아요.
[java.io] BufferedReader 감싸기 → while 반복 → readLine() → 리스트에 모으기 (여러 줄)
↓ 같은 일을
[java.nio] Files.readAllLines(file) (한 줄!)
그런데 여기서 한 가지 의문이 생겨요. 파일이 엄청 크면 어떡하죠? readAllLines 는 파일 전체를 한꺼번에 메모리에 올려요. 작은 캡션 파일이야 괜찮지만, 수백만 줄짜리 로그 파일이면 메모리가 버거워요. 그럴 땐 Files.lines 를 써요. 파일을 통째로 올리지 않고 한 줄씩 흘려 보내는 Stream 을 돌려줘요.
// 큰 파일은 Stream 으로 한 줄씩. Stream 이 파일을 여니까 try-with-resources 로 꼭 닫아요.
public long countNonEmptyLines(Path file) throws IOException {
try (Stream<String> lines = Files.lines(file)) {
return lines.filter(line -> !line.isBlank()).count();
}
}
Day 26~27 에서 배운 Stream 이 여기서 빛나요. filter 로 빈 줄을 걸러내고 count 로 세는 흐름이 그대로 통해요. 다만 한 가지 주의할 점이 있어요. Files.lines 가 돌려준 Stream 은 실제 파일을 열어둔 채 한 줄씩 읽어요. 그래서 다 쓰면 닫아야 해요. 그래서 try (...) 소괄호 안에 넣었죠. 이 "Stream 도 닫아야 한다" 는 이야기는 Step 5 에서 다시 만나요.
🙋 학생 질문 — "튜터님, readAllLines 는 안 닫는데 Files.lines 는 왜 닫아요?"
좋은 질문이에요! 둘 다 파일을 읽지만 "읽는 시점" 이 달라요.
Files.readAllLines 는 호출하는 순간 파일을 처음부터 끝까지 다 읽어서 리스트에 담고, 파일을 바로 닫아요. 돌려받은 건 이미 메모리에 다 올라온 리스트라, 우리가 닫을 게 없어요.
반면 Files.lines 는 "필요할 때 한 줄씩" 읽는 게으른 방식이에요. 우리가 Stream 을 다 소비할 때까지 파일을 열어둬요. 그래서 다 쓰고 나면 우리가 닫아줘야 그 열린 파일이 반납돼요. try-with-resources 에 맡기면 알아서 닫히니 안전해요.
기억하기 쉽게 정리하면, "한 번에 다 읽는 메서드(readString·readAllLines)" 는 안 닫아도 되고, "Stream 을 돌려주는 메서드(lines·list·walk)" 는 닫아야 해요.
💡
Files.readString/readAllLines는 파일을 한 줄로 읽고 UTF-8 이 기본이라 한글이 안 깨져요. 큰 파일은Files.lines로 한 줄씩 흘려 읽되, 파일을 열어둔 Stream 이므로try-with-resources로 닫아요.
Step 3: 파일 쓰기도 한 줄로
읽기가 한 줄이 됐으니 쓰기도 당연히 간단해졌겠죠? 지난 시간 BufferedWriter 로 감싸고 newLine() 으로 줄바꿈을 넣던 일이 Files 한 줄로 줄어요.
// com/instagram/javabasic/nio/NioCaptionWriter.java
// 파일 전체를 한 번에 써요. (없으면 생성, 있으면 통째로 덮어쓰기)
public void writeWhole(Path file, String content) throws IOException {
Files.writeString(file, content);
}
// 여러 줄을 한 번에 써요. 줄바꿈은 Files 가 알아서 넣어줘요.
public void writeLines(Path file, List<String> lines) throws IOException {
Files.write(file, lines);
}
Files.writeString(file, content) 은 문자열 하나를 통째로 파일에 써요. 파일이 없으면 새로 만들고, 이미 있으면 기존 내용을 지우고 덮어써요. Files.write(file, lines) 는 줄 리스트를 받아 줄바꿈까지 알아서 넣어 저장해요. 역시 둘 다 UTF-8 이 기본이라 인코딩을 따로 적지 않아도 돼요.
그런데 "덮어쓰기" 가 항상 좋은 건 아니에요. 게시물에 댓글이 새로 달릴 때마다 파일 전체를 덮어쓰면, 기존 댓글이 다 날아가고 마지막 것만 남잖아요. 이럴 땐 기존 내용 뒤에 이어 붙여야 해요. 이때 쓰는 게 StandardOpenOption 이에요. "파일을 어떻게 열까" 를 고르는 옵션 묶음이죠.
// 기존 파일 끝에 한 줄 덧붙여요. (댓글이 새로 달릴 때처럼)
public void appendLine(Path file, String line) throws IOException {
Files.writeString(file, line + System.lineSeparator(),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
옵션 이름이 그대로 뜻이에요. CREATE 는 "파일이 없으면 만들어라", APPEND 는 "끝에 덧붙여라" 예요. 둘을 같이 주면 "없으면 만들고, 있으면 끝에 이어 써라" 가 되죠. 자주 쓰는 옵션 셋만 기억해두면 충분해요.
| 옵션 | 뜻 |
|---|---|
CREATE |
파일이 없으면 새로 만들어요 |
APPEND |
기존 내용 끝에 이어 써요 (덮어쓰지 않음) |
TRUNCATE_EXISTING |
기존 내용을 비우고 처음부터 써요 (덮어쓰기) |
System.lineSeparator() 는 운영체제에 맞는 줄바꿈 기호를 돌려줘요. 윈도우와 맥의 줄바꿈이 미묘하게 다른데, 이걸 쓰면 자바가 알아서 맞춰주니 편해요.
💡
Files.writeString/write로 파일을 한 줄에 써요(UTF-8 기본). 기본 동작은 "덮어쓰기" 라, 댓글 추가처럼 이어 붙이려면StandardOpenOption.CREATE+APPEND를 함께 줘요.
Step 4: 파일과 폴더 다루기
지금까지는 파일 안의 내용을 읽고 썼어요. 이번엔 파일과 폴더 자체를 다뤄볼게요. 있는지 확인하고, 폴더를 만들고, 복사하고, 옮기고, 지우고, 용량을 재는 일이에요. 인스타그램에서 사진을 업로드하면 임시 폴더에 잠깐 뒀다가 영구 폴더로 옮기고, 썸네일을 따로 복사해 만들죠. 그런 일들이에요.
Files 에는 이런 일을 하는 메서드가 이름 그대로 준비돼 있어요. 하나씩 볼게요.
// com/instagram/javabasic/nio/MediaFileManager.java
// 중간 폴더가 없어도 한 번에 다 만들어줘요. (uploads/jaehoon/2026 처럼 깊어도 OK)
public void ensureDir(Path dir) throws IOException {
Files.createDirectories(dir);
}
// 썸네일을 만들 때처럼 원본은 두고 복사해요.
public void copyFile(Path src, Path dest) throws IOException {
Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING);
}
// 임시 폴더에 올라온 사진을 영구 폴더로 옮겨요. (원본은 사라져요)
public void moveFile(Path src, Path dest) throws IOException {
Files.move(src, dest, StandardCopyOption.REPLACE_EXISTING);
}
createDirectories 가 특히 고마워요. uploads/jaehoon/2026 처럼 중간 폴더가 여러 단계 없어도, 한 번 호출하면 빠진 폴더를 전부 만들어줘요. copy 는 원본을 그대로 두고 사본을 만들고(썸네일 생성), move 는 원본을 옮겨서 원래 자리에서는 사라지게 해요(임시 → 영구). StandardCopyOption.REPLACE_EXISTING 은 "대상 위치에 이미 파일이 있으면 덮어써도 돼" 라는 허락이에요. 이게 없는데 같은 이름 파일이 있으면 예외가 나요.
나머지 셋도 간단해요.
public boolean exists(Path path) {
return Files.exists(path);
}
// 있으면 지우고 true, 원래 없었으면 false. (없어도 예외가 안 나요)
public boolean removeIfExists(Path path) throws IOException {
return Files.deleteIfExists(path);
}
// 파일 용량을 바이트로 재요. (업로드 용량 제한 검사에 써요)
public long fileSize(Path path) throws IOException {
return Files.size(path);
}
deleteIfExists 가 영리해요. 그냥 delete 는 파일이 없으면 예외를 던지는데, deleteIfExists 는 없으면 조용히 false 만 돌려줘요. "있으면 지우고, 없으면 말고" 같은 상황에 딱이에요. size 는 파일이 몇 바이트인지 알려줘서, "10MB 넘는 사진은 거부" 같은 검사에 써요. 이 메서드들의 동작은 코드베이스 MediaFileManagerTest.java 에서 폴더 생성·복사·이동·삭제·용량까지 케이스별로 검증해뒀어요.
💡
Files는 폴더 만들기(createDirectories, 중간 폴더까지)·복사(copy)·이동(move)·삭제(deleteIfExists, 없어도 안전)·용량(size)을 메서드 한 줄로 제공해요. 덮어쓰기를 허용하려면StandardCopyOption.REPLACE_EXISTING을 줘요.
Step 5: 업로드 폴더 통째로 훑기
이번엔 폴더 안을 들여다볼게요. 인스타그램이라면 "이 사용자의 업로드 폴더에 사진이 몇 장 있지?" 같은 걸 알고 싶을 때가 있어요. 폴더 안의 파일을 하나씩 훑는 일이죠. java.nio 는 이걸 Stream 으로 돌려줘서, Day 26~27 에서 배운 filter·count 가 그대로 통해요.
폴더를 훑는 방법은 두 가지예요. 한 단계만 볼지, 하위 폴더 속까지 파고들지에 따라 나뉘어요.
Files.list(dir) 한 단계만: dir 바로 아래 항목만 본다 (하위 폴더 속은 안 들어감)
Files.walk(dir) 재귀로: dir 아래 + 하위 폴더 속 + 그 속의 속까지 전부 훑는다
먼저 한 단계만 보는 Files.list 예요.
// com/instagram/javabasic/nio/UploadFolderScanner.java
// 폴더 바로 아래 항목만 이름순으로 모아요.
public List<Path> listDirectly(Path dir) throws IOException {
try (Stream<Path> entries = Files.list(dir)) {
return entries.sorted().toList();
}
}
Files.list 는 폴더 바로 아래의 파일과 폴더를 Stream 으로 돌려줘요. sorted() 로 이름순 정렬하고 toList() 로 리스트를 만들었죠. 그런데 또 try (...) 가 보이네요. 맞아요, Files.list 도 Step 2 의 Files.lines 처럼 파일 시스템을 열어둔 Stream 이에요. 그래서 반드시 닫아야 해요. 이게 java.nio 의 디렉토리 순회에서 가장 자주 빠뜨리는 부분이라, 꼭 기억해두세요.
이제 하위 폴더 속까지 재귀로 훑는 Files.walk 예요. 사용자별 폴더가 여러 개 있고 그 안에 사진이 흩어져 있어도, 한 번에 전부 셀 수 있어요.
// 하위 폴더까지 재귀로 훑어 .jpg 파일 개수를 세요.
public long countJpgRecursively(Path root) throws IOException {
try (Stream<Path> paths = Files.walk(root)) {
return paths
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith(".jpg"))
.count();
}
}
Files.walk 는 시작 폴더부터 그 아래 모든 깊이의 항목을 하나하나 흘려보내요. 여기서 filter 두 개를 거쳤어요. Files::isRegularFile 로 폴더는 빼고 진짜 파일만 남기고(Day 25 메서드 참조 기억나죠?), 이름이 .jpg 로 끝나는 것만 골라 count 로 셌어요. 폴더 구조가 아무리 깊어도 이 한 흐름이면 끝이에요.
uploads/
├── jaehoon/
│ ├── a.jpg ← walk 가 훑음 ✓
│ └── b.jpg ← walk 가 훑음 ✓
└── minji/
├── c.jpg ← walk 가 훑음 ✓
└── memo.txt ← .jpg 아니라 filter 에서 제외
→ 결과: .jpg 3개
코드베이스 UploadFolderScannerTest.java 에서 이렇게 폴더를 만들어두고 돌리면 .jpg 가 정확히 3개로 세지는 걸 확인해뒀어요. memo.txt 는 filter 에서 걸러지죠.
🙋 학생 질문 — "튜터님, Stream 을 안 닫으면 바로 에러가 나나요? 테스트는 잘 돌던데요."
당장은 멀쩡해 보일 수 있어요. 그래서 더 위험해요.
Files.list 나 walk 가 연 Stream 을 닫지 않으면, 그 폴더를 향한 "열린 통로" 가 반납되지 않고 남아요. 작은 프로그램에서 한두 번이면 티가 안 나요. 하지만 서버가 폴더를 수천 번 훑는데 매번 닫기를 빠뜨리면, 열린 통로가 계속 쌓여서 결국 "더는 못 열어" 하고 멈추는 사고로 이어져요.
그래서 Files.lines·list·walk 처럼 Stream 을 돌려주는 메서드는 항상 try (...) 소괄호 안에서 받는 습관을 들이는 게 좋아요. 닫는 걸 사람이 기억하는 대신 try-with-resources 에게 맡기면 빠뜨릴 일이 없어요.
💡
Files.list는 한 단계만,Files.walk는 하위 폴더까지 재귀로 훑어요. 둘 다 Stream 을 돌려주니filter·count가 그대로 통하고, 파일을 열어둔 Stream 이라 반드시try-with-resources로 닫아야 해요.
Step 6: 파일이 바뀌면 알려줘요 — WatchService
지금까지 우리가 폴더를 들여다본 방식은 전부 "우리가 먼저 물어보는" 거였어요. list 하고, walk 하고, 우리가 능동적으로 "지금 뭐 있어?" 하고 확인했죠. 그런데 반대 상황을 생각해볼까요. "새 사진이 업로드되면 그때 알려줘" 처럼, 변화가 생길 때 통보를 받고 싶은 경우예요.
이걸 우리가 직접 하려면 어떻게 해야 할까요? 1초마다 폴더를 list 해서 이전과 비교하는 수밖에 없어요. 이렇게 끊임없이 직접 확인하러 가는 방식을 폴링(polling) 이라고 해요. Day 33 에서 인터럽트를 이야기할 때 살짝 나왔던 그 개념이에요. 폴링은 변화가 없는데도 계속 확인하느라 자원을 낭비해요.
java.nio 는 더 똑똑한 길을 줘요. 바로 WatchService 예요. 폴더를 감시해 달라고 운영체제에 등록해두면, 변화가 생겼을 때 운영체제가 "여기 새 파일 생겼어요!" 하고 알려줘요. 우리가 계속 확인하러 갈 필요가 없죠. 이렇게 변화가 생길 때 통보받는 방식을 이벤트(event) 라고 해요.
폴링(polling): 나 → "변했어?" → 폴더 (계속 직접 물어봄, 변화 없어도 낭비)
나 → "변했어?" → 폴더
나 → "변했어?" → 폴더 ...
이벤트(event): 폴더 ──"새 파일 생겼어요!"──▶ 나 (변할 때만 알려줌, 효율적)
(WatchService 가 운영체제의 알림을 받아 전달)
코드로 보면 흐름이 이래요. 감시 서비스를 만들고, 어떤 폴더의 어떤 변화를 지켜볼지 등록한 다음, 알림이 올 때까지 기다려요.
// com/instagram/javabasic/nio/UploadFolderWatcher.java
// dir 에 새 파일이 생기길 timeout 동안 기다려요. 생기면 그 파일 이름을 돌려줘요.
public Optional<String> waitForNextFile(Path dir, Duration timeout) throws IOException, InterruptedException {
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
// 이 폴더에서 "새 파일 생성(ENTRY_CREATE)" 이벤트를 지켜봐 달라고 등록해요.
dir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE);
// timeout 만큼만 기다려요. 이벤트가 오면 key, 안 오면 null.
WatchKey key = watchService.poll(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (key == null) {
return Optional.empty();
}
for (WatchEvent<?> event : key.pollEvents()) {
Path created = (Path) event.context(); // 새로 생긴 파일 이름
return Optional.of(created.toString());
}
return Optional.empty();
}
}
흐름을 따라가볼게요. newWatchService() 로 감시자를 하나 만들고, dir.register(...) 로 "이 폴더에서 새 파일이 생기면(ENTRY_CREATE) 알려줘" 라고 등록해요. 그다음 poll(...) 로 알림을 기다리는데, 알림이 오면 key 에 정보가 담기고, 정해둔 시간이 지나도 아무 변화가 없으면 null 이 와요. 알림이 왔다면 key.pollEvents() 로 무슨 일이 있었는지 꺼내고, event.context() 로 새로 생긴 파일 이름을 알아내죠. 결과는 Day 28 에서 배운 Optional 로 감싸서 "찾았으면 이름, 못 찾았으면 비어 있음" 을 분명하게 표현했어요.
여기서 작은 설계 선택을 하나 짚을게요. 원래 WatchService 에는 알림이 올 때까지 무한정 기다리는 take() 메서드도 있어요. 하지만 무한정 멈추면 그 스레드는 다른 일을 못 하니, 보통은 Day 33~37 에서 배운 것처럼 감시를 별도 스레드에서 돌려요. 여기서는 입문용으로 "정해진 시간만 기다리는" poll(timeout) 을 써서, 변화가 없으면 깔끔하게 빠져나오게 했어요. 이 동작은 코드베이스 UploadFolderWatcherTest.java 에서 새 파일이 생기면 그 이름을 감지하고, 변화가 없으면 비어 있음을 돌려주는 두 경우로 검증해뒀어요.
💡
WatchService는 폴더를 감시하다 변화가 생기면 알려주는 이벤트 방식이에요. 우리가 계속 확인하는 폴링보다 효율적이에요.register로 지켜볼 폴더·이벤트를 등록하고,take()(무한 대기) 또는poll(timeout)(시간 제한)으로 알림을 받아요.
Step 7: 버퍼와 채널, nio 의 진짜 엔진
마지막으로 java.nio 의 밑바닥을 살짝 들여다볼게요. 지금까지 쓴 Files 메서드들이 편했던 건, 사실 그 아래에서 채널(Channel) 과 버퍼(Buffer) 라는 엔진이 돌고 있기 때문이에요. nio 의 "nio" 다움이 여기 있어요. 깊이 파고들 필요는 없고, 개념만 잡아두면 "왜 nio 가 큰 파일에 빠른가" 가 이해돼요.
지난 시간 배운 스트림(java.io) 을 떠올려보세요. 데이터가 한 방향으로, 한 바이트씩 줄지어 흘렀어요. 수도관처럼요. 단순하고 좋지만, 큰 파일을 한 바이트씩 나르면 느려요.
채널(java.nio) 은 다르게 일해요. 데이터를 한 바이트씩 흘리지 않고, 버퍼(통) 에 한 덩어리씩 퍼 담아 옮겨요. 게다가 채널은 양방향이에요. 한 통으로 읽기도 쓰기도 하죠. 큰 파일을 덩어리째 옮기니 훨씬 효율적이에요.
스트림(java.io): 데이터가 ──한 바이트씩──▶ 한 방향으로 흐른다 (단순, 작은 데이터에 OK)
채널(java.nio): [파일] ◀──버퍼(통)에 덩어리째──▶ [파일] (효율적, 큰 파일에 강함)
큰 동영상 파일을 복사하는 가장 빠른 길은 채널끼리 직접 옮기는 거예요. transferTo 한 줄이면 "이 채널 내용을 저 채널로 통째로 옮겨" 라고 운영체제에 맡길 수 있어요.
// com/instagram/javabasic/nio/ChannelCopyDemo.java
// 채널끼리 통째로 옮겨요. 큰 미디어 파일을 복사할 때 가장 빠른 길이에요.
public void copyViaChannel(Path src, Path dest) throws IOException {
try (FileChannel in = FileChannel.open(src, StandardOpenOption.READ);
FileChannel out = FileChannel.open(dest,
StandardOpenOption.CREATE, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
in.transferTo(0, in.size(), out);
}
}
버퍼가 실제로 어떻게 움직이는지도 한 번만 풀어볼게요. 채널이 속으로 하는 일을 손으로 직접 쓴 모습이에요.
// 버퍼에 담아 한 덩어리씩 옮기는 모습. 채널이 속으로 하는 일을 직접 풀어 본 거예요.
public void copyViaBuffer(Path src, Path dest) throws IOException {
try (FileChannel in = FileChannel.open(src, StandardOpenOption.READ);
FileChannel out = FileChannel.open(dest,
StandardOpenOption.CREATE, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING)) {
ByteBuffer buffer = ByteBuffer.allocate(1024); // 1KB 짜리 통
while (in.read(buffer) != -1) { // 통을 채워요
buffer.flip(); // 채우기 → 비우기 모드로 전환
out.write(buffer); // 통을 비워 파일에 써요
buffer.clear(); // 다시 채울 준비
}
}
}
ByteBuffer.allocate(1024) 로 1KB 짜리 통을 만들고, 파일에서 통을 채우고(read) → 방향을 바꾸고(flip) → 통을 비워 쓰고(write) → 다시 비우는(clear) 동작을 반복해요. 채우고 비우길 반복하며 파일을 옮기는 거죠. 실무에서는 이렇게 손으로 쓸 일이 거의 없고 Files.copy 나 transferTo 를 쓰지만, "버퍼에 담아 덩어리째 옮긴다" 는 nio 의 원리를 한 번 눈으로 보는 게 중요해요.
⚠️ 이 버퍼·채널 부분은 지금 당장 외우지 않아도 괜찮아요. "스트림은 한 바이트씩, 채널은 버퍼에 덩어리째" 라는 그림 하나만 가져가세요. 나중에 대용량 파일이나 네트워크를 다룰 때 이 개념이 다시 찾아올 거예요.
💡 채널과 버퍼는 nio 의 밑바닥 엔진이에요. 스트림이 한 바이트씩 흐른다면, 채널은 버퍼(통)에 덩어리째 담아 양방향으로 옮겨서 큰 파일에 효율적이에요. 큰 파일 복사는
FileChannel.transferTo한 줄이 가장 빨라요.
Step 8: java.io vs java.nio, 그리고 다음 여정
오늘 배운 java.nio 와 지난 시간의 java.io 를 나란히 정리해볼게요. 둘은 경쟁자가 아니라, 같은 일을 다른 방식으로 푸는 도구예요.
| 하고 싶은 일 | java.io (Day 38) | java.nio (Day 39) |
|---|---|---|
| 경로 가리키기 | new File("...") |
Path.of("...") |
| 파일 전체 읽기 | BufferedReader + while + readLine |
Files.readString(path) |
| 줄 단위 읽기 | BufferedReader.readLine() 반복 |
Files.readAllLines(path) |
| 파일 쓰기 | BufferedWriter + write + newLine |
Files.writeString(path, ...) |
| 폴더 만들기 | file.mkdirs() |
Files.createDirectories(path) |
| 폴더 순회 | file.listFiles() 직접 재귀 |
Files.walk(path) + Stream |
| 변화 감지 | (직접 폴링) | WatchService (이벤트) |
한눈에 봐도 java.nio 쪽이 짧고 선언적이에요. "어떻게 한 줄씩 돌릴까" 가 아니라 "무엇을 원하는가" 를 적게 되죠. 그래서 오늘날 새 코드를 쓴다면 java.nio 를 먼저 고르는 게 기본 이에요. 그렇다고 java.io 가 쓸모없어진 건 아니에요. 바이트를 한 단계씩 세밀하게 다루거나, 예전 코드와 맞춰야 할 때는 java.io 의 스트림이 여전히 제 몫을 해요. 둘 다 알아두고 상황에 맞게 고르면 돼요.
자, 여기까지가 Phase 5 — 동시성과 I/O 의 마지막이에요. 돌아보면 우리는 Day 33 부터 스레드로 여러 일을 동시에 돌리고, 가상 스레드로 가볍게 확장하고, 파일로 데이터를 영구히 저장하는 법까지 익혔어요. 자바 언어의 핵심 문법과 라이브러리를 사실상 다 둘러본 셈이에요.
그러면 다음은 뭘까요? 지금까지가 "자바로 무엇을 할 수 있는가" 를 배운 시간이었다면, 다음 시간부터는 "어떻게 잘 설계하는가" 를 배워요. 같은 기능도 어떤 구조로 짜느냐에 따라 고치기 쉬운 코드가 되기도, 손대기 무서운 코드가 되기도 하거든요. 그 지혜를 정리해둔 게 디자인 패턴(design pattern) 이에요.
다음 시간(Day 40)에는 그 첫걸음으로 생성 패턴 을 배워요. 객체를 똑똑하게 만들어내는 방법들이에요. 딱 하나만 존재해야 하는 객체를 보장하는 법(Singleton), 옵션이 많은 객체를 깔끔하게 조립하는 법(Builder), 어떤 객체를 만들지 상황에 따라 고르는 법(Factory) 같은 거죠. 문법을 넘어 "설계" 의 세계로 들어가는 거예요. 기대해주세요!
마무리
오늘은 모던 파일 API인 java.nio 를 배웠어요. 지난 시간 손이 많이 갔던 파일 작업이 Files 메서드 한 줄로 줄어드는 걸 직접 봤죠.
Path와Files—Path는 경로를 가리키는 순수 객체(File의 후계자),Files는 실제로 읽고 쓰는 일꾼. 역할이 깔끔하게 나뉘어요.- 한 줄 읽기·쓰기 —
Files.readString/readAllLines/writeString/write. UTF-8 이 기본이라 한글이 안 깨져요. - 큰 파일과 Stream —
Files.lines·list·walk는 Stream 을 돌려주니filter·count가 통하고, 파일을 열어두므로try-with-resources로 닫아요. - 파일·폴더 다루기 —
createDirectories·copy·move·deleteIfExists·size로 업로드 폴더를 관리해요. WatchService— 변화가 생기면 알려주는 이벤트 방식. 직접 확인하는 폴링보다 효율적이에요.- 버퍼와 채널 — nio 의 밑바닥 엔진. 스트림이 한 바이트씩이라면, 채널은 버퍼에 덩어리째 담아 큰 파일에 효율적이에요.
이렇게 자바의 핵심 문법과 라이브러리를 두루 익혔어요. 다음 시간부터는 Phase 6 — 설계와 깊이 로 넘어가요. 같은 기능을 어떻게 하면 더 유연하고 고치기 쉬운 구조로 짤 수 있는지, 디자인 패턴 의 세계로 들어가요. 그 첫걸음은 객체를 똑똑하게 만들어내는 생성 패턴 이에요. 수고 많으셨어요!
과제
오늘 배운 java.nio 를 직접 다뤄볼 시간이에요. 셋 다 인스타그램에서 실제로 일어날 법한 상황이에요. 막히면 본문의 데모 코드를 참고하되, 먼저 스스로 흐름을 그려보세요. 파일 경로는 Path 로 가리키고, Stream 을 돌려주는 메서드(lines·list·walk)는 꼭 try-with-resources 로 열어 자동으로 닫히게 하세요.
과제 1 (기초): 프로필 소개글을 한 줄로 저장하고 불러오기
상황 배경: 사용자 프로필의 소개글(bio)을 파일에 저장해두었다가, 앱을 다시 켜면 그대로 불러오고 싶어요. 지난 시간엔 BufferedWriter/BufferedReader 로 했지만, 오늘은 Files 한 줄로 해봐요. Step 2·3 의 NioCaptionReader·NioCaptionWriter 를 참고하세요.
🎯 해결 미션:
BioStore같은 클래스에save(Path file, String bio)와load(Path file)메서드를 두세요.- 저장은
Files.writeString한 줄로, 읽기는Files.readString한 줄로 하세요. 인코딩은 따로 지정하지 마세요 (기본이 UTF-8 이에요). - 한글이 섞인 소개글(예:
"사진 찍는 걸 좋아해요 📷")을 저장하고 다시 불러왔을 때 글자가 그대로인지 확인하세요. - 지난 시간 같은 일을
BufferedReader+while로 했던 것과 비교해, 코드가 몇 줄에서 몇 줄로 줄었는지 한 줄로 적어보세요.
과제 2 (응용): 업로드 폴더에서 확장자별 파일 개수 세기
상황 배경: 사용자별 업로드 폴더 안에 사진(.jpg)과 동영상(.mp4)이 하위 폴더까지 흩어져 있어요. 전체에서 사진이 몇 장, 동영상이 몇 개인지 한 번에 세고 싶어요. Step 5 의 UploadFolderScanner 를 참고하세요.
🎯 해결 미션:
MediaCounter같은 클래스에countByExtension(Path root, String extension)메서드를 두세요.Files.walk로 하위 폴더까지 재귀로 훑고,filter로 진짜 파일(Files::isRegularFile)이면서 이름이 주어진 확장자로 끝나는 것만 골라count하세요.walk가 돌려준 Stream 을try-with-resources로 꼭 닫으세요. 왜 닫아야 하는지 한 줄로 적어보세요.- 폴더 몇 개에
.jpg와.mp4를 섞어 만들어두고,.jpg개수와.mp4개수가 각각 맞게 세지는지 확인하세요.
과제 3 (심화): 업로드 폴더를 백업 폴더로 통째로 복사하기
상황 배경: 사용자의 업로드 폴더 전체를 백업 폴더로 그대로 복사하려고 해요. 하위 폴더 구조까지 똑같이 만들어야 해요. Step 4·5 의 Files.walk·createDirectories·copy 를 함께 써봐요.
🎯 해결 미션:
FolderBackup같은 클래스에backup(Path sourceRoot, Path backupRoot)메서드를 두세요.Files.walk로 원본 폴더의 모든 항목을 훑으면서, 각 항목이 원본에서 떨어진 만큼 백업 폴더 아래의 대응 경로를 계산하세요. (힌트:sourceRoot.relativize(path)로 상대 경로를 구하고,backupRoot.resolve(...)로 붙여요.)- 항목이 폴더면
createDirectories로 만들고, 파일이면copy로 복사하세요. 덮어쓰기를 허용하려면StandardCopyOption.REPLACE_EXISTING을 주세요. - 원본에 폴더와 파일을 몇 개 만들어두고 백업한 뒤, 백업 폴더에 같은 구조와 파일이 그대로 생겼는지 확인하세요. 만약 원본 폴더 자체(맨 위)를 백업 폴더로 그대로 복사하면 어떤 문제가 생길지 한 줄로 생각해보세요.
생각해볼 주제
혼자 고민해도 좋고, 동료와 토론해도 좋아요. 정답을 외우기보다 "나라면 어떻게 설명할까" 를 떠올리며 읽어보세요.
1. java.nio 가 이렇게 편한데, java.io 는 왜 아직 남아 있을까?
오늘 보니 Files.readString 한 줄이 지난 시간 여러 줄을 대신했어요. 거의 모든 면에서 java.nio 가 깔끔해 보이죠. 그러면 자바는 왜 java.io 를 없애지 않고 둘 다 남겨뒀을까요?
힌트는 두 갈래예요. 하나는 "이미 java.io 로 짜인 코드가 세상에 산더미처럼 많다" 는 현실이에요. 다른 하나는 java.io 의 스트림 이 가진 고유한 쓸모예요. 파일뿐 아니라 네트워크, 압축, 암호화처럼 "데이터가 한 방향으로 흐르는" 모든 곳에 스트림이라는 같은 개념이 통하거든요. 편리함만으로 도구를 고를 수 없는 이유를 정리해보세요.
2. Files.lines 는 닫아야 하는데 Files.readAllLines 는 안 닫아도 되는 이유는?
본문에서 Files.lines·list·walk 는 try-with-resources 로 닫아야 한다고 했어요. 그런데 Files.readString 이나 readAllLines 는 닫으라는 말이 없었죠. 똑같이 파일을 읽는데 왜 한쪽만 닫아야 할까요?
"언제 파일을 읽는가" 를 떠올려보세요. readAllLines 는 부르는 즉시 끝까지 다 읽고 파일을 바로 닫아요. 반면 lines 가 돌려준 Stream 은 우리가 한 줄씩 꺼내 쓰는 동안 파일을 계속 열어둬요. 이 "게으르게, 필요할 때 읽는" 성질이 왜 닫기를 우리 책임으로 만드는지 연결해서 설명해보세요. 닫기를 빠뜨리면 무슨 일이 쌓이는지도요.
3. WatchService 의 이벤트 방식은 직접 폴링하는 것과 무엇이 다를까?
Step 6 에서 변화를 감지하는 두 가지 방식을 봤어요. 1초마다 직접 폴더를 list 해서 비교하는 폴링과, 변화가 생기면 통보받는 WatchService 의 이벤트요. 둘 다 결국 "새 파일을 알아낸다" 는 같은 목적인데, 무엇이 다를까요?
"변화가 거의 없는 폴더" 를 상상해보세요. 하루에 사진 한 장 올라올까 말까 하는 폴더를 1초마다 확인하면, 대부분의 확인은 "변화 없음" 만 알아내고 끝나요. 이 헛수고가 쌓이면 어떤 비용이 될지 생각해보세요. 반대로, 이벤트 방식이 공짜는 아닌 이유(운영체제의 도움이 필요하고, 짧은 시간에 변화가 쏟아지면 놓칠 수도 있다는 점)도 함께 떠올리면, 두 방식을 언제 골라야 할지 보일 거예요.
✅ 예시 답안정답 보기
아래는 과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니니, 내 코드와 흐름을 비교하며 "왜 이렇게 했는지" 를 짚어보세요.
과제 예시답안
과제 1 예시답안 — 프로필 소개글을 한 줄로 저장하고 불러오기
핵심 접근
지난 시간 BufferedWriter/BufferedReader 로 대여섯 줄이 필요했던 일을, 오늘은 Files 한 줄씩으로 끝내는 게 목표예요. 저장은 Files.writeString, 읽기는 Files.readString 한 줄이면 충분해요. 인코딩은 따로 지정하지 않아요 — 이 메서드들은 기본이 UTF-8 이라, 한글이나 이모지가 섞여도 깨지지 않거든요.
예시 구현
// com/instagram/javabasic/nio/solution/day39/BioStore.java
// 소개글을 파일에 통째로 써요. (한 줄)
public void save(Path file, String bio) throws IOException {
Files.writeString(file, bio);
}
// 파일 전체를 문자열 하나로 읽어요. (한 줄)
public String load(Path file) throws IOException {
return Files.readString(file);
}
"사진 찍는 걸 좋아해요 📷" 처럼 한글과 이모지가 섞인 소개글을 저장하고 다시 불러와도 글자가 그대로예요.
채점 포인트
- 저장을
Files.writeString, 읽기를Files.readString한 줄로 했는가 - 인코딩을 따로 지정하지 않았는가 (기본 UTF-8 을 활용)
- 한글·이모지가 섞인 소개글이 라운드트립 후에도 그대로인지 확인했는가
- 파일 경로를
File이 아니라Path로 받았는가
흔한 실수
- 굳이 인코딩을 지정하려다 다른 인코딩을 넘기는 경우.
Files.writeString/readString은 기본이 UTF-8 이라, 양쪽 다 기본을 쓰면 가장 안전해요. - 한 줄로 끝낼 수 있는데 습관처럼
BufferedReader를 다시 감싸는 경우. 작은 텍스트 한 덩어리는Files.readString한 줄이 더 간결해요. - "몇 줄에서 몇 줄로 줄었나" 의 답 — 지난 시간 읽기는 빈 리스트 만들기 +
BufferedReader감싸기 +while반복 +readLine+ 리스트에 담기로 대여섯 줄이었는데, 오늘은Files.readString한 줄이에요. "무엇을 원하는가" 만 적게 된 거예요.
과제 2 예시답안 — 업로드 폴더에서 확장자별 파일 개수 세기
핵심 접근
Step 5 의 UploadFolderScanner 를 한 단계 일반화하는 과제예요. .jpg 로 고정하지 않고, 확장자를 인자로 받아 그걸로 끝나는 파일을 세요. Files.walk 로 하위 폴더까지 재귀로 훑고, filter 두 개로 진짜 파일(Files::isRegularFile)이면서 이름이 주어진 확장자로 끝나는 것만 골라 count 해요. walk 가 돌려준 Stream 은 파일 시스템을 열어두니 try-with-resources 로 꼭 닫아요.
예시 구현
// com/instagram/javabasic/nio/solution/day39/MediaCounter.java
// root 아래(하위 폴더 포함)에서 extension 으로 끝나는 파일 개수를 세요.
public long countByExtension(Path root, String extension) throws IOException {
try (Stream<Path> paths = Files.walk(root)) {
return paths
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith(extension))
.count();
}
}
.jpg 와 .mp4 를 섞어 폴더를 만들어두고 각각 세면, .jpg 와 .mp4 개수가 따로 정확히 나와요. 같은 메서드에 확장자만 바꿔 넘기면 되니 재사용이 편하죠.
채점 포인트
Files.walk로 하위 폴더까지 재귀로 훑었는가Files::isRegularFile로 폴더를 제외하고 진짜 파일만 남겼는가- 확장자를 인자로 받아
endsWith로 걸렀는가 (.jpg하드코딩이 아니라) walk의 Stream 을try-with-resources로 닫았는가
흔한 실수
Files::isRegularFile필터를 빼먹어 폴더까지 세는 경우.walk는 파일뿐 아니라 폴더도 흘려보내니, 진짜 파일만 골라야 개수가 맞아요.- Stream 을
try-with-resources없이 받는 경우. 당장은 동작해도, 폴더를 자주 훑는 프로그램에서 열린 통로가 쌓여 사고로 이어져요. - "왜 닫아야 하나" 의 답 —
Files.walk가 돌려준 Stream 은 폴더를 향한 통로를 열어둔 채 한 항목씩 읽어요. 닫지 않으면 그 통로가 반납되지 않아, 많이 쌓이면 "더는 못 열어" 하고 멈춰요.
과제 3 예시답안 — 업로드 폴더를 백업 폴더로 통째로 복사하기
핵심 접근
폴더 구조까지 그대로 복사하는 과제예요. 핵심은 "원본에서 각 항목이 어디쯤 있는지" 를 백업 폴더에 똑같이 옮겨 계산하는 거예요. sourceRoot.relativize(source) 로 원본 루트 기준 상대 경로를 구하고, backupRoot.resolve(상대경로) 로 백업 루트 아래 대응 위치를 만들어요. 그다음 항목이 폴더면 createDirectories, 파일이면 copy 로 옮기면 돼요.
예시 구현
// com/instagram/javabasic/nio/solution/day39/FolderBackup.java
public void backup(Path sourceRoot, Path backupRoot) throws IOException {
// walk 가 연 Stream 을 먼저 리스트로 받아 닫아요.
List<Path> all;
try (Stream<Path> paths = Files.walk(sourceRoot)) {
all = paths.toList();
}
for (Path source : all) {
Path relative = sourceRoot.relativize(source); // 원본 루트에서 떨어진 만큼
Path target = backupRoot.resolve(relative); // 백업 루트 아래 같은 위치
if (Files.isDirectory(source)) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent()); // 부모 폴더 먼저
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
원본에 폴더와 파일을 몇 개 만들어두고 백업하면, 백업 폴더에 같은 구조와 파일 내용이 그대로 생겨요. 다시 백업해도 REPLACE_EXISTING 덕분에 덮어쓰며 깔끔하게 끝나요.
채점 포인트
relativize로 상대 경로를 구하고resolve로 백업 위치를 만들었는가- 폴더는
createDirectories, 파일은copy로 나눠 처리했는가 - 덮어쓰기를 위해
StandardCopyOption.REPLACE_EXISTING을 줬는가 walk의 Stream 을 닫았는가 (복사 전에 리스트로 받거나, 복사까지 try 안에서)
흔한 실수
- 상대 경로를 안 쓰고 파일 이름만 붙이는 경우. 그러면 하위 폴더 구조가 뭉개져 전부 한 폴더에 쏟아져요.
relativize로 "원본에서의 위치" 를 살려야 구조가 보존돼요. - 파일을 복사하기 전에 부모 폴더를 안 만드는 경우. 대상 폴더가 아직 없으면
copy가 실패해요. 그래서 파일 복사 전에target.getParent()를createDirectories해줘요. - "원본 폴더 자체를 백업 폴더로 복사하면?" 의 답 — 만약 백업 폴더가 원본 폴더 안에 있으면, 백업하는 도중 방금 만든 백업본까지 다시 훑어 복사가 끝없이 늘어나는 문제가 생겨요. 백업 폴더는 원본 바깥에 두는 게 안전해요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — java.nio 가 이렇게 편한데, java.io 는 왜 아직 남아 있을까?
[문제 상황 요약]
Files.readString 한 줄이 지난 시간 여러 줄을 대신할 만큼 java.nio 가 깔끔한데, 자바는 왜 java.io 를 없애지 않고 둘 다 남겨뒀나 하는 질문이에요.
[튜터의 가이드 및 해설]
두 갈래로 답할 수 있어요. 첫째는 현실이에요. 세상에는 java.io 로 짜인 코드가 산더미처럼 쌓여 있어요. 자바가 java.io 를 갑자기 없애면 그 많은 프로그램이 한순간에 망가져요. 그래서 새 도구(nio)를 더해주되, 옛 도구(io)도 그대로 둬서 기존 코드가 계속 돌게 한 거예요. 오래된 언어가 신뢰를 얻는 방식이기도 해요.
둘째는 java.io 의 스트림 이 가진 고유한 쓸모예요. 스트림은 "데이터가 한 방향으로 흐른다" 는 개념인데, 이게 파일에만 쓰이는 게 아니에요. 네트워크로 데이터를 주고받을 때, 파일을 압축하거나 풀 때, 암호화할 때 — 전부 "데이터가 흐른다" 는 같은 그림이에요. 그래서 스트림이라는 한 가지 개념을 여기저기에 끼워 쓸 수 있어요. 파일 읽기 한 가지만 보면 nio 가 짧지만, "흐르는 데이터" 라는 넓은 그림에서는 io 의 스트림이 여전히 제 몫을 해요.
정리하면, 도구를 고를 때 "편리함" 만 기준이 아니에요. 이미 쌓인 코드와의 호환, 그리고 그 도구가 가진 개념의 넓이도 함께 봐야 해요. 그래서 둘은 경쟁자가 아니라 역할을 나눠 공존해요.
🎯 면접관을 홀리는 핵심 멘트
"java.nio 는 파일 작업을 한 줄로 줄여주지만, java.io 가 사라지지 않는 이유는 두 가지예요. 하나는 기존 코드와의 호환이고, 다른 하나는 스트림이라는 개념이 파일뿐 아니라 네트워크·압축·암호화처럼 '데이터가 흐르는' 모든 곳에 통한다는 점이에요. 그래서 새 코드는 nio 를 먼저 고르되, 둘은 경쟁이 아니라 역할을 나눠 공존한다고 봅니다."
생각해볼 주제 2 예시답안 — Files.lines 는 닫아야 하는데 Files.readAllLines 는 안 닫아도 되는 이유는?
[문제 상황 요약]
Files.lines·list·walk 는 try-with-resources 로 닫아야 한다고 했는데, Files.readString·readAllLines 는 닫으라는 말이 없었어요. 똑같이 파일을 읽는데 왜 한쪽만 닫아야 하는지에 대한 질문이에요.
[튜터의 가이드 및 해설]
핵심은 "언제 파일을 읽는가" 예요. readAllLines 는 부르는 즉시 파일을 처음부터 끝까지 다 읽어서 리스트에 담고, 그 자리에서 파일을 바로 닫아요. 우리가 돌려받는 건 이미 메모리에 다 올라온 리스트라, 파일과의 연결은 이미 끊긴 상태예요. 그러니 우리가 닫을 게 없어요.
반면 Files.lines 가 돌려준 Stream 은 "게으른" 방식이에요. 부르는 순간 다 읽지 않고, 우리가 한 줄씩 꺼내 쓸 때마다 그때그때 파일에서 읽어와요. 그래서 우리가 Stream 을 다 쓸 때까지 파일을 계속 열어둬야 해요. 이 "필요할 때 읽는" 성질 때문에, 다 쓰고 나면 우리가 직접 닫아줘야 그 열린 파일이 반납돼요. list 와 walk 도 폴더를 향한 통로를 열어둔 채 항목을 흘려보내니 똑같아요.
닫기를 빠뜨리면 어떻게 될까요? 한 번은 티가 안 나요. 하지만 파일이나 폴더를 수천 번 여닫는 프로그램에서 매번 닫기를 빠뜨리면, 열린 통로가 차곡차곡 쌓여요. 운영체제가 한 프로그램에 허락하는 열린 파일 수에는 한계가 있어서, 결국 "더는 못 열어" 하고 막혀버려요. 그래서 Stream 을 돌려주는 메서드는 항상 try-with-resources 에 맡기는 습관이 안전해요.
🎯 면접관을 홀리는 핵심 멘트
"readAllLines 는 호출 즉시 전부 읽고 파일을 바로 닫아 우리가 닫을 게 없지만, Files.lines·list·walk 는 게으르게 한 항목씩 읽느라 파일을 열어둔 채 Stream 을 돌려줘요. 그래서 다 쓰면 우리가 닫아야 하고, 안 닫으면 열린 파일 핸들이 쌓여 결국 한도를 넘겨 못 여는 사고가 나요. 그래서 Stream 을 돌려주는 메서드는 항상 try-with-resources 로 받습니다."
생각해볼 주제 3 예시답안 — WatchService 의 이벤트 방식은 직접 폴링하는 것과 무엇이 다를까?
[문제 상황 요약]
변화를 감지하는 두 방식 — 1초마다 직접 폴더를 list 해서 비교하는 폴링과, 변화가 생기면 통보받는 WatchService 의 이벤트 — 가 결국 같은 목적인데 무엇이 다른지에 대한 질문이에요.
[튜터의 가이드 및 해설]
"변화가 거의 없는 폴더" 를 떠올리면 차이가 또렷해져요. 하루에 사진 한 장 올라올까 말까 하는 폴더를 1초마다 확인한다고 해봐요. 하루 86,400 번 확인하는데, 그중 86,399 번은 "변화 없음" 만 알아내고 끝나요. 이 헛걸음이 곧 비용이에요. 확인할 때마다 폴더를 읽고 이전 목록과 비교하느라 CPU 와 디스크를 쓰는데, 대부분이 허탕이죠.
WatchService 의 이벤트 방식은 이 헛걸음을 없애요. 운영체제에게 "이 폴더에 변화가 생기면 알려줘" 라고 부탁해두고 가만히 기다려요. 변화가 없으면 우리는 아무 일도 안 해요. 운영체제는 파일 시스템을 직접 관리하니까, 변화가 생긴 바로 그 순간에 정확히 알려줄 수 있어요. 그래서 변화가 드문 폴더일수록 이벤트 방식이 압도적으로 효율적이에요.
다만 이벤트 방식이 공짜는 아니에요. 운영체제의 도움이 필요해서, 환경에 따라 동작이 조금씩 달라요. 또 아주 짧은 시간에 변화가 폭주하면 알림을 놓칠 수도 있어요. 그래서 "변화가 드물고, 생긴 즉시 반응해야 하는" 상황엔 WatchService 가 좋고, "변화를 정확히 일정 간격으로 모아서 확인하면 충분한" 단순한 상황엔 폴링도 나쁘지 않아요. 무엇을 고를지는 변화의 빈도와 반응 속도 요구에 달려 있어요.
🎯 면접관을 홀리는 핵심 멘트
"폴링은 변화가 없어도 일정 간격으로 계속 확인하니, 변화가 드문 폴더일수록 대부분의 확인이 헛걸음이 돼 CPU·디스크를 낭비해요. WatchService 는 운영체제에 감시를 맡겨 변화가 생긴 순간에만 통보받으니 그 헛걸음이 사라지죠. 대신 OS 의존적이고 변화가 폭주하면 놓칠 수 있어서, 변화 빈도와 반응 속도 요구에 따라 둘을 골라 씁니다."