
HelloServlet에서 /hello로 웹페이지를 줘 라는 작업을 4번째 쓰레드가 받는 상황이다. 실제로, 자바에서도 쓰레드 풀을 사용해서 작업을 관리하는걸 확인해보자.
- 지난 포스트에서 Blocking Queue를 사용해 보았다. 자바의 Thread Pool에서 Blocking Queue를 어떻게 사용하는지 보기위해 브레이크 포인트를 걸어주었다.
- 4번째 스레드가 서블릿에 할당되었고, 스레드를 더 깊게 살펴보니 ThreadPoolWorker가 있었다.
- 해당 클래스 내부에는 blocking
queue<Runnable>로 쓰레드를 관리하는 블락킹 큐를 볼 수있다. 해당 큐를 사용해서 쓰레드를 미리 생성하고 관리하는걸 알 수 있다.- Producer(생산자): 작업을
BlockingQueue<Runnable>에 추가하는 역할- Consumer(소비자): 큐에서 작업을 가져와 실행하는 역할
기존 스레드 활용 방식은 Runnalbe을 통새 스레드 객체를 생성하고 직접 start()를 통해 작업을 실행시켜주었다.
스레드 풀을 사용하면 수행할 작업을 제출만 하면된다. 즉, 개발자가 하는게 아니라 내장 라이브러리가 해준다는 뜻이다.
Executor 인터페이스를 통해 실제 스레드의 작업을 제출하게되는데, 그럼 start() 함수는 어디에 있을까? 아래 코드의 주석처럼 브레이크 포인트를 걸고 직접 해보자
public static void main(String[] args) {
Executor executor = Executors.newSingleThreadExecutor();
Runnable thread = () -> System.out.println("스레드 작업 수행");
// exeuctor(실행자)에게 스레드가 수행할 작업을 제출
executor.execute(thread);// 이 라인에 브레이크포인트
/**
* void execute(Runnable command);
* 실제 스레드의 작업 실행은 execute() 내부에서 수행됨
* 실제 스레드를 실행하는 코드를 execute() 내부에서 확인
*
*/
}

보통 쓰레드를 사용할 때 Runnalbe을 아무렇지 않게 사용할텐데. Runnable은 void 타입이기에, 수행 결과값을 받는것이 꽤 번거롭다. 한번 체험해보자.
public class Step02RunnableLimitation { private static String result;
public static void main(String[] args) {
Runnable runnable = new Runnable() {
public void run() {
result = "Runnable 실행 결과";
}
};
Thread thread = new Thread(runnable);
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(result);
}
}
1. Runnalbe 객체를 생성한다.
2. 쓰레드가 result에 값을 할당하는 작업을 실행하도록 지시한다. `thread.start()`
3. 메인 쓰레드가 워커 쓰레드의 작업의 결과를 받아 출력하려면, 워커 쓰레드의 작업이 완료되고 종료되길 기다려야한다. thread.join();
4. join 과정에서 발생할 수 있는 예외처리를 수행해준다.
5. 결과값을 출력한다.
> reuslt라는 String 값을 받기 위해 여러가지 처리를 수행해주어야 한다. 이는 개발자 입장에서 피로하다.
<br><br><br>
## Callable
#### 위에서 Runnalbe의 수행 결과값을 받는 과정이 복잡했음을 느꼈다면, 더 쾌적한 방법을 찾게된다. Callable이 해답을 제시한다.
#### Callable은 쓰레드의 실행값을 반환해주는 쓰레드 작업 인터페이스이다. call()이라는 메서드를 사용하며, Future와 함께 결과를 비동기적으로 받는다.
~~Future는 처음 접해서, 추가 학습 필요~~
```java
public static void main(String[] args) {
try {
Callable<String> callable = () -> {
return "Callable 작업 결과";
};
System.out.println(callable.call());
} catch (Exception e) {
e.printStackTrace();
}
}
Callable을 사용하면서, return문 만으로 간단하게 결과값을 받아 볼 수 있다.
Socket이란 네트워크 통신을 위한 인터페이스이다.과거의 소켓 통신을 구현하는것은 매우 복잡한 일이었다. 하지만, 자바는 IP 주소와 포트 번호 등 몇가지 개념만을 이용해서, 네트워크 통신이 가능하게 일관된 방법을 제공한다.
이번 챕터는 소켓을 이용한 1:1 , 1:N 통신을 간단히 구현해보려고 한다.

하드웨어의 메모리는 User Space와 Kernel Space 두가지가 존재한다.
우리가 소켓을 직접 사용해서 통신 프로그램을 만들기 위해선, C/C++같은 로우레벨 언어를 사용해서, 커널 스페이스까지 관리 해야한다.
- 하지만, 자바의 Socket 네트워크 api를 사용하면, 유저스페이스에서 소켓 객체를 생성하면, 커널 스페이스의 소켓과 연결해준다. 덕분에 편리하게 클라이언트와 데이터를 송수신할수 있게 됨.

public class SimpleSimpleClient {
public static void main(String[] args) {
final String SIMPLE_SERVER_IP = "127.0.0.1";
final int SERVER_PORT = 5555;
// 서버에 연결 시도
try {
Socket socket = new Socket(SIMPLE_SERVER_IP, SERVER_PORT);
System.out.println("서버에 연결되었습니다.");
} catch (UnknownHostException e) {
System.out.println("서버를 찾을 수 없습니다: " + e.getMessage());
} catch (IOException e) {
System.out.println("서버에 연결 대기 중입니다.");
}
}
}
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(5555);
System.out.println("서버가 시작되었습니다. 클라이언트를 기다립니다.");
Socket socket = serverSocket.accept();
System.out.println("클라이언트가 연결됨");
}

간단하게 소켓 api를 사용해 1:1 통신을 해보았다. 그럼 실제 데이터를 송수신 해보는 작업을 추가해보자.
OutputStream, InputStream을 사용해 서버는 데이터를 수신해서 받은 값을 다시 클라이언트로 Reply 해주는 Echo Server를 만들것이다.
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(5555);
System.out.println("서버가 시작되었습니다. 클라이언트를 기다립니다.");
Socket socket = serverSocket.accept();
System.out.println("클라이언트가 연결됨");
OutputStream out = socket.getOutputStream();
InputStream in = socket.getInputStream();
while (true) {
try {
int request = in.read();
System.out.println(request);
out.write(request);
}
catch (IOException e) {
break;
}
}
}
public static void main(String[] args) {
final String SIMPLE_SERVER_IP = "127.0.0.1";
final int SERVER_PORT = 5555;
// 서버에 연결 시도
try {
Socket socket = new Socket(SIMPLE_SERVER_IP, SERVER_PORT);
System.out.println("서버에 연결되었습니다.");
try (PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
BufferedReader stdIn = new BufferedReader(new InputStreamReader(System.in))) {
String userInput;
while ((userInput = stdIn.readLine()) != null) {
out.pr_intln(userInput); // 서버로 메시지 전송
System.out.println("서버로부터 수신: " + in.readLine()); // 서버로부터 에코 수신
}
}
} catch (UnknownHostException e) {
System.out.println("서버를 찾을 수 없습니다: " + e.getMessage());
} catch (IOException e) {
System.out.println("서버에 연결 대기 중입니다.");
}
}
사용자가 입력한 데이터를 서버에 송신하고, 서버측에서 응답한 데이터를 다시 읽어들여 print 문으로 보여주고 있다.
방금 전 코드에서 클라이언트를 하나 더 실행시켜서 통신을 시도해도, 응답이 오지 않는다.
서버의 소켓이 accept를 호출해버리면 하나의 클라이언트하고만 연결이 가능해진다, 어떻게 하면 될까? while 루프를 돌면서 accept를 받고, client가 연결을 요청하면 쓰레드를 생성해 쓰레드에게 클라이언트와 통신을 맡긴다.
public class ChatServer {
public static void main(String[] args) {
try {
ServerSocket serverSocket = new ServerSocket(5555);
System.out.println("서버가 시작되었습니다. 클라이언트를 기다립니다.");
while (true) {
Socket socket = serverSocket.accept();
System.out.println("클라이언트가 연결됨: " + socket.getInetAddress());
new ClientHandler(socket).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
소켓의 accept만 메인 쓰레드가 하고, ClientHandler가 클라이언트와의 통신을 담당하고 있다.
class ClientHandler extends Thread {
private Socket socket;
public ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try {
InputStream in = socket.getInputStream();
InputStreamReader inputStreamReader = new InputStreamReader(in);
OutputStream out = socket.getOutputStream();
OutputStreamWriter outputStreamReader = new OutputStreamWriter(out);
while (true) {
int request = inputStreamReader.read();
if (request == -1) break; // 클라이언트가 연결 종료하면 루프 종료
System.out.println("클라이언트 " + socket.getInetAddress() + " 요청: " + request);
outputStreamReader.write(request);
}
System.out.println("클라이언트 연결 종료: " + socket.getInetAddress());
socket.close(); // 소켓 닫기
} catch (IOException e) {
System.out.println("클라이언트 통신 오류: " + e.getMessage());
}
}
}
클라이언트와의 응답을 담당하는 ClientHandler 쓰레드를 상속받게 한뒤, 에코서버의 동작을 이전과 동일하게 수행한다.
문제점
- 병목 지점 발생: 서버 소켓의 accept()가 병목 지점이 된다. 즉, 엄청나게 많은 클라이언트들이 접속 요청을 하면 accept()의 블로킹 시간 동안 수행 시간이 길어진다.
- 메모리 용량 초과: 현재 클라이언트 수만큼 쓰레드를 할당하는데 쓰레드 하나당, 1Mb 정도의 스택 메모리를 할당한다고 한다. 무제한으로 쓰레드의 수를 늘린다면 메모리초과 OOM (Out Of Memory) 에러가 발생할 수 있다. 또, 컨텍스트 스위칭 비용도 높아져 오히려 성능저하가 발생할 수 있다.
위의 문제점을 극복하기 위해 쓰레드풀 모델을 도입할거다.
그럼 단점은 없을까?
결론, 스레드 풀 내에 적절한 개수의 스레드를 생성하고 관리하는것이 필요하다. 어떻게??
실습을 진행하기 위해 클라이언트는 서버로 10개의 HTTP통신 요청을 보내고 sleep 5초 하는것을 default로 생각하고 진행하겠다.

- 예상 결과:
Idle Thread가 2개이고, Thtreads.max가 5이므로 2개의 작업이 먼저 시작되고, 3개의 쓰레드를 생성해야 하므로 쓰레드 생성 시간만큼의 간격으로 3개의 작업이 실행될것이다.
실제 결과
- 흰 줄을 보면 같은 시간에 5개의 요청을 연결하고, 5개의 작업을 쓰레드가 시작한다.
- 파란색 줄을 보면 흰줄에서 5초 후에, 같은 시간에 5개의 작업을 5초후에 완료했고, 완료된 쓰레드가 곧 바로 작업을 시작하는것을 볼 수 있다.
- 마지막으로, 핑크색줄에선 파란줄에서 요청한 작업이 5초후에 5개가 완료되는것을 볼 수 있다.
쓰레드를 몇개정도 생성하는 시간은 개발자가 체감하지 못할정도로 짧아, 실제론 동시에 일어난다고 봐도 무방하다. 5개 단위로 작업이 실행된다.

- 예상 결과:
Max-Connection이 10개로 요청 10개를 모두 연결할 수 있고, 쓰레드의 최대개수가 10개이므로 요청 10개가 동시에 시작하고, 5초후 10개의 작업이 동시에 실행이 완료될 것이다.
- 실제 결과:
예상대로 10개의 작업이 동시에 22초에 시작되고 동시에 27초에 종료되는 것을 알 수 있다.

- 예상 결과:
Max-Connections는 충분하므로, 10개의 연결은 모두 연결이 되지만, Thread가 2개뿐이므로 2개가 작업을 시작하고, 종료되면 다음 작업이 실행된다. 즉, 5초 단위로 2개의 작업이 완료되고 시작할 것 같다.
- 실제 결과:
예상 결과와 동일하게 결과가 나왔다. 24초에 작업을 요청하고 29초에 작업이 종료되고 이후 34초에 또 작업하고 5초주기로 쓰레드의 작업이 반복된다.

예상 결과:
다른 블로그를 참고해서 진행했다. Max-Connection이 5이니까 5개의 요청이 먼저 연결되고, 이후 스레드의 작업이 종료되고 소켓이 연결을 종료하면 Accept-count에 대기중인 5개의 요청이 차례로 연결된다. 소켓은 하지만 Fin을 보내고 Timeout 만큼 대기한 뒤 자원을 반납하기 때문에, 1분+5초 즉, Timeout + 스레드의 작업 시간 만큼의 간격을 두고 작업이 진행된다.
- 실제 결과: timeout이 1분이 지나도 발생하지 않아서 나머지 5개의 요청이 연결되지 않았다.
- server.xml의 connetionTimeOut이 커넥션 타임아웃인줄 알고 1000으로 변경해주었는데 똑같이 동작했다.
- 검색해보니, 이 값은 클라이언트가 요청을 보낸 후, 서버의 응답을 기다리는 시간이다.
클라이언트가 요청을 1000ms 만큼 보내지 않으면 자동으로 종료 한다는 의미- mac OS 때문인지 모르겠지만, TIME_OUT이 발생하지 않고 클라이언트 연결이 즉시 종료되어버린다. Mac에서 No-delay 옵션을 true로 해두어서 그렇다곤 하는데 해결은 하지 못했다.