F-LAB JAVA · 3주차 · Phase 8 · Stream 실전
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
read(byte[]) 의 정확한 동작과 반환값은?read(byte[], off, len) 의 3가지 매개변수의 의미는?for (byte b : buf) 가 왜 위험 한가?readNBytes (Java 9+) 와의 차이는?transferTo (Java 9+) 의 활용은?
read(byte[] buf)는 한 번의 호출로 최대buf.length만큼 읽지만, 실제 읽은 바이트 수는 반환값n만큼이며buf의 0~n-1 만 유효하다.
1바이트씩 read 는 매 호출이 system call 이지만 byte[] 활용은 한 번에 여러 바이트를 받아 호출 횟수를 절감 — 1MB 파일 처리 시 수천~수만 배 빨라진다.
가장 흔한 함정은for (byte b : buf)— 마지막 read 에서n < buf.length인 경우 이전 데이터가 남아 잘못된 결과.
해결:for (int k = 0; k < n; k++)또는process(buf, 0, n)으로n만큼만 처리.
버퍼 크기는 보통 4~8KB (OS 페이지 크기), 대용량 I/O 에선 64KB 도 좋음.
1바이트씩 (컵 한 잔씩):
- 매번 수도꼭지 ↔ 컵
- 수천 번 왕복
- 매우 느림
byte[] 활용 (양동이로):
- 한 번에 8KB 양동이 채움
- 같은 양 → 128번 왕복
- 빠름
함정 (마지막 양동이):
- 1MB 파일을 8KB 양동이로
- 128번째: 8192바이트 (가득)
- 129번째: 부분만 (1024바이트)
- "양동이 전체" 로 처리하면 ★ 이전 데이터 남음
- "n 만큼만" (1024) 처리해야 정확
→ byte[] = 효율 + 정확한 n 처리.
1. read(byte[]) 의 정확한 동작
2. read(byte[], off, len) 의 매개변수
3. 1바이트 vs byte[] 성능 비교
4. 마지막 읽기 함정 — n 만큼만!
5. for-each 의 위험성
6. 버퍼 크기 선택의 기준
7. read 가 buf.length 안 채우는 이유
8. readNBytes, transferTo (Java 9+)
9. 면접 + 자기 점검
public int read(byte[] b) throws IOException;
// 또는 (내부적으로)
public int read(byte[] b) throws IOException {
return read(b, 0, b.length);
}
핵심:
사용자 코드:
byte[] buf = new byte[8192];
int n = fis.read(buf);
JVM 내부:
1. native 메서드 호출
2. JNI 통해 OS read 시스템 호출
3. user → kernel 전환
4. kernel 이 파일에서 최대 buf.length 바이트 읽음
5. user buffer 에 복사
6. kernel → user 전환
7. 실제 읽은 바이트 수 반환 (n)
n 의 의미:
- 양수 (1 ~ buf.length): 실제 읽은 수
- -1: EOF (한 바이트도 못 읽음)
- 0: 매우 드뭄 (보통 Blocking 에서)
파일 내용 (20 바이트):
[A B C D E F G H I J K L M N O P Q R S T]
0 1 2 3 4 5 6 7 8 9 10..............19
버퍼 (8 바이트):
byte[] buf = new byte[8];
read 호출 1:
int n = fis.read(buf); // n = 8
buf: [A, B, C, D, E, F, G, H] ← 가득
파일 위치: 8
read 호출 2:
int n = fis.read(buf); // n = 8
buf: [I, J, K, L, M, N, O, P] ← 가득
파일 위치: 16
read 호출 3:
int n = fis.read(buf); // n = 4 (남은 만큼)
buf: [Q, R, S, T, M, N, O, P] ← 0~3 만 유효!
파일 위치: 20
★ buf 의 4~7 은 이전 데이터 (M, N, O, P)
★ 0 ~ n-1 (0~3) 만 처리해야
read 호출 4:
int n = fis.read(buf); // n = -1 (EOF)
// 가장 일반적
try (FileInputStream fis = new FileInputStream("file.txt")) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) != -1) {
// 0 ~ n-1 처리 (n 까지가 아님!)
process(buf, 0, n);
}
}
// 잘못된 패턴 (다음 섹션에서 정밀)
// for (byte b : buf) { ... } // ❌
보장:
- n ≥ 0 (음수는 -1 만)
- n ≤ buf.length
- 데이터는 buf[0] ~ buf[n-1] 에
- buf[n] ~ buf[buf.length-1] 는 이전 값
다음 호출:
- 파일 위치는 자동 증가
- 다음 read 는 이어서
EOF:
- n = -1 반환
- buf 는 변경 없음 (또는 일부만)
- 보장 X: 정확한 상태
public class ShipmentFileReader {
private static final int BUFFER_SIZE = 8192; // 8KB
public byte[] readAllBytes(Path path) throws IOException {
try (FileInputStream fis = new FileInputStream(path.toFile());
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = fis.read(buf)) != -1) {
baos.write(buf, 0, n); // ★ n 만큼만
}
return baos.toByteArray();
}
}
}
read(byte[]) 의 정확한 동작은?
답:
1. 시그니처:
int read(byte[] b)반환값:
데이터 위치:
호출 결과:
권장 패턴:
while ((n = read(buf)) != -1)process(buf, 0, n) — n 만큼만public int read(byte[] b, int off, int len) throws IOException;
// 매개변수:
// - b: 채울 배열
// - off: 채우기 시작 위치 (offset)
// - len: 최대 채울 바이트 수
// 동작:
// - b[off] ~ b[off+len-1] 채움
// - 실제 읽은 수 반환 (0 ~ len)
// - -1 (EOF)
// 부분 채우기
byte[] buf = new byte[1024];
// buf 의 100 위치부터 200 바이트 읽기
int n = fis.read(buf, 100, 200);
// buf[100] ~ buf[299] 에 데이터
// buf[0~99] 와 buf[300~1023] 은 변경 X
// 효과:
// - 일부분만 채우기
// - 다른 데이터와 결합
// - 디스플레이 영역 등
// 같은 동작
fis.read(buf);
fis.read(buf, 0, buf.length);
// 내부적으로 read(byte[]) 가 read(byte[], 0, length) 호출
public int read(byte[] b) throws IOException {
return read(b, 0, b.length);
}
// IndexOutOfBoundsException 가능
fis.read(buf, -1, 100); // off < 0
fis.read(buf, 0, -1); // len < 0
fis.read(buf, 100, 1000); // off + len > buf.length
// 자바 표준 InputStream 의 검증
// IndexOutOfBoundsException 또는 NullPointerException
// 시나리오: 헤더 (10바이트) + 본문 분리
public byte[] readWithHeader(InputStream is) throws IOException {
byte[] result = new byte[1024];
// 1. 헤더 10바이트
int n = is.read(result, 0, 10);
if (n != 10) throw new EOFException("Header incomplete");
// 2. 본문 (나머지)
n = is.read(result, 10, 1014);
// result 의 10 ~ 1023 위치에 채워짐
return result;
}
// FileInputStream.read 는 buf.length 안 채울 수 있음
byte[] buf = new byte[100];
int n = fis.read(buf);
// n < 100 가능
// DataInputStream.readFully 는 정확히 채움
DataInputStream dis = new DataInputStream(fis);
dis.readFully(buf);
// 정확히 100 바이트, 부족하면 EOFException
// 사용자 구현
public static void readFully(InputStream is, byte[] buf, int off, int len)
throws IOException {
int totalRead = 0;
while (totalRead < len) {
int n = is.read(buf, off + totalRead, len - totalRead);
if (n == -1) throw new EOFException();
totalRead += n;
}
}
// Java 9+ 의 readNBytes
int n = fis.readNBytes(buf, 0, 100);
// 정확히 100 (또는 EOF)
public class ShipmentBinaryReader {
// 헤더 분리 처리
public ShipmentRecord readRecord(InputStream is) throws IOException {
byte[] buf = new byte[256];
// 1. 헤더 16바이트
int n = is.read(buf, 0, 16);
if (n != 16) throw new EOFException("Header incomplete");
ShipmentHeader header = parseHeader(buf, 0, 16);
// 2. 본문 (헤더의 length 기반)
int bodyLength = header.getBodyLength();
n = is.read(buf, 16, bodyLength);
if (n != bodyLength) throw new EOFException("Body incomplete");
ShipmentBody body = parseBody(buf, 16, bodyLength);
return new ShipmentRecord(header, body);
}
// readNBytes 활용 (Java 9+)
public byte[] readExact(InputStream is, int length) throws IOException {
byte[] buf = new byte[length];
int n = is.readNBytes(buf, 0, length);
if (n != length) {
throw new EOFException("Expected " + length + " bytes, got " + n);
}
return buf;
}
}
read(byte[], off, len) 의 매개변수와 활용은?
답:
1. 매개변수:
반환:
데이터:
활용:
readFully (DataInputStream):
파일 크기 1MB (1,048,576 바이트):
1바이트씩 read:
- 호출 횟수: 1,048,576
- 각 호출 = 1 system call
byte[] (1KB 버퍼):
- 호출 횟수: 1024
- 1000배 절감
byte[] (8KB 버퍼):
- 호출 횟수: 128
- 약 8000배 절감
byte[] (64KB 버퍼):
- 호출 횟수: 16
- 약 65000배 절감
public class ReadPerformanceBenchmark {
public static void main(String[] args) throws IOException {
Path file = Path.of("1MB.dat");
// 1바이트씩
long start = System.nanoTime();
try (FileInputStream fis = new FileInputStream(file.toFile())) {
while (fis.read() != -1) { }
}
long oneByteTime = System.nanoTime() - start;
// byte[] 1KB
start = System.nanoTime();
try (FileInputStream fis = new FileInputStream(file.toFile())) {
byte[] buf = new byte[1024];
while (fis.read(buf) != -1) { }
}
long oneKBTime = System.nanoTime() - start;
// byte[] 8KB
start = System.nanoTime();
try (FileInputStream fis = new FileInputStream(file.toFile())) {
byte[] buf = new byte[8192];
while (fis.read(buf) != -1) { }
}
long eightKBTime = System.nanoTime() - start;
// BufferedInputStream
start = System.nanoTime();
try (FileInputStream fis = new FileInputStream(file.toFile());
BufferedInputStream bis = new BufferedInputStream(fis)) {
while (bis.read() != -1) { }
}
long bufferedTime = System.nanoTime() - start;
// 결과
System.out.printf("1 byte: %d ns%n", oneByteTime);
System.out.printf("1 KB: %d ns%n", oneKBTime);
System.out.printf("8 KB: %d ns%n", eightKBTime);
System.out.printf("Buffered: %d ns%n", bufferedTime);
}
}
1MB 파일 읽기 (대략적 결과):
1바이트씩: ~1,000,000,000 ns (1 초)
byte[] 1KB: ~1,500,000 ns (1.5 ms) — 약 670배
byte[] 8KB: ~500,000 ns (0.5 ms) — 약 2000배
byte[] 64KB: ~300,000 ns (0.3 ms) — 약 3300배
Buffered (1바이트): ~3,000,000 ns (3 ms) — 약 333배
결론:
- byte[] 가 압도적으로 빠름
- 8KB 버퍼가 sweet spot
- 64KB 이상은 큰 차이 없음
이유:
1. 시스템 호출 횟수
- 매 system call = ~1μs 오버헤드
- 1바이트: 100만 번 = 1초
- 8KB: 128번 = 128μs
2. 디스크 I/O 의 단위
- 디스크는 블록 단위 (4KB)
- 1바이트 요청도 4KB 읽음
- 큰 버퍼가 효율
3. CPU 캐시
- 큰 버퍼 = 캐시 히트율 ↑
- 1바이트 = 캐시 비효율
4. JIT 최적화
- 큰 버퍼 처리 = SIMD 등 활용
- 1바이트 = 최적화 제한
// 방법 1: byte[]
try (FileInputStream fis = new FileInputStream("file.txt")) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) != -1) {
process(buf, 0, n);
}
}
// 방법 2: BufferedInputStream
try (BufferedInputStream bis = new BufferedInputStream(
new FileInputStream("file.txt"))) {
int b;
while ((b = bis.read()) != -1) {
// 사용자는 1바이트씩
// 내부 8KB 버퍼
process((byte) b);
}
}
// 차이:
// - 방법 1: 사용자가 직접 byte[] 다룸
// - 방법 2: 1바이트 API 그대로, 내부 버퍼링
// - 성능: 비슷 (둘 다 효율적)
// - 가독성: 방법 2 가 더 단순
// BufferedInputStream + byte[] (가장 효율)
try (BufferedInputStream bis = new BufferedInputStream(
new FileInputStream("file.txt"))) {
byte[] buf = new byte[8192];
int n;
while ((n = bis.read(buf)) != -1) {
process(buf, 0, n);
}
}
// 효과:
// - BufferedInputStream 의 버퍼링
// - byte[] 의 일괄 처리
// - 사실상 가장 빠름
// - 단, BufferedInputStream + byte[] 큰 차이는 X
public class HighPerformanceFileReader {
private static final int OPTIMAL_BUFFER_SIZE = 8192; // 8KB
// 표준 패턴 — byte[] 활용
public long readAndCount(Path path) throws IOException {
long count = 0;
try (FileInputStream fis = new FileInputStream(path.toFile())) {
byte[] buf = new byte[OPTIMAL_BUFFER_SIZE];
int n;
while ((n = fis.read(buf)) != -1) {
count += n;
}
}
return count;
}
// BufferedInputStream + byte[]
public byte[] readAllBuffered(Path path) throws IOException {
try (BufferedInputStream bis = new BufferedInputStream(
new FileInputStream(path.toFile()));
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buf = new byte[OPTIMAL_BUFFER_SIZE];
int n;
while ((n = bis.read(buf)) != -1) {
baos.write(buf, 0, n);
}
return baos.toByteArray();
}
}
}
1바이트 vs byte[] 의 성능 차이는?
답:
1. 시스템 호출 횟수:
이유:
권장:
실측:
시나리오:
파일 크기: 20 바이트
버퍼 크기: 8 바이트
read 호출 결과:
1. n = 8 (가득)
2. n = 8 (가득)
3. n = 4 (마지막, 부분만) ← 함정 지점
4. n = -1 (EOF)
// ❌ 함정 — buf.length 사용
try (FileInputStream fis = new FileInputStream("20bytes.dat")) {
byte[] buf = new byte[8];
int n;
while ((n = fis.read(buf)) != -1) {
// 잘못 — buf 전체 처리
for (int i = 0; i < buf.length; i++) {
System.out.print((char) buf[i]);
}
}
}
// 동작:
// 1. n = 8 → buf 가득, 0~7 처리 (정상)
// 2. n = 8 → buf 가득, 0~7 처리 (정상, 이전 데이터 덮어씀)
// 3. n = 4 → 0~3 만 새 데이터, 4~7 은 이전 데이터!
// 하지만 buf.length 만큼 처리 → 잘못된 결과
파일: [A B C D E F G H I J K L M N O P Q R S T]
읽기 1: buf = [A B C D E F G H] n=8 ✓
읽기 2: buf = [I J K L M N O P] n=8 ✓
읽기 3: buf = [Q R S T M N O P] n=4 ★ 4~7 은 이전 데이터!
잘못된 처리 (buf.length):
Q R S T M N O P ← 잘못된 출력 (M N O P 가 두 번 나옴)
올바른 처리 (n 만큼):
Q R S T ← 정확한 출력
// ✓ 올바름 — n 만큼만
try (FileInputStream fis = new FileInputStream("20bytes.dat")) {
byte[] buf = new byte[8];
int n;
while ((n = fis.read(buf)) != -1) {
// n 까지만 처리
for (int i = 0; i < n; i++) {
System.out.print((char) buf[i]);
}
}
}
// 또는 write 에 0, n 전달
try (FileInputStream fis = new FileInputStream("src.dat");
FileOutputStream fos = new FileOutputStream("dest.dat")) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) != -1) {
fos.write(buf, 0, n); // ★ 0, n
}
}
// 또는 ByteArrayOutputStream
try (FileInputStream fis = new FileInputStream("file.dat");
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) != -1) {
baos.write(buf, 0, n); // ★ 0, n
}
}
// 실수 1: for-each (다음 섹션에서 정밀)
for (byte b : buf) {
process(b);
// 항상 buf.length 만큼
// 마지막 부분에서 이전 데이터 포함
}
// 실수 2: buf.length 직접
for (int i = 0; i < buf.length; i++) {
process(buf[i]);
}
// 실수 3: 잘못된 write
fos.write(buf); // buf.length 만큼 쓰기 (이전 데이터 포함)
// 또는
fos.write(buf, 0, buf.length); // 위와 동일
// ✓ 올바름
fos.write(buf, 0, n); // n 만큼만
// 파일 읽어서 String 으로
byte[] buf = new byte[8192];
int n = fis.read(buf);
// ❌ 잘못
String s = new String(buf, StandardCharsets.UTF_8);
// buf.length 모두 변환
// 끝에 \0 또는 이전 데이터 포함
// ✓ 올바름
String s = new String(buf, 0, n, StandardCharsets.UTF_8);
// 0 ~ n-1 만 변환
public class SafeFileReader {
// 패턴 1: ByteArrayOutputStream
public byte[] readAll(InputStream is) throws IOException {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int n;
while ((n = is.read(buf)) != -1) {
baos.write(buf, 0, n); // ✓ n 만큼
}
return baos.toByteArray();
}
}
// 패턴 2: process(buf, 0, n)
public void processStream(InputStream is, ByteConsumer consumer) throws IOException {
byte[] buf = new byte[8192];
int n;
while ((n = is.read(buf)) != -1) {
consumer.consume(buf, 0, n); // ✓ 0, n 명시
}
}
// 패턴 3: String 변환
public String readAsString(InputStream is) throws IOException {
StringBuilder sb = new StringBuilder();
byte[] buf = new byte[8192];
int n;
while ((n = is.read(buf)) != -1) {
sb.append(new String(buf, 0, n, StandardCharsets.UTF_8));
// ✓ 0, n 만 변환
}
return sb.toString();
}
// 패턴 4: 복사
public void copy(InputStream src, OutputStream dest) throws IOException {
byte[] buf = new byte[8192];
int n;
while ((n = src.read(buf)) != -1) {
dest.write(buf, 0, n); // ✓ 0, n
}
}
// 또는 Java 9+
public void copyEasy(InputStream src, OutputStream dest) throws IOException {
src.transferTo(dest); // 알아서 처리
}
@FunctionalInterface
interface ByteConsumer {
void consume(byte[] buf, int off, int len);
}
}
마지막 읽기 함정의 메커니즘은?
답:
1. 시나리오:
함정:
해결:
process(buf, 0, n) 패턴for (int i = 0; i < n; i++)흔한 위치:
fos.write(buf, 0, n)new String(buf, 0, n, charset)dest.write(buf, 0, n)// ❌ for-each 의 함정
byte[] buf = new byte[8192];
int n = fis.read(buf);
for (byte b : buf) {
process(b);
// 항상 buf.length (8192) 만큼 반복
// 마지막 read 에서 n < 8192 면 잘못된 결과
}
// 이유:
// - for-each 는 배열 전체 순회
// - n 을 무시
// - 이전 데이터 또는 0 (초기값) 처리
// 파일: "HELLO" (5바이트)
// 버퍼: byte[8]
try (FileInputStream fis = new FileInputStream("hello.txt")) {
byte[] buf = new byte[8];
int n = fis.read(buf); // n = 5
// 잘못 — for-each
for (byte b : buf) {
System.out.print((char) b);
}
// 출력: "HELLO\0\0\0" (\0 = 초기값 0)
// 5글자 후 0 (null) 3개
}
// 올바름
for (int i = 0; i < n; i++) {
System.out.print((char) buf[i]);
}
// 출력: "HELLO" ✓
// 버퍼 재사용 시 더 큰 함정
try (FileInputStream fis = new FileInputStream("file.txt")) {
byte[] buf = new byte[8];
int n;
StringBuilder sb = new StringBuilder();
while ((n = fis.read(buf)) != -1) {
// ❌ for-each
for (byte b : buf) {
sb.append((char) b);
}
// 마지막 read 에서 이전 데이터 누적
}
System.out.println(sb.toString());
// 파일 내용 + 이전 read 의 잔여 데이터
// 완전히 잘못된 결과
}
// for-each 를 안전하게 쓰려면
byte[] buf = new byte[8192];
int n = fis.read(buf);
// 1. 정확한 길이의 새 배열
byte[] data = Arrays.copyOf(buf, n); // 새 배열, 정확한 크기
for (byte b : data) {
process(b); // 안전
}
// 2. 또는 일반 for
for (int i = 0; i < n; i++) {
process(buf[i]);
}
// 3. 또는 Stream API
IntStream.range(0, n).forEach(i -> process(buf[i]));
// for-each 가 OK 인 경우:
// 1. 배열 전체가 의미 있을 때
byte[] fullData = Files.readAllBytes(path);
for (byte b : fullData) { // OK — 모든 데이터
process(b);
}
// 2. 완전히 채워진 배열
byte[] complete = new byte[1024];
fis.readNBytes(complete, 0, 1024); // 정확히 채움 (Java 9+)
for (byte b : complete) {
process(b); // OK
}
// 3. readAllBytes 결과
byte[] all = is.readAllBytes();
for (byte b : all) {
process(b); // OK
}
// 즉:
// - read(byte[]) 후엔 for-each 위험
// - readAllBytes 후엔 for-each OK
// - readNBytes 후엔 OK (정확히 채움)
체크리스트:
1. read(byte[]) 호출 후 for-each?
→ ❌ 위험
2. read(byte[]) 호출 후 buf.length 사용?
→ ❌ 위험
3. read(byte[]) 후 n 으로 제한?
→ ✓ 안전
4. readAllBytes 후 처리?
→ ✓ 안전
5. write(buf) 또는 write(buf, 0, buf.length)?
→ ❌ 위험 (마지막 read 후)
6. write(buf, 0, n)?
→ ✓ 안전
public class SafeByteProcessor {
// ❌ 잘못된 예 (코드 리뷰에서 잡아야)
public void badProcessFile(Path path) throws IOException {
try (FileInputStream fis = new FileInputStream(path.toFile())) {
byte[] buf = new byte[1024];
while (fis.read(buf) != -1) {
for (byte b : buf) { // ❌ 위험
process(b);
}
}
}
}
// ✓ 올바른 예 1
public void goodProcessFile1(Path path) throws IOException {
try (FileInputStream fis = new FileInputStream(path.toFile())) {
byte[] buf = new byte[1024];
int n;
while ((n = fis.read(buf)) != -1) {
for (int i = 0; i < n; i++) {
process(buf[i]);
}
}
}
}
// ✓ 올바른 예 2 (Arrays.copyOf)
public void goodProcessFile2(Path path) throws IOException {
try (FileInputStream fis = new FileInputStream(path.toFile())) {
byte[] buf = new byte[1024];
int n;
while ((n = fis.read(buf)) != -1) {
byte[] chunk = Arrays.copyOf(buf, n);
for (byte b : chunk) {
process(b);
}
}
}
}
// ✓ 가장 권장 (한 번에 모두)
public void bestProcessFile(Path path) throws IOException {
byte[] all = Files.readAllBytes(path);
for (byte b : all) { // 안전 (전체 데이터)
process(b);
}
}
private void process(byte b) {
// 처리
}
}
for-each 가 read(byte[]) 후 위험한 이유는?
답:
1. 이유:
결과:
해결:
for (int i = 0; i < n; i++)Arrays.copyOf(buf, n) 으로 새 배열process(buf, 0, n) 패턴for-each 가 OK 한 경우:
실무 권장:
매우 작은 파일 (< 1KB): 1KB 또는 직접 readAllBytes
일반 텍스트 파일 (< 1MB): 4KB ~ 8KB
일반 바이너리 (1MB ~ 1GB): 8KB ~ 64KB
대용량 파일 (> 1GB): 64KB ~ 1MB
네트워크 I/O: 4KB ~ 64KB
표준 자바 라이브러리:
BufferedInputStream: 8KB (DEFAULT_BUFFER_SIZE = 8192)
ByteArrayOutputStream: 32바이트 (초기), 2배씩 증가
버퍼 크기와 성능:
1KB 버퍼:
- 시스템 호출 ↑↑
- 메모리 ↓
- 작은 파일에 OK
8KB 버퍼:
- 시스템 호출 ↓
- 메모리 적당
- 가장 일반적 권장 (Sweet spot)
64KB 버퍼:
- 시스템 호출 ↓↓
- 메모리 좀 더
- 큰 파일에 효율
1MB 버퍼:
- 시스템 호출 최소
- 메모리 큼
- 매우 큰 파일만
- L1/L2 캐시 효과 ↓
OS 의 페이지 크기:
- Linux/Mac: 4KB (또는 16KB on M1/M2)
- Windows: 4KB
디스크 블록 크기:
- SSD: 4KB 또는 8KB
- HDD: 512바이트 또는 4KB
권장:
- 버퍼 크기 = 페이지 크기의 배수
- 4KB, 8KB, 16KB, 32KB, 64KB
- 페이지 정렬 시 효율 ↑
public class BufferSizeBenchmark {
public static void main(String[] args) throws IOException {
Path file = Path.of("100MB.dat");
int[] sizes = {1, 1024, 4096, 8192, 16384, 65536, 1048576};
for (int size : sizes) {
long start = System.nanoTime();
try (FileInputStream fis = new FileInputStream(file.toFile())) {
byte[] buf = new byte[size];
while (fis.read(buf) != -1) { }
}
long elapsed = System.nanoTime() - start;
System.out.printf("Buffer %d B: %d ms%n", size, elapsed / 1_000_000);
}
}
}
// 예상 결과 (100MB 파일):
// Buffer 1 B: ~100,000 ms (1바이트씩, 매우 느림)
// Buffer 1024 B: ~200 ms (1KB)
// Buffer 4096 B: ~80 ms (4KB)
// Buffer 8192 B: ~50 ms (8KB) ★
// Buffer 16384 B: ~45 ms (16KB)
// Buffer 65536 B: ~40 ms (64KB)
// Buffer 1048576 B: ~40 ms (1MB)
//
// → 8KB 부터 큰 차이 X
// → 8KB ~ 64KB 가 sweet spot
// 1. 작은 파일 (< 100KB) — 한 번에
byte[] all = Files.readAllBytes(path);
// 2. 일반 텍스트 — 8KB
try (BufferedReader reader = Files.newBufferedReader(path)) {
// BufferedReader 기본 8KB
}
// 3. 일반 바이너리 — 8KB
try (FileInputStream fis = new FileInputStream(file)) {
byte[] buf = new byte[8192];
// ...
}
// 4. 대용량 (영상, ISO 등) — 64KB
try (FileInputStream fis = new FileInputStream(file)) {
byte[] buf = new byte[65536];
// ...
}
// 5. 매우 큰 파일 (수 GB) — Memory-mapped
try (FileChannel ch = FileChannel.open(path, READ)) {
MappedByteBuffer buf = ch.map(MapMode.READ_ONLY, 0, ch.size());
// ...
}
// Direct Buffer 풀
public class BufferPool {
private final Queue<ByteBuffer> pool = new ConcurrentLinkedQueue<>();
private final int bufferSize;
private final int maxPoolSize;
public BufferPool(int bufferSize, int maxPoolSize) {
this.bufferSize = bufferSize;
this.maxPoolSize = maxPoolSize;
}
public ByteBuffer acquire() {
ByteBuffer buf = pool.poll();
return (buf != null) ? buf : ByteBuffer.allocateDirect(bufferSize);
}
public void release(ByteBuffer buf) {
buf.clear();
if (pool.size() < maxPoolSize) {
pool.offer(buf);
}
}
}
// 활용
BufferPool pool = new BufferPool(8192, 100);
ByteBuffer buf = pool.acquire();
try {
// 사용
} finally {
pool.release(buf);
}
// BufferedInputStream
public BufferedInputStream(InputStream in) {
this(in, 8192); // 8KB 기본
}
// BufferedReader
public BufferedReader(Reader in) {
this(in, 8192); // 8KB
}
// PrintWriter
// 내부 BufferedWriter 도 8KB
// Files.newBufferedReader
// 8KB 기본
// 결론: 표준의 sweet spot = 8KB
public class ShipmentIoConstants {
// 일반 텍스트 파일
public static final int TEXT_BUFFER_SIZE = 8192; // 8KB
// 대용량 바이너리 (이미지, PDF 등)
public static final int BINARY_BUFFER_SIZE = 65536; // 64KB
// 네트워크 I/O
public static final int NETWORK_BUFFER_SIZE = 16384; // 16KB
// 작은 파일
public static final long SMALL_FILE_THRESHOLD = 100_000; // 100KB
}
@Service
public class ShipmentFileService {
public byte[] readFile(Path path) throws IOException {
long size = Files.size(path);
if (size < ShipmentIoConstants.SMALL_FILE_THRESHOLD) {
// 작은 파일 — 한 번에
return Files.readAllBytes(path);
} else {
// 큰 파일 — 청크 단위
try (InputStream is = Files.newInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
int bufferSize = (size > 100_000_000)
? ShipmentIoConstants.BINARY_BUFFER_SIZE
: ShipmentIoConstants.TEXT_BUFFER_SIZE;
byte[] buf = new byte[bufferSize];
int n;
while ((n = is.read(buf)) != -1) {
baos.write(buf, 0, n);
}
return baos.toByteArray();
}
}
}
}
버퍼 크기 선택의 기준은?
답:
1. 일반 권장:
OS 페이지 크기:
자바 표준 기본:
실측:
선택 기준:
오해:
"read(buf) 는 buf.length 만큼 읽는다"
실제:
"read(buf) 는 0 ~ buf.length 만큼 읽을 수 있다"
즉:
- 최대 buf.length
- 더 작을 수도
- 정확히 buf.length 아닐 수 있음
1. 파일 끝 근접
- 남은 데이터가 buf.length 보다 적음
- 마지막 읽기에서 발생
2. 네트워크 패킷
- TCP/IP 는 패킷 단위
- 한 패킷 도착 후 즉시 리턴
- 더 기다리지 않음
3. 디스크 블록
- 한 번에 한 블록만 처리
- 사용자 요청 = 가능한 만큼만
4. 사용자 interrupt
- 인터럽트 신호로 중단 가능
- InterruptedIOException
// 시나리오 1: 파일 끝
// 파일 크기 100바이트, 버퍼 200바이트
try (FileInputStream fis = new FileInputStream("100bytes.dat")) {
byte[] buf = new byte[200];
int n = fis.read(buf);
// n = 100 (파일 끝)
}
// 시나리오 2: 네트워크
try (Socket socket = new Socket("example.com", 80);
InputStream is = socket.getInputStream()) {
byte[] buf = new byte[8192];
int n = is.read(buf);
// n = 패킷 크기 (보통 1500 이하)
// 또는 일부만 도착한 만큼
}
// 시나리오 3: 표준 입력
byte[] buf = new byte[1024];
int n = System.in.read(buf);
// n = 사용자가 Enter 누른 줄의 길이
// 정확히 buf.length 만큼 읽기
// 방법 1: 직접 loop
public static int readFully(InputStream is, byte[] buf, int off, int len)
throws IOException {
int totalRead = 0;
while (totalRead < len) {
int n = is.read(buf, off + totalRead, len - totalRead);
if (n == -1) return totalRead; // EOF
totalRead += n;
}
return totalRead;
}
// 방법 2: DataInputStream.readFully
DataInputStream dis = new DataInputStream(fis);
byte[] buf = new byte[100];
dis.readFully(buf);
// 정확히 100, 부족하면 EOFException
// 방법 3: Java 9+ readNBytes
int n = is.readNBytes(buf, 0, 100);
// 정확히 100 (또는 EOF 까지)
// 정확한 크기가 중요한 경우 — 헤더 등
// ❌ 단순 read
int n = is.read(headerBuf);
// n < headerBuf.length 가능 → 헤더 불완전
// ✓ readFully
DataInputStream dis = new DataInputStream(is);
byte[] headerBuf = new byte[16];
dis.readFully(headerBuf);
// 정확히 16바이트 보장
// ✓ readNBytes (Java 9+)
byte[] headerBuf = is.readNBytes(16);
// 정확히 16바이트 (또는 EOF 면 더 작음)
// 데이터를 점진적으로 읽기 — 좋은 패턴
public byte[] readWithProgress(InputStream is, long totalSize, ProgressListener listener)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
long readBytes = 0;
int n;
while ((n = is.read(buf)) != -1) {
baos.write(buf, 0, n);
readBytes += n;
listener.onProgress(readBytes, totalSize); // 진행률 통보
}
return baos.toByteArray();
}
// 네트워크에서 부분 읽기 활용
// - 데이터 일부 도착 → 즉시 처리
// - 모든 데이터 기다리지 않음
// - 응답성 ↑
public class ShipmentDataReader {
// 헤더 정확히 읽기
public ShipmentHeader readHeader(InputStream is) throws IOException {
byte[] buf = new byte[ShipmentHeader.HEADER_SIZE];
int n = is.readNBytes(buf, 0, buf.length); // Java 9+
if (n != ShipmentHeader.HEADER_SIZE) {
throw new EOFException("Header incomplete: " + n);
}
return ShipmentHeader.parse(buf);
}
// 진행률 표시 다운로드
public byte[] downloadWithProgress(URL url) throws IOException {
try (InputStream is = url.openStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int n;
long total = 0;
while ((n = is.read(buf)) != -1) {
baos.write(buf, 0, n);
total += n;
if (total % (1024 * 1024) < 8192) {
log.info("Downloaded: {} MB", total / (1024 * 1024));
}
}
return baos.toByteArray();
}
}
// 청크 단위 처리 — 메모리 효율
public void processInChunks(InputStream is, int chunkSize) throws IOException {
byte[] buf = new byte[chunkSize];
int n;
while ((n = is.read(buf)) != -1) {
processChunk(buf, 0, n);
}
}
}
read 가 buf.length 안 채우는 4가지 이유는?
답:
1. 파일 끝 근접: 남은 데이터 < buf.length
2. 네트워크 패킷: 한 패킷 도착 후 즉시 리턴
3. 디스크 블록: 한 번에 한 블록
4. 인터럽트: 사용자 interrupt 신호
정확히 읽으려면:
DataInputStream.readFullyreadNBytes부분 읽기의 활용:
// Java 9+
public byte[] readAllBytes() throws IOException;
// 사용
try (InputStream is = Files.newInputStream(path)) {
byte[] all = is.readAllBytes();
// 끝까지 모두
}
// 또는 Files
byte[] all = Files.readAllBytes(path);
// 주의:
// - 작은 파일에만 (메모리 한도)
// - 큰 파일은 OutOfMemoryError 위험
// - Integer.MAX_VALUE 제한 (~2GB)
// Java 9+
public byte[] readNBytes(int len) throws IOException;
public int readNBytes(byte[] b, int off, int len) throws IOException;
// 사용
byte[] data = is.readNBytes(100);
// 정확히 100 바이트 (또는 EOF 까지)
// 부족하면 더 작은 배열
// 또는 기존 배열에
byte[] buf = new byte[100];
int n = is.readNBytes(buf, 0, 100);
// n = 실제 읽은 수 (100 또는 그 이하)
// readFully 와 비교:
// - readFully: 부족하면 EOFException
// - readNBytes: 부족해도 OK, 작은 배열 반환
// Java 9+
public long transferTo(OutputStream out) throws IOException;
// 사용 — 가장 간단한 복사
try (InputStream is = Files.newInputStream(src);
OutputStream os = Files.newOutputStream(dest)) {
long n = is.transferTo(os);
// n = 복사된 바이트 수
}
// 내부:
// - 적절한 버퍼 사용
// - 자동 최적화
// - 사용자 코드 단순
// 기본 구현 (InputStream)
public long transferTo(OutputStream out) throws IOException {
Objects.requireNonNull(out);
long transferred = 0;
byte[] buffer = new byte[8192]; // 8KB
int read;
while ((read = this.read(buffer, 0, 8192)) >= 0) {
out.write(buffer, 0, read);
transferred += read;
}
return transferred;
}
// FileInputStream 의 transferTo 는 더 최적화
// - 같은 파일 시스템: 빠른 복사
// - zero-copy 가능 (OS 지원 시)
// 1. 파일 복사
try (InputStream src = Files.newInputStream(srcPath);
OutputStream dest = Files.newOutputStream(destPath)) {
src.transferTo(dest);
}
// 또는 더 간단히 (Files)
Files.copy(srcPath, destPath);
// 2. HTTP 응답 → 파일
try (InputStream is = url.openStream();
OutputStream os = Files.newOutputStream(path)) {
is.transferTo(os);
}
// 3. JAR 리소스 → 파일
try (InputStream is = MyClass.class.getResourceAsStream("/data.txt");
OutputStream os = Files.newOutputStream(extractPath)) {
is.transferTo(os);
}
// 4. 메모리 → 파일
byte[] data = ...;
try (InputStream is = new ByteArrayInputStream(data);
OutputStream os = Files.newOutputStream(path)) {
is.transferTo(os);
}
| 메서드 | 동작 | 활용 |
|---|---|---|
| readAllBytes() | 모두 한 번에 | 작은 파일 |
| readNBytes(int) | n 바이트 (또는 EOF까지) | 정확한 크기 |
| readNBytes(buf, off, len) | 기존 배열에 정확히 | 헤더 등 |
| transferTo(OutputStream) | 다른 스트림으로 | 복사, 전송 |
| skip(long) (개선) | 더 정확 (Java 12+ skipNBytes) | 위치 이동 |
public class ModernFileService {
// 1. 작은 파일 — readAllBytes
public byte[] readSmall(Path path) throws IOException {
if (Files.size(path) > 100_000_000) {
throw new IOException("File too large for readAllBytes");
}
return Files.readAllBytes(path);
}
// 2. 정확한 크기 — readNBytes
public byte[] readExact(InputStream is, int size) throws IOException {
byte[] data = is.readNBytes(size);
if (data.length != size) {
throw new EOFException("Expected " + size + " bytes, got " + data.length);
}
return data;
}
// 3. 복사 — transferTo
public void copy(Path src, Path dest) throws IOException {
try (InputStream is = Files.newInputStream(src);
OutputStream os = Files.newOutputStream(dest,
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long copied = is.transferTo(os);
log.info("Copied {} bytes", copied);
}
}
// 4. 다운로드
public void download(String urlStr, Path dest) throws IOException {
URL url = new URL(urlStr);
try (InputStream is = url.openStream();
OutputStream os = Files.newOutputStream(dest)) {
long n = is.transferTo(os);
log.info("Downloaded {} bytes from {}", n, urlStr);
}
}
// 5. 리소스 추출
public void extractResource(String resourceName, Path dest) throws IOException {
try (InputStream is = getClass().getResourceAsStream(resourceName)) {
if (is == null) throw new FileNotFoundException(resourceName);
Files.createDirectories(dest.getParent());
try (OutputStream os = Files.newOutputStream(dest)) {
is.transferTo(os);
}
}
}
}
Java 9+ 의 새 메서드는?
답:
1. readAllBytes():
readNBytes(int):
readNBytes(buf, off, len):
transferTo(OutputStream):
활용:
src.transferTo(dest)url.openStream().transferTo(file)| Q | 핵심 답변 |
|---|---|
| read(byte[]) 반환? | 실제 읽은 수 (1~buf.length), -1 EOF |
| 1바이트 vs byte[] 성능? | system call 수 차이, 8KB ~ 2000배 |
| 마지막 read 함정? | n < buf.length, 이전 데이터 남음 |
| for-each 위험? | buf.length 전체 순회, n 무시 |
| 권장 버퍼 크기? | 8KB (자바 표준 sweet spot) |
| OS 페이지 크기? | 4KB (보통), 8KB 배수 권장 |
| read 가 buf.length 안 채우는 이유? | 파일 끝/네트워크/블록/인터럽트 |
| readFully? | DataInputStream, 정확히 채움 |
| readNBytes (Java 9+)? | 정확한 크기, EOF 까지 |
| transferTo (Java 9+)? | 다른 스트림으로 직접 전송 |
| BufferedInputStream vs byte[]? | 둘 다 효율, 결합 OK |
답:
byte[] buf = new byte[0];
int n = is.read(buf); // 0 가능
답:
답:
답:
답:
1. read(byte[]) 의 본질
2. 마지막 읽기 함정
process(buf, 0, n) 패턴 필수3. Java 9+ 권장
이번 Unit에서 byte[] 효율 읽기를 봤다면, 다음은 파일 쓰기.
🚀 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 실전 (3/6 진행)
총: 34/43 Unit (약 79%)