이전 글에서 “Hello”를 한 번 주고받고 종료하는 가장 단순한 네트워크 프로그램을 만들었다. 이번에는 exit를 입력할 때까지 계속 메시지를 주고받는 버전으로 개선하고, 여기서 자연스럽게 드러나는 단일 스레드 서버의 구조적 한계를 분석한다
네트워크 프로그램 V2 – 지속 통신
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.Scanner;
import static util.MyLogger.log;
public class ClientV2 {
public static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("클라이언트 시작");
Socket socket = new Socket("localhost", PORT);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
log("소켓 연결: " + socket);
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("전송 문자: ");
String toSend = scanner.nextLine();
// 서버에게 문자 보내기
output.writeUTF(toSend);
log("client -> server: " + toSend);
if (toSend.equals("exit")) {
break;
}
// 서버로부터 문자 받기
String received = input.readUTF();
log("client <- server: " + received);
}
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
}
}
ServerV2
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import static util.MyLogger.log;
public class ServerV2 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
log("서버 시작");
ServerSocket serverSocket = new ServerSocket(PORT);
log("서버 소켓 시작 - 리스닝 포트: " + PORT);
Socket socket = serverSocket.accept(); // 블로킹
log("소켓 연결: " + socket);
DataInputStream input = new DataInputStream(socket.getInputStream());
DataOutputStream output = new DataOutputStream(socket.getOutputStream());
while (true) {
// 클라이언트로부터 문자 받기
String received = input.readUTF(); // 블로킹
log("client -> server: " + received);
if (received.equals("exit")) {
break;
}
// 클라이언트에게 문자 보내기
String toSend = received + " World!";
output.writeUTF(toSend);
log("client <- server: " + toSend);
}
// 자원 정리
log("연결 종료: " + socket);
input.close();
output.close();
socket.close();
serverSocket.close();
}
}
V1과의 차이는 단 하나다. 메시지를 주고받는 부분을 while(true)로 감쌌다. exit를 수신하면 양쪽 모두 루프를 빠져나와 자원을 정리하고 종료한다
실행 결과 – 클라이언트
13:59:50.460 [ main] 클라이언트 시작 13:59:50.466 [ main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=56524] 전송 문자: hello 13:59:53.882 [ main] client -> server: hello 13:59:53.882 [ main] client <- server: hello World! 전송 문자: hi 13:59:55.017 [ main] client -> server: hi 13:59:55.018 [ main] client <- server: hi World! 전송 문자: exit 13:59:56.552 [ main] client -> server: exit 13:59:56.552 [ main] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=56524]
실행 결과 – 서버
13:59:47.388 [ main] 서버 시작 13:59:47.389 [ main] 서버 소켓 시작 - 리스닝 포트: 12345 13:59:50.466 [ main] 소켓 연결: Socket[addr=/127.0.0.1,port=56524,localport=12345] 13:59:53.882 [ main] client -> server: hello 13:59:53.882 [ main] client <- server: hello World! 13:59:55.017 [ main] client -> server: hi 13:59:55.018 [ main] client <- server: hi World! 13:59:56.552 [ main] client -> server: exit 13:59:56.553 [ main] 연결 종료: Socket[addr=/127.0.0.1,port=56524,localport=12345]
ServerV2의 구조적 한계 – 다중 클라이언트 불가
V2는 잘 동작하는 것처럼 보이지만, 두 번째 클라이언트가 접속하는 순간 문제가 드러난다
ClientV2-2 실행 결과
14:12:42.420 [ main] 클라이언트 시작 14:12:42.426 [ main] 소켓 연결: Socket[addr=localhost/127.0.0.1,port=12345,localport=58298] 전송 문자: hello 14:12:46.158 [ main] client -> server: hello // 여기서 응답 없이 멈춤
소켓 연결까지는 됐지만, 메시지를 보내도 서버로부터 아무런 응답이 오지 않는다
왜 이런 문제가 발생하는가?
TCP 연결과 Socket 객체는 별개다
- 이 문제를 이해하려면 TCP 연결이 완료되는 시점과 서버의
Socket객체가 생성되는 시점을 구분해야 한다. - 클라이언트가
new Socket("localhost", 12345)를 호출하면, OS 계층에서3-way handshake가 진행된다. 이 과정은 자바 애플리케이션이 아닌OS가 처리하기 때문에, 서버 애플리케이션에서accept()를 아직 호출하지 않았더라도 TCP 연결 자체는 완료된다. 해당 정보는 OS의backlog queue에 쌓인다 - 따라서 두 번째 클라이언트도 TCP 연결은 정상적으로 완료되고, 클라이언트의
Socket객체도 정상 생성된다. 두 번째 클라이언트가 메시지를 전송하면, 메시지는 네트워크를 타고 서버 OS에 도달한다. 하지만 서버 애플리케이션에서accept()를 호출해Socket객체를 만들지 않았기 때문에 이 메시지는서버 OS의 TCP 수신 버퍼에서 대기한 채로 머물게 된다
ServerSocket만으로 TCP 연결은 완료된다. accept()는 이미 완료된 TCP 연결 정보를 기반으로 서버 측에 Socket 객체를 생성하는 역할이다. 이 Socket 객체가 있어야 비로소 스트림을 통해 메시지를 읽고 쓸 수 있다
메시지 송수신 경로
소켓의 스트림으로 데이터를 주고받는다는 것은 내부적으로 다음 경로를 거친다
[클라이언트] 애플리케이션 → OS TCP 송신 버퍼 → 네트워크 카드 → 인터넷 [서버] 인터넷 → 네트워크 카드 → OS TCP 수신 버퍼 → 애플리케이션
두 번째 클라이언트의 메시지는 서버의 TCP 수신 버퍼까지는 도달하지만, 서버 애플리케이션에서 Socket 객체를 통해 readUTF()를 호출하지 않으면 그 메시지를 꺼낼 수 없다
ServerV2 코드의 문제
ServerSocket serverSocket = new ServerSocket(PORT);
Socket socket = serverSocket.accept(); // 최초 1회만 호출
while (true) {
String received = input.readUTF(); // 첫 번째 클라이언트 전용 루프
output.writeUTF(toSend);
}
accept()는 최초 1회만 호출된다. 이후 메인 스레드는 while루프 안에서 첫 번째 클라이언트와의 통신에만 집중한다. 두 번째 클라이언트가 접속해도 accept()를 다시 호출할 기회가 없으므로, 서버 측 Socket 객체가 생성되지 않는다
핵심 – 두 개의 블로킹 작업은 별도의 스레드가 필요하다
ServerV2에는 블로킹 작업이 두 가지 존재한다
| 메서드 | 블로킹 조건 |
| serverSocket.accept() | 새로운 클라이언트가 접속할 때까지 대기 |
| input.readUTF() | 클라이언트가 메시지를 보낼 때까지 대기 |
두 작업이 하나의 스레드를 공유하면, 한쪽이 블로킹되는 순간 다른 작업은 영원히 실행될 수 없다. 여러 클라이언트를 동시에 처리하려면, 이 두 블로킹 작업을 각각 별도의 스레드에서 처리해야 한다. 구체적으로 다음과 같이 역할을 분리해야 한다
- 메인 스레드:
accept()를 반복 호출하며 새 클라이언트 접속만 담당 - 클라이언트별 스레드: 각 클라이언트와의 메시지 송수신을 전담