Java I/O는 단순한 파일 읽기/쓰기가 아닙니다. OS 커널의 시스템 콜, 페이지 캐시, epoll, zero-copy 같은 저수준 메커니즘이 Java API 뒤에 숨어 있습니다. 이 글은 “왜 그렇게 설계했는가”를 중심으로 InputStream 바이트 계층부터 NIO.2 비동기 채널까지 전 체계를 해부합니다.

비유로 이해하기: java.io는 창고 직원 한 명이 상자 하나씩 들고 뛰는 방식입니다. 배달이 오지 않으면 문 앞에서 멍하니 기다립니다(블로킹). java.nio는 컨베이어 벨트(Buffer) + 고속 통로(Channel) + CCTV(Selector)를 갖춘 물류 센터입니다. CCTV가 어느 통로에 짐이 쌓였는지 감시하므로 직원 한 명이 수천 개 통로를 동시에 관리합니다. java.nio.file은 물류 센터 위에 올라탄 관리 포털 — “이 파일을 저기로 복사해” 한 줄이면 끝납니다.


1. I/O 기본 개념 — 왜 시스템 콜이 비싼가

1.1 User Space vs Kernel Space

I/O 연산은 반드시 OS 커널을 경유합니다. Java 코드(User Space)가 read()를 호출하면 CPU가 User Mode → Kernel Mode로 전환됩니다. 이 컨텍스트 스위치가 수백 나노초 ~ 수 마이크로초를 소모합니다.

// 이 한 줄이 OS 시스템 콜을 발생시킵니다
int b = fileInputStream.read(); // read(2) 시스템 콜 → Kernel이 디스크에서 1바이트 복사
graph LR
    JVM["JVM (User Space)"] -->|"read() syscall"| KERN["Kernel"]
    KERN -->|"DMA transfer"| DISK["Disk"]
    DISK -->|"Page Cache → JVM heap"| JVM

핵심 WHY: 버퍼(Buffer)가 필요한 근본 이유는 시스템 콜 비용을 분할상환(amortize)하기 위해서입니다. 1바이트마다 syscall을 내리면 8KB 파일을 읽는 데 8,192번의 syscall이 발생합니다. 8KB 버퍼로 한 번에 읽으면 syscall이 1번으로 끝납니다.

1.2 스트림(Stream)의 단방향성

스트림은 데이터가 흐르는 단방향 파이프입니다. 수도관처럼 역류가 불가능하기 때문에 읽기와 쓰기는 별개의 스트림을 사용합니다.

graph LR
    SRC["데이터 소스"] -->|"InputStream"| APP["애플리케이션"]
    APP -->|"OutputStream"| DEST["데이터 목적지"]

2. InputStream / OutputStream — 바이트 스트림 계층 구조

2.1 왜 바이트 스트림이 기본인가

컴퓨터의 모든 데이터는 궁극적으로 바이트입니다. 파일, 네트워크 패킷, 메모리 — 어떤 형태든 바이트 배열로 표현됩니다. Java는 바이트 스트림을 최하위 계층으로 두고 그 위에 모든 I/O 추상화를 쌓았습니다.

graph LR
    IS["InputStream"] --> FIS["FileInputStream"]
    IS --> BIS["ByteArrayInputStre"]
    IS --> OIS["ObjectInputStream"]
    OS["OutputStream"] --> FOS["FileOutputStream"]
    OS --> BOS["BufferedOutputStre"]

2.2 InputStream의 핵심 계약

public abstract class InputStream implements Closeable {
    // 핵심 메서드: 1바이트를 0~255 int로 반환, EOF이면 -1
    // 왜 int를 반환하는가? byte는 -128~127이므로 EOF(-1)를 표현할 수 없음
    // int로 반환하면 0~255는 정상 데이터, -1은 EOF
    public abstract int read() throws IOException;

    // 배열로 읽기 — 실제로는 read()를 반복 호출하는 기본 구현
    // 서브클래스에서 재정의해 시스템 콜 1번으로 처리
    public int read(byte[] b) throws IOException {
        return read(b, 0, b.length);
    }

    // available(): 블로킹 없이 읽을 수 있는 바이트 수 추정
    // 주의: 이 값을 신뢰해서 루프를 짜면 안 됨 — 네트워크에서는 항상 0 가능
    public int available() throws IOException { return 0; }

    // Closeable → AutoCloseable: try-with-resources 사용 가능
    public void close() throws IOException {}
}

2.3 FileInputStream 내부 동작

import java.io.*;
import java.nio.file.*;

public class ByteStreamDeepDive {

    public static void main(String[] args) throws IOException {
        // FileInputStream은 OS의 파일 디스크립터(fd)를 보유
        // open(2) 시스템 콜로 fd를 얻은 후 read(2)로 데이터를 읽음
        try (FileInputStream fis = new FileInputStream("data.bin")) {

            // 1바이트씩 읽기 — 절대 하지 말아야 할 방식
            // 10MB 파일이면 10,485,760번의 read(2) 시스템 콜 발생
            int b;
            while ((b = fis.read()) != -1) {
                process(b); // 각 바이트 처리
            }
        }

        // 올바른 방식 — 배열 버퍼로 한 번에 읽기
        try (FileInputStream fis = new FileInputStream("data.bin")) {
            byte[] buffer = new byte[8192]; // 8KB: OS 페이지 크기(4KB)의 2배
            int bytesRead;
            // read(buffer)는 최대 8192바이트를 한 번의 read(2)로 읽음
            // 반환값이 8192보다 작을 수 있음 — 항상 반환값으로 실제 읽은 양 확인
            while ((bytesRead = fis.read(buffer)) != -1) {
                process(buffer, 0, bytesRead);
            }
        }
    }

    // 파일 복사: 바이트 스트림의 실전 패턴
    public static long copyBinary(Path src, Path dst) throws IOException {
        long totalBytes = 0;
        try (FileInputStream in = new FileInputStream(src.toFile());
             FileOutputStream out = new FileOutputStream(dst.toFile())) {

            byte[] buf = new byte[65536]; // 64KB: L1 캐시 크기 고려
            int n;
            while ((n = in.read(buf)) != -1) {
                out.write(buf, 0, n);
                totalBytes += n;
            }
            // FileOutputStream.close()가 flush()를 자동 호출
            // 하지만 명시적 flush()를 권장 — 특히 BufferedOutputStream을 감쌀 때
        }
        return totalBytes;
    }

    private static void process(int b) {}
    private static void process(byte[] b, int off, int len) {}
}

2.4 OutputStream의 핵심 계약

public abstract class OutputStream implements Closeable, Flushable {
    // 1바이트 쓰기 — 하위 8비트만 사용 (int의 상위 24비트 무시)
    public abstract void write(int b) throws IOException;

    // 배열 쓰기
    public void write(byte[] b) throws IOException { write(b, 0, b.length); }
    public void write(byte[] b, int off, int len) throws IOException { /* ... */ }

    // flush(): 버퍼에 남은 데이터를 하위 스트림으로 강제 전송
    // FileOutputStream은 OS 버퍼 → 파일 디스크립터로만 flush
    // 물리 디스크에 쓰려면 FileDescriptor.sync()나 FileChannel.force(true) 필요
    public void flush() throws IOException {}
}

3. Reader / Writer — 문자 스트림이 별도로 존재하는 이유

3.1 왜 문자 스트림이 필요한가 — 인코딩 문제

바이트 스트림은 텍스트 파일을 다룰 때 인코딩 변환을 직접 해야 합니다. ‘A’라는 문자는 ASCII에서 0x41(1바이트)이지만, UTF-16에서는 0x00 0x41(2바이트), 한글 ‘가’는 UTF-8에서 0xEA 0xB0 0x80(3바이트)입니다.

graph LR
    BYTES["바이트 배열"] -->|"Charset.decode()"| CHARS["char 배열"]
    CHARS -->|"Charset.encode()"| BYTES

Reader/Writer는 바이트 ↔ 문자 변환을 내부적으로 처리합니다. 인코딩을 지정하지 않으면 Charset.defaultCharset()을 사용하는데, Windows에서는 MS949, Linux에서는 UTF-8이 될 수 있어 이식성 문제가 발생합니다.

import java.io.*;
import java.nio.charset.*;
import java.nio.file.*;

public class CharStreamDeepDive {

    public static void demonstrateEncodingProblem() throws IOException {
        // 위험한 코드 — 플랫폼 기본 인코딩에 의존
        // Windows: MS949, Linux: UTF-8 → 같은 코드, 다른 동작
        FileReader dangerous = new FileReader("korean.txt"); // 절대 쓰지 말것

        // 올바른 코드 — 항상 인코딩 명시
        FileReader safe = new FileReader("korean.txt", StandardCharsets.UTF_8);

        // InputStreamReader: 바이트 스트림 → 문자 스트림 변환 브릿지
        // 내부에서 StreamDecoder가 Charset을 사용해 바이트→char 변환
        InputStreamReader isr = new InputStreamReader(
            new FileInputStream("korean.txt"),
            StandardCharsets.UTF_8
        );
    }

    // 텍스트 파일 읽기 — 실전 패턴
    public static String readTextFile(Path path) throws IOException {
        StringBuilder sb = new StringBuilder();
        // BufferedReader는 8192 char 버퍼를 유지
        // readLine()은 내부 버퍼에서 '\n' 또는 '\r\n'을 찾아 반환
        // 시스템 콜 없이 메모리에서 처리 → 매우 빠름
        try (BufferedReader br = new BufferedReader(
                new InputStreamReader(
                    new FileInputStream(path.toFile()),
                    StandardCharsets.UTF_8),
                16384)) { // 16KB 버퍼 (기본 8KB의 2배)

            String line;
            while ((line = br.readLine()) != null) {
                sb.append(line).append('\n');
            }
        }
        return sb.toString();
    }

    // 텍스트 파일 쓰기 — 실전 패턴
    public static void writeTextFile(Path path, String content) throws IOException {
        try (BufferedWriter bw = new BufferedWriter(
                new OutputStreamWriter(
                    new FileOutputStream(path.toFile()),
                    StandardCharsets.UTF_8))) {

            bw.write(content);
            bw.newLine(); // OS에 맞는 줄바꿈 (\n 또는 \r\n)
            bw.flush();   // 버퍼 강제 비우기
        }
    }
}

3.2 Reader / Writer 계층 구조

graph LR
    RD["Reader"] --> BR["BufferedReader"]
    RD --> ISR["InputStreamReader"]
    WR["Writer"] --> BW["BufferedWriter"]
    WR --> OSW["OutputStreamWriter"]

InputStreamReaderStreamDecoderCharset.decode()ByteBufferCharBuffer의 파이프라인으로 바이트가 문자로 변환됩니다.


4. BufferedStream — 왜 버퍼 크기가 8192인가

4.1 8192(8KB)의 근거

Java BufferedInputStream/BufferedReader의 기본 버퍼 크기는 8192바이트입니다. 이 숫자의 근거는 다음과 같습니다.

  1. OS 페이지 크기: 대부분의 OS는 4KB 페이지를 사용합니다. 8KB는 2페이지로 DMA 전송 단위와 정렬됩니다.
  2. L1 캐시: 대부분의 CPU L1 캐시는 32~64KB입니다. 8KB 버퍼는 다른 데이터와 함께 L1에 안전하게 올라갑니다.
  3. 실측 기반: Java 초기 개발팀이 실측한 최적 지점이 8KB 근방이었습니다.
import java.io.*;

public class BufferedStreamInternals {

    // BufferedInputStream 내부 동작을 시뮬레이션
    public static void demonstrateBuffer() throws IOException {
        // 기본 버퍼 8192바이트
        // 내부적으로 byte[] buf = new byte[8192] 보유
        // fill()이 호출될 때 underlying stream에서 최대 8192바이트를 한 번에 읽어 buf에 채움
        try (BufferedInputStream bis = new BufferedInputStream(
                new FileInputStream("large_file.bin"))) {

            // 첫 번째 read() 호출 시:
            // 1. buf가 비어있음 → fill() 호출
            // 2. FileInputStream.read(buf, 0, 8192) → read(2) syscall 1번
            // 3. buf[0..n-1]에 데이터 채워짐
            // 4. buf[0] 반환, count=n, pos=1

            // 두 번째 read() 호출 시:
            // 1. pos < count → buf[1] 반환, pos=2
            // (syscall 없음! 메모리에서 바로 반환)
            int b1 = bis.read(); // syscall 발생
            int b2 = bis.read(); // syscall 없음, 버퍼에서 반환
        }

        // flush()의 의미: BufferedOutputStream에서 중요
        try (BufferedOutputStream bos = new BufferedOutputStream(
                new FileOutputStream("output.bin"))) {
            // write()는 내부 버퍼에만 씀 — 파일에는 아직 반영 안 됨
            bos.write(new byte[100]);

            // 버퍼가 꽉 차거나(8192바이트), flush() 호출 시
            // write(2) syscall로 실제 파일에 씀
            bos.flush(); // 중간 결과를 즉시 반영해야 할 때 명시적 호출

            // close()는 flush() 후 fd를 닫음
        } // close() → flush() → write(2) syscall → close(2) syscall
    }

    // mark/reset: 되돌아가기 기능
    public static void demonstrateMarkReset() throws IOException {
        try (BufferedInputStream bis = new BufferedInputStream(
                new FileInputStream("data.bin"), 1024)) {

            bis.mark(256); // 최대 256바이트까지 되감기 지원

            byte[] preview = new byte[16];
            bis.read(preview); // 16바이트 미리보기

            // 매직 바이트 확인 (파일 시그니처)
            if (preview[0] == (byte)0xFF && preview[1] == (byte)0xD8) {
                System.out.println("JPEG 파일");
            }

            bis.reset(); // 읽기 위치를 mark() 지점으로 되돌림
            // 이제 처음부터 다시 읽음
        }
    }
}

4.2 DataInputStream / DataOutputStream — 기본형 바이너리 직렬화

import java.io.*;

public class DataStreamExample {

    // 빅 엔디언(Big-Endian)으로 기본형을 바이너리에 씀
    // WHY 빅 엔디언: Java는 플랫폼 독립성을 위해 네트워크 바이트 순서(빅 엔디언)를 선택
    // CPU는 보통 리틀 엔디언(Intel x86)이지만 Java는 추상화로 숨김
    public static void writeBinary(String filePath) throws IOException {
        try (DataOutputStream dos = new DataOutputStream(
                new BufferedOutputStream(new FileOutputStream(filePath)))) {

            dos.writeInt(0x12345678);    // 4바이트: 0x12, 0x34, 0x56, 0x78 순서로 저장
            dos.writeLong(System.nanoTime());  // 8바이트 빅 엔디언
            dos.writeDouble(Math.PI);    // IEEE 754 64-bit double, 빅 엔디언
            dos.writeBoolean(true);      // 1바이트: 0x01
            dos.writeUTF("안녕하세요"); // 변형 UTF-8: 앞에 2바이트 길이 + 데이터
            // writeUTF의 변형 UTF-8은 표준 UTF-8과 다름 — null 문자(U+0000)를 다르게 인코딩
            // 따라서 writeUTF로 쓴 데이터는 readUTF로만 읽어야 함
        }
    }

    public static void readBinary(String filePath) throws IOException {
        try (DataInputStream dis = new DataInputStream(
                new BufferedInputStream(new FileInputStream(filePath)))) {

            int magic = dis.readInt();       // 쓴 순서와 동일하게 읽어야 함
            long timestamp = dis.readLong();
            double pi = dis.readDouble();
            boolean flag = dis.readBoolean();
            String text = dis.readUTF();

            System.out.printf("magic=0x%X, ts=%d, pi=%.5f, flag=%b, text=%s%n",
                magic, timestamp, pi, flag, text);
        }
    }
}

5. NIO — Channel + Buffer + Selector

5.1 왜 NIO가 등장했는가

마치 콜센터처럼 생각해 보세요. java.io 방식은 고객 1명당 직원 1명이 붙어서(스레드 1:1) 통화가 끝날 때까지 대기합니다. NIO의 Selector 방식은 한 직원이 헤드셋을 여러 개 끼고 누군가 말할 때만 반응합니다. 10만 고객에게 java.io 방식은 직원 10만 명이, NIO 방식은 직원 몇 명으로도 충분합니다.

java.io의 근본적 한계는 두 가지입니다.

첫째, 블로킹(Blocking) 문제: read()를 호출한 스레드는 데이터가 올 때까지 멈춥니다. 클라이언트 10만 개를 동시에 처리하려면 스레드 10만 개가 필요한데, JVM 스레드는 OS 스레드와 1:1 매핑되고 스레드당 기본 1MB 스택을 사용하므로 10만 스레드 = 100GB 메모리가 필요합니다.

둘째, 복사 오버헤드: 파일 데이터가 디스크 → 커널 버퍼 → JVM 힙 으로 두 번 복사됩니다. 대용량 파일 전송 시 메모리 대역폭을 낭비합니다.

NIO는 Channel + Buffer 모델로 이 두 문제를 해결합니다.

5.2 ByteBuffer — 핵심 상태 머신

ByteBuffer는 capacity, limit, position, mark 네 개의 포인터로 상태를 관리합니다. 이 상태 머신을 이해하지 못하면 NIO 버그의 절반이 발생합니다.

graph LR
    WRITE["쓰기 모드"] -->|"flip()"| READ["읽기 모드"]
    READ -->|"clear()"| WRITE
    READ -->|"compact()"| WRITE
    READ -->|"rewind()"| READ
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;

public class ByteBufferDeepDive {

    public static void demonstrateStateMachine() {
        // allocate: JVM 힙에 버퍼 할당
        // capacity=10, limit=10, position=0, mark=-1
        ByteBuffer buf = ByteBuffer.allocate(10);

        System.out.println("초기: pos=" + buf.position() + " lim=" + buf.limit()); // 0, 10

        // 쓰기: position 증가
        buf.put((byte) 'H'); // pos=1
        buf.put((byte) 'i'); // pos=2
        buf.putInt(42);      // pos=6 (int = 4바이트)

        System.out.println("쓰기후: pos=" + buf.position()); // 6

        // flip(): 쓰기 모드 → 읽기 모드 전환
        // limit = position (쓴 마지막 위치)
        // position = 0 (처음부터 읽기)
        // 이걸 빠뜨리면 hasRemaining()이 false → 아무것도 읽지 못함
        buf.flip();
        System.out.println("flip후: pos=" + buf.position() + " lim=" + buf.limit()); // 0, 6

        // 읽기
        byte h = buf.get();   // 'H', pos=1
        byte i = buf.get();   // 'i', pos=2
        int num = buf.getInt(); // 42, pos=6

        System.out.println("읽기후: pos=" + buf.position()); // 6

        // clear(): 완전 초기화 — 실제 데이터는 지우지 않음, 포인터만 리셋
        buf.clear(); // pos=0, limit=capacity=10
        System.out.println("clear후: pos=" + buf.position() + " lim=" + buf.limit()); // 0, 10

        // compact(): 읽지 않은 데이터를 앞으로 이동 후 쓰기 모드로 전환
        // 부분 읽기 후 추가 데이터를 이어서 쓸 때 사용
        ByteBuffer buf2 = ByteBuffer.allocate(10);
        buf2.put(new byte[]{1, 2, 3, 4, 5});
        buf2.flip();
        buf2.get(); buf2.get(); buf2.get(); // 3개 읽음, 2개 남음
        // compact(): [4, 5, X, X, X, X, X, X, X, X], pos=2, limit=10
        buf2.compact(); // 안 읽은 [4,5]를 앞으로 복사, pos=2, limit=10
        buf2.put((byte) 6); // 이어서 쓰기 가능
    }

    // 절대 주소(absolute) vs 상대 주소(relative) 접근
    public static void demonstrateAbsoluteAccess() {
        ByteBuffer buf = ByteBuffer.allocate(20);

        // 상대 접근: position이 증가
        buf.putInt(100);  // position: 0→4
        buf.putInt(200);  // position: 4→8

        // 절대 접근: position 변경 없음
        buf.putInt(0, 999);   // 오프셋 0에 999 저장, position 변화 없음
        buf.putInt(4, 888);   // 오프셋 4에 888 저장

        System.out.println("절대 읽기: " + buf.getInt(0)); // 999
        System.out.println("position: " + buf.position());  // 8 (변화 없음)
    }
}

5.3 Heap Buffer vs Direct Buffer — 언제 무엇을 쓰는가

public class BufferTypeComparison {

    public static void compareBufferTypes() throws IOException {
        // 힙 버퍼 (Heap Buffer)
        // 장점: GC가 관리, 생성 빠름, 디버깅 용이
        // 단점: I/O 시 OS가 JVM 힙을 직접 읽을 수 없음
        //       → 내부적으로 임시 다이렉트 버퍼로 복사 후 I/O
        ByteBuffer heapBuf = ByteBuffer.allocate(4096);

        // 다이렉트 버퍼 (Direct Buffer)
        // 장점: OS 네이티브 메모리에 직접 할당 → I/O 시 복사 없음 (zero-copy)
        //       네트워크 전송, 대용량 파일 I/O에서 힙 버퍼보다 최대 2배 빠름
        // 단점: GC 대상 아님 → 해제가 늦어질 수 있음 (메모리 누수 주의)
        //       생성 비용이 큼 → 재사용 필수 (풀링 권장)
        //       ByteBuffer.allocateDirect()는 Cleaner를 통해 GC 시 해제됨
        ByteBuffer directBuf = ByteBuffer.allocateDirect(4096);

        // WHY: FileChannel.read(heapBuf) 내부 코드
        // if (dst.isDirect()) {
        //     return IOUtil.read(fd, dst, position, nd);  // 직접 읽기
        // } else {
        //     ByteBuffer tmp = Util.getTemporaryDirectBuffer(dst.remaining()); // 임시 버퍼 생성
        //     IOUtil.read(fd, tmp, position, nd); // 네이티브 버퍼에 읽음
        //     tmp.flip();
        //     dst.put(tmp);                        // 힙 버퍼로 복사
        //     Util.releaseTemporaryDirectBuffer(tmp);
        // }

        // 실전: I/O 핫패스에는 Direct, 로직 처리에는 Heap 사용
        try (FileChannel fc = FileChannel.open(Path.of("data.bin"), StandardOpenOption.READ)) {
            ByteBuffer direct = ByteBuffer.allocateDirect(65536); // 64KB, 재사용

            while (fc.read(direct) != -1) {
                direct.flip();
                // 처리 로직에서는 Heap 버퍼로 변환 (절대 주소 접근 편의)
                byte[] arr = new byte[direct.remaining()];
                direct.get(arr);
                processData(arr);
                direct.clear();
            }
        }
    }

    private static void processData(byte[] data) {}
}

5.4 FileChannel — zero-copy와 memory-mapped file

import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;

public class FileChannelDeepDive {

    // transferTo: 진정한 zero-copy
    // WHY: 일반 파일 전송은 4단계 복사
    //   1. 디스크 → 커널 읽기 버퍼 (DMA)
    //   2. 커널 읽기 버퍼 → JVM 힙 (CPU 복사)
    //   3. JVM 힙 → 커널 쓰기 버퍼 (CPU 복사)
    //   4. 커널 쓰기 버퍼 → 소켓/파일 (DMA)
    // transferTo는 Linux sendfile(2) / splice(2) syscall 사용:
    //   1. 디스크 → 커널 페이지 캐시 (DMA)
    //   2. 커널 페이지 캐시 → 소켓 버퍼 (CPU 복사 or DMA, OS에 따라)
    // JVM 힙을 거치지 않아 CPU 복사 1~2회 절감
    public static void zeroCopyTransfer(Path srcFile, SocketChannel socket) throws IOException {
        try (FileChannel fc = FileChannel.open(srcFile, StandardOpenOption.READ)) {
            long size = fc.size();
            long transferred = 0;

            // transferTo는 한 번에 전송을 보장하지 않음 (네트워크 버퍼 제한)
            // 루프로 완전 전송 보장
            while (transferred < size) {
                long n = fc.transferTo(transferred, size - transferred, socket);
                if (n == 0) break; // 소켓 버퍼가 꽉 찬 경우
                transferred += n;
            }
        }
    }

    // FileChannel 간 파일 복사 (transferTo)
    public static void fileCopyZeroCopy(Path src, Path dst) throws IOException {
        try (FileChannel in = FileChannel.open(src, StandardOpenOption.READ);
             FileChannel out = FileChannel.open(dst,
                 StandardOpenOption.CREATE,
                 StandardOpenOption.WRITE,
                 StandardOpenOption.TRUNCATE_EXISTING)) {

            long size = in.size();
            long copied = 0;
            while (copied < size) {
                // transferTo: 일반적으로 2GB 이하 단위로 잘라서 호출해야 함
                copied += in.transferTo(copied, size - copied, out);
            }
        }
    }

    // Memory-Mapped File: OS 가상 메모리에 파일 매핑
    // WHY: 대용량 파일을 모두 메모리에 올리지 않고 필요한 페이지만 on-demand로 로드
    // mmap(2) syscall: 파일을 프로세스 주소 공간에 직접 매핑
    // 읽기: 매핑된 주소 접근 → 페이지 폴트 → OS가 디스크에서 페이지 로드 → 재시작
    // 쓰기: 매핑된 주소에 쓰기 → dirty 페이지 → OS가 백그라운드로 파일에 동기화
    public static void memoryMappedRead(Path filePath) throws IOException {
        try (FileChannel fc = FileChannel.open(filePath, StandardOpenOption.READ)) {
            long fileSize = fc.size();

            // 주의: 파일 전체를 한 번에 매핑하면 32비트 JVM에서는 불가 (주소 공간 부족)
            // 64비트 JVM에서도 파일 크기가 가상 메모리보다 작아야 함
            // 실제 물리 메모리 사용량은 접근한 페이지만큼만 증가
            MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fileSize);

            // mbb는 DirectByteBuffer — OS 페이지 캐시를 직접 참조
            // get() 호출이 단순 메모리 읽기와 동일한 속도
            mbb.load(); // 선택적: OS에 전체 파일을 페이지 캐시에 미리 로드 요청

            // 고정 크기 레코드 직접 접근
            int recordSize = 16; // 각 레코드: int(4) + long(8) + int(4)
            long recordCount = fileSize / recordSize;

            for (int i = 0; i < recordCount; i++) {
                int id = mbb.getInt(i * recordSize);          // 오프셋 직접 접근
                long timestamp = mbb.getLong(i * recordSize + 4);
                int value = mbb.getInt(i * recordSize + 12);
                processRecord(id, timestamp, value);
            }
        }
        // 주의: MappedByteBuffer는 GC로 해제됨
        // 파일 삭제나 재오픈이 필요하면 sun.misc.Cleaner를 사용한 명시적 해제 필요 (트릭)
    }

    // MappedByteBuffer 읽기/쓰기
    public static void memoryMappedWrite(Path filePath) throws IOException {
        try (FileChannel fc = FileChannel.open(filePath,
                StandardOpenOption.READ, StandardOpenOption.WRITE)) {

            MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, 0, 4096);

            mbb.putInt(0, 42);          // 오프셋 0에 int 쓰기 (포인터 변화 없음)
            mbb.putLong(4, System.nanoTime());
            mbb.put(12, (byte) 0xFF);

            // force(): 변경사항을 파일에 강제 동기화 (msync syscall)
            // 호출하지 않으면 OS가 적절한 시점에 자동 동기화
            // 크래시 복구가 중요한 경우 force() 필수
            mbb.force();
        }
    }

    // 2GB+ 대용량 파일: 청크 단위 매핑
    public static void largeFileMappedProcessing(Path filePath) throws IOException {
        try (FileChannel fc = FileChannel.open(filePath, StandardOpenOption.READ)) {
            long fileSize = fc.size();
            long chunkSize = 512L * 1024 * 1024; // 512MB 청크

            for (long offset = 0; offset < fileSize; offset += chunkSize) {
                long size = Math.min(chunkSize, fileSize - offset);
                MappedByteBuffer chunk = fc.map(FileChannel.MapMode.READ_ONLY, offset, size);
                processChunk(chunk);
                // chunk는 GC 대상이 되지만 명시적 해제가 어려움
                // DirectByteBuffer의 Cleaner를 리플렉션으로 호출하는 트릭 존재
            }
        }
    }

    private static void processRecord(int id, long ts, int val) {}
    private static void processChunk(MappedByteBuffer buf) {}
}

5.5 Selector — epoll 매핑과 논블로킹 I/O

import java.io.*;
import java.net.*;
import java.nio.*;
import java.nio.channels.*;
import java.util.*;

public class SelectorDeepDive {

    // WHY Selector가 필요한가:
    // 블로킹 모델: 클라이언트마다 스레드 1개 → 10만 연결 = 10만 스레드 (불가능)
    // Selector 모델: OS의 I/O 멀티플렉싱 활용
    //   Linux: epoll (epoll_create1, epoll_ctl, epoll_wait)
    //   macOS: kqueue
    //   Windows: IOCP (Windows NIO는 내부적으로 select 사용, 성능 제한)
    // epoll: O(1) 이벤트 감지, 수십만 fd 등록 가능
    // select(2): O(n) fd 탐색, 최대 1024 fd 제한 (epoll 이전 방식)

    public static void echoServer(int port) throws IOException {
        // Selector.open(): OS에 epoll 인스턴스 생성
        Selector selector = Selector.open();

        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.configureBlocking(false); // 논블로킹 모드 — 필수
        serverChannel.bind(new InetSocketAddress(port));

        // register: epoll_ctl(ADD) — 서버 채널을 epoll에 등록
        // SelectionKey.OP_ACCEPT: 클라이언트 연결 수락 이벤트
        // 다른 이벤트: OP_READ(0x1), OP_WRITE(0x4), OP_CONNECT(0x8), OP_ACCEPT(0x10)
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        System.out.println("서버 시작: " + port);

        while (true) {
            // select(): epoll_wait syscall — I/O 이벤트가 발생할 때까지 블로킹
            // 반환값: 준비된 채널 수
            // select(0): 논블로킹 즉시 반환
            // select(timeout): 타임아웃 지정
            int ready = selector.select(1000); // 1초 타임아웃

            if (ready == 0) {
                // 타임아웃 — 주기적 작업 수행 기회
                System.out.print("."); // 하트비트
                continue;
            }

            // selectedKeys(): 이벤트가 발생한 채널들의 SelectionKey 집합
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> iter = keys.iterator();

            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                // 중요: 처리한 키는 반드시 remove() — 자동으로 제거되지 않음
                // remove() 누락 시 다음 select()에서 같은 키가 또 반환됨
                iter.remove();

                if (!key.isValid()) continue;

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

    private static void handleAccept(SelectionKey key, Selector selector) throws IOException {
        ServerSocketChannel server = (ServerSocketChannel) key.channel();
        SocketChannel client = server.accept(); // 논블로킹: 즉시 반환 (연결 없으면 null)
        if (client == null) return;

        client.configureBlocking(false);

        // 클라이언트 채널을 Selector에 등록 — OP_READ 이벤트 감시
        // SelectionKey에 attachment 저장 가능 (클라이언트 상태 객체 등)
        ByteBuffer buf = ByteBuffer.allocateDirect(1024);
        client.register(selector, SelectionKey.OP_READ, buf);

        System.out.println("클라이언트 연결: " + client.getRemoteAddress());
    }

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

        buf.clear();
        int bytesRead = client.read(buf); // 논블로킹: 현재 사용 가능한 데이터만 읽음

        if (bytesRead == -1) {
            // 클라이언트가 연결을 닫음
            client.close();
            key.cancel(); // epoll_ctl(DEL)
            return;
        }

        buf.flip();
        // 에코: 읽은 데이터를 그대로 쓰기
        // 쓰기 버퍼가 꽉 차면 일부만 쓸 수 있으므로 OP_WRITE 등록
        if (buf.hasRemaining()) {
            // 쓰기 버퍼가 준비될 때 OP_WRITE 이벤트 받기
            key.interestOps(SelectionKey.OP_WRITE);
        }
    }

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

        client.write(buf); // 논블로킹: 소켓 버퍼에 쓸 수 있는 만큼만 씀

        if (!buf.hasRemaining()) {
            // 쓰기 완료 — OP_WRITE 해제 (쓰기 준비 이벤트는 항상 true이므로 해제 필수)
            key.interestOps(SelectionKey.OP_READ);
        }
    }
}

6. Path / Files API — Java 7+ NIO.2

6.1 왜 File 클래스를 버리고 Path를 도입했는가

java.io.File의 문제점은 설계 결함이 많았습니다.

  • File.delete()가 실패해도 false만 반환하고 이유를 알 수 없음
  • File.list()가 오류 시 null 반환 (NullPointerException 유발)
  • 심볼릭 링크 지원 없음
  • 임시 파일이 JVM 종료 시 삭제 보장 안 됨
  • 파일 이동이 원자적이지 않음

Path + Files는 이 모든 문제를 해결합니다.

import java.io.*;
import java.nio.charset.*;
import java.nio.file.*;
import java.nio.file.attribute.*;
import java.util.stream.*;

public class NIO2DeepDive {

    public static void pathOperations() throws IOException {
        // Path는 인터페이스 — 실제 구현은 OS에 따라 다름
        // Windows: WindowsPath, Unix: UnixPath
        Path p = Path.of("/home/user/documents/file.txt"); // Java 11+
        // Java 7~10: Paths.get("/home/user/documents/file.txt")

        // 경로 분해
        System.out.println(p.getFileName()); // file.txt (Path 타입)
        System.out.println(p.getParent());   // /home/user/documents
        System.out.println(p.getRoot());     // / (Unix) or C:\ (Windows)
        System.out.println(p.getNameCount()); // 4 (home, user, documents, file.txt)
        System.out.println(p.getName(2));    // documents

        // 경로 조합
        Path base = Path.of("/home/user");
        Path full = base.resolve("documents/file.txt"); // /home/user/documents/file.txt
        Path rel = base.relativize(full);               // documents/file.txt

        // 정규화: .. 과 . 제거
        Path messy = Path.of("/home/user/../user/./docs");
        System.out.println(messy.normalize()); // /home/user/docs

        // 실제 파일시스템 경로로 변환 (심볼릭 링크 해소)
        // Path.toRealPath(): 파일이 존재해야 함
        // Path.toAbsolutePath(): 현재 디렉토리 기준으로 절대 경로만 변환
        try {
            Path real = p.toRealPath(LinkOption.NOFOLLOW_LINKS); // 심볼릭 링크 유지
        } catch (NoSuchFileException e) {
            System.out.println("파일 없음");
        }
    }

    public static void filesOperations() throws IOException {
        Path path = Path.of("example.txt");

        // 파일 읽기 — 크기별 선택 기준
        // < 10MB: readString 또는 readAllBytes (간결함)
        // 10MB~수GB: lines() 스트림 (메모리 절약)
        // 수GB+: FileChannel + MappedByteBuffer

        // Java 11+: 가장 간결
        String content = Files.readString(path, StandardCharsets.UTF_8);

        // 전통적 방식: 바이트 배열로
        byte[] bytes = Files.readAllBytes(path);

        // 대용량 처리: 스트림 — 파일을 한 줄씩 처리, 메모리 O(1)
        try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
            long errorCount = lines
                .filter(line -> line.startsWith("ERROR"))
                .count();
            System.out.println("에러 수: " + errorCount);
        } // 스트림 닫히면 파일 핸들도 자동 해제

        // 파일 쓰기
        Files.writeString(path, "내용", StandardCharsets.UTF_8,
            StandardOpenOption.CREATE,
            StandardOpenOption.APPEND);

        // BufferedWriter로 제어력 있는 쓰기
        try (BufferedWriter bw = Files.newBufferedWriter(path, StandardCharsets.UTF_8,
                StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
            bw.write("첫 번째 줄");
            bw.newLine();
            bw.write("두 번째 줄");
        }
    }

    public static void fileOperations() throws IOException {
        Path src = Path.of("source.txt");
        Path dst = Path.of("dest.txt");

        // 복사: 실패 시 IOException, 이유 포함 (File.copy와 다름)
        Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING,
                              StandardCopyOption.COPY_ATTRIBUTES);

        // 이동: ATOMIC_MOVE → rename(2) syscall (같은 파티션이면 원자적)
        // 다른 파티션이면 ATOMIC_MOVE 불가 → AtomicMoveNotSupportedException
        Files.move(src, dst, StandardCopyOption.ATOMIC_MOVE);

        // 삭제
        Files.delete(path()); // 없으면 NoSuchFileException (원인 명확)
        Files.deleteIfExists(path()); // 없어도 OK

        // 속성 조회
        BasicFileAttributes attrs = Files.readAttributes(src, BasicFileAttributes.class);
        System.out.println("크기: " + attrs.size());
        System.out.println("생성: " + attrs.creationTime());
        System.out.println("수정: " + attrs.lastModifiedTime());
        System.out.println("디렉토리: " + attrs.isDirectory());
        System.out.println("심볼릭링크: " + attrs.isSymbolicLink());
    }

    public static void directoryWalk() throws IOException {
        Path dir = Path.of("/home/user/projects");

        // list(): 1레벨만 (ls -1)
        try (Stream<Path> entries = Files.list(dir)) {
            entries.forEach(System.out::println);
        }

        // walk(): 재귀 탐색 (깊이 제한 가능)
        try (Stream<Path> walk = Files.walk(dir, 5)) { // 최대 5레벨
            walk.filter(p -> p.toString().endsWith(".java"))
                .sorted()
                .forEach(System.out::println);
        }

        // find(): 속성 필터링
        try (Stream<Path> found = Files.find(dir, Integer.MAX_VALUE,
                (p, attrs) -> attrs.isRegularFile()
                    && attrs.size() > 1_000_000 // 1MB 이상
                    && p.toString().endsWith(".log"))) {
            found.forEach(System.out::println);
        }

        // SimpleFileVisitor: 정교한 제어 (방문 전/후 콜백, 오류 처리)
        Files.walkFileTree(dir, new SimpleFileVisitor<>() {
            @Override
            public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes attrs) {
                if (d.getFileName().toString().equals(".git")) {
                    return FileVisitResult.SKIP_SUBTREE; // .git 디렉토리 건너뜀
                }
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
                System.out.println(file + " (" + attrs.size() + " bytes)");
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult visitFileFailed(Path file, IOException exc) {
                System.err.println("접근 실패: " + file + " - " + exc.getMessage());
                return FileVisitResult.CONTINUE; // 실패해도 계속 (File 클래스는 이게 불가능했음)
            }
        });
    }

    private static Path path() { return Path.of("example.txt"); }
}

6.2 WatchService — 파일시스템 이벤트 감시

import java.io.*;
import java.nio.file.*;

public class WatchServiceExample {

    // WatchService: OS의 파일 시스템 이벤트 알림 API 활용
    // Linux: inotify (inotify_init, inotify_add_watch, read)
    // macOS: FSEvents 또는 kqueue
    // Windows: ReadDirectoryChangesW
    // Java 코드가 inotify와 FSEvents를 추상화함
    public static void watchDirectory(String dirPath) throws IOException, InterruptedException {
        WatchService watcher = FileSystems.getDefault().newWatchService();
        Path dir = Path.of(dirPath);

        // inotify_add_watch: 감시 이벤트 등록
        WatchKey key = dir.register(watcher,
            StandardWatchEventKinds.ENTRY_CREATE,  // 파일 생성
            StandardWatchEventKinds.ENTRY_DELETE,  // 파일 삭제
            StandardWatchEventKinds.ENTRY_MODIFY,  // 파일 수정
            StandardWatchEventKinds.OVERFLOW        // 이벤트 유실 (OS 큐 오버플로우)
        );

        System.out.println("감시 시작: " + dir);

        while (true) {
            // take(): 이벤트 큐에서 WatchKey 꺼내기 — 없으면 블로킹
            // poll(): 즉시 반환 (없으면 null)
            // poll(timeout, unit): 타임아웃
            WatchKey detected = watcher.take();

            for (WatchEvent<?> event : detected.pollEvents()) {
                WatchEvent.Kind<?> kind = event.kind();

                if (kind == StandardWatchEventKinds.OVERFLOW) {
                    System.err.println("이벤트 유실 발생!");
                    continue;
                }

                @SuppressWarnings("unchecked")
                WatchEvent<Path> pathEvent = (WatchEvent<Path>) event;
                Path filename = dir.resolve(pathEvent.context());

                System.out.printf("[%s] %s (count=%d)%n",
                    kind.name(), filename, event.count());
                // event.count(): ENTRY_MODIFY에서 수정 횟수 (압축되어 1보다 클 수 있음)
            }

            // reset() 필수: false 반환 시 디렉토리가 삭제되었거나 접근 불가
            if (!detected.reset()) {
                System.out.println("감시 종료 (디렉토리 삭제됨)");
                break;
            }
        }
    }
}

7. try-with-resources — AutoCloseable과 Suppressed Exception

7.1 왜 finally 블록으로는 부족한가

// 전통적 방식의 문제: finally에서 예외가 발생하면 try의 예외가 사라짐
public static void traditionalProblem() throws IOException {
    FileInputStream fis = null;
    try {
        fis = new FileInputStream("file.txt");
        throw new IOException("본문 예외"); // 이 예외가
    } finally {
        if (fis != null) {
            // close()에서 예외가 발생하면 본문 예외가 덮어씌워짐!
            fis.close(); // IOException 발생 시 "본문 예외"는 완전히 사라짐
        }
    }
}

7.2 try-with-resources의 내부 동작

import java.io.*;

public class TryWithResourcesDeepDive {

    // try-with-resources 컴파일 후 바이트코드에서 실제로는 이렇게 동작
    // (Java 컴파일러가 자동 생성)
    public static void compiledEquivalent() throws IOException {
        FileInputStream primaryException = null;
        FileInputStream fis = null;
        try {
            fis = new FileInputStream("file.txt");
            try {
                // 본문
                int data = fis.read();
            } catch (Throwable t) {
                primaryException = null; // 사실은 t를 저장
                try {
                    fis.close();
                } catch (Throwable suppressed) {
                    // 핵심: 본문 예외를 주 예외로 유지하고
                    // close()의 예외를 억제된 예외로 첨부
                    t.addSuppressed(suppressed); // Throwable.addSuppressed()
                }
                throw t; // 본문 예외를 다시 throw
            }
            fis.close(); // 예외 없는 정상 종료
        } catch (Throwable t) {
            throw new IOException(t);
        }
    }

    // 실전: 여러 자원의 close 순서
    public static void multipleResources() throws IOException {
        // 자원은 선언 역순으로 close() 호출됨
        // 선언: A, B, C → close: C.close(), B.close(), A.close()
        try (FileInputStream a = new FileInputStream("a.txt");    // 마지막에 close
             BufferedInputStream b = new BufferedInputStream(a);  // 두 번째
             DataInputStream c = new DataInputStream(b)) {        // 첫 번째 close
            int data = c.readInt();
        }
        // c.close() → b.close() → a.close() 순서
        // b.close()는 내부적으로 a.close()를 다시 호출하지만 안전
    }

    // Suppressed Exception 확인
    public static void suppressedExceptionExample() {
        class BrokenResource implements AutoCloseable {
            private final String name;
            BrokenResource(String name) { this.name = name; }

            public void doWork() throws Exception {
                throw new Exception(name + ": 작업 예외");
            }

            @Override
            public void close() throws Exception {
                throw new Exception(name + ": close 예외");
            }
        }

        try (BrokenResource r = new BrokenResource("R1")) {
            r.doWork(); // 이 예외가 주 예외
        } catch (Exception e) {
            System.out.println("주 예외: " + e.getMessage()); // "R1: 작업 예외"

            // close()의 예외는 억제되어 getSuppressed()로 확인
            for (Throwable suppressed : e.getSuppressed()) {
                System.out.println("억제: " + suppressed.getMessage()); // "R1: close 예외"
            }
        }
    }

    // AutoCloseable vs Closeable
    // Closeable: throws IOException (구체적 예외)
    // AutoCloseable: throws Exception (더 넓은 예외 — 비 I/O 자원에 사용)
    public static class ManagedTransaction implements AutoCloseable {
        private boolean committed = false;

        public void commit() {
            committed = true;
            System.out.println("트랜잭션 커밋");
        }

        @Override
        public void close() throws Exception {
            if (!committed) {
                System.out.println("트랜잭션 롤백"); // 예외 없이 성공하면 커밋, 아니면 자동 롤백
            }
        }
    }

    public static void transactionPattern() throws Exception {
        try (ManagedTransaction tx = new ManagedTransaction()) {
            // 작업 수행
            processData();
            tx.commit(); // 성공하면 commit 호출
        } // 예외 발생 시 close()에서 자동 롤백
    }

    private static void processData() {}
}

8. 직렬화 — ObjectOutputStream, serialVersionUID, Externalizable

8.1 직렬화 내부 프로토콜

graph LR
    OBJ["Java Object"] -->|"ObjectOutputStream"| PROTO["직렬화 프로토콜"]
    PROTO -->|"바이트 스트림"| STORE["파일/네트워크"]
    STORE -->|"ObjectInputStream"| OBJ2["복원 Object"]

직렬화 스트림의 구조는 정해진 바이너리 프로토콜을 따릅니다:

  • 매직 헤더: 0xACED 0x0005 (STREAM_MAGIC + STREAM_VERSION)
  • 클래스 디스크립터: 클래스 이름, serialVersionUID, 필드 목록
  • 인스턴스 데이터: 각 필드 값
import java.io.*;
import java.util.*;

public class SerializationDeepDive {

    // WHY serialVersionUID:
    // 직렬화 프로토콜은 클래스 구조가 같은지 확인하기 위해 UID를 사용
    // 명시하지 않으면 JVM이 클래스 이름, 필드, 메서드 시그니처를 SHA-1로 해시해 자동 생성
    // 필드 하나만 추가해도 해시값이 바뀜 → 기존 직렬화 데이터 읽기 실패
    // InvalidClassException: local class incompatible: stream classdesc serialVersionUID = X,
    //                          local class serialVersionUID = Y
    public static class Employee implements Serializable {
        private static final long serialVersionUID = 1L; // 명시적 선언 필수

        private String name;
        private int age;
        private String department;

        // transient: 직렬화 제외
        // WHY: 보안(비밀번호), 재생성 가능한 값(계산 결과), 직렬화 불가 타입(Thread)
        private transient String sessionToken; // 직렬화 제외
        private transient Thread workerThread; // Thread는 Serializable 아님 → transient 필수

        // static: 인스턴스 상태 아님 → 직렬화 안 됨
        private static String company = "Example Corp";

        public Employee(String name, int age, String dept) {
            this.name = name;
            this.age = age;
            this.department = dept;
            this.sessionToken = UUID.randomUUID().toString();
        }

        @Override
        public String toString() {
            return String.format("Employee{name=%s, age=%d, dept=%s, token=%s}",
                name, age, department, sessionToken);
        }
    }

    public static void basicSerialize() throws IOException, ClassNotFoundException {
        Employee emp = new Employee("김자바", 35, "개발팀");
        System.out.println("직렬화 전: " + emp);

        // 직렬화
        try (ObjectOutputStream oos = new ObjectOutputStream(
                new BufferedOutputStream(new FileOutputStream("employee.ser")))) {
            // 매직 헤더 + 클래스 디스크립터 + 필드 값 순서로 씀
            oos.writeObject(emp);
            oos.flush();
        }

        // 역직렬화
        try (ObjectInputStream ois = new ObjectInputStream(
                new BufferedInputStream(new FileInputStream("employee.ser")))) {
            // readObject()는 클래스의 기본 생성자를 호출하지 않음!
            // 직렬화된 바이트에서 직접 필드를 복원
            // transient 필드는 기본값(null, 0, false)으로 초기화
            Employee restored = (Employee) ois.readObject();
            System.out.println("역직렬화 후: " + restored);
            // transient sessionToken은 null
        }
    }
}

8.2 커스텀 직렬화 — writeObject / readObject

import java.io.*;
import java.security.*;
import java.util.Base64;

public class CustomSerialization implements Serializable {
    private static final long serialVersionUID = 2L;

    private String username;
    private transient char[] password; // char[]를 사용하면 메모리에서 명시적 제거 가능

    public CustomSerialization(String username, char[] password) {
        this.username = username;
        this.password = password;
    }

    // JVM이 직렬화 시 자동 호출 (리플렉션으로 private 메서드 접근)
    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.defaultWriteObject(); // username 등 기본 필드 직렬화

        // password를 암호화해서 저장 (transient이므로 defaultWriteObject에서 제외됨)
        String encrypted = encrypt(new String(password));
        oos.writeObject(encrypted);
    }

    // JVM이 역직렬화 시 자동 호출
    private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
        ois.defaultReadObject(); // 기본 필드 역직렬화

        // 암호화된 값 복원
        String encrypted = (String) ois.readObject();
        this.password = decrypt(encrypted).toCharArray();
    }

    // 역직렬화 후 호출 — 객체 유효성 검증
    private Object readResolve() throws ObjectStreamException {
        // 싱글톤 패턴에서 역직렬화 시 기존 인스턴스 반환할 때 사용
        // 또는 유효성 검증 후 예외 throw
        if (username == null || username.isEmpty()) {
            throw new InvalidObjectException("username이 null입니다");
        }
        return this;
    }

    private String encrypt(String data) {
        // 실제로는 AES 등 사용
        return Base64.getEncoder().encodeToString(data.getBytes());
    }

    private String decrypt(String data) {
        return new String(Base64.getDecoder().decode(data));
    }
}

8.3 Externalizable — 완전한 커스텀 직렬화

import java.io.*;

// Externalizable: Serializable보다 더 완전한 제어
// writeExternal/readExternal을 직접 구현 — defaultWriteObject 없음
// 성능: Java 기본 직렬화보다 빠름 (불필요한 메타데이터 제거 가능)
// 단점: 필드 추가 시 readExternal 수동 관리 필요
public class Point3D implements Externalizable {
    private double x, y, z;

    // Externalizable은 기본 생성자가 반드시 필요 (역직렬화 시 호출)
    public Point3D() {}

    public Point3D(double x, double y, double z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeDouble(x);
        out.writeDouble(y);
        out.writeDouble(z);
        // 딱 24바이트만 씀 — 클래스 디스크립터 메타데이터 없음
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
        x = in.readDouble();
        y = in.readDouble();
        z = in.readDouble();
    }
}

8.4 직렬화의 위험성과 대안

// 위험 1: 역직렬화 취약점 (Java 직렬화는 RCE(원격 코드 실행) 공격 벡터)
// 악의적으로 조작된 바이트 스트림 → readObject()에서 임의 코드 실행 가능
// Apache Commons Collections 취약점 (CVE-2015-7501) 이 대표 사례
// 대응: ObjectInputFilter (Java 9+)로 허용 클래스 화이트리스트 지정

ObjectInputStream ois = new ObjectInputStream(inputStream);
ois.setObjectInputFilter(info -> {
    // 특정 클래스만 역직렬화 허용
    if (info.serialClass() == Employee.class) {
        return ObjectInputFilter.Status.ALLOWED;
    }
    return ObjectInputFilter.Status.REJECTED; // 나머지는 거부
});

// 현대적 대안: Java 직렬화 대신 다음을 사용
// 1. JSON: Jackson, Gson — 가독성, 언어 독립
// 2. Protocol Buffers: Google — 고성능, 스키마 진화
// 3. Avro: 스키마 진화에 강함
// 4. MessagePack: 바이너리 JSON — 성능과 가독성 균형

9. NIO.2 AsynchronousFileChannel — 비동기 파일 I/O

9.1 왜 비동기 I/O가 필요한가

동기 I/O는 read()를 호출한 스레드가 I/O가 완료될 때까지 블로킹됩니다. 이 스레드는 CPU를 사용하지 않으면서 OS 스레드 스택(기본 1MB)을 점유합니다. 비동기 I/O는 I/O를 요청하고 즉시 반환한 뒤, I/O 완료 시 콜백을 받습니다.

Linux에서 AsynchronousFileChannel은 내부적으로 스레드 풀로 구현됩니다 (실제 커널 AIO는 Java에서 제한적으로 사용). 네트워크 비동기 채널(AsynchronousSocketChannel)은 epoll + 스레드 풀을 결합해 사용합니다.

import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.file.*;
import java.util.concurrent.*;

public class AsyncFileChannelDeepDive {

    // CompletionHandler 방식: 콜백 기반
    public static void readWithCallback(Path filePath) throws IOException {
        AsynchronousFileChannel afc = AsynchronousFileChannel.open(
            filePath,
            StandardOpenOption.READ
        );

        ByteBuffer buffer = ByteBuffer.allocate(4096);
        long position = 0;

        // read()는 즉시 반환 (논블로킹)
        // I/O 완료 시 OS가 CompletionHandler 콜백 호출
        // 콜백은 AsynchronousChannelGroup의 스레드 풀에서 실행
        afc.read(buffer, position, buffer, new CompletionHandler<Integer, ByteBuffer>() {
            @Override
            public void completed(Integer bytesRead, ByteBuffer buf) {
                if (bytesRead == -1) {
                    System.out.println("파일 읽기 완료");
                    try { afc.close(); } catch (IOException e) { e.printStackTrace(); }
                    return;
                }

                buf.flip();
                byte[] data = new byte[buf.remaining()];
                buf.get(data);
                processData(data);

                // 다음 청크 비동기 읽기
                buf.clear();
                long nextPosition = position + bytesRead;
                afc.read(buf, nextPosition, buf, this); // 재귀적 비동기 읽기
            }

            @Override
            public void failed(Throwable exc, ByteBuffer buf) {
                System.err.println("읽기 실패: " + exc.getMessage());
                try { afc.close(); } catch (IOException e) { e.printStackTrace(); }
            }
        });
    }

    // Future 방식: 동기적으로 결과를 기다리거나 폴링
    public static byte[] readWithFuture(Path filePath) throws Exception {
        try (AsynchronousFileChannel afc = AsynchronousFileChannel.open(
                filePath, StandardOpenOption.READ)) {

            long fileSize = afc.size();
            ByteBuffer buffer = ByteBuffer.allocate((int) fileSize);

            // read()는 Future 반환 — 즉시 반환
            Future<Integer> future = afc.read(buffer, 0);

            // 다른 작업 수행 가능
            doOtherWork();

            // future.get(): I/O 완료까지 블로킹 (타임아웃 지정 권장)
            int bytesRead = future.get(30, TimeUnit.SECONDS);

            buffer.flip();
            byte[] result = new byte[bytesRead];
            buffer.get(result);
            return result;
        }
    }

    // 비동기 파일 쓰기
    public static CompletableFuture<Void> writeAsync(Path filePath, byte[] data) {
        CompletableFuture<Void> result = new CompletableFuture<>();

        try {
            AsynchronousFileChannel afc = AsynchronousFileChannel.open(
                filePath,
                StandardOpenOption.CREATE,
                StandardOpenOption.WRITE
            );

            ByteBuffer buffer = ByteBuffer.wrap(data);

            afc.write(buffer, 0, null, new CompletionHandler<Integer, Void>() {
                @Override
                public void completed(Integer written, Void attachment) {
                    try {
                        afc.close();
                        result.complete(null);
                    } catch (IOException e) {
                        result.completeExceptionally(e);
                    }
                }

                @Override
                public void failed(Throwable exc, Void attachment) {
                    result.completeExceptionally(exc);
                    try { afc.close(); } catch (IOException e) { e.addSuppressed(exc); }
                }
            });
        } catch (IOException e) {
            result.completeExceptionally(e);
        }

        return result;
    }

    // AsynchronousChannelGroup: 스레드 풀 공유
    public static void withCustomThreadPool() throws IOException {
        // 기본 스레드 풀 대신 커스텀 스레드 풀 사용
        ExecutorService executor = Executors.newFixedThreadPool(
            Runtime.getRuntime().availableProcessors()
        );
        AsynchronousChannelGroup group = AsynchronousChannelGroup.withThreadPool(executor);

        AsynchronousFileChannel afc = AsynchronousFileChannel.open(
            Path.of("data.bin"),
            Set.of(StandardOpenOption.READ),
            group // 이 그룹의 스레드 풀에서 콜백 실행
        );

        // group을 닫으면 소속된 모든 채널이 닫힘
        // group.shutdown() → group.awaitTermination(30, TimeUnit.SECONDS)
    }

    private static void processData(byte[] data) {}
    private static void doOtherWork() {}
}

10. 성능 비교 및 상황별 선택 기준

10.1 API 선택 트리

graph LR
    Q1["파일 크기?"] -->|"< 10MB"| FILES["Files.readString"]
    Q1 -->|"10MB~1GB"| LINES["Files.lines()"]
    Q1 -->|"> 1GB"| MMAP["MappedByteBuffer"]
    MMAP -->|"랜덤/순차"| FC["FileChannel"]
import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.*;
import java.nio.file.*;
import java.util.stream.*;

public class IOPatternGuide {

    // 패턴 1: 설정 파일 읽기 (< 1MB) — Files.readString
    public static String readConfig(Path configPath) throws IOException {
        return Files.readString(configPath, StandardCharsets.UTF_8);
    }

    // 패턴 2: 대용량 로그 분석 — Files.lines 스트림 (메모리 효율)
    public static Map<String, Long> analyzeLog(Path logPath) throws IOException {
        try (Stream<String> lines = Files.lines(logPath, StandardCharsets.UTF_8)) {
            return lines
                .filter(line -> line.contains("ERROR"))
                .collect(Collectors.groupingBy(
                    line -> line.substring(0, 10), // 날짜 키
                    Collectors.counting()
                ));
        }
    }

    // 패턴 3: 이진 파일 복사 — transferTo zero-copy
    public static void copyFile(Path src, Path dst) throws IOException {
        try (FileChannel in = FileChannel.open(src, StandardOpenOption.READ);
             FileChannel out = FileChannel.open(dst,
                 StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
            long size = in.size();
            long copied = 0;
            while (copied < size) {
                copied += in.transferTo(copied, size - copied, out);
            }
        }
    }

    // 패턴 4: 고정 크기 레코드 랜덤 접근 — MappedByteBuffer
    public static int readRecord(MappedByteBuffer mmap, int index, int recordSize) {
        return mmap.getInt(index * recordSize);
    }

    // 패턴 5: 고성능 바이너리 프로토콜 — ByteBuffer + Direct
    public static void writeProtocolMessage(FileChannel channel, int messageType,
                                             byte[] payload) throws IOException {
        ByteBuffer header = ByteBuffer.allocateDirect(8);
        header.putInt(messageType);
        header.putInt(payload.length);
        header.flip();

        ByteBuffer body = ByteBuffer.wrap(payload);

        // scatter/gather I/O: 여러 버퍼를 한 번의 syscall로 쓰기
        channel.write(new ByteBuffer[]{header, body});
    }

    // 패턴 6: 텍스트 파일 쓰기 — BufferedWriter
    public static void writeReport(Path outputPath, Iterable<String> lines) throws IOException {
        try (BufferedWriter bw = Files.newBufferedWriter(outputPath, StandardCharsets.UTF_8,
                StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
            for (String line : lines) {
                bw.write(line);
                bw.newLine();
            }
        }
    }

    private static Map<String, Long> result;
    private static java.util.Map<String, Long> createMap() { return new java.util.HashMap<>(); }
}

10.2 버퍼 크기 최적화

public class BufferSizeOptimization {

    // 버퍼 크기 선택 기준
    public static void demonstrateBufferSizes() {
        // 4KB: OS 페이지 크기 — 최소 의미 있는 크기
        // 8KB: Java 기본값 — 대부분의 경우 충분
        // 64KB: 네트워크 TCP 소켓 버퍼와 정렬 — 네트워크 I/O에 권장
        // 256KB~1MB: 순차 디스크 I/O에서 처리량 최대화
        // 실측: 같은 작업을 4KB, 8KB, 64KB, 256KB 버퍼로 측정하고 최적값 선택

        int[] bufferSizes = {4096, 8192, 65536, 262144, 1048576};
        for (int size : bufferSizes) {
            long start = System.nanoTime();
            // readLargeFile(size);
            long elapsed = System.nanoTime() - start;
            System.out.printf("Buffer %6d bytes: %,d ms%n", size, elapsed / 1_000_000);
        }
        // 일반적 결과: 64KB~256KB 근방에서 처리량 최대, 이후 거의 변화 없음
        // WHY: 디스크 읽기 속도가 병목이 되면 버퍼 크기가 중요하지 않음
        //      CPU 처리가 병목이면 작은 버퍼가 L1/L2 캐시 친화적
    }
}

11. 실무에서 자주 나오는 실수

실수 1: 스트림 닫지 않아 파일 핸들 누수

// 위험: 예외 발생 시 close() 미호출
public void dangerousRead(String path) throws IOException {
    FileInputStream fis = new FileInputStream(path);
    int data = fis.read(); // 예외 발생 시 fis 누수
    fis.close();           // 여기까지 도달 못 함
}

// 올바른 방식
public void safeRead(String path) throws IOException {
    try (FileInputStream fis = new FileInputStream(path)) {
        int data = fis.read();
    } // 예외 발생 여부와 무관하게 자동 close()
}

파일 핸들은 OS 자원입니다. Linux의 기본 ulimit -n은 1024(프로세스당 최대 fd 수)입니다. 누수가 쌓이면 Too many open files 오류로 새 파일을 열 수 없게 됩니다.

실수 2: 인코딩 미지정으로 한글 깨짐

// 위험: 플랫폼 기본 인코딩 사용
FileReader fr = new FileReader("korean.txt"); // Windows: MS949, Linux: UTF-8

// 올바른 방식: 항상 UTF-8 명시
FileReader fr = new FileReader("korean.txt", StandardCharsets.UTF_8);
// 또는
String content = Files.readString(Path.of("korean.txt"), StandardCharsets.UTF_8);

실수 3: flip() 누락

// 위험: flip() 없이 Channel에서 읽은 Buffer를 바로 읽기
ByteBuffer buf = ByteBuffer.allocate(1024);
channel.read(buf); // position = 512 (읽은 바이트 수)
// buf.flip() 누락!
while (buf.hasRemaining()) { // position(512) == limit(1024)? No. 이미 512 ~ 1024 구간 읽음
    process(buf.get()); // 쓰레기 값 또는 기대와 다른 결과
}

// 올바른 방식
channel.read(buf);
buf.flip(); // limit = 512, position = 0
while (buf.hasRemaining()) {
    process(buf.get());
}
buf.clear();

실수 4: BufferedOutputStream.close() 전 flush() 미확인

// 주의: PrintWriter의 autoFlush=false(기본값)이면 close() 시에만 flush
// 프로그램 비정상 종료 시 마지막 버퍼 내용 유실
PrintWriter pw = new PrintWriter(new FileWriter("log.txt")); // autoFlush=false
pw.println("중요한 로그");
// 예외로 pw.close()가 실행 안 되면 "중요한 로그"가 파일에 없음

// 올바른 방식
try (PrintWriter pw = new PrintWriter(
        new BufferedWriter(new FileWriter("log.txt")), true)) { // autoFlush=true
    pw.println("중요한 로그");
}
// 또는 명시적 flush
pw.flush();

실수 5: serialVersionUID 미선언

// 위험: 클래스 변경 시 역직렬화 불가
public class Config implements Serializable {
    private String host; // serialVersionUID 자동 생성 — 클래스 변경 시 바뀜
}

// 안전: 명시적 선언으로 하위 호환 유지
public class Config implements Serializable {
    private static final long serialVersionUID = 1L;
    private String host;
    private int port = 8080; // 필드 추가해도 serialVersionUID 동일 → 하위 호환
}

12. 극한 시나리오

시나리오 1: 서버 OOM — Files.readAllBytes()로 10GB 로그 읽기

// 대형 장애 사례: 10GB 로그 파일을 한 번에 메모리에 올리려다 OOM
byte[] allBytes = Files.readAllBytes(Path.of("10gb.log")); // OutOfMemoryError!

// 올바른 방식: 스트리밍으로 처리
try (Stream<String> lines = Files.lines(Path.of("10gb.log"), StandardCharsets.UTF_8)) {
    lines
        .filter(line -> line.contains("ERROR"))
        .map(line -> parseError(line))
        .forEach(error -> errorRepository.save(error));
    // 메모리 사용량: 버퍼 크기(수십 KB) 수준으로 유지
}

// 더 빠른 방식: MappedByteBuffer + 직접 파싱
try (FileChannel fc = FileChannel.open(Path.of("10gb.log"), StandardOpenOption.READ)) {
    long size = fc.size();
    long chunkSize = 256L * 1024 * 1024; // 256MB 청크

    for (long offset = 0; offset < size; offset += chunkSize) {
        long len = Math.min(chunkSize, size - offset);
        MappedByteBuffer chunk = fc.map(FileChannel.MapMode.READ_ONLY, offset, len);
        // 청크 내 줄 단위 파싱 — OS 페이지 캐시에서 직접 읽기
        parseLines(chunk);
    }
}

시나리오 2: 10만 동시 연결 — 스레드 기반 vs Selector 기반

// 스레드 기반 (BIO): 10만 연결 = 10만 스레드 = ~100GB 메모리 필요 → 불가능
ServerSocket ss = new ServerSocket(8080);
while (true) {
    Socket client = ss.accept();
    // 스레드 1개당 1MB 스택 → 10만 연결 = 100GB
    new Thread(() -> handleClient(client)).start(); // 실패
}

// Selector 기반 (NIO): 10만 연결을 수십 개 스레드로 처리
// Netty 사용 시 내부적으로 이 패턴
// Boss 스레드: accept 담당 (1개)
// Worker 스레드: 실제 I/O 처리 (CPU 코어 수)
// 10만 연결 @ 1GB 메모리 — 현실적으로 가능

// Java 21 Virtual Thread: 스레드 기반의 단순함 + NIO의 효율성
// Virtual Thread는 I/O 블로킹 시 OS 스레드를 자동으로 해제
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    ServerSocket ss = new ServerSocket(8080);
    while (true) {
        Socket client = ss.accept();
        executor.submit(() -> handleClient(client)); // Virtual Thread 사용
        // Virtual Thread는 수백만 개 생성 가능 (경량, 힙에 저장)
        // read() 블로킹 → Virtual Thread unmount → OS 스레드 반환
        // I/O 완료 → Virtual Thread remount → 재개
    }
}

시나리오 3: 직렬화 취약점 공격 방어

// 공격: ObjectInputStream.readObject()는 바이트 스트림만 있으면 임의 클래스 인스턴스 생성
// Commons Collections 가젯 체인: 역직렬화 시 Runtime.exec() 호출 가능
// 방어 1: ObjectInputFilter (Java 9+) — 화이트리스트 기반

ObjectInputStream ois = new ObjectInputStream(untrustedStream);

// 전역 필터 설정 (JVM 시작 시 한 번)
ObjectInputFilter.Config.setSerialFilter(info -> {
    Class<?> cl = info.serialClass();
    if (cl == null) return ObjectInputFilter.Status.UNDECIDED;

    // 허용 패키지만 화이트리스트
    if (cl.getPackageName().startsWith("com.myapp.model")) {
        return ObjectInputFilter.Status.ALLOWED;
    }
    return ObjectInputFilter.Status.REJECTED;
});

// 방어 2: 직렬화 자체를 포기하고 JSON/Protobuf로 전환
// Jackson ObjectMapper를 사용하면 가젯 체인 공격 불가
ObjectMapper mapper = new ObjectMapper();
MyObject obj = mapper.readValue(json, MyObject.class); // 타입 안전

시나리오 4: Zero-Copy HTTP 파일 서버 vs 일반 스트리밍

// 일반 방식: 4번 복사
// 디스크 → 커널 읽기 버퍼 (DMA)
// 커널 읽기 버퍼 → JVM 힙 (CPU)
// JVM 힙 → 커널 소켓 버퍼 (CPU)
// 커널 소켓 버퍼 → NIC (DMA)
public void serveFileOld(Path file, OutputStream httpOut) throws IOException {
    byte[] buf = new byte[65536];
    try (FileInputStream fis = new FileInputStream(file.toFile())) {
        int n;
        while ((n = fis.read(buf)) != -1) {
            httpOut.write(buf, 0, n); // JVM 힙 경유 — CPU 복사 2회
        }
    }
}

// Zero-Copy 방식: Linux sendfile(2) 활용 — CPU 복사 0~1회
// 디스크 → 커널 페이지 캐시 (DMA)
// 커널 페이지 캐시 → NIC 버퍼 (최신 Linux DMA, 또는 CPU 복사 1회)
public void serveFileZeroCopy(Path file, SocketChannel socket) throws IOException {
    try (FileChannel fc = FileChannel.open(file, StandardOpenOption.READ)) {
        long size = fc.size();
        long sent = 0;
        while (sent < size) {
            sent += fc.transferTo(sent, size - sent, socket);
        }
        // 처리량: 일반 방식의 2~3배 향상 가능 (대용량 파일, 고부하 시)
    }
}

13. 면접 포인트 5개 (Deep WHY)

Q. java.io와 java.nio의 근본적 차이는 무엇인가?

표면적 답변: java.io는 스트림 기반 블로킹 I/O, java.nio는 채널+버퍼 기반으로 논블로킹 가능.

심층 WHY: java.io에서 read() 호출은 데이터가 없으면 OS가 해당 스레드를 WAIT 상태로 전환합니다. 이 스레드는 CPU를 쓰지 않지만 OS 스레드 스택(기본 512KB~1MB)을 점유합니다. 10만 연결 = 10만 개의 블로킹 스레드 = 최대 100GB 메모리 → 현실 불가능.

java.nio의 Selector는 Linux의 epoll_wait()을 호출합니다. epoll은 커널 이벤트 테이블에 fd를 등록해 두고 이벤트(데이터 도착, 쓰기 가능)가 발생하면 epoll_wait()가 반환됩니다. 10만 fd를 등록해도 준비된 fd만 반환하므로 스레드 수는 CPU 코어 수 정도로 충분합니다. Netty, Spring WebFlux, Vert.x 모두 이 원리로 고성능 서버를 구현합니다.

단, Java 21 Virtual Thread 등장 이후 블로킹 I/O도 고성능이 가능해졌습니다. Virtual Thread는 read() 블로킹 시 OS 스레드를 반환하고 I/O 완료 시 재개하므로 스레드 수 문제가 해결됩니다.

Q. ByteBuffer의 flip(), compact(), clear() 차이와 내부 동작은?

표면적 답변: flip은 쓰기→읽기 전환, clear는 초기화, compact는 미읽은 데이터 보존.

심층 WHY: ByteBuffer는 position, limit, capacity 세 포인터로 상태를 관리합니다.

flip(): limit = position; position = 0; mark = -1. Channel에서 읽은 데이터(position이 데이터 끝 가리킴)를 애플리케이션이 읽으려면 처음(position=0)부터 데이터 끝(limit=이전 position)까지만 읽어야 합니다. flip()이 이 전환을 합니다. NIO 버그의 30% 이상이 flip() 누락입니다.

clear(): position = 0; limit = capacity; mark = -1. 데이터를 지우지 않고 포인터만 초기화합니다. 다음 Channel.read()가 처음부터 덮어씁니다.

compact(): 현재 position에서 limit까지 읽지 않은 데이터를 버퍼 앞으로 이동 후 position = remaining; limit = capacity. 부분 읽기 후 추가 데이터를 이어 쓸 때 사용합니다. 예: 프레이밍 프로토콜에서 패킷 경계가 버퍼 중간에 걸릴 때.

Q. FileChannel.transferTo()가 zero-copy인 이유는?

표면적 답변: 커널 공간에서 직접 전송해 JVM 힙을 거치지 않습니다.

심층 WHY: 일반 파일 전송은 4단계: (1) 디스크 → 커널 읽기 버퍼 (DMA), (2) 커널 읽기 버퍼 → JVM 힙 (CPU 복사), (3) JVM 힙 → 커널 소켓 버퍼 (CPU 복사), (4) 커널 소켓 버퍼 → NIC (DMA). CPU 복사가 2번 발생합니다.

transferTo()는 Linux sendfile(2) 또는 splice(2)를 사용합니다. 커널 페이지 캐시의 파일 데이터를 NIC 버퍼로 직접 전달합니다. 최신 Linux(scatter-gather DMA 지원 시): 커널 페이지 캐시 → NIC (DMA, CPU 복사 0회). 구형 Linux: 커널 페이지 캐시 → 소켓 버퍼 (CPU 복사 1회). JVM 힙을 전혀 거치지 않으므로 GC 압력도 없습니다. 1GB 파일 전송에서 일반 방식 대비 2~4배 처리량 향상이 가능합니다.

Q. MappedByteBuffer와 일반 ByteBuffer의 차이는?

표면적 답변: MappedByteBuffer는 파일을 가상 메모리에 매핑해 직접 접근합니다.

심층 WHY: FileChannel.map()mmap(2) syscall을 호출합니다. 이 syscall은 파일의 일부(또는 전체)를 프로세스의 가상 주소 공간에 매핑합니다. 실제 물리 메모리에 올리는 것이 아니라 “이 가상 주소 범위는 이 파일의 이 오프셋에 해당한다”는 매핑 테이블을 만드는 것입니다.

처음 해당 주소를 읽으면 페이지 폴트가 발생하고, OS가 디스크에서 해당 4KB 페이지를 커널 페이지 캐시에 로드한 뒤 접근을 재개합니다. 이후 같은 페이지 접근은 메모리 읽기와 동일 속도입니다.

장점: read() syscall 없이 메모리 접근으로 파일 읽기. 여러 프로세스가 같은 파일을 mmap하면 커널이 페이지 캐시를 공유합니다. 고정 크기 레코드의 랜덤 접근이 특히 빠릅니다.

단점: GC로 명시적 해제 불가 — JVM 종료 전까지 페이지 캐시를 점유할 수 있습니다. 파일 삭제 후에도 매핑이 살아있으면 파일이 실제 삭제되지 않습니다. 2GB 이상 매핑은 주소 공간 분할이 필요합니다.

Q. Java 직렬화의 대안과 serialVersionUID 관리 전략은?

표면적 답변: JSON/Protobuf 사용하고 serialVersionUID를 명시합니다.

심층 WHY: Java 직렬화는 세 가지 치명적 문제가 있습니다.

보안: ObjectInputStream.readObject()는 클래스패스의 모든 클래스를 인스턴스화할 수 있습니다. Apache Commons Collections의 InvokerTransformer 가젯 체인을 활용하면 역직렬화 시 Runtime.exec()를 호출할 수 있습니다(RCE 공격). ObjectInputFilter(Java 9+)로 허용 클래스를 화이트리스트로 제한해야 합니다.

유지보수: serialVersionUID를 명시하지 않으면 JVM이 클래스 구조를 SHA-1로 해시해 자동 생성합니다. 필드 추가 → 해시 변경 → 기존 직렬화 데이터 역직렬화 실패(InvalidClassException). 해결책: 항상 private static final long serialVersionUID = 1L 명시. 하위 호환을 유지하면서 필드를 추가할 때는 serialVersionUID를 변경하지 않습니다.

성능: Java 직렬화는 클래스 디스크립터, 타입 정보 등 메타데이터가 많아 Protocol Buffers 대비 5~10배 큰 바이트 크기를 생성합니다.

대안 선택 기준:

  • REST API / 로그: JSON (Jackson — @JsonIgnore로 transient 대체)
  • 마이크로서비스 IPC: Protocol Buffers (스키마 진화, 언어 중립)
  • 대용량 데이터: Avro (Hadoop 생태계) 또는 MessagePack (바이너리 JSON)
  • JVM 내부 세션 등: Java 직렬화 가능하나 ObjectInputFilter 필수

핵심 정리

항목 java.io java.nio java.nio.file (NIO.2)
도입 Java 1.0 Java 1.4 Java 7
처리 단위 스트림(바이트/문자) 버퍼+채널 Path 기반
블로킹 항상 블로킹 논블로킹 가능 동기 (내부 채널 사용)
인코딩 제어 InputStreamReader CharsetDecoder StandardCharsets 지정
성능 버퍼 없이 낮음 높음 (Direct, transferTo) 높음 (내부적으로 NIO)
편의성 중간 낮음 (복잡) 매우 높음
비동기 불가 Selector (논블로킹) AsynchronousFileChannel
직렬화 ObjectOutputStream
주요 사용처 레거시, 간단한 작업 고성능 서버, 대용량 처리 일반 파일 작업

함께 읽으면 좋은 글

카테고리:

업데이트:

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →