Stream과 Buffer

허세진·2026년 2월 6일

backend

목록 보기
20/20

Stream

Stream은 데이터의 흐름, 즉 데이터가 이동하는 통로다.

Stream은 데이터를 순차적으로 읽거나 쓰는 단방향 데이터 흐름을 추상화한 개념이다.

Stream의 특징

  1. 단방향성: 읽기(Input) 또는 쓰기(Output) 중 하나만 가능
  2. 순차적 접근: 데이터를 처음부터 끝까지 순서대로 처리
  3. 실시간 처리: 데이터가 도착하는 즉시 처리 가능
  4. 무한 길이 가능: 네트워크 스트림처럼 끝이 정해지지 않을 수 있음

Stream의 종류

Byte 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 (문자 스트림)

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 vs Character Stream

구분Byte StreamCharacter Stream
처리 단위8비트 (1바이트)16비트 (2바이트, 유니코드)
용도바이너리 데이터 (이미지, 동영상, 실행 파일)텍스트 데이터 (문서, 로그)
클래스 예시InputStream, OutputStreamReader, Writer
인코딩불필요필요 (UTF-8, EUC-KR 등)
한글 처리직접 처리 어려움자동 처리

Stream 동작 방식

Unbuffered Stream (버퍼 없는 스트림)

// 1바이트씩 읽기 → 매번 시스템 콜 발생
FileInputStream fis = new FileInputStream("file.txt");
int data = fis.read();  // 시스템 콜 1회
data = fis.read();      // 시스템 콜 1회
data = fis.read();      // 시스템 콜 1회
// 1000바이트 읽으려면 1000번의 시스템 콜!

버퍼 없는 스트림은

  1. 매번 디스크 I/O 발생 → 극도로 느림
  2. 시스템 콜 오버헤드 증가
  3. CPU ↔ 디스크 간 빈번한 전환

이러한 문제점이 있다.

실제 성능

파일 크기: 1MB
읽기 방식: 1바이트씩 1,048,576번 읽기
소요 시간: 약 10초 (매우 느림!)

Buffered Stream (버퍼링된 스트림)

// 8KB 버퍼에 한 번에 읽고, 메모리에서 1바이트씩 반환
BufferedInputStream bis = new BufferedInputStream(
    new FileInputStream("file.txt")
);
// 내부적으로 8KB를 한 번에 읽음 (시스템 콜 1회)
int data = bis.read();  // 메모리에서 읽음 (빠름)
data = bis.read();      // 메모리에서 읽음 (빠름)
data = bis.read();      // 메모리에서 읽음 (빠름)
// ... 8KB를 모두 읽으면 다시 시스템 콜 1회

버퍼링된 스트림은

  1. 시스템 콜 횟수 대폭 감소
  2. 메모리 접근 (빠름), 디스크 접근 (느림)
  3. 성능 향상 10~100배

등의 장점이 있다.

실제 성능

파일 크기: 1MB
읽기 방식: 8KB 버퍼 사용
시스템 콜: 128번 (1MB / 8KB)
소요 시간: 약 0.1초 (100배 빠름!)

Stream의 한계

  1. 순차 접근만 가능: 랜덤 액세스 불가 (특정 위치로 점프 불가)
  2. 재사용 불가: 한 번 읽으면 다시 읽을 수 없음
  3. 양방향 불가: 읽기와 쓰기를 동시에 할 수 없음
  4. 버퍼 미사용 시 성능 저하: 직접 버퍼링 필요

Buffer

Buffer는 데이터를 임시로 저장하는 메모리 공간이다.

Buffer는 데이터를 한꺼번에 모아서 처리함으로써 I/O 성능을 향상시키는 역할을 한다.

Buffer의 특징

  1. 임시 저장소: 데이터를 메모리에 일시적으로 보관
  2. 배치 처리: 여러 데이터를 모아서 한 번에 처리
  3. 양방향 가능: 읽기와 쓰기 모두 가능
  4. 랜덤 액세스: 임의의 위치에 접근 가능

Buffer의 종류

ByteBuffer

// 직접 버퍼 생성
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()

기타 Buffer 타입

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);

Buffer의 3가지 속성

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와 동일)

Buffer의 상태 전환

초기 상태 (쓰기 모드)
┌─────────────────────────┐
│ 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          │
└─────────────────────────┘

Buffer의 주요 메서드

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로 복귀

Heap Buffer vs Direct Buffer

Heap Buffer

ByteBuffer heapBuffer = ByteBuffer.allocate(1024);

Heap Buffer 특징

  1. JVM Heap 메모리에 할당
  2. GC 대상 (자동 메모리 관리)
  3. 생성/해제 빠름
  4. 일반적인 용도에 적합

메모리 구조

JVM Heap
┌──────────────────┐
│  ByteBuffer      │
│  ┌────────────┐  │
│  │ byte[]     │  │ ← Java 객체
│  └────────────┘  │
└──────────────────┘

Direct Buffer

ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);

Direct Buffer 특징

  1. JVM 외부 Native Memory에 할당
  2. GC 대상 아님 (명시적 해제 필요)
  3. 생성/해제 느림
  4. I/O 성능 우수 (커널 버퍼로 복사 불필요)

메모리 구조

JVM Heap           Native Memory (OS 영역)
┌───────────┐      ┌────────────────┐
│ByteBuffer │----->│  Actual Data   │
│(참조만)   │      │  [1][2][3]...  │
└───────────┘      └────────────────┘

Zero-Copy

전통적 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 + Buffer

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의 차이

접근 방식

구분StreamBuffer
방향성단방향 (읽기 또는 쓰기)양방향 (읽기와 쓰기)
액세스순차 접근 (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 읽음 (재사용 가능)

Stream과 Buffer 선택 기준

Stream을 사용하는 경우

// 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();

Buffer를 사용하는 경우

// 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 + BufferZero-Copy, 고성능
텍스트 파일BufferedReader줄 단위 읽기 편리
바이너리 파일FileChannel + ByteBuffer타입 안전, 성능 우수
순차 접근Stream간단
랜덤 액세스Buffer위치 제어 가능
네트워크 통신NIO SocketChannel논블로킹, 멀티플렉싱
일반 파일 복사transferToOS 레벨 Zero-Copy
profile
로그를 파고드는 시간을 즐기는 백엔드 개발자, 허세진입니다.

0개의 댓글