TCP 첫 연결과 다중 클라이언트 서버
ServerSocket과 Socket으로 메시지를 주고받는 에코 서버를 직접 실행해 보고, IP·포트·DNS와 TCP 바이트 스트림의 성질을 익힌 뒤 여러 클라이언트를 동시에 받는 서버로 넓혀 갑니다.
앞 장에서는 프로그램이 만든 바이트를 파일에 저장하고 다시 읽었습니다. 이번 장에서는 그 바이트를 파일 대신 네트워크 너머의 다른 프로그램에 보냅니다. 상대 프로그램이 같은 컴퓨터에 있든 멀리 떨어진 서버에 있든, 자바 코드에서는 둘 다 Socket 객체에서 얻은 입력 스트림과 출력 스트림으로 보입니다. 그래서 문자 인코딩과 스트림 장에서 익힌 스트림 사용법이 거의 그대로 통합니다.
네트워크로 대화하는 두 프로그램 가운데 먼저 연결을 요청하는 쪽을 클라이언트(client), 요청을 기다렸다가 받아 주는 쪽을 서버(server)라고 부릅니다. 이 이름은 컴퓨터에 붙은 고정된 신분이 아니라 한 번의 연결에서 맡은 역할입니다. 예를 들어 웹 서버는 브라우저에게는 서버이지만, 데이터베이스에 질의할 때는 데이터베이스의 클라이언트가 됩니다. 그러니 어느 쪽이 서버인지 헷갈리면 그 연결을 누가 먼저 요청했는지를 보면 됩니다.
다른 프로그램에 요청을 보내는 일은 메서드 호출과 비슷해 보이지만 중요한 차이가 있습니다. 같은 JVM 안의 메서드 호출은 반환값을 돌려주거나 예외를 던지므로, 호출이 끝나면 호출한 쪽은 둘 중 무슨 일이 일어났는지 분명히 압니다. 네트워크에서는 요청이 가는 도중에 사라질 수도 있고, 응답이 늦게 올 수도 있고, 연결이 중간에 끊길 수도 있습니다. 게다가 응답이 오지 않을 때 상대가 아직 처리 중인지, 응답이 사라졌는지, 연결이 끊겼는지를 한쪽에서만 보고는 확정하기 어렵습니다. 그래서 네트워크 프로그램은 메시지를 어디서 끊어 읽을지, 얼마나 기다릴지, 같은 요청이 두 번 오면 어떻게 할지를 따로 정해야 합니다.
이번 장은 이 문제들을 하나씩 다룹니다. 이 글에서는 가장 단순한 성공 사례인 에코 서버를 실행해 보고, 그 과정에 나온 IP 주소·포트·TCP의 성질을 정리한 다음, 클라이언트 여러 개를 동시에 받는 서버로 넓혀 갑니다.
메시지 하나를 주고받는 에코 서버
처음 만들 프로그램은 에코 서버(echo server)입니다. 클라이언트가 보낸 메시지를 서버가 그대로 돌려보내는 것이 전부라서, 연결이 제대로 되는지 확인하기에 알맞습니다. 자바에서 서버 쪽은 ServerSocket으로 연결을 기다리고, 클라이언트 쪽은 Socket에 서버의 주소와 포트 번호를 지정해 연결합니다. 연결이 이루어지면 서버 쪽에도 그 연결을 맡는 Socket이 하나 생기고, 양쪽의 Socket이 바이트를 주고받는 통로가 됩니다.
서버 코드부터 보겠습니다. new ServerSocket(5000)은 이 컴퓨터의 5000번 포트에서 연결을 기다리겠다고 운영체제에 등록합니다. 이어서 호출하는 accept()는 클라이언트가 연결해 올 때까지 멈춰 있다가, 연결이 들어오면 그 연결 전용 Socket을 돌려줍니다. 이처럼 결과가 나올 때까지 스레드가 멈춰서 기다리는 동작을 블로킹(blocking)이라고 합니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public final class EchoServer {
public static void main(String[] args) throws Exception {
try (ServerSocket server = new ServerSocket(5000)) {
System.out.println("서버 대기: 5000");
try (Socket socket = server.accept();
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
System.out.println("연결됨: " + socket.getRemoteSocketAddress());
String message = input.readUTF();
System.out.println("받음: " + message);
output.writeUTF(message);
output.flush();
}
}
}
}accept()가 돌려준 socket에서 getInputStream()과 getOutputStream()으로 바이트 스트림을 얻고, 문자열을 편하게 주고받으려고 버퍼 스트림으로 감싸기와 닫는 순서에서 본 DataInputStream과 DataOutputStream으로 감쌌습니다. 서버는 문자열 하나를 readUTF()로 읽어 화면에 찍고, writeUTF()로 같은 문자열을 돌려보낸 뒤 끝납니다. getRemoteSocketAddress()는 연결해 온 상대의 주소와 포트를 알려 주는데, 이 값은 실행 결과를 볼 때 다시 살펴봅니다.
클라이언트는 new Socket("localhost", 5000)으로 연결합니다. localhost는 지금 이 컴퓨터를 가리키는 이름이고, 5000은 서버가 기다리고 있는 포트입니다. 이 생성자는 연결이 완료된 뒤에야 반환하므로, 다음 줄로 넘어왔다면 이미 서버와 연결된 상태입니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;
public final class EchoClient {
public static void main(String[] args) throws Exception {
try (Socket socket = new Socket("localhost", 5000);
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
System.out.println("내 주소: " + socket.getLocalSocketAddress());
output.writeUTF("hello");
output.flush();
System.out.println("응답: " + input.readUTF());
}
}
}두 파일을 컴파일한 뒤 터미널을 두 개 열어, 첫 번째 터미널에서 서버를 먼저 실행하고 두 번째 터미널에서 클라이언트를 실행합니다. 이 장의 명령은 JDK 25를 기준으로 하며, 컴파일러가 --release 25를 지원하지 않는다면 javac -version으로 사용 중인 JDK를 확인하세요.
javac --release 25 -d out src/EchoServer.java src/EchoClient.java
# 터미널 1
java -cp out EchoServer
# 터미널 2
java -cp out EchoClient서버 터미널에는 다음과 같이 출력됩니다. 첫 줄은 서버가 시작하자마자 찍히고, 나머지 두 줄은 클라이언트가 연결한 뒤에 나타납니다.
서버 대기: 5000
연결됨: /127.0.0.1:34114
받음: hello클라이언트는 연결하자마자 자기 쪽 소켓 주소를 찍고, readUTF()가 서버의 응답을 받을 때까지 기다렸다가 그 문자열을 찍습니다. 클라이언트 터미널의 출력은 다음과 같습니다.
내 주소: /127.0.0.1:34114
응답: hello서버의 "연결됨" 줄과 클라이언트의 "내 주소" 줄이 같은 값이라는 점을 눈여겨보세요. 127.0.0.1은 이 컴퓨터 자신을 가리키는 주소이고, 콜론 뒤의 34114는 클라이언트 쪽 포트 번호입니다. 클라이언트 코드는 자기 포트를 정한 적이 없으므로 이 번호는 운영체제가 연결할 때 골라 준 것이고, 실행할 때마다 달라집니다. 나머지 줄은 클라이언트가 보낸 hello가 서버에 도착했고(받음), 서버가 돌려보낸 문자열이 다시 클라이언트에 도착했다(응답)는 뜻입니다. 서버는 메시지 하나를 처리하면 끝나도록 만들었으므로, 클라이언트를 다시 실행하려면 서버도 다시 실행해야 합니다.
실행 순서와 읽기·쓰기 순서
서버를 먼저 실행해야 하는 이유는 클라이언트가 연결할 대상이 있어야 하기 때문입니다. 서버 없이 클라이언트만 실행하면 그 포트에서 기다리는 프로그램이 없으므로 운영체제가 연결을 거절하고, 클라이언트는 다음 예외로 끝납니다.
Exception in thread "main" java.net.ConnectException: Connection refused반대로 서버를 두 개 띄우거나 다른 프로그램이 이미 5000번 포트를 쓰고 있으면, 서버 쪽에서 java.net.BindException: Address already in use가 납니다. 같은 포트에서 연결을 기다리는 프로그램은 보통 하나만 둘 수 있기 때문입니다. 예를 들어 macOS에서는 AirPlay 수신 기능이 5000번 포트를 쓰고 있을 수 있습니다. 이럴 때는 두 파일의 5000을 다른 번호로 똑같이 바꾸면 됩니다.
읽기와 쓰기의 순서도 양쪽이 맞아야 합니다. 이 예제에서 클라이언트는 먼저 쓰고 나서 읽고, 서버는 먼저 읽고 나서 씁니다. 만약 양쪽 모두 readUTF()부터 호출하면, 둘 다 상대가 먼저 보내 주기를 기다리며 영원히 멈춥니다. 컴파일 오류도 예외도 없이 그냥 멈추기 때문에 원인을 찾기도 어렵습니다. 그래서 네트워크 프로그램을 만들 때는 누가 언제 무엇을 보내는지를 먼저 정해 두는데, 양쪽이 지키기로 한 이런 통신 규칙을 프로토콜(protocol)이라고 합니다.
flush() 호출도 짚어 두겠습니다. DataOutputStream에는 바이트를 모아 두는 버퍼가 없습니다. 그래서 지금 구조에서는 writeUTF()가 곧바로 소켓에 바이트를 넘기고, flush()를 호출하지 않아도 전송됩니다. 하지만 성능을 위해 중간에 BufferedOutputStream을 끼우면 바이트가 버퍼에 머물러, 상대가 응답을 끝내 받지 못하고 기다리게 될 수 있습니다. 메시지 하나를 다 썼으면 flush()를 호출하는 습관을 들여 두면 이런 문제를 미리 막을 수 있습니다.
IP 주소와 포트 번호
앞의 출력에 나온 127.0.0.1:34114처럼, 네트워크에서 통신 상대는 IP 주소와 포트 번호의 쌍으로 가리킵니다. 두 값이 각각 무엇을 구분하는지, 그리고 그 위에서 TCP가 무엇을 해 주는지 차례로 보겠습니다.
IP 주소(IP address)는 네트워크에 연결된 컴퓨터, 정확히는 그 컴퓨터의 네트워크 인터페이스를 가리킵니다. 데이터는 패킷(packet)이라는 작은 단위로 나뉘어 전송되는데, 패킷마다 출발지와 목적지 IP 주소가 적혀 있고 중간의 라우터들이 이 주소를 보고 목적지 쪽으로 넘겨 줍니다. 다만 IP는 최선을 다해 전달할 뿐(best effort)이라서, 패킷이 반드시 도착한다거나 보낸 순서대로 도착한다거나 한 번만 도착한다고 약속하지 않습니다.
IP 위에서 동작하는 TCP(Transmission Control Protocol)가 이 빈틈을 메웁니다. TCP에서는 데이터를 보내기 전에 양쪽이 먼저 연결(connection)을 맺습니다. 그런 다음 TCP가 사라진 패킷은 다시 보내고 순서가 뒤바뀐 패킷은 바로잡아, 보낸 바이트를 보낸 순서대로 상대에게 전달합니다. 받는 쪽이 감당할 수 있는 속도로 보내도록 조절하는 흐름 제어(flow control)도 TCP가 맡습니다. 반면 UDP(User Datagram Protocol)는 연결 없이 데이터그램(datagram)이라는 독립된 덩어리를 보내기만 하므로, 손실이나 순서가 중요하면 애플리케이션이 직접 처리해야 합니다. 자바의 Socket과 ServerSocket은 TCP를, DatagramSocket은 UDP를 사용하며, 이번 장은 TCP만 다룹니다.
포트 번호(port number)는 한 컴퓨터 안에서 통신할 프로그램을 구분합니다. 한 컴퓨터에서 웹 서버, 데이터베이스, 방금 만든 에코 서버가 동시에 돌아갈 수 있으므로 IP 주소만으로는 어느 프로그램에 보낼지 정할 수 없기 때문입니다. 포트 번호는 0부터 65535까지의 정수이고, 서버는 클라이언트가 미리 알 수 있도록 정해 둔 포트에서 기다립니다. 에코 서버의 5000이 그런 번호입니다. 클라이언트는 보통 포트를 직접 정하지 않고, 연결할 때 운영체제가 비어 있는 번호 하나를 임시 포트(ephemeral port)로 골라 줍니다. 앞의 34114가 바로 그 임시 포트입니다.
TCP 연결 하나는 클라이언트 IP, 클라이언트 포트, 서버 IP, 서버 포트의 네 값으로 구분됩니다. 그래서 서버 포트는 5000 하나뿐이어도, 클라이언트 쪽 값이 다른 연결 여러 개를 동시에 유지할 수 있습니다. 같은 컴퓨터에서 클라이언트 두 개가 연결하면 두 연결은 클라이언트 포트만 다르고 나머지 세 값은 같습니다. 뒤에서 만들 서버가 포트 하나로 여러 클라이언트를 받을 수 있는 것도 이 때문입니다.
호스트 이름과 DNS
사람은 example.com 같은 이름을 기억하지만, 연결하려면 IP 주소가 필요합니다. 이름을 IP 주소로 바꿔 주는 체계가 DNS(Domain Name System)입니다. 이름을 조회하면 운영체제는 hosts 파일과 로컬 캐시를 먼저 확인하고, 거기에 없으면 네트워크의 DNS 서버에 묻습니다. 받은 답은 정해진 유효 시간(TTL, time to live) 동안 캐시에 보관됩니다. 이름 하나가 IPv4와 IPv6 주소를 함께 돌려주거나 여러 서버의 주소를 돌려주는 일도 흔하므로, 조회 결과의 첫 주소가 언제나 같은 컴퓨터라고 가정하면 안 됩니다.
자바에서는 InetAddress가 이름 조회를 맡습니다. 다음 코드는 루프백 주소가 무엇인지, 그리고 localhost라는 이름이 어떤 주소들로 바뀌는지 출력합니다.
import java.net.InetAddress;
import java.util.Arrays;
public final class LocalAddressInspector {
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
InetAddress[] localhost = InetAddress.getAllByName("localhost");
System.out.println("loopback=" + loopback.getHostAddress());
System.out.println("isLoopback=" + loopback.isLoopbackAddress());
System.out.println("localhost=" + Arrays.stream(localhost)
.map(InetAddress::getHostAddress).toList());
}
}앞의 두 줄은 이름을 조회하지 않고 얻은 루프백 주소에 관한 것이고, 마지막 줄은 localhost라는 이름을 실제로 조회한 결과입니다. 이 글을 쓰며 실행한 Linux 환경의 결과는 다음과 같습니다.
loopback=127.0.0.1
isLoopback=true
localhost=[127.0.0.1]루프백 주소(loopback address)로 보낸 패킷은 네트워크 카드 밖으로 나가지 않고 같은 컴퓨터 안으로 되돌아옵니다. IPv4에서는 127.0.0.1, IPv6에서는 ::1이 루프백 주소이고, isLoopbackAddress()가 true를 돌려준 것도 그래서입니다. getAllByName("localhost")는 이름에 대응하는 주소를 모두 배열로 돌려줍니다. hosts 파일에 IPv6 항목도 있는 컴퓨터에서는 마지막 줄이 [127.0.0.1, 0:0:0:0:0:0:0:1]처럼 두 개로 나올 수 있습니다.
이 장의 예제들이 루프백 주소를 쓰는 이유도 여기에 있습니다. 외부 DNS나 방화벽 설정에 영향을 받지 않으므로 어느 컴퓨터에서나 같은 결과를 얻을 수 있습니다. 한편 서버가 어느 주소에 자리를 잡느냐에 따라 누가 접속할 수 있는지가 달라진다는 점도 알아 두어야 합니다. 서버가 특정 주소와 포트를 차지하는 것을 바인드(bind)라고 하는데, 앞의 new ServerSocket(5000)처럼 주소를 지정하지 않으면 이 컴퓨터의 모든 주소에 바인드합니다. 그러면 방화벽이 막지 않는 한 다른 컴퓨터에서도 접속할 수 있습니다. 뒤의 예제처럼 new ServerSocket(0, 50, InetAddress.getLoopbackAddress())로 루프백 주소를 지정하면 같은 컴퓨터 안의 프로그램만 접속할 수 있습니다.
TCP는 메시지가 아니라 바이트를 전달한다
TCP 연결은 3-way handshake라고 부르는 세 번의 신호 교환(SYN, SYN-ACK, ACK)으로 시작됩니다. 이 과정이 끝나면 양쪽은 각자 상대에게 바이트를 보낼 수 있는 양방향 스트림을 얻습니다. 여기서 중요한 점은 TCP가 전달하는 것이 메시지가 아니라 구분 없이 이어진 바이트의 흐름이라는 것입니다. 보내는 쪽의 write 한 번이 받는 쪽의 read 한 번과 짝을 이룬다는 보장이 없습니다.
다음 예제로 확인해 보겠습니다. 클라이언트는 write를 두 번 호출해 hello와 world를 따로 보내고, 서버는 잠시 기다렸다가 read를 한 번만 호출합니다. 서버와 클라이언트를 한 프로그램에 넣으려고 클라이언트가 먼저 연결한 뒤 서버가 accept()로 그 연결을 꺼내는 순서를 썼는데, 이 순서가 가능한 이유는 뒤의 연결 대기열 절에서 설명합니다. new ServerSocket(0, ...)처럼 포트를 0으로 주면 운영체제가 비어 있는 포트를 골라 주고, 그 번호는 getLocalPort()로 알 수 있습니다.
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public final class StreamBoundaryDemo {
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
try (var server = new ServerSocket(0, 50, loopback);
var client = new Socket(loopback, server.getLocalPort());
var accepted = server.accept()) {
OutputStream out = client.getOutputStream();
out.write("hello".getBytes(StandardCharsets.UTF_8)); // 첫 번째 write: 5바이트
out.write("world".getBytes(StandardCharsets.UTF_8)); // 두 번째 write: 5바이트
Thread.sleep(200); // 두 덩어리가 모두 도착할 시간을 준다
InputStream in = accepted.getInputStream();
byte[] buffer = new byte[100];
int count = in.read(buffer); // read 한 번
System.out.println(count + "바이트: " + new String(buffer, 0, count, StandardCharsets.UTF_8));
}
}
}count는 read 한 번이 실제로 채운 바이트 수입니다. 그래서 출력의 숫자를 보면 write 두 번으로 보낸 바이트가 read 한 번에 얼마나 들어왔는지 알 수 있습니다.
10바이트: helloworldwrite는 두 번 했지만 read 한 번에 10바이트가 한꺼번에 들어왔고, 받는 쪽에서는 어디까지가 첫 번째 메시지였는지 알 길이 없습니다. Thread.sleep(200)은 두 덩어리가 모두 도착할 시간을 주어 이 현상을 매번 재현하려고 넣은 것입니다. 기다리지 않으면 5바이트만 먼저 읽힐 수도 있고, 반대로 큰 데이터를 한 번에 써도 받는 쪽에서는 여러 번의 read로 나뉘어 들어올 수 있습니다. 어떻게 나뉘고 합쳐질지는 운영체제와 네트워크 상황이 정하므로, 프로그램은 이 동작에 기대어서는 안 됩니다.
그래서 메시지를 주고받는 프로그램은 메시지가 어디서 끝나는지를 스스로 표시해야 합니다. 흔히 쓰는 방법은 메시지 앞에 길이를 먼저 보내는 길이 접두사(length prefix), 메시지 끝에 줄바꿈 같은 구분자(delimiter)를 붙이는 방식, 모든 메시지를 같은 크기로 맞추는 고정 길이 방식입니다. 이렇게 바이트 흐름을 메시지 단위로 끊어 읽는 규칙을 프레이밍(framing)이라고 합니다.
에코 서버가 문제없이 동작한 것은 writeUTF와 readUTF가 이미 길이 접두사 방식을 쓰기 때문입니다. writeUTF("hello")는 길이 5를 나타내는 2바이트 00 05 뒤에 h, e, l, l, o 다섯 바이트를 붙여 모두 7바이트를 보냅니다. readUTF()는 먼저 2바이트를 읽어 길이를 알아낸 다음, 정확히 그만큼의 바이트가 모일 때까지 읽습니다. 그래서 TCP가 바이트를 어떻게 나누어 전달하든 문자열 하나를 온전히 복원합니다.
다만 writeUTF는 자바 프로그램끼리 간단히 주고받기에 편한 형식이지, 다른 언어로 만든 프로그램과 통신하려고 만든 형식은 아닙니다. 문자열을 표준 UTF-8과 조금 다른 수정된 UTF-8(modified UTF-8)로 인코딩하고, 인코딩한 결과가 65535바이트를 넘으면 UTFDataFormatException을 던집니다. 다른 언어와 주고받는 프로토콜이라면 연습 문제 1처럼 길이와 표준 UTF-8 바이트를 직접 보내는 형식을 정하는 편이 낫습니다. JSON을 보낸다고 해도 사정은 같습니다. TCP는 JSON 문서가 어디서 끝나는지 모르므로, 한 줄에 문서 하나를 쓰거나 HTTP의 Content-Length 헤더처럼 길이를 앞에서 알려 주는 표시가 필요합니다.
더 깊이: 수정된 UTF-8은 무엇이 다를까
한글처럼 기본 다국어 평면(BMP)에 속하는 문자는 수정된 UTF-8과 표준 UTF-8의 바이트가 똑같습니다. 예를 들어 writeUTF("안녕")은 길이 00 06 뒤에 표준 UTF-8과 같은 ec 95 88 eb 85 95를 보냅니다. 차이는 두 군데에서만 생깁니다. 문자 U+0000은 표준 UTF-8의 00 한 바이트 대신 c0 80 두 바이트로 쓰는데, 인코딩된 결과에 0 바이트가 나오지 않게 하려는 설계입니다. 또 이모지 같은 보충 문자는 표준 UTF-8의 4바이트 형식(😀이면 f0 9f 98 80) 대신, 자바 내부 표현인 서로게이트 쌍의 두 char를 각각 3바이트로 써서 6바이트(ed a0 bd ed b8 80)가 됩니다.
이 차이 때문에 한글과 영문만으로 테스트할 때는 다른 언어의 UTF-8 디코더와 잘 맞다가, 이모지가 섞인 메시지에서 갑자기 깨지는 일이 생깁니다. 형식의 정확한 정의는 java.io.DataInput 인터페이스 문서의 "Modified UTF-8" 항목에 있습니다.
서버와 클라이언트를 한 프로그램에서 실행하기
터미널 두 개를 오가며 실행하는 방식은 처음 한 번은 좋지만, 코드를 고칠 때마다 반복하기는 번거롭습니다. 다음 예제는 서버를 별도 스레드에서 돌리고 클라이언트는 main 스레드에서 실행해, 한 번의 실행으로 왕복 전체를 확인합니다. 서버 쪽 작업은 실행자와 스레드 풀 장에서 배운 ExecutorService에 제출하고, 클라이언트가 응답을 받은 뒤 Future로 서버 작업의 결과를 회수합니다.
앞 예제와 마찬가지로 포트에는 0을 줍니다. 고정 포트는 다른 프로그램과 겹치면 BindException이 나지만, 0을 주면 운영체제가 그때 비어 있는 포트를 골라 주므로 그럴 일이 없습니다. 클라이언트가 같은 프로그램 안에 있으므로 server.getLocalPort()로 실제 번호를 알아내 바로 넘길 수 있습니다. 세 번째 인자로 루프백 주소를 지정해 같은 컴퓨터 안에서만 접속할 수 있게 했고, 두 번째 인자 10은 뒤에서 설명할 연결 대기열의 길이입니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;
public final class EchoSocketRoundTrip {
public static void main(String[] args) throws Exception {
try (var server = new ServerSocket(0, 10, InetAddress.getLoopbackAddress());
var executor = Executors.newSingleThreadExecutor()) {
var serverResult = executor.submit(() -> {
try (Socket accepted = server.accept();
var input = new DataInputStream(accepted.getInputStream());
var output = new DataOutputStream(accepted.getOutputStream())) {
String request = input.readUTF();
output.writeUTF(request + " World!");
output.flush();
return request;
}
});
try (Socket client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
var input = new DataInputStream(client.getInputStream());
var output = new DataOutputStream(client.getOutputStream())) {
output.writeUTF("Hello");
output.flush();
System.out.println(input.readUTF());
}
System.out.println("serverReceived=" + serverResult.get());
}
}
}readUTF()는 상대가 writeUTF()로 보낸 문자열 하나가 온전히 도착할 때까지 기다립니다. 그래서 두 스레드가 동시에 움직여도 클라이언트는 응답을 받기 전에 다음 줄로 넘어가지 않고, 출력 순서도 매번 같습니다.
Hello World!
serverReceived=Hello실행 흐름을 따라가 보겠습니다. 실행기 스레드는 server.accept()에서 연결을 기다리고, main 스레드는 new Socket(...)으로 연결합니다. 연결이 이루어지면 main이 Hello를 보내고, 서버 작업은 그것을 읽어 Hello World!를 돌려보낸 뒤 받은 요청 Hello를 반환합니다. 첫 줄은 클라이언트가 받은 응답이고, 둘째 줄은 serverResult.get()이 꺼낸 서버 작업의 반환값입니다.
Future로 결과를 회수하는 데는 이유가 있습니다. 서버 작업 안에서 예외가 나면 그 예외는 Future 안에 보관되고, get()을 호출할 때 ExecutionException에 감싸여 다시 던져집니다. get()을 호출하지 않으면 서버 쪽 실패가 아무 흔적 없이 사라질 수 있으므로, 테스트 코드에서는 이렇게 끝에서 결과를 확인하는 편이 안전합니다. 단, 이 예제에는 연결·읽기·get() 어디에도 시간 제한이 없어서, 서버가 응답하지 않으면 프로그램이 영원히 기다립니다. 시간 제한은 소켓 연결·읽기 시간 제한에서 다룹니다.
아래 도표는 이 예제에 등장하는 소켓 세 개의 역할을 단계별로 보여 줍니다.
리스너는 연결을 받고 두 Socket이 데이터를 주고받는다
클라이언트 연결을 listener가 accept하여 별도 세션 Socket을 만들고 요청과 응답을 주고받습니다. 두 서버 소켓의 역할을 고정해 연결과 메시지의 시간을 나눌 수 있습니다.
EchoSocketRoundTrip의 루프백 리스너, 클라이언트 Socket, accept가 반환한 Socket을 연결과 데이터 방향으로 구분합니다.
TCP 왕복 · 리스너와 연결 소켓
리스너는 연결을 받고 두 Socket이 데이터를 주고받는다
ServerSocket은 연결을 받고, 요청과 응답은 클라이언트와 수락된 Socket 사이로 오갑니다.
- ServerSocket 리스너
- 원문의 동작: 루프백의 임시 포트에서 accept자원 경계: 직접 메시지를 읽고 쓰지 않음
- 클라이언트 Socket
- 원문의 동작: Hello를 쓰고 Hello World!를 읽음자원 경계: 클라이언트의 try-with-resources가 닫음
- accept가 반환한 Socket
- 원문의 동작: Hello를 읽고 응답을 씀자원 경계: 서버 작업의 try-with-resources가 닫음
리스너를 닫는 것과 이미 수락한 Socket을 닫는 것은 별개입니다. 이 원문에는 connect·read·Future.get의 시간 제한이 없으며, 요청·응답 문자열은 코드에 지정된 값입니다.
이 예의 조건
- listener와 accept가 반환한 세션 Socket은 다른 객체이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 EchoSocketRoundTrip의 루프백 리스너, 클라이언트 Socket, accept가 반환한 Socket을 연결과 데이터 방향으로 구분합니다. TCP 왕복 · 리스너와 연결 소켓 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 ServerSocket은 연결을 받고, 요청과 응답은 클라이언트와 수락된 Socket 사이로 오갑니다. ServerSocket 리스너 원문의 동작: 루프백의 임시 포트에서 accept 자원 경계: 직접 메시지를 읽고 쓰지 않음 클라이언트 Socket 원문의 동작: Hello를 쓰고 Hello World!를 읽음 자원 경계: 클라이언트의 try-with-resources가 닫음 accept가 반환한 Socket 원문의 동작: Hello를 읽고 응답을 씀 자원 경계: 서버 작업의 try-with-resources가 닫음 리스너를 닫는 것과 이미 수락한 Socket을 닫는 것은 별개입니다. 이 원문에는 connect·read·Future.get의 시간 제한이 없으며, 요청·응답 문자열은 코드에 지정된 값입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 연결 대기 서버 listener는 accept에서 연결을 기다립니다. |
|
|
| 연결 수락 클라이언트 connect를 accept하여 별도 세션 Socket을 얻습니다. |
|
|
| 요청 읽기 클라이언트가 Hello를 보내고 서버 세션이 읽습니다. |
|
|
| 응답 읽기 서버 세션이 Hello World!를 보내고 클라이언트가 읽습니다. |
|
|
리스너는 연결을 받고 두 Socket이 데이터를 주고받는다
클라이언트 연결을 listener가 accept하여 별도 세션 Socket을 만들고 요청과 응답을 주고받습니다. 두 서버 소켓의 역할을 고정해 연결과 메시지의 시간을 나눌 수 있습니다.
EchoSocketRoundTrip의 루프백 리스너, 클라이언트 Socket, accept가 반환한 Socket을 연결과 데이터 방향으로 구분합니다.
TCP 왕복 · 리스너와 연결 소켓
리스너는 연결을 받고 두 Socket이 데이터를 주고받는다
ServerSocket은 연결을 받고, 요청과 응답은 클라이언트와 수락된 Socket 사이로 오갑니다.
- ServerSocket 리스너
- 원문의 동작: 루프백의 임시 포트에서 accept자원 경계: 직접 메시지를 읽고 쓰지 않음
- 클라이언트 Socket
- 원문의 동작: Hello를 쓰고 Hello World!를 읽음자원 경계: 클라이언트의 try-with-resources가 닫음
- accept가 반환한 Socket
- 원문의 동작: Hello를 읽고 응답을 씀자원 경계: 서버 작업의 try-with-resources가 닫음
리스너를 닫는 것과 이미 수락한 Socket을 닫는 것은 별개입니다. 이 원문에는 connect·read·Future.get의 시간 제한이 없으며, 요청·응답 문자열은 코드에 지정된 값입니다.
이 예의 조건
- listener와 accept가 반환한 세션 Socket은 다른 객체이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 EchoSocketRoundTrip의 루프백 리스너, 클라이언트 Socket, accept가 반환한 Socket을 연결과 데이터 방향으로 구분합니다. TCP 왕복 · 리스너와 연결 소켓 리스너는 연결을 받고 두 Socket이 데이터를 주고받는다 ServerSocket은 연결을 받고, 요청과 응답은 클라이언트와 수락된 Socket 사이로 오갑니다. ServerSocket 리스너 원문의 동작: 루프백의 임시 포트에서 accept 자원 경계: 직접 메시지를 읽고 쓰지 않음 클라이언트 Socket 원문의 동작: Hello를 쓰고 Hello World!를 읽음 자원 경계: 클라이언트의 try-with-resources가 닫음 accept가 반환한 Socket 원문의 동작: Hello를 읽고 응답을 씀 자원 경계: 서버 작업의 try-with-resources가 닫음 리스너를 닫는 것과 이미 수락한 Socket을 닫는 것은 별개입니다. 이 원문에는 connect·read·Future.get의 시간 제한이 없으며, 요청·응답 문자열은 코드에 지정된 값입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 연결 대기 서버 listener는 accept에서 연결을 기다립니다. |
|
|
| 연결 수락 클라이언트 connect를 accept하여 별도 세션 Socket을 얻습니다. |
|
|
| 요청 읽기 클라이언트가 Hello를 보내고 서버 세션이 읽습니다. |
|
|
| 응답 읽기 서버 세션이 Hello World!를 보내고 클라이언트가 읽습니다. |
|
|
도표에서 보듯 ServerSocket은 연결을 받아들이기만 하고 메시지를 직접 읽거나 쓰지 않습니다. 이처럼 연결을 기다리는 소켓을 리스너(listener)라고도 부릅니다. 실제 데이터는 클라이언트의 Socket과 accept()가 돌려준 Socket 사이를 오가며, 요청과 응답은 같은 연결을 반대 방향으로 지나갈 뿐입니다. 리스너와 수락된 소켓은 서로 다른 객체이므로 리스너를 닫는다고 이미 수락한 소켓이 닫히지는 않으며, 각자 따로 닫아야 합니다. 이 차이가 다음 글에서 다룰 자원 정리의 출발점입니다.
연결 대기열: accept보다 먼저 도착한 연결
앞 예제에서 실행기 스레드가 accept()에 도달하기 전에 main 스레드가 먼저 연결할 수도 있습니다. StreamBoundaryDemo는 아예 의도적으로 연결을 먼저 하고 accept()를 나중에 호출했습니다. 그런데도 두 예제 모두 문제없이 동작한 이유는, 클라이언트가 연결을 요청하면 TCP 연결 맺기를 운영체제가 알아서 끝내 두기 때문입니다. 연결 맺기가 끝난 연결은 애플리케이션이 accept()를 호출할 때까지 운영체제의 연결 대기열(backlog)에서 기다리고, accept()는 이 대기열에서 연결 하나를 꺼내 Socket으로 돌려줍니다.
이 대기열의 길이에는 한도가 있습니다. new ServerSocket(0, 10, ...)의 두 번째 인자가 요청하는 최대 대기 연결 수이고, 포트만 받는 new ServerSocket(5000)은 50을 씁니다. 다만 Javadoc에 따르면 이 값의 정확한 의미는 구현마다 다르며, 운영체제가 상한을 두거나 아예 무시할 수도 있습니다. 예를 들어 Linux에서는 커널 설정 net.core.somaxconn보다 큰 값을 줘도 그 값으로 줄어듭니다. 애플리케이션이 accept()를 너무 늦게 호출해 대기열이 가득 차면, 새 연결은 거절되거나 클라이언트 쪽에서 시간 초과로 끝날 수 있습니다.
연결을 꺼낸 뒤에도 비슷한 일이 데이터 단위로 일어납니다. 소켓에 도착한 바이트는 먼저 운영체제의 수신 버퍼에 쌓이고, 애플리케이션이 read를 호출해야 꺼내집니다. 정리하면 요청 하나는 대기열에서 accept()를 기다리는 단계, 수신 버퍼에서 read를 기다리는 단계, 자바 코드가 실제로 처리하는 단계를 거칩니다. 패킷이 컴퓨터에 도착했다는 것과 자바 코드가 요청을 처리했다는 것은 다른 사건이므로, 서버가 느리다는 신고를 받으면 어느 단계에서 기다리고 있는지부터 나누어 봐야 합니다.
스레드 하나로 처리하는 서버의 한계
이제 서버가 클라이언트 여러 개를 상대하는 경우를 생각해 보겠습니다. 가장 먼저 떠오르는 구조는 에코 서버를 반복문으로 감싸, 연결 하나를 받아 처리하고 끝나면 다음 연결을 받는 것입니다. 그런데 이 구조에서는 블로킹하는 지점이 두 군데라는 점이 문제가 됩니다. accept()는 새 연결이 올 때까지 멈추고, 연결된 소켓의 readUTF()는 그 클라이언트가 데이터를 보낼 때까지 멈춥니다. 스레드가 하나뿐이면 첫 번째 클라이언트의 readUTF()에서 기다리는 동안 accept()로 돌아갈 수 없습니다.
다음 프로그램으로 실제로 확인해 보겠습니다. serve()는 클라이언트가 exit를 보낼 때까지 받은 문자열을 돌려보내고, 끝나면 다음 연결을 받으러 가는 단일 스레드 서버입니다. main은 클라이언트 두 개를 연결한 뒤, 첫 번째 클라이언트는 아무것도 보내지 않은 채 두고 두 번째 클라이언트로만 hi를 보냅니다. 응답을 무한정 기다리지 않도록 setSoTimeout(1000)으로 읽기 대기 시간을 1초로 제한했습니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
public final class SingleThreadedBlockingServer {
// 연결 하나를 끝까지 처리해야 다음 accept로 돌아가는 서버
static void serve(ServerSocket server) {
while (!server.isClosed()) {
try (Socket socket = server.accept();
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
while (true) {
String message = input.readUTF();
if (message.equals("exit")) break;
output.writeUTF(message);
}
} catch (IOException e) {
// 클라이언트가 끊기거나 서버 소켓이 닫히면 여기로 온다
}
}
}
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
try (var server = new ServerSocket(0, 50, loopback)) {
Thread.ofPlatform().daemon().start(() -> serve(server));
try (var first = new Socket(loopback, server.getLocalPort());
var firstOut = new DataOutputStream(first.getOutputStream());
var second = new Socket(loopback, server.getLocalPort());
var secondIn = new DataInputStream(second.getInputStream());
var secondOut = new DataOutputStream(second.getOutputStream())) {
System.out.println("두 클라이언트 모두 연결됨");
secondOut.writeUTF("hi");
second.setSoTimeout(1000); // 응답을 최대 1초만 기다린다
try {
System.out.println("second 응답: " + secondIn.readUTF());
} catch (SocketTimeoutException e) {
System.out.println("second: 1초 동안 응답 없음");
}
firstOut.writeUTF("exit"); // 첫 번째 클라이언트가 세션을 끝내면
System.out.println("second 응답: " + secondIn.readUTF());
}
}
}
}두 번째 클라이언트가 1초 안에 응답을 받으면 second 응답: 줄이, 받지 못하면 1초 동안 응답 없음 줄이 찍힙니다. 실행 결과는 다음과 같습니다.
두 클라이언트 모두 연결됨
second: 1초 동안 응답 없음
second 응답: hi두 클라이언트의 연결은 모두 성공했지만, 두 번째 클라이언트는 1초를 기다려도 응답을 받지 못했습니다. 그러다 첫 번째 클라이언트가 exit를 보내자 그제야 hi가 돌아왔습니다. 무슨 일이 있었는지 단계별로 따라가 보겠습니다.
| 단계 | main 스레드(클라이언트 둘) | 서버 스레드 |
|---|---|---|
| 1 | first가 연결한다 | accept()가 first의 연결을 꺼내고, first의 readUTF()에서 멈춘다 |
| 2 | second가 연결한다. 연결은 대기열에 들어간다 | 여전히 first의 readUTF()에서 멈춰 있다 |
| 3 | second로 hi를 보낸다. 바이트는 수신 버퍼에 쌓인다 | 여전히 멈춰 있다. second의 연결은 아직 꺼내지도 않았다 |
| 4 | 1초 뒤 SocketTimeoutException이 난다 | 여전히 멈춰 있다 |
| 5 | first로 exit를 보낸다 | readUTF()가 exit를 받아 first를 닫고, 다시 accept()로 대기열의 second를 꺼낸다 |
| 6 | hi를 받는다 | 수신 버퍼에 있던 hi를 읽어 돌려보낸 뒤 다음 readUTF()에서 기다린다 |
2단계에서 second의 연결이 문제없이 성공한 이유는 앞 절의 연결 대기열 때문입니다. 운영체제가 연결 맺기를 끝내 두었으므로 클라이언트 쪽 new Socket(...)은 정상으로 반환하지만, 그 연결을 꺼내 처리할 애플리케이션 코드는 다른 클라이언트에 묶여 있습니다. 클라이언트 입장에서는 연결도 되고 전송도 되는데 응답만 오지 않는 상태라서, 실제 서비스였다면 원인을 짐작하기 어려웠을 것입니다. 첫 번째 클라이언트가 한 시간 동안 아무것도 보내지 않는다면 다른 모든 클라이언트가 한 시간을 기다려야 합니다.
예제 코드에서 몇 가지를 더 짚어 두겠습니다. setSoTimeout()은 블로킹되기 전에 설정해야 효과가 있고, 시간이 지나 SocketTimeoutException이 나더라도 소켓은 여전히 쓸 수 있습니다. 여기서는 아직 한 바이트도 도착하지 않은 상태에서 시간이 지났으므로 다시 readUTF()를 호출해도 안전합니다. 또 서버 스레드는 데몬 스레드로 만들어, 예제가 끝날 때 서버 스레드가 남아 있더라도 JVM이 종료되게 했습니다. 이 결과는 운영체제가 대기열의 연결을 도착한 순서대로 꺼낸다는 점에도 기대고 있는데, Linux를 포함한 일반적인 환경에서는 그렇게 동작합니다.
accept 전용 스레드와 연결별 세션
해결책은 연결을 받는 일과 연결을 처리하는 일을 다른 스레드로 나누는 것입니다. 연결을 받는 스레드는 accept()로 연결을 꺼내 세션 작업에 넘기고 곧바로 다음 accept()로 돌아갑니다. 연결 하나와 주고받는 대화 전체를 세션(session)이라고 하는데, 세션 작업은 자기에게 넘어온 Socket 하나만 맡아 읽기, 응답, 닫기를 모두 처리합니다. 이렇게 하면 한 세션이 readUTF()에서 오래 기다려도 accept()는 멈추지 않습니다.
다음 서버는 앞 예제와 같은 상황을 그대로 재현합니다. main의 클라이언트 쪽 코드는 앞 예제와 거의 같고, 서버 구조만 바뀌었습니다. 세션은 가상 스레드(virtual thread)에서 실행합니다. 가상 스레드는 Java 21에서 정식 기능이 된 가벼운 스레드로, 블로킹하며 기다리는 작업을 아주 많이 만들어도 부담이 작습니다. 자세한 내용은 가상 스레드와 ScopedValue에서 다루므로, 여기서는 Executors.newVirtualThreadPerTaskExecutor()가 작업 하나마다 새 가상 스레드를 만들어 실행한다는 것만 알면 충분합니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public final class MultiClientEchoServer {
// 연결 하나를 맡아 처리하는 세션 작업
static void session(Socket socket) {
try (socket;
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
while (true) {
String message = input.readUTF();
if (message.equals("exit")) break;
output.writeUTF(message);
}
} catch (IOException e) {
System.err.println("세션 오류: " + e);
}
}
// 연결을 받으면 세션 작업으로 넘기고 곧바로 다음 accept로 돌아간다
static void acceptLoop(ServerSocket server, ExecutorService sessions) {
try {
while (true) {
Socket socket = server.accept();
sessions.execute(() -> session(socket));
}
} catch (IOException e) {
if (!server.isClosed()) {
System.err.println("accept 실패: " + e);
}
}
}
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
try (var sessions = Executors.newVirtualThreadPerTaskExecutor();
var server = new ServerSocket(0, 50, loopback)) {
Thread acceptor = Thread.ofPlatform().start(() -> acceptLoop(server, sessions));
try (var first = new Socket(loopback, server.getLocalPort());
var firstOut = new DataOutputStream(first.getOutputStream());
var second = new Socket(loopback, server.getLocalPort());
var secondIn = new DataInputStream(second.getInputStream());
var secondOut = new DataOutputStream(second.getOutputStream())) {
System.out.println("두 클라이언트 모두 연결됨");
secondOut.writeUTF("hi");
second.setSoTimeout(1000);
System.out.println("second 응답: " + secondIn.readUTF());
firstOut.writeUTF("exit");
secondOut.writeUTF("exit");
}
server.close(); // accept 대기를 깨워 acceptor 스레드를 끝낸다
acceptor.join();
} // sessions.close()가 남은 세션 작업이 모두 끝날 때까지 기다린다
}
}이번 main에는 SocketTimeoutException을 잡는 catch가 없습니다. 그래서 두 번째 클라이언트가 1초 안에 응답을 받지 못하면 프로그램은 예외로 끝나고, 응답을 받으면 두 줄만 찍고 정상으로 끝납니다.
두 클라이언트 모두 연결됨
second 응답: hi이번에는 첫 번째 클라이언트가 여전히 아무것도 보내지 않은 상태인데도 두 번째 클라이언트가 곧바로 응답을 받았습니다. 첫 번째 세션은 자기 가상 스레드에서 readUTF()를 기다리고, 그동안 acceptor 스레드는 두 번째 연결을 받아 새 세션에 넘겼기 때문입니다. 1초 제한도 그대로 두었지만 이번에는 시간 초과가 나지 않습니다.
코드의 각 부분이 하는 일을 정리해 보겠습니다.
session()의try (socket; ...)는 Java 9부터 쓸 수 있는 문법으로, 이미 있는 변수를 try-with-resources의 자원으로 등록합니다. 이 줄 덕분에 세션이 넘겨받은 소켓을 끝날 때 반드시 닫습니다.acceptLoop()는accept()와execute()만 반복합니다. 돌려받을 결과가 없는 작업이라submit()대신execute()를 썼는데,submit()은 작업에서 난 예외를 아무도 꺼내지 않는Future안에 보관해 버리기 때문입니다.- 서버 소켓을 닫으면
accept()에서 기다리던 스레드에SocketException이 던져집니다.main이server.close()를 호출해acceptor를 깨우고,isClosed()로 일부러 닫은 경우인지 확인해 그때는 오류를 출력하지 않습니다. - 자원은 선언의 역순으로 닫히므로
server가 먼저,sessions가 나중에 닫힙니다.ExecutorService는 Java 19부터AutoCloseable이며,close()는 새 작업을 더 받지 않고 이미 제출된 작업이 모두 끝날 때까지 기다립니다.
이 서버도 실제 서비스에 쓰기에는 아직 빠진 것이 있습니다. 실행기가 이미 종료되어 execute()가 RejectedExecutionException을 던지면, 방금 받은 소켓은 아무도 닫지 않은 채 남습니다. 서버를 끝낼 때 이미 연결된 세션들을 찾아 함께 닫는 절차도 없습니다. 앞의 문제는 다음 글에서, 종료 절차는 활성 세션 관리와 서버 종료와 accept 루프 멈추기와 단계적 서버 종료에서 다룹니다.
세션을 어떤 스레드에서 실행할까
연결마다 플랫폼 스레드를 하나씩 만드는 방식은 오래전부터 쓰인 구조로, 코드가 위에서 아래로 읽히는 동기 방식이라 이해하기 쉽습니다. 하지만 플랫폼 스레드는 운영체제 스레드와 짝을 이루므로 연결 수만큼 스택 메모리와 스케줄링 비용이 늘어나, 연결이 수천 개를 넘으면 부담이 커집니다. 크기가 정해진 스레드 풀을 쓰면 동시에 처리하는 세션 수에 상한이 생기지만, 세션들이 readUTF()에서 오래 기다리면 풀의 스레드가 모두 묶여 새 클라이언트가 기다리게 됩니다. 연습 문제 2에서 이 상황을 직접 확인할 수 있습니다.
가상 스레드는 I/O를 기다리는 동안 대개 운영체제 스레드를 붙잡지 않으므로, 연결마다 하나씩 만들어도 비용이 작고 코드도 동기 방식 그대로 쓸 수 있습니다. 그렇다고 모든 자원이 무한해지는 것은 아닙니다. 세션들이 데이터베이스나 외부 API를 호출한다면 그쪽이 감당할 수 있는 동시 요청 수는 Semaphore 같은 도구로 따로 제한해야 하고, 동시 연결 수 자체에도 상한을 두는 것이 좋습니다.
더 깊이: 스레드를 연결마다 두지 않는 방식
연결마다 스레드를 두지 않고, 적은 수의 스레드가 많은 연결을 번갈아 돌보는 방식도 있습니다. java.nio.channels의 Selector는 여러 채널 가운데 지금 읽거나 쓸 수 있는 것만 골라 알려 주므로, 스레드 하나가 수많은 연결을 블로킹 없이 처리할 수 있습니다. Netty 같은 네트워크 프레임워크가 이런 논블로킹(non-blocking) 구조를 쓰는데, 대신 한 요청의 처리가 여러 콜백으로 쪼개져 코드가 복잡해집니다. 가상 스레드는 블로킹 코드를 그대로 쓰면서 이와 비슷한 규모를 다루려는 접근이어서, 이 교재의 예제는 블로킹 Socket과 가상 스레드를 기본으로 합니다.
예제마다 try-with-resources로 닫는 이유
이 글의 예제는 Socket과 스트림을 모두 try-with-resources로 닫았습니다. 네트워크 세션 하나는 소켓, 입력 스트림, 출력 스트림처럼 서로 엮인 자원을 여러 개 갖는데, 이것을 손으로 닫으려면 챙겨야 할 경우가 많기 때문입니다. 정상 흐름의 끝에 close()를 나열하면 readUTF()가 예외를 던지는 순간 그 줄들을 건너뜁니다. finally로 옮겨도 첫 번째 close()가 예외를 던지면 뒤의 자원은 닫히지 않고, 본문에서 난 진짜 원인이 close()의 예외에 가려질 수 있습니다.
try-with-resources는 이 일을 언어 차원에서 처리합니다. 괄호 안에 선언한 자원을 선언의 역순으로 닫고, 하나가 실패해도 나머지의 close()를 모두 시도합니다. 본문에서 이미 예외가 났다면 그 예외를 주 예외로 던지고, 닫다가 난 예외는 억제된 예외(suppressed exception)로 주 예외에 붙여 getSuppressed()로 꺼낼 수 있게 합니다. 본문은 성공했는데 close()만 실패했다면 그 close() 예외가 주 예외가 됩니다. 그러니 장애 로그를 남길 때는 주 예외와 함께 억제된 예외 목록도 기록해야 정리 과정의 실패까지 추적할 수 있습니다.
규칙 자체는 try-with-resources와 공통 예외 처리에서 다뤘고, 네트워크 코드에 적용하는 방법은 try-with-resources로 소켓 닫기에서 자세히 봅니다. 닫는 순서와 억제된 예외가 쌓이는 순서는 연습 문제 3에서 직접 확인해 보세요.
정리
- 서버는
ServerSocket으로 포트에 바인드하고accept()로 연결을 기다립니다.accept()는 연결마다 새Socket을 돌려주며, 실제 데이터는 이Socket과 클라이언트의Socket사이를 오갑니다. - IP 주소는 컴퓨터를, 포트 번호는 그 안의 프로그램을 가리킵니다. TCP 연결은 양쪽 IP와 포트 네 값으로 구분되므로 서버 포트 하나로 여러 연결을 받을 수 있습니다.
- TCP는 순서 있는 바이트 흐름을 전달할 뿐 메시지의 끝을 알려 주지 않습니다. 길이 접두사, 구분자, 고정 길이 가운데 하나로 메시지의 끝을 직접 표시해야 합니다.
accept()와read는 각각 블로킹합니다. 한 스레드가 둘 다 맡으면 한 클라이언트가 다른 모든 클라이언트를 멈춰 세울 수 있으므로,accept()전용 스레드와 연결별 세션 작업으로 나눕니다.
실제 서버를 만들 때 점검할 항목을 표로 모으면 다음과 같습니다. 대부분은 이번 장의 뒤 글에서 하나씩 다룹니다.
| 점검할 것 | 해야 할 일 | 확인하는 방법 | 다루는 글 |
|---|---|---|---|
| 주소 해석 | 이름 조회 실패와 연결 실패를 구분한다 | 루프백 주소와 존재하지 않는 이름으로 실행한다 | 연결 실패 원인 구분과 재시도 |
| 연결과 읽기 | connect와 read에 시간 제한을 둔다 | 아무도 기다리지 않는 포트, 응답하지 않는 서버로 실행한다 | 소켓 연결·읽기 시간 제한 |
| 메시지 | 길이나 구분자로 메시지의 끝을 표시한다 | 나뉘어 도착하거나 합쳐져 도착하는 입력을 넣는다 | 이 글, 연습 문제 1 |
| 동시성 | accept()와 세션을 다른 스레드에서 처리한다 | 클라이언트 여러 개를 동시에 연결한다 | 이 글 |
| 자원 | 소켓마다 닫을 쪽을 정하고 역순으로 닫는다 | 읽는 도중에 연결을 끊는다 | 다음 글, try-with-resources로 소켓 닫기 |
| 과부하 | 동시 세션 수에 상한을 둔다 | 오래 유지되는 연결을 많이 만든다 | ThreadPoolExecutor의 스레드 수와 작업 큐 |
연습 문제
연습 1: 길이 접두사로 메시지 주고받기
writeUTF 대신 4바이트 길이와 표준 UTF-8 바이트를 차례로 보내는 writeMessage와, 그 형식을 읽어 문자열로 되돌리는 readMessage를 만드세요. 소켓 대신 ByteArrayOutputStream과 ByteArrayInputStream으로 확인해도 됩니다. 소켓에서 얻는 스트림도 같은 InputStream과 OutputStream이기 때문입니다. hello와 안녕을 차례로 쓰고, 쓴 바이트 수와 다시 읽은 두 문자열을 출력하세요.
정답과 해설
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public final class LengthPrefixedFrames {
static void writeMessage(DataOutputStream out, String message) throws IOException {
byte[] body = message.getBytes(StandardCharsets.UTF_8);
out.writeInt(body.length); // 4바이트 길이
out.write(body); // 본문
}
static String readMessage(DataInputStream in) throws IOException {
int length = in.readInt();
byte[] body = new byte[length];
in.readFully(body); // length바이트를 모두 채울 때까지 읽는다
return new String(body, StandardCharsets.UTF_8);
}
public static void main(String[] args) throws IOException {
var bytes = new ByteArrayOutputStream();
try (var out = new DataOutputStream(bytes)) {
writeMessage(out, "hello");
writeMessage(out, "안녕");
}
System.out.println("보낸 바이트 수: " + bytes.size());
try (var in = new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
System.out.println(readMessage(in));
System.out.println(readMessage(in));
}
}
}실행 결과:
보낸 바이트 수: 19
hello
안녕hello는 UTF-8로 5바이트이고 안녕은 한 글자에 3바이트씩 6바이트이므로, 길이 4바이트씩을 더하면 9 + 10 = 19바이트입니다. 핵심은 readMessage에서 read(body) 대신 readFully(body)를 쓴 것입니다. 본문에서 본 것처럼 소켓의 read는 요청한 것보다 적은 바이트를 돌려줄 수 있는데, readFully는 배열이 가득 찰 때까지 반복해서 읽고 그 전에 스트림이 끝나면 EOFException을 던집니다. 실제 서버라면 readInt()로 읽은 길이가 지나치게 크지 않은지도 먼저 검사해야 합니다. 상대가 21억 같은 길이를 보내면 그만큼의 배열을 만들려다 메모리가 바닥날 수 있기 때문입니다.
연습 2: 스레드 하나짜리 풀로 바꾸면
MultiClientEchoServer의 Executors.newVirtualThreadPerTaskExecutor()를 Executors.newFixedThreadPool(1)로 바꾸면 실행 결과가 어떻게 될지 먼저 예측한 뒤 실행해 보세요.
정답과 해설
"두 클라이언트 모두 연결됨"이 출력되고 1초 뒤 main이 java.net.SocketTimeoutException: Read timed out으로 끝납니다. acceptor는 두 연결을 모두 받아 실행기에 넘기지만, 하나뿐인 작업자 스레드가 첫 번째 세션의 readUTF()에 묶여 있어 두 번째 세션 작업은 실행기의 큐에서 기다리기만 하기 때문입니다. 단일 스레드 서버의 문제가 자리만 옮겨 다시 나타난 셈입니다.
예외로 클라이언트 소켓들이 닫히면 세션들이 연결 끊김을 감지하므로, 표준 오류에 세션 오류: java.io.EOFException이 두 번 함께 출력됩니다. main의 예외는 try-with-resources가 sessions.close()로 두 세션 작업이 끝나기를 기다린 뒤에야 main 밖으로 나가므로, 이 두 줄은 언제나 예외 메시지보다 먼저 찍힙니다. 크기가 정해진 풀을 쓴다면 이처럼 오래 기다리는 세션이 풀을 독차지하지 않도록, 읽기 시간 제한이나 유휴 연결 정리를 함께 두어야 합니다.
연습 3: 닫는 순서와 억제된 예외
AutoCloseable 자원 두 개를 try-with-resources로 선언하고, 본문과 두 자원의 close()가 모두 예외를 던지게 하세요. 어떤 예외가 주 예외가 되는지, close()가 어떤 순서로 호출되는지, 억제된 예외가 배열에 어떤 순서로 쌓이는지 출력합니다.
정답과 해설
public final class SuppressedCloseOrderSolution {
static final class Resource implements AutoCloseable {
private final String name;
Resource(String name) { this.name = name; }
void use() { System.out.println("use " + name); }
@Override public void close() throws Exception {
System.out.println("close " + name);
throw new Exception("close-" + name);
}
}
public static void main(String[] args) {
try (var first = new Resource("first");
var second = new Resource("second")) {
first.use();
second.use();
throw new IllegalStateException("body");
} catch (Exception primary) {
System.out.println("primary=" + primary.getMessage());
for (Throwable suppressed : primary.getSuppressed()) {
System.out.println("suppressed=" + suppressed.getMessage());
}
}
}
}실행 결과:
use first
use second
close second
close first
primary=body
suppressed=close-second
suppressed=close-first본문이 IllegalStateException("body")를 던지면 try-with-resources는 선언의 역순으로 second부터 닫습니다. second.close()가 던진 예외는 주 예외를 바꾸지 않고 억제된 예외의 첫 칸에 붙고, 이어서 first.close()의 예외가 둘째 칸에 붙습니다. 그래서 catch가 받은 예외는 본문의 body이고, getSuppressed()는 닫은 순서대로 close-second, close-first를 돌려줍니다. 아래 도표에서 이 과정을 단계별로 볼 수 있습니다.
본문 예외를 유지한 채 자원을 역순으로 닫는다
본문 예외를 주 예외로 유지하며 두 자원을 선언 역순으로 닫아 suppressed[0], suppressed[1]을 붙입니다. 예외 목록이 쌓이는 실제 전환을 보여 줄 수 있습니다.
SuppressedCloseOrderSolution의 두 자원과 body 예외를 기준으로 close 및 suppressed 순서를 보여줍니다.
자원 정리 · 역순과 예외 보존
본문 예외를 유지한 채 자원을 역순으로 닫는다
second와 first의 close 실패를 억제 예외에 보관하고, 본문의 body 예외를 주 예외로 전달합니다.
역순 close 실패는 본문 주 예외에 붙는다
본문 body 예외를 유지한다. second.close를 먼저, first.close를 다음에 시도하고 각각 close-second와 close-first를 suppressed 0·1로 붙인다.
상태별 상세 해설
두 번째 자원 second를 먼저 close
- 발생하는 예외
- Exception("close-second")
- catch에서의 위치
- primary의 suppressed[0]
첫 번째 자원 first를 다음 close
- 발생하는 예외
- Exception("close-first")
- catch에서의 위치
- primary의 suppressed[1]
자원 정리 뒤 catch로 전달
- 발생하는 예외
- 본문의 IllegalStateException("body")
- catch에서의 위치
- primary 자체 · 정리 예외로 교체되지 않음
두 자원 생성이 성공하고 본문이 예외를 던지는 원문의 경로입니다. 자원 생성 실패나 본문 정상 완료일 때의 주 예외 선택과 구분합니다.
이 예의 조건
- 자원은 선언 역순으로 닫는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 5 | second와 first의 close 실패를 억제 예외로 보관하고, 본문의 body 예외를 주 예외로 전달합니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 본문 예외를 유지한 채 자원을 역순으로 닫는다 본문 예외를 유지한 채 자원을 역순으로 닫는다 SuppressedCloseOrderSolution의 두 자원과 body 예외를 기준으로 close 및 suppressed 순서를 보여줍니다. 자원 정리 · 역순과 예외 보존 본문 예외를 유지한 채 자원을 역순으로 닫는다 second와 first의 close 실패를 억제 예외로 보관하고, 본문의 body 예외를 주 예외로 전달합니다. 두 번째 자원 second를 먼저 close 발생하는 예외 Exception("close-second") catch에서의 위치 primary의 suppressed[0] 첫 번째 자원 first를 다음 close 발생하는 예외 Exception("close-first") catch에서의 위치 primary의 suppressed[1] 자원 정리 뒤 catch로 전달 발생하는 예외 본문의 IllegalStateException("body") catch에서의 위치 primary 자체 · 정리 예외로 교체되지 않음 두 자원 생성이 성공하고 본문이 예외를 던지는 원문의 경로입니다. 자원 생성 실패나 본문 정상 완료일 때의 주 예외 선택과 구분합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 본문 실패 작업 본문이 IllegalStateException을 던집니다. |
|
|
| 둘째 자원 닫기 second.close의 실패를 주 예외에 붙입니다. |
|
|
| 첫째 자원 닫기 first.close의 실패도 뒤에 붙이고 catch로 나갑니다. |
|
|
본문 예외를 유지한 채 자원을 역순으로 닫는다
본문 예외를 주 예외로 유지하며 두 자원을 선언 역순으로 닫아 suppressed[0], suppressed[1]을 붙입니다. 예외 목록이 쌓이는 실제 전환을 보여 줄 수 있습니다.
SuppressedCloseOrderSolution의 두 자원과 body 예외를 기준으로 close 및 suppressed 순서를 보여줍니다.
자원 정리 · 역순과 예외 보존
본문 예외를 유지한 채 자원을 역순으로 닫는다
second와 first의 close 실패를 억제 예외에 보관하고, 본문의 body 예외를 주 예외로 전달합니다.
역순 close 실패는 본문 주 예외에 붙는다
본문 body 예외를 유지한다. second.close를 먼저, first.close를 다음에 시도하고 각각 close-second와 close-first를 suppressed 0·1로 붙인다.
상태별 상세 해설
두 번째 자원 second를 먼저 close
- 발생하는 예외
- Exception("close-second")
- catch에서의 위치
- primary의 suppressed[0]
첫 번째 자원 first를 다음 close
- 발생하는 예외
- Exception("close-first")
- catch에서의 위치
- primary의 suppressed[1]
자원 정리 뒤 catch로 전달
- 발생하는 예외
- 본문의 IllegalStateException("body")
- catch에서의 위치
- primary 자체 · 정리 예외로 교체되지 않음
두 자원 생성이 성공하고 본문이 예외를 던지는 원문의 경로입니다. 자원 생성 실패나 본문 정상 완료일 때의 주 예외 선택과 구분합니다.
이 예의 조건
- 자원은 선언 역순으로 닫는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 5 | second와 first의 close 실패를 억제 예외로 보관하고, 본문의 body 예외를 주 예외로 전달합니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 본문 예외를 유지한 채 자원을 역순으로 닫는다 본문 예외를 유지한 채 자원을 역순으로 닫는다 SuppressedCloseOrderSolution의 두 자원과 body 예외를 기준으로 close 및 suppressed 순서를 보여줍니다. 자원 정리 · 역순과 예외 보존 본문 예외를 유지한 채 자원을 역순으로 닫는다 second와 first의 close 실패를 억제 예외로 보관하고, 본문의 body 예외를 주 예외로 전달합니다. 두 번째 자원 second를 먼저 close 발생하는 예외 Exception("close-second") catch에서의 위치 primary의 suppressed[0] 첫 번째 자원 first를 다음 close 발생하는 예외 Exception("close-first") catch에서의 위치 primary의 suppressed[1] 자원 정리 뒤 catch로 전달 발생하는 예외 본문의 IllegalStateException("body") catch에서의 위치 primary 자체 · 정리 예외로 교체되지 않음 두 자원 생성이 성공하고 본문이 예외를 던지는 원문의 경로입니다. 자원 생성 실패나 본문 정상 완료일 때의 주 예외 선택과 구분합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 본문 실패 작업 본문이 IllegalStateException을 던집니다. |
|
|
| 둘째 자원 닫기 second.close의 실패를 주 예외에 붙입니다. |
|
|
| 첫째 자원 닫기 first.close의 실패도 뒤에 붙이고 catch로 나갑니다. |
|
|
네트워크 장애를 기록할 때 억제된 예외를 버리지 않으면, 요청 처리가 실패한 원인과 자원을 정리하다 실패한 원인을 함께 추적할 수 있습니다.
다음 글
finally로 소켓 자원 정리하기에서는 try-with-resources 없이 finally로 소켓과 스트림을 직접 닫아 보면서, 어떤 경우에 자원이 새고 예외가 사라지는지 살펴봅니다. 이 글에서 미뤄 둔, 세션에 넘기지 못한 소켓을 누가 닫아야 하는지도 그 글에서 다룹니다.