파일은 디스크 위의 바이트 나열입니다. 자바는 이것을 두 층으로 다룹니다.
디스크 (바이트) ──▶ InputStream (byte) ──▶ Reader (char) ──▶ String
FileInputStream InputStreamReader readLine()
(+ Charset 디코딩)| 계층 | 기반 클래스 | 단위 | 용도 |
|---|---|---|---|
| 바이트 스트림 | InputStream / OutputStream |
byte |
이미지, 압축 파일, 바이너리 전문, 그리고 문자 스트림의 밑바닥 |
| 문자 스트림 | Reader / Writer |
char (UTF-16) |
텍스트. 내부에서 바이트 ↔ 문자 변환(디코딩/인코딩) 수행 |
FileReader 는 사실 InputStreamReader(new FileInputStream(f)) 의 축약입니다. 바이트를 읽어 Charset 규칙으로 문자로 바꾸는 층이 항상 끼어 있습니다. 이 변환은 공짜가 아니며, 어떤 Charset 을 쓰는지가 한글 깨짐의 원인이 됩니다(2.4).
자바 프로그램은 디스크를 직접 읽지 못합니다. 운영체제(커널)에 "이 파일의 이 위치에서 N 바이트 줘" 라고 부탁해야 하고, 이 부탁이 시스템 콜(system call)입니다. 시스템 콜은 CPU 가 사용자 모드에서 커널 모드로 전환하고, 커널이 작업을 마친 뒤 다시 돌아오는 과정이라 한 번에 수백 ns ~ 수 µs 가 듭니다. 메모리 접근(수 ns)보다 100~1000배 느립니다.
버퍼 없이 read() 한 글자씩:
자바 ──read(1)──▶ 커널 ──▶ 디스크/페이지캐시 ──▶ 1바이트 x 4,000,000번 (4MB 파일)
시스템 콜 4,000,000번 x 1µs = 4초
BufferedReader (8KB 버퍼):
자바 ──read(8192)──▶ 커널 ──▶ 8KB 덩어리 x 500번
이후 readLine() 은 자바 힙의 버퍼(char[]) 에서 꺼냄 = 메모리 접근
시스템 콜 500번 x 1µs = 0.5ms버퍼링 = 시스템 콜 횟수를 줄이는 것입니다. 파일 크기가 같아도 커널에 부탁하는 횟수가 4,000,000번에서 500번으로 줄어드니 수천 배 차이가 납니다. (실제로는 FileReader 내부의 StreamDecoder 도 8KB 바이트 버퍼를 갖고 있어서 시스템 콜 자체는 이미 줄어 있지만, read() 한 글자마다 동기화·디코더 상태 확인·경계 검사가 반복되어 여전히 수 배~수십 배 느립니다. 벤치마크로 확인합니다.)
| 방식 | 시스템 콜 (4MB) | 100k 행 실측 (참고) |
|---|---|---|
FileReader.read() 한 글자씩 |
수백~수천 (내부 버퍼) + 글자당 오버헤드 | ~90 ms |
BufferedReader.readLine() |
~500 | ~25 ms |
Files.readAllLines |
~500 + 전체 List 생성 | ~35 ms (메모리 급증) |
Files.lines 스트림 |
~500 | ~25 ms |
MappedByteBuffer 바이트 스캔 |
0 (mmap 1번, 이후 페이지 폴트) | ~5 ms (디코딩 없음) |
쓰기도 같습니다. FileWriter.write() 를 버퍼 없이 호출하면 매번 커널에 부탁하고, BufferedWriter 는 8KB 가 찰 때까지 모았다가 한 번에 보냅니다.
| 클래스 | 특징 | 주의 |
|---|---|---|
BufferedWriter |
write(String), newLine(). 버퍼가 차거나 flush()/close() 때 실제 기록 |
IOException 을 던진다 → 오류를 놓치지 않음 |
PrintWriter |
println, printf 등 편의 메서드. 내부에 버퍼 있음 |
예외를 삼킨다. checkError() 로 확인해야 함. 배치에서 디스크 꽉 참을 모르고 지나갈 수 있음 |
FileWriter |
버퍼 없음(사실상). 반드시 BufferedWriter 로 감쌀 것 |
단독 사용 금지 |
flush() 는 "버퍼에 있는 것을 지금 커널로 보내라", close() 는 "flush 하고 핸들을 반납하라"입니다. close 하지 않으면 마지막 8KB 가 파일에 안 남습니다. "파일 끝이 잘려 있어요"의 원인 1위입니다. 그래서 try-with-resources 는 선택이 아니라 필수입니다.
try (BufferedWriter w = Files.newBufferedWriter(path, UTF_8)) { // 블록 끝에서 close() 자동
w.write("...");
} // 예외가 나도 close() → flush + 핸들 반납바이트를 문자로 바꾸는 규칙이 Charset 입니다. 한글 "가" 는 UTF-8 에서 3바이트(EA B0 80), MS949(CP949, EUC-KR 확장) 에서 2바이트(B0 A1) 입니다. 쓴 쪽과 읽는 쪽의 규칙이 다르면 깨집니다.
| 상황 | 결과 |
|---|---|
| UTF-8 로 쓴 파일을 MS949 로 읽음 | 媛 같은 한자 뭉치 |
| MS949 로 쓴 파일을 UTF-8 로 읽음 | �(�) 대체 문자 |
| 인코딩을 지정하지 않음 | JDK 17 까지는 OS 기본값(Windows MS949, Linux UTF-8). 서버에서 깨짐 |
| JDK 18+ | 기본값이 UTF-8 로 통일(JEP 400). 그래도 명시하는 습관을 들일 것 |
실무 함정: Excel 이 저장한 CSV 는 Windows 에서 MS949 입니다. 담당자가 Excel 로 만들어 준 회원 목록 CSV 를 UTF-8 로 읽으면 이름이 전부 깨집니다. 반대로 UTF-8 CSV 를 Excel 로 열면 깨지므로, Excel 용 CSV 는 BOM(EF BB BF)을 앞에 붙이거나 MS949 로 씁니다.
Files.newBufferedReader(path, StandardCharsets.UTF_8); // 항상 명시
Files.newBufferedReader(path, Charset.forName("MS949")); // Excel CSVJDK 7 의 NIO.2 (java.nio.file) 는 File 클래스의 불편함(예외 없는 실패, 심볼릭 링크, 원자적 이동 불가)을 해결했습니다. 배치에서는 Files 의 정적 메서드를 주로 씁니다.
| 메서드 | 메모리 | 용도 |
|---|---|---|
Files.readAllLines(p, cs) |
전체 List |
설정 파일 등 작은 파일만. 대용량 금지 |
Files.readString(p) |
전체 String | 작은 파일 |
Files.newBufferedReader(p, cs) |
8KB 버퍼 | 대용량 한 줄씩. 배치 기본값 |
Files.lines(p, cs) |
지연 스트림 | 대용량 + 스트림 API. 반드시 try-with-resources (파일 핸들을 잡고 있음) |
Files.newBufferedWriter(p, cs, options) |
8KB 버퍼 | 쓰기 기본값. APPEND, CREATE_NEW 등 옵션 |
Files.write(p, lines, cs) |
리스트 크기 | 청크 하나를 파일로 |
Files.walk(dir) |
지연 스트림 | 디렉터리 재귀 순회 (try-with-resources) |
Files.copy / move |
- | ATOMIC_MOVE, REPLACE_EXISTING 옵션 |
Files.size / exists / createDirectories |
- | 메타 조작 |
Files.readAllLines 와 Files.lines 는 이름이 비슷하지만 정반대입니다. 전자는 파일을 다 읽어 List 를 만들고 반환하고, 후자는 파일을 열어 두고 한 줄씩 꺼내 주는 스트림을 반환합니다. 후자를 닫지 않으면 파일 핸들이 GC 될 때까지 열려 있습니다.
FileChannel.map() 은 파일을 프로세스의 가상 메모리에 직접 매핑합니다. 읽기 시스템 콜이 사라지고, 파일 내용이 마치 byte[] 처럼 보입니다. 실제 디스크 읽기는 접근하는 페이지(4KB)에서 페이지 폴트로 일어나며 OS 페이지 캐시를 그대로 씁니다.
일반 읽기: 디스크 → 커널 페이지 캐시 → (복사) → 자바 힙 byte[] → (디코딩) → char[]
mmap: 디스크 → 커널 페이지 캐시 ═══(매핑)═══ MappedByteBuffer (복사 없음)| 유리한 경우 | 불리한 경우 |
|---|---|
| 같은 파일을 여러 번 임의 접근(인덱스 파일, 검색) | 한 번 순차 읽기 (BufferedReader 와 큰 차이 없거나 오히려 손해) |
| 바이트 수준 스캔(줄 수 세기, 구분자 찾기) — 디코딩 없음 | 텍스트를 문자열로 변환해야 하는 일반 CSV 파싱 (결국 디코딩 필요) |
| 프로세스 간 파일 공유 | 2GB 초과 파일(한 번에 Integer.MAX_VALUE 까지만 매핑, 나눠서 매핑 필요) |
| 대용량 바이너리 랜덤 접근 | 매핑 해제 시점 제어 불가(GC 의존) → 파일 삭제/이름 변경이 Windows 에서 실패하기도 함 |
배치에서는 "줄 수 세기", "특정 바이트 패턴 찾기" 같은 전처리에 쓰고, 본 처리는 BufferedReader 로 하는 것이 일반적입니다.
1,2026-09-01,1001,WITHDRAW,5000,"lunch, with team"line.split(",") 은 7개 필드를 만듭니다. memo 안의 쉼표를 구분자로 오해하기 때문입니다. RFC 4180 규칙은 다음과 같습니다.
| 규칙 | 예 |
|---|---|
| 필드에 쉼표·따옴표·개행이 있으면 따옴표로 감싼다 | "lunch, with team" |
| 감싼 필드 안의 따옴표는 두 번 쓴다 | "book, ""Java"" 21" → book, "Java" 21 |
| 감싸지 않은 필드는 그대로 | WITHDRAW |
파서는 상태 머신입니다. inQuotes 플래그 하나로 "지금 이 쉼표가 구분자인가"를 판단합니다.
상태: OUT(따옴표 밖) 상태: IN(따옴표 안)
',' → 필드 종료 ',' → 그냥 문자
'"' → IN 으로 '"' 다음이 '"' → 문자 '"' 추가, 두 칸 전진
기타 → 문자 추가 '"' 다음이 아님 → OUT 으로
기타 → 문자 추가한 줄 안에 개행이 들어간 CSV(따옴표 안 줄바꿈)는 readLine() 으로 나눌 수 없으므로 문자 단위로 읽는 파서가 필요합니다. 이 레슨의 파서는 "한 줄 = 한 레코드"를 가정합니다. 실무에서 그 가정이 깨지면 Apache Commons CSV 나 OpenCSV 를 씁니다. 파서를 직접 쓰는 이유는 라이브러리를 쓰지 말자는 것이 아니라, 라이브러리가 뭘 해 주는지 알자는 것입니다.
금융권 전문은 필드 구분자 없이 위치와 길이로 필드를 정의합니다.
id(10) date(8) acct(6) t(1) amount(12)
0000000001 20260915 001234 W 000000005000
└─ 0~10 ─┘└─10~18─┘└18~24┘└24┘└── 25~37 ──┘| 장점 | 단점 |
|---|---|
파싱이 substring 뿐이라 매우 빠름 |
필드 길이 초과 값은 잘림 |
| 구분자 이스케이프 문제 없음 | 레이아웃 문서가 없으면 해석 불가 |
| 메인프레임/COBOL 과 호환 | 한글 필드는 바이트 길이(MS949 2바이트). substring 대신 바이트로 자름 |
한글이 섞인 전문은 byte[] 로 읽어 Arrays.copyOfRange 후 new String(bytes, MS949) 로 처리합니다. ASCII 만 있는 전문은 substring 으로 충분합니다.
파일 쓰기는 트랜잭션이 없습니다. 100만 행 중 60만 행을 쓰다 죽으면 60만 행짜리 파일이 정식 이름으로 남고, 새벽 4시에 그 파일을 가져가는 다른 시스템은 "정상 파일"로 처리합니다.
❌ 직접 쓰기:
report.csv ← 쓰는 중... (죽음) → 반쪽 report.csv 가 남음
✅ 임시 파일 + 원자적 이동:
report.csv.tmp ← 쓰는 중... (죽음) → .tmp 만 남음, report.csv 없음 → 소비자는 기다림
report.csv.tmp ← 완성 → Files.move(tmp, report.csv, ATOMIC_MOVE) → 한순간에 나타남ATOMIC_MOVE 는 같은 파일시스템 안에서 rename 시스템 콜 하나로 처리되어 "반쯤 이동된" 상태가 존재하지 않습니다. 다른 파일시스템(다른 드라이브) 간에는 복사+삭제가 되므로 원자성이 깨지며, 자바는 이 경우 AtomicMoveNotSupportedException 을 던집니다. 임시 파일은 반드시 목적지와 같은 디렉터리에 만드세요.
| 작업 | 왜 필요한가 | 방법 |
|---|---|---|
| 분할(split) | 병렬 처리(파일 하나 = 워커 하나), 전송 크기 제한, 재시작 단위 축소 | 한 줄씩 읽으며 N 줄마다 새 파일 열기. 헤더는 각 파일에 복사 |
| 병합(merge) | 병렬 처리 결과를 하나로, 여러 소스 취합 | 파일 순서대로 한 줄씩 복사. 첫 파일만 헤더 유지 |
두 작업 모두 "한 줄씩 스트리밍"이면 파일 크기와 무관하게 메모리는 8KB 버퍼 + 한 줄뿐입니다.