Chapter .19-3 NIO 기반 입출력 및 네트워킹(TCP 블로킹 채널)
19.6 TCP 블로킹 채널
NIO를 이용해서 TCP 서버/클라이언트 애플리케이션을 개발하려면 브로킹, 넌블로킹, 비동기 구현 방식 중에서 하나를 결정해야 한다.
이 결정에 따라 구현이 완전히 달라지기 때문이다.
다소 복잡해지기도 했지만 네트워크 입출력의 성능과 효율성 면에서 선택의 폭이 넓어졌기 때문에 최적의 네트워크 애플리케이션을 개발할 수 있게 되었다.
19.6.1 서버소켓 채널과 소켓 채널의 용도
NIO에서 TCP 네트워크 통신을 위해 사용하는 채널을 java.nio.channels.ServerSocketChannel과 java.nio.channels.SocketChannel이다.
이 두 채널은 IO의 ServerSocket과 Socket에 대응되는 클래스로, IO가 버퍼를 사용하지 않고 블로킹 입출력 방식만 지원한다면 serverSocketChannel, SocketChannel은 버퍼를 이용하고 블로킹과 넌블로킹 방식을 모두 지원한다.
사용방법은 IO와 큰 차이점이 없는데, 다음 그림처럼 ServerSocketChannel은 클라이언트 SocketChannel의 연결 요청을 수락하고 통신용 SocketChannel을 생성한다.
19.6.2 서버소켓 채널 생성과 연결 수락
- ServerSocketChannel 생성
서버를 개발 하려면 우선 ServerSocketChannel 객체를 얻어야 한다.
ServerSocketChannel은 정적 메소드인 open() 으로 생성하고, 블로킹 방식으로 동작시키기 위해 configueBlocking(true) 메소드를 호출한다.
기본적으로 블로킹 방식으로 동작 하지만 명시적으로 설정하는 이유는 넌브로킹과 구분하기 위해서이다.
포트에 바인딩 하기 위해서는 bind() 메소드가 호출되어야 하는데, 포트 정보를 가진 InetSocketAddress 객체를 매개값으로 주면 된다.
포트 바인딩까지 끝났다면 ServerSocketChannel은 클라이언트 연결 수락을 위해 accpet() 메소드를 실행해야 한다.
accpet() 메소드는 클라이어늩가 요청을 요청 하기 전까지 블로킹되기 때문에 UI및 이벤트를 처리하는 스레드에서 accpt() 메소드를 호출하지 않도록 한다.
클라이언트가 연결을 요청하면 accpt()는 클라이언트와 통신할 SocketChannel을 만들고 리턴한다.
SocketChannel = socketChannel = serverSocketChannel.accpt();
연결된 클라이언트의 IP와 포트 정볼르 알고 싶다면 SocketChannel의 getRemoteAddress() 메소드를 호출해서 SocketAddress를 얻으면 된다.
실제 리턴되는 것은 InetSocketAddress 인스턴스이므로 다음과 같이 타입 변환할 수 있다.
InetSocketAddress socketAddress = (InetSocketAddress) socketChannel.getREmoteAddress();
더 이상 클라이언트를 위해 연결 수락이 필요 없다면 ServerSocketChannel의 close() 메소드를 호출해서 포트를 언바인딩 시켜야 한다.
그래야 다른 프로그램에서 해당 포트를 재사용할 수 있다.
serverSocketChannel.close();
다음 예제는 반복적으로 accpet() 메소드를 호출해서 다중 클라이언트 연결을 수락하는 가장 기본적인 코드를 보여준다.
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
public class ServerExample {
public static void main(String[] args) {
ServerSocketChannel serverSocketChannel = null;
try {
serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(true);
serverSocketChannel.bind(new InetSocketAddress(5001));
while(true) {
System.out.println( "[연결 기다림]");
SocketChannel socketChannel = serverSocketChannel.accept();
InetSocketAddress isa = (InetSocketAddress) socketChannel.getRemoteAddress();
System.out.println("[연결 수락함] " + isa.getHostName());
}
} catch(Exception e) {}
if(serverSocketChannel.isOpen()) {
try {
serverSocketChannel.close();
} catch (IOException e1) {}
}
}
}
19.6.3 소켓 채널 생성과 연결 요청
클라이언트가 서버에 연결 요청을 할 떄에는 java.nio.channels.SocketChannel을 이용한다.
SocketChannel은 정적 메소드인 open()으로 생성하고, 블로킹 방식으로 동작시키기 위해 configureBlocking(true) 메소드를 호출한다.
기본적으로 블로킹 방식으로 동작하지만, 명시적으로 설정하는 이유는 넌블로킹과 구분하기 위해서이다.
서버 연결 요청은 connect() 메소들르 호출하면 된느데, 서버 IP와 포트 정보를 가진 InetSocketAddress 객체를 매개값으로 주면 된다.
connect() 메소드는 연결이 완료될 때까지 블로킹되고, 연결이 완료되면 리턴한다.
다음은 로컬 PC의 5001포트에 바인딩된 서버에 연결을 요청하는 코드이다.
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(true);
socketChannel.connect(new InetSocketAddress("localhost",5001));
connect() 메소드는 서버와 연결이 될 때까지 블로킹 되므로 UI 및 이벤트를 처리하는 스레드에서 connect()를 호출하지 않도록 한다.
블로킹 되면 UI갱신이나 이벤트 처리를 할 수 없기 때문이다.
연결된 후 클라이언트 프로그램을 종료하거나, 필요에 따라서 연결을 끊고 싶다면 다음과 같이 SocketChannel의 close() 메소드를 호출하면 된다.
SocketChannel.close();
다음 에제는 localhost 5001 포트로 연결 요청하는 코드이다.
connect() 메소드가 정상적으로 리턴되면 연결 성공한 것이다.
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;
public class ClientExample {
public static void main(String[] args) {
SocketChannel socketChannel = null;
try {
socketChannel = SocketChannel.open();
socketChannel.configureBlocking(true);
System.out.println( "[연결 요청]");
socketChannel.connect(new InetSocketAddress("localhost", 5001));
System.out.println( "[연결 성공]");
} catch(Exception e) {}
if(socketChannel.isOpen()) {
try {
socketChannel.close();
} catch (IOException e1) {}
}
}
}
19.6.4 소켓 채널 데이터 통신
클라이언트가 연결 요청(connect()) 하고 서버가 연결 수락(accept()) 했다면, 양쪽 Socket Channel 객체의 read(), write() 메소드를 호출해서 데이터 통신을 할 수 있다.
이 메소드들은 모두 버퍼를 이용하기 때문에 버퍼로 읽고, 쓰는 작업을 해야 한다.
다음은 SocketChannel의 write() 메소드를 이용해서 문자열을 보내는 코드이다.
Charset charset = Charset.forName("UTF-8");
ByteBuffer byteBuffer = charset.encode("Hello Server");
socketChannel.write(byteBuffer);
다음은 SocketChannel의 read() 메소드를 이용해서 문자열을 받는 코드이다.
ByteBuffer byteBuffer = ByteBuffer.allocate(100);
int byteCount = socketChannel.read(byteBuffer);
byteBuffer.flip();
Charset charset = Charset.forName("UTF-8");
String message = charset.decode(byteBuffer).toString();
다음 예제는 연결 성공후 클라이언트가 먼저 "Hello Server" 를 보낸다.
서버가 이 데이터를 받고 "Hello Client"를 클라이언트로 보내면 클라이언트가 이 데이터를 받는다.
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
public class ClientExample {
public static void main(String[] args) {
SocketChannel socketChannel = null;
try {
socketChannel = SocketChannel.open();
socketChannel.configureBlocking(true);
System.out.println( "[연결 요청]");
socketChannel.connect(new InetSocketAddress("localhost", 5001));
System.out.println( "[연결 성공]");
ByteBuffer byteBuffer = null;
Charset charset = Charset.forName("UTF-8");
byteBuffer = charset.encode("Hello Server");
socketChannel.write(byteBuffer);
System.out.println( "[데이터 보내기 성공]");
byteBuffer = ByteBuffer.allocate(100);
int byteCount = socketChannel.read(byteBuffer);
byteBuffer.flip();
String message = charset.decode(byteBuffer).toString();
System.out.println("[데이터 받기 성공]: " + message);
} catch(Exception e) {}
if(socketChannel.isOpen()) {
try {
socketChannel.close();
} catch (IOException e1) {}
}
}
}
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
public class ServerExample {
public static void main(String[] args) {
ServerSocketChannel serverSocketChannel = null;
try {
serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(true);
serverSocketChannel.bind(new InetSocketAddress(5001));
while(true) {
System.out.println( "[연결 기다림]");
SocketChannel socketChannel = serverSocketChannel.accept();
InetSocketAddress isa = (InetSocketAddress) socketChannel.getRemoteAddress();
System.out.println("[연결 수락함] " + isa.getHostName());
ByteBuffer byteBuffer = null;
Charset charset = Charset.forName("UTF-8");
byteBuffer = ByteBuffer.allocate(100);
int byteCount = socketChannel.read(byteBuffer);
byteBuffer.flip();
String message = charset.decode(byteBuffer).toString();
System.out.println("[데이터 받기 성공]: " + message);
byteBuffer = charset.encode("Hello Client");
socketChannel.write(byteBuffer);
System.out.println( "[데이터 보내기 성공]");
}
} catch(Exception e) {}
if(serverSocketChannel.isOpen()) {
try {
serverSocketChannel.close();
} catch (IOException e1) {}
}
}
}
데이터를 받기 위해 read() 메소드를 호출하면 상대방이 데이터를 보내기전까지는 블로킹(blocking) 되는데, read() 메소드가 블로킹 해제되고 리턴되는 경우는 다음 세 가지 이다.
19.6.5 스레드 병렬처리
TCP 블로킹 방식은 데이터 입출력이 완료되기 전까지 read()와 write() 메소드가 블롴이 된다.
만약 애플리케이션을 실행시키는 main스레드가 직접 입출력 작업을 담당하게 되면 입출력이 완료될 때까지 다른 작업을 할 수 없는 상태가 된다.
예를 들어 서버 애플리케이션은 지속적으로 클라이언트의 연결 수락 기능을 수행해야 하는데, 입출력에서 블로킹되면 이 작업을 할 수 없게 된다.
또한 클라이언트1과 입출력한느 동안에는 클라이언트2와 입출력을 할 수가 없게 된다.
그렇기 때문에 연결(채널)하나에 작업 스레드 하나를 할당해서 병렬처리 해야 한다.
위 그림과 같이 스레드 병렬처리를 할 경우 수 천개의 클라이언트가 동시에 연결됨녀 수 천개의 스레드가 서버에 생성되기 때문에 서버 성능이 급격히 저하되고, 다운되는 현상이 발생할 수 있다.
클라이언트의 폭증으로 인해 서버의 과도한 스레드 생성을 방지하려면 스레드풀을 사용하는 것이 바람직하다.
스레드풀은 스레드 수를 제한해서 사용하기 떄문에 갑작스런 클라이언트의 폭증은 작업 큐의 작업 량만 증가시킬 뿐 스레드 수에는 변함이 없기 때문에 서버 성능은 완만히 저하된다.
다만 대기하는 작업량이 증가하기 때문에 개발 클라이언트에서 응답을 늦게 받을 수 있다.
이 경우 서버의 하드웨어 사양에 맞게 적절히 스레드풀의 스레드 수를 늘려주면 된다.