파일을 읽고 쓰는 건 익숙하죠. 이번 장은 "왜 버퍼링이 성능을 바꾸고, 왜 블로킹 I/O가 확장성의 벽이 되는가"를 파고듭니다. 스트림·채널·버퍼·Selector, 그리고 직렬화의 보안 함정까지 — 네트워크 서버 설계와 장애 대응, 시니어 면접의 단골이에요.
AutoCloseable 계약을 이해해요.java.io의 블로킹·스레드-per-connection 모델이 왜 확장성의 벽에 부딪히는지 알아요.serialVersionUID·transient를 이해해요.java.io의 세계는 스트림(stream), 즉 한 방향으로 흐르는 데이터의 연속이에요. 크게 둘로 나뉩니다. 바이트 스트림(InputStream/OutputStream)은 8비트 바이트를 그대로 다뤄 이미지·오디오·바이너리에 쓰고, 문자 스트림(Reader/Writer)은 Charset을 거쳐 바이트를 문자로 해석해 텍스트에 씁니다.
InputStreamReader·OutputStreamWriter가 그 변환 다리예요. 여기서 Charset을 안 주면 플랫폼 기본 인코딩이 쓰여, 윈도우(MS949)에서 만든 파일을 리눅스(UTF-8)에서 읽을 때 한글이 깨지는 고전적 버그가 나요.
그래서 실무 규칙은 하나예요 — 인코딩을 항상 명시하세요. Java 18부터는 file.encoding 기본값이 UTF-8로 통일됐지만(JEP 400), 하위 호환·이식성을 위해 명시가 안전합니다.
두 번째 기본기는 버퍼링이에요. 버퍼 없이 read()를 1바이트씩 호출하면 매번 커널 시스템 콜(syscall)이 발생해 극도로 느려요. BufferedInputStream/BufferedReader는 내부 배열에 덩어리로 미리 읽어두고(prefetch) 메모리에서 꺼내줘, syscall 횟수를 수천분의 일로 줄입니다.
// 인코딩 명시 + 버퍼링 + try-with-resources (표준 3종 세트)
Path path = Path.of("data.txt");
try (BufferedReader br = new BufferedReader(
new InputStreamReader(
Files.newInputStream(path), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
process(line);
}
} // close()가 자동 호출 — 예외가 나도 자원 누수 없음
AutoCloseable이면 블록을 벗어날 때 선언의 역순으로 자동 close()돼요. 게다가 본문에서 예외가 나고 close()에서도 예외가 나면, 본문 예외를 살리고 close 예외는 suppressed(억제된 예외)로 붙여줘요(getSuppressed()로 조회). 예전 finally에서 close하던 장황한 코드는 이제 안티패턴입니다.java.io의 소켓 I/O는 블로킹(blocking)이에요. socket.getInputStream().read()를 호출하면 데이터가 도착할 때까지 그 스레드는 멈춰서 대기합니다. 연결 하나당 스레드 하나를 붙이는 스레드-per-connection 모델이 자연스레 따라오죠.
java.nio(New I/O, Java 1.4~)는 근본 모델이 달라요. 스트림이 한 바이트씩 흐르는 것이라면, NIO는 Buffer라는 블록에 담아 Channel을 통해 오가요. 핵심은 Channel은 양방향이고(읽기·쓰기 모두), 데이터는 항상 Buffer를 경유한다는 점입니다.
Buffer는 세 개의 커서로 상태를 관리해요 — capacity(전체 크기), position(현재 위치), limit(읽고/쓸 수 있는 경계). 채널에서 읽어 담은 뒤 처리하려면 flip()으로 쓰기 모드 → 읽기 모드를 전환하는 게 NIO 초심자의 첫 관문이죠.
try (FileChannel ch = FileChannel.open(Path.of("data.bin"))) {
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = ch.read(buf); // 채널 → 버퍼 (쓰기 모드로 채움)
buf.flip(); // limit=position, position=0 (읽기 모드)
while (buf.hasRemaining()) {
byte b = buf.get(); // 버퍼에서 꺼내기
// ...
}
buf.clear(); // 다시 쓰기 모드로 (position=0, limit=capacity)
}
ByteBuffer.allocate()는 JVM 힙 안에 버퍼를 만들어요(heap buffer). 반면 ByteBuffer.allocateDirect()는 힙 바깥 네이티브 메모리(off-heap)에 만들어요.
왜 off-heap이 빠를까요? 힙 버퍼로 I/O를 하면 OS가 GC로 이동할 수 있는 힙 객체를 직접 못 다뤄서, 내부적으로 임시 다이렉트 버퍼로 복사하는 단계가 끼어요. 다이렉트 버퍼는 이 중간 복사를 건너뛰어(zero-copy 유리) 커널과 바로 주고받습니다. 대신 할당·해제 비용이 크고 GC가 직접 관리하지 않아(Cleaner에 의존) 큰 버퍼를 오래 재사용할 때만 이득이에요.
java.io.File은 실패 시 boolean false만 돌려주고 심볼릭 링크·권한 처리가 빈약했어요. NIO.2(Java 7)의 Path·Files는 명확한 예외, 원자적 이동(Files.move + ATOMIC_MOVE), Files.readAllLines·Files.lines(스트림) 같은 편의를 줍니다. 신규 코드는 Path/Files를 우선하세요.NIO 채널은 논블로킹 모드로 둘 수 있어요(configureBlocking(false)). 데이터가 없으면 멈추지 않고 즉시 0을 반환하고 돌아오죠. 하지만 채널 수천 개를 계속 폴링하면 CPU 낭비예요. 그래서 Selector가 등장합니다.
register)하고 관심 이벤트(OP_READ, OP_ACCEPT 등)를 걸어둬요. 그 다음 selector.select() 한 번의 호출로 "지금 준비된(ready) 채널들"만 골라 받습니다. 내부적으로 OS의 epoll(리눅스)/kqueue(BSD)/IOCP 같은 커널 이벤트 다중화를 씁니다.
결과: 단 하나의 스레드(이벤트 루프)가 수천~수만 연결을 감시하며, 준비된 것만 처리해요. 스레드-per-connection의 메모리·컨텍스트 스위칭 폭증 문제를 근본적으로 피합니다. Netty·Nginx·Node의 심장이 바로 이 모델이에요.
Selector selector = Selector.open();
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 준비된 이벤트가 생길 때까지 대기
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 처리한 키는 반드시 제거
if (key.isAcceptable()) accept(key);
else if (key.isReadable()) read(key); // 한 스레드가 다수 연결을 처리
}
}
| 기능 | 무엇 | 포인트 |
|---|---|---|
| Files 유틸 | readString·writeString·copy·walk·lines | 한 줄로 읽기/쓰기. Files.lines는 지연 스트림이라 대용량에 유리(try-with-resources 필수) |
| WatchService | 디렉터리의 생성·수정·삭제 이벤트 감시 | 폴링 대신 OS 파일시스템 알림 기반. 설정 핫리로드·감시 폴더 처리에 씀 |
| AsynchronousChannel | AsynchronousSocketChannel·AsynchronousFileChannel | 진짜 비동기: 작업을 걸고 즉시 반환, 완료 시 Future 또는 CompletionHandler 콜백 |
AsynchronousSocketChannel도 내부적으론 스레드 풀+epoll로 흉내 내는 경우가 많아요. 그래서 실무 네트워크 서버는 여전히 Selector 기반(Netty)이 주류입니다.
직렬화(Serialization)는 객체의 상태를 바이트 스트림으로 변환해 저장·전송하고, 역직렬화(Deserialization)로 다시 객체로 복원하는 것이에요. 클래스가 Serializable(마커 인터페이스)을 구현하면 ObjectOutputStream으로 통째로 쓸 수 있죠.
class Session implements Serializable {
private static final long serialVersionUID = 1L; // 버전 명시 권장
private String userId;
private transient String password; // transient = 직렬화 제외 (민감정보)
}
| 키워드 | 역할 |
|---|---|
serialVersionUID | 클래스 버전 식별자. 없으면 컴파일러가 자동 계산 → 필드 하나만 바뀌어도 값이 달라져 InvalidClassException. 직접 명시가 안전 |
transient | 해당 필드를 직렬화에서 제외(비밀번호·캐시·유도 가능 값·직렬화 불가 객체) |
readObject()는 바이트 스트림만 보고 임의 클래스의 객체 그래프를 복원해요. 공격자가 악의적으로 조작된 바이트를 흘려보내면, 역직렬화 과정에서 gadget chain(라이브러리에 존재하는 부작용 있는 메서드 연쇄)이 실행돼 원격 코드 실행(RCE)까지 이어질 수 있어요. 신뢰할 수 없는 데이터를 자바 기본 직렬화로 절대 역직렬화하지 마세요.ObjectInputFilter(Java 9+)로 허용 클래스 화이트리스트를 걸어요.
Effective Java의 조언은 단호해요 — "자바 직렬화는 되도록 피하라." 새 시스템에서 신뢰 경계를 넘는 통신에 기본 직렬화를 쓸 이유는 거의 없습니다. 실제로 OpenJDK도 직렬화를 장기적으로 걷어내는 방향(레코드 기반 대안 등)을 검토 중이에요.
java.io는 블로킹·스레드-per-connection이라 연결 폭증(C10K)에서 확장성의 벽에 부딪힌다.ObjectInputFilter. transient로 민감정보 제외.