F-LAB JAVA · 3주차 · Phase 8 · Stream 실전
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
new FileOutputStream(path, true) 의 의미는? (이어쓰기)
FileOutputStream은OutputStream의 파일 전용 구현으로 디스크에 1바이트씩 쓰는 가장 기본적인 도구다.
new FileOutputStream(path)는 덮어쓰기 모드 (기본),new FileOutputStream(path, true)는 이어쓰기 (append) 모드.
write(int b)가 int 의 하위 8비트 (0~255) 만 사용하고, 1바이트씩 쓰면 한글이 깨진다 — 한글 1글자 = UTF-8 3바이트인데 각 바이트를 따로 쓰면 의미 잃음.
파일에 저장되는 건 숫자 (바이트) 이며, 텍스트 에디터가 인코딩에 따라 문자로 보여줌 — 65 → 'A' 는 ASCII/UTF-8 매핑일 뿐.
FileOutputStream(new File(path), true)모드와getCanonicalPath,flush()의 정확한 의미를 알면 실무 버그의 70%를 회피.
FileOutputStream:
새 노트 펴고 글자 쓰기
- 1바이트 (1글자) 씩
- 기본: 새 노트 (덮어쓰기)
- append: 이어 쓰기
write(int b):
- int 65 입력
- 노트에 0x41 (1바이트) 쓰임
- 텍스트 에디터로 보면 'A' (ASCII 매핑)
- 사실 노트엔 그냥 숫자 65
한글 "안" (3바이트):
- 1바이트씩 따로 쓰면 ★ 깨짐
- 한 번에 byte[] 로 써야
→ FileOutputStream = JVM → 파일의 가장 기본 통로.
1. FileOutputStream 의 정의
2. 5가지 생성자
3. 덮어쓰기 vs 이어쓰기 (append)
4. write 메서드 종류
5. flush() 의 역할
6. 바이트가 문자로 보이는 이유
7. 한글이 깨지는 메커니즘
8. 파일 생성과 부모 디렉토리 함정
9. 면접 + 자기 점검
package java.io;
public class FileOutputStream extends OutputStream {
private final FileDescriptor fd;
private final boolean append;
private final String path;
// 생성자, 메서드, ...
}
핵심:
OutputStream 의 자식java.io 패키지OutputStream 의 계층:
OutputStream (추상)
├── FileOutputStream ← 파일
├── ByteArrayOutputStream ← 메모리
├── PipedOutputStream ← 파이프
├── FilterOutputStream
│ ├── BufferedOutputStream
│ ├── DataOutputStream
│ └── PrintStream ← System.out
├── ObjectOutputStream ← 직렬화
└── 기타
public class FileOutputStream extends OutputStream {
// 핵심 메서드
public void write(int b) throws IOException;
public void write(byte[] b) throws IOException;
public void write(byte[] b, int off, int len) throws IOException;
// 추가
public void flush() throws IOException;
public void close() throws IOException;
// FileOutputStream 만의 메서드
public FileChannel getChannel();
public FileDescriptor getFD() throws IOException;
}
// 간단한 쓰기
try (FileOutputStream fos = new FileOutputStream("output.txt")) {
fos.write(72); // 'H'
fos.write(101); // 'e'
fos.write(108); // 'l'
fos.write(108); // 'l'
fos.write(111); // 'o'
}
// 파일 내용: "Hello"
// 실제 저장된 것: 0x48 0x65 0x6C 0x6C 0x6F (5바이트)
// OutputStream 으로 추상화
public void write(OutputStream os, byte[] data) throws IOException {
os.write(data);
}
// 다양한 OutputStream 으로 호출
write(new FileOutputStream("file.txt"), data); // 파일
write(new ByteArrayOutputStream(), data); // 메모리
write(socket.getOutputStream(), data); // 네트워크
write(System.out, data); // 콘솔
// ❌ 자원 누수
FileOutputStream fos = new FileOutputStream("file.txt");
fos.write(data);
// close 안 함!
// 파일 핸들 누수 + 버퍼 데이터 손실
// ✓ try-with-resources
try (FileOutputStream fos = new FileOutputStream("file.txt")) {
fos.write(data);
}
// 자동 close
// 버퍼 flush 자동
public class ShipmentFileWriter {
// 1. 단순 쓰기
public void writeBytes(Path path, byte[] data) throws IOException {
try (FileOutputStream fos = new FileOutputStream(path.toFile())) {
fos.write(data);
}
}
// 2. NIO.2 권장
public void writeBytesModern(Path path, byte[] data) throws IOException {
Files.write(path, data);
// 더 간결, 명확한 예외
}
}
FileOutputStream 의 정의와 위치는?
답:
1. 정의:
본질:
다형성:
자원:
// 1. 경로 문자열 (덮어쓰기)
public FileOutputStream(String name) throws FileNotFoundException;
// 2. 경로 문자열 + append 모드
public FileOutputStream(String name, boolean append) throws FileNotFoundException;
// 3. File 객체 (덮어쓰기)
public FileOutputStream(File file) throws FileNotFoundException;
// 4. File 객체 + append 모드
public FileOutputStream(File file, boolean append) throws FileNotFoundException;
// 5. FileDescriptor
public FileOutputStream(FileDescriptor fdObj);
// 덮어쓰기 (기본)
FileOutputStream fos1 = new FileOutputStream("file.txt");
// 파일이 있으면 내용 삭제
// 파일이 없으면 생성
// 이어쓰기
FileOutputStream fos2 = new FileOutputStream("file.txt", true);
// 파일 끝에 추가
// 파일이 없으면 생성
// 절대 경로
FileOutputStream fos3 = new FileOutputStream("/var/data/file.txt");
// 부모 디렉토리 /var/data 가 있어야!
File file = new File("file.txt");
// 덮어쓰기
FileOutputStream fos1 = new FileOutputStream(file);
// 이어쓰기
FileOutputStream fos2 = new FileOutputStream(file, true);
// 활용
File logFile = new File("/var/log/app.log");
if (!logFile.exists()) {
logFile.getParentFile().mkdirs();
}
FileOutputStream fos = new FileOutputStream(logFile, true); // 이어쓰기
// 표준 출력으로
FileOutputStream stdout = new FileOutputStream(FileDescriptor.out);
stdout.write("Hello".getBytes());
// 표준 에러로
FileOutputStream stderr = new FileOutputStream(FileDescriptor.err);
// 일반적으로 사용 X
// System.out, System.err 직접 사용 권장
// 생성자가 즉시 파일 처리:
// 1. 파일이 없으면
// - 새로 생성 (빈 파일)
// 2. 파일이 있고 덮어쓰기 모드
// - 파일 truncate (크기 0 으로)
// - 내용 삭제
// 3. 파일이 있고 append 모드
// - 파일 유지
// - 쓰기 위치를 끝으로
// 4. 부모 디렉토리 없음
// - FileNotFoundException
// 5. 권한 없음
// - FileNotFoundException (Java) 또는 IOException
// java.io
FileOutputStream fos = new FileOutputStream("file.txt");
FileOutputStream append = new FileOutputStream("file.txt", true);
// NIO.2 (Java 7+) — 명시적 옵션
OutputStream os1 = Files.newOutputStream(Path.of("file.txt"));
// 기본: CREATE, TRUNCATE_EXISTING, WRITE
OutputStream os2 = Files.newOutputStream(Path.of("file.txt"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND); // ★ append
// 다른 옵션
StandardOpenOption.CREATE // 없으면 생성
StandardOpenOption.CREATE_NEW // 없으면 생성, 있으면 실패
StandardOpenOption.WRITE // 쓰기
StandardOpenOption.APPEND // 끝에 추가
StandardOpenOption.TRUNCATE_EXISTING // 있으면 truncate
StandardOpenOption.DELETE_ON_CLOSE // 닫을 때 삭제
StandardOpenOption.SYNC // 강제 동기화
StandardOpenOption.DSYNC // 데이터만 동기화
public class ShipmentLogger {
private final Path logFile;
public ShipmentLogger(Path logFile) {
this.logFile = logFile;
}
// 1. 덮어쓰기 — 새 로그
public void initLog() throws IOException {
try (FileOutputStream fos = new FileOutputStream(logFile.toFile())) {
fos.write("=== Log Started ===\n".getBytes());
}
}
// 2. 이어쓰기 — 누적
public void appendLog(String message) throws IOException {
try (FileOutputStream fos = new FileOutputStream(logFile.toFile(), true)) {
String line = LocalDateTime.now() + " " + message + "\n";
fos.write(line.getBytes(StandardCharsets.UTF_8));
}
}
// 3. NIO.2 권장
public void appendLogModern(String message) throws IOException {
String line = LocalDateTime.now() + " " + message + "\n";
Files.write(logFile, line.getBytes(StandardCharsets.UTF_8),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
}
FileOutputStream 의 5가지 생성자는?
답:
1. String: 경로, 덮어쓰기
2. String + boolean: 경로 + append
3. File: 객체, 덮어쓰기
4. File + boolean: 객체 + append
5. FileDescriptor: 저수준
동작:
NIO.2 대안:
Files.newOutputStream(Path, StandardOpenOption...)덮어쓰기 (기본, append = false):
1. 파일 열기
2. 기존 내용 truncate (크기 0)
3. write 호출은 처음부터
4. 기존 데이터 손실
이어쓰기 (append = true):
1. 파일 열기
2. 쓰기 위치를 파일 끝으로
3. write 호출은 끝에 추가
4. 기존 데이터 보존
초기 파일: "Hello World"
// 덮어쓰기 모드
try (FileOutputStream fos = new FileOutputStream("file.txt")) {
fos.write("Bye".getBytes());
}
// 결과 파일: "Bye"
// "Hello World" 완전히 사라짐
초기 파일: "Hello World"
// 이어쓰기 모드
try (FileOutputStream fos = new FileOutputStream("file.txt", true)) {
fos.write("Bye".getBytes());
}
// 결과 파일: "Hello WorldBye"
// 기존 + 추가
append 모드의 OS 레벨 동작:
1. 파일 열 때
- O_APPEND 플래그 전달
- OS 가 "쓰기 위치 = 파일 끝" 설정
2. 매 write 시
- 자동으로 파일 끝으로 이동
- 동시에 다른 프로세스가 써도 안전
- 원자적 (Atomic) — 작은 쓰기는 보장
특징:
- 멀티 프로세스 로깅에 안전
- position 변경 무관 (항상 끝)
- 큰 쓰기는 원자성 보장 X
// 시나리오 1: 로그 파일
public void log(String message) throws IOException {
try (FileOutputStream fos = new FileOutputStream("app.log", true)) {
// ★ append 필수
// 매 호출마다 누적
fos.write((message + "\n").getBytes());
}
}
// 시나리오 2: 설정 파일 (덮어쓰기)
public void saveConfig(Properties props) throws IOException {
try (FileOutputStream fos = new FileOutputStream("config.properties")) {
// 덮어쓰기 (최신 설정으로)
props.store(fos, "Configuration");
}
}
// 시나리오 3: CSV export (덮어쓰기)
public void exportCsv(List<Shipment> shipments, Path dest) throws IOException {
try (FileOutputStream fos = new FileOutputStream(dest.toFile())) {
// 새 export — 덮어쓰기
fos.write("id,blNo,weight\n".getBytes());
for (Shipment s : shipments) {
fos.write(s.toCsvLine().getBytes());
fos.write("\n".getBytes());
}
}
}
// 시나리오 4: 메시지 큐 파일
public void enqueue(String message) throws IOException {
try (FileOutputStream fos = new FileOutputStream("queue.log", true)) {
// append — 메시지 누적
fos.write((message + "\n").getBytes());
}
}
// 함정 1: 모드 잊음
try (FileOutputStream fos = new FileOutputStream("important.log")) {
// ★ append=true 누락
fos.write(newLog.getBytes());
}
// 기존 로그 모두 삭제!
// 함정 2: 동시성
// 두 스레드가 같은 파일에 append
Thread t1 = new Thread(() -> log("A"));
Thread t2 = new Thread(() -> log("B"));
// O_APPEND 는 작은 쓰기는 원자적
// 하지만 큰 데이터 (> 4KB) 는 섞일 수 있음
// 해결: FileLock 또는 단일 스레드
// 함정 3: 무한 누적
try (FileOutputStream fos = new FileOutputStream("log.txt", true)) {
while (true) {
fos.write(...);
}
}
// 파일이 무한히 커짐
// 로그 회전 (rotation) 필요
// NIO.2 — 명시적
Files.newOutputStream(Path.of("file.txt"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND);
// 또는 Files.write
Files.write(Path.of("file.txt"), data,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
// 더 간결
Files.writeString(Path.of("file.txt"), "Hello\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
@Component
public class ShipmentLogRotator {
private final Path baseDir = Path.of("/var/log/shipment");
private final long maxSize = 10 * 1024 * 1024; // 10MB
public void appendLog(String level, String message) throws IOException {
Path current = getCurrentLogFile();
// 회전 체크
if (Files.exists(current) && Files.size(current) > maxSize) {
rotate(current);
}
// append
String line = String.format("%s [%s] %s%n",
LocalDateTime.now(), level, message);
Files.writeString(current, line,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
}
private Path getCurrentLogFile() {
return baseDir.resolve("app.log");
}
private void rotate(Path current) throws IOException {
Path archive = baseDir.resolve("archive")
.resolve("app." + System.currentTimeMillis() + ".log");
Files.createDirectories(archive.getParent());
Files.move(current, archive);
}
}
덮어쓰기 vs 이어쓰기의 차이는?
답:
1. 덮어쓰기 (기본):
new FileOutputStream(path)이어쓰기 (append):
new FileOutputStream(path, true)OS 레벨:
활용:
NIO.2:
StandardOpenOption.APPENDpublic abstract class OutputStream {
// 1. 1바이트 쓰기 (추상)
public abstract void write(int b) throws IOException;
// 2. 배열 전체 쓰기
public void write(byte[] b) throws IOException {
write(b, 0, b.length);
}
// 3. 배열 일부 쓰기
public void write(byte[] b, int off, int len) throws IOException;
}
// write(int b) 의 동작
public void write(int b) throws IOException {
// b 의 하위 8비트만 사용
// 즉, b & 0xFF
// 0 ~ 255 의 값으로 변환되어 쓰임
}
// 예
fos.write(65); // 'A' (0x41)
fos.write(72); // 'H' (0x48)
fos.write('A'); // 'A' (char 가 int 로 자동 변환, 65)
fos.write(0x100); // ★ 0 (하위 8비트만, 0x100 의 하위 = 0x00)
fos.write(-1); // ★ 255 (0xFF, 음수도 하위 8비트만)
// 즉:
// int 의 24비트는 무시
// 0~255 범위만 의미 있음
byte[] data = "Hello".getBytes();
fos.write(data);
// 5바이트 모두 쓰기
// 내부 구현
public void write(byte[] b) throws IOException {
write(b, 0, b.length);
}
// 효율적 — 한 번에 여러 바이트
// system call 1번 (또는 적게)
byte[] buf = "Hello World".getBytes();
fos.write(buf, 6, 5);
// buf[6] ~ buf[10] 쓰기
// "World"
// 활용:
// - 큰 버퍼의 일부만
// - 헤더 + 본문 분리
// - 정확한 길이 제어
// 일반적 복사 패턴
try (FileInputStream fis = new FileInputStream("src.txt");
FileOutputStream fos = new FileOutputStream("dest.txt")) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) != -1) {
fos.write(buf, 0, n); // ★ n 만큼만! (Unit 8.3)
}
}
// ❌ 잘못 — buf.length 쓰기
while ((n = fis.read(buf)) != -1) {
fos.write(buf); // 항상 buf.length
// 마지막 read 후 이전 데이터 누적
}
// ❌ 더 잘못
while ((n = fis.read(buf)) != -1) {
fos.write(buf, 0, buf.length); // 위와 같음
}
// PrintStream (System.out 의 타입)
// OutputStream + 편의 메서드
PrintStream ps = new PrintStream(new FileOutputStream("file.txt"));
ps.print("Hello");
ps.println(" World");
ps.printf("Value: %d%n", 42);
// 편의:
// - 문자열 자동 인코딩
// - 줄바꿈 자동
// - printf
// 단점:
// - 예외 던지지 않음 (checkError 로 확인)
// - 인코딩 명시 필요
// 일반 파일: 거의 즉시 (디스크 버퍼)
fos.write(data); // 빠름
// 네트워크 소켓: 네트워크 버퍼 가득 차면 대기
socket.getOutputStream().write(data); // 느릴 수 있음
// 큰 데이터:
// - OS 의 페이지 캐시에 일단 저장
// - 디스크 동기화는 백그라운드
// - flush() 또는 force() 로 강제 가능
public class ShipmentDataWriter {
// 1. 단일 바이트
public void writeStatus(Path path, byte status) throws IOException {
try (FileOutputStream fos = new FileOutputStream(path.toFile())) {
fos.write(status);
}
}
// 2. 배열
public void writeData(Path path, byte[] data) throws IOException {
try (FileOutputStream fos = new FileOutputStream(path.toFile())) {
fos.write(data);
}
}
// 3. 부분 쓰기
public void writeHeader(Path path, byte[] fullData, int headerLen) throws IOException {
try (FileOutputStream fos = new FileOutputStream(path.toFile())) {
fos.write(fullData, 0, headerLen); // 헤더만
}
}
// 4. 복사 (read + write)
public void copy(Path src, Path dest) throws IOException {
try (FileInputStream fis = new FileInputStream(src.toFile());
FileOutputStream fos = new FileOutputStream(dest.toFile())) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) != -1) {
fos.write(buf, 0, n);
}
}
}
// 5. 더 간단 (Java 9+)
public void copyEasy(Path src, Path dest) throws IOException {
try (InputStream is = Files.newInputStream(src);
OutputStream os = Files.newOutputStream(dest)) {
is.transferTo(os);
}
}
}
write 메서드 3가지의 차이는?
답:
1. write(int b):
write(byte[] b):
write(byte[] b, int off, int len):
주의:
public void flush() throws IOException;
핵심:
버퍼링 (Buffering):
write 호출마다 OS 호출 하지 않고
내부 버퍼에 일단 저장.
버퍼 가득 차면 한 번에 OS 로.
이유:
- system call 비용 절감
- 디스크 I/O 효율
- 작은 write 의 누적 처리
단점:
- 버퍼 안의 데이터는 디스크에 X
- 비정상 종료 시 손실
- 강제 출력 필요 시 flush()
FileOutputStream 자체는 버퍼링 X
- 매 write 가 OS 호출
- flush() 의 효과 거의 없음
BufferedOutputStream 이 버퍼링
- 내부 8KB 버퍼
- 매 write 가 버퍼에 저장
- 버퍼 가득 또는 flush() 시 OS 호출
PrintStream/PrintWriter 도 보통 버퍼링
- System.out 은 line buffering (줄 단위)
- println 마다 자동 flush 가능
// 시나리오 1: FileOutputStream — flush 무의미
try (FileOutputStream fos = new FileOutputStream("file.txt")) {
fos.write(data);
fos.flush(); // 효과 거의 없음 (이미 OS 로)
}
// 시나리오 2: BufferedOutputStream — flush 중요
try (BufferedOutputStream bos = new BufferedOutputStream(
new FileOutputStream("file.txt"))) {
bos.write(data);
// 데이터가 8KB 버퍼에 있음
// 디스크엔 아직 X
bos.flush(); // ★ 디스크에 쓰기
// 또는 close() 가 자동
}
// 시나리오 3: 진행 중 강제 출력
try (BufferedOutputStream bos = new BufferedOutputStream(
new FileOutputStream("file.txt"))) {
for (int i = 0; i < 100; i++) {
bos.write(generateData(i));
bos.flush(); // 매 데이터마다 디스크
// 비정상 종료 시 데이터 보존
}
}
// flush — 자바 버퍼 → OS 버퍼
bos.flush();
// OS 의 페이지 캐시에 있을 뿐
// 디스크엔 아직 안 갔을 수도
// force — OS 버퍼 → 디스크
FileChannel ch = fos.getChannel();
ch.force(true);
// 또는 false (메타데이터 제외)
// 즉:
// flush: 자바 → OS
// force: OS → 디스크
// 둘 다 거쳐야 진짜 디스크
// close 가 자동 flush
try (BufferedOutputStream bos = new BufferedOutputStream(
new FileOutputStream("file.txt"))) {
bos.write(data);
// try 블록 끝 → close() 호출
// close() 가 내부적으로 flush() 호출
}
// 명시적 flush
try (BufferedOutputStream bos = new BufferedOutputStream(
new FileOutputStream("file.txt"))) {
bos.write(data);
bos.flush(); // 즉시 OS 로
// 이후도 사용 가능
bos.write(moreData);
}
// PrintStream 의 autoFlush 옵션
PrintStream ps = new PrintStream(new FileOutputStream("file.txt"), true);
// ↑ autoFlush
// autoFlush = true:
// - println 후 자동 flush
// - byte 배열 쓴 후 자동 flush
// 일반:
PrintStream ps2 = new PrintStream(new FileOutputStream("file.txt"));
// autoFlush = false (기본)
// System.out 의 경우:
// - 일반 PrintStream
// - autoFlush 보장 없음
// - 단, 콘솔은 보통 line-buffered (\n 마다 flush)
public class TransactionLogger {
private final BufferedOutputStream bos;
public TransactionLogger(Path path) throws IOException {
this.bos = new BufferedOutputStream(
new FileOutputStream(path.toFile(), true));
}
public void logTransaction(Transaction tx) throws IOException {
bos.write(tx.toBytes());
bos.flush(); // ★ 즉시 디스크
// 비정상 종료 시 거래 손실 방지
}
public void close() throws IOException {
bos.close(); // 자동 flush
}
}
// 또는 더 강력하게 (force)
public class CriticalLogger {
public void logCritical(String message, Path path) throws IOException {
try (FileOutputStream fos = new FileOutputStream(path.toFile(), true)) {
fos.write(message.getBytes());
// FileOutputStream 자체는 버퍼링 X
// 강제 디스크 동기화
fos.getChannel().force(false); // 데이터만 (메타 제외)
}
}
}
flush() 의 역할과 언제 호출?
답:
1. 역할:
FileOutputStream:
BufferedOutputStream:
호출 시점:
flush vs force:
사실: 파일은 바이트 (숫자) 의 나열.
저장:
fos.write(65);
→ 파일에 0x41 (1바이트) 저장
파일 내용 (16진수):
0x41
해석:
- ASCII 로: 'A'
- 16진수: 65
- 10진수: 65
- 이진수: 01000001
모두 같은 바이트, 다른 표현
텍스트 에디터:
- 파일의 바이트 읽음
- 인코딩 매핑 적용
- 문자로 화면에 표시
예:
파일 바이트: 0x48 0x65 0x6C 0x6C 0x6F
ASCII/UTF-8 매핑:
- 0x48 → 'H' (72)
- 0x65 → 'e' (101)
- 0x6C → 'l' (108)
- 0x6C → 'l' (108)
- 0x6F → 'o' (111)
화면: "Hello"
영어 (ASCII):
- 모든 알파벳이 1바이트 (0~127)
- ASCII 표준
- UTF-8 호환
write(65) → 파일 0x41 → 텍스트 에디터 'A'
영어가 잘 되는 이유:
- ASCII 가 산업 표준
- 모든 시스템이 인식
- 1바이트 매핑 = 단순
주요 ASCII 코드:
65 = 'A' 97 = 'a'
66 = 'B' 98 = 'b'
... ...
90 = 'Z' 122 = 'z'
48 = '0' 32 = ' ' (공백)
49 = '1' 10 = '\n' (LF)
... 13 = '\r' (CR)
57 = '9' 9 = '\t' (Tab)
특수:
33 = '!' 63 = '?'
64 = '@' 35 = '#'
검증:
fos.write(65); // 파일에 'A'
fos.write(97); // 파일에 'a'
fos.write(48); // 파일에 '0'
fos.write(32); // 파일에 공백
// "Hello, World!" 쓰기
try (FileOutputStream fos = new FileOutputStream("file.txt")) {
fos.write(72); // 'H'
fos.write(101); // 'e'
fos.write(108); // 'l'
fos.write(108); // 'l'
fos.write(111); // 'o'
fos.write(44); // ','
fos.write(32); // ' '
fos.write(87); // 'W'
fos.write(111); // 'o'
fos.write(114); // 'r'
fos.write(108); // 'l'
fos.write(100); // 'd'
fos.write(33); // '!'
}
// 파일 (hex dump):
// 48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21
// 텍스트 에디터 (UTF-8/ASCII):
// "Hello, World!"
// String → byte[] 의 매핑
String s = "Hello";
byte[] bytes = s.getBytes();
// 인코딩 사용 (시스템 기본 또는 명시)
// UTF-8
byte[] utf8 = s.getBytes(StandardCharsets.UTF_8);
// [72, 101, 108, 108, 111]
// 영어는 UTF-8 도 1바이트씩
// 이를 write:
fos.write(utf8);
// 파일: 0x48 0x65 0x6C 0x6C 0x6F
// 텍스트 에디터: "Hello"
텍스트 에디터의 동작:
1. 파일 열기
2. 바이트 시퀀스 읽음
3. 인코딩 추정 또는 사용자 선택
4. 매핑 적용
5. 화면에 문자 표시
추정 방법:
- BOM 확인 (UTF-8 BOM, UTF-16 BOM)
- 통계적 분석
- 사용자 설정
잘못 추정하면:
- 한글이 깨짐
- 영어는 정상 (ASCII 호환)
- 사용자가 인코딩 변경
public class TextFileGenerator {
// ASCII 안전한 텍스트
public void writeAsciiText(Path path, String text) throws IOException {
// ASCII 만 (영어 + 숫자 + 기본 기호)
try (FileOutputStream fos = new FileOutputStream(path.toFile())) {
for (char c : text.toCharArray()) {
if (c < 128) { // ASCII 범위
fos.write(c);
} else {
throw new IOException("Non-ASCII character: " + c);
}
}
}
}
// 바이트 직접 (영어 텍스트)
public void writeBytes(Path path, byte[] bytes) throws IOException {
try (FileOutputStream fos = new FileOutputStream(path.toFile())) {
fos.write(bytes);
}
}
// 검증
public void verify(Path path) throws IOException {
byte[] bytes = Files.readAllBytes(path);
System.out.println("File bytes (hex):");
for (byte b : bytes) {
System.out.printf("%02X ", b);
}
System.out.println();
System.out.println("Interpreted as ASCII:");
System.out.println(new String(bytes, StandardCharsets.US_ASCII));
}
}
바이트가 문자로 보이는 이유는?
답:
1. 파일의 본질:
텍스트 에디터:
ASCII:
영어가 잘 되는 이유:
한글이 안 되는 이유 (다음 섹션):
한글 1글자의 인코딩별 크기:
UTF-8: 3바이트
"안" → 0xEC 0x95 0x88
"녕" → 0xEB 0x85 0x95
UTF-16: 2바이트
"안" → 0xC5 0x48
"녕" → 0xB1 0x4D
EUC-KR / CP949: 2바이트
"안" → 0xBE 0xC8
"녕" → 0xB3 0xC4
→ 한글은 절대 1바이트 X
→ write(int) 로 한 번에 못 씀
// ❌ 한글 1글자를 1바이트씩
try (FileOutputStream fos = new FileOutputStream("file.txt")) {
fos.write('안'); // ★ 잘못!
}
// 동작:
// 1. '안' = char (16비트)
// 2. fos.write(int) 가 int 의 하위 8비트만
// 3. '안' = 0xC548
// 4. 하위 8비트 = 0x48 ('H' 와 같은 바이트)
// 5. 파일에 0x48 (1바이트) 저장
// 결과: '안' 이 'H' 로 저장
// 데이터 손실 + 깨짐
// ✓ 한글을 byte[] 로
try (FileOutputStream fos = new FileOutputStream("file.txt")) {
byte[] utf8 = "안녕".getBytes(StandardCharsets.UTF_8);
fos.write(utf8);
// 6바이트 모두 정확히 쓰임
}
// 파일 (hex):
// EC 95 88 EB 85 95
// UTF-8 로 열기:
// "안녕"
// EUC-KR 로 열기:
// 깨짐
String text = "안녕";
// UTF-8 (현대 권장)
byte[] utf8 = text.getBytes(StandardCharsets.UTF_8);
// [0xEC, 0x95, 0x88, 0xEB, 0x85, 0x95]
// 6바이트
// EUC-KR (옛 한국)
byte[] euckr = text.getBytes(Charset.forName("EUC-KR"));
// [0xBE, 0xC8, 0xB3, 0xC4]
// 4바이트
// UTF-16
byte[] utf16 = text.getBytes(StandardCharsets.UTF_16);
// [0xFE, 0xFF, 0xC5, 0x48, 0xB1, 0x4D] (with BOM)
// 6바이트
// 쓰기 (UTF-8)
try (FileOutputStream fos = new FileOutputStream("file.txt")) {
fos.write("안녕".getBytes(StandardCharsets.UTF_8));
}
// 읽기 — 같은 인코딩
try (FileInputStream fis = new FileInputStream("file.txt")) {
byte[] bytes = fis.readAllBytes();
String text = new String(bytes, StandardCharsets.UTF_8); // ✓
// text = "안녕"
}
// 읽기 — 다른 인코딩
try (FileInputStream fis = new FileInputStream("file.txt")) {
byte[] bytes = fis.readAllBytes();
String text = new String(bytes, Charset.forName("EUC-KR")); // ❌
// text = 깨진 문자
}
// ❌ FileOutputStream 직접 — 매번 인코딩 처리
try (FileOutputStream fos = new FileOutputStream("file.txt")) {
fos.write("안녕".getBytes(StandardCharsets.UTF_8));
fos.write("\n".getBytes());
fos.write("하세요".getBytes(StandardCharsets.UTF_8));
}
// ✓ Writer 활용 — 인코딩 자동
try (Writer writer = new OutputStreamWriter(
new FileOutputStream("file.txt"),
StandardCharsets.UTF_8)) {
writer.write("안녕\n");
writer.write("하세요");
}
// ✓✓ BufferedWriter — 효율
try (BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(
new FileOutputStream("file.txt"),
StandardCharsets.UTF_8))) {
writer.write("안녕");
writer.newLine();
writer.write("하세요");
}
// ✓✓✓ NIO.2 (가장 권장)
try (BufferedWriter writer = Files.newBufferedWriter(
Path.of("file.txt"),
StandardCharsets.UTF_8)) {
writer.write("안녕");
writer.newLine();
writer.write("하세요");
}
// 한글 안전 패턴 (Unit 8.5, 8.6 에서 정밀)
public void writeKoreanSafe(Path path, String text) throws IOException {
try (BufferedWriter writer = Files.newBufferedWriter(path,
StandardCharsets.UTF_8)) {
writer.write(text);
}
}
public String readKoreanSafe(Path path) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(path,
StandardCharsets.UTF_8)) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append('\n');
}
return sb.toString();
}
}
public class ShipmentKoreanWriter {
private static final Charset CHARSET = StandardCharsets.UTF_8;
// CSV 한글 포함
public void writeCsv(Path path, List<Shipment> shipments) throws IOException {
try (BufferedWriter writer = Files.newBufferedWriter(path, CHARSET)) {
// 헤더
writer.write("ID,선적번호,수하인이름,중량(kg)\n");
// 데이터
for (Shipment s : shipments) {
writer.write(s.getId() + ",");
writer.write(s.getBlNo() + ",");
writer.write(s.getConsigneeName() + ","); // 한글 OK
writer.write(s.getWeight() + "\n");
}
}
}
// JSON 한글
public void writeJson(Path path, Shipment s) throws IOException {
String json = String.format(
"{\"id\":%d,\"수하인\":\"%s\",\"비고\":\"%s\"}",
s.getId(), s.getConsigneeName(), s.getNotes());
Files.writeString(path, json, CHARSET);
}
// 로그 (한글 + 영문)
public void log(Path logFile, String level, String message) throws IOException {
String line = String.format("[%s] %s %s%n",
LocalDateTime.now(), level, message);
Files.writeString(logFile, line, CHARSET,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
}
한글이 깨지는 메커니즘은?
답:
1. 한글 = 다바이트:
write(int) 의 한계:
올바른 방법:
byte[] utf8 = text.getBytes(UTF_8)fos.write(utf8)권장:
OutputStreamWriter + 인코딩Files.newBufferedWriter인코딩 일치:
// FileOutputStream 이 자동으로:
// 1. 파일 없으면 생성 (빈 파일)
// 2. 파일 있으면 truncate (덮어쓰기 모드) 또는 append
new FileOutputStream("file.txt");
// "file.txt" 가 없으면 생성, 있으면 truncate
// ❌ 부모 디렉토리는 자동 생성 X
new FileOutputStream("/var/data/2026/05/file.txt");
// /var/data/2026/05 가 없으면 ★ FileNotFoundException
// 에러 메시지:
// java.io.FileNotFoundException: /var/data/2026/05/file.txt
// (No such file or directory)
// 자주 발생하는 함정
// 시나리오 1: 날짜별 디렉토리
public void writeLog(String message) throws IOException {
LocalDate today = LocalDate.now();
String path = String.format("/var/log/%d/%02d/%02d/app.log",
today.getYear(), today.getMonthValue(), today.getDayOfMonth());
// ❌ 부모 디렉토리 누락
try (FileOutputStream fos = new FileOutputStream(path, true)) {
fos.write(message.getBytes());
}
// FileNotFoundException 가능
}
// 시나리오 2: 업로드 처리
public void saveUpload(String userId, byte[] data) throws IOException {
String path = "/var/uploads/" + userId + "/file.dat";
// ❌ /var/uploads/<userId> 없으면 실패
try (FileOutputStream fos = new FileOutputStream(path)) {
fos.write(data);
}
}
// ✓ 부모 디렉토리 미리 생성
public void writeLogSafe(String message) throws IOException {
LocalDate today = LocalDate.now();
Path path = Path.of("/var/log",
String.valueOf(today.getYear()),
String.format("%02d", today.getMonthValue()),
String.format("%02d", today.getDayOfMonth()),
"app.log");
// 부모 디렉토리 보장
Files.createDirectories(path.getParent());
try (FileOutputStream fos = new FileOutputStream(path.toFile(), true)) {
fos.write(message.getBytes());
}
}
// ✓ File 활용
public void writeLogFile(String message) throws IOException {
File file = new File("/var/log/2026/05/19/app.log");
// 부모 디렉토리 생성
File parent = file.getParentFile();
if (parent != null && !parent.exists()) {
if (!parent.mkdirs()) {
throw new IOException("Cannot create dir: " + parent);
}
}
try (FileOutputStream fos = new FileOutputStream(file, true)) {
fos.write(message.getBytes());
}
}
// ✓ NIO.2 (가장 권장)
public void writeLogModern(String message) throws IOException {
Path path = Path.of("/var/log/2026/05/19/app.log");
Files.createDirectories(path.getParent());
Files.writeString(path, message + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
// File.createNewFile
File file = new File("/var/data/file.txt");
file.createNewFile(); // 부모 없으면 IOException
// FileOutputStream
new FileOutputStream("/var/data/file.txt");
// 부모 없으면 FileNotFoundException
// 둘 다 부모 디렉토리 자동 생성 X
// mkdirs() 또는 createDirectories() 필요
// 권한 없는 경로
new FileOutputStream("/root/file.txt");
// FileNotFoundException
// "Permission denied"
// 읽기 전용 파일 시스템
new FileOutputStream("/proc/test.txt");
// IOException
// 검증 후 시도
public boolean canWrite(Path path) {
Path parent = path.getParent();
return parent != null && Files.isWritable(parent);
}
@Component
public class SafeFileWriter {
private final Path baseDir = Path.of("/var/shipment");
// 1. 안전한 파일 생성
public void writeShipmentFile(String relativePath, byte[] data) throws IOException {
Path file = baseDir.resolve(relativePath).normalize();
// Path Traversal 방어
if (!file.startsWith(baseDir)) {
throw new SecurityException("Invalid path: " + relativePath);
}
// 부모 디렉토리 생성
Files.createDirectories(file.getParent());
// 권한 확인
if (!Files.isWritable(file.getParent())) {
throw new IOException("Cannot write to: " + file.getParent());
}
// 쓰기
Files.write(file, data);
}
// 2. 날짜별 로그
public void writeDateLog(String level, String message) throws IOException {
LocalDate today = LocalDate.now();
Path file = baseDir.resolve("logs")
.resolve(String.valueOf(today.getYear()))
.resolve(String.format("%02d", today.getMonthValue()))
.resolve(String.format("%02d", today.getDayOfMonth()))
.resolve(level.toLowerCase() + ".log");
Files.createDirectories(file.getParent());
String line = String.format("[%s] %s%n",
LocalDateTime.now(), message);
Files.writeString(file, line, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
// 3. 업로드 안전 처리
public Path saveUpload(String userId, String filename, byte[] data)
throws IOException {
// 사용자 디렉토리
Path userDir = baseDir.resolve("uploads").resolve(userId);
Files.createDirectories(userDir);
// 안전한 파일명 (시간스탬프 + 원본)
String safeFilename = System.currentTimeMillis() + "_" + sanitize(filename);
Path target = userDir.resolve(safeFilename);
// 쓰기
Files.write(target, data,
StandardOpenOption.CREATE_NEW); // 새 파일만 (중복 방지)
return target;
}
private String sanitize(String filename) {
return filename.replaceAll("[^a-zA-Z0-9.가-힣_-]", "_");
}
}
파일 생성과 부모 디렉토리 함정은?
답:
1. 자동 생성:
부모 디렉토리 없으면:
해결:
Files.createDirectories(file.getParent())file.getParentFile().mkdirs()자주 발생하는 곳:
추가 안전:
| Q | 핵심 답변 |
|---|---|
| FileOutputStream 정의? | OutputStream 의 파일 전용 |
| 5가지 생성자? | String, File, FileDescriptor + append 변형 |
| 덮어쓰기 vs append? | truncate vs 끝에 추가 |
new FileOutputStream(path, true)? | append 모드 |
| write(int b) 의 동작? | 하위 8비트만, 0~255 |
| write(byte[], 0, n) 의 이유? | 마지막 read 의 n < buf.length |
| flush() 의 역할? | 버퍼 → OS 강제 |
| FileOutputStream 의 flush? | 자체 버퍼링 X, 효과 없음 |
| BufferedOutputStream 의 flush? | 8KB 버퍼 → OS |
| flush vs force? | 자바→OS vs OS→디스크 |
| 바이트가 영어로? | ASCII/UTF-8 매핑 |
| 한글 1바이트 쓰면? | 깨짐 (3바이트 필요) |
| 부모 디렉토리 자동 생성? | X, mkdirs 필요 |
답:
fos.write(data);
fos.flush(); // 자바 → OS
fos.getChannel().force(true); // OS → 디스크
답:
답:
try (BufferedOutputStream bos = new BufferedOutputStream(
new FileOutputStream("file.txt"))) {
bos.write(data);
}
// close 순서:
// 1. bos.close() — flush + 자기 close
// 2. (내부) fos.close() — 파일 핸들 해제
// 외부 (BufferedOutputStream) 만 close 해도 OK
// 내부 (FileOutputStream) 도 close 됨
답:
답:
1. FileOutputStream
2. write 와 flush
3. 함정
이번 Unit에서 1바이트 쓰기의 한계를 봤다면, 다음은 한글 읽기.
🚀 Phase 8 — Stream 실전
✅ 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 1 ~ 7 완주 (31 Unit)
🚀 Phase 8 — Stream 실전 (4/6 진행)
총: 35/43 Unit (약 81%)