스트림 I/O 전통 I/O 한계 NIO 모델 Selector 다중화 NIO.2 · 비동기 직렬화 📝 문제풀이
◀ 심화 CH 07 📋 목차 심화 CH 09 ▶
🔌 심화 CHAPTER 08 · 5년차 트랙

바이트는 어떻게 흐르고, 왜 한 스레드로 수만 연결을 감당할까?

파일을 읽고 쓰는 건 익숙하죠. 이번 장은 "왜 버퍼링이 성능을 바꾸고, 왜 블로킹 I/O가 확장성의 벽이 되는가"를 파고듭니다. 스트림·채널·버퍼·Selector, 그리고 직렬화의 보안 함정까지 — 네트워크 서버 설계와 장애 대응, 시니어 면접의 단골이에요.

🎯 이 장을 끝내면
🌊
스트림 I/O
바이트 스트림과 문자 스트림
버퍼링·인코딩·자원 해제 — 기본기 3종 세트

java.io의 세계는 스트림(stream), 즉 한 방향으로 흐르는 데이터의 연속이에요. 크게 둘로 나뉩니다. 바이트 스트림(InputStream/OutputStream)은 8비트 바이트를 그대로 다뤄 이미지·오디오·바이너리에 쓰고, 문자 스트림(Reader/Writer)은 Charset을 거쳐 바이트를 문자로 해석해 텍스트에 씁니다.

🔟
바이트 스트림
InputStream / OutputStream
바이너리 그대로
🔤
문자 스트림
Reader / Writer
Charset로 인코딩·디코딩
🌉 둘을 잇는 다리 — InputStreamReader 바이트를 문자로 바꾸려면 어떤 인코딩인지 알아야 해요. 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()가 자동 호출 — 예외가 나도 자원 누수 없음
💡
try-with-resources는 "close 잊음"을 문법이 막아줘요. 괄호 안에 선언한 자원은 AutoCloseable이면 블록을 벗어날 때 선언의 역순으로 자동 close()돼요. 게다가 본문에서 예외가 나고 close()에서도 예외가 나면, 본문 예외를 살리고 close 예외는 suppressed(억제된 예외)로 붙여줘요(getSuppressed()로 조회). 예전 finally에서 close하던 장황한 코드는 이제 안티패턴입니다.
🧱
전통 I/O 한계
블로킹과 스레드-per-connection의 벽
C10K 문제 — 왜 java.io로는 수만 연결이 안 될까

java.io의 소켓 I/O는 블로킹(blocking)이에요. socket.getInputStream().read()를 호출하면 데이터가 도착할 때까지 그 스레드는 멈춰서 대기합니다. 연결 하나당 스레드 하나를 붙이는 스레드-per-connection 모델이 자연스레 따라오죠.

⚠️
스레드는 공짜가 아니에요. 플랫폼 스레드 하나는 기본 스택 ~1MB와 커널 스케줄링 비용을 먹어요. 연결이 1만 개면 스레드 1만 개 → 메모리 수 GB에 컨텍스트 스위칭 폭증. 게다가 대부분의 스레드는 실제로는 read()에서 놀며(idle) 블로킹돼 있어 CPU는 남는데 확장이 안 되는 역설이 생깁니다. 이게 유명한 C10K 문제예요.
🖼️ 그림으로 보기 — 스레드-per-connection (블로킹)
연결 (수만 개) 스레드 (연결마다 1개) conn 1 conn 2 conn 3 … conn N thread 1 — read() 대기 thread 2 — read() 대기 thread 3 — read() 대기 … thread N 스레드마다 스택 ~1MB · 대부분 블로킹(idle) 연결 폭증 → 메모리·컨텍스트 스위칭 폭증 (C10K)
🔎 블로킹이 나쁘기만 한 건 아니다 코드가 순차적으로 읽힌다는 건 블로킹의 큰 장점이에요. 연결 수가 적고(수백 이하) 처리 로직이 단순하면 스레드-per-connection이 가장 이해하기 쉽고 충분히 빠릅니다. 문제는 연결 수가 수천~수만으로 폭증할 때죠. Java 21의 가상 스레드(Virtual Thread)는 이 딜레마를 다른 방향으로 풉니다 — 블로킹 코드의 단순함은 유지하면서, 블로킹 시 캐리어 스레드를 반납해 수백만 개까지 값싸게 확장해요. "블로킹 스타일 + NIO급 확장"이 목표입니다.
📦
NIO 모델
Buffer와 Channel, 그리고 다이렉트 버퍼
스트림에서 "블록 지향"으로 사고방식이 바뀐다

java.nio(New I/O, Java 1.4~)는 근본 모델이 달라요. 스트림이 한 바이트씩 흐르는 것이라면, NIO는 Buffer라는 블록에 담아 Channel을 통해 오가요. 핵심은 Channel은 양방향이고(읽기·쓰기 모두), 데이터는 항상 Buffer를 경유한다는 점입니다.

🧊
Buffer
데이터를 담는 블록
position·limit·capacity
🚇
Channel
양방향 연결 통로
FileChannel·SocketChannel

Buffer는 세 개의 커서로 상태를 관리해요 — capacity(전체 크기), position(현재 위치), limit(읽고/쓸 수 있는 경계). 채널에서 읽어 담은 뒤 처리하려면 flip()으로 쓰기 모드 → 읽기 모드를 전환하는 게 NIO 초심자의 첫 관문이죠.

🖼️ 그림으로 보기 — Buffer의 position·limit·capacity와 flip()
① 채운 직후 (쓰기 모드) position=5 limit=capacity=8 flip() ↓ limit=position, position=0 ② flip() 후 (읽기 모드) position=0 limit=5 (읽을 경계)
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)
}
⚡ 다이렉트 버퍼(Direct Buffer) — off-heap ByteBuffer.allocate()JVM 힙 안에 버퍼를 만들어요(heap buffer). 반면 ByteBuffer.allocateDirect()힙 바깥 네이티브 메모리(off-heap)에 만들어요. 왜 off-heap이 빠를까요? 힙 버퍼로 I/O를 하면 OS가 GC로 이동할 수 있는 힙 객체를 직접 못 다뤄서, 내부적으로 임시 다이렉트 버퍼로 복사하는 단계가 끼어요. 다이렉트 버퍼는 이 중간 복사를 건너뛰어(zero-copy 유리) 커널과 바로 주고받습니다. 대신 할당·해제 비용이 크고 GC가 직접 관리하지 않아(Cleaner에 의존) 큰 버퍼를 오래 재사용할 때만 이득이에요.
🧭
java.nio.file — Path와 Files. 낡은 java.io.File은 실패 시 boolean false만 돌려주고 심볼릭 링크·권한 처리가 빈약했어요. NIO.2(Java 7)의 Path·Files명확한 예외, 원자적 이동(Files.move + ATOMIC_MOVE), Files.readAllLines·Files.lines(스트림) 같은 편의를 줍니다. 신규 코드는 Path/Files를 우선하세요.
🎛️
Selector 다중화
한 스레드로 수천 연결을 감시하기
논블로킹 + 이벤트 다중화(multiplexing)

NIO 채널은 논블로킹 모드로 둘 수 있어요(configureBlocking(false)). 데이터가 없으면 멈추지 않고 즉시 0을 반환하고 돌아오죠. 하지만 채널 수천 개를 계속 폴링하면 CPU 낭비예요. 그래서 Selector가 등장합니다.

🎯 Selector의 핵심 아이디어 여러 채널을 하나의 Selector에 등록(register)하고 관심 이벤트(OP_READ, OP_ACCEPT 등)를 걸어둬요. 그 다음 selector.select() 한 번의 호출"지금 준비된(ready) 채널들"만 골라 받습니다. 내부적으로 OS의 epoll(리눅스)/kqueue(BSD)/IOCP 같은 커널 이벤트 다중화를 씁니다. 결과: 단 하나의 스레드(이벤트 루프)가 수천~수만 연결을 감시하며, 준비된 것만 처리해요. 스레드-per-connection의 메모리·컨텍스트 스위칭 폭증 문제를 근본적으로 피합니다. Netty·Nginx·Node의 심장이 바로 이 모델이에요.
🖼️ 그림으로 보기 — Selector — 한 스레드가 여러 채널을 감시
채널 수천 개 channel 1 channel 2 channel 3 channel 4 … channel N Selector select() 준비된 것만 선별 이벤트 루프 스레드 단 1개 ready만 내부적으로 OS의 epoll·kqueue·IOCP 이벤트 다중화 사용
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);   // 한 스레드가 다수 연결을 처리
    }
}
⚠️
이벤트 루프에서 블로킹 작업 금지. Selector 모델의 힘은 "빠르게 돌며 준비된 것만 처리"에 있어요. 그 스레드에서 DB 조회·무거운 계산 같은 블로킹 작업을 하면 모든 연결이 함께 멈춥니다(head-of-line blocking). 무거운 일은 별도 워커 스레드 풀로 넘겨야 해요. 그래서 실무에선 로우레벨 NIO를 직접 쓰기보다 Netty 같은 검증된 프레임워크를 씁니다.
⏱️
NIO.2 · 비동기
Files 유틸 · WatchService · 비동기 채널
Java 7이 더한 편의와 진짜 비동기 I/O
기능무엇포인트
Files 유틸readString·writeString·copy·walk·lines한 줄로 읽기/쓰기. Files.lines지연 스트림이라 대용량에 유리(try-with-resources 필수)
WatchService디렉터리의 생성·수정·삭제 이벤트 감시폴링 대신 OS 파일시스템 알림 기반. 설정 핫리로드·감시 폴더 처리에 씀
AsynchronousChannelAsynchronousSocketChannel·AsynchronousFileChannel진짜 비동기: 작업을 걸고 즉시 반환, 완료 시 Future 또는 CompletionHandler 콜백
🔀 논블로킹(NIO Selector) vs 비동기(AIO)는 다르다 헷갈리기 쉬운 지점이에요. Selector 기반 논블로킹은 "준비됐는지 내가 물어보고, 준비되면 내 스레드가 읽는" 방식(readiness 모델)이에요.
비동기(AIO)는 "작업을 OS에 맡기고, 완료되면 콜백으로 통보받는" 방식(completion 모델)이에요. 리눅스에선 진짜 커널 AIO 지원이 제한적이라, JVM의 AsynchronousSocketChannel도 내부적으론 스레드 풀+epoll로 흉내 내는 경우가 많아요. 그래서 실무 네트워크 서버는 여전히 Selector 기반(Netty)이 주류입니다.
🧬
직렬화
객체를 바이트로 — 그리고 그 위험
Serializable · serialVersionUID · 역직렬화 취약점

직렬화(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)까지 이어질 수 있어요. 신뢰할 수 없는 데이터를 자바 기본 직렬화로 절대 역직렬화하지 마세요.
🛡️ 그래서 현대적 대안은 ① 포맷을 바꾼다 — 신뢰 경계를 넘는 데이터는 JSON(Jackson)·Protobuf·Avro 같은 데이터 전용 포맷을 써요. 이들은 "임의 객체 복원"이 아니라 정해진 스키마로만 값을 채워 gadget chain 자체가 성립하지 않아요.
② 꼭 자바 직렬화를 써야 한다면ObjectInputFilter(Java 9+)로 허용 클래스 화이트리스트를 걸어요. Effective Java의 조언은 단호해요 — "자바 직렬화는 되도록 피하라." 새 시스템에서 신뢰 경계를 넘는 통신에 기본 직렬화를 쓸 이유는 거의 없습니다. 실제로 OpenJDK도 직렬화를 장기적으로 걷어내는 방향(레코드 기반 대안 등)을 검토 중이에요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·서버 설계·장애대응에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 09 🏛️ 객체지향 설계와 패턴 — SOLID, GoF 패턴, 리팩터링, 응집도·결합도 (제작 예정)