Stream은 데이터의 흐름, 즉 데이터가 이동하는 통로다.
Stream은 데이터를 순차적으로 읽거나 쓰는 단방향 데이터 흐름을 추상화한 개념이다.
Byte Stream은 8비트 단위로 데이터를 처리하는 스트림이다.
// InputStream 계층
InputStream
├─ FileInputStream // 파일에서 읽기
├─ ByteArrayInputStream // 바이트 배열에서 읽기
├─ BufferedInputStream // 버퍼링된 읽기 (성능 향상)
└─ ObjectInputStream // 객체 직렬화 읽기
// OutputStream 계층
OutputStream
├─ FileOutputStream // 파일에 쓰기
├─ ByteArrayOutputStream // 바이트 배열에 쓰기
├─ BufferedOutputStream // 버퍼링된 쓰기 (성능 향상)
└─ ObjectOutputStream // 객체 직렬화 쓰기
사용 예시
// 파일에서 바이트 단위로 읽기
try (InputStream is = new FileInputStream("data.bin")) {
int byteData;
while ((byteData = is.read()) != -1) { // 1바이트씩 읽기
// 처리
}
}
// 파일에 바이트 단위로 쓰기
try (OutputStream os = new FileOutputStream("output.bin")) {
os.write(65); // 'A'의 ASCII 값 쓰기
os.write(new byte[]{66, 67, 68}); // "BCD" 쓰기
}
Character Stream은 16비트 유니코드 단위로 데이터를 처리하는 스트림이다.
// Reader 계층
Reader
├─ FileReader // 파일에서 문자 읽기
├─ CharArrayReader // 문자 배열에서 읽기
├─ BufferedReader // 버퍼링된 읽기 (줄 단위 읽기)
├─ InputStreamReader // 바이트 → 문자 변환
└─ StringReader // 문자열에서 읽기
// Writer 계층
Writer
├─ FileWriter // 파일에 문자 쓰기
├─ CharArrayWriter // 문자 배열에 쓰기
├─ BufferedWriter // 버퍼링된 쓰기
├─ OutputStreamWriter // 문자 → 바이트 변환
├─ PrintWriter // 포맷팅 출력
└─ StringWriter // 문자열에 쓰기
사용 예시
// 파일에서 문자 단위로 읽기 (한글 처리 가능)
try (Reader reader = new FileReader("text.txt")) {
int charData;
while ((charData = reader.read()) != -1) { // 1문자씩 읽기
System.out.print((char) charData);
}
}
// 줄 단위로 읽기 (가장 많이 사용)
try (BufferedReader br = new BufferedReader(new FileReader("text.txt"))) {
String line;
while ((line = br.readLine()) != null) { // 한 줄씩 읽기
System.out.println(line);
}
}
| 구분 | Byte Stream | Character Stream |
|---|---|---|
| 처리 단위 | 8비트 (1바이트) | 16비트 (2바이트, 유니코드) |
| 용도 | 바이너리 데이터 (이미지, 동영상, 실행 파일) | 텍스트 데이터 (문서, 로그) |
| 클래스 예시 | InputStream, OutputStream | Reader, Writer |
| 인코딩 | 불필요 | 필요 (UTF-8, EUC-KR 등) |
| 한글 처리 | 직접 처리 어려움 | 자동 처리 |
// 1바이트씩 읽기 → 매번 시스템 콜 발생
FileInputStream fis = new FileInputStream("file.txt");
int data = fis.read(); // 시스템 콜 1회
data = fis.read(); // 시스템 콜 1회
data = fis.read(); // 시스템 콜 1회
// 1000바이트 읽으려면 1000번의 시스템 콜!
버퍼 없는 스트림은
이러한 문제점이 있다.
실제 성능
파일 크기: 1MB
읽기 방식: 1바이트씩 1,048,576번 읽기
소요 시간: 약 10초 (매우 느림!)
// 8KB 버퍼에 한 번에 읽고, 메모리에서 1바이트씩 반환
BufferedInputStream bis = new BufferedInputStream(
new FileInputStream("file.txt")
);
// 내부적으로 8KB를 한 번에 읽음 (시스템 콜 1회)
int data = bis.read(); // 메모리에서 읽음 (빠름)
data = bis.read(); // 메모리에서 읽음 (빠름)
data = bis.read(); // 메모리에서 읽음 (빠름)
// ... 8KB를 모두 읽으면 다시 시스템 콜 1회
버퍼링된 스트림은
등의 장점이 있다.
실제 성능
파일 크기: 1MB
읽기 방식: 8KB 버퍼 사용
시스템 콜: 128번 (1MB / 8KB)
소요 시간: 약 0.1초 (100배 빠름!)
Buffer는 데이터를 임시로 저장하는 메모리 공간이다.
Buffer는 데이터를 한꺼번에 모아서 처리함으로써 I/O 성능을 향상시키는 역할을 한다.
// 직접 버퍼 생성
ByteBuffer buffer = ByteBuffer.allocate(1024); // Heap 버퍼
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // Direct 버퍼
// 데이터 쓰기
buffer.put((byte) 65); // 'A' 쓰기
buffer.put("Hello".getBytes()); // "Hello" 쓰기
// 읽기 모드로 전환
buffer.flip();
// 데이터 읽기
while (buffer.hasRemaining()) {
byte b = buffer.get();
System.out.print((char) b);
}
// 버퍼 초기화
buffer.clear(); // 또는 buffer.compact()
CharBuffer charBuffer = CharBuffer.allocate(100);
IntBuffer intBuffer = IntBuffer.allocate(100);
FloatBuffer floatBuffer = FloatBuffer.allocate(100);
DoubleBuffer doubleBuffer = DoubleBuffer.allocate(100);
LongBuffer longBuffer = LongBuffer.allocate(100);
ShortBuffer shortBuffer = ShortBuffer.allocate(100);
ByteBuffer buffer = ByteBuffer.allocate(10);
// 1. Capacity: 버퍼의 전체 크기 (고정, 변경 불가)
int capacity = buffer.capacity(); // 10
// 2. Position: 현재 읽기/쓰기 위치
buffer.put((byte) 1); // position = 0 → 1
buffer.put((byte) 2); // position = 1 → 2
int position = buffer.position(); // 2
// 3. Limit: 읽기/쓰기 가능한 마지막 위치
int limit = buffer.limit(); // 10 (쓰기 모드에서는 capacity와 동일)
초기 상태 (쓰기 모드)
┌─────────────────────────┐
│ position=0 │
│ limit=capacity │
│ capacity=10 │
└─────────────────────────┘
buffer.put((byte) 1);
buffer.put((byte) 2);
buffer.put((byte) 3);
쓰기 완료
┌─────────────────────────┐
│ [1][2][3][ ][ ][ ]... │
│ ↑ ↑ │
│ position=3 limit=10│
└─────────────────────────┘
buffer.flip(); // 읽기 모드로 전환
읽기 모드
┌─────────────────────────┐
│ [1][2][3] │
│ ↑ ↑ │
│ pos=0 limit=3 │
└─────────────────────────┘
buffer.get(); // 1 읽음, position=0→1
buffer.get(); // 2 읽음, position=1→2
읽기 중
┌─────────────────────────┐
│ [1][2][3] │
│ ↑ ↑ │
│ pos=2 limit=3 │
└─────────────────────────┘
buffer.clear(); // 초기화 (쓰기 모드로 복귀)
초기 상태로 복귀
┌─────────────────────────┐
│ position=0 │
│ limit=capacity │
└─────────────────────────┘
1. flip() - 쓰기 → 읽기 전환
buffer.put((byte) 1);
buffer.put((byte) 2);
buffer.flip(); // limit = position, position = 0
// 이제 읽기 가능
2. clear() - 전체 초기화
buffer.clear(); // position = 0, limit = capacity
// 버퍼를 처음부터 다시 사용 (데이터는 유지됨, 포인터만 초기화)
3. compact() - 읽지 않은 데이터 보존
buffer.put((byte) 1);
buffer.put((byte) 2);
buffer.put((byte) 3);
buffer.flip();
buffer.get(); // 1 읽음, [2][3]이 남음
buffer.compact(); // [2][3]을 버퍼 앞으로 이동
// position = 2 (남은 데이터 뒤), limit = capacity
// 계속 쓰기 가능
4. rewind() - 처음부터 다시 읽기
buffer.rewind(); // position = 0, limit은 유지
// 같은 데이터를 다시 읽을 수 있음
5. mark() / reset() - 특정 위치 기억
buffer.position(5);
buffer.mark(); // 현재 위치(5) 저장
buffer.position(10);
buffer.reset(); // position = 5로 복귀
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
Heap Buffer 특징
메모리 구조
JVM Heap
┌──────────────────┐
│ ByteBuffer │
│ ┌────────────┐ │
│ │ byte[] │ │ ← Java 객체
│ └────────────┘ │
└──────────────────┘
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
Direct Buffer 특징
메모리 구조
JVM Heap Native Memory (OS 영역)
┌───────────┐ ┌────────────────┐
│ByteBuffer │----->│ Actual Data │
│(참조만) │ │ [1][2][3]... │
└───────────┘ └────────────────┘
전통적 I/O의 문제 (2번 복사)
일반적인 파일 읽기/쓰기는 데이터를 2번 복사한다.
파일 읽기 과정
1단계: 디스크 → OS 커널 버퍼
┌──────┐ 복사1 ┌───────────┐
│ 디스크 │ ─────→ │OS 커널 버퍼│
└──────┘ └───────────┘
2단계: OS 커널 버퍼 → JVM Heap (byte[])
┌───────────┐ 복사2 ┌─────────┐
│OS 커널 버퍼│ ─────→ │ JVM Heap │
└───────────┘ └─────────┘
3단계: 애플리케이션에서 사용
┌─────────┐
│ JVM Heap │ ← 애플리케이션이 읽음
└─────────┘
총 2번의 복사 발생 → 느림!
Heap Buffer의 한계
// Heap Buffer 사용
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);
channel.read(heapBuffer); // 파일 → 버퍼
실제 동작
1. 디스크 → OS 커널 버퍼 (복사 1)
2. OS 커널 버퍼 → JVM Heap의 byte[] (복사 2)
→ 2번 복사 발생
메모리 구조
디스크 OS 커널 버퍼 JVM Heap
┌─────┐ ┌─────────┐ ┌──────────┐
│데이터│ ───→ │ 복사본 │ ───→ │ byte[] │
└─────┘ └─────────┘ └──────────┘
복사 1 복사 2
Direct Buffer는 JVM 외부의 Native Memory를 사용한다.
// Direct Buffer 사용
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);
channel.read(directBuffer); // 파일 → 버퍼
실제 동작
1. 디스크 → Native Memory (복사 1)
→ 1번 복사만 발생 (OS가 직접 접근 가능)
메모리 구조
디스크 Native Memory (OS 영역)
┌─────┐ ┌─────────────────┐
│데이터│ ───→ │ Direct Buffer │ ← OS가 직접 접근
└─────┘ └─────────────────┘
복사 1 (끝!)
JVM Heap
┌──────────────┐
│ ByteBuffer │ ─────────┐
│ (참조만 보관) │ │ 포인터만
└──────────────┘ ↓
Native Memory
왜 "Zero-Copy"인가?
엄밀히는 1번 복사가 발생하지만, JVM ↔ OS 간 복사가 없다는 의미에서 "Zero-Copy"라고 부른다.
Heap Buffer:
디스크 → OS 커널 → JVM Heap (2번 복사)
↑
이 복사가 없어짐!
Direct Buffer:
디스크 → Native Memory (1번 복사, OS가 직접 처리)
성능 차이
100MB 파일 쓰기
Heap Buffer
100MB 데이터를 2번 복사 → 200MB 복사량
소요 시간: 500ms
Direct Buffer
100MB 데이터를 1번 복사 → 100MB 복사량
소요 시간: 100ms (5배 빠름!)
BufferedStream은 일반 Stream에 내부 버퍼(byte 배열)를 추가한 것이다.
일반 Stream은 1바이트를 읽을 때마다 디스크에 접근한다.
-> 매우 비효율적
일반 FileInputStream의 문제
read() 호출 → 디스크 접근 (느림)
read() 호출 → 디스크 접근 (느림)
read() 호출 → 디스크 접근 (느림)
...1000번 호출하면 1000번 디스크 접근
BufferedInputStream은 이 문제를 해결한다.
1. 처음 read() 호출 시 → 디스크에서 8KB를 한꺼번에 읽어서 내부 버퍼(byte[8192])에 저장
2. 이후 read() 호출 → 버퍼(메모리)에서 1바이트씩 반환 (매우 빠름)
3. 버퍼의 8KB를 모두 읽으면 → 다시 디스크에서 8KB 읽어옴
// BufferedInputStream 내부 구조 (간략화)
class BufferedInputStream extends InputStream {
private byte[] buf = new byte[8192]; // 내부 버퍼 8KB
private int pos = 0; // 현재 읽는 위치
private int count = 0; // 버퍼에 들어있는 데이터 크기
private InputStream in; // 실제 파일 스트림
public int read() {
// 버퍼가 비었으면 디스크에서 8KB 읽어옴
if (pos >= count) {
count = in.read(buf, 0, buf.length); // 시스템 콜 1회로 8KB 읽기
pos = 0;
}
// 버퍼(메모리)에서 1바이트 반환
return buf[pos++]; // 메모리 접근 (빠름!)
}
}
| 구분 | Stream | Buffer |
|---|---|---|
| 방향성 | 단방향 (읽기 또는 쓰기) | 양방향 (읽기와 쓰기) |
| 액세스 | 순차 접근 (Sequential) | 랜덤 액세스 (Random Access) |
| 재사용 | 불가 (한 번 읽으면 끝) | 가능 (rewind, flip 등) |
| 위치 제어 | 불가 (되돌릴 수 없음) | 가능 (position 이동) |
// Stream: 데이터가 흐르는 대로 처리
InputStream stream = new FileInputStream("file.txt");
int data = stream.read(); // 1바이트 읽음 → 다음으로 이동 (되돌릴 수 없음)
data = stream.read(); // 그다음 1바이트
// 처음부터 다시 읽으려면 스트림을 다시 열어야 함
// Buffer: 데이터를 버퍼에 담아서 자유롭게 처리
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put((byte) 1);
buffer.put((byte) 2);
buffer.flip();
buffer.get(); // 1 읽음
buffer.rewind(); // 처음으로 되돌림
buffer.get(); // 다시 1 읽음 (재사용 가능)
// 1. 간단한 텍스트 파일 읽기 (편리함 우선)
BufferedReader reader = new BufferedReader(new FileReader("config.txt"));
// 2. 표준 입출력
System.out.println("Hello"); // PrintStream
Scanner scanner = new Scanner(System.in);
// 3. 네트워크 스트리밍 (무한 데이터)
InputStream netStream = socket.getInputStream();
// 4. 객체 직렬화
ObjectInputStream ois = new ObjectInputStream(fis);
MyObject obj = (MyObject) ois.readObject();
// 1. 고성능 파일 I/O
FileChannel channel = FileChannel.open(path);
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
// 2. 랜덤 액세스 필요
buffer.position(100); // 100번째 바이트로 이동
buffer.get();
// 3. 메모리 매핑 파일 (초대용량 파일)
MappedByteBuffer mapped = channel.map(
FileChannel.MapMode.READ_ONLY, 0, channel.size()
);
// 4. 네트워크 통신 (논블로킹 I/O)
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false); // 논블로킹 모드
| 조건 | 권장 방식 | 이유 |
|---|---|---|
| 작은 파일 (< 10MB) | BufferedStream | 코드 간결, 충분한 성능 |
| 큰 파일 (> 100MB) | FileChannel + Buffer | Zero-Copy, 고성능 |
| 텍스트 파일 | BufferedReader | 줄 단위 읽기 편리 |
| 바이너리 파일 | FileChannel + ByteBuffer | 타입 안전, 성능 우수 |
| 순차 접근 | Stream | 간단 |
| 랜덤 액세스 | Buffer | 위치 제어 가능 |
| 네트워크 통신 | NIO SocketChannel | 논블로킹, 멀티플렉싱 |
| 일반 파일 복사 | transferTo | OS 레벨 Zero-Copy |