wu-ftpd와 같은 오래된 방식의 유닉스 서버의 경우 다수의 클라이언트에게 동시에 서비스를 제공하기 위해 각각의 연결마다 새로운 프로세스를 생성한다.
자바 프로그램은 클라이언트와 통신하기 위한 스레드를 생성할 수 있으며, 서버는 스레드를 이용하여 곧 들어올 다음 연결을 처리할 준비를 할 수 있다. 스레드는 독립적인 자식 프로세스를 생성하는 것보다 서버에 훨씬 적은 부하를 준다.
사실 일반적인 유닉스 FTP 서버가 속도의 저하 없이 동시에 400 연결 이상을 처리할 수 없는 가장 큰 이유가 많은 프로세스를 생헝할 때 발생하는 부하 때문이다.
반면에 프로토콜이 단순하고 빠르며 대화가 끝날 때 서버가 연결을 종료하는 것이 허용된다면, 클라이언트의 요청마다 스레드를 생성하지 않고 즉시 처리하는 것이 서버에게는 더욱 효과적이다.
운영체제는 특정 포트를 향해 들어오는 연결 요청을 FIFO(fisrt-in, first-out) 큐에 저장한다.
자바는 기본적으로 이 큐의 길이를 50으로 설정하지만, 운영체제마다 다를 수 있다.
큐의 길이가 부족할 경우 큐의 길이를 변경할 수 는 있지만 운영체제가 지원하는 최대 길이 이상으로 큐를 증가시킬 수는 없다. 큐 길이에 관계없이 각 연결을 처리하는 데 상당한 시간이 걸리는 경우에도, 새로운 연결이 들어오는 속도보다 빠르게 큐를 비우고 싶을 것이다.
이 문제를 해결하기 위해서는 큐에 추가되는 새로운 연결을 수용하는 스레드와 분리된 별도의 스레드를 각 연결마다 할당하는 것이다.
아래의 예제는 각각의 연결을 처리하기 위해 새로운 스레드를 생성하는 daytime 서버이다. 이 버서는 하나의 느린 클라이언트가 다른 모든 클라이언트를 블로킹시키지 못하도록한다. 이것이 바로 연결마다 스레드를 할당하는 설계 방법이다.
package network;
import java.io.*;
import java.net.*;
import java.util.Date;
public class T53MultithreadedDaytimeServer {
public final static int PORT = 13;
public static void main(String[] args) {
try(ServerSocket server = new ServerSocket(PORT )){
while(true){
try{
Socket connection = server.accept();
Thread task = new DaytimeThread(connection);
task.start();
}catch(IOException e){}
}
}catch(IOException e){
System.err.println("스타트 서버에 연결할 수 없습니다.");
}
}
private static class DaytimeThread extends Thread{
private Socket connection;
DaytimeThread(Socket connection){
this.connection=connection;
}
public void run(){
try{
Writer out = new OutputStreamWriter(connection.getOutputStream());
Date now = new Date();
out.write(now.toString() + "\r\n");
out.flush();
}catch(IOException e){
System.err.println(e);
}finally{
try{
connection.close();
}catch(IOException e){}
}
}
}
}
위 예제는 서버 소켓의 자동 종료를 위해 try-with-resources 구문을 사용한다. 그러나 의도적으로 서버 소켓에 의해 수용된 클라이언트 소켓은 try-with-resources 구문을 사용하지 않았다. 이것은 클라이언트 소켓은 try 블록을 벗어나 분리된 스레드에서 처리되기 때문이다. 클라이언트 소켓을 try-with-resources 구문을 사용할 경우 메인 스레드는 실행의 흐름이 while 루프의 끝에 도달할 때, 클라이언트 소켓을 사용 중인 새로 생성된 스레드가 종료되기도 전에 클라이언트 소켓을 닫으려고 할 것이다.
위 예제는 동시 다발적인 연결 요청이 무수히 들어올 경우 (디도스 공격 등..) 무한정 스레드를 생성하게 되어, 결국 JVM이 메모리 부족으로 비정상 종료된다.
더 나은 접근 방법은 잠재적인 리소스 사용량을 제한하기 위해 고정된 스레드 풀을 사용하는 것이다.
스레드는 50개 정도면 충분할 것이며, 아래 예제는 다발적인 연결 시도에도 결코 장애가 발생하지 않는다. 다만 연결을 거부하기 시작한다.
package network;
import java.io.*;
import java.net.*;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class T54PooledDaytimeServer {
public final static int PORT = 13;
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(50);
try(ServerSocket server = new ServerSocket(PORT )){
while(true){
try{
Socket connection = server.accept();
Callable<Void> task = new DaytimeTask(connection);
pool.submit(task);
}catch(IOException e){}
}
}catch(IOException e){
System.err.println("스타트 서버에 연결할 수 없습니다.");
}
}
private static class DaytimeTask implements Callable<Void> {
private Socket connection;
DaytimeTask(Socket connection) {
this.connection= connection;
}
public Void call(){
try{
Writer out = new OutputStreamWriter(connection.getOutputStream());
Date now = new Date();
out.write(now.toString() + "\r\n");
out.flush();
}catch(IOException e){
System.err.println(e);
}finally{
try{
connection.close();
}catch(IOException e){}
}
return null;
}
}
}
두 예제의 유일한 차이점은 Thread 서브클래스 대신 Callable을 사용했다는 것이다. 그리고 스레드를 직접 생성하지 않고, Callable 객체를 50개의 스레드로 미리 설정된 ExecutorService에 제출한다.