F-LAB JAVA · 3주차 · Phase 7 · I/O 시스템 큰 그림
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
Stream (IO) 은 "1바이트씩 흐르는 단방향 데이터 흐름", Channel (NIO) 은 "Buffer 를 통해 블록 단위로 양방향 입출력하는 통로" 다.
Stream 은 비유적으로 "수도꼭지에서 물 한 방울씩", Channel + Buffer 는 "양동이로 물 퍼서 옮기기".
Channel 은 항상 Buffer 통해서만 데이터를 주고받으며 (channel.read(buffer), channel.write(buffer)),
Buffer 의 position, limit, capacity 3가지 속성과 flip/clear 모드 전환을 정확히 이해해야 NIO 를 제대로 쓸 수 있다.
Direct Buffer + transferTo 로 OS 의 zero-copy 를 활용하면 일반 IO 보다 수십~수백 배 빠르다.
Stream (IO):
수도꼭지에서 물 받기
- 한 방울 (1바이트) 씩 흐름
- 단방향 (수도꼭지 → 컵, 또는 반대)
- 보조 도구 (Buffered) 로 양 늘리기 가능
- Blocking — 물이 안 나오면 대기
Channel + Buffer (NIO):
양동이로 물 퍼서 옮기기
- 양동이 (Buffer) 만큼 한 번에
- 양방향 (한 양동이로 퍼고 붓기)
- 양동이 크기 (capacity) 미리 정함
- Non-blocking 가능 — 물 없으면 즉시 리턴
→ Stream = 흐름, Channel + Buffer = 양동이.
1. Stream (IO) 의 정밀 구조
2. Channel (NIO) 의 정밀 구조
3. Buffer 의 4가지 속성
4. Buffer 의 모드 전환 (flip, clear, rewind, compact)
5. Heap Buffer vs Direct Buffer
6. Stream vs Channel 동작 비교
7. 성능과 zero-copy
8. 실무 패턴과 선택 기준
9. 면접 + 자기 점검
Stream (java.io):
데이터가 흐르는 일방향 통로.
- 1바이트 (또는 1문자) 단위
- 단방향 (Input 또는 Output)
- 시작점과 끝점이 정해진 흐름
java.io 의 스트림 4계열:
1. InputStream (바이트 입력)
- 자식: FileInputStream, ByteArrayInputStream, ...
- 메서드: read(), read(byte[]), read(byte[], int, int)
2. OutputStream (바이트 출력)
- 자식: FileOutputStream, ByteArrayOutputStream, ...
- 메서드: write(int), write(byte[]), write(byte[], int, int)
3. Reader (문자 입력)
- 자식: FileReader, StringReader, ...
- 메서드: read(), read(char[])
4. Writer (문자 출력)
- 자식: FileWriter, StringWriter, ...
- 메서드: write(int), write(char[]), write(String)
public abstract class InputStream implements Closeable {
// 핵심 메서드 (추상)
public abstract int read() throws IOException;
// - 1바이트 읽음
// - 0~255 반환 (int 로 확장)
// - 스트림 끝 = -1
// 편의 메서드
public int read(byte[] b) throws IOException {
return read(b, 0, b.length);
}
public int read(byte[] b, int off, int len) throws IOException {
// 여러 바이트 읽기
// 실제 읽은 바이트 수 반환
// 끝이면 -1
}
// 추가 메서드
public int available() throws IOException;
public long skip(long n) throws IOException;
public void close() throws IOException;
public boolean markSupported();
public void mark(int readlimit);
public void reset() throws IOException;
// Java 9+
public byte[] readAllBytes() throws IOException;
public long transferTo(OutputStream out) throws IOException;
}
// read() 가 int 를 반환하는 이유
byte b = (byte) -1; // -1 = 1바이트로 1111_1111
// 정상 데이터 0xFF (= -1 as byte) 도 -1
// 그러면 "끝" 과 구분 못 함!
// 해결: int (32비트) 로 확장
public abstract int read() throws IOException;
// 0~255: 정상 데이터
// -1: 스트림 끝 (정상 데이터에 못 옴)
// 변환 시 주의
int b = in.read();
if (b == -1) {
// 끝
} else {
byte data = (byte) b; // 명시적 캐스트 필요
// 또는
char ch = (char) b;
}
public abstract class OutputStream implements Closeable, Flushable {
// 핵심 (추상)
public abstract void write(int b) throws IOException;
// - 1바이트 쓰기
// - int 의 하위 8비트만 사용
public void write(byte[] b) throws IOException {
write(b, 0, b.length);
}
public void write(byte[] b, int off, int len) throws IOException;
// 추가
public void flush() throws IOException;
// 버퍼링된 데이터 강제 출력
public void close() throws IOException;
// Java 11+
public static OutputStream nullOutputStream();
}
InputStream:
외부 (파일) 스트림 프로그램
[data..............] → → → → → → → read()
↓
byte b
한 방향 (외부 → 프로그램)
1바이트씩
끝이 -1
OutputStream:
프로그램 스트림 외부 (파일)
write(b) → → → → → → → → → → → → [data...]
한 방향 (프로그램 → 외부)
1바이트씩
close() 또는 flush() 필요
// 일반 FileInputStream
FileInputStream fis = new FileInputStream("file.txt");
int b;
while ((b = fis.read()) != -1) {
// 매 read() 가 OS system call
// 1MB 파일 = 1,048,576번 호출
}
// Buffered 추가
BufferedInputStream bis = new BufferedInputStream(fis); // 기본 8KB
int b;
while ((b = bis.read()) != -1) {
// 사용자는 1바이트씩 호출하지만
// 내부적으로 8KB 단위로 OS 호출
// 1MB / 8KB = 128번 호출
}
// 또는 byte[] 활용
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) != -1) {
// 한 번에 8192 바이트
// OS 호출 횟수 ↓
}
Stream 의 5가지 특징:
1. 1바이트 (또는 1문자) 단위
- byte / char 가 기본 단위
- 보조 스트림으로 늘릴 수 있음
2. 단방향
- InputStream OR OutputStream
- 둘이 함께면 두 객체
3. 순차 접근
- 처음부터 끝까지 순서대로
- Random Access 어려움 (RandomAccessFile 예외)
4. Blocking
- 데이터 올 때까지 대기
- 다른 일 못 함
5. Decorator 패턴
- 기본 + 보조 스트림 중첩
- 기능 확장 (Buffered, Data, Object 등)
// CSV 한 줄씩 읽기 — Stream 의 자연스러운 활용
public class ShipmentCsvReader {
public List<Shipment> read(Path path) throws IOException {
List<Shipment> shipments = new ArrayList<>();
try (BufferedReader reader = Files.newBufferedReader(path)) {
// BufferedReader = Reader + 버퍼링
// 한 줄씩 효율적으로 읽음
String line;
reader.readLine(); // 헤더 건너뛰기
while ((line = reader.readLine()) != null) {
shipments.add(parseShipment(line));
}
}
return shipments;
}
private Shipment parseShipment(String line) {
String[] parts = line.split(",");
return Shipment.builder()
.blNo(parts[0])
.weight(new BigDecimal(parts[1]))
.build();
}
}
Stream (IO) 의 핵심 특징 5가지는?
답:
1. 1바이트/1문자 단위: 가장 작은 단위
2. 단방향: InputStream OR OutputStream
3. 순차 접근: 처음부터 끝까지
4. Blocking: 항상 대기
5. Decorator 패턴: 보조 스트림으로 확장
read() 의 반환이 int 인 이유:
Channel (java.nio.channels):
외부 자원과 데이터를 주고받는 양방향 통로.
- 파일, 소켓, 파이프 등을 추상화
- Buffer 를 통해서만 데이터 입출력
- 블록 단위 처리
- Non-blocking 가능
java.nio.channels 의 Channel 계층:
Channel (인터페이스)
└── ReadableByteChannel — read(ByteBuffer)
└── WritableByteChannel — write(ByteBuffer)
ByteChannel extends ReadableByteChannel, WritableByteChannel
└── 양방향
GatheringByteChannel — 여러 Buffer 한 번에 쓰기
ScatteringByteChannel — 여러 Buffer 한 번에 읽기
자바 표준 구현:
├── FileChannel
├── SocketChannel (TCP 클라이언트)
├── ServerSocketChannel (TCP 서버)
├── DatagramChannel (UDP)
└── Pipe.SinkChannel / SourceChannel
비동기 채널 (Java 7+):
├── AsynchronousFileChannel
├── AsynchronousSocketChannel
└── AsynchronousServerSocketChannel
public interface ReadableByteChannel extends Channel {
int read(ByteBuffer dst) throws IOException;
// - Buffer 에 데이터 읽기
// - 실제 읽은 바이트 수 반환
// - 스트림 끝 = -1
// - Non-blocking 채널: 데이터 없으면 0
}
특징:
public interface WritableByteChannel extends Channel {
int write(ByteBuffer src) throws IOException;
// - Buffer 의 데이터 쓰기
// - 실제 쓴 바이트 수 반환
// - Non-blocking: 일부만 쓸 수도
}
public abstract class FileChannel extends AbstractInterruptibleChannel
implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel {
// 기본 read/write
public abstract int read(ByteBuffer dst) throws IOException;
public abstract int write(ByteBuffer src) throws IOException;
// Random Access (Stream 못 함)
public abstract long position() throws IOException;
public abstract FileChannel position(long newPosition) throws IOException;
public abstract long size() throws IOException;
public abstract FileChannel truncate(long size) throws IOException;
// 특정 위치에서 read/write
public abstract int read(ByteBuffer dst, long position) throws IOException;
public abstract int write(ByteBuffer src, long position) throws IOException;
// zero-copy
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
public abstract long transferFrom(ReadableByteChannel src, long position, long count) throws IOException;
// 메모리 매핑
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
// 강제 동기
public abstract void force(boolean metaData) throws IOException;
// 락
public abstract FileLock lock(long position, long size, boolean shared) throws IOException;
public final FileLock lock() throws IOException;
public abstract FileLock tryLock(long position, long size, boolean shared) throws IOException;
}
// FileChannel
FileChannel ch1 = FileChannel.open(
Path.of("file.txt"),
StandardOpenOption.READ);
FileChannel ch2 = FileChannel.open(
Path.of("file.txt"),
StandardOpenOption.WRITE, StandardOpenOption.CREATE);
FileChannel ch3 = FileChannel.open(
Path.of("file.txt"),
StandardOpenOption.READ, StandardOpenOption.WRITE);
// 옵션
StandardOpenOption.READ
StandardOpenOption.WRITE
StandardOpenOption.APPEND
StandardOpenOption.CREATE
StandardOpenOption.CREATE_NEW
StandardOpenOption.TRUNCATE_EXISTING
StandardOpenOption.DELETE_ON_CLOSE
// SocketChannel
SocketChannel sc = SocketChannel.open(new InetSocketAddress("host", 8080));
// ServerSocketChannel
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
// FileChannel from FileInputStream (legacy)
FileInputStream fis = new FileInputStream("file.txt");
FileChannel fch = fis.getChannel();
// 양방향 — 같은 채널로 read + write
try (FileChannel channel = FileChannel.open(
Path.of("file.txt"),
StandardOpenOption.READ, StandardOpenOption.WRITE)) {
// 1. 처음부터 읽기
ByteBuffer readBuf = ByteBuffer.allocate(1024);
channel.read(readBuf);
// 2. position 이동 + 쓰기
channel.position(0);
ByteBuffer writeBuf = ByteBuffer.wrap("Hello".getBytes());
channel.write(writeBuf);
// 3. 다시 읽기
channel.position(0);
readBuf.clear();
channel.read(readBuf);
// 한 채널로 모든 작업
}
Channel 의 5가지 특징:
1. 양방향
- 한 채널로 read + write
- SocketChannel 도 양방향
2. 블록 단위
- Buffer 의 크기만큼
- 1바이트씩 X
3. Random Access
- position() 으로 위치 이동
- Stream 못 함 (RandomAccessFile 외)
4. Non-blocking 가능
- configureBlocking(false)
- Selector 와 결합
5. Buffer 필수
- read/write 가 항상 ByteBuffer 매개변수
- Buffer 없이 데이터 입출력 불가
public class ShipmentChannelService {
// 양방향 파일 처리
public void updateInPlace(Path file, long offset, byte[] newData) throws IOException {
try (FileChannel channel = FileChannel.open(
file,
StandardOpenOption.READ, StandardOpenOption.WRITE)) {
// 1. 특정 위치 읽기
ByteBuffer readBuf = ByteBuffer.allocate(newData.length);
channel.read(readBuf, offset);
// 2. 같은 위치에 쓰기
ByteBuffer writeBuf = ByteBuffer.wrap(newData);
channel.write(writeBuf, offset);
}
}
// 큰 파일의 일부 빠른 접근
public byte[] readChunk(Path file, long offset, int length) throws IOException {
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(length);
int read = channel.read(buffer, offset);
buffer.flip();
byte[] result = new byte[buffer.remaining()];
buffer.get(result);
return result;
}
}
}
Channel 의 핵심 특징 5가지는?
답:
1. 양방향: 한 채널로 read + write
2. 블록 단위: Buffer 크기만큼
3. Random Access: position() 이동
4. Non-blocking 가능: configureBlocking(false)
5. Buffer 필수: 모든 I/O 가 ByteBuffer 통해
Stream 과 차이:
Buffer (java.nio):
데이터의 컨테이너.
- 특정 타입 (byte, char, int 등) 의 배열
- Channel 과 데이터 교환의 매개체
- 4가지 속성으로 상태 관리
Buffer 의 4가지 속성:
capacity (용량):
- 버퍼의 최대 크기
- 생성 시 결정, 불변
- 예: ByteBuffer.allocate(1024) 면 capacity = 1024
position (위치):
- 다음 read/write 위치
- 0 ≤ position ≤ limit
limit (한계):
- read/write 가능한 마지막 위치 + 1
- 0 ≤ limit ≤ capacity
mark (북마크, 선택):
- position 의 임시 저장
- reset() 으로 복귀
- 기본 -1 (설정 안 됨)
불변식:
0 ≤ mark ≤ position ≤ limit ≤ capacity
capacity = 10 (생성 시)
쓰기 모드 (초기):
[_, _, _, _, _, _, _, _, _, _]
↑ position = 0 ↑ limit = capacity = 10
- position 부터 limit 까지 쓰기 가능
- 처음엔 비어있음
데이터 5개 쓴 후:
[A, B, C, D, E, _, _, _, _, _]
↑ position = 5 ↑ limit = 10
flip() 호출 (읽기 모드로):
[A, B, C, D, E, _, _, _, _, _]
↑ position = 0
↑ limit = 5 (이전 position)
- 0 ~ 5 사이가 읽을 데이터
- position 부터 limit 까지 읽기
읽기 후 (3개 읽음):
[A, B, C, D, E, _, _, _, _, _]
↑ position = 3
↑ limit = 5
- 아직 D, E 남음
// 기본 생성 — capacity 만 지정
ByteBuffer buf1 = ByteBuffer.allocate(1024);
// capacity = 1024
// position = 0
// limit = 1024
// mark = -1
// 배열로부터 (wrap)
byte[] bytes = "Hello".getBytes();
ByteBuffer buf2 = ByteBuffer.wrap(bytes);
// 배열 공유 (변경이 서로 영향)
// capacity = 5
// position = 0
// limit = 5
// 직접 메모리
ByteBuffer buf3 = ByteBuffer.allocateDirect(1024);
// JVM heap 밖
// 다음 섹션 (Direct Buffer) 참고
ByteBuffer buf = ByteBuffer.allocate(10);
// 초기
buf.capacity(); // 10
buf.position(); // 0
buf.limit(); // 10
buf.remaining(); // limit - position = 10
buf.hasRemaining(); // true
// 데이터 5개 쓰기
buf.put((byte) 'A');
buf.put((byte) 'B');
buf.put((byte) 'C');
buf.put((byte) 'D');
buf.put((byte) 'E');
// 상태:
buf.capacity(); // 10
buf.position(); // 5
buf.limit(); // 10
buf.remaining(); // 5
// flip() — 쓰기 → 읽기
buf.flip();
buf.capacity(); // 10
buf.position(); // 0
buf.limit(); // 5
buf.remaining(); // 5
// 데이터 3개 읽기
byte b1 = buf.get(); // A
byte b2 = buf.get(); // B
byte b3 = buf.get(); // C
buf.position(); // 3
buf.remaining(); // 2
// 남은 D, E 도 읽기
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
// DE 출력
ByteBuffer buf = ByteBuffer.allocate(10);
buf.put("Hello".getBytes());
buf.flip();
buf.get(); // H
buf.mark(); // position 표시 (1)
buf.get(); // e
buf.get(); // l
buf.reset(); // position = mark = 1
buf.get(); // e (다시)
// 활용: 임시 위치 저장 + 복귀
// 8가지 기본 타입에 대한 Buffer
ByteBuffer bb = ByteBuffer.allocate(1024);
CharBuffer cb = CharBuffer.allocate(1024);
ShortBuffer sb = ShortBuffer.allocate(1024);
IntBuffer ib = IntBuffer.allocate(1024);
LongBuffer lb = LongBuffer.allocate(1024);
FloatBuffer fb = FloatBuffer.allocate(1024);
DoubleBuffer db = DoubleBuffer.allocate(1024);
// boolean 은 없음
// ByteBuffer 가 가장 자주 사용
// 다른 타입은 ByteBuffer 로부터 변환 가능
ByteBuffer bb = ByteBuffer.allocate(1024);
IntBuffer ib = bb.asIntBuffer(); // 같은 데이터를 int 로 해석
ByteBuffer buf = ByteBuffer.allocate(1024);
// put — 다양한 타입
buf.put((byte) 65); // 1바이트
buf.putChar('A'); // 2바이트
buf.putShort((short) 100); // 2바이트
buf.putInt(42); // 4바이트
buf.putLong(123L); // 8바이트
buf.putFloat(3.14f); // 4바이트
buf.putDouble(2.718); // 8바이트
buf.put("Hello".getBytes()); // 5바이트
// get — 동일하게
buf.flip();
byte b = buf.get();
char c = buf.getChar();
short s = buf.getShort();
int i = buf.getInt();
long l = buf.getLong();
float f = buf.getFloat();
double d = buf.getDouble();
// 인덱스 지정 (position 변경 없음)
buf.put(0, (byte) 'A');
byte b = buf.get(0);
// 배열로
byte[] dst = new byte[10];
buf.get(dst); // dst 에 채움
buf.get(dst, 0, 5);
Buffer 의 4가지 속성과 불변식은?
답:
1. capacity: 버퍼 최대 크기 (불변)
2. position: 다음 read/write 위치
3. limit: read/write 가능한 마지막 위치 + 1
4. mark: position 의 임시 저장 (선택)
불변식:
0 ≤ mark ≤ position ≤ limit ≤ capacity
활용:
public abstract class Buffer {
// flip — 쓰기 → 읽기
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
// clear — 처음 상태로 (초기화, 비우기)
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
// rewind — position 만 0 으로
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
// compact — 안 읽은 데이터를 앞으로
// (구체 클래스에 정의)
}
ByteBuffer buf = ByteBuffer.allocate(10);
// 쓰기 모드
buf.put("Hello".getBytes()); // 5바이트
// position = 5, limit = 10
// flip — 읽기 준비
buf.flip();
// position = 0, limit = 5
// 읽기
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
// Hello 출력
ByteBuffer buf = ByteBuffer.allocate(10);
buf.put("Hello".getBytes());
buf.flip();
// 일부 읽음
buf.get(); // H
buf.get(); // e
// clear — 처음 상태로 (데이터는 그대로 남지만 무시)
buf.clear();
// position = 0, limit = 10
// 새 데이터 쓰기 가능
buf.put("World".getBytes());
// 기존 'l', 'l', 'o' 부분은 덮어쓰일 수도
// 주의: clear 가 데이터를 지우지 않음
// 단지 position/limit 만 리셋
ByteBuffer buf = ByteBuffer.allocate(10);
buf.put("Hello".getBytes());
buf.flip();
// 읽기
buf.get(); // H
buf.get(); // e
// rewind — 처음으로
buf.rewind();
// position = 0, limit = 그대로 (5)
// 처음부터 다시 읽기
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
// Hello 출력 (전체)
ByteBuffer buf = ByteBuffer.allocate(10);
buf.put("Hello".getBytes());
buf.flip();
// 일부 읽음
buf.get(); // H
buf.get(); // e
// 상태: [H, e, l, l, o, _, _, _, _, _]
// ↑ position = 2
// ↑ limit = 5
// compact — 안 읽은 (l, l, o) 를 앞으로
buf.compact();
// 결과: [l, l, o, _, _, _, _, _, _, _]
// ↑ position = 3
// ↑ limit = 10 (capacity)
// 이제 추가 쓰기 가능 (쓰기 모드)
buf.put("!".getBytes());
// 결과: [l, l, o, !, ...]
| 메서드 | 효과 | position | limit | 데이터 |
|---|---|---|---|---|
| flip | 쓰기 → 읽기 | 0 | 이전 position | 그대로 |
| clear | 비우기 (재사용) | 0 | capacity | 그대로 (무시) |
| rewind | 처음으로 (다시 읽기) | 0 | 그대로 | 그대로 |
| compact | 안 읽은 거 앞으로 | 안 읽은 개수 | capacity | 이동됨 |
// 패턴 1: 쓰기 → 읽기 → 비우기 → 다시 쓰기
ByteBuffer buf = ByteBuffer.allocate(1024);
while (channel.read(buf) > 0) { // 쓰기 (Channel → Buffer)
buf.flip(); // 읽기 모드
while (buf.hasRemaining()) {
process(buf.get()); // 읽기
}
buf.clear(); // 비우기, 다시 쓸 준비
}
// 패턴 2: 쓰기 → 읽기 → 일부 처리 후 compact → 더 쓰기
ByteBuffer buf = ByteBuffer.allocate(1024);
channel.read(buf);
buf.flip();
// 일부 처리
processSomeData(buf);
buf.compact(); // 안 읽은 데이터 앞으로, 새 데이터 받을 준비
channel.read(buf); // 추가 데이터 읽기 (이어서)
// 실수 1: flip 안 함
ByteBuffer buf = ByteBuffer.allocate(10);
buf.put("Hello".getBytes());
// 바로 읽기 시도
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
// 잘못된 결과 — position = 5, limit = 10
// 빈 데이터 (0x00) 5개 출력
// 해결
buf.flip(); // 읽기 모드
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
// Hello
// 실수 2: clear 대신 flip
ByteBuffer buf = ByteBuffer.allocate(1024);
while (channel.read(buf) > 0) {
buf.flip();
process(buf);
buf.flip(); // ❌ 잘못
// 두 번 flip 하면: limit = 0, position = 0
}
// 해결
buf.clear(); // 다음 쓰기 준비
public class ShipmentBufferReader {
// 큰 파일을 Channel + Buffer 로 효율적으로 읽기
public List<String> readLines(Path file) throws IOException {
List<String> lines = new ArrayList<>();
ByteBuffer buffer = ByteBuffer.allocate(8192); // 8KB
StringBuilder line = new StringBuilder();
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
while (channel.read(buffer) > 0) {
buffer.flip(); // 읽기 모드
while (buffer.hasRemaining()) {
char c = (char) buffer.get();
if (c == '\n') {
lines.add(line.toString());
line.setLength(0);
} else {
line.append(c);
}
}
buffer.clear(); // 다음 읽기 준비
}
if (line.length() > 0) {
lines.add(line.toString());
}
}
return lines;
}
}
flip, clear, rewind, compact 의 차이는?
답:
1. flip: 쓰기 → 읽기 (limit = position, position = 0)
2. clear: 비우기 (position = 0, limit = capacity, 데이터는 안 지움)
3. rewind: 처음으로 (position = 0, limit 유지)
4. compact: 안 읽은 데이터 앞으로 (재사용)
언제?:
Heap Buffer:
ByteBuffer.allocate(1024)
- JVM heap 안의 메모리
- byte[] 배열 기반
- GC 가 관리
- 할당/해제 빠름
Direct Buffer:
ByteBuffer.allocateDirect(1024)
- JVM heap 밖의 메모리 (off-heap)
- OS 의 native 메모리
- GC 가 직접 관리 X
- I/O 시 복사 불필요
Heap Buffer 의 흐름 (파일 읽기):
[OS 파일]
↓
[OS 의 native 버퍼]
↓ (복사 1)
[JVM 의 Direct 버퍼] (임시)
↓ (복사 2)
[JVM Heap 의 Heap Buffer (byte[])]
↓
[사용자 코드]
→ 복사 2번 발생
→ 작은 데이터는 OK, 큰 데이터는 비효율
Direct Buffer 의 흐름 (파일 읽기):
[OS 파일]
↓
[OS 의 native 버퍼]
↓ (복사 1)
[Direct Buffer (off-heap)]
↓
[사용자 코드]
→ 복사 1번
→ 큰 데이터에 효율적
→ JVM heap 우회
// Heap Buffer
ByteBuffer heap = ByteBuffer.allocate(1024);
// JVM heap 안
// byte[] 기반
// Direct Buffer
ByteBuffer direct = ByteBuffer.allocateDirect(1024);
// JVM heap 밖
// native 메모리
// 사용은 동일
heap.put("Hello".getBytes());
direct.put("Hello".getBytes());
// 검사
heap.isDirect(); // false
direct.isDirect(); // true
시나리오: 1GB 파일 복사
Heap Buffer (1MB 단위):
- 1024번 read/write
- 매번 2번 복사
- 총 복사: 2 * 1GB = 2GB 데이터 이동
Direct Buffer (1MB 단위):
- 1024번 read/write
- 매번 1번 복사
- 총 복사: 1GB 데이터 이동
성능 차이:
- 작은 데이터 (< 1MB): Heap 이 빠를 수도 (할당 비용)
- 큰 데이터 (> 1MB): Direct 가 빠름
- 자주 재사용: Direct 가 매우 유리
Direct Buffer 의 단점:
1. 할당/해제 비용 큼
- native 메모리 할당 = system call
- 자주 만들면 오히려 느림
2. GC 가 직접 정리 안 함
- Cleaner (PhantomReference 기반) 으로 정리
- 정리 시점 예측 불가
- 메모리 누수 가능
3. 메모리 사용 추적 어려움
- JVM heap 통계에 안 잡힘
- -XX:MaxDirectMemorySize 로 제한 가능
4. OutOfMemoryError 가능
- Direct memory 한도 초과 시
- 일반 OOM 과 다른 메시지
Heap Buffer 권장:
✓ 작은 데이터 (< 1MB)
✓ 일회성 사용
✓ 자주 새로 만듦
✓ JVM heap 안에서 작업
Direct Buffer 권장:
✓ 큰 데이터 (> 1MB)
✓ 자주 재사용
✓ 네트워크 I/O 빈번
✓ JNI 와 통신
실무 패턴:
- 풀링 (BufferPool) 으로 재사용
- Netty 같은 라이브러리가 자동 관리
// 파일을 메모리에 직접 매핑
try (FileChannel channel = FileChannel.open(
Path.of("large.dat"),
StandardOpenOption.READ, StandardOpenOption.WRITE)) {
MappedByteBuffer mapped = channel.map(
FileChannel.MapMode.READ_WRITE,
0, // 시작 위치
channel.size()); // 매핑할 크기
// mapped 는 파일과 직접 연결된 메모리
// 읽기 빠름 (페이지 교체)
// 쓰기는 OS 가 비동기로 디스크 반영
byte b = mapped.get(0);
mapped.put(0, (byte) 'A');
// force() 로 강제 디스크 쓰기
mapped.force();
}
// 활용:
// - 매우 큰 파일의 일부만 자주 접근
// - DB 엔진의 페이지 캐시
// - 고성능 IPC
public class HighPerformanceLogger {
// 고빈도 로깅을 위한 재사용 Direct Buffer
private static final ByteBuffer LOG_BUFFER =
ByteBuffer.allocateDirect(64 * 1024); // 64KB
private static final Object LOCK = new Object();
public void log(String message, FileChannel channel) throws IOException {
byte[] data = (message + "\n").getBytes(StandardCharsets.UTF_8);
synchronized (LOCK) {
// Buffer 재사용
if (LOG_BUFFER.remaining() < data.length) {
// 버퍼 가득 → flush
LOG_BUFFER.flip();
channel.write(LOG_BUFFER);
LOG_BUFFER.clear();
}
LOG_BUFFER.put(data);
}
}
public void flush(FileChannel channel) throws IOException {
synchronized (LOCK) {
LOG_BUFFER.flip();
channel.write(LOG_BUFFER);
LOG_BUFFER.clear();
}
}
}
Heap Buffer 와 Direct Buffer 의 차이와 선택은?
답:
1. 위치:
I/O 시 복사:
할당 비용:
GC:
선택:
// 작업: 파일을 1024 바이트씩 읽어서 처리
// Stream (IO) 방식
try (FileInputStream fis = new FileInputStream("file.txt");
BufferedInputStream bis = new BufferedInputStream(fis)) {
byte[] buf = new byte[1024];
int n;
while ((n = bis.read(buf)) != -1) {
process(buf, 0, n); // n 바이트만 처리
}
}
// Channel (NIO) 방식
try (FileChannel channel = FileChannel.open(
Path.of("file.txt"), StandardOpenOption.READ)) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (channel.read(buffer) > 0) {
buffer.flip();
process(buffer); // Buffer 자체를 처리
buffer.clear();
}
}
Stream 방식:
- byte[] 배열 직접 다룸
- read() 가 채운 바이트 수 반환
- n 만큼만 처리 (마지막 read 에서 부분 채움 주의)
- 가독성 ↑ (직관적)
Channel 방식:
- ByteBuffer 다룸
- position/limit 자동 관리
- flip/clear 모드 전환 필요
- 가독성 ↓ (Buffer 학습 필요)
- 양방향, Non-blocking 등 추가 기능
// 한 번에 충분히 크게
byte[] buf = new byte[8192]; // 8KB
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
// BufferedInputStream 도 비슷한 효과
// 더 효율: Java 9+ transferTo
in.transferTo(out);
// 내부적으로 최적화
// 1. 일반 Channel 복사
try (FileChannel src = FileChannel.open(srcPath, READ);
FileChannel dest = FileChannel.open(destPath, WRITE, CREATE)) {
ByteBuffer buffer = ByteBuffer.allocate(8192);
while (src.read(buffer) > 0) {
buffer.flip();
dest.write(buffer);
buffer.clear();
}
}
// 2. zero-copy (가장 빠름)
try (FileChannel src = FileChannel.open(srcPath, READ);
FileChannel dest = FileChannel.open(destPath, WRITE, CREATE)) {
src.transferTo(0, src.size(), dest);
// OS 의 sendfile 시스템 호출 활용 (Linux)
// 커널 ↔ 유저 공간 복사 회피
}
1GB 파일 복사 성능 (예시):
방식 1: Stream (1바이트씩)
- 너무 느림 (사용 안 함)
방식 2: Stream + BufferedInputStream
- ~3000ms
방식 3: Stream + byte[8192]
- ~1500ms
방식 4: Channel + Heap Buffer
- ~1200ms
방식 5: Channel + Direct Buffer
- ~900ms
방식 6: Channel.transferTo (zero-copy)
- ~400ms
→ zero-copy 가 가장 빠름
→ 작은 파일은 차이 미미
// Stream + Files.lines (실제로는 java.io 의 BufferedReader 기반)
try (Stream<String> lines = Files.lines(Path.of("file.txt"))) {
lines.filter(l -> !l.isEmpty())
.map(this::process)
.forEach(System.out::println);
}
// Channel + ByteBuffer 는 함수형과 직접 결합 어려움
// 보통 Stream API 와 함께 사용
| 항목 | Stream (IO) | Channel (NIO) |
|---|---|---|
| 데이터 단위 | byte / byte[] | ByteBuffer |
| 방향 | 단방향 | 양방향 |
| 위치 관리 | 순차 | Random Access |
| Blocking | 항상 | Non-blocking 가능 |
| Buffer | 보조 스트림 (Buffered) | 필수 |
| 가독성 | ↑ | ↓ (학습 곡선) |
| 성능 (단순 작업) | OK | 약간 빠름 |
| 성능 (zero-copy) | 불가 | 가능 |
| 메모리 매핑 | X | MappedByteBuffer |
| 직렬화 | ✓ (ObjectStream) | X (직접 안 됨) |
| 네트워크 대규모 | 어려움 | 권장 |
같은 작업을 Stream/Channel 로 할 때 차이는?
답:
1. 데이터 단위:
위치 관리:
성능:
가독성:
확장:
일반 파일 → 네트워크 복사:
1. 사용자가 read() 호출
2. context switch (user → kernel)
3. 디스크 → kernel buffer (DMA)
4. kernel buffer → user buffer (CPU 복사)
5. context switch (kernel → user)
6. 사용자가 write() 호출
7. context switch (user → kernel)
8. user buffer → socket buffer (CPU 복사)
9. socket buffer → 네트워크 (DMA)
10. context switch (kernel → user)
총: 4번 복사, 4번 context switch
zero-copy (transferTo, Linux sendfile):
1. 사용자가 transferTo() 호출
2. context switch (user → kernel)
3. 디스크 → kernel buffer (DMA)
4. kernel buffer → socket buffer (DMA, 또는 직접)
5. socket buffer → 네트워크 (DMA)
6. context switch (kernel → user)
총: 2번 복사 (DMA), 2번 context switch
→ CPU 복사 제거
→ context switch 절반
// FileChannel.transferTo (가장 권장)
try (FileChannel src = FileChannel.open(srcPath, READ);
FileChannel dest = FileChannel.open(destPath, WRITE, CREATE)) {
src.transferTo(0, src.size(), dest);
// 내부적으로 OS 의 sendfile() 시스템 호출
// Linux: sendfile, Windows: TransmitFile
}
// FileChannel.transferFrom — 반대 방향
dest.transferFrom(src, 0, src.size());
// 파일 → 네트워크
try (FileChannel file = FileChannel.open(filePath, READ);
SocketChannel socket = SocketChannel.open(addr)) {
file.transferTo(0, file.size(), socket);
// 매우 효율적
// 웹 서버의 파일 전송 (Nginx 등) 의 핵심
}
zero-copy 의 제약:
1. OS 의 지원 필요
- Linux: sendfile ✓
- Windows: TransmitFile (소켓만)
- 다른 OS 는 fallback
2. 변환 작업 불가
- 데이터를 그대로 전달
- 압축, 암호화 등 처리 X
- 처리 필요하면 일반 I/O
3. 크기 제한
- Linux sendfile: 2GB 제한 (구버전)
- 큰 파일은 분할
4. 일부 채널만
- FileChannel.transferTo 가 가장 효과적
- SocketChannel 도 가능
// 메모리 매핑 — 파일을 메모리처럼
try (FileChannel channel = FileChannel.open(
path, READ, WRITE)) {
MappedByteBuffer mapped = channel.map(
FileChannel.MapMode.READ_WRITE,
0, channel.size());
// 파일을 직접 메모리처럼 다룸
byte b = mapped.get(0);
mapped.put(1000, (byte) 'A');
// OS 의 페이지 교체 활용
// 큰 파일의 랜덤 접근에 빠름
mapped.force(); // 디스크 동기화
}
// 활용:
// - DB 엔진 (페이지 캐시)
// - 큰 인덱스 파일
// - IPC (공유 메모리)
// 큰 파일 처리 — Direct Buffer + Channel
try (FileChannel src = FileChannel.open(srcPath, READ);
FileChannel dest = FileChannel.open(destPath, WRITE, CREATE)) {
ByteBuffer buffer = ByteBuffer.allocateDirect(64 * 1024); // 64KB
while (src.read(buffer) > 0) {
buffer.flip();
dest.write(buffer);
buffer.clear();
}
}
// Direct Buffer 의 이점:
// - OS 와 직접 통신
// - 추가 복사 회피
// - 큰 파일 처리에 효과적
파일 복사 성능 (1GB):
1. FileInputStream.read() 1바이트:
매우 느림 (사용 X)
~수십 초
2. FileInputStream.read(byte[]) 8KB:
~3 초
3. FileChannel + Heap Buffer:
~2.5 초
4. FileChannel + Direct Buffer:
~2 초
5. FileChannel.transferTo (zero-copy):
~1 초
6. MappedByteBuffer:
~1.5 초 (랜덤 접근 빠름)
→ 큰 파일은 zero-copy 또는 메모리 매핑
→ 작은 파일은 Stream 도 충분
public class HighPerformanceFileService {
// 1. zero-copy 파일 복사
public void copyLargeFile(Path src, Path dest) throws IOException {
try (FileChannel srcCh = FileChannel.open(src, StandardOpenOption.READ);
FileChannel destCh = FileChannel.open(dest,
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long size = srcCh.size();
long transferred = 0;
while (transferred < size) {
long n = srcCh.transferTo(transferred, size - transferred, destCh);
transferred += n;
}
}
}
// 2. 메모리 매핑으로 큰 파일 분석
public BigDecimal sumColumnFromLargeFile(Path file, int columnIndex) throws IOException {
BigDecimal sum = BigDecimal.ZERO;
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.READ)) {
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_ONLY, 0, channel.size());
StringBuilder line = new StringBuilder();
while (buffer.hasRemaining()) {
char c = (char) buffer.get();
if (c == '\n') {
String[] parts = line.toString().split(",");
sum = sum.add(new BigDecimal(parts[columnIndex]));
line.setLength(0);
} else {
line.append(c);
}
}
}
return sum;
}
// 3. 파일 → 네트워크 직접 전송
public void streamToClient(Path file, SocketChannel client) throws IOException {
try (FileChannel ch = FileChannel.open(file, StandardOpenOption.READ)) {
long size = ch.size();
long sent = 0;
while (sent < size) {
long n = ch.transferTo(sent, size - sent, client);
sent += n;
}
// 웹 서버의 파일 다운로드 핵심
}
}
}
zero-copy 의 원리와 활용은?
답:
1. 일반 I/O:
zero-copy:
활용:
FileChannel.transferTo한계:
자바 표준 활용:
실무 선택 가이드:
Stream (java.io) 선택:
✓ 텍스트 파일 읽기/쓰기
✓ 직렬화 (ObjectStream)
✓ 작은 파일
✓ 간단한 코드 우선
✓ Files.lines, Files.readString 활용
Channel (java.nio) 선택:
✓ 큰 파일 (>1MB)
✓ zero-copy 필요
✓ 메모리 매핑
✓ 네트워크 (대규모 동시 연결)
✓ Random Access
✓ 비동기 I/O
시나리오 1: CSV 파일 처리 (10MB)
→ Stream + BufferedReader
→ Files.lines + Stream API
→ 충분히 빠름, 가독성 ↑
시나리오 2: 대용량 로그 분석 (10GB)
→ Channel + MappedByteBuffer
→ 랜덤 접근 빠름
→ 또는 Stream API 의 라인 처리
시나리오 3: 파일 다운로드 서버
→ FileChannel.transferTo (zero-copy)
→ 매우 효율적
→ Nginx, Tomcat 의 기본
시나리오 4: 객체 직렬화
→ ObjectOutputStream (IO)
→ NIO 와 결합 가능
→ Files.newOutputStream + ObjectOutputStream
시나리오 5: 실시간 동시 연결 (수천)
→ SocketChannel + Selector
→ 또는 Netty 같은 라이브러리
실무 라이브러리:
Apache Commons IO:
- IOUtils.copy(in, out)
- IOUtils.toByteArray(in)
- Stream 시대의 편의
Google Guava:
- ByteStreams, CharStreams
- Stream 기반
Netty:
- Non-blocking 네트워크 표준
- Direct Buffer 풀링
- zero-copy 최적화
Project Reactor:
- Reactive Streams
- Spring WebFlux
Spring:
- Resource 추상화
- 내부적으로 NIO 활용
// Spring Boot 의 파일 업로드
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
Path dest = Path.of("/uploads", file.getOriginalFilename());
// 옵션 1: Spring 의 transferTo (내부적으로 NIO)
file.transferTo(dest);
// 옵션 2: 명시적 NIO
try (InputStream is = file.getInputStream();
OutputStream os = Files.newOutputStream(dest)) {
is.transferTo(os); // Java 9+ Stream 의 transferTo
}
return "uploaded";
}
// Spring Boot 의 파일 다운로드
@GetMapping("/download/{name}")
public ResponseEntity<Resource> download(@PathVariable String name) {
Path path = Path.of("/uploads", name);
Resource resource = new FileSystemResource(path);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + name)
.body(resource);
// Spring 이 내부적으로 zero-copy 활용 가능
}
// 잘못된 패턴 — 매번 새 Buffer
public void copy(Path src, Path dest) throws IOException {
try (FileChannel s = FileChannel.open(src, READ);
FileChannel d = FileChannel.open(dest, WRITE)) {
ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024);
// 매 호출마다 Direct Buffer 할당 (비싼 비용)
// ...
}
}
// 권장 패턴 — Buffer Pool
public class BufferPool {
private final Queue<ByteBuffer> pool = new ConcurrentLinkedQueue<>();
private final int bufferSize;
public BufferPool(int bufferSize) {
this.bufferSize = bufferSize;
}
public ByteBuffer acquire() {
ByteBuffer buf = pool.poll();
if (buf == null) {
buf = ByteBuffer.allocateDirect(bufferSize);
}
return buf;
}
public void release(ByteBuffer buf) {
buf.clear();
pool.offer(buf);
}
}
// 사용
BufferPool pool = new BufferPool(64 * 1024);
ByteBuffer buf = pool.acquire();
try {
// 사용
} finally {
pool.release(buf);
}
@Service
public class ShipmentFileIoService {
private final BufferPool bufferPool = new BufferPool(64 * 1024);
// 1. 일반 CSV — Stream
public List<Shipment> importCsv(Path src) throws IOException {
try (Stream<String> lines = Files.lines(src)) {
return lines
.skip(1)
.map(this::parseShipment)
.toList();
}
}
// 2. 큰 파일 export — Stream + Buffered
public void exportLargeCsv(List<Shipment> shipments, Path dest) throws IOException {
try (BufferedWriter writer = Files.newBufferedWriter(dest)) {
writer.write("id,blNo,weight,createdAt\n");
for (Shipment s : shipments) {
writer.write(s.toCsvLine());
writer.newLine();
}
}
}
// 3. 매우 큰 파일 복사 — zero-copy
public void copyLargeBackup(Path src, Path dest) throws IOException {
try (FileChannel srcCh = FileChannel.open(src, READ);
FileChannel destCh = FileChannel.open(dest, CREATE, WRITE)) {
long size = srcCh.size();
long transferred = 0;
while (transferred < size) {
transferred += srcCh.transferTo(transferred, size - transferred, destCh);
}
}
}
// 4. 큰 파일 부분 읽기 — Channel + position
public byte[] readShipmentBlock(Path file, long offset, int length) throws IOException {
try (FileChannel channel = FileChannel.open(file, READ)) {
ByteBuffer buffer = bufferPool.acquire();
try {
buffer.limit(length);
channel.read(buffer, offset);
buffer.flip();
byte[] result = new byte[buffer.remaining()];
buffer.get(result);
return result;
} finally {
bufferPool.release(buffer);
}
}
}
// 5. 직렬화 — ObjectStream (IO) + Files (NIO.2)
public void saveBinary(Shipment s, Path dest) throws IOException {
try (OutputStream os = Files.newOutputStream(dest);
ObjectOutputStream oos = new ObjectOutputStream(os)) {
oos.writeObject(s);
}
}
}
실무에서 IO + NIO 결합:
1. NIO.2 의 Files 가 IO 의 클래스 반환
- Files.newBufferedReader → BufferedReader (IO)
- Files.newInputStream → InputStream (IO)
2. IO 의 ObjectStream + NIO.2 의 Files
- 직렬화는 ObjectStream
- 파일은 NIO.2
3. Stream API + Files
- Files.lines (Stream + IO 결합)
- 함수형 + 효율
4. Channel + Stream
- Channels.newInputStream(channel)
- Channel 을 InputStream 으로
- 두 API 호환
실무에서 Stream/Channel 선택의 기준은?
답:
1. 데이터 크기:
작업 종류:
가독성 vs 성능:
결합:
라이브러리:
| Q | 핵심 답변 |
|---|---|
| Stream vs Channel? | 1바이트/단방향 vs 블록/양방향 |
| Channel 이 양방향인 이점? | 한 객체로 read+write |
| Buffer 의 4가지 속성? | capacity, position, limit, mark |
| 불변식? | 0 ≤ mark ≤ position ≤ limit ≤ capacity |
| flip 의 동작? | limit=position, position=0 |
| clear의 동작? | position=0, limit=capacity |
| compact 의 동작? | 안 읽은 데이터 앞으로 |
| Heap vs Direct? | heap 안 vs heap 밖, 복사 횟수 |
| Direct Buffer 단점? | 할당 비용, GC 어려움 |
| zero-copy? | 커널 ↔ 유저 복사 회피 |
| transferTo? | FileChannel 의 zero-copy 메서드 |
| MappedByteBuffer? | 파일을 메모리처럼 |
| Stream 이 항상 필요한 곳? | 직렬화, Reader/Writer 텍스트 |
답:
설계 결정:
장점:
비교:
답:
답:
// 명시적 cleanup (비표준)
if (buffer instanceof DirectBuffer) {
((DirectBuffer) buffer).cleaner().clean();
}
답:
장점:
단점:
답:
1. Stream vs Channel
2. Buffer 정밀
3. 고성능 패턴
이번 Unit에서 Stream/Channel 의 정밀을 봤다면, 다음은 Blocking vs Non-blocking 의 마스터.
🚀 Phase 7 — I/O 시스템 큰 그림
✅ Unit 7.1 I/O 란 무엇인가
✅ Unit 7.2 IO vs NIO (역사적 진화)
✅ Unit 7.3 Stream vs Channel ← 여기
⏭ Unit 7.4 Blocking vs Non-blocking (★ 마스터 깊이)
⏭ Unit 7.5 오버헤드와 File 객체
✅ Phase 1 — Pass by Value (1.1 ~ 1.3 완주)
✅ Phase 2 — 컬렉션 프레임워크 (2.1 ~ 2.6 완주)
✅ Phase 3 — 해시의 원리 (3.1 ~ 3.4 완주)
✅ Phase 4 — 추상화의 두 도구 (4.1 ~ 4.4 완주)
✅ Phase 5 — 제네릭과 와일드카드 (5.1 ~ 5.5 완주)
✅ Phase 6 — 객체 비교 (6.1 ~ 6.4 완주)
🚀 Phase 7 — I/O 시스템 큰 그림 (3/5 진행)
총: 29/43 Unit 작성 (약 67%)