1주차 Unit 7.3 — NIO (Channel + Buffer)

Psj·2026년 5월 11일

F-lab

목록 보기
48/230

Unit 7.3 — NIO (Channel + Buffer)

F-LAB JAVA · 1주차 · Phase 7 · 외부 세계와의 통신


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • 전통 Stream IO의 3가지 한계는?
  • Channel과 Stream의 결정적 차이는?
  • Buffer의 4가지 상태 변수(capacity, limit, position, mark)와 그 관계는?
  • flip(), clear(), compact()는 각각 언제 쓰는가?
  • allocate vs allocateDirect의 차이는?
  • Zero-Copy가 무엇이고, 왜 빠른가?
  • Selector는 어떻게 한 스레드로 1만 연결을 다루는가?
  • OS별 Selector 구현체(epoll, kqueue, IOCP)는 무엇인가?

🎯 핵심 한 문장

NIO (Java 1.4+) = Channel(양방향 통로) + Buffer(고정 메모리 창고) + Selector(다중 채널 감시자)
— Stream IO의 "1바이트씩 · 블로킹 · 단방향" 한계를 깨뜨려, 단일 스레드로 수천~수만 동시 연결을 처리할 수 있게 한다.

비유 — 우체국의 진화

시대모델비유
Stream IO (Java 1.0~)직원 N명, 각자 1명씩 담당손님 1만 명 = 직원 1만 명 필요. 손님이 말 안 하면 직원은 멍하니 대기
NIO (Java 1.4+)모든 창구에 알림 벨, 직원 1명이 벨 울린 창구만 응대손님 1만 명 = 직원 몇 명으로 충분. 직원은 절대 멍 때리지 않음

직원 = 스레드, 창구 = Channel, 벨 = OS 이벤트, 직원 1명 = Selector를 든 1 스레드.


🧭 9개 섹션 로드맵

1. 탄생 배경            — Stream IO의 한계 (10K Problem)
2. NIO 4대 추상         — Channel · Buffer · Selector · Charset
3. Buffer 메커닉        — 4가지 상태 변수와 상태 전이
4. Channel 종류와 특기   — FileChannel · SocketChannel · Zero-Copy
5. Selector — 멀티플렉싱 — 1 스레드 다중 채널의 진실
6. ILIC 실무 코드 7가지  — Zero-Copy · MappedByteBuffer · FileLock
7. 흔한 실수 9가지       — flip 누락 · iterator 제거 · MappedBuffer 해제
8. NIO vs NIO.2 vs Netty — 어디서 멈출 것인가
9. 면접 질문 + 자기 점검

1️⃣ 탄생 배경 — Stream IO의 한계

1.1 전통 Stream의 3가지 한계

❶ 1바이트씩 (혹은 char씩) 처리

try (InputStream in = new FileInputStream("invoice.pdf")) {
    int b;
    while ((b = in.read()) != -1) {   // 한 바이트씩
        process(b);
    }
}

read()는 1바이트만 읽는다. BufferedInputStream으로 감싸도 표면상의 인터페이스는 여전히 byte 단위.
디스크 IO는 본질적으로 블록 단위인데, 추상화가 어긋난다.

❷ 단방향

  • InputStream — 읽기만
  • OutputStream — 쓰기만
  • 양방향 통로가 필요하면 둘을 따로 들고 다녀야 함

❸ 블로킹

InputStream in = socket.getInputStream();
int b = in.read();   // ❗ 데이터 올 때까지 스레드 정지

read() 호출 시 데이터가 없으면 스레드는 완전히 멈춘다.
다른 일 못 하고, OS 입장에선 그냥 자고 있는 스레드.


1.2 C10K Problem — 1만 동시 접속의 비극

웹 서버 시나리오: 클라이언트 10,000명 동시 연결.

Stream IO 모델 (블로킹):

연결 1만 개 → 스레드 1만 개 (Thread-per-Connection)

스레드 1개 메모리:
- Stack: 512KB ~ 1MB
- TCB(Thread Control Block): 추가 오버헤드

총 메모리: 5GB ~ 10GB (메모리만!)
컨텍스트 스위칭: CPU의 대부분이 스레드 전환에 소모
실제 작업 CPU: 거의 0%

문제의 핵심:

  • 대부분의 스레드는 read()에서 멈춰서 자고 있음
  • 자는 스레드도 메모리는 차지
  • OS는 어떤 스레드를 깨워야 할지 매번 다 확인

불가능한 모델. 이게 1999년경부터 "C10K Problem"으로 불린 유명한 문제다.

1.3 해결 방향 — Java 1.4 (2002)의 NIO

세 가지 동시 해결:

한계해결책
1바이트씩Buffer — 블록 단위 처리
단방향Channel — 양방향 통로
블로킹NON_BLOCKING + Selector — 1 스레드 다중 연결

이후 Java 7에서 NIO.2(Unit 7.2의 Files/Path)가 보완되며 사실상 완성됨.


2️⃣ NIO 4대 추상

2.1 전통 IO vs NIO 모델 비교

Stream IO 모델:
   InputStream  ─── byte ───►  Application
   Application  ─── byte ───►  OutputStream
   (한 방향, 1 바이트씩)

NIO 모델:
                ◄─── read ───
   Channel     ←─── Buffer ───►   Application
                ─── write ──►
   (양방향, 블록 단위)
   
   여러 Channel을 ───► Selector ───► 1 Thread가 감시

2.2 4대 추상 정리

📡 Channel       (java.nio.channels.Channel)
   ↳ 데이터 통로. Stream을 대체.
   ↳ 양방향. Buffer를 통해서만 IO

📦 Buffer        (java.nio.Buffer)
   ↳ 고정 크기 메모리 영역.
   ↳ Primitive 타입별: ByteBuffer · IntBuffer · CharBuffer ...
   ↳ Channel ↔ Buffer가 모든 IO의 단위

🎯 Selector      (java.nio.channels.Selector)
   ↳ 여러 Channel을 한 스레드로 감시.
   ↳ "준비된 Channel만" 깨워줌

🔤 Charset       (java.nio.charset.Charset)
   ↳ 문자 인코딩. byte ↔ char 변환.
   ↳ Encoder · Decoder 제공

이 Unit에서는 Channel · Buffer · Selector에 집중. Charset은 별도 학습 주제.


3️⃣ Buffer 메커닉 — 4가지 상태 변수

Buffer는 NIO의 가장 까다로운 부분. 한 번에 정확히 외울 것.

3.1 4가지 상태 변수

public abstract class Buffer {
    private int mark = -1;
    private int position = 0;
    private int limit;
    private int capacity;
}
변수의미비유
capacity버퍼의 전체 크기 (불변)양동이 용량
limit읽기/쓰기 가능 한계양동이의 물 높이 표시
position현재 작업 위치다음에 떠갈 위치
mark임시 저장 위치 (reset() 대상)책갈피

불변식: 0 ≤ mark ≤ position ≤ limit ≤ capacity

3.2 상태 전이 — 가장 중요한 다이어그램

┌─ 1) 생성 직후 ─────────────────────────────┐
│  ByteBuffer.allocate(8)                  │
│                                          │
│  [_ _ _ _ _ _ _ _]                       │
│   ↑                ↑                     │
│   position=0       limit=8 = capacity    │
│                                          │
│  → 쓰기 모드. 8 바이트 쓸 수 있음              │
└──────────────────────────────────────────┘

┌─ 2) 4 바이트 쓴 후 ────────────────────────┐
│  buf.put((byte)1).put((byte)2)           │
│     .put((byte)3).put((byte)4);          │
│                                          │
│  [1 2 3 4 _ _ _ _]                       │
│           ↑       ↑                      │
│           pos=4   limit=8                │
│                                          │
│  → 더 쓸 수 있고, 아직 못 읽음                 │
└──────────────────────────────────────────┘

┌─ 3) flip() 후 ────────────────────────────┐
│  buf.flip();                             │
│  // limit = position;                    │
│  // position = 0;                        │
│                                          │
│  [1 2 3 4 _ _ _ _]                       │
│   ↑       ↑                              │
│   pos=0   limit=4                        │
│                                          │
│  → 읽기 모드. 0~3까지 읽을 수 있음              │
└──────────────────────────────────────────┘

┌─ 4) 2 바이트 읽은 후 ──────────────────────┐
│  buf.get(); buf.get();                   │
│                                          │
│  [1 2 3 4 _ _ _ _]                       │
│       ↑   ↑                              │
│       pos=2 limit=4                      │
└──────────────────────────────────────────┘

┌─ 5a) clear() — 모든 것 리셋 ──────────────┐
│  buf.clear();                            │
│  // position = 0;                        │
│  // limit = capacity;                    │
│  // (데이터는 그대로지만 덮어쓰기 가능)         │
│                                          │
│  [1 2 3 4 _ _ _ _]   ← 데이터는 남아있음    │
│   ↑                ↑                     │
│   pos=0            limit=8               │
└──────────────────────────────────────────┘

┌─ 5b) compact() — 안 읽은 것 앞으로 ────────┐
│  // 위 4) 상태에서 compact() 호출          │
│  buf.compact();                          │
│  // 안 읽은 3,4를 앞으로 옮기고             │
│  // pos = 2, limit = capacity            │
│                                          │
│  [3 4 3 4 _ _ _ _]                       │
│       ↑           ↑                      │
│       pos=2       limit=8                │
│                                          │
│  → 이어서 쓸 수 있음. 안 읽은 데이터 보존       │
└──────────────────────────────────────────┘

3.3 주요 메서드 한 줄 정리

메서드동작언제
allocate(n)Heap에 버퍼 생성일반
allocateDirect(n)OS 메모리 직접 할당큰 IO · 빠름
put(b)한 바이트 쓰기쓰기
get()한 바이트 읽기읽기
flip()limit=pos; pos=0쓰기 → 읽기
clear()pos=0; limit=cap처음부터 다시 쓸 때
compact()안 읽은 데이터 보존 + 쓰기부분 처리 후 이어 쓸 때
rewind()pos=0 (limit 유지)다시 읽을 때
mark()현재 pos 저장책갈피
reset()mark 위치로 pos 복원책갈피 돌아가기
remaining()limit - position남은 양
hasRemaining()pos < limit루프 조건

3.4 flip()을 까먹으면? — NIO 최대 실수

ByteBuffer buf = ByteBuffer.allocate(8);
buf.put((byte)1).put((byte)2).put((byte)3);

// ❌ flip 없이 바로 channel.write(buf)
// 채널은 pos(=3)부터 limit(=8)까지 쓰려고 함
// → 이미 쓴 3바이트는 무시되고, 의미 없는 5바이트가 나감

buf.flip();   // ✅ pos=0, limit=3
channel.write(buf);   // 1, 2, 3 정확히 출력

암기 문장:

"쓰기 끝났으면 flip, 읽기 끝나면 clear 또는 compact"

3.5 allocate vs allocateDirect

ByteBuffer heapBuf = ByteBuffer.allocate(1024);          // Heap 안
ByteBuffer directBuf = ByteBuffer.allocateDirect(1024);  // OS 메모리 직접
항목allocate (Heap)allocateDirect (Direct)
위치JVM HeapOS 메모리 (Heap 밖)
GC 대상✗ (Cleaner가 정리)
할당 비용낮음높음
IO 성능보통빠름 (커널 복사 1번 줄어듦)
메모리 누수 위험없음있음 (Cleaner가 못 정리하면)

언제 Direct?

  • IO가 빈번하고
  • 버퍼가 크고 (KB ~ MB)
  • 재사용 가능할 때

언제 Heap?

  • 작은 임시 버퍼
  • 일회용

잘못된 선택의 함정: 작은 버퍼를 매번 allocateDirect 하면 할당 비용 때문에 오히려 느려진다.


4️⃣ Channel 종류와 특기

4.1 Channel 분류

📡 Channel
  ├── FileChannel              파일 IO
  ├── SocketChannel            TCP 클라이언트
  ├── ServerSocketChannel      TCP 서버 (accept)
  ├── DatagramChannel          UDP
  └── Pipe.SinkChannel /       스레드 간 통신
      Pipe.SourceChannel

4.2 FileChannel — 일반 IO

<try (FileChannel fc = FileChannel.open(path,
                        StandardOpenOption.READ)) {

    ByteBuffer buf = ByteBuffer.allocate(1024);

    while (fc.read(buf) > 0) {     // Channel → Buffer
        buf.flip();
        process(buf);
        buf.clear();
    }
}

핵심 패턴:
1. read → Buffer 채움
2. flip → 읽기 모드
3. 처리
4. clear → 다음 read 준비

4.3 FileChannel의 특기 1 — Zero-Copy

대용량 파일 전송 시 일반 IO의 문제:

일반 IO (4번 복사):
  Disk
   ↓ (1) DMA
  Kernel Buffer (page cache)
   ↓ (2) CPU 복사
  User Space (Java byte[])
   ↓ (3) CPU 복사
  Kernel Socket Buffer
   ↓ (4) DMA
  Network

→ User Space를 거치는 2번의 CPU 복사가 낭비

Zero-Copy:

try (FileChannel fc = FileChannel.open(path, READ);
     WritableByteChannel out = Channels.newChannel(outputStream)) {

    fc.transferTo(0, fc.size(), out);
}
Zero-Copy (2번 복사):
  Disk
   ↓ DMA
  Kernel Buffer
   ↓ DMA (page cache → socket buffer, CPU 미관여)
  Network

→ Linux의 sendfile() 시스템 콜 활용
→ CPU 사용량 ↓, 메모리 대역폭 ↓, 처리량 ↑

성능 차이: 큰 파일 전송 시 일반 IO 대비 2~5배 빠름.

4.4 FileChannel의 특기 2 — MappedByteBuffer

파일을 메모리에 매핑.

try (FileChannel fc = FileChannel.open(huge, READ)) {
    MappedByteBuffer buf = fc.map(
        FileChannel.MapMode.READ_ONLY, 0, fc.size()
    );

    // 4GB 파일도 마치 byte[] 처럼 다룸
    byte b = buf.get(1_000_000_000L);   // ❌ 컴파일 에러 (int)
    byte b = buf.get(0);                 // ✓ random access

    buf.position(500_000_000);
    buf.get(new byte[1024]);
}

MapMode:

  • READ_ONLY: 읽기만
  • READ_WRITE: 쓰기 시 파일에 반영
  • PRIVATE: 쓰기 시 사본 (copy-on-write)

장점:

  • OS 페이지 캐시 활용 → 캐시 효과
  • 4GB+ 파일도 random access
  • read() 호출 오버헤드 없음

단점:

  • Direct Buffer (Heap 밖, Cleaner 의존)
  • 명시적 해제 어려움 (Java 9까지 hack 필요)
  • 매핑 동안 파일 잠금 효과

4.5 FileChannel의 특기 3 — FileLock

try (FileChannel fc = FileChannel.open(path, READ, WRITE);
     FileLock lock = fc.lock()) {        // exclusive lock

    // 임계 영역 — 다른 JVM 프로세스가 못 읽음
    processFile(fc);
}
// lock 자동 해제

모드:

  • lock() — exclusive (배타)
  • lock(pos, size, shared) — 부분 잠금, shared 가능
  • tryLock() — 즉시 시도, 못 잡으면 null

용도 (ILIC):

  • 여러 batch job이 같은 정산 파일에 동시 접근 방지
  • 디스크 락이라 같은 머신의 다른 프로세스에도 효과

4.6 SocketChannel — TCP

try (SocketChannel sc = SocketChannel.open()) {
    sc.connect(new InetSocketAddress("api.carrier.com", 443));

    ByteBuffer buf = ByteBuffer.wrap("GET / HTTP/1.1\r\n\r\n".getBytes());
    sc.write(buf);

    ByteBuffer in = ByteBuffer.allocate(4096);
    sc.read(in);
    in.flip();
    // ...
}

configureBlocking(false) 호출 시 논블로킹 모드 → Selector와 함께 사용.


5️⃣ Selector — 멀티플렉싱

5.1 핵심 개념

Thread 1개
   ↓
Selector
   ├── Channel A (READ ready?)
   ├── Channel B (WRITE ready?)
   ├── Channel C (ACCEPT ready?)
   └── ... N개 Channel
   
Thread는 selector.select()에서 대기
"준비된 Channel이 있을 때만" 깨어남

OS 시스템 콜로 수천 개의 fd(file descriptor)를 한 번에 감시할 수 있다는 사실에 기반.

5.2 OS별 구현체

OS시스템 콜특징
Linuxepoll가장 효율적, fd 수 무제한
macOS / BSDkqueueepoll과 유사
WindowsIOCP / select동작 모델 다름
구식 (모든 OS)select, pollfd 수 제한 (보통 1024)

Java NIO는 OS에 맞는 시스템 콜을 자동 선택.

5.3 4가지 관심 이벤트

SelectionKey.OP_ACCEPT     // ServerSocketChannel: 새 연결 수락 가능
SelectionKey.OP_CONNECT    // SocketChannel: 연결 완료
SelectionKey.OP_READ       // 읽을 데이터 있음
SelectionKey.OP_WRITE      // 쓸 수 있는 상태

5.4 전형적인 Echo 서버

public void runEchoServer(int port) throws IOException {
    try (Selector selector = Selector.open();
         ServerSocketChannel server = ServerSocketChannel.open()) {

        server.bind(new InetSocketAddress(port));
        server.configureBlocking(false);                          // ⚠ 필수
        server.register(selector, SelectionKey.OP_ACCEPT);

        while (!Thread.currentThread().isInterrupted()) {
            selector.select();                                    // 준비된 채널 있을 때까지 대기

            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();                                      // ⚠ 필수: 처리한 키는 제거

                if (key.isAcceptable()) {
                    handleAccept(key, selector);
                } else if (key.isReadable()) {
                    handleRead(key);
                }
            }
        }
    }
}

private void handleAccept(SelectionKey key, Selector selector) throws IOException {
    ServerSocketChannel server = (ServerSocketChannel) key.channel();
    SocketChannel client = server.accept();
    client.configureBlocking(false);
    client.register(selector, SelectionKey.OP_READ,
                    ByteBuffer.allocate(1024));                   // 키에 버퍼 attach
}

private void handleRead(SelectionKey key) throws IOException {
    SocketChannel client = (SocketChannel) key.channel();
    ByteBuffer buf = (ByteBuffer) key.attachment();

    int n = client.read(buf);
    if (n == -1) {
        client.close();
        return;
    }

    buf.flip();
    client.write(buf);
    buf.compact();      // 안 보낸 게 남았으면 보존
}

5.5 selectedKeys()의 함정

Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys) {
    handle(key);
}
// ❌ 다음 select()도 같은 키들이 다시 들어옴
//    → 무한 루프

// ✅ iterator로 돌면서 remove() 호출
Iterator<SelectionKey> it = keys.iterator();
while (it.hasNext()) {
    SelectionKey key = it.next();
    it.remove();
    handle(key);
}

이게 NIO 초보자가 가장 많이 빠지는 함정.

5.6 select 변형

메서드동작
select()준비된 채널 있을 때까지 블록
select(timeout)최대 timeout ms 까지 대기
selectNow()즉시 반환 (논블로킹)
wakeup()다른 스레드에서 select() 깨우기

6️⃣ ILIC 실무 코드 7가지

국제종합물류 ILIC에서 NIO를 어디서 어떻게 쓰는가.

6.1 대용량 PDF 다운로드 (Zero-Copy)

@Service
public class InvoiceDownloadService {

    public void streamInvoice(Long invoiceId, OutputStream out) {
        Path file = pathFor(invoiceId);

        try (FileChannel fc = FileChannel.open(file, READ);
             WritableByteChannel oc = Channels.newChannel(out)) {

            long size = fc.size();
            long pos = 0;
            while (pos < size) {
                long sent = fc.transferTo(pos, size - pos, oc);
                if (sent <= 0) break;
                pos += sent;
            }
        } catch (IOException e) {
            throw new DownloadException("스트리밍 실패", e);
        }
    }
}

포인트:

  • transferTo는 한 번에 전송 보장 X → 루프로 감싸야 함
  • Linux/Mac: 내부적으로 sendfile() 시스템 콜 사용
  • 큰 파일에서 일반 read/write 대비 2~5배 처리량

6.2 대용량 정산 파일 메모리 매핑

public SettlementSummary parseLargeSettlement(Path file) {
    try (FileChannel fc = FileChannel.open(file, READ)) {

        if (fc.size() > Integer.MAX_VALUE) {
            // 2GB 이상은 분할 매핑
            return parseInChunks(fc);
        }

        MappedByteBuffer buf = fc.map(READ_ONLY, 0, fc.size());
        return parseCSV(buf);                  // byte[] 처럼 다룸

    } catch (IOException e) {
        throw new SettlementParseException(e);
    }
}

포인트:

  • 단일 map()int 크기 (2GB) 제한
  • 2GB 이상은 segment 분할 매핑 필요
  • OS 페이지 캐시 → 같은 파일 재파싱 시 디스크 IO 안 함

6.3 동시 정산 처리 방지 — FileLock

public void processSettlementBatch(Path lockFile) {
    try (FileChannel fc = FileChannel.open(lockFile, CREATE, WRITE);
         FileLock lock = fc.tryLock()) {

        if (lock == null) {
            log.warn("다른 인스턴스가 처리 중 — 스킵");
            return;
        }

        // 임계 영역 — 같은 머신의 다른 JVM도 못 들어옴
        settlementProcessor.runDaily();

    } catch (IOException e) {
        throw new SettlementException(e);
    }
}

포인트:

  • Spring @Scheduled 가 여러 인스턴스에서 동시 실행되는 사고 방지
  • DB lock보다 가볍고 단순 (단, 같은 머신 한정)
  • 진정한 분산 락은 Redis · ZooKeeper 사용

6.4 S3 ↔ 로컬 복사 (transferFrom)

public Path downloadFromS3(String key, Path target) {
    try (InputStream s3In = s3Client.getObject(bucket, key);
         ReadableByteChannel src = Channels.newChannel(s3In);
         FileChannel dst = FileChannel.open(target, CREATE, WRITE)) {

        dst.transferFrom(src, 0, Long.MAX_VALUE);
        return target;

    } catch (IOException e) {
        throw new S3DownloadException(key, e);
    }
}

포인트:

  • transferFrom = transferTo의 반대 방향
  • S3 SDK가 반환하는 InputStream을 Channel로 감싸 효율적 전송

6.5 Excel Export 스트리밍 (대용량)

public void exportShipmentsToCSV(Path target, Stream<Shipment> shipments) {
    try (FileChannel fc = FileChannel.open(target, CREATE, WRITE)) {

        ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024);   // 64KB

        Charset utf8 = StandardCharsets.UTF_8;
        writeLine(fc, buf, "id,blNo,origin,destination", utf8);

        shipments.forEach(s -> {
            String line = s.getId() + "," + s.getBlNo() + "," + s.getOrigin() + "," + s.getDestination();
            writeLine(fc, buf, line, utf8);
        });

        // 마지막 flush
        buf.flip();
        if (buf.hasRemaining()) fc.write(buf);

    } catch (IOException e) {
        throw new ExportException(e);
    }
}

private void writeLine(FileChannel fc, ByteBuffer buf, String line, Charset cs) throws IOException {
    byte[] bytes = (line + "\n").getBytes(cs);
    if (bytes.length > buf.remaining()) {
        buf.flip();
        fc.write(buf);
        buf.clear();
    }
    buf.put(bytes);
}

포인트:

  • allocateDirect — 큰 IO에 적합
  • 버퍼 가득 차면 flush → flip → write → clear 사이클
  • 메모리 사용량 64KB로 고정 (수십만 행 export 가능)

6.6 Carrier API 비동기 호출 — Java 11+ HttpClient

private final HttpClient http = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_2)
    .build();   // 내부적으로 NIO + Selector 사용

public CompletableFuture<TrackingInfo> fetchTracking(String trackingNo) {
    HttpRequest req = HttpRequest.newBuilder()
        .uri(URI.create(BASE_URL + "/tracking/" + trackingNo))
        .GET()
        .build();

    return http.sendAsync(req, BodyHandlers.ofString())
               .thenApply(HttpResponse::body)
               .thenApply(this::parseTracking);
}

포인트:

  • 우리가 NIO를 직접 안 써도 JDK 내부가 NIO 기반
  • 100개 동시 외부 API 호출도 스레드 100개 안 씀
  • 원리를 알면 튜닝·디버깅 가능

6.7 Tomcat NIO Connector 튜닝

Spring Boot 기본 내장 톰캣은 NIO Connector 사용 (org.apache.coyote.http11.Http11NioProtocol).

# application.yml
server:
  tomcat:
    threads:
      max: 200             # worker thread 풀
      min-spare: 10
    accept-count: 100      # backlog
    connection-timeout: 20000
    max-connections: 8192  # 동시 처리 가능 연결 (NIO 덕분에 thread 200으로 8192 처리)

포인트:

  • max-connections >> threads.max — NIO의 핵심 가치
  • IO 대기는 Selector가, 실제 처리만 worker thread가
  • 톰캣 튜닝 시 이 비율이 중요

7️⃣ 흔한 실수 9가지

실수 1 — flip() 누락

// ❌
buf.put(data);
channel.write(buf);    // pos부터 limit까지 — 의미 없는 데이터 전송

// ✅
buf.put(data);
buf.flip();
channel.write(buf);

실수 2 — clear() vs compact() 혼동

// 부분만 읽고 다시 채워야 할 때
buf.flip();
int n = channel.write(buf);    // 일부만 쓰임
buf.compact();                 // ✓ 안 쓴 부분 보존, 이어서 채울 수 있음

// ❌ clear()를 쓰면 안 쓴 데이터 사라짐

실수 3 — selectedKeys() iterator.remove() 누락

// ❌ 같은 키가 계속 깨어남
for (SelectionKey key : selector.selectedKeys()) {
    handle(key);
}

// ✅
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
    SelectionKey key = it.next();
    it.remove();
    handle(key);
}

실수 4 — configureBlocking(false) 누락

// ❌ 블로킹 모드인 채로 register → IllegalBlockingModeException
SocketChannel sc = SocketChannel.open();
sc.register(selector, OP_READ);

// ✅
sc.configureBlocking(false);
sc.register(selector, OP_READ);

실수 5 — allocateDirect 남용

// ❌ 작은 임시 버퍼를 Direct로
for (int i = 0; i < 1000000; i++) {
    ByteBuffer buf = ByteBuffer.allocateDirect(64);   // 매번 OS 호출
    // ...
}

// ✅ 작은 건 Heap, 또는 재사용
ByteBuffer reusable = ByteBuffer.allocate(64);
for (int i = 0; i < 1000000; i++) {
    reusable.clear();
    // ...
}

실수 6 — Buffer를 여러 스레드에서 공유

// ❌ Buffer는 thread-safe 아님
class Server {
    private final ByteBuffer shared = ByteBuffer.allocate(4096);

    void handleRequest(SocketChannel ch) {
        ch.read(shared);   // 여러 스레드가 동시 호출 시 데이터 깨짐
    }
}

// ✅ 연결마다 별도 버퍼 (key.attachment() 활용)

실수 7 — MappedByteBuffer 해제

<MappedByteBuffer buf = fc.map(...);
// 파일을 닫아도 매핑은 살아있음
// Cleaner가 언제 호출될지 모름 → 디스크에 락이 남을 수 있음

// Java 9+: Unsafe로 강제 해제 가능 (권장 X)
// 가장 안전한 방법: try 블록 안에서 사용 끝나면 빠르게 GC 유도
buf = null;
System.gc();   // 최후의 수단

실수 8 — transferTo 한 번에 다 갈 거라 가정

// ❌ OS마다 한 번에 보낼 수 있는 양이 다름
fc.transferTo(0, fc.size(), out);

// ✅ 루프
long size = fc.size();
long pos = 0;
while (pos < size) {
    long sent = fc.transferTo(pos, size - pos, out);
    if (sent <= 0) break;
    pos += sent;
}

실수 9 — select()를 spin 루프로 호출

// ❌ CPU 100%
while (true) {
    selector.selectNow();
    processKeys();
}

// ✅ 블로킹 또는 timeout
while (true) {
    selector.select();          // 또는 select(1000)
    processKeys();
}

8️⃣ NIO vs NIO.2 vs Netty — 어디서 멈출 것인가

8.1 세 레이어 비교

   Netty / Reactor Netty          ── 9999 명이 여기서 멈춤
        ▲
        │
   Java 11+ HttpClient            ── 80%의 일반 개발자
   Java NIO.2 (Files/Path)        ── 파일 작업 표준
        ▲
        │
   Java NIO (Channel/Buffer)      ── 원리 이해의 마지막 층
        ▲
        │
   Java IO (Stream)               ── 더는 안 쓰는 게 좋음

8.2 권장 선택

작업권장
파일 작업NIO.2 (Files/Path)
HTTP 호출Java 11+ HttpClient
대용량 파일 전송NIO FileChannel.transferTo
메모리 매핑NIO MappedByteBuffer
분산 락 (한 머신)NIO FileLock
고성능 서버 (1만+ 연결)Netty (NIO 직접 사용 X)

8.3 왜 Netty인가 — NIO 직접 사용의 함정

NIO를 직접 쓰면:

  • Selector 루프, key 관리, buffer 관리 → 코드 1000줄+
  • Edge case 폭탄 (Linux epoll의 spurious wakeup, half-close, ...)
  • 메모리 관리, backpressure, idle timeout 등 직접 구현
  • 디버깅 지옥

Netty는 이걸 다 추상화. NIO의 원리를 이해하는 게 목표지, 직접 구현하는 게 목표가 아니다.

Spring Boot의 내장 톰캣, Netty Reactor (Spring WebFlux), gRPC Java 모두 내부에서 NIO 사용.
우리는 그 위에서 일하지만, 튜닝·문제 진단 시 원리가 필요.


9️⃣ 면접 질문 + 자기 점검

9.1 면접 단골 질문 매핑

Q핵심 답변
전통 IO vs NIO의 결정적 차이?Stream(단방향·1바이트·블로킹) vs Channel+Buffer(양방향·블록·논블로킹)
Buffer의 4가지 상태 변수?capacity, limit, position, mark
flip()은 정확히 뭘 하나?limit = position; position = 0; mark = -1;
clear() vs compact()?clear: 전체 리셋 (데이터 무시), compact: 안 읽은 것 앞으로 보존
allocate vs allocateDirect?Heap vs OS 메모리. Direct는 IO 빠르지만 할당 비쌈
Zero-Copy가 뭐고 왜 빠른가?User space 복사 생략, sendfile 시스템 콜. CPU·메모리 대역폭 절약
Selector는 어떻게 동작?OS 시스템 콜(epoll/kqueue/IOCP)로 다중 fd 감시
1만 동시 연결, 스레드 몇 개?NIO + Selector 사용 시 수십 개로도 가능
MappedByteBuffer 장단점?페이지 캐시 활용 · random access. 해제 어려움 · OS 의존
NIO 직접 쓸 일이 있나?일반적으로 X. Netty/HttpClient/내장 톰캣이 알아서 사용

9.2 자기 점검 체크리스트

기본 이해

  • Stream IO의 3가지 한계를 설명할 수 있다
  • Channel · Buffer · Selector의 역할을 한 줄씩 설명할 수 있다
  • Buffer의 4가지 상태 변수를 그림으로 그릴 수 있다
  • flip · clear · compact의 동작 차이를 안다
  • C10K Problem과 NIO의 관계를 설명할 수 있다

실전 적용

  • FileChannel로 파일 복사 코드를 작성할 수 있다
  • transferTo로 Zero-Copy 다운로드를 구현할 수 있다
  • FileLock으로 동시 실행 방지 코드를 작성할 수 있다
  • Selector echo 서버를 구현할 수 있다
  • iterator.remove() 누락의 위험을 안다

면접 대비 — 5분 답변

  • NIO 등장 배경 (1만 동시 연결 문제)
  • 4대 추상 (Channel · Buffer · Selector · Charset)
  • Buffer 상태 전이 (put → flip → get → clear)
  • Zero-Copy 원리와 측정 가능한 이득
  • Selector의 OS별 구현 (epoll/kqueue/IOCP)

🎯 핵심 요약 — 3줄 정리

1. NIO = Stream IO의 3가지 한계를 동시 해결

  • 1바이트씩 → Buffer (블록 단위)
  • 단방향 → Channel (양방향)
  • 블로킹 → NON_BLOCKING + Selector (1 스레드 다중 연결)

2. Buffer 메커닉이 NIO의 진입 장벽

  • capacity (불변) ≥ limit ≥ position ≥ mark
  • 쓰기 끝 → flip / 읽기 끝 → clear or compact
  • flip 누락이 가장 흔한 버그

3. ILIC 실무에서 직접 쓸 곳은 한정적

  • 대용량 파일 전송 — transferTo (Zero-Copy)
  • 대용량 파일 파싱 — MappedByteBuffer
  • 동시 batch 방지 — FileLock
  • 일반 HTTP/서버 IO — JDK가 알아서 NIO 사용
  • 1만+ 연결 서버는 Netty 사용

📚 다음으로...

Unit 7.4 — Serializable과 transient

  • 객체를 바이트 스트림으로 변환하는 메커니즘
  • serialVersionUID 가 없으면 언제 터지는가
  • transient로 보안 위험 차단 (비밀번호, 토큰, 캐시)
  • 역직렬화 공격 (Insecure Deserialization)과 방어
  • ILIC의 Redis 세션 직렬화 패턴

1주차 진행 상황

  • ✅ Unit 6.4 HashMap과 LoadFactor
  • ✅ Unit 7.1 try-with-resources
  • ✅ Unit 7.2 NIO.2 (Files, Path)
  • ✅ Unit 7.3 NIO (Channel + Buffer) — 이 문서
  • ⏭ Unit 7.4 Serializable과 transient (1주차 마지막)
  • 🎓 종합 자기 점검 24문항

추가 학습 자료

  • JSR 51 (Java 1.4 NIO 원래 명세)
  • Java Networking and Proxies — Oracle docs
  • The C10K Problem — Dan Kegel (1999)
  • Netty in Action — Norman Maurer (NIO 추상화 학습용)
profile
Software Developer

0개의 댓글