본문으로 건너뛰기

1 NIO

  • 자바 4에서 new Input/Ouput이라는 뜻으로 java.nio 패키지가 추가되었습니다.
  • 자바 7에서 IO와 NIO 사이의 일관성 없는 클래스 설계를 바로 잡고 비동기 채널 등의 네트워크 지원을 대폭 강화한 NIO2 API가 추가 되었습니다.
  • NIO2는 기존 java.nio의 하위 패키지로 제공됩니다.

1.1 IO와 차이점

  • 기존 Java I/O에 대한 내용은 이 곳을 참조하세요.
  • 기존 I/O와 NIO의 차이점은 스트림과 채널, 버퍼, 논블로킹입니다. 하나씩 살펴보겠습니다.

스트림과 채널

  • IO는 스트림 기반입니다. 따라서 파일의 데이터를 읽기 위해서는 입력 스트림을 생성해야 하고 파일에 데이터를 출력하기 위해서는 출력 스트림을 생성해야 합니다.
    • File을 예시로 들면 입출력을 위해서는 FileInputStream, FileOutputStream를 생성해야 합니다.
  • 하지만 NIO는 채널 기반입니다. 채널은 스트림과 달리 양방향으로 입출력이 가능합니다.
  • 따라서 하나의 파일에서 데이터를 읽고 쓰는 작업을 모두 해야 한다면 FileChannel 하나만 생성하면 됩니다.

버퍼

  • IO에서는 출력 스트림이 1바이트를 쓰면 입력 스트림이 1바이트를 읽습니다.
    • IO는 스트림에서 읽은 데이터를 즉시 처리하기 때문에 입력된 전체 데이터를 별도로 저장하지 않으면 입력된 데이터 위치를 이동해 가면서 자유롭게 이용할 수 없습니다.
    • IO에서는 추가적으로 보조 스트림인 BufferedInputStream, BufferedOutputStream을 연결해 버퍼를 사용해 복수 개의 바이트를 한꺼번에 입력받고 출력할 수 있습니다.
  • IO와 다르게 NIO는 기본적으로 버퍼를 사용합니다.
    • Channel에서 데이터를 읽으면 Buffer에 담깁니다.
    • Channel에 데이터를 쓰려면 먼저 Buffer에 데이터를 담고 Buffer에 담긴 데이터를 Channel에 씁니다.

논블로킹

  • IO는 블록킹됩니다. 입력 스트림의 read() 메서드를 호출하면 데이터가 입력되기 전까지 스레드는 블로킹됩니다.
  • IO스레드가 블로킹되면 다른 일을 할 수 없고 블로킹을 빠져나가기 위해 인터럽트도 할 수 없습니다.
    • 블로킹을 빠져나가는 유일한 방법은 스트림을 닫는 것입니다.
  • 반면에 NIO는 블로킹과 논블로킹을 모두 지원합니다. NIO의 블로킹이 IO의 블로킹과 다른점은 스레드를 인터럽트해서 빠져나갈수 있습니다.
  • NIO의 논블로킹은 입출력 작업 준비가 완료된 채널만 선택해서 작업 스레드가 처리하기 때문에 작업 스레드가 블로킹 되지 않습니다.
  • NIO 논블로킹의 핵심 객체는 멀티플렉서인 셀럭터입니다.
    • 셀렉터는 복수 개의 채널 중에서 준비 완료된 채널을 선택하는 방법을 제공합니다.

2 버퍼

  • NIO에서는 데이터를 입출력하기 위해 항상 버퍼를 사용해야 합니다.
  • 버퍼는 읽고 쓰기가 가능한 메모리 배열입니다.
  • 버퍼가 사용하는 메모리의 위치에 따라 non-direct 버퍼와 direct 버퍼로 분류됩니다.
    • non-direct 버퍼: JVM이 관리하는 힙 메모리 공간을 이용하는 버퍼입니다.
    • direct 버퍼: 운영체제가 관리하는 메모리 공간을 이용하는 버퍼입니다.

2.1 종류

2.1.1 non-direct buffer

  • JVM힙 메모리에 생성되는 버퍼를 의미합니다.
  • JVM의 제한된 힙 메모리를 사용하므로 버퍼의 크기가 제한됩니다.
  • 다이렉트 버퍼와 비교하여 생성과 삭제 속도가 빠릅니다.
  • GC로 자동 메모리 관리가 가능합니다.
동작 방식
  • JVM 힙에 Non-Direct Buffer 생성
  • I/O 요청 발생
  • 커널 메모리에 임시 Direct Buffer 생성
  • Non-Direct Buffer 데이터를 임시 Direct Buffer로 복사
  • OS가 Direct Buffer로 I/O 수행
  • 임시 Direct Buffer 해제

2.1.2 direct buffer

  • OS 커널 메모리에 직접 생성되는 버퍼를 의미합니다.
  • 운영체제의 메모리를 할당받기 위해 운영체제의 네이티브 C 함수를 호출하고 여러가지 처리를 해야하므로 상대적으로 버퍼 생성이 느립니다.
    • 따라서 자주 생성하지 않고 생성한 버퍼를 재사용하는 것이 적합합니다.
  • 운영체제가 관리하는 메모리를 사용하므로 운영체제가 허용하는 범위 내에서 대용량 버퍼를 사용할 수 있습니다.
  • GC 대상 아니여서 명시적으로 해제해야 합니다.
동작 방식
  • JNI를 통해 커널 메모리에 Direct Buffer 생성
  • I/O 요청 발생
  • OS가 Direct Buffer로 직접 I/O 수행

2.1.3 비교

  • 다이렉트 버퍼는 OS의 메모리를, 넌다이렉트 버퍼는 JVM 힙 메모리를 사용합니다.
  • 다이렉트 버퍼는 OS의 네이티브 I/O 작업에 직접 사용되어 성능이 높지만, 생성과 해제 비용이 큽니다.
  • 넌다이렉트 버퍼는 JVM에 의해 자동으로 관리되지만, 다이렉트 버퍼는 명시적인 관리가 필요합니다.
  • 다이렉트 버퍼는 대용량, 장기 사용 데이터에 적합하고, 넌다이렉트 버퍼는 소량, 단기 사용 데이터에 적합합니다.

2.2 버퍼 생성

  • 데이터 타입별로 넌다이렉트 버퍼를 생성하기 위해서는 각 Buffer 클래스의 allocate()wrap() 메서드를 호출하면 됩니다.
  • 다이렉트 버퍼는 allocateDirect() 메서드를 호출하면 됩니다.

allocate() 메서드

ByteBuffer byteBuffer = ByteBuffer.allocate(100);  
CharBuffer charBuffer = CharBuffer.allocate(100);
  • 최대 100개의 바이트를 저장하는 ByteBuffer를 생성하고 최대 100개의 문자를 저장하는 CharBuffer를 생성하는 코드

wrap() 메서드

byte[] bytes = new byte[100];  
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
  • 각 타일별 Buffer는 모두 wrap() 메서드를 가지고 있다.
  • wrap()는 이미 생성되어 있는 자바 배열을 래핑해서 Buffer 객체를 생성한다.

allocateDirect()

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100);  
CharBuffer charBuffer = byteBuffer.asCharBuffer();
  • allocateDirect() 메서드는 JVM 힙 메모리 바깥쪽 즉 운영체제가 관리하는 메모리에 다이렉트 버퍼를 생성한다.
  • 이 메서드는 각 타입별 Buffer 클래스에는 없고 ByteBuffer만 제공한다.
    • 따라서 각 타입별로 다이렉트 버퍼를 생성하고 싶다면 asXX() 메서드를 호출해 얻을 수 있다.

2.3 Buffer의 위치 속성

  • 버퍼를 사용하기 위해선 먼저 버퍼의 위치 속성을 잘 알아야 한다.
  • 버퍼는 네 가지의 위치 속성을 가진다.
    • position
      • 현재 읽거나 쓰는 위치 값이다.
      • 인덱스 값이기 때문에 0부터 시작한다.
      • limit보다 큰 값을 가지 수 없다.
      • 만약 limit 값과 같다면 더 이상 데이터를 쓰거나 읽을 수 없다는 뜻이다.
    • limit
      • 버퍼에서 읽거나 쓸 수 있는 위치의 한계를 나타낸다.
      • 이 값은 capacity보다 같거나 작은 값을 가진다.
      • 최초에 버퍼를 만들면 capacity와 같은 값을 가진다.
    • capacity
      • 버퍼의 최대 데이터 개수
      • 메모리의 크기를 나타낸다.
    • mark
      • reset() 메서드를 호출했을 때 돌아갈 위치를 기록하는 속성이다.

2.4 Buffer 메서드

2.4.1 clear()

public Buffer clear() {  
position = 0;
limit = capacity;
mark = -1;
return this;
}
  • 버퍼의 위치 속성을 초기화 한다.

2.4.2 flip()

public Buffer flip() {  
limit = position;
position = 0;
mark = -1;
return this;
}
  • 데이터를 읽기 위해 위치 속성값을 변경한다.

2.4.3 mark()

public Buffer mark() {  
mark = position;
return this;
}
  • 현재 위치를 마킹한다.

2.4.3 reset()

public Buffer reset() {  
int m = mark;
if (m < 0)
throw new InvalidMarkException();
position = m;
return this;
}
  • 현재 위치(position)을 마킹한 위치로 변경한다.

3 Channel

  • 채널은 NIO에서 데이터를 읽고 쓰는 연결을 나타낸다.
  • 각 채널은 특정 I/O 서비스(예: 파일 I/O, 소켓 I/O)에 바인딩된다.
  • 채널은 항상 버퍼와 함께 사용되며, 데이터는 버퍼를 통해 채널로 흐르거나 채널에서 버퍼로 흐른다.
  • 채널은 블로킹과 논블로킹 모드를 모두 지원할 수 있다.

3.1 FileChannel

  • 레퍼런스
  • java.nio.channels.FileChannel을 이용하면 파일 읽기와 쓰기를 할 수 있습니다.
  • FileChannel은 동기화 처리가 되어 있어 쓰레드 세이프합니다.

3.1.1 FileChannel 생성과 닫기

public static FileChannel open(Path path, OpenOption... options) 
  • 정적 메서드인 open 메서드로 FileChannel을 생성할 수 있습니다.
  • 첫 번째 path는 열거나 생성하고자 하는 파일의 경로를 Path 객체로 생성해 지정한다.
  • 두 번째 옵션은 StandardOpenOption의열거 상수를 나열하면 됩니다.
예시
FileChannel open = FileChannel.open(  
Paths.get("/test.txt"),
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
);
  • /test.txt 파일을 생성하고 쓰려면 위와 같이 채널을 생성합니다.

3.1.2 파일 읽기와 쓰기

  • FileChannel의 read와 write 메서드는 블로킹됩니다.
  • NIO에서는 비동기 파일 입출력 작업을 위해 AsynchronousFileChannel 클래스를 별도로 제공합니다.
public abstract int write(ByteBuffer src)
  • 파일에 바이트를 쓰려면 FileChannel의 write() 메서드를 호출하면 된다.
  • 매개값으로 ByteBuffer 객체를 주면 된다.
  • ByteBuffer의 position부터 limit까지 파일에 쓰여진다.
  • ByteBuffer에서 파일로 쓰여진 바이트 수가 반환된다.
public abstract int read(ByteBuffer dst)
  • 파일로부터 바이트를 읽기 위해 read() 메서드를 사용한다.
  • 매개값으로 ByteBuffer 객체를 주면 파일에서 읽혀지는 바이트를 ByteBuffer의 position 부터 ByteBuffer에 저장한다.
  • 반환값은 파일에서 ByteBuffer로 읽혀진 바이트 수다.
  • 더 이상 읽을 바이트가 없다면 -1을 반환한다.

파일 복사 예시

Path from = Paths.get("/Users/YT/Documents/test.txt");  
Path to = Paths.get("/Users/YT/Documents/test2.txt");

FileChannel fileChannelFrom = FileChannel.open(from, StandardOpenOption.READ);
FileChannel fileChannelTo = FileChannel.open(to, StandardOpenOption.CREATE, StandardOpenOption.WRITE);

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100);
int byteCount = 0;
while (true) {
byteBuffer.clear();
byteCount = fileChannelFrom.read(byteBuffer);
if (byteCount == -1) break;
byteBuffer.flip();
fileChannelTo.write(byteBuffer);
}

fileChannelFrom.close();
fileChannelTo.close();
  • /Users/YT/Documents/test.txt파일을 /Users/YT/Documents/test2.txt로 복사하는 예시

4 Selctor

  • Selctor는 하나의 스레드가 여러 채널의 이벤트를 모니터링할 수 있게 해주는 구성 요소입니다.
  • Selctor에 여러 채널을 등록하고, 이 채널들 중에서 I/O 작업이 가능한 채널을 결정합니다.
    • 이를 통해 하나의 스레드가 여러 네트워크 연결을 효율적으로 관리할 수 있습니다.
  • Selctor는 논블로킹 I/O 작업에 사용되며, 블로킹 방식의 문제를 해결해주는 중요한 기능입니다.
  • Selector.open() 메소드로 선택자를 생성하고, 채널에 configureBlocking(false)를 호출하여 논블로킹 모드로 설정한 후 선택자에 채널을 등록합니다.

4.1 Selctor 생성

  • Selector는 Selector.open() 정적 메소드를 호출하여 생성할 수 있습니다. 이 메소드는 새로운 Selector 객체를 반환합니다.
  • 생성된 Selector는 여러 채널을 관리하며, 이 채널들은 Selector에 등록되어야 합니다.
    • java.nio.channels.SelectableChannel 하위 채널만 등록할 수 있습니다.
      • ServerSocketChannel, SocketChannel 등
    • 논블로킹으로 설정된 채널만 등록할 수 있습니다.
  • Selector는 특정 이벤트(예: 연결 요청, 데이터 도착)가 발생할 때까지 블로킹하거나, 블로킹하지 않고 주기적으로 채널의 상태를 확인할 수 있습니다.

4.2 Channel 등록

  • Selector에 채널을 등록하기 위해서는 채널을 논블로킹 모드로 설정해야 합니다.
    • 이는 configureBlocking(false) 메소드를 호출하여 수행할 수 있습니다.
  • 채널을 Selector에 등록하기 위해서는 채널의 register() 메소드를 사용합니다.
    • 이 메소드는 SelectionKey 객체를 반환합니다.
    • 이 키는 Selector와 채널 간의 관계를 나타내며 Selector에 채널이 등록될 때 생성됩니다.
  • register() 메소드는 관심 있는 I/O 이벤트 유형을 인자로 받습니다.
    • 예를 들어, 읽기, 쓰기, 연결 가능, 수락 가능 등의 이벤트가 있습니다.

예시

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
SelectionKey key = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

  • 위 코드에서 ServerSocketChannel은 논블로킹 모드로 설정되고, Selector에 등록합니다.
  • 등록 시, 관심 있는 이벤트 유형으로 OP_ACCEPT를 지정하여, 연결 수락을 감지할 수 있습니다.
  • 클라이언트 연결이 들어오면, Selector는 해당 이벤트를 감지하고, 애플리케이션은 이 정보를 사용하여 해당 이벤트를 처리할 수 있습니다.

4.2.1 SelectionKey

  • SelectionKey는 Selector와 Channel 사이의 등록 관계를 표현하는 토큰입니다.
  • 채널이 Selector에 등록될 때 생성되며, 다음과 같은 중요한 정보를 포함합니다
  • 관심 작업(Interest Operations)
    • SelectionKey는 채널이 어떤 이벤트에 관심이 있는지를 나타내는 비트 집합을 유지합니다.
    • 주요 이벤트 타입은 다음과 같습니다
      • SelectionKey.OP_READ (1): 읽기 작업
      • SelectionKey.OP_WRITE (4): 쓰기 작업
      • SelectionKey.OP_CONNECT (8): TCP 연결 작업
      • SelectionKey.OP_ACCEPT (16): TCP 연결 수락 작업
  • 준비된 작업(Ready Operations)
    • isReadable(): 읽기 가능 여부
    • isWritable(): 쓰기 가능 여부
    • isConnectable(): 연결 가능 여부
    • isAcceptable(): 연결 수락 가능 여부
  • 연결된 객체 접근
    • SelectionKey는 등록된 채널과 선택자에 대한 참조를 제공합니다:
    • channel(): SelectionKey에 대한 채널 반환
    • selector(): SelectionKey에 대한 선택자 반환

4.3 준비된 채널 선택

4.3.1 select()

// 블로킹되어 준비된 채널을 선택합니다. 반환 값은 준비된 채널의 수입니다.
public abstract int select() throws IOException
  • Selector는 등록된 채널들 중에서 I/O 작업이 가능한 채널을 선택하는 역할을 합니다.
  • Selector의 select() 메소드를 호출하여 준비된 채널들을 선택할 수 있습니다.
    • 이 메소드는 하나 이상의 채널이 작업 준비가 되었을 때까지 블로킹됩니다.
    • 최소 하나의 SelectionKey로부터 작업 처리가 준비되었다는 통보가 올 때까지 블로킹된다.
  • select(long timeout)은 지정된 시간 동안 블로킹되며, selectNow()는 즉시 반환됩니다.
  • select, selectNow 메서드의 반환 값은 준비된 SelectionKey의 수입니다.
    • 다른 스레드가 selector.wakeup()을 호출하면 select() 작업이 즉시 중단되고 0을 반환할 수 있습니다.
    • select()가 블로킹되어 있는 동안 해당 스레드가 interrupt 되면 작업이 중단되고 0을 반환할 수 있습니다

4.3.2 selectedKeys()

  • selectedKeys()는 Selector에서 IO 이벤트가 준비된 채널들의 SelectionKey 집합을 반환하는 메서드입니다.
  • SelectionKey 집합에서 키를 제거할 수는 있지만 추가할 수는 없습니다.
    • 추가하면 UnsupportedOperationException이 발생합니다.
  • 이벤트를 한번만 처리하기 위해서는 이벤트를 처리한 후 SelectionKey를 직접 제거해야 합니다.
    • Selector는 이벤트가 발생한 키를 자동으로 selectedKeys 집합에 추가만 하고, 제거는 하지 않습니다.
    • 따라서 다음 select() 호출 시에도 이전에 처리된 이벤트가 계속 남아있게 되므로 직접 제거해야 합니다.

4.3.3 예시

while (true) {  
int readyChannels = selector.select();
if (readyChannels == 0) continue;

Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// 연결 수락 처리...
} else if (key.isReadable()) {
// 읽기 처리...
} else if (key.isWritable()) {
// 쓰기 처리...
}
keyIterator.remove();
}
}
  • select() 메소드가 반환되면, 준비된 채널들의 집합을 처리할 수 있습니다.
  • selectedKeys() 메소드를 사용하여 선택된 채널들의 SelectionKey 집합을 얻을 수 있습니다.
  • SelectionKey는 특정 채널의 준비된 이벤트를 나타낸다.
    • isAcceptable(), isConnectable(), isReadable(), isWritable() 등의 메소드를 사용하여 해당 이벤트에 따라 적절한 처리를 할 수 있습니다.
  • 이벤트 처리 후, keyIterator.remove()를 호출하여 이벤트를 처리한 키를 집합에서 제거합니다.

5 TCP 블로킹 채널

  • 이 장에서는 간단한 TCP 서버를 통해 Java의 네트워크 프로그래밍 방식의 발전 과정을 살펴보겠습니다.
  • 우리가 만들 서버는 다음과 같은 간단한 기능을 수행합니다
    1. 클라이언트의 연결을 수락
    2. 연결된 클라이언트에게 "Hi!" 메시지 전송
    3. 메시지 전송 후 연결 종료

5.1 Old I/O (OIO) 방식

가장 전통적인 방식으로, Java의 초기부터 제공된 블로킹 I/O를 사용한 구현입니다.

public class PlainOioServer {
public void serve(int port) throws IOException {
// 서버 소켓 생성 및 포트 바인딩
final ServerSocket socket = new ServerSocket(port);
try {
for (;;) {
// 클라이언트 연결 대기 (블로킹)
final Socket clientSocket = socket.accept();
System.out.println(
"Accepted connection from " + clientSocket);
// 새 스레드 생성하여 클라이언트 처리
new Thread(new Runnable() {
@Override
public void run() {
OutputStream out;
try {
out = clientSocket.getOutputStream();
// 클라이언트에 메시지 전송
out.write("Hi!\r\n".getBytes(
Charset.forName("UTF-8")));
out.flush();
// 연결 종료
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
clientSocket.close();
} catch (IOException ex) {
// ignore on close
}
}
}
}).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

특징:

  • 구현이 직관적이고 이해하기 쉽습니다.
  • 각 클라이언트 연결마다 새로운 스레드를 생성합니다.
  • accept(), read(), write() 메소드가 모두 블로킹됩니다.
  • 다수의 클라이언트 연결시 많은 스레드가 생성되어 리소스 낭비가 발생할 수 있습니다.

5.2 NIO 블로킹 방식

Java NIO를 사용하지만, 여전히 블로킹 방식으로 동작하는 구현입니다.

public class PlainNioBlockingServer {
public void serve(int port) throws IOException {
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(true); // 블로킹 모드
ServerSocket serverSocket = serverChannel.socket();
serverSocket.bind(new InetSocketAddress(port));

try {
while (true) {
// 클라이언트 연결 대기 (블로킹)
final SocketChannel clientChannel = serverChannel.accept();
System.out.println("Accepted connection from " + clientChannel);

// 새 스레드를 생성하여 클라이언트 처리
new Thread(new Runnable() {
@Override
public void run() {
try {
// 메시지 전송을 위한 버퍼 준비
ByteBuffer buffer = ByteBuffer.wrap("Hi!\r\n".getBytes());

// 버퍼의 모든 데이터를 클라이언트에 전송
while (buffer.hasRemaining()) {
clientChannel.write(buffer);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
// 연결 종료
clientChannel.close();
} catch (IOException ex) {
// ignore on close
}
}
}
}).start();
}
} finally {
serverChannel.close();
}
}
}

특징:

  • NIO의 Channel과 Buffer를 사용합니다.
  • OIO와 마찬가지로 각 클라이언트마다 새로운 스레드를 생성합니다.
  • 버퍼를 사용하여 데이터 처리가 더 효율적입니다.
  • 여전히 블로킹 방식으로 동작하여 동시성 처리에 한계가 있습니다.

6 TCP 논블로킹 채널

6.1 NIO 논블로킹 방식

Java NIO의 논블로킹 특성과 Selector를 활용한 가장 발전된 형태의 구현입니다.

public class PlainNioServer {
public void serve(int port) throws IOException {
// ServerSocketChannel 열기
ServerSocketChannel serverChannel = ServerSocketChannel.open();

// 비동기 모드로 설정
serverChannel.configureBlocking(false);

// 로컬 포트에 바인딩
ServerSocket ssocket = serverChannel.socket();
InetSocketAddress address = new InetSocketAddress(port);
ssocket.bind(address);

// Selector 생성 및 채널 등록
Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

final ByteBuffer msg = ByteBuffer.wrap("Hi!\r\n".getBytes());

for (;;) {
try {
// 블로킹되어 새로운 이벤트를 기다림
selector.select();
} catch (IOException ex) {
ex.printStackTrace();
break;
}

// 준비된 이벤트들을 얻어옴
Set<SelectionKey> readyKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = readyKeys.iterator();

while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();

try {
// 새로운 연결 수락
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel)key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_WRITE | SelectionKey.OP_READ, msg.duplicate());
System.out.println("Accepted connection from " + client);
}
// 클라이언트에 데이터 쓰기
if (key.isWritable()) {
SocketChannel client = (SocketChannel)key.channel();
ByteBuf buffer = (ByteBuf)key.attachment();
while (buffer.hasRemaining()) {
if (client.write(buffer) == 0) {
break;
}
}
client.close();
}
} catch (IOException ex) {
key.cancel();
try {
key.channel().close();
} catch (IOException cex) {
}
}
}
}
}
}

특징과 장점:

  1. 단일 스레드로 다중 연결 처리
    • Selector를 사용하여 여러 채널의 이벤트를 효율적으로 관리
    • 스레드 생성 비용 절감
  2. 리소스 효율성
    • 논블로킹 방식으로 동작하여 스레드 대기 시간 최소화
    • 버퍼를 사용한 효율적인 메모리 관리
  3. 확장성
    • 다수의 클라이언트 연결을 효율적으로 처리
    • 시스템 리소스를 효율적으로 사용

6.2 세 가지 방식의 비교

  1. OIO (Old I/O)
    • 장점: 구현이 단순하고 이해하기 쉬움
    • 단점: 클라이언트당 스레드 생성으로 인한 리소스 낭비
  2. NIO 블로킹
    • 장점: 버퍼를 사용한 효율적인 데이터 처리
    • 단점: OIO와 마찬가지로 클라이언트당 스레드 생성 필요
  3. NIO 논블로킹
    • 장점: 단일 스레드로 다수의 클라이언트 처리 가능
    • 단점: 구현이 복잡하고 디버깅이 어려움

7 Netty 소개

  • 지금까지 Java NIO를 사용한 네트워크 프로그래밍에 대해 알아보았습니다.
  • NIO는 강력한 기능을 제공하지만, 직접 사용하기에는 몇 가지 어려움이 있습니다:
    • 복잡한 버퍼 관리
    • 까다로운 이벤트 처리 로직
    • 디버깅의 어려움
    • 동시성 처리의 복잡성
  • 이러한 문제를 해결하기 위해 등장한 것이 바로 Netty입니다.

7.1 Netty란?

  • Netty는 비동기 이벤트 기반 네트워크 애플리케이션 프레임워크입니다.
  • NIO의 복잡한 처리를 추상화하여 개발자가 비즈니스 로직에 집중할 수 있게 해줍니다.
  • 자세한 내용은 Netty Introduction 참고

7.2 앞서 본 TCP 서버를 Netty로 구현

  • 이전에 살펴본 "Hi!" 메시지를 전송하는 서버를 Netty로 구현하면 다음과 같습니다:
public class NettyNioServer {
public void server(int port) throws Exception {
final ByteBuf buf = Unpooled.copiedBuffer("Hi!\r\n",
CharsetUtil.UTF_8);
EventLoopGroup group = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(group).channel(NioServerSocketChannel.class)
.localAddress(new InetSocketAddress(port))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch)
throws Exception {
ch.pipeline().addLast(
new ChannelInboundHandlerAdapter() {
@Override
public void channelActive(
ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(buf.duplicate())
.addListener(
ChannelFutureListener.CLOSE);
}
});
}
});
ChannelFuture f = b.bind().sync();
f.channel().closeFuture().sync();
} finally {
group.shutdownGracefully().sync();
}
}
}