공공부하자개발 · 영어 학습 노트
자바
실무 배치대용량 데이터 처리0/6 완료
  • 01배치 프로세스 개념과 아키텍처
  • 02대용량 파일 I/O (NIO, Buffered)
  • 03Chunk 단위 처리와 OOM 방지
  • 04트랜잭션: 커밋과 롤백 시뮬레이션
  • 05Skip과 Retry 로직 구현
  • 06메서드 활용 패턴 (배치 유틸)
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 배치 › 02 / 6

대용량 파일 I/O (NIO, Buffered)

섹션 7진행 0 / 6
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 자바 I/O 의 두 계층: 바이트와 문자

파일은 디스크 위의 바이트 나열입니다. 자바는 이것을 두 층으로 다룹니다.

text
디스크 (바이트)  ──▶  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).

2.2 버퍼링이 왜 빠른가 — 시스템 콜의 비용

자바 프로그램은 디스크를 직접 읽지 못합니다. 운영체제(커널)에 "이 파일의 이 위치에서 N 바이트 줘" 라고 부탁해야 하고, 이 부탁이 시스템 콜(system call)입니다. 시스템 콜은 CPU 가 사용자 모드에서 커널 모드로 전환하고, 커널이 작업을 마친 뒤 다시 돌아오는 과정이라 한 번에 수백 ns ~ 수 µs 가 듭니다. 메모리 접근(수 ns)보다 100~1000배 느립니다.

text
버퍼 없이 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 가 찰 때까지 모았다가 한 번에 보냅니다.

2.3 BufferedWriter vs PrintWriter, flush 와 close

클래스 특징 주의
BufferedWriter write(String), newLine(). 버퍼가 차거나 flush()/close() 때 실제 기록 IOException 을 던진다 → 오류를 놓치지 않음
PrintWriter println, printf 등 편의 메서드. 내부에 버퍼 있음 예외를 삼킨다. checkError() 로 확인해야 함. 배치에서 디스크 꽉 참을 모르고 지나갈 수 있음
FileWriter 버퍼 없음(사실상). 반드시 BufferedWriter 로 감쌀 것 단독 사용 금지

flush() 는 "버퍼에 있는 것을 지금 커널로 보내라", close() 는 "flush 하고 핸들을 반납하라"입니다. close 하지 않으면 마지막 8KB 가 파일에 안 남습니다. "파일 끝이 잘려 있어요"의 원인 1위입니다. 그래서 try-with-resources 는 선택이 아니라 필수입니다.

java
try (BufferedWriter w = Files.newBufferedWriter(path, UTF_8)) {   // 블록 끝에서 close() 자동
    w.write("...");
}   // 예외가 나도 close() → flush + 핸들 반납

2.4 인코딩 — 명시하지 않으면 사고가 난다

바이트를 문자로 바꾸는 규칙이 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 로 씁니다.

java
Files.newBufferedReader(path, StandardCharsets.UTF_8);          // 항상 명시
Files.newBufferedReader(path, Charset.forName("MS949"));         // Excel CSV

2.5 NIO — java.nio.file.Files 와 Path

JDK 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 될 때까지 열려 있습니다.

2.6 메모리 매핑 파일 — MappedByteBuffer

FileChannel.map() 은 파일을 프로세스의 가상 메모리에 직접 매핑합니다. 읽기 시스템 콜이 사라지고, 파일 내용이 마치 byte[] 처럼 보입니다. 실제 디스크 읽기는 접근하는 페이지(4KB)에서 페이지 폴트로 일어나며 OS 페이지 캐시를 그대로 씁니다.

text
일반 읽기:  디스크 → 커널 페이지 캐시 → (복사) → 자바 힙 byte[] → (디코딩) → char[]
mmap:      디스크 → 커널 페이지 캐시 ═══(매핑)═══ MappedByteBuffer   (복사 없음)
유리한 경우 불리한 경우
같은 파일을 여러 번 임의 접근(인덱스 파일, 검색) 한 번 순차 읽기 (BufferedReader 와 큰 차이 없거나 오히려 손해)
바이트 수준 스캔(줄 수 세기, 구분자 찾기) — 디코딩 없음 텍스트를 문자열로 변환해야 하는 일반 CSV 파싱 (결국 디코딩 필요)
프로세스 간 파일 공유 2GB 초과 파일(한 번에 Integer.MAX_VALUE 까지만 매핑, 나눠서 매핑 필요)
대용량 바이너리 랜덤 접근 매핑 해제 시점 제어 불가(GC 의존) → 파일 삭제/이름 변경이 Windows 에서 실패하기도 함

배치에서는 "줄 수 세기", "특정 바이트 패턴 찾기" 같은 전처리에 쓰고, 본 처리는 BufferedReader 로 하는 것이 일반적입니다.

2.7 CSV 파싱 — split(",") 이 틀리는 이유

text
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 플래그 하나로 "지금 이 쉼표가 구분자인가"를 판단합니다.

text
상태: OUT(따옴표 밖)          상태: IN(따옴표 안)
  ','  → 필드 종료               ','  → 그냥 문자
  '"'  → IN 으로                 '"'  다음이 '"' → 문자 '"' 추가, 두 칸 전진
  기타 → 문자 추가               '"'  다음이 아님 → OUT 으로
                                 기타 → 문자 추가

한 줄 안에 개행이 들어간 CSV(따옴표 안 줄바꿈)는 readLine() 으로 나눌 수 없으므로 문자 단위로 읽는 파서가 필요합니다. 이 레슨의 파서는 "한 줄 = 한 레코드"를 가정합니다. 실무에서 그 가정이 깨지면 Apache Commons CSV 나 OpenCSV 를 씁니다. 파서를 직접 쓰는 이유는 라이브러리를 쓰지 말자는 것이 아니라, 라이브러리가 뭘 해 주는지 알자는 것입니다.

2.8 고정길이 파일(전문)

금융권 전문은 필드 구분자 없이 위치와 길이로 필드를 정의합니다.

text
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 으로 충분합니다.

2.9 안전한 쓰기 — 임시 파일 + 원자적 이동

파일 쓰기는 트랜잭션이 없습니다. 100만 행 중 60만 행을 쓰다 죽으면 60만 행짜리 파일이 정식 이름으로 남고, 새벽 4시에 그 파일을 가져가는 다른 시스템은 "정상 파일"로 처리합니다.

text
❌ 직접 쓰기:
   report.csv ← 쓰는 중... (죽음) → 반쪽 report.csv 가 남음

✅ 임시 파일 + 원자적 이동:
   report.csv.tmp ← 쓰는 중... (죽음) → .tmp 만 남음, report.csv 없음 → 소비자는 기다림
   report.csv.tmp ← 완성 → Files.move(tmp, report.csv, ATOMIC_MOVE) → 한순간에 나타남

ATOMIC_MOVE 는 같은 파일시스템 안에서 rename 시스템 콜 하나로 처리되어 "반쯤 이동된" 상태가 존재하지 않습니다. 다른 파일시스템(다른 드라이브) 간에는 복사+삭제가 되므로 원자성이 깨지며, 자바는 이 경우 AtomicMoveNotSupportedException 을 던집니다. 임시 파일은 반드시 목적지와 같은 디렉터리에 만드세요.

2.10 파일 분할과 병합

작업 왜 필요한가 방법
분할(split) 병렬 처리(파일 하나 = 워커 하나), 전송 크기 제한, 재시작 단위 축소 한 줄씩 읽으며 N 줄마다 새 파일 열기. 헤더는 각 파일에 복사
병합(merge) 병렬 처리 결과를 하나로, 여러 소스 취합 파일 순서대로 한 줄씩 복사. 첫 파일만 헤더 유지

두 작업 모두 "한 줄씩 스트리밍"이면 파일 크기와 무관하게 메모리는 8KB 버퍼 + 한 줄뿐입니다.

핵심 원리
  • 2.1 자바 I/O 의 두 계층: 바이트와 문자
  • 2.2 버퍼링이 왜 빠른가 — 시스템 콜의 비용
  • 2.3 BufferedWriter vs PrintWriter, flush 와 close
  • 2.4 인코딩 — 명시하지 않으면 사고가 난다
  • 2.5 NIO — java.nio.file.Files 와 Path
  • 2.6 메모리 매핑 파일 — MappedByteBuffer
  • 2.7 CSV 파싱 — split(",") 이 틀리는 이유
  • 2.8 고정길이 파일(전문)
  • 2.9 안전한 쓰기 — 임시 파일 + 원자적 이동
  • 2.10 파일 분할과 병합
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제